feat(arcrun): implement arcrun MVP — open-source AI workflow engine
Phase 1-5 complete per .agents/specs/u6u-core-mvp/: **Phase 1 — Cherry-pick & cleanup** - Create arcrun/ from cypher-executor, credentials, builtins, registry - Remove 9 InkStone Service Bindings (KBDB, REGISTRY, CLINIC_*, AICEO, MINI_ME) - Rewrite component-loader: 3-layer (builtin → WASM_BUCKET R2 → error) - Remove autoPublishMissing.ts, proxy.ts (AICEO), execution-logger.ts (KBDB) - Clean all KV namespace IDs and InkStone internal URLs from config files **Phase 2 — contract.yaml completeness** - Add credentials_required to gmail, google_sheets, telegram, line_notify - Add config_example to all 21 components with annotated field descriptions **Phase 3 — Credential injection** - Add credential-injector.ts: AES-GCM decrypt from CREDENTIALS_KV - Integrate into GraphExecutor before WASM execution - Structured errors with repair instructions when credential missing **Phase 4 — CLI (acr)** - cli/package.json: arcrun package, bin: acr, deps: commander/js-yaml/chalk/ora - 8 commands: init, creds push, push, run, validate, parts, list, logs - Standard mode: writes directly to user's CF KV via CF REST API - acr init: interactive setup with arcrun.dev API Key registration **Phase 5 — Open source release prep** - README.md: 5-minute quickstart, component table, workflow YAML syntax - CONTRIBUTING.md: TinyGo dev env, component scaffolding, submission flow - Security audit: no InkStone internal URLs/IDs in committed files - .gitignore: exclude credentials.yaml, .wrangler, *.wasm https://claude.ai/code/session_01BnCdSLVH8tUed9VrrPavgT
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
dist/
|
||||
*.wasm
|
||||
credentials.yaml
|
||||
~/.arcrun/
|
||||
.env
|
||||
.env.*
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
# Contributing to arcrun
|
||||
|
||||
感謝你考慮貢獻 arcrun!本文件說明如何新增零件(WASM component)並提交至公眾零件庫。
|
||||
|
||||
---
|
||||
|
||||
## 開發環境
|
||||
|
||||
### 必要工具
|
||||
|
||||
```bash
|
||||
# TinyGo(編譯 Go → WASM)
|
||||
brew install tinygo # macOS
|
||||
# 或參考 https://tinygo.org/getting-started/
|
||||
|
||||
# wasmtime(本機測試 WASM)
|
||||
brew install wasmtime # macOS
|
||||
# 或參考 https://wasmtime.dev
|
||||
|
||||
# Wrangler CLI(部署 Cloudflare Workers)
|
||||
npm i -g wrangler
|
||||
|
||||
# arcrun CLI
|
||||
npm i -g arcrun
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 新增零件步驟
|
||||
|
||||
### 1. 建立目錄結構
|
||||
|
||||
```
|
||||
registry/components/my_component/
|
||||
├── component.contract.yaml # 零件規格宣告
|
||||
├── main.go # 零件邏輯(TinyGo)
|
||||
└── my_component.wasm # 編譯產出(不提交至 git,CI 自動產生)
|
||||
```
|
||||
|
||||
### 2. 撰寫 component.contract.yaml
|
||||
|
||||
```yaml
|
||||
canonical_id: "my_component"
|
||||
display_name: "我的零件"
|
||||
category: "data" # api / logic / data / ai
|
||||
version: "v1"
|
||||
author: "@your-github-username"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true # 功能類零件不允許網路請求
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [input]
|
||||
properties:
|
||||
input:
|
||||
type: string
|
||||
description: 輸入文字
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "基本轉換"
|
||||
given: '{"input":"hello"}'
|
||||
then_contains: '"result"'
|
||||
config_example: |
|
||||
transform: # 節點名稱(可自訂)
|
||||
input: "{{input.text}}" # 輸入欄位(必填)
|
||||
description: "我的零件功能說明。"
|
||||
```
|
||||
|
||||
**需要 Credential 的整合類零件需額外加入:**
|
||||
|
||||
```yaml
|
||||
credentials_required:
|
||||
- key: my_api_token
|
||||
type: api_key
|
||||
description: "My Service API token"
|
||||
inject_as: api_token
|
||||
```
|
||||
|
||||
### 3. 撰寫 main.go
|
||||
|
||||
WASM 零件使用 stdin/stdout JSON I/O 模型(WASI preview1):
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Input string `json:"input"`
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
Success bool `json:"success"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var input Input
|
||||
decoder := json.NewDecoder(os.Stdin)
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
out, _ := json.Marshal(Output{Success: false, Error: "invalid input: " + err.Error()})
|
||||
fmt.Println(string(out))
|
||||
return
|
||||
}
|
||||
|
||||
// 你的邏輯
|
||||
result := doTransform(input.Input)
|
||||
|
||||
out, _ := json.Marshal(Output{Success: true, Result: result})
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
|
||||
func doTransform(s string) string {
|
||||
return "[transformed] " + s
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 編譯 WASM
|
||||
|
||||
```bash
|
||||
cd registry/components/my_component
|
||||
tinygo build -o my_component.wasm -target wasi .
|
||||
```
|
||||
|
||||
### 5. 本機測試
|
||||
|
||||
```bash
|
||||
# 直接測試 WASM
|
||||
echo '{"input":"hello world"}' | wasmtime run my_component.wasm
|
||||
|
||||
# 預期輸出:{"success":true,"result":"[transformed] hello world"}
|
||||
```
|
||||
|
||||
### 6. 驗證 Gherkin 測試
|
||||
|
||||
確認 contract.yaml 的 `gherkin_tests` 所有場景都通過:
|
||||
|
||||
```bash
|
||||
# 每個 scenario:given 輸入 → 輸出包含 then_contains
|
||||
echo '<given-json>' | wasmtime run my_component.wasm | grep '<then_contains>'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 提交零件至公眾 Registry
|
||||
|
||||
```bash
|
||||
# 確保已編譯 .wasm
|
||||
tinygo build -o my_component.wasm -target wasi .
|
||||
|
||||
# 提交(需要 arcrun.dev API Key)
|
||||
acr parts publish ./registry/components/my_component/
|
||||
```
|
||||
|
||||
提交後:
|
||||
- **功能類**零件(無網路請求):體積 + syscall + Gherkin 測試通過後,立即 `author_only`(你自己可用)
|
||||
- **整合類**零件(有外部 API 呼叫):體積 + syscall 掃描通過後,`author_only`
|
||||
- 等人工審核通過 → `public`(所有人可用,開始累積執行統計)
|
||||
|
||||
查詢審核進度:
|
||||
|
||||
```bash
|
||||
acr parts publish --status <submission_id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 零件類型指引
|
||||
|
||||
### 功能類(category: logic / data / ai)
|
||||
|
||||
- 不允許網路請求(`no_network_syscall: true`)
|
||||
- 不允許檔案系統存取(`no_filesystem_syscall: true`)
|
||||
- 需通過所有 Gherkin 測試
|
||||
- 冷啟動 < 50ms,體積 < 2048KB
|
||||
|
||||
### 整合類(category: api)
|
||||
|
||||
- 允許網路請求(設 `no_network_syscall: false`)
|
||||
- 必須宣告 `credentials_required`
|
||||
- 需通過體積和 syscall 掃描
|
||||
|
||||
---
|
||||
|
||||
## 程式碼風格
|
||||
|
||||
- 零件必須回傳包含 `success: bool` 的 JSON
|
||||
- 錯誤時回傳 `{"success":false,"error":"..."}`,不 panic
|
||||
- 所有 `required` 欄位缺少時應回傳 `success:false` 而非 crash
|
||||
- contract 的 `input_schema.required[]` 必須與 main.go 的驗證邏輯一致
|
||||
|
||||
---
|
||||
|
||||
## 問題回報
|
||||
|
||||
開 Issue:[github.com/arcrun/arcrun/issues](https://github.com/arcrun/arcrun/issues)
|
||||
@@ -0,0 +1,234 @@
|
||||
# arcrun
|
||||
|
||||
**AI Workflow Execution Engine** — 以 WASM 零件為基礎的 Cloudflare Workers 工作流平台。
|
||||
|
||||
定義一個 workflow,串接 Gmail、Telegram、Google Sheets 等服務,無需伺服器,直接跑在 Cloudflare Edge。
|
||||
|
||||
---
|
||||
|
||||
## 專案定位
|
||||
|
||||
| 層級 | 內容 | 存取 |
|
||||
|------|------|------|
|
||||
| **開源核心** | cypher-executor、21 個 WASM 零件、credentials Worker、CLI(acr) | MIT License |
|
||||
| **Hosted SaaS** | 一行指令取得 API Key,使用 arcrun.dev 的執行引擎與公眾零件庫,credential 永遠在你自己的 CF KV | 免費 |
|
||||
| **InkStone 付費** | KBDB 向量搜尋、Graph 查詢、Persona SDK、MatchGPT | 付費方案 |
|
||||
|
||||
**你的 credential 和 workflow 永遠在你自己的 Cloudflare KV,arcrun.dev 不儲存它們。**
|
||||
|
||||
---
|
||||
|
||||
## 快速開始 — Standard 模式(推薦,零部署)
|
||||
|
||||
只需在 Cloudflare 建立一個 KV namespace,其餘由 arcrun.dev 處理。
|
||||
|
||||
### 安裝 CLI
|
||||
|
||||
```bash
|
||||
npm i -g arcrun
|
||||
```
|
||||
|
||||
### 1. 初始化
|
||||
|
||||
```bash
|
||||
acr init
|
||||
```
|
||||
|
||||
互動式問答:
|
||||
- Cloudflare Account ID
|
||||
- USER_KV Namespace ID(在 [CF Dashboard](https://dash.cloudflare.com) 建立一個 KV)
|
||||
- CF API Token(只需 KV Edit 權限)
|
||||
- Email(取得 arcrun.dev API Key)
|
||||
|
||||
### 2. 設定 Credential
|
||||
|
||||
建立 `credentials.yaml`(已自動加入 `.gitignore`):
|
||||
|
||||
```yaml
|
||||
# credentials.yaml — 不要提交至 git!
|
||||
gmail_token: "ya29.your-google-oauth-token"
|
||||
telegram_bot_token: "1234567890:ABCxxx"
|
||||
```
|
||||
|
||||
上傳加密 credential 至你的 CF KV:
|
||||
|
||||
```bash
|
||||
acr creds push credentials.yaml
|
||||
```
|
||||
|
||||
### 3. 部署 Workflow
|
||||
|
||||
建立 `newsletter_subscribe.yaml`:
|
||||
|
||||
```yaml
|
||||
name: newsletter_subscribe
|
||||
description: 訂閱電子報,發感謝信並記錄到 GSheets
|
||||
|
||||
flow:
|
||||
- "input >> 完成後 >> send_thanks"
|
||||
- "input >> 完成後 >> save_to_sheet"
|
||||
- "send_thanks >> 完成後 >> output"
|
||||
- "send_thanks >> 失敗時 >> notify_error"
|
||||
- "save_to_sheet >> 完成後 >> output"
|
||||
|
||||
config:
|
||||
send_thanks:
|
||||
to: "{{input.email}}"
|
||||
subject: "感謝訂閱!"
|
||||
body: "歡迎加入!"
|
||||
# access_token 由 credentials.yaml 的 gmail_token 自動注入
|
||||
|
||||
save_to_sheet:
|
||||
action: write
|
||||
spreadsheet_id: "your-sheet-id"
|
||||
range: "訂閱者!A:B"
|
||||
values: [["{{input.email}}", "{{input.timestamp}}"]]
|
||||
|
||||
notify_error:
|
||||
chat_id: "your-telegram-chat-id"
|
||||
text: "發信失敗:{{input.email}}"
|
||||
```
|
||||
|
||||
部署:
|
||||
|
||||
```bash
|
||||
acr push newsletter_subscribe.yaml
|
||||
```
|
||||
|
||||
### 4. 執行
|
||||
|
||||
```bash
|
||||
acr run newsletter_subscribe --input email=user@example.com timestamp=2026-01-01
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速開始 — Self-hosted 模式
|
||||
|
||||
自行部署所有 Worker 到你的 Cloudflare 帳號。
|
||||
|
||||
```bash
|
||||
# 1. 部署 cypher-executor
|
||||
cd cypher-executor
|
||||
wrangler deploy
|
||||
|
||||
# 2. 部署 credentials Worker
|
||||
cd ../credentials
|
||||
wrangler deploy
|
||||
|
||||
# 3. 初始化 CLI(Self-hosted 模式)
|
||||
acr init --self-hosted
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow YAML 語法
|
||||
|
||||
### 三元組格式
|
||||
|
||||
```
|
||||
"A >> 關係詞 >> B"
|
||||
```
|
||||
|
||||
### 合法關係詞
|
||||
|
||||
| 關係詞 | 英文別名 | 說明 |
|
||||
|--------|----------|------|
|
||||
| `完成後` | `ON_SUCCESS` | 上游成功後執行 |
|
||||
| `失敗時` | `ON_FAIL` | 上游失敗時執行 |
|
||||
| `對每個` | `FOREACH` | 迭代執行 |
|
||||
| `條件滿足時` | `IF` | 條件分支 |
|
||||
| `ON_CLICK` | — | 前端點擊觸發 |
|
||||
| `CALLS_SUBFLOW` | — | 呼叫子 workflow |
|
||||
|
||||
> `PIPE` 已棄用,請改用 `完成後` 或 `ON_SUCCESS`。
|
||||
|
||||
---
|
||||
|
||||
## 零件列表(21 個)
|
||||
|
||||
### 整合類(需要 Credential)
|
||||
|
||||
| 零件 | 說明 | 所需 Credential |
|
||||
|------|------|-----------------|
|
||||
| `gmail` | Gmail 發信 | `gmail_token`(Google OAuth) |
|
||||
| `google_sheets` | Google Sheets 讀寫 | `google_oauth`(Google OAuth) |
|
||||
| `telegram` | Telegram Bot 發訊息 | `telegram_bot_token` |
|
||||
| `line_notify` | LINE Notify 發訊息 | `line_token` |
|
||||
| `http_request` | HTTP 請求(手動設 headers) | — |
|
||||
|
||||
### 控制流
|
||||
|
||||
| 零件 | 說明 |
|
||||
|------|------|
|
||||
| `if_control` | 條件判斷 |
|
||||
| `foreach_control` | 迴圈執行 |
|
||||
| `try_catch` | 錯誤處理 |
|
||||
| `switch` | 多路路由 |
|
||||
| `wait` | 延遲等待 |
|
||||
|
||||
### 資料處理
|
||||
|
||||
| 零件 | 說明 |
|
||||
|------|------|
|
||||
| `set` | 設定/賦值 |
|
||||
| `filter` | 陣列過濾 |
|
||||
| `merge` | 合併物件 |
|
||||
| `string_ops` | 字串操作 |
|
||||
| `number_ops` | 數字運算 |
|
||||
| `array_ops` | 陣列操作 |
|
||||
| `date_ops` | 日期操作 |
|
||||
|
||||
### AI 類
|
||||
|
||||
| 零件 | 說明 |
|
||||
|------|------|
|
||||
| `ai_transform_compile` | AI 轉換規則編譯(Workers AI) |
|
||||
| `ai_transform_run` | AI 轉換執行 |
|
||||
|
||||
### 其他
|
||||
|
||||
| 零件 | 說明 |
|
||||
|------|------|
|
||||
| `validate_json` | JSON Schema 驗證 |
|
||||
| `cron` | Cron 排程觸發 |
|
||||
|
||||
取得任一零件的 config 範本:
|
||||
|
||||
```bash
|
||||
acr parts scaffold gmail
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI 指令
|
||||
|
||||
```
|
||||
acr init 互動式初始化設定
|
||||
acr creds push [file] 上傳加密 credentials 至 CF KV
|
||||
acr push <workflow.yaml> 部署 workflow
|
||||
acr run <name> [--input] 執行 workflow
|
||||
acr validate <workflow.yaml> 執行前驗證
|
||||
acr parts 列出所有零件(含統計)
|
||||
acr parts scaffold <comp> 取得 config 範本
|
||||
acr parts publish <dir> 提交零件至公眾庫
|
||||
acr list 列出已部署的 workflow
|
||||
acr logs <name> 查看執行記錄
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 貢獻零件
|
||||
|
||||
詳見 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
```bash
|
||||
# 提交零件至公眾 registry(審核通過後對所有人開放)
|
||||
acr parts publish ./my-component/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Generated
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@inkstone/u6u-builtins-worker",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@inkstone/u6u-builtins-worker",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250219.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workers-types": {
|
||||
"version": "4.20260329.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260329.1.tgz",
|
||||
"integrity": "sha512-LxBHrYYI/AZ6OCbUzRqRgg6Rt1qev2KxN2NNd3saye41AO2g52cYvHV+ohts5oPnrIUD7YRjbgN/J3NU7e7m5A==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.9",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
|
||||
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "arcrun-builtins",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250219.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// initComponents:把所有內建零件上架到 Component Registry(via Service Binding)
|
||||
import type { Bindings } from '../types';
|
||||
import { buildComponentDefs } from '../types';
|
||||
|
||||
async function publishOne(
|
||||
registry: Fetcher,
|
||||
def: ReturnType<typeof buildComponentDefs>[number],
|
||||
): Promise<{ id: string; status: number; ok: boolean; error?: unknown }> {
|
||||
const payload = {
|
||||
id: def.id,
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
url: def.url,
|
||||
method: def.method,
|
||||
tags: def.tags,
|
||||
input_schema: JSON.stringify(def.input_schema),
|
||||
output_schema: JSON.stringify(def.output_schema),
|
||||
author: 'u6u-builtins',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const res = await registry.fetch('http://registry/components', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const body = await res.json().catch(() => ({ error: 'parse error' }));
|
||||
// 201 = 新建成功;409 = 已存在(也算 ok)
|
||||
const isOk = res.status === 201 || res.status === 409 || res.ok;
|
||||
return { id: def.id, status: res.status, ok: isOk, ...(!isOk && { error: body }) };
|
||||
}
|
||||
|
||||
export async function initComponents(
|
||||
env: Bindings,
|
||||
): Promise<{ ok: number; failed: number; results: unknown[] }> {
|
||||
const defs = buildComponentDefs(env.WORKER_BASE_URL);
|
||||
const results = await Promise.all(defs.map(def => publishOne(env.REGISTRY, def)));
|
||||
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
const failed = results.filter(r => !r.ok).length;
|
||||
return { ok, failed, results };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// u6u-builtins Worker
|
||||
// 所有零件已遷移至 u6u-core/registry/components/ 的 TinyGo .wasm 版本
|
||||
// 此 Worker 保留 /init 端點供初始化 Component Registry 使用
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { Bindings } from './types';
|
||||
import { initComponents } from './actions/initComponents';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
app.use('*', cors());
|
||||
|
||||
app.get('/', c => c.json({
|
||||
service: 'u6u-builtins',
|
||||
version: '2.0.0',
|
||||
status: 'ok',
|
||||
note: '所有零件已遷移至 WASM,請使用 Component Registry',
|
||||
}));
|
||||
|
||||
// POST /init — 把所有零件上架到 Component Registry(冪等,可重複執行)
|
||||
app.post('/init', async c => {
|
||||
const result = await initComponents(c.env);
|
||||
const allOk = result.failed === 0;
|
||||
return c.json({ success: allOk, ...result }, allOk ? 200 : 207);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,156 @@
|
||||
// u6u-builtins Worker 型別定義
|
||||
|
||||
export type Bindings = {
|
||||
REGISTRY: Fetcher; // Component Registry Service Binding
|
||||
CYPHER: Fetcher; // Cypher Executor Service Binding(排程執行用)
|
||||
U6U_STORE: KVNamespace; // KV Store(cron: + ai-transform: 前綴)
|
||||
AI: Ai; // Workers AI(ai-transform compile 用)
|
||||
WORKER_BASE_URL: string; // 本 Worker 對外 URL(用於上架時填入 url 欄位)
|
||||
ENVIRONMENT: string;
|
||||
};
|
||||
|
||||
export type ActionResponse<T = unknown> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
// componentDefs:所有內建零件的定義清單(資料層,initComponents.ts 使用)
|
||||
export interface ComponentDef {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
method: 'POST';
|
||||
tags: string;
|
||||
input_schema: object;
|
||||
output_schema: object;
|
||||
}
|
||||
|
||||
// CronJob:排程定義(儲存在 U6U_STORE,key = cron:{id})
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
cron_expr: string; // 標準 5 欄位 cron expression
|
||||
triplets?: string[]; // 三元組格式工作流(與 graph_token 二選一)
|
||||
graph_token?: string; // 已存在的 webhook token
|
||||
description: string;
|
||||
created_at: string;
|
||||
last_run?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// AiTransform:已編譯的 AI 轉換函式(儲存在 U6U_STORE,key = ai-transform:{id})
|
||||
export interface AiTransform {
|
||||
id: string;
|
||||
description: string; // 自然語言描述
|
||||
fn_body: string; // 產生的 JS 函式 body(可直接 new Function 執行)
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function buildComponentDefs(baseUrl: string): ComponentDef[] {
|
||||
return [
|
||||
// === 既有零件 ===
|
||||
{ id: 'http-request', name: 'http-request',
|
||||
description: '發送 HTTP 請求(GET/POST/PUT/DELETE),回傳 status 和 response body。支援自訂 headers 和 body。',
|
||||
url: `${baseUrl}/http-request`, method: 'POST', tags: 'builtin,http,request,api',
|
||||
input_schema: { type: 'object', required: ['url'], properties: { url: { type: 'string' }, method: { type: 'string', enum: ['GET','POST','PUT','DELETE','PATCH'] }, headers: { type: 'object' }, body: {} } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { status: { type: 'number' }, body: {} } } } } },
|
||||
{ id: 'set', name: 'set',
|
||||
description: '設定變數(key-value),把結果傳遞到下一個節點。支援 assignments 陣列或 values 物件兩種格式。',
|
||||
url: `${baseUrl}/set`, method: 'POST', tags: 'builtin,variable,set,transform',
|
||||
input_schema: { type: 'object', properties: { assignments: { type: 'array', items: { type: 'object' } }, values: { type: 'object' }, context: { type: 'object' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object' } } } },
|
||||
{ id: 'filter', name: 'filter',
|
||||
description: '依條件過濾陣列,回傳符合條件的元素。',
|
||||
url: `${baseUrl}/filter`, method: 'POST', tags: 'builtin,filter,array,condition',
|
||||
input_schema: { type: 'object', required: ['items','condition'], properties: { items: { type: 'array' }, condition: { type: 'object' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { items: { type: 'array' }, count: { type: 'number' } } } } } },
|
||||
{ id: 'switch', name: 'switch',
|
||||
description: '依條件路由,多個出口分支。',
|
||||
url: `${baseUrl}/switch`, method: 'POST', tags: 'builtin,switch,branch,route,condition',
|
||||
input_schema: { type: 'object', required: ['value','cases'], properties: { value: {}, cases: { type: 'array' }, default_branch: { type: 'string' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { branch: { type: 'string' } } } } } },
|
||||
{ id: 'merge', name: 'merge',
|
||||
description: '合併多個輸入物件為一個。',
|
||||
url: `${baseUrl}/merge`, method: 'POST', tags: 'builtin,merge,combine,object',
|
||||
input_schema: { type: 'object', required: ['inputs'], properties: { inputs: { type: 'array', items: { type: 'object' } } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object' } } } },
|
||||
{ id: 'wait', name: 'wait',
|
||||
description: '等待指定毫秒數後繼續,最多 30 秒。',
|
||||
url: `${baseUrl}/wait`, method: 'POST', tags: 'builtin,wait,delay,throttle',
|
||||
input_schema: { type: 'object', required: ['ms'], properties: { ms: { type: 'number' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object' } } } },
|
||||
{ id: 'google-sheets', name: 'google-sheets',
|
||||
description: '讀取或寫入 Google 試算表。需要 Google OAuth access_token。',
|
||||
url: `${baseUrl}/google-sheets`, method: 'POST', tags: 'integration,google,sheets,oauth',
|
||||
input_schema: { type: 'object', required: ['spreadsheet_id','range','access_token'], properties: { spreadsheet_id: { type: 'string' }, range: { type: 'string' }, action: { type: 'string', enum: ['read','write'] }, values: { type: 'array' }, access_token: { type: 'string' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object' } } } },
|
||||
{ id: 'gmail', name: 'gmail',
|
||||
description: '透過 Gmail 發送 Email。需要 Google OAuth access_token。',
|
||||
url: `${baseUrl}/gmail`, method: 'POST', tags: 'integration,google,gmail,email,oauth',
|
||||
input_schema: { type: 'object', required: ['to','subject','body','access_token'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' }, access_token: { type: 'string' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object' } } } },
|
||||
{ id: 'line-notify', name: 'line-notify',
|
||||
description: '發送 LINE Notify 訊息。需要 LINE Channel Access Token。',
|
||||
url: `${baseUrl}/line-notify`, method: 'POST', tags: 'integration,line,notify,message',
|
||||
input_schema: { type: 'object', required: ['message','token'], properties: { message: { type: 'string' }, token: { type: 'string' }, image_url: { type: 'string' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object' } } } },
|
||||
{ id: 'telegram', name: 'telegram',
|
||||
description: '透過 Telegram Bot 發送訊息。需要 bot_token 和 chat_id。',
|
||||
url: `${baseUrl}/telegram`, method: 'POST', tags: 'integration,telegram,bot,message',
|
||||
input_schema: { type: 'object', required: ['chat_id','text','bot_token'], properties: { chat_id: { type: 'string' }, text: { type: 'string' }, bot_token: { type: 'string' }, parse_mode: { type: 'string' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: {} } } },
|
||||
// === P1 新增:Cron ===
|
||||
{ id: 'cron', name: 'cron',
|
||||
description: '建立定時排程工作流。指定 cron expression(如 0 9 * * *),到時間自動執行指定工作流。',
|
||||
url: `${baseUrl}/cron`, method: 'POST', tags: 'builtin,cron,schedule,trigger,timer',
|
||||
input_schema: { type: 'object', required: ['cron_expr'], properties: { cron_expr: { type: 'string', description: '標準 cron expression,如 0 9 * * *' }, triplets: { type: 'array', items: { type: 'string' } }, graph_token: { type: 'string' }, description: { type: 'string' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { cron_id: { type: 'string' }, cron_expr: { type: 'string' }, enabled: { type: 'boolean' } } } } } },
|
||||
// === P2 新增:控制流 ===
|
||||
{ id: 'if', name: 'if',
|
||||
description: '單一條件判斷,true/false 兩個出口。condition 支援 JS 表達式(如 x > 5)。',
|
||||
url: `${baseUrl}/if`, method: 'POST', tags: 'builtin,control,if,branch,condition',
|
||||
input_schema: { type: 'object', required: ['condition'], properties: { condition: { type: 'string', description: 'JS 表達式,如 x > 5' }, input: { type: 'object', description: '提供給 condition 的變數' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { result: { type: 'boolean' }, branch: { type: 'string', enum: ['true', 'false'] } } } } } },
|
||||
{ id: 'foreach', name: 'foreach',
|
||||
description: '對輸入陣列的每個元素執行一次後續工作流(依序)。',
|
||||
url: `${baseUrl}/foreach`, method: 'POST', tags: 'builtin,control,foreach,loop,iteration',
|
||||
input_schema: { type: 'object', required: ['items'], properties: { items: { type: 'array', description: '要迭代的陣列' }, item_key: { type: 'string', description: '每個元素注入的變數名,預設 item' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { items: { type: 'array' }, count: { type: 'number' }, current_index: { type: 'number' }, current_item: {} } } } } },
|
||||
{ id: 'try-catch', name: 'try-catch',
|
||||
description: '錯誤處理分支:執行失敗時走 catch 出口繼續,不中斷整個工作流。',
|
||||
url: `${baseUrl}/try-catch`, method: 'POST', tags: 'builtin,control,try,catch,error,handling',
|
||||
input_schema: { type: 'object', required: ['action'], properties: { action: { type: 'object', description: '要嘗試執行的動作(url + body)' }, fallback: { description: '失敗時的預設輸出' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { branch: { type: 'string', enum: ['try', 'catch'] }, result: {}, error: { type: 'string' } } } } } },
|
||||
// === P3 新增:資料處理 ===
|
||||
{ id: 'string-ops', name: 'string-ops',
|
||||
description: '字串操作:capitalize, trim, replace, split, join, substring, upper, lower, includes, startsWith, endsWith, regex match/extract/replace。',
|
||||
url: `${baseUrl}/string-ops`, method: 'POST', tags: 'builtin,data,string,transform,text',
|
||||
input_schema: { type: 'object', required: ['operation','input'], properties: { operation: { type: 'string' }, input: { type: 'string' }, args: { description: '操作參數(依 operation 而定)' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { result: {}, operation: { type: 'string' } } } } } },
|
||||
{ id: 'number-ops', name: 'number-ops',
|
||||
description: '數字操作:round, floor, ceil, abs, format, add, subtract, multiply, divide, mod, min, max, clamp。',
|
||||
url: `${baseUrl}/number-ops`, method: 'POST', tags: 'builtin,data,number,math,transform',
|
||||
input_schema: { type: 'object', required: ['operation','input'], properties: { operation: { type: 'string' }, input: { type: 'number' }, args: { description: '操作參數(依 operation 而定)' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { result: {}, operation: { type: 'string' } } } } } },
|
||||
{ id: 'array-ops', name: 'array-ops',
|
||||
description: '陣列操作:map, sort, max, min, sum, average, count, first, last, flatten, unique, reverse, chunk。',
|
||||
url: `${baseUrl}/array-ops`, method: 'POST', tags: 'builtin,data,array,list,transform',
|
||||
input_schema: { type: 'object', required: ['operation','input'], properties: { operation: { type: 'string' }, input: { type: 'array' }, args: { description: '操作參數(依 operation 而定)' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { result: {}, operation: { type: 'string' } } } } } },
|
||||
{ id: 'date-ops', name: 'date-ops',
|
||||
description: '日期操作:now, format, add, subtract, diff, parse, startOf, endOf, isBefore, isAfter。',
|
||||
url: `${baseUrl}/date-ops`, method: 'POST', tags: 'builtin,data,date,time,transform',
|
||||
input_schema: { type: 'object', required: ['operation'], properties: { operation: { type: 'string' }, input: { type: 'string', description: 'ISO 日期字串(now 操作可省略)' }, args: { description: '操作參數(依 operation 而定)' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { result: {}, operation: { type: 'string' } } } } } },
|
||||
// === P4 新增:AI Transform ===
|
||||
{ id: 'ai-transform-compile', name: 'ai-transform-compile',
|
||||
description: 'AI compile:輸入自然語言描述,AI 產生確定性 JS 轉換函式並儲存。返回 transform_id + 函式預覽。',
|
||||
url: `${baseUrl}/ai-transform/compile`, method: 'POST', tags: 'ai,transform,compile,nlp,codegen',
|
||||
input_schema: { type: 'object', required: ['description'], properties: { description: { type: 'string', description: '自然語言描述,如「把日期改成台灣格式 YYYY/MM/DD」' }, example_input: { description: '範例輸入(幫助 AI 理解)' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { transform_id: { type: 'string' }, fn_preview: { type: 'string' }, description: { type: 'string' } } } } } },
|
||||
{ id: 'ai-transform-run', name: 'ai-transform-run',
|
||||
description: 'AI run:使用已編譯的 transform_id 機械式執行轉換(不再呼叫 AI)。',
|
||||
url: `${baseUrl}/ai-transform/run`, method: 'POST', tags: 'ai,transform,run,execute',
|
||||
input_schema: { type: 'object', required: ['transform_id','input'], properties: { transform_id: { type: 'string', description: '由 compile 端點回傳的 ID' }, input: { description: '要轉換的資料' } } },
|
||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { result: {}, transform_id: { type: 'string' } } } } } },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
name = "arcrun-builtins"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
workers_dev = true
|
||||
|
||||
# Service Binding:呼叫 Cypher Executor 執行排程工作流
|
||||
[[services]]
|
||||
binding = "CYPHER"
|
||||
service = "arcrun-cypher-executor" # 填入你的 cypher-executor Worker 名稱
|
||||
|
||||
# KV Store:cron: 前綴 = 排程定義,ai-transform: 前綴 = 編譯後轉換函式
|
||||
[[kv_namespaces]]
|
||||
binding = "U6U_STORE"
|
||||
id = "" # 填入你的 KV Namespace ID
|
||||
|
||||
# Workers AI:ai-transform 零件的 compile 階段使用
|
||||
[ai]
|
||||
binding = "AI"
|
||||
|
||||
# Cron Trigger:每分鐘掃描並執行到期的排程工作流
|
||||
[triggers]
|
||||
crons = ["* * * * *"]
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "production"
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "arcrun",
|
||||
"version": "1.0.0",
|
||||
"description": "AI Workflow CLI for arcrun — deploy and run WASM-based AI workflows on Cloudflare",
|
||||
"bin": {
|
||||
"acr": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"ora": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": ["cloudflare", "workers", "wasm", "workflow", "ai", "arcrun"],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/arcrun/arcrun.git"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* acr creds push [credentials.yaml]
|
||||
* 讀取 credentials.yaml,加密後上傳至用戶自己的 CF KV(key 格式:cred:{name})
|
||||
* 不經過 arcrun.dev
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import yaml from 'js-yaml';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { CfKvClient, encryptCredential } from '../lib/cf-api.js';
|
||||
|
||||
export async function cmdCredsPush(filePath: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.cloudflare_account_id || !config.cf_api_token) {
|
||||
console.error(chalk.red('缺少 Cloudflare 設定,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 讀取 credentials.yaml
|
||||
let creds: Record<string, string>;
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf8');
|
||||
creds = yaml.load(raw) as Record<string, string>;
|
||||
} catch (e) {
|
||||
console.error(chalk.red(`無法讀取 ${filePath}:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const entries = Object.entries(creds).filter(([, v]) => typeof v === 'string' && v.length > 0);
|
||||
if (entries.length === 0) {
|
||||
console.log(chalk.yellow('credentials.yaml 中沒有有效的 credential(請取消注解並填入值)'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 決定要寫入哪個 KV namespace
|
||||
const namespaceId = config.mode === 'standard'
|
||||
? config.user_kv_namespace_id!
|
||||
: config.credentials_kv_namespace_id!;
|
||||
|
||||
if (!namespaceId) {
|
||||
console.error(chalk.red('缺少 KV Namespace ID,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kv = new CfKvClient({
|
||||
accountId: config.cloudflare_account_id,
|
||||
namespaceId,
|
||||
apiToken: config.cf_api_token,
|
||||
});
|
||||
|
||||
// 加密金鑰(若無則用 dev 模式 base64)
|
||||
const encryptionKey = process.env.ARCRUN_ENCRYPTION_KEY ?? '';
|
||||
|
||||
console.log(chalk.bold(`\n 上傳 ${entries.length} 個 credentials 至你的 CF KV\n`));
|
||||
|
||||
for (const [name, value] of entries) {
|
||||
const spinner = ora(` ${name}`).start();
|
||||
try {
|
||||
const encrypted = await encryptCredential(String(value), encryptionKey);
|
||||
await kv.put(`cred:${name}`, encrypted);
|
||||
spinner.succeed(chalk.green(` ✓ ${name} 已加密上傳至你的 CF KV`));
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.gray('\n 你的 credential 存在你自己的 CF KV,arcrun 不會儲存它們。\n'));
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* acr init — 互動式初始化設定
|
||||
* 詢問 CF Account ID、KV namespace、API Token、email,
|
||||
* 呼叫 arcrun.dev 取得 API Key,寫入 ~/.arcrun/config.yaml
|
||||
*/
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import chalk from 'chalk';
|
||||
import { saveConfig, type ArcrunConfig } from '../lib/config.js';
|
||||
|
||||
const ARCRUN_REGISTER_URL = 'https://api.arcrun.dev/register';
|
||||
|
||||
async function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
const answer = await rl.question(chalk.cyan(`? ${question}: `));
|
||||
return answer.trim();
|
||||
}
|
||||
|
||||
export async function cmdInit(options: { selfHosted?: boolean }): Promise<void> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
console.log(chalk.bold('\n arcrun 初始化設定\n'));
|
||||
|
||||
try {
|
||||
if (options.selfHosted) {
|
||||
await initSelfHosted(rl);
|
||||
} else {
|
||||
await initStandard(rl);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function initStandard(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||
console.log(chalk.gray(' Standard 模式:使用 arcrun.dev 的執行引擎,credential 存在你自己的 CF KV\n'));
|
||||
|
||||
const accountId = await prompt(rl, '你的 Cloudflare Account ID');
|
||||
const kvNamespaceId = await prompt(rl,
|
||||
'USER_KV Namespace ID(先至 CF Dashboard 建立一個 KV 後貼上)'
|
||||
);
|
||||
const cfApiToken = await prompt(rl,
|
||||
'CF API Token(只需 KV Edit 權限,供 acr 讀寫你的 KV)'
|
||||
);
|
||||
const email = await prompt(rl, 'Email(取得 arcrun.dev API Key)');
|
||||
|
||||
process.stdout.write(chalk.gray('\n → 向 arcrun.dev 取得 API Key...'));
|
||||
|
||||
let apiKey: string;
|
||||
try {
|
||||
const res = await fetch(ARCRUN_REGISTER_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, cf_api_token: cfApiToken }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`API Key 取得失敗(${res.status}):${err}`);
|
||||
}
|
||||
|
||||
const data = await res.json() as { api_key: string; tenant_id: string };
|
||||
apiKey = data.api_key;
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ✗(離線模式,請稍後執行 acr init 重試)'));
|
||||
apiKey = '';
|
||||
}
|
||||
|
||||
const config: ArcrunConfig = {
|
||||
mode: 'standard',
|
||||
cloudflare_account_id: accountId,
|
||||
user_kv_namespace_id: kvNamespaceId,
|
||||
cf_api_token: cfApiToken,
|
||||
api_key: apiKey,
|
||||
};
|
||||
|
||||
saveConfig(config);
|
||||
|
||||
// 建立空白 credentials.yaml
|
||||
createCredentialsYamlIfMissing();
|
||||
|
||||
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml'));
|
||||
if (apiKey) {
|
||||
console.log(chalk.green(` ✓ API Key:${apiKey.slice(0, 8)}...(已安全儲存)`));
|
||||
}
|
||||
console.log(chalk.green(' ✓ 建立 credentials.yaml(已加入 .gitignore)\n'));
|
||||
console.log(chalk.gray(' 你的 credential 與 workflow 存在你自己的 CF KV,arcrun 不會儲存它們。\n'));
|
||||
console.log(' 下一步:');
|
||||
console.log(chalk.cyan(' acr creds push credentials.yaml') + ' # 上傳加密 credentials');
|
||||
console.log(chalk.cyan(' acr push workflow.yaml') + ' # 部署 workflow');
|
||||
console.log(chalk.cyan(' acr run <workflow_name>') + ' # 執行 workflow\n');
|
||||
}
|
||||
|
||||
async function initSelfHosted(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||
console.log(chalk.gray(' Self-hosted 模式:自行部署所有 Worker 到你的 Cloudflare 帳號\n'));
|
||||
|
||||
const accountId = await prompt(rl, '你的 Cloudflare Account ID');
|
||||
const cypherUrl = await prompt(rl, 'Cypher Executor URL(部署後的 workers.dev URL)');
|
||||
const webhooksKvId = await prompt(rl, 'WEBHOOKS KV Namespace ID');
|
||||
const credentialsKvId = await prompt(rl, 'CREDENTIALS_KV Namespace ID');
|
||||
const wasmBucket = await prompt(rl, 'WASM_BUCKET 名稱');
|
||||
const cfApiToken = await prompt(rl, 'CF API Token(KV Edit 權限)');
|
||||
|
||||
const config: ArcrunConfig = {
|
||||
mode: 'self-hosted',
|
||||
cloudflare_account_id: accountId,
|
||||
cypher_executor_url: cypherUrl,
|
||||
webhooks_kv_namespace_id: webhooksKvId,
|
||||
credentials_kv_namespace_id: credentialsKvId,
|
||||
wasm_bucket: wasmBucket,
|
||||
cf_api_token: cfApiToken,
|
||||
multi_tenant: false,
|
||||
};
|
||||
|
||||
saveConfig(config);
|
||||
createCredentialsYamlIfMissing();
|
||||
|
||||
console.log(chalk.green('\n ✓ 設定完成 → ~/.arcrun/config.yaml'));
|
||||
console.log(chalk.green(' ✓ 建立 credentials.yaml\n'));
|
||||
}
|
||||
|
||||
function createCredentialsYamlIfMissing(): void {
|
||||
const credPath = join(process.cwd(), 'credentials.yaml');
|
||||
if (!existsSync(credPath)) {
|
||||
writeFileSync(credPath,
|
||||
'# arcrun credentials — 不要提交至 git!\n' +
|
||||
'# 執行 acr creds push 上傳加密後的 credential 到你的 CF KV\n\n' +
|
||||
'# gmail_token: "your-google-oauth-token"\n' +
|
||||
'# telegram_bot_token: "your-telegram-bot-token"\n' +
|
||||
'# google_oauth: "your-google-oauth-token"\n' +
|
||||
'# line_token: "your-line-notify-token"\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
// 確保 .gitignore 排除 credentials.yaml
|
||||
const gitignorePath = join(process.cwd(), '.gitignore');
|
||||
if (existsSync(gitignorePath)) {
|
||||
const content = require('node:fs').readFileSync(gitignorePath, 'utf8');
|
||||
if (!content.includes('credentials.yaml')) {
|
||||
require('node:fs').appendFileSync(gitignorePath, '\ncredentials.yaml\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* acr list — 列出 USER_KV 中所有已上傳的 workflow
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { CfKvClient } from '../lib/cf-api.js';
|
||||
|
||||
export async function cmdList(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.cloudflare_account_id || !config.cf_api_token) {
|
||||
console.error(chalk.red('缺少 Cloudflare 設定,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const namespaceId = config.mode === 'standard'
|
||||
? config.user_kv_namespace_id!
|
||||
: config.webhooks_kv_namespace_id!;
|
||||
|
||||
if (!namespaceId) {
|
||||
console.error(chalk.red('缺少 KV Namespace ID,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kv = new CfKvClient({
|
||||
accountId: config.cloudflare_account_id,
|
||||
namespaceId,
|
||||
apiToken: config.cf_api_token,
|
||||
});
|
||||
|
||||
const spinner = ora('讀取 workflow 清單').start();
|
||||
|
||||
try {
|
||||
const keys = await kv.list('workflow:');
|
||||
spinner.stop();
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log(chalk.yellow('\n 沒有已部署的 workflow。執行 acr push <workflow.yaml> 部署第一個。\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`\n 已部署 ${keys.length} 個 workflow\n`));
|
||||
|
||||
for (const key of keys) {
|
||||
const name = key.name.replace('workflow:', '');
|
||||
// 嘗試讀取 workflow 定義取得 created_at
|
||||
try {
|
||||
const raw = await kv.get(key.name);
|
||||
if (raw) {
|
||||
const def = JSON.parse(raw) as { name: string; description?: string; created_at?: string };
|
||||
const date = def.created_at ? new Date(def.created_at).toLocaleString('zh-TW') : '未知';
|
||||
const desc = def.description ? chalk.gray(` — ${def.description}`) : '';
|
||||
console.log(` • ${chalk.cyan(name.padEnd(25))} ${date}${desc}`);
|
||||
} else {
|
||||
console.log(` • ${chalk.cyan(name)}`);
|
||||
}
|
||||
} catch {
|
||||
console.log(` • ${chalk.cyan(name)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`KV 讀取失敗:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* acr logs <workflow_name> — 顯示最近執行記錄
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { CfKvClient } from '../lib/cf-api.js';
|
||||
|
||||
export async function cmdLogs(workflowName: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.cloudflare_account_id || !config.cf_api_token) {
|
||||
console.error(chalk.red('缺少 Cloudflare 設定,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const namespaceId = config.mode === 'standard'
|
||||
? config.user_kv_namespace_id!
|
||||
: config.webhooks_kv_namespace_id!;
|
||||
|
||||
if (!namespaceId) {
|
||||
console.error(chalk.red('缺少 KV Namespace ID,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kv = new CfKvClient({
|
||||
accountId: config.cloudflare_account_id,
|
||||
namespaceId,
|
||||
apiToken: config.cf_api_token,
|
||||
});
|
||||
|
||||
const spinner = ora(`讀取 "${workflowName}" 執行記錄`).start();
|
||||
|
||||
try {
|
||||
const keys = await kv.list(`log:${workflowName}:`);
|
||||
spinner.stop();
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log(chalk.yellow(`\n "${workflowName}" 沒有執行記錄。\n`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 依時間排序(key 格式:log:{name}:{timestamp})
|
||||
const sorted = keys.sort((a, b) => b.name.localeCompare(a.name)).slice(0, 20);
|
||||
|
||||
console.log(chalk.bold(`\n "${workflowName}" 最近 ${sorted.length} 次執行記錄\n`));
|
||||
|
||||
for (const key of sorted) {
|
||||
try {
|
||||
const raw = await kv.get(key.name);
|
||||
if (!raw) continue;
|
||||
|
||||
const log = JSON.parse(raw) as {
|
||||
success: boolean;
|
||||
duration_ms: number;
|
||||
executed_at: string;
|
||||
failed_node?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const icon = log.success ? chalk.green('✓') : chalk.red('✗');
|
||||
const date = new Date(log.executed_at).toLocaleString('zh-TW');
|
||||
const duration = chalk.gray(`${log.duration_ms}ms`);
|
||||
|
||||
if (log.success) {
|
||||
console.log(` ${icon} ${date} ${duration}`);
|
||||
} else {
|
||||
console.log(` ${icon} ${date} ${duration} ${chalk.red(`失敗節點:${log.failed_node ?? '未知'}`)}`);
|
||||
if (log.error) {
|
||||
console.log(chalk.red(` ${log.error.slice(0, 100)}`));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 跳過無法解析的記錄
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`KV 讀取失敗:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* acr parts — 列出所有可用零件(按類型分組,含統計與 author)
|
||||
* acr parts scaffold <component> — 輸出 config 範本
|
||||
* acr parts publish <component> — 提交零件至公眾 registry
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
|
||||
const REGISTRY_URL = 'https://registry.arcrun.dev';
|
||||
|
||||
interface ComponentInfo {
|
||||
canonical_id: string;
|
||||
display_name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
author?: string;
|
||||
total_runs?: number;
|
||||
success_rate?: number;
|
||||
avg_duration_ms?: number;
|
||||
visibility?: 'public' | 'author_only';
|
||||
credentials_required?: Array<{ key: string; type: string; inject_as: string }>;
|
||||
}
|
||||
|
||||
export async function cmdParts(): Promise<void> {
|
||||
const spinner = ora('從 registry.arcrun.dev 取得零件清單').start();
|
||||
|
||||
let components: ComponentInfo[] = [];
|
||||
try {
|
||||
const res = await fetch(`${REGISTRY_URL}/components`);
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { components: ComponentInfo[] };
|
||||
components = data.components ?? [];
|
||||
}
|
||||
spinner.stop();
|
||||
} catch {
|
||||
spinner.stop();
|
||||
console.log(chalk.yellow(' 無法連線 registry.arcrun.dev,顯示本地零件清單\n'));
|
||||
}
|
||||
|
||||
if (components.length === 0) {
|
||||
// fallback:顯示本地 registry 目錄中的零件
|
||||
components = loadLocalComponents();
|
||||
}
|
||||
|
||||
// 依 category 分組
|
||||
const grouped: Record<string, ComponentInfo[]> = {};
|
||||
for (const comp of components) {
|
||||
const cat = comp.category ?? 'other';
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(comp);
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
api: '整合類(Integration)',
|
||||
logic: '控制類(Control Flow)',
|
||||
data: '資料類(Data)',
|
||||
ai: 'AI 類',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
console.log(chalk.bold('\n arcrun 零件庫\n'));
|
||||
|
||||
for (const [cat, comps] of Object.entries(grouped)) {
|
||||
console.log(chalk.bold.underline(` ${categoryLabels[cat] ?? cat}`));
|
||||
for (const comp of comps) {
|
||||
const isAuthorOnly = comp.visibility === 'author_only';
|
||||
const tag = isAuthorOnly ? chalk.yellow(' [待審核]') : '';
|
||||
|
||||
let statsLine = '';
|
||||
if (!isAuthorOnly && comp.total_runs !== undefined) {
|
||||
const rate = ((comp.success_rate ?? 1) * 100).toFixed(1);
|
||||
const runs = comp.total_runs.toLocaleString();
|
||||
const ms = Math.round(comp.avg_duration_ms ?? 0);
|
||||
statsLine = chalk.gray(` ★ ${rate}% 成功 | ${runs} 次執行 | 平均 ${ms}ms`);
|
||||
}
|
||||
|
||||
const authorStr = comp.author ? chalk.gray(` by ${comp.author}`) : '';
|
||||
const credStr = comp.credentials_required?.length
|
||||
? chalk.yellow(` 🔑 需要 ${comp.credentials_required.map(c => c.key).join(', ')}`)
|
||||
: '';
|
||||
|
||||
console.log(` • ${chalk.cyan(comp.canonical_id.padEnd(20))}${comp.display_name}${tag}${authorStr}${credStr}`);
|
||||
if (statsLine) console.log(statsLine);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(chalk.gray(' 使用 acr parts scaffold <component> 取得 config 範本'));
|
||||
console.log(chalk.gray(' 使用 acr parts publish <component> 提交零件至公眾庫\n'));
|
||||
}
|
||||
|
||||
export async function cmdPartsScaffold(componentId: string): Promise<void> {
|
||||
// 優先從本地 registry 讀取 contract.yaml
|
||||
const localContract = loadLocalContract(componentId);
|
||||
|
||||
if (!localContract) {
|
||||
// 嘗試從 registry.arcrun.dev 取得
|
||||
try {
|
||||
const res = await fetch(`${REGISTRY_URL}/components/${componentId}/contract`);
|
||||
if (!res.ok) {
|
||||
console.error(chalk.red(`零件 "${componentId}" 不存在,執行 acr parts 查看可用清單`));
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json() as { config_example?: string; credentials_required?: unknown[] };
|
||||
printScaffold(componentId, data.config_example, data.credentials_required as ComponentInfo['credentials_required']);
|
||||
} catch {
|
||||
console.error(chalk.red(`無法取得 "${componentId}" 的 contract,請確認零件名稱`));
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const configExample = extractYamlField(localContract, 'config_example');
|
||||
const credsRequired = extractCredentialsRequired(localContract);
|
||||
printScaffold(componentId, configExample, credsRequired);
|
||||
}
|
||||
|
||||
function printScaffold(
|
||||
componentId: string,
|
||||
configExample?: string,
|
||||
credsRequired?: ComponentInfo['credentials_required'],
|
||||
): void {
|
||||
console.log(chalk.bold(`\n ${componentId} — workflow.yaml config 範本\n`));
|
||||
|
||||
if (configExample) {
|
||||
console.log(chalk.cyan(' # 貼入 workflow.yaml 的 config: 區塊'));
|
||||
console.log(configExample.split('\n').map(l => ` ${l}`).join('\n'));
|
||||
} else {
|
||||
console.log(chalk.yellow(' (無 config_example,請參考文檔)'));
|
||||
}
|
||||
|
||||
if (credsRequired && credsRequired.length > 0) {
|
||||
console.log(chalk.bold('\n credentials.yaml 範本(加入後執行 acr creds push)\n'));
|
||||
for (const cred of credsRequired) {
|
||||
console.log(chalk.cyan(` # ${cred.type}(${cred.inject_as} 欄位自動注入)`));
|
||||
console.log(` ${cred.key}: "your-${cred.type}-token"\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdPartsPublish(componentDir: string, options: { status?: string }): Promise<void> {
|
||||
if (options.status) {
|
||||
// 查詢審核進度
|
||||
try {
|
||||
const res = await fetch(`${REGISTRY_URL}/submit/status/${options.status}`);
|
||||
const data = await res.json() as { status: string; visibility?: string; failed_step?: string; reason?: string; approved_at?: string };
|
||||
console.log(chalk.bold(`\n 提交狀態:${options.status}\n`));
|
||||
console.log(` 狀態:${data.status}`);
|
||||
if (data.visibility) console.log(` Visibility:${data.visibility}`);
|
||||
if (data.failed_step) console.log(chalk.red(` 失敗步驟:${data.failed_step}`));
|
||||
if (data.reason) console.log(chalk.red(` 原因:${data.reason}`));
|
||||
if (data.approved_at) console.log(chalk.green(` 核准時間:${data.approved_at}`));
|
||||
} catch (e) {
|
||||
console.error(chalk.red(`查詢失敗:${e instanceof Error ? e.message : e}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
if (!config.api_key) {
|
||||
console.error(chalk.red('缺少 API Key,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 讀取零件目錄
|
||||
const contractPath = join(componentDir, 'component.contract.yaml');
|
||||
const mainGoPath = join(componentDir, 'main.go');
|
||||
const wasmName = componentDir.split('/').pop() ?? componentDir;
|
||||
const wasmPath = join(componentDir, `${wasmName}.wasm`);
|
||||
|
||||
if (!existsSync(contractPath)) {
|
||||
console.error(chalk.red(`找不到 ${contractPath}`));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!existsSync(wasmPath)) {
|
||||
console.error(chalk.red(`找不到 ${wasmPath}(請先編譯:tinygo build -o ${wasmName}.wasm -target wasi .)`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const spinner = ora('提交零件至 registry.arcrun.dev').start();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('contract', new Blob([readFileSync(contractPath)], { type: 'application/yaml' }), 'component.contract.yaml');
|
||||
if (existsSync(mainGoPath)) {
|
||||
formData.append('source', new Blob([readFileSync(mainGoPath)], { type: 'text/plain' }), 'main.go');
|
||||
}
|
||||
formData.append('wasm', new Blob([readFileSync(wasmPath)], { type: 'application/wasm' }), `${wasmName}.wasm`);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${REGISTRY_URL}/submit`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Arcrun-API-Key': config.api_key },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
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 { submission_id: string; status: string; visibility?: string };
|
||||
spinner.succeed(chalk.green(`✓ 提交成功`));
|
||||
console.log(`\n Submission ID:${chalk.cyan(data.submission_id)}`);
|
||||
console.log(` 狀態:${data.status}`);
|
||||
if (data.visibility) console.log(` Visibility:${data.visibility}`);
|
||||
console.log(chalk.gray(`\n 查詢進度:acr parts publish --status ${data.submission_id}\n`));
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`提交失敗:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function loadLocalComponents(): ComponentInfo[] {
|
||||
// 嘗試從相對路徑尋找 registry/components
|
||||
const dirs = [
|
||||
join(process.cwd(), 'registry/components'),
|
||||
join(process.cwd(), '../registry/components'),
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (existsSync(dir)) {
|
||||
const components: ComponentInfo[] = [];
|
||||
const { readdirSync } = require('node:fs');
|
||||
for (const name of readdirSync(dir)) {
|
||||
const contractPath = join(dir, name, 'component.contract.yaml');
|
||||
if (existsSync(contractPath)) {
|
||||
const raw = readFileSync(contractPath, 'utf8');
|
||||
const canonical_id = extractYamlScalar(raw, 'canonical_id') ?? name;
|
||||
const display_name = extractYamlScalar(raw, 'display_name') ?? name;
|
||||
const category = extractYamlScalar(raw, 'category') ?? 'other';
|
||||
const description = extractYamlScalar(raw, 'description') ?? '';
|
||||
components.push({ canonical_id, display_name, category, description });
|
||||
}
|
||||
}
|
||||
return components;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function loadLocalContract(componentId: string): string | null {
|
||||
const dirs = [
|
||||
join(process.cwd(), `registry/components/${componentId}/component.contract.yaml`),
|
||||
join(process.cwd(), `../registry/components/${componentId}/component.contract.yaml`),
|
||||
];
|
||||
for (const p of dirs) {
|
||||
if (existsSync(p)) return readFileSync(p, 'utf8');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractYamlScalar(yaml: string, key: string): string | undefined {
|
||||
const m = yaml.match(new RegExp(`^${key}:\\s*["']?([^"'\\n]+)["']?`, 'm'));
|
||||
return m?.[1]?.trim();
|
||||
}
|
||||
|
||||
function extractYamlField(yaml: string, field: string): string | undefined {
|
||||
const m = yaml.match(new RegExp(`^${field}:\\s*\\|\\n((?:[ \\t]+[^\\n]*\\n?)*)`, 'm'));
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
function extractCredentialsRequired(yaml: string): ComponentInfo['credentials_required'] {
|
||||
const section = yaml.match(/credentials_required:\s*([\s\S]*?)(?=\n\w|\n#|$)/);
|
||||
if (!section) return [];
|
||||
const items: ComponentInfo['credentials_required'] = [];
|
||||
const blocks = section[1].split(/\n - /).slice(1);
|
||||
for (const block of blocks) {
|
||||
const key = block.match(/key:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
|
||||
const type = block.match(/type:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
|
||||
const inject_as = block.match(/inject_as:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
|
||||
if (key && type && inject_as) {
|
||||
items!.push({ key, type, inject_as });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* acr push <workflow.yaml>
|
||||
* 解析三元組,轉成執行圖,直接寫入用戶的 USER_KV(key = workflow:{name})
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||
import { CfKvClient } from '../lib/cf-api.js';
|
||||
import { loadWorkflowYaml, parseTriplets, validateRelations } from '../lib/yaml-parser.js';
|
||||
|
||||
export async function cmdPush(filePath: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
const spinner = ora('解析 workflow.yaml').start();
|
||||
let workflow;
|
||||
try {
|
||||
workflow = loadWorkflowYaml(filePath);
|
||||
spinner.text = `驗證三元組(${workflow.flow.length} 條)`;
|
||||
const triplets = parseTriplets(workflow.flow);
|
||||
validateRelations(triplets);
|
||||
spinner.succeed(`解析完成:${workflow.name}(${triplets.length} 條三元組)`);
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`解析失敗:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// POST 到 cypher-executor 取得執行圖
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
const triplets = parseTriplets(workflow.flow);
|
||||
const searchSpinner = ora(`向 ${executorUrl} 解析執行圖`).start();
|
||||
|
||||
let graph: unknown;
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
|
||||
|
||||
const res = await fetch(`${executorUrl}/cypher/search`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ triplets: workflow.flow }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
searchSpinner.fail(chalk.red(`執行圖解析失敗(${res.status}):${err.slice(0, 200)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = await res.json() as { cypher: unknown; missing: string[] };
|
||||
if (data.missing.length > 0) {
|
||||
searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
graph = data.cypher;
|
||||
searchSpinner.succeed('執行圖解析完成');
|
||||
} catch (e) {
|
||||
searchSpinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 寫入 USER_KV(key = workflow:{name})
|
||||
const namespaceId = config.mode === 'standard'
|
||||
? config.user_kv_namespace_id!
|
||||
: config.webhooks_kv_namespace_id!;
|
||||
|
||||
if (!namespaceId || !config.cloudflare_account_id || !config.cf_api_token) {
|
||||
console.error(chalk.red('缺少 KV 設定,請執行 acr init'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kv = new CfKvClient({
|
||||
accountId: config.cloudflare_account_id,
|
||||
namespaceId,
|
||||
apiToken: config.cf_api_token,
|
||||
});
|
||||
|
||||
const kvSpinner = ora('寫入 workflow 至 CF KV').start();
|
||||
try {
|
||||
const workflowDef = {
|
||||
name: workflow.name,
|
||||
description: workflow.description ?? '',
|
||||
graph,
|
||||
config: workflow.config ?? {},
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
await kv.put(`workflow:${workflow.name}`, JSON.stringify(workflowDef));
|
||||
kvSpinner.succeed(chalk.green(`✓ workflow "${workflow.name}" 已寫入你的 CF KV`));
|
||||
} catch (e) {
|
||||
kvSpinner.fail(chalk.red(`KV 寫入失敗:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const executorBase = getCypherExecutorUrl(config);
|
||||
const webhookUrl = `${executorBase}/webhooks/${workflow.name}`;
|
||||
console.log(chalk.bold(`\n Webhook URL:${chalk.cyan(webhookUrl)}`));
|
||||
if (config.api_key) {
|
||||
console.log(chalk.gray(` (使用時需帶 X-Arcrun-API-Key: ${config.api_key.slice(0, 8)}...)\n`));
|
||||
}
|
||||
console.log(` 執行:${chalk.cyan(`acr run ${workflow.name}`)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* acr run <workflow_name> [--input key=value...]
|
||||
* 觸發 cypher-executor 執行指定 workflow
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.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 spinner = ora(`執行 workflow "${workflowName}"`).start();
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
|
||||
|
||||
const webhookUrl = `${executorUrl}/webhooks/${workflowName}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(inputContext),
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
if (data.success) {
|
||||
spinner.succeed(chalk.green(`✓ 執行成功(${data.duration_ms}ms)`));
|
||||
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}` : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
spinner.fail(chalk.red(`網路錯誤:${e instanceof Error ? e.message : e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* acr validate <workflow.yaml>
|
||||
* 在執行前驗證 YAML 格式、關係詞合法性、零件是否存在、credentials 是否已上傳
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||||
import { loadWorkflowYaml, parseTriplets, validateRelations, getNodeNames } from '../lib/yaml-parser.js';
|
||||
import { CfKvClient } from '../lib/cf-api.js';
|
||||
|
||||
export async function cmdValidate(filePath: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
let allPassed = true;
|
||||
|
||||
const check = (label: string, ok: boolean, detail?: string) => {
|
||||
const icon = ok ? chalk.green('✓') : chalk.red('✗');
|
||||
console.log(` ${icon} ${label}${detail ? ` ${chalk.gray(detail)}` : ''}`);
|
||||
if (!ok) allPassed = false;
|
||||
};
|
||||
|
||||
console.log(chalk.bold(`\n 驗證 ${filePath}\n`));
|
||||
|
||||
// 1. YAML 格式
|
||||
let workflow;
|
||||
try {
|
||||
workflow = loadWorkflowYaml(filePath);
|
||||
check('YAML 格式正確', true, `name=${workflow.name}`);
|
||||
} catch (e) {
|
||||
check('YAML 格式', false, e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. 三元組解析
|
||||
let triplets;
|
||||
try {
|
||||
triplets = parseTriplets(workflow.flow);
|
||||
check('三元組解析', true, `${triplets.length} 條`);
|
||||
} catch (e) {
|
||||
check('三元組解析', false, e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. 關係詞驗證(不允許 PIPE)
|
||||
try {
|
||||
validateRelations(triplets);
|
||||
check('關係詞合法性', true);
|
||||
} catch (e) {
|
||||
check('關係詞合法性', false, e instanceof Error ? e.message : String(e));
|
||||
allPassed = false;
|
||||
}
|
||||
|
||||
// 4. 所有節點在 config 中有對應(Input/Output 節點除外)
|
||||
const nodeNames = getNodeNames(triplets);
|
||||
const inputNodes = new Set(triplets.map(t => t.subject).filter(s =>
|
||||
!triplets.some(t => t.object === s)
|
||||
));
|
||||
const outputNodes = new Set(triplets.map(t => t.object).filter(o =>
|
||||
!triplets.some(t => t.subject === o)
|
||||
));
|
||||
const componentNodes = nodeNames.filter(n => !inputNodes.has(n) && !outputNodes.has(n));
|
||||
|
||||
const missingConfigs = componentNodes.filter(n => !(workflow.config ?? {})[n]);
|
||||
if (missingConfigs.length > 0) {
|
||||
check('config 完整性', false, `缺少 config:${missingConfigs.join(', ')}`);
|
||||
allPassed = false;
|
||||
} else {
|
||||
check('config 完整性', true, `${componentNodes.length} 個節點均有 config`);
|
||||
}
|
||||
|
||||
// 5. 零件存在於 WASM_BUCKET(透過 cypher/search 確認)
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
|
||||
|
||||
const res = await fetch(`${executorUrl}/cypher/search`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ triplets: workflow.flow }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { missing: string[] };
|
||||
if (data.missing.length > 0) {
|
||||
check('零件存在性', false, `WASM_BUCKET 中找不到:${data.missing.join(', ')}`);
|
||||
allPassed = false;
|
||||
} else {
|
||||
check('零件存在性', true, '所有零件均已在 WASM_BUCKET');
|
||||
}
|
||||
} else {
|
||||
check('零件存在性', false, `無法連線 ${executorUrl},跳過驗證`);
|
||||
}
|
||||
} catch {
|
||||
check('零件存在性', false, `無法連線 ${executorUrl},跳過驗證`);
|
||||
}
|
||||
|
||||
// 6. 所需 credentials 已上傳至 CREDENTIALS_KV(僅在有 KV 設定時執行)
|
||||
if (config.cloudflare_account_id && config.cf_api_token) {
|
||||
const namespaceId = config.mode === 'standard'
|
||||
? config.user_kv_namespace_id
|
||||
: config.credentials_kv_namespace_id;
|
||||
|
||||
if (namespaceId) {
|
||||
const kv = new CfKvClient({
|
||||
accountId: config.cloudflare_account_id,
|
||||
namespaceId,
|
||||
apiToken: config.cf_api_token,
|
||||
});
|
||||
|
||||
// 查詢已上傳的 credentials
|
||||
try {
|
||||
const kvKeys = await kv.list('cred:');
|
||||
const uploadedCreds = new Set(kvKeys.map(k => k.name.replace('cred:', '')));
|
||||
|
||||
// 比對 workflow config 中引用的 credential key
|
||||
const usedCreds = extractCredentialRefs(workflow.config ?? {});
|
||||
const missingCreds = usedCreds.filter(k => !uploadedCreds.has(k));
|
||||
|
||||
if (missingCreds.length > 0) {
|
||||
check('Credentials 上傳', false, `未上傳:${missingCreds.join(', ')}(執行 acr creds push)`);
|
||||
allPassed = false;
|
||||
} else {
|
||||
check('Credentials 上傳', true, `已上傳 ${uploadedCreds.size} 個 credential`);
|
||||
}
|
||||
} catch {
|
||||
check('Credentials 上傳', false, 'KV 查詢失敗,跳過驗證');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (allPassed) {
|
||||
console.log(chalk.green.bold(' ✓ 驗證通過\n'));
|
||||
} else {
|
||||
console.log(chalk.red.bold(' ✗ 驗證未通過,請修正上方錯誤\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/** 從 workflow config 中提取可能的 credential key 引用(模板 {{creds.xxx}})*/
|
||||
function extractCredentialRefs(config: Record<string, Record<string, unknown>>): string[] {
|
||||
const refs = new Set<string>();
|
||||
const jsonStr = JSON.stringify(config);
|
||||
const matches = jsonStr.matchAll(/\{\{creds\.([^}]+)\}\}/g);
|
||||
for (const m of matches) {
|
||||
refs.add(m[1]);
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* arcrun CLI — acr
|
||||
* AI Workflow CLI for Cloudflare Workers + WASM
|
||||
*
|
||||
* 安裝:npm i -g arcrun
|
||||
* 使用:acr <指令>
|
||||
*/
|
||||
import { Command } from 'commander';
|
||||
import { cmdInit } from './commands/init.js';
|
||||
import { cmdCredsPush } from './commands/creds.js';
|
||||
import { cmdPush } from './commands/push.js';
|
||||
import { cmdRun } from './commands/run.js';
|
||||
import { cmdValidate } from './commands/validate.js';
|
||||
import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js';
|
||||
import { cmdList } from './commands/list.js';
|
||||
import { cmdLogs } from './commands/logs.js';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('acr')
|
||||
.description('arcrun — AI Workflow CLI for Cloudflare Workers + WASM')
|
||||
.version('1.0.0');
|
||||
|
||||
// acr init [--self-hosted]
|
||||
program
|
||||
.command('init')
|
||||
.description('互動式初始化設定(建立 ~/.arcrun/config.yaml)')
|
||||
.option('--self-hosted', '使用 Self-hosted 模式(自行部署所有 Worker)')
|
||||
.action((options: { selfHosted?: boolean }) => cmdInit(options));
|
||||
|
||||
// acr creds push [credentials.yaml]
|
||||
const credsCmd = program.command('creds').description('Credential 管理');
|
||||
credsCmd
|
||||
.command('push [file]')
|
||||
.description('加密上傳 credentials.yaml 至你的 CF KV(不經過 arcrun.dev)')
|
||||
.action((file: string) => cmdCredsPush(file ?? 'credentials.yaml'));
|
||||
|
||||
// acr push <workflow.yaml>
|
||||
program
|
||||
.command('push <file>')
|
||||
.description('解析 workflow.yaml 並部署至你的 CF KV')
|
||||
.action((file: string) => cmdPush(file));
|
||||
|
||||
// acr run <workflow_name> [--input key=value...]
|
||||
program
|
||||
.command('run <workflow>')
|
||||
.description('執行指定 workflow')
|
||||
.option('-i, --input <pairs...>', 'input 參數(格式:key=value)')
|
||||
.action((workflow: string, options: { input?: string[] }) => cmdRun(workflow, options));
|
||||
|
||||
// acr validate <workflow.yaml>
|
||||
program
|
||||
.command('validate <file>')
|
||||
.description('執行前驗證 workflow.yaml(格式、關係詞、零件存在性、credentials)')
|
||||
.action((file: string) => cmdValidate(file));
|
||||
|
||||
// acr parts
|
||||
// acr parts scaffold <component>
|
||||
// acr parts publish <component> [--status <submission_id>]
|
||||
const partsCmd = program.command('parts').description('零件庫管理');
|
||||
partsCmd
|
||||
.action(() => cmdParts());
|
||||
|
||||
partsCmd
|
||||
.command('scaffold <component>')
|
||||
.description('輸出零件的 config 範本(可直接貼入 workflow.yaml)')
|
||||
.action((component: string) => cmdPartsScaffold(component));
|
||||
|
||||
partsCmd
|
||||
.command('publish <component-dir>')
|
||||
.description('提交零件至 arcrun.dev 公眾 registry')
|
||||
.option('--status <submission_id>', '查詢提交審核進度')
|
||||
.action((dir: string, options: { status?: string }) => cmdPartsPublish(dir, options));
|
||||
|
||||
// acr list
|
||||
program
|
||||
.command('list')
|
||||
.description('列出 CF KV 中所有已部署的 workflow')
|
||||
.action(() => cmdList());
|
||||
|
||||
// acr logs <workflow_name>
|
||||
program
|
||||
.command('logs <workflow>')
|
||||
.description('顯示 workflow 最近執行記錄')
|
||||
.action((workflow: string) => cmdLogs(workflow));
|
||||
|
||||
program.parse(process.argv);
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Cloudflare KV REST API wrapper
|
||||
* 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI
|
||||
*/
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
export interface CfKvClientOptions {
|
||||
accountId: string;
|
||||
namespaceId: string;
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
export class CfKvClient {
|
||||
private base: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor({ accountId, namespaceId, apiToken }: CfKvClientOptions) {
|
||||
this.base = `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async put(key: string, value: string): Promise<void> {
|
||||
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...this.headers, 'Content-Type': 'text/plain' },
|
||||
body: value,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`KV PUT 失敗(${res.status}):${err.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`KV GET 失敗(${res.status}):${err.slice(0, 200)}`);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async list(prefix?: string): Promise<Array<{ name: string; expiration?: number; metadata?: unknown }>> {
|
||||
const url = new URL(`${this.base}/keys`);
|
||||
if (prefix) url.searchParams.set('prefix', prefix);
|
||||
url.searchParams.set('limit', '1000');
|
||||
|
||||
const res = await fetch(url.toString(), { headers: this.headers });
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`KV LIST 失敗(${res.status}):${err.slice(0, 200)}`);
|
||||
}
|
||||
const data = await res.json() as {
|
||||
result: Array<{ name: string; expiration?: number; metadata?: unknown }>;
|
||||
};
|
||||
return data.result ?? [];
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`KV DELETE 失敗(${res.status}):${err.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
|
||||
export async function encryptCredential(value: string, encryptionKey: string): Promise<string> {
|
||||
// 若沒有設定 encryption key,使用 base64 作為 fallback(dev 模式)
|
||||
if (!encryptionKey || encryptionKey.length < 32) {
|
||||
const b64 = Buffer.from(value).toString('base64');
|
||||
return JSON.stringify({ encrypted: b64, iv: 'dev-mode', mode: 'base64' });
|
||||
}
|
||||
|
||||
const keyBytes = hexToUint8Array(encryptionKey);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt'],
|
||||
);
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(value);
|
||||
const cipherBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, encoded);
|
||||
|
||||
return JSON.stringify({
|
||||
encrypted: uint8ArrayToBase64(new Uint8Array(cipherBuffer)),
|
||||
iv: uint8ArrayToBase64(iv),
|
||||
});
|
||||
}
|
||||
|
||||
function hexToUint8Array(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function uint8ArrayToBase64(arr: Uint8Array): string {
|
||||
return Buffer.from(arr).toString('base64');
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* CLI 設定檔管理(~/.arcrun/config.yaml)
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
export interface ArcrunConfig {
|
||||
mode: 'standard' | 'self-hosted';
|
||||
// Standard 模式
|
||||
cloudflare_account_id?: string;
|
||||
user_kv_namespace_id?: string;
|
||||
cf_api_token?: string;
|
||||
api_key?: string; // arcrun.dev API Key(ak_前綴)
|
||||
// Self-hosted 模式
|
||||
cypher_executor_url?: string;
|
||||
credentials_kv_namespace_id?: string;
|
||||
webhooks_kv_namespace_id?: string;
|
||||
wasm_bucket?: string;
|
||||
// 共用
|
||||
multi_tenant?: boolean;
|
||||
}
|
||||
|
||||
const CONFIG_DIR = join(homedir(), '.arcrun');
|
||||
const CONFIG_PATH = join(CONFIG_DIR, 'config.yaml');
|
||||
|
||||
export function configExists(): boolean {
|
||||
return existsSync(CONFIG_PATH);
|
||||
}
|
||||
|
||||
export function loadConfig(): ArcrunConfig {
|
||||
if (!existsSync(CONFIG_PATH)) {
|
||||
throw new Error(
|
||||
'找不到 ~/.arcrun/config.yaml\n' +
|
||||
'請先執行:acr init'
|
||||
);
|
||||
}
|
||||
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
||||
return yaml.load(raw) as ArcrunConfig;
|
||||
}
|
||||
|
||||
export function saveConfig(config: ArcrunConfig): void {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
writeFileSync(CONFIG_PATH, yaml.dump(config), 'utf8');
|
||||
}
|
||||
|
||||
export function getCypherExecutorUrl(config: ArcrunConfig): string {
|
||||
if (config.mode === 'self-hosted' && config.cypher_executor_url) {
|
||||
return config.cypher_executor_url;
|
||||
}
|
||||
return 'https://cypher.arcrun.dev';
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* workflow.yaml 解析與三元組驗證
|
||||
*/
|
||||
import yaml from 'js-yaml';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export interface WorkflowYaml {
|
||||
name: string;
|
||||
description?: string;
|
||||
flow: string[];
|
||||
config?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface ParsedTriplet {
|
||||
subject: string;
|
||||
relation: string;
|
||||
object: string;
|
||||
}
|
||||
|
||||
/** 合法關係詞(拒絕 PIPE)*/
|
||||
const VALID_RELATIONS = new Set([
|
||||
'完成後', '失敗時', '對每個', '條件滿足時',
|
||||
'ON_SUCCESS', 'ON_FAIL', 'FOREACH', 'IF', 'ON_CLICK', 'CALLS_SUBFLOW',
|
||||
]);
|
||||
|
||||
const BANNED_RELATIONS = new Set(['PIPE']);
|
||||
|
||||
export function loadWorkflowYaml(filePath: string): WorkflowYaml {
|
||||
const raw = readFileSync(filePath, 'utf8');
|
||||
const doc = yaml.load(raw) as WorkflowYaml;
|
||||
|
||||
if (!doc.name) throw new Error('workflow.yaml 缺少 name 欄位');
|
||||
if (!Array.isArray(doc.flow) || doc.flow.length === 0) {
|
||||
throw new Error('workflow.yaml 的 flow 欄位必須為非空陣列');
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
export function parseTriplets(flow: string[]): ParsedTriplet[] {
|
||||
const triplets: ParsedTriplet[] = [];
|
||||
|
||||
for (const line of flow) {
|
||||
const parts = line.split('>>').map(s => s.trim());
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(
|
||||
`三元組格式錯誤:「${line}」\n` +
|
||||
`正確格式:「A >> 關係詞 >> B」`
|
||||
);
|
||||
}
|
||||
const [subject, relation, object] = parts;
|
||||
triplets.push({ subject, relation, object });
|
||||
}
|
||||
|
||||
return triplets;
|
||||
}
|
||||
|
||||
export function validateRelations(triplets: ParsedTriplet[]): void {
|
||||
for (const t of triplets) {
|
||||
if (BANNED_RELATIONS.has(t.relation)) {
|
||||
throw new Error(
|
||||
`不允許使用關係詞「${t.relation}」。\n` +
|
||||
`「PIPE」已棄用,請改用「完成後」或「ON_SUCCESS」。`
|
||||
);
|
||||
}
|
||||
if (!VALID_RELATIONS.has(t.relation)) {
|
||||
throw new Error(
|
||||
`未知關係詞「${t.relation}」。\n` +
|
||||
`合法關係詞:${[...VALID_RELATIONS].join('、')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeNames(triplets: ParsedTriplet[]): string[] {
|
||||
const nodes = new Set<string>();
|
||||
for (const t of triplets) {
|
||||
nodes.add(t.subject);
|
||||
nodes.add(t.object);
|
||||
}
|
||||
return [...nodes];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Generated
+2805
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "arcrun-credentials",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.14.2",
|
||||
"@cloudflare/workers-types": "^4.20250219.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
Generated
+1648
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
// createCredential:儲存 credential(name + secret + type),加密後存入 KV
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings, CredentialRecord } from '../types';
|
||||
import { encrypt } from './crypto';
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || `cred-${Date.now()}`;
|
||||
}
|
||||
|
||||
export async function handleCreateCredential(c: Context<{ Bindings: Bindings }>) {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) return c.json({ success: false, error: '無效的 JSON body' }, 400);
|
||||
|
||||
const { name, secret, type } = body;
|
||||
if (!name) return c.json({ success: false, error: 'name 必填' }, 400);
|
||||
if (!secret) return c.json({ success: false, error: 'secret 必填' }, 400);
|
||||
if (!type) return c.json({ success: false, error: 'type 必填(例: api_key, bearer_token, google_oauth)' }, 400);
|
||||
|
||||
// 使用 ENCRYPTION_KEY secret;若未設定,使用 fallback(開發環境)
|
||||
const hexKey = c.env.ENCRYPTION_KEY || '0'.repeat(64);
|
||||
|
||||
const { encrypted, iv } = await encrypt(String(secret), hexKey);
|
||||
const id = slugify(String(name));
|
||||
|
||||
const record: CredentialRecord = {
|
||||
id,
|
||||
name: String(name),
|
||||
type: String(type),
|
||||
encrypted_secret: encrypted,
|
||||
iv,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
await c.env.CREDENTIALS_KV.put(`cred:${id}`, JSON.stringify(record));
|
||||
|
||||
return c.json({ success: true, data: { id, name: record.name, type: record.type } }, 201);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// crypto:AES-GCM 加解密工具(Web Crypto API)
|
||||
|
||||
/** 從 hex 字串匯入 AES-GCM key */
|
||||
async function importKey(hexKey: string): Promise<CryptoKey> {
|
||||
const raw = new Uint8Array(hexKey.match(/.{1,2}/g)!.map(b => parseInt(b, 16)));
|
||||
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
/** 加密 plaintext,回傳 { encrypted, iv }(均為 base64) */
|
||||
export async function encrypt(plaintext: string, hexKey: string): Promise<{ encrypted: string; iv: string }> {
|
||||
const key = await importKey(hexKey);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
|
||||
return {
|
||||
encrypted: btoa(String.fromCharCode(...new Uint8Array(cipherBuf))),
|
||||
iv: btoa(String.fromCharCode(...iv)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 解密,回傳 plaintext */
|
||||
export async function decrypt(encrypted: string, iv: string, hexKey: string): Promise<string> {
|
||||
const key = await importKey(hexKey);
|
||||
const ivBuf = Uint8Array.from(atob(iv), c => c.charCodeAt(0));
|
||||
const cipherBuf = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
|
||||
const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBuf }, key, cipherBuf);
|
||||
return new TextDecoder().decode(plainBuf);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// deleteCredential:刪除指定 credential(by name or id)
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export async function handleDeleteCredential(c: Context<{ Bindings: Bindings }>) {
|
||||
const name = c.req.param('name');
|
||||
const key = `cred:${name}`;
|
||||
|
||||
const existing = await c.env.CREDENTIALS_KV.get(key);
|
||||
if (!existing) {
|
||||
return c.json({ success: false, error: `找不到 credential: ${name}` }, 404);
|
||||
}
|
||||
|
||||
await c.env.CREDENTIALS_KV.delete(key);
|
||||
return c.json({ success: true, data: { deleted: name } });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// getCredentialSecret:解密並回傳 secret(內部使用,Cypher Executor inject 用)
|
||||
// 此端點只接受內部呼叫(需 Authorization: Bearer <INTERNAL_TOKEN>)
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings, CredentialRecord } from '../types';
|
||||
import { decrypt } from './crypto';
|
||||
|
||||
export async function handleGetSecret(c: Context<{ Bindings: Bindings }>) {
|
||||
const name = c.req.param('name');
|
||||
const raw = await c.env.CREDENTIALS_KV.get(`cred:${name}`);
|
||||
if (!raw) return c.json({ success: false, error: `找不到 credential: ${name}` }, 404);
|
||||
|
||||
const record = JSON.parse(raw) as CredentialRecord;
|
||||
const hexKey = c.env.ENCRYPTION_KEY || '0'.repeat(64);
|
||||
|
||||
try {
|
||||
const secret = await decrypt(record.encrypted_secret, record.iv, hexKey);
|
||||
return c.json({ success: true, data: { id: record.id, type: record.type, secret } });
|
||||
} catch {
|
||||
return c.json({ success: false, error: '解密失敗,請確認 ENCRYPTION_KEY 是否正確' }, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// listCredentials:列出所有 credential(只回傳 id/name/type,不含 secret)
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings, CredentialRecord, CredentialSummary } from '../types';
|
||||
|
||||
export async function handleListCredentials(c: Context<{ Bindings: Bindings }>) {
|
||||
const { keys } = await c.env.CREDENTIALS_KV.list({ prefix: 'cred:' });
|
||||
|
||||
const summaries: CredentialSummary[] = [];
|
||||
for (const key of keys) {
|
||||
const raw = await c.env.CREDENTIALS_KV.get(key.name);
|
||||
if (!raw) continue;
|
||||
const record = JSON.parse(raw) as CredentialRecord;
|
||||
summaries.push({ id: record.id, name: record.name, type: record.type, created_at: record.created_at });
|
||||
}
|
||||
|
||||
summaries.sort((a, b) => b.created_at - a.created_at);
|
||||
return c.json({ success: true, data: { credentials: summaries, count: summaries.length } });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// u6u-credentials Worker — Credential 儲存與注入
|
||||
// index.ts 只做路由宣告,業務邏輯在 actions/(INV Layer 1)
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { Bindings } from './types';
|
||||
import { handleCreateCredential } from './actions/createCredential';
|
||||
import { handleListCredentials } from './actions/listCredentials';
|
||||
import { handleDeleteCredential } from './actions/deleteCredential';
|
||||
import { handleGetSecret } from './actions/getCredentialSecret';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
app.use('*', cors());
|
||||
|
||||
// Health check
|
||||
app.get('/', c => c.json({ service: 'u6u-credentials', version: '1.0.0', status: 'ok' }));
|
||||
|
||||
// POST /credentials — 建立 credential(加密存入 KV)
|
||||
// GET /credentials — 列出所有 credential(不含 secret)
|
||||
// DELETE /credentials/:name — 刪除 credential
|
||||
// GET /credentials/:name/secret — 取得解密 secret(Cypher Executor inject 用)
|
||||
app.post ('/credentials', handleCreateCredential);
|
||||
app.get ('/credentials', handleListCredentials);
|
||||
app.delete('/credentials/:name', handleDeleteCredential);
|
||||
app.get ('/credentials/:name/secret', handleGetSecret);
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,24 @@
|
||||
// u6u-credentials Worker 型別定義
|
||||
|
||||
export type Bindings = {
|
||||
CREDENTIALS_KV: KVNamespace;
|
||||
ENCRYPTION_KEY: string; // hex-encoded 256-bit AES key(wrangler secret)
|
||||
ENVIRONMENT: string;
|
||||
};
|
||||
|
||||
export interface CredentialRecord {
|
||||
id: string; // 用 name slugify 生成
|
||||
name: string; // 用戶命名(human-readable)
|
||||
type: string; // api_key / bearer_token / google_oauth / telegram_bot_token / ...
|
||||
encrypted_secret: string; // AES-GCM base64 encrypted
|
||||
iv: string; // base64 IV
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
// 對外回傳(不含 secret)
|
||||
export interface CredentialSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
created_at: number;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Preservation Tests — AES-GCM Credential Round-Trip
|
||||
// Task 2: 確認基線行為(修復前執行,預期通過)
|
||||
//
|
||||
// **Validates: Requirements 3.9**
|
||||
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fc from 'fast-check';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Property: Credential round-trip
|
||||
//
|
||||
// For all non-zero credential name/secret pairs,
|
||||
// POST /credentials → GET /credentials/:name/secret returns the same secret.
|
||||
// This validates AES-GCM encrypt → decrypt round-trip correctness.
|
||||
//
|
||||
// **Validates: Requirements 3.9**
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Preservation: AES-GCM credential round-trip', () => {
|
||||
it('property: POST /credentials then GET /credentials/:name/secret returns original secret', async () => {
|
||||
// Generate non-empty name and secret pairs
|
||||
const nameArb = fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0);
|
||||
const secretArb = fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.length > 0);
|
||||
|
||||
await fc.assert(
|
||||
fc.asyncProperty(nameArb, secretArb, async (name, secret) => {
|
||||
// Use a unique suffix to avoid collisions between runs
|
||||
const uniqueName = `${name}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// POST /credentials — store encrypted credential
|
||||
const createRes = await SELF.fetch('http://localhost/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: uniqueName, secret, type: 'api_key' }),
|
||||
});
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
const created = await createRes.json() as Record<string, unknown>;
|
||||
expect(created.success).toBe(true);
|
||||
|
||||
const credId = (created.data as Record<string, unknown>).id as string;
|
||||
|
||||
// GET /credentials/:name/secret — retrieve and decrypt
|
||||
const getRes = await SELF.fetch(`http://localhost/credentials/${credId}/secret`);
|
||||
expect(getRes.status).toBe(200);
|
||||
|
||||
const retrieved = await getRes.json() as Record<string, unknown>;
|
||||
expect(retrieved.success).toBe(true);
|
||||
|
||||
// The decrypted secret must equal the original
|
||||
const retrievedSecret = (retrieved.data as Record<string, unknown>).secret as string;
|
||||
expect(retrievedSecret).toBe(secret);
|
||||
}),
|
||||
{ numRuns: 5 }
|
||||
);
|
||||
});
|
||||
|
||||
it('example: specific name/secret round-trip preserves secret', async () => {
|
||||
const name = 'preservation-test-key';
|
||||
const secret = 'my-super-secret-api-key-12345';
|
||||
|
||||
const createRes = await SELF.fetch('http://localhost/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, secret, type: 'bearer_token' }),
|
||||
});
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
const created = await createRes.json() as Record<string, unknown>;
|
||||
const credId = (created.data as Record<string, unknown>).id as string;
|
||||
|
||||
const getRes = await SELF.fetch(`http://localhost/credentials/${credId}/secret`);
|
||||
expect(getRes.status).toBe(200);
|
||||
|
||||
const retrieved = await getRes.json() as Record<string, unknown>;
|
||||
expect((retrieved.data as Record<string, unknown>).secret).toBe(secret);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cloudflareTest } from '@cloudflare/vitest-pool-workers';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
cloudflareTest({
|
||||
wrangler: { configPath: './wrangler.toml' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
name = "arcrun-credentials"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
workers_dev = true
|
||||
|
||||
# KV Namespace:加密 credential 儲存
|
||||
[[kv_namespaces]]
|
||||
binding = "CREDENTIALS_KV"
|
||||
id = "" # 填入你的 KV Namespace ID(執行 wrangler kv namespace create CREDENTIALS_KV)
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "production"
|
||||
# ENCRYPTION_KEY: 256-bit AES key(hex),透過 wrangler secret set ENCRYPTION_KEY 設定
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "^1.2.4",
|
||||
"hono": "^4.7.0",
|
||||
"zod": "~3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.8.0",
|
||||
"@cloudflare/workers-types": "^4.20250219.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
Generated
+2007
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Credential Injector
|
||||
*
|
||||
* 在 WASM 零件執行前,從 CREDENTIALS_KV 讀取加密 credential,
|
||||
* AES-GCM 解密後注入到 input 的對應欄位(inject_as)。
|
||||
*
|
||||
* 用戶的 workflow.yaml config 中不需要也不應該包含明文 token。
|
||||
*
|
||||
* 設計原則:
|
||||
* - contract.yaml 的 credentials_required 宣告需要哪個 credential
|
||||
* - CREDENTIALS_KV 存放 AES-GCM 加密後的 credential(key = cred:{name})
|
||||
* - 注入發生在 WASM 執行前,不修改 WEBHOOKS KV 中儲存的 workflow 定義
|
||||
*/
|
||||
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export interface CredentialRequirement {
|
||||
key: string; // CREDENTIALS_KV 的 key(如 gmail_token)
|
||||
type: string; // token 類型(如 google_oauth)
|
||||
description: string; // 說明
|
||||
inject_as: string; // 注入到 input 的欄位名稱(如 access_token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀取並解析零件的 contract.yaml(從 WASM_BUCKET)
|
||||
* 回傳 credentials_required 陣列,若不存在則回傳空陣列
|
||||
*/
|
||||
async function loadCredentialsRequired(
|
||||
componentId: string,
|
||||
wasmBucket: R2Bucket,
|
||||
): Promise<CredentialRequirement[]> {
|
||||
const contractKey = `${componentId}/component.contract.yaml`;
|
||||
const obj = await wasmBucket.get(contractKey);
|
||||
if (!obj) return [];
|
||||
|
||||
const yamlText = await obj.text();
|
||||
return parseCredentialsRequired(yamlText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 從 YAML 文字解析 credentials_required 欄位
|
||||
* 使用簡單的正規表達式解析(避免引入 js-yaml 依賴)
|
||||
*/
|
||||
function parseCredentialsRequired(yaml: string): CredentialRequirement[] {
|
||||
const credsSection = yaml.match(/credentials_required:\s*([\s\S]*?)(?=\n\w|\n#|$)/);
|
||||
if (!credsSection) return [];
|
||||
|
||||
const items: CredentialRequirement[] = [];
|
||||
const blockText = credsSection[1];
|
||||
|
||||
// 解析 " - key: xxx" 開頭的項目
|
||||
const itemMatches = blockText.split(/\n - /).slice(1);
|
||||
for (const item of itemMatches) {
|
||||
const key = item.match(/key:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
|
||||
const type = item.match(/type:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
|
||||
const description = item.match(/description:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim() ?? '';
|
||||
const inject_as = item.match(/inject_as:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
|
||||
|
||||
if (key && type && inject_as) {
|
||||
items.push({ key, type, description, inject_as });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-GCM 解密(與 credentials Worker 的加密邏輯對應)
|
||||
* CREDENTIALS_KV 儲存格式:{ encrypted: base64, iv: base64 }
|
||||
*/
|
||||
async function decryptCredential(encryptedJson: string, encryptionKey: string): Promise<string> {
|
||||
const { encrypted, iv } = JSON.parse(encryptedJson) as { encrypted: string; iv: string };
|
||||
|
||||
// 將 hex-encoded 256-bit key 轉為 CryptoKey
|
||||
const keyBytes = hexToUint8Array(encryptionKey);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt'],
|
||||
);
|
||||
|
||||
const ivBytes = base64ToUint8Array(iv);
|
||||
const cipherBytes = base64ToUint8Array(encrypted);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: ivBytes },
|
||||
cryptoKey,
|
||||
cipherBytes,
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
function hexToUint8Array(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function base64ToUint8Array(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行 credential 注入
|
||||
*
|
||||
* @param componentId - 零件 canonical_id
|
||||
* @param input - 節點的原始 input(來自 workflow config)
|
||||
* @param env - Cloudflare Worker Bindings
|
||||
* @returns 注入 credential 後的 input
|
||||
*
|
||||
* @throws 若 credential 不存在,拋出結構化錯誤(含 key 名稱與修復步驟)
|
||||
*/
|
||||
export async function injectCredentials(
|
||||
componentId: string,
|
||||
input: Record<string, unknown>,
|
||||
env: Bindings,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// 讀取 contract.yaml 中的 credentials_required
|
||||
const required = await loadCredentialsRequired(componentId, env.WASM_BUCKET);
|
||||
if (required.length === 0) return input;
|
||||
|
||||
const enriched = { ...input };
|
||||
|
||||
for (const cred of required) {
|
||||
const kvKey = `cred:${cred.key}`;
|
||||
const record = await env.CREDENTIALS_KV.get(kvKey);
|
||||
|
||||
if (!record) {
|
||||
throw new Error(
|
||||
`缺少 credential:${cred.key}(${cred.description})\n` +
|
||||
`修復步驟:\n` +
|
||||
` 1. 在 credentials.yaml 中加入:\n` +
|
||||
` ${cred.key}: "your-${cred.type}-token"\n` +
|
||||
` 2. 執行:acr creds push`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptCredential(record, env.ENCRYPTION_KEY);
|
||||
enriched[cred.inject_as] = decrypted;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`credential "${cred.key}" 解密失敗:${e instanceof Error ? e.message : String(e)}\n` +
|
||||
`修復步驟:重新執行 acr creds push 上傳正確的 credential。`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Bindings, ExecutionGraph } from '../types';
|
||||
import { ExecutionError } from '../types';
|
||||
import { GraphExecutor } from '../graph-executor';
|
||||
import { graphSchema } from '../lib/schemas';
|
||||
import { createComponentLoader } from '../lib/component-loader';
|
||||
import { writeEvaluation, updateComponentStats } from './execution-evaluator';
|
||||
import { parseTriplets } from './triplet-parser';
|
||||
import { searchNodes } from './search-nodes';
|
||||
import { buildExecutionGraph } from './graph-builder';
|
||||
|
||||
export async function handleCypherSearch(
|
||||
triplets: unknown[],
|
||||
env: Bindings,
|
||||
): Promise<{ nodes: Record<string, unknown>; cypher: unknown; missing: string[] }> {
|
||||
const parsed = parseTriplets(triplets);
|
||||
if (!parsed) {
|
||||
throw new Error('無法解析任何節點');
|
||||
}
|
||||
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed, env.WASM_BUCKET);
|
||||
|
||||
if (missingNodes.length > 0) {
|
||||
return { nodes: nodeResults, cypher: null, missing: missingNodes };
|
||||
}
|
||||
|
||||
const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result');
|
||||
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: [] };
|
||||
}
|
||||
|
||||
export async function handleCypherExecute(
|
||||
triplets: unknown[],
|
||||
context: Record<string, unknown> | undefined,
|
||||
graphId: string,
|
||||
graphName: string,
|
||||
env: Bindings,
|
||||
waitUntil: (promise: Promise<void>) => void,
|
||||
): Promise<{ success: boolean; data?: unknown; error?: string; trace?: unknown; duration_ms: number; graph?: ExecutionGraph }> {
|
||||
const parsed = parseTriplets(triplets as unknown[]);
|
||||
if (!parsed) {
|
||||
throw new Error('無法解析任何節點');
|
||||
}
|
||||
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed, env.WASM_BUCKET);
|
||||
|
||||
if (missingNodes.length > 0) {
|
||||
throw new Error(
|
||||
`以下零件不存在於 WASM_BUCKET:${missingNodes.join(', ')}\n` +
|
||||
`修復:執行 acr parts 查看可用零件清單,或執行 acr validate <workflow.yaml> 進行完整驗證。`
|
||||
);
|
||||
}
|
||||
|
||||
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName);
|
||||
const parseResult = graphSchema.safeParse(graph);
|
||||
if (!parseResult.success) {
|
||||
throw new Error('圖定義產生失敗');
|
||||
}
|
||||
|
||||
const loader = createComponentLoader(env);
|
||||
const executor = new GraphExecutor(loader, undefined, env);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await executor.execute(parseResult.data as ExecutionGraph, context ?? {}, env.EXEC_CONTEXT);
|
||||
const duration_ms = Date.now() - start;
|
||||
|
||||
// 非同步記錄統計(Phase 7 補充 analytics,目前為 no-op)
|
||||
const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId;
|
||||
const runId = `${graphId}-${Date.now()}`;
|
||||
waitUntil(writeEvaluation(env, {
|
||||
run_id: runId,
|
||||
workflow_id: graphId,
|
||||
component_id: componentId,
|
||||
verdict: 'success',
|
||||
duration_ms,
|
||||
evaluated_at: Date.now(),
|
||||
}));
|
||||
waitUntil(updateComponentStats(env, componentId, 'success', duration_ms));
|
||||
|
||||
return { success: true, data: result.data, trace: result.trace, duration_ms, graph };
|
||||
} catch (err) {
|
||||
const duration_ms = Date.now() - start;
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId;
|
||||
const runId = `${graphId}-${Date.now()}`;
|
||||
waitUntil(writeEvaluation(env, {
|
||||
run_id: runId,
|
||||
workflow_id: graphId,
|
||||
component_id: componentId,
|
||||
verdict: 'failed',
|
||||
duration_ms,
|
||||
error_message: errMsg.slice(0, 200),
|
||||
evaluated_at: Date.now(),
|
||||
}));
|
||||
waitUntil(updateComponentStats(env, componentId, 'failed', duration_ms));
|
||||
if (err instanceof ExecutionError) {
|
||||
const traceFormatted = err.trace.map(s => ({
|
||||
node: s.nodeId,
|
||||
status: s.error ? 'failed' : 'success',
|
||||
...(s.error ? { error: s.error } : {}),
|
||||
}));
|
||||
throw new Error(JSON.stringify({
|
||||
success: false,
|
||||
error: errMsg,
|
||||
failed_node: err.failed_node,
|
||||
failed_input: err.failed_input,
|
||||
trace: traceFormatted,
|
||||
duration_ms,
|
||||
}));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Execution Analytics — 零件執行後的統計記錄
|
||||
*
|
||||
* Phase 1 MVP:stub(不寫入任何外部服務)
|
||||
* Phase 7 補充:fire-and-forget POST 至 registry.arcrun.dev/analytics/record
|
||||
*/
|
||||
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export interface EvaluationRecord {
|
||||
run_id: string;
|
||||
workflow_id: string;
|
||||
component_id: string;
|
||||
verdict: 'success' | 'failed' | 'timeout';
|
||||
duration_ms: number;
|
||||
error_message?: string;
|
||||
evaluated_at: number;
|
||||
}
|
||||
|
||||
/** 記錄執行結果(MVP:no-op,Phase 7 補充 analytics)*/
|
||||
export async function writeEvaluation(
|
||||
_env: Bindings,
|
||||
_record: EvaluationRecord,
|
||||
): Promise<void> {
|
||||
// Phase 7: POST to registry.arcrun.dev/analytics/record
|
||||
}
|
||||
|
||||
/** 更新零件統計(MVP:no-op,Phase 7 補充)*/
|
||||
export async function updateComponentStats(
|
||||
_env: Bindings,
|
||||
_componentId: string,
|
||||
_verdict: 'success' | 'failed' | 'timeout',
|
||||
_durationMs: number,
|
||||
): Promise<void> {
|
||||
// Phase 7: update ANALYTICS_KV via registry worker
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ParsedTriplets } from './triplet-parser';
|
||||
import { toEdgeType } from './triplet-parser';
|
||||
import type { SearchResult } from './search-nodes';
|
||||
|
||||
/** 從 nodeResults + parsed 組成可直接送入 /execute 的 ExecutionGraph */
|
||||
export function buildExecutionGraph(
|
||||
parsed: ParsedTriplets,
|
||||
nodeResults: SearchResult['nodeResults'],
|
||||
graphId: string,
|
||||
graphName: string,
|
||||
) {
|
||||
const nodes = [...parsed.nodeNames].map(name => {
|
||||
const nr = nodeResults[name]!;
|
||||
const id = name.toLowerCase().replace(/\s+/g, '-');
|
||||
return {
|
||||
id,
|
||||
type: nr.type,
|
||||
componentId: nr.componentId,
|
||||
label: name,
|
||||
};
|
||||
});
|
||||
|
||||
const edges = parsed.edges.map(e => ({
|
||||
from: e.from.toLowerCase().replace(/\s+/g, '-'),
|
||||
to: e.to.toLowerCase().replace(/\s+/g, '-'),
|
||||
type: toEdgeType(e.label),
|
||||
}));
|
||||
|
||||
return { id: graphId, name: graphName, nodes, edges };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BUILTIN_IDS } from '../lib/constants';
|
||||
import type { ParsedTriplets, NodeRole } from './triplet-parser';
|
||||
import { resolveNodeRole } from './triplet-parser';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export type SearchResult = {
|
||||
nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }>;
|
||||
missingNodes: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 對所有節點進行解析,確認每個節點對應的零件是否存在。
|
||||
*
|
||||
* 優先序:
|
||||
* 1. Input/Output 角色:自動標記,不需查找
|
||||
* 2. 內建零件(BUILTIN_IDS):直接標記 found
|
||||
* 3. WASM_BUCKET 查找:確認 {componentId}/{componentId}.wasm 是否存在
|
||||
*/
|
||||
export async function searchNodes(
|
||||
parsed: ParsedTriplets,
|
||||
wasmBucket: R2Bucket,
|
||||
): Promise<SearchResult> {
|
||||
const nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }> = {};
|
||||
const missingNodes: string[] = [];
|
||||
|
||||
for (const nodeName of parsed.nodeNames) {
|
||||
const role = resolveNodeRole(nodeName, parsed);
|
||||
|
||||
// 事件源節點(起始點):自動標記 Input,不查 WASM_BUCKET
|
||||
if (role === 'Input') {
|
||||
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
|
||||
continue;
|
||||
}
|
||||
|
||||
// 輸出節點
|
||||
if (role === 'Output') {
|
||||
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
|
||||
continue;
|
||||
}
|
||||
|
||||
// 內建零件:直接標記 found
|
||||
if (BUILTIN_IDS.has(nodeName)) {
|
||||
nodeResults[nodeName] = { status: 'found', componentId: nodeName, type: role };
|
||||
continue;
|
||||
}
|
||||
|
||||
// WASM_BUCKET 查找:確認 {nodeName}/{nodeName}.wasm 是否存在
|
||||
// 節點名稱即零件 canonical_id(如 "gmail"、"telegram")
|
||||
const wasmKey = `${nodeName}/${nodeName}.wasm`;
|
||||
const obj = await wasmBucket.head(wasmKey);
|
||||
|
||||
if (obj) {
|
||||
nodeResults[nodeName] = { status: 'found', componentId: nodeName, type: role };
|
||||
} else {
|
||||
nodeResults[nodeName] = { status: 'missing', type: role };
|
||||
missingNodes.push(nodeName);
|
||||
}
|
||||
}
|
||||
|
||||
return { nodeResults, missingNodes };
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { SEMANTIC_EDGE_MAP, VALID_EDGE_TYPES } from '../lib/constants';
|
||||
import type { EdgeType } from '../types';
|
||||
|
||||
export type ParsedTriplets = {
|
||||
edges: Array<{ from: string; to: string; label: string }>;
|
||||
nodeNames: Set<string>;
|
||||
/** 出現在 from 但不出現在任何 to 的節點(事件源 / 起始點) */
|
||||
sourceNodes: Set<string>;
|
||||
/** 出現在 to 但不出現在任何 from 的節點(終點)*/
|
||||
sinkNodes: Set<string>;
|
||||
};
|
||||
|
||||
export type NodeRole = 'Input' | 'Component' | 'Output';
|
||||
|
||||
/**
|
||||
* 解析後的零件 URI
|
||||
* 支援格式:
|
||||
* component://validate_json
|
||||
* component://validate_json@stable
|
||||
* component://validate_json@pinned:v1
|
||||
* workflow://wf_save_to_db
|
||||
* ui://u6u-btn
|
||||
* style://glow-effect
|
||||
*/
|
||||
export interface ResolvedComponentId {
|
||||
type: 'component' | 'workflow' | 'ui' | 'style';
|
||||
canonicalId: string;
|
||||
stability: 'floating' | 'stable' | 'pinned';
|
||||
pinnedVersion?: string;
|
||||
/** 原始 URI 字串 */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/** 解析零件 URI 協議 */
|
||||
export function resolveComponentId(uri: string): ResolvedComponentId {
|
||||
const raw = uri.trim();
|
||||
|
||||
// 解析協議前綴
|
||||
let type: ResolvedComponentId['type'] = 'component';
|
||||
let rest = raw;
|
||||
|
||||
if (raw.startsWith('component://')) {
|
||||
type = 'component';
|
||||
rest = raw.slice('component://'.length);
|
||||
} else if (raw.startsWith('workflow://')) {
|
||||
type = 'workflow';
|
||||
rest = raw.slice('workflow://'.length);
|
||||
} else if (raw.startsWith('ui://')) {
|
||||
type = 'ui';
|
||||
rest = raw.slice('ui://'.length);
|
||||
} else if (raw.startsWith('style://')) {
|
||||
type = 'style';
|
||||
rest = raw.slice('style://'.length);
|
||||
}
|
||||
|
||||
// 解析穩定性標籤
|
||||
// component://id@stable
|
||||
// component://id@pinned:v1
|
||||
let canonicalId = rest;
|
||||
let stability: ResolvedComponentId['stability'] = 'floating';
|
||||
let pinnedVersion: string | undefined;
|
||||
|
||||
const atIdx = rest.indexOf('@');
|
||||
if (atIdx > 0) {
|
||||
canonicalId = rest.slice(0, atIdx);
|
||||
const tag = rest.slice(atIdx + 1);
|
||||
if (tag === 'stable') {
|
||||
stability = 'stable';
|
||||
} else if (tag.startsWith('pinned:')) {
|
||||
stability = 'pinned';
|
||||
pinnedVersion = tag.slice('pinned:'.length);
|
||||
}
|
||||
}
|
||||
|
||||
return { type, canonicalId, stability, pinnedVersion, raw };
|
||||
}
|
||||
|
||||
/** 解析 triplets 字串陣列,回傳節點與邊的結構 */
|
||||
export function parseTriplets(rawTriplets: unknown[]): ParsedTriplets | null {
|
||||
const edges: Array<{ from: string; to: string; label: string }> = [];
|
||||
const nodeNames = new Set<string>();
|
||||
const fromSet = new Set<string>();
|
||||
const toSet = new Set<string>();
|
||||
|
||||
for (const line of rawTriplets) {
|
||||
if (typeof line !== 'string') continue;
|
||||
const parts = line.split('>>').map((s: string) => s.trim());
|
||||
if (parts.length !== 3) continue;
|
||||
const [from, action, to] = parts;
|
||||
edges.push({ from, to, label: action });
|
||||
nodeNames.add(from);
|
||||
nodeNames.add(to);
|
||||
fromSet.add(from);
|
||||
toSet.add(to);
|
||||
}
|
||||
|
||||
if (nodeNames.size === 0) return null;
|
||||
|
||||
const sourceNodes = new Set([...fromSet].filter(n => !toSet.has(n)));
|
||||
const sinkNodes = new Set([...toSet].filter(n => !fromSet.has(n)));
|
||||
return { edges, nodeNames, sourceNodes, sinkNodes };
|
||||
}
|
||||
|
||||
/** 根據節點在圖中的位置決定其 type */
|
||||
export function resolveNodeRole(name: string, parsed: ParsedTriplets): NodeRole {
|
||||
if (parsed.sourceNodes.has(name)) return 'Input';
|
||||
if (parsed.sinkNodes.has(name)) return 'Output';
|
||||
return 'Component';
|
||||
}
|
||||
|
||||
/** 將 edge label 轉換為合法 EdgeType
|
||||
* 優先序:VALID_EDGE_TYPES(完整匹配)→ SEMANTIC_EDGE_MAP(語意別名)→ 預設 PIPE */
|
||||
export function toEdgeType(label: string): EdgeType {
|
||||
const upper = label.toUpperCase();
|
||||
if (VALID_EDGE_TYPES.has(upper)) return upper as EdgeType;
|
||||
return (SEMANTIC_EDGE_MAP[label] ?? SEMANTIC_EDGE_MAP[upper] ?? 'PIPE') as EdgeType;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Bindings } from '../types';
|
||||
import { graphSchema } from '../lib/schemas';
|
||||
import { parseTriplets } from './triplet-parser';
|
||||
import { searchNodes } from './search-nodes';
|
||||
import { buildExecutionGraph } from './graph-builder';
|
||||
|
||||
export async function resolveWebhookGraph(
|
||||
body: Record<string, unknown>,
|
||||
description: string,
|
||||
env: Bindings,
|
||||
): Promise<{ resolvedGraph: Record<string, unknown>; error?: string; missingNodes?: string[] }> {
|
||||
// 路徑 A:triplets 格式
|
||||
if (Array.isArray(body.triplets) && body.triplets.length > 0) {
|
||||
const parsed = parseTriplets(body.triplets as unknown[]);
|
||||
if (!parsed) return { resolvedGraph: {}, error: '無法解析 triplets' };
|
||||
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed, env.WASM_BUCKET);
|
||||
if (missingNodes.length > 0) {
|
||||
return { resolvedGraph: {}, error: `以下零件不存在:${missingNodes.join(', ')}。請執行 acr validate 確認所有零件已上傳。`, missingNodes };
|
||||
}
|
||||
|
||||
const graphId = `webhook-${Date.now()}`;
|
||||
const graphName = description || `Webhook ${new Date().toISOString()}`;
|
||||
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName) as Record<string, unknown>;
|
||||
|
||||
const parseResult = graphSchema.safeParse(graph);
|
||||
if (!parseResult.success) {
|
||||
return { resolvedGraph: {}, error: '圖定義產生失敗' };
|
||||
}
|
||||
|
||||
return { resolvedGraph: graph };
|
||||
}
|
||||
|
||||
// 路徑 B:graph 格式
|
||||
if (body.graph && typeof body.graph === 'object') {
|
||||
const graphWithDefaults = {
|
||||
id: `webhook-${Date.now()}`,
|
||||
name: description || `Webhook ${new Date().toISOString()}`,
|
||||
...(body.graph as Record<string, unknown>),
|
||||
};
|
||||
const parsed = graphSchema.safeParse(graphWithDefaults);
|
||||
if (!parsed.success) {
|
||||
return { resolvedGraph: {}, error: '圖定義驗證失敗' };
|
||||
}
|
||||
return { resolvedGraph: graphWithDefaults };
|
||||
}
|
||||
|
||||
// 路徑 C:body 直接就是 graph
|
||||
if (body.nodes && body.edges) {
|
||||
const graphWithDefaults = {
|
||||
id: `webhook-${Date.now()}`,
|
||||
name: description || `Webhook ${new Date().toISOString()}`,
|
||||
...body,
|
||||
};
|
||||
const parsed = graphSchema.safeParse(graphWithDefaults);
|
||||
if (!parsed.success) {
|
||||
return { resolvedGraph: {}, error: '圖定義驗證失敗' };
|
||||
}
|
||||
return { resolvedGraph: graphWithDefaults };
|
||||
}
|
||||
|
||||
return { resolvedGraph: {}, error: '需提供 graph 物件或 triplets 陣列' };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Bindings, ExecutionGraph } from '../types';
|
||||
import { ExecutionError } from '../types';
|
||||
import { GraphExecutor } from '../graph-executor';
|
||||
import { graphSchema } from '../lib/schemas';
|
||||
import { createComponentLoader } from '../lib/component-loader';
|
||||
|
||||
type WebhookRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
description: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export function generateToken(): string {
|
||||
const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
return Array.from(tokenBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export async function validateAndParseWebhook(raw: string): Promise<WebhookRecord | null> {
|
||||
try {
|
||||
return JSON.parse(raw) as WebhookRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeWebhookGraph(
|
||||
env: Bindings,
|
||||
graph: Record<string, unknown>,
|
||||
triggerContext: Record<string, unknown>,
|
||||
token: string,
|
||||
): Promise<{ success: boolean; data?: unknown; error?: string; trace?: unknown; duration_ms: number }> {
|
||||
const parsed = graphSchema.safeParse(graph);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: '圖定義已失效', duration_ms: 0 };
|
||||
}
|
||||
|
||||
const loader = createComponentLoader(env);
|
||||
const executor = new GraphExecutor(loader, undefined, env);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await executor.execute(
|
||||
parsed.data as ExecutionGraph,
|
||||
{ ...triggerContext, _webhook_token: token },
|
||||
env.EXEC_CONTEXT,
|
||||
);
|
||||
const duration_ms = Date.now() - start;
|
||||
return { success: true, data: result.data, duration_ms };
|
||||
} catch (err) {
|
||||
const duration_ms = Date.now() - start;
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
if (err instanceof ExecutionError) {
|
||||
const traceFormatted = err.trace.map(s => ({
|
||||
node: s.nodeId,
|
||||
status: s.error ? 'failed' : 'success',
|
||||
...(s.error ? { error: s.error } : {}),
|
||||
}));
|
||||
return {
|
||||
success: false,
|
||||
error: errMsg,
|
||||
trace: traceFormatted,
|
||||
duration_ms,
|
||||
};
|
||||
}
|
||||
return { success: false, error: errMsg, duration_ms };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// arcrun 圖遍歷引擎 — 支援完整 Cypher 語意關係
|
||||
import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types';
|
||||
import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError } from './types';
|
||||
import { injectCredentials } from './actions/credential-injector';
|
||||
|
||||
export type ComponentLoader = (componentId: string) => Promise<ComponentRunner>;
|
||||
export type WorkflowLoader = (workflowId: string) => Promise<ExecutionGraph>;
|
||||
|
||||
// Fan-in 狀態:入度 > 1 的節點需要等所有上游完成後才執行
|
||||
type FanInState = Map<string, { ctx: Record<string, unknown>; remaining: number }>;
|
||||
|
||||
export class GraphExecutor {
|
||||
private loader: ComponentLoader;
|
||||
private workflowLoader?: WorkflowLoader;
|
||||
private env?: Bindings;
|
||||
public recordComponentReference?: (componentId: string, workflowId: string) => Promise<void>;
|
||||
|
||||
constructor(loader: ComponentLoader, workflowLoader?: WorkflowLoader, env?: Bindings) {
|
||||
this.loader = loader;
|
||||
this.workflowLoader = workflowLoader;
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
async execute(
|
||||
graph: ExecutionGraph,
|
||||
initialContext: Record<string, unknown>,
|
||||
kvNamespace?: KVNamespace | undefined,
|
||||
): Promise<{
|
||||
data: unknown;
|
||||
trace: TraceStep[];
|
||||
}> {
|
||||
const trace: TraceStep[] = [];
|
||||
|
||||
// 建立 KV Context Store(BUILD-006)
|
||||
// run_id = graphId + timestamp,確保每次執行獨立
|
||||
const kvStore: KVContextStore | undefined = kvNamespace
|
||||
? { runId: `${graph.id}-${Date.now()}`, kv: kvNamespace }
|
||||
: undefined;
|
||||
|
||||
// 找出所有起點(沒有任何邊指向的節點)
|
||||
const hasIncoming = new Set(graph.edges.map(e => e.to));
|
||||
const startNodes = graph.nodes.filter(n => !hasIncoming.has(n.id));
|
||||
|
||||
if (startNodes.length === 0) {
|
||||
return { data: initialContext, trace };
|
||||
}
|
||||
|
||||
// 建立 fan-in 狀態(入度 > 1 的節點需要等所有上游)
|
||||
const fanIn: FanInState = new Map();
|
||||
for (const node of graph.nodes) {
|
||||
const inDeg = graph.edges.filter(e => e.to === node.id).length;
|
||||
if (inDeg > 1) {
|
||||
fanIn.set(node.id, { ctx: { ...initialContext }, remaining: inDeg });
|
||||
}
|
||||
}
|
||||
|
||||
// 並行執行所有起點
|
||||
const results = await Promise.all(
|
||||
startNodes.map(node =>
|
||||
this.executeNode(node, graph, initialContext, new Set(), trace, fanIn, kvStore)
|
||||
)
|
||||
);
|
||||
|
||||
// 合併所有起點的輸出
|
||||
// 注意:若結果是 string(如 HTML),不可直接展開 — 展開 string 會產生字元索引物件
|
||||
let mergedResult: unknown;
|
||||
if (results.length === 1) {
|
||||
mergedResult = results[0];
|
||||
} else {
|
||||
mergedResult = results.reduce(
|
||||
(acc: Record<string, unknown>, r: unknown) => ({
|
||||
...acc,
|
||||
...(typeof r === 'object' && r !== null ? (r as Record<string, unknown>) : {}),
|
||||
}),
|
||||
{} as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
return { data: mergedResult, trace };
|
||||
}
|
||||
|
||||
private async executeNode(
|
||||
node: GraphNode,
|
||||
graph: ExecutionGraph,
|
||||
context: unknown,
|
||||
visited: Set<string>,
|
||||
trace: TraceStep[],
|
||||
fanIn: FanInState,
|
||||
kvStore?: KVContextStore,
|
||||
): Promise<unknown> {
|
||||
const nodeKey = `${node.id}:${JSON.stringify(context).slice(0, 50)}`;
|
||||
if (visited.has(nodeKey)) return context;
|
||||
visited.add(nodeKey);
|
||||
|
||||
const start = Date.now();
|
||||
let result: unknown = context;
|
||||
let nodeInput: unknown = context;
|
||||
|
||||
try {
|
||||
switch (node.type) {
|
||||
case 'Input':
|
||||
result = node.data ?? context;
|
||||
nodeInput = result;
|
||||
break;
|
||||
|
||||
case 'Component': {
|
||||
if (!node.componentId) throw new Error(`節點 ${node.id} 缺少 componentId`);
|
||||
const runner = await this.loader(node.componentId);
|
||||
// 優先順序:node.data(靜態參數,如 pattern/sheet)> context(全局參數)
|
||||
let mergedContext: Record<string, unknown> = {
|
||||
...(context as Record<string, unknown>),
|
||||
...(node.data ?? {}),
|
||||
};
|
||||
|
||||
// Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token
|
||||
if (this.env) {
|
||||
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env);
|
||||
}
|
||||
|
||||
nodeInput = mergedContext;
|
||||
result = await runner(mergedContext);
|
||||
|
||||
// BUILD-006:將節點 output 存入 KV(key = {run_id}:node:{node_id})
|
||||
// 這讓下游節點可以透過 KV 讀取上游的具名 output,解決同名欄位衝突
|
||||
if (kvStore && result !== null && result !== undefined) {
|
||||
await kvSetNodeOutput(kvStore, node.id, result);
|
||||
}
|
||||
|
||||
// Phase 2:記錄 component 被引用(追蹤生命週期)
|
||||
// 由 component-registry 追蹤使用狀態,決定是否保留
|
||||
// 在後台執行,不阻擋主流程
|
||||
void this.recordComponentReference?.(node.componentId, graph.id).catch(() => {
|
||||
// 記錄失敗不應該中止執行
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Output':
|
||||
result = context;
|
||||
break;
|
||||
}
|
||||
} catch (e: any) {
|
||||
const errMsg = e.message || String(e);
|
||||
trace.push({
|
||||
nodeId: node.id,
|
||||
type: node.type,
|
||||
input: nodeInput,
|
||||
output: null,
|
||||
error: errMsg,
|
||||
duration_ms: Date.now() - start,
|
||||
});
|
||||
// 若已是 ExecutionError(上游節點拋出),保留原始 trace 繼續往上傳
|
||||
if (e instanceof ExecutionError) throw e;
|
||||
throw new ExecutionError(
|
||||
`Node ${node.id} failed: ${errMsg}`,
|
||||
node.id,
|
||||
nodeInput,
|
||||
trace,
|
||||
);
|
||||
}
|
||||
|
||||
trace.push({
|
||||
nodeId: node.id,
|
||||
type: node.type,
|
||||
input: nodeInput,
|
||||
output: result,
|
||||
duration_ms: Date.now() - start,
|
||||
});
|
||||
|
||||
// 處理出邊
|
||||
const outEdges = graph.edges.filter(e => e.from === node.id);
|
||||
|
||||
for (const edge of outEdges) {
|
||||
const nextNode = graph.nodes.find(n => n.id === edge.to);
|
||||
if (!nextNode) continue;
|
||||
|
||||
switch (edge.type as EdgeType) {
|
||||
case 'PIPE': {
|
||||
const baseResult = (typeof result === 'object' && result !== null)
|
||||
? (result as Record<string, unknown>)
|
||||
: {};
|
||||
const pipeContext: Record<string, unknown> = {
|
||||
...(context as Record<string, unknown>),
|
||||
...baseResult,
|
||||
};
|
||||
|
||||
if (kvStore) {
|
||||
const kvOutput = await kvGetNodeOutput(kvStore, node.id);
|
||||
if (kvOutput !== undefined) {
|
||||
if (!pipeContext._kv_outputs) pipeContext._kv_outputs = {};
|
||||
(pipeContext._kv_outputs as Record<string, unknown>)[node.id] = kvOutput;
|
||||
}
|
||||
}
|
||||
|
||||
const fanInState = fanIn.get(nextNode.id);
|
||||
if (fanInState) {
|
||||
Object.assign(fanInState.ctx, pipeContext);
|
||||
fanInState.remaining--;
|
||||
if (fanInState.remaining === 0) {
|
||||
result = await this.executeNode(nextNode, graph, fanInState.ctx, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
} else {
|
||||
result = await this.executeNode(nextNode, graph, pipeContext, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ON_SUCCESS': {
|
||||
// 只在上游節點成功(無 error)時執行
|
||||
const hasError = result && typeof result === 'object' && 'error' in (result as object);
|
||||
if (!hasError) {
|
||||
result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ON_FAIL': {
|
||||
// 只在上游節點失敗(有 error)時執行,傳遞 error context
|
||||
const hasError = result && typeof result === 'object' && 'error' in (result as object);
|
||||
if (hasError) {
|
||||
result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'IF': {
|
||||
const passes = evaluateCondition(edge.condition ?? 'true', result);
|
||||
if (passes) {
|
||||
result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'FOREACH': {
|
||||
const iteratorKey = edge.iterator ?? 'item';
|
||||
const items = getIterableFromContext(result, iteratorKey);
|
||||
const iterResults: unknown[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const itemContext = { ...(result as Record<string, unknown>), [iteratorKey]: item };
|
||||
const itemResult = await this.executeNode(nextNode, graph, itemContext, new Set(), trace, fanIn, kvStore);
|
||||
iterResults.push(itemResult);
|
||||
}
|
||||
|
||||
result = { ...(result as Record<string, unknown>), results: iterResults };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CALLS_SUBFLOW': {
|
||||
// 從 workflowLoader 載入子 Workflow,以當前 context 執行,輸出合併回主流程
|
||||
const subWorkflowId = nextNode.componentId?.replace('workflow://', '') ?? nextNode.id;
|
||||
if (this.workflowLoader) {
|
||||
const subGraph = await this.workflowLoader(subWorkflowId);
|
||||
const subExecutor = new GraphExecutor(this.loader, this.workflowLoader);
|
||||
const subResult = await subExecutor.execute(
|
||||
subGraph,
|
||||
result as Record<string, unknown>,
|
||||
kvStore?.kv,
|
||||
);
|
||||
result = {
|
||||
...(result as Record<string, unknown>),
|
||||
...(subResult.data as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ON_CLICK': {
|
||||
// 前端觸發:payload 已在 context 中,直接執行下游節點
|
||||
result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'IS_A': {
|
||||
// 節點類型宣告:記錄 componentId,不執行
|
||||
// IS_A 邊的 to 是零件 URI(如 component://validate_json)
|
||||
// 這個資訊已在 graph-builder 階段處理,執行時不需要額外動作
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CONTAINS':
|
||||
case 'HAS_STYLE':
|
||||
case 'HAS_BEHAVIOR': {
|
||||
// 結構語意:只記錄圖結構,不執行
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CONTINUE':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全條件評估(不使用 new Function)
|
||||
* 支援格式:ctx.key === value, ctx.key > value, ctx.key(truthy)
|
||||
*/
|
||||
function evaluateCondition(condition: string, context: unknown): boolean {
|
||||
if (!context || typeof context !== 'object') return false;
|
||||
const ctx = context as Record<string, unknown>;
|
||||
|
||||
// 正規化:把 result. 替換為空(直接存取 key)
|
||||
const expr = condition.replace(/result\./g, '').replace(/ctx\./g, '');
|
||||
|
||||
// 簡單 === 比較
|
||||
const eqMatch = expr.match(/^(\w+)\s*===?\s*(.+)$/);
|
||||
if (eqMatch) {
|
||||
const key = eqMatch[1];
|
||||
const rawVal = eqMatch[2].trim();
|
||||
const expected = rawVal === 'true' ? true : rawVal === 'false' ? false : rawVal.replace(/['"]/g, '');
|
||||
return ctx[key] === expected;
|
||||
}
|
||||
|
||||
// 簡單 > 比較
|
||||
const gtMatch = expr.match(/^(\w+)\s*>\s*(\d+)$/);
|
||||
if (gtMatch) {
|
||||
return Number(ctx[gtMatch[1]]) > Number(gtMatch[2]);
|
||||
}
|
||||
|
||||
// truthy check
|
||||
const key = expr.trim();
|
||||
if (key && key in ctx) return !!ctx[key];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getIterableFromContext(context: unknown, key: string): unknown[] {
|
||||
if (!context || typeof context !== 'object') return [];
|
||||
const plural = key + 's';
|
||||
const obj = context as Record<string, unknown>;
|
||||
const items = obj[plural] ?? obj[key];
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// arcrun cypher-executor Worker — AI 工作流執行引擎
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { Bindings } from './types';
|
||||
import { healthRouter } from './routes/health';
|
||||
import { executeRouter } from './routes/execute';
|
||||
import { cypherRouter } from './routes/cypher';
|
||||
import { validateRouter } from './routes/validate';
|
||||
import { docsRouter } from './routes/docs';
|
||||
import { webhooksRouter } from './routes/webhooks';
|
||||
import { webhooksCrudRouter } from './routes/webhooks-crud';
|
||||
import { webhooksListRouter } from './routes/webhooks-list';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// 全域 CORS
|
||||
app.use('*', cors());
|
||||
|
||||
// 掛載所有路由器
|
||||
app.route('/', docsRouter);
|
||||
app.route('/', healthRouter);
|
||||
app.route('/', executeRouter);
|
||||
app.route('/', cypherRouter);
|
||||
app.route('/', validateRouter);
|
||||
app.route('/', webhooksRouter);
|
||||
app.route('/', webhooksCrudRouter);
|
||||
app.route('/', webhooksListRouter);
|
||||
|
||||
// Worker 導出
|
||||
export default app;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BUILTIN_COMPONENTS } from './constants';
|
||||
import type { Bindings, ComponentRunner } from '../types';
|
||||
|
||||
/**
|
||||
* 建立零件載入器
|
||||
*
|
||||
* 三層優先序:
|
||||
* 1. 內建零件(BUILTIN_COMPONENTS,純本地轉換,不需 R2)
|
||||
* 2. WASM_BUCKET R2 直讀 → {componentId}/{componentId}.wasm
|
||||
* 3. 找不到 → 結構化錯誤(含 R2 key 與修復說明)
|
||||
*/
|
||||
export function createComponentLoader(env: Bindings) {
|
||||
return async (componentId: string): Promise<ComponentRunner> => {
|
||||
// 層 1:內建零件(無需 R2)
|
||||
const builtin = BUILTIN_COMPONENTS.get(componentId);
|
||||
if (builtin) return builtin;
|
||||
|
||||
// 層 2:從 WASM_BUCKET R2 讀取
|
||||
const wasmKey = `${componentId}/${componentId}.wasm`;
|
||||
const wasmObj = await env.WASM_BUCKET.get(wasmKey);
|
||||
if (wasmObj) {
|
||||
const wasmBuffer = await wasmObj.arrayBuffer();
|
||||
return createWasmRunner(componentId, wasmBuffer, env);
|
||||
}
|
||||
|
||||
// 層 3:找不到
|
||||
throw new Error(
|
||||
`零件 ${componentId} 不存在。\n` +
|
||||
`請確認 ${wasmKey} 已上傳至 WASM_BUCKET。\n` +
|
||||
`修復:執行 acr parts 查看可用零件清單。`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 WASM 零件執行器
|
||||
* 使用 WASI preview1 stdin/stdout JSON I/O 模型
|
||||
*/
|
||||
function createWasmRunner(
|
||||
componentId: string,
|
||||
wasmBuffer: ArrayBuffer,
|
||||
_env: Bindings,
|
||||
): ComponentRunner {
|
||||
return async (ctx: unknown): Promise<unknown> => {
|
||||
// 動態 import wasm-executor(避免頂層 import 造成 Worker 啟動問題)
|
||||
const { executeWasm } = await import('./wasm-executor');
|
||||
return executeWasm(componentId, wasmBuffer, ctx);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ComponentRunner, EdgeType } from '../types';
|
||||
|
||||
export const VALID_EDGE_TYPES = new Set([
|
||||
// 現有
|
||||
'PIPE', 'IF', 'FOREACH', 'CONTINUE',
|
||||
// 新增:執行語意
|
||||
'IS_A', 'ON_SUCCESS', 'ON_FAIL',
|
||||
// 新增:觸發語意
|
||||
'ON_CLICK', 'CALLS_SUBFLOW',
|
||||
// 新增:結構語意(記錄圖結構,不執行)
|
||||
'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR',
|
||||
]);
|
||||
|
||||
/** 內建零件 ID 集合(不需要查 WASM_BUCKET,Worker 記憶體中已有實作)*/
|
||||
export const BUILTIN_IDS = new Set([
|
||||
'webhook', 'comp_passthrough', 'comp_uppercase', 'comp_counter',
|
||||
]);
|
||||
|
||||
/** 語意邊 → EdgeType 映射(ADR-057 u6u L1:支援中文語意關係詞)
|
||||
* 完成後 → PIPE(成功後觸發下一個)
|
||||
* 失敗時 → CONTINUE(失敗後繼續)
|
||||
* 對每個 → FOREACH(迭代執行)
|
||||
* 條件滿足時 → IF(條件分支)
|
||||
*/
|
||||
export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
|
||||
// 中文語意詞
|
||||
'完成後': 'PIPE',
|
||||
'失敗時': 'ON_FAIL',
|
||||
'對每個': 'FOREACH',
|
||||
'條件滿足時': 'IF',
|
||||
// 英文別名
|
||||
'SUCCESS': 'ON_SUCCESS',
|
||||
'FAIL': 'ON_FAIL',
|
||||
'CLICK': 'ON_CLICK',
|
||||
'SUBFLOW': 'CALLS_SUBFLOW',
|
||||
};
|
||||
|
||||
/**
|
||||
* 內建零件表(靜態函數,不需要 R2)
|
||||
* WASM 零件從 WASM_BUCKET R2 直接讀取
|
||||
*/
|
||||
export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
|
||||
['comp_passthrough', (ctx) => ctx],
|
||||
['comp_uppercase', (ctx) => {
|
||||
const c = ctx as Record<string, unknown>;
|
||||
return { ...c, text: String(c.text || '').toUpperCase() };
|
||||
}],
|
||||
['comp_counter', (ctx) => {
|
||||
const c = ctx as Record<string, unknown>;
|
||||
return { ...c, count: (Number(c.count) || 0) + 1 };
|
||||
}],
|
||||
]);
|
||||
|
||||
export const SCORE_THRESHOLD = 0.5;
|
||||
@@ -0,0 +1,306 @@
|
||||
export const OPENAPI_SPEC = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'arcrun cypher-executor API',
|
||||
description: 'AI Workflow Execution Engine — 透過三元組 Triplet 或圖 Graph 定義工作流,系統執行並回傳結果',
|
||||
version: '1.0.0',
|
||||
contact: {
|
||||
name: 'arcrun',
|
||||
url: 'https://github.com/arcrun/arcrun',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{ url: 'https://cypher.arcrun.dev', description: 'arcrun.dev Hosted' },
|
||||
{ url: 'http://localhost:8787', description: 'Local Development' },
|
||||
],
|
||||
paths: {
|
||||
'/': {
|
||||
get: {
|
||||
summary: 'Health Check',
|
||||
tags: ['Health'],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Service is running',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
service: { type: 'string' },
|
||||
version: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/cypher/search': {
|
||||
post: {
|
||||
summary: '搜尋工作流需要的零件',
|
||||
tags: ['Cypher'],
|
||||
description: '用三元組描述工作流,系統解析並從 Registry 查詢對應零件',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triplets: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['start >> 完成後 >> get-data', 'get-data >> 完成後 >> done'],
|
||||
description: '三元組陣列,格式:\"FROM >> ACTION >> TO\"',
|
||||
},
|
||||
auto_publish: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '缺失的零件是否自動產生發佈',
|
||||
},
|
||||
},
|
||||
required: ['triplets'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: '零件搜尋成功(含版本號和時戳,適合 Markdown 文檔追蹤)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: 'search-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp})' },
|
||||
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
|
||||
triplets: { type: 'array', items: { type: 'string' }, description: '回送的三元組列表' },
|
||||
nodes: { type: 'object', description: '搜尋到的零件及其狀態' },
|
||||
cypher: { type: 'object', description: '工作流圖(null 若有缺失零件)' },
|
||||
missing: { type: 'array', items: { type: 'string' }, description: '缺失零件列表' },
|
||||
auto_published: { type: 'object', description: '自動發佈的零件(若 auto_publish=true)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'400': { description: '無法解析三元組' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/cypher/execute': {
|
||||
post: {
|
||||
summary: '執行工作流',
|
||||
tags: ['Cypher'],
|
||||
description: '直接執行 triplets,回傳完整執行結果。支援自動發佈缺失零件。',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triplets: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: '三元組陣列,格式:"FROM >> ACTION >> TO"',
|
||||
},
|
||||
context: {
|
||||
type: 'object',
|
||||
description: '執行上下文,傳入各節點作為初始參數',
|
||||
},
|
||||
auto_publish: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '缺失的零件是否自動產生臨時實作',
|
||||
},
|
||||
},
|
||||
required: ['triplets'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: '執行成功(含版本號和時戳)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: 'execute-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp})' },
|
||||
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
data: { type: 'object', description: '執行結果' },
|
||||
trace: { type: 'array', description: '執行跟蹤' },
|
||||
duration_ms: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'500': {
|
||||
description: '執行失敗或部份零件缺失(含版本號和時戳)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: 'execute-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp})' },
|
||||
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
|
||||
success: { type: 'boolean', enum: [false] },
|
||||
error: { type: 'string' },
|
||||
missing: { type: 'array', items: { type: 'string' }, description: '無法自動發佈的缺失零件' },
|
||||
auto_published: {
|
||||
type: 'object',
|
||||
description: '自動發佈的零件資訊',
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
componentId: { type: 'string' },
|
||||
temporary_endpoint: { type: 'string', format: 'uri', description: '臨時實作的 URL' },
|
||||
implement_by: { type: 'string', format: 'date-time', description: '實作截止時間' },
|
||||
},
|
||||
},
|
||||
},
|
||||
duration_ms: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/webhooks': {
|
||||
post: {
|
||||
summary: '建立 Webhook',
|
||||
tags: ['Webhooks'],
|
||||
description: '將工作流註冊成 Webhook,得到公開 URL',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triplets: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
description: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'201': {
|
||||
description: 'Webhook 建立成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
webhook_url: { type: 'string', format: 'uri' },
|
||||
description: { type: 'string' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
get: {
|
||||
summary: '列出所有 Webhooks',
|
||||
tags: ['Webhooks'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'Authorization',
|
||||
in: 'header',
|
||||
required: true,
|
||||
schema: { type: 'string', example: 'Bearer u6u_xxxxx' },
|
||||
description: 'API Key 認證',
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Webhooks 列表',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
webhooks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
total: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': { description: '未授權' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/webhooks/{token}': {
|
||||
get: {
|
||||
summary: '查詢單個 Webhook',
|
||||
tags: ['Webhooks'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'token',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Webhook 資訊',
|
||||
},
|
||||
'404': { description: 'Webhook 不存在' },
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
summary: '刪除 Webhook',
|
||||
tags: ['Webhooks'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'token',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': { description: 'Webhook 已刪除' },
|
||||
'404': { description: 'Webhook 不存在' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
ApiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'Authorization',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// 圖定義的 Zod Schema
|
||||
export const graphSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
nodes: z.array(z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['Input', 'Component', 'Output']),
|
||||
componentId: z.string().optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
})),
|
||||
edges: z.array(z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE']),
|
||||
condition: z.string().optional(),
|
||||
iterator: z.string().optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
export const executeSchema = z.object({
|
||||
graph: graphSchema,
|
||||
context: z.record(z.unknown()).default({}),
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* WASI preview1 輕量 shim
|
||||
* 只實作 stdin/stdout/stderr 所需的最小 syscall 集合。
|
||||
* 其餘 syscall 一律回傳 ENOSYS(76),確保零件無法呼叫網路或檔案系統。
|
||||
*
|
||||
* 不依賴任何外部套件(不使用 @cloudflare/workers-wasi)。
|
||||
* Requirements: 3.1, 3.3
|
||||
*/
|
||||
|
||||
const WASI_ESUCCESS = 0;
|
||||
const WASI_ENOSYS = 76;
|
||||
|
||||
// fd 常數
|
||||
const FD_STDIN = 0;
|
||||
const FD_STDOUT = 1;
|
||||
const FD_STDERR = 2;
|
||||
|
||||
export interface WasiShim {
|
||||
/** WebAssembly.Imports 物件,傳入 WebAssembly.instantiate */
|
||||
imports: WebAssembly.Imports;
|
||||
/** 取得 stdout 的完整輸出(合併所有 chunks) */
|
||||
getStdout(): string;
|
||||
/** 取得 stderr 的完整輸出 */
|
||||
getStderr(): string;
|
||||
/** 注入 WebAssembly.Memory(instantiate 後呼叫) */
|
||||
setMemory(memory: WebAssembly.Memory): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host function 注入介面
|
||||
* 讓 .wasm 零件能透過 host function 呼叫外部服務,而不需要網路 syscall
|
||||
*/
|
||||
export interface WasiHostFunctions {
|
||||
/** HTTP 請求 host function:.wasm 呼叫此函數發出 HTTP 請求 */
|
||||
http_request?: (url: string, method: string, headers: string, body: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 WASI shim 實例
|
||||
* @param stdinData - 要寫入 stdin 的 UTF-8 字串(通常是 JSON.stringify(input))
|
||||
* @param hostFunctions - 可選的 host function 注入(讓 .wasm 呼叫外部服務)
|
||||
*/
|
||||
export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFunctions): WasiShim {
|
||||
const stdinBytes = new TextEncoder().encode(stdinData);
|
||||
let stdinOffset = 0;
|
||||
|
||||
const stdoutChunks: Uint8Array[] = [];
|
||||
const stderrChunks: Uint8Array[] = [];
|
||||
|
||||
let memory: WebAssembly.Memory | null = null;
|
||||
|
||||
function getMemoryView(): DataView {
|
||||
if (!memory) throw new Error('WASI memory not set — call setMemory() after instantiate');
|
||||
return new DataView(memory.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* fd_write: 將 iovec 陣列的資料寫入 fd(stdout=1 或 stderr=2)
|
||||
* iovec 結構:{ buf: i32, buf_len: i32 }(各 4 bytes,little-endian)
|
||||
*/
|
||||
function fd_write(fd: number, iovs: number, iovs_len: number, nwritten_ptr: number): number {
|
||||
if (fd !== FD_STDOUT && fd !== FD_STDERR) return WASI_ENOSYS;
|
||||
const view = getMemoryView();
|
||||
const buf = memory!.buffer;
|
||||
let totalWritten = 0;
|
||||
|
||||
for (let i = 0; i < iovs_len; i++) {
|
||||
const iov_base = view.getUint32(iovs + i * 8, true);
|
||||
const iov_len = view.getUint32(iovs + i * 8 + 4, true);
|
||||
if (iov_len === 0) continue;
|
||||
const chunk = new Uint8Array(buf, iov_base, iov_len);
|
||||
const copy = new Uint8Array(iov_len);
|
||||
copy.set(chunk);
|
||||
if (fd === FD_STDOUT) stdoutChunks.push(copy);
|
||||
else stderrChunks.push(copy);
|
||||
totalWritten += iov_len;
|
||||
}
|
||||
|
||||
view.setUint32(nwritten_ptr, totalWritten, true);
|
||||
return WASI_ESUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* fd_read: 從 stdin 讀取資料到 iovec 陣列
|
||||
*/
|
||||
function fd_read(fd: number, iovs: number, iovs_len: number, nread_ptr: number): number {
|
||||
if (fd !== FD_STDIN) return WASI_ENOSYS;
|
||||
const view = getMemoryView();
|
||||
const buf = memory!.buffer;
|
||||
let totalRead = 0;
|
||||
|
||||
for (let i = 0; i < iovs_len; i++) {
|
||||
const iov_base = view.getUint32(iovs + i * 8, true);
|
||||
const iov_len = view.getUint32(iovs + i * 8 + 4, true);
|
||||
if (iov_len === 0) continue;
|
||||
|
||||
const remaining = stdinBytes.length - stdinOffset;
|
||||
if (remaining <= 0) break;
|
||||
|
||||
const toCopy = Math.min(iov_len, remaining);
|
||||
const dest = new Uint8Array(buf, iov_base, toCopy);
|
||||
dest.set(stdinBytes.subarray(stdinOffset, stdinOffset + toCopy));
|
||||
stdinOffset += toCopy;
|
||||
totalRead += toCopy;
|
||||
}
|
||||
|
||||
view.setUint32(nread_ptr, totalRead, true);
|
||||
return WASI_ESUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* proc_exit: 零件呼叫 exit(),拋出 Error 中止執行
|
||||
*/
|
||||
function proc_exit(code: number): never {
|
||||
throw new Error(`wasm exit: ${code}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* random_get: 填充隨機 bytes(使用 Web Crypto API)
|
||||
*/
|
||||
function random_get(buf_ptr: number, buf_len: number): number {
|
||||
const view = new Uint8Array(memory!.buffer, buf_ptr, buf_len);
|
||||
crypto.getRandomValues(view);
|
||||
return WASI_ESUCCESS;
|
||||
}
|
||||
|
||||
const shim: WasiShim = {
|
||||
imports: {
|
||||
wasi_snapshot_preview1: { fd_write,
|
||||
fd_read,
|
||||
proc_exit,
|
||||
random_get,
|
||||
// 其餘 syscall 回傳 ENOSYS(不允許網路/檔案系統操作)
|
||||
fd_seek: () => WASI_ENOSYS,
|
||||
fd_close: () => WASI_ESUCCESS,
|
||||
fd_fdstat_get: () => WASI_ENOSYS,
|
||||
fd_prestat_get: () => WASI_ENOSYS,
|
||||
fd_prestat_dir_name: () => WASI_ENOSYS,
|
||||
environ_get: () => WASI_ESUCCESS,
|
||||
environ_sizes_get: (count_ptr: number, size_ptr: number) => {
|
||||
if (memory) {
|
||||
const view = getMemoryView();
|
||||
view.setUint32(count_ptr, 0, true);
|
||||
view.setUint32(size_ptr, 0, true);
|
||||
}
|
||||
return WASI_ESUCCESS;
|
||||
},
|
||||
args_get: () => WASI_ESUCCESS,
|
||||
args_sizes_get: (argc_ptr: number, argv_buf_size_ptr: number) => {
|
||||
if (memory) {
|
||||
const view = getMemoryView();
|
||||
view.setUint32(argc_ptr, 0, true);
|
||||
view.setUint32(argv_buf_size_ptr, 0, true);
|
||||
}
|
||||
return WASI_ESUCCESS;
|
||||
},
|
||||
clock_time_get: (id: number, precision: bigint, time_ptr: number) => {
|
||||
if (memory) {
|
||||
const view = getMemoryView();
|
||||
const now = BigInt(Date.now()) * 1_000_000n;
|
||||
view.setBigUint64(time_ptr, now, true);
|
||||
}
|
||||
return WASI_ESUCCESS;
|
||||
},
|
||||
clock_res_get: () => WASI_ENOSYS,
|
||||
poll_oneoff: () => WASI_ENOSYS,
|
||||
sched_yield: () => WASI_ESUCCESS,
|
||||
proc_raise: () => WASI_ENOSYS,
|
||||
sock_accept: () => WASI_ENOSYS,
|
||||
sock_recv: () => WASI_ENOSYS,
|
||||
sock_send: () => WASI_ENOSYS,
|
||||
sock_shutdown: () => WASI_ENOSYS,
|
||||
path_open: () => WASI_ENOSYS,
|
||||
path_create_directory: () => WASI_ENOSYS,
|
||||
path_remove_directory: () => WASI_ENOSYS,
|
||||
path_rename: () => WASI_ENOSYS,
|
||||
path_unlink_file: () => WASI_ENOSYS,
|
||||
path_filestat_get: () => WASI_ENOSYS,
|
||||
path_readlink: () => WASI_ENOSYS,
|
||||
path_symlink: () => WASI_ENOSYS,
|
||||
path_link: () => WASI_ENOSYS,
|
||||
},
|
||||
// u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務
|
||||
// .wasm 零件用 //go:wasmimport u6u http_request 宣告
|
||||
u6u: {
|
||||
http_request: hostFunctions?.http_request
|
||||
? async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number,
|
||||
headersPtr: number, headersLen: number, bodyPtr: number, bodyLen: number,
|
||||
outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
if (!memory) return 1;
|
||||
const buf = memory.buffer;
|
||||
const dec = new TextDecoder();
|
||||
const url = dec.decode(new Uint8Array(buf, urlPtr, urlLen));
|
||||
const method = dec.decode(new Uint8Array(buf, methodPtr, methodLen));
|
||||
const headers = dec.decode(new Uint8Array(buf, headersPtr, headersLen));
|
||||
const body = dec.decode(new Uint8Array(buf, bodyPtr, bodyLen));
|
||||
try {
|
||||
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
||||
const encoded = new TextEncoder().encode(result);
|
||||
// 寫入結果到 outPtr 指向的 buffer
|
||||
const view = new DataView(buf);
|
||||
new Uint8Array(buf, outPtr, encoded.length).set(encoded);
|
||||
view.setUint32(outLenPtr, encoded.length, true);
|
||||
return 0; // success
|
||||
} catch {
|
||||
return 1; // error
|
||||
}
|
||||
}
|
||||
: () => 1, // host function 未注入時回傳錯誤
|
||||
},
|
||||
},
|
||||
|
||||
setMemory(mem: WebAssembly.Memory) {
|
||||
memory = mem;
|
||||
},
|
||||
|
||||
getStdout(): string {
|
||||
if (stdoutChunks.length === 0) return '';
|
||||
const total = stdoutChunks.reduce((n, c) => n + c.length, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of stdoutChunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return new TextDecoder().decode(merged);
|
||||
},
|
||||
|
||||
getStderr(): string {
|
||||
if (stderrChunks.length === 0) return '';
|
||||
const total = stderrChunks.reduce((n, c) => n + c.length, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of stderrChunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return new TextDecoder().decode(merged);
|
||||
},
|
||||
};
|
||||
|
||||
return shim;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Tier 1 WASM 執行器
|
||||
* 從 R2 載入 .wasm,透過 WASI preview1 shim 執行,stdin/stdout JSON I/O。
|
||||
*
|
||||
* 快取策略:WebAssembly.Module 快取於 Worker 記憶體(跨請求共享),
|
||||
* 避免重複編譯。每次執行只重新 instantiate。
|
||||
*
|
||||
* Requirements: 3.1, 3.3, 6.6
|
||||
*/
|
||||
|
||||
import { createWasiShim, type WasiHostFunctions } from './wasi-shim';
|
||||
|
||||
// Worker 記憶體快取:r2Key → WebAssembly.Module
|
||||
const moduleCache = new Map<string, WebAssembly.Module>();
|
||||
|
||||
export interface WasmExecutorOptions {
|
||||
/** R2 Bucket binding */
|
||||
bucket: R2Bucket;
|
||||
/** R2 物件鍵(例:components/validate_json/v1.wasm) */
|
||||
r2Key: string;
|
||||
/** 逾時上限(ms),對應 contract.constraints.max_cold_start_ms */
|
||||
timeoutMs?: number;
|
||||
/** 可選的 host function 注入(讓 .wasm 呼叫外部服務) */
|
||||
hostFunctions?: WasiHostFunctions;
|
||||
}
|
||||
|
||||
export interface WasmExecuteResult {
|
||||
output: unknown;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行 WASM 零件
|
||||
* @param input - 傳入零件的 JSON 物件(寫入 stdin)
|
||||
* @param options - 執行選項
|
||||
*/
|
||||
export async function executeWasm(
|
||||
input: unknown,
|
||||
options: WasmExecutorOptions,
|
||||
): Promise<WasmExecuteResult> {
|
||||
const { bucket, r2Key, timeoutMs = 50, hostFunctions } = options;
|
||||
|
||||
// ...(其餘不變)
|
||||
const start = Date.now();
|
||||
|
||||
// 1. 取得或編譯 WebAssembly.Module(快取)
|
||||
let wasmModule = moduleCache.get(r2Key);
|
||||
if (!wasmModule) {
|
||||
const obj = await bucket.get(r2Key);
|
||||
if (!obj) throw new Error(`WASM 零件不存在於 R2:${r2Key}`);
|
||||
const arrayBuffer = await obj.arrayBuffer();
|
||||
wasmModule = await WebAssembly.compile(arrayBuffer);
|
||||
moduleCache.set(r2Key, wasmModule);
|
||||
}
|
||||
|
||||
// 2. 建立 WASI shim,注入 stdin 與可選的 host functions
|
||||
const stdinJson = JSON.stringify(input);
|
||||
const shim = createWasiShim(stdinJson, hostFunctions);
|
||||
|
||||
// 3. instantiate(每次執行都重新 instantiate,shim 狀態是獨立的)
|
||||
const instance = await WebAssembly.instantiate(wasmModule, shim.imports);
|
||||
|
||||
// 4. 注入 memory(WASI fd_read/fd_write 需要存取 memory)
|
||||
const memory = instance.exports.memory as WebAssembly.Memory | undefined;
|
||||
if (memory) shim.setMemory(memory);
|
||||
|
||||
// 5. 執行(帶逾時)
|
||||
const exports = instance.exports as Record<string, unknown>;
|
||||
const entryFn = (exports._start ?? exports.main) as (() => void) | undefined;
|
||||
if (typeof entryFn !== 'function') {
|
||||
throw new Error(`WASM 零件缺少 _start 或 main export(r2Key: ${r2Key})`);
|
||||
}
|
||||
|
||||
const runWithTimeout = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`WASM 執行逾時(>${timeoutMs}ms):${r2Key}`));
|
||||
}, timeoutMs);
|
||||
try {
|
||||
entryFn();
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
// proc_exit(0) 拋出 "wasm exit: 0",視為正常結束
|
||||
if (e instanceof Error && e.message === 'wasm exit: 0') {
|
||||
resolve();
|
||||
} else {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await runWithTimeout;
|
||||
|
||||
// 6. 讀取 stdout,JSON.parse
|
||||
const stdout = shim.getStdout().trim();
|
||||
const stderr = shim.getStderr().trim();
|
||||
const duration_ms = Date.now() - start;
|
||||
|
||||
if (!stdout) {
|
||||
throw new Error(`WASM 零件沒有輸出(stdout 為空):${r2Key}`);
|
||||
}
|
||||
|
||||
let output: unknown;
|
||||
try {
|
||||
output = JSON.parse(stdout);
|
||||
} catch {
|
||||
throw new Error(`WASM 零件輸出不是合法 JSON:${stdout.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return { output, stdout, stderr, duration_ms };
|
||||
}
|
||||
|
||||
/** 清除 Module 快取(測試用) */
|
||||
export function clearModuleCache(): void {
|
||||
moduleCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { handleCypherSearch, handleCypherExecute } from '../actions/cypher-handlers';
|
||||
|
||||
export const cypherRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /cypher/search — 三元組 → 解析節點 → 語意搜尋零件 → 回傳 Cypher JSON (開發友善格式)
|
||||
cypherRouter.post('/cypher/search', async (c) => {
|
||||
const body = await c.req.json() as { triplets?: unknown };
|
||||
const rawTriplets = body?.triplets;
|
||||
|
||||
if (!Array.isArray(rawTriplets) || rawTriplets.length === 0) {
|
||||
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString();
|
||||
const versionId = `search-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
const result = await handleCypherSearch(rawTriplets, c.env);
|
||||
|
||||
const response = {
|
||||
version: versionId,
|
||||
timestamp,
|
||||
triplets: rawTriplets,
|
||||
nodes: result.nodes,
|
||||
cypher: result.cypher,
|
||||
missing: result.missing,
|
||||
};
|
||||
|
||||
return c.json(response);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: errMsg }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /cypher/execute — 三元組 → 一步執行(search + execute 合一)
|
||||
cypherRouter.post('/cypher/execute', async (c) => {
|
||||
const body = await c.req.json() as { triplets?: unknown; context?: Record<string, unknown>; graph_id?: string; graph_name?: string };
|
||||
|
||||
if (!Array.isArray(body?.triplets) || body.triplets.length === 0) {
|
||||
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
|
||||
}
|
||||
|
||||
const graphId = typeof body.graph_id === 'string' ? body.graph_id : `triplet-exec-${Date.now()}`;
|
||||
const graphName = typeof body.graph_name === 'string' ? body.graph_name : 'Triplet Execution';
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString();
|
||||
// 版本號格式:execute-v1-20260327-143022
|
||||
const versionId = `execute-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
try {
|
||||
const result = await handleCypherExecute(
|
||||
body.triplets as unknown[],
|
||||
body.context,
|
||||
graphId,
|
||||
graphName,
|
||||
c.env,
|
||||
(p) => c.executionCtx.waitUntil(p),
|
||||
);
|
||||
// 包裝成開發友善格式(execute 成功時)
|
||||
const response = {
|
||||
version: versionId,
|
||||
timestamp,
|
||||
...result,
|
||||
};
|
||||
return c.json(response);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
try {
|
||||
const parsed = JSON.parse(errMsg);
|
||||
const response = {
|
||||
version: versionId,
|
||||
timestamp,
|
||||
...parsed,
|
||||
};
|
||||
return c.json(response, 500);
|
||||
} catch {
|
||||
return c.json({ version: versionId, timestamp, success: false, error: errMsg, duration_ms: 0 }, 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { OPENAPI_SPEC } from '../lib/openapi';
|
||||
|
||||
export const docsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// GET /openapi.json
|
||||
docsRouter.get('/openapi.json', (c) => {
|
||||
return c.json(OPENAPI_SPEC);
|
||||
});
|
||||
|
||||
// GET /docs — Swagger UI
|
||||
docsRouter.get('/docs', (c) => {
|
||||
const specStr = JSON.stringify(OPENAPI_SPEC);
|
||||
const htmlStr = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Cypher Executor API Docs</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@4/swagger-ui.css">
|
||||
<style>html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; } *, *:before, *:after { box-sizing: inherit; } body { margin:0; padding:0; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@4/swagger-ui-bundle.js"> </script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@4/swagger-ui-standalone-preset.js"> </script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
spec: ${specStr},
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "BaseLayout"
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
return c.html(htmlStr);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings, ExecutionGraph } from '../types';
|
||||
import { ExecutionError } from '../types';
|
||||
import { GraphExecutor } from '../graph-executor';
|
||||
import { executeSchema } from '../lib/schemas';
|
||||
import { createComponentLoader } from '../lib/component-loader';
|
||||
import { writeExecutionVerdict } from '../actions/execution-logger';
|
||||
|
||||
export const executeRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /execute — 執行一個完整的圖
|
||||
executeRouter.post('/execute', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = executeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: '圖定義驗證失敗', details: parsed.error.issues }, 400);
|
||||
}
|
||||
|
||||
const { graph, context } = parsed.data;
|
||||
const loader = createComponentLoader(c.env);
|
||||
const executor = new GraphExecutor(loader);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// BUILD-006:傳入 KV namespace(若不存在則 fallback 到記憶體 merge)
|
||||
const result = await executor.execute(graph as ExecutionGraph, context, c.env.EXEC_CONTEXT);
|
||||
const duration_ms = Date.now() - start;
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成')
|
||||
);
|
||||
return c.json({ success: true, data: result.data, trace: result.trace, duration_ms });
|
||||
} catch (err) {
|
||||
const duration_ms = Date.now() - start;
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100))
|
||||
);
|
||||
if (err instanceof ExecutionError) {
|
||||
const traceFormatted = err.trace.map(s => ({
|
||||
node: s.nodeId,
|
||||
status: s.error ? 'failed' : 'success',
|
||||
...(s.error ? { error: s.error } : {}),
|
||||
}));
|
||||
return c.json({
|
||||
success: false,
|
||||
error: errMsg,
|
||||
failed_node: err.failed_node,
|
||||
failed_input: err.failed_input,
|
||||
trace: traceFormatted,
|
||||
duration_ms,
|
||||
}, 500);
|
||||
}
|
||||
return c.json({ success: false, error: errMsg, failed_node: null, trace: [], duration_ms }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
healthRouter.get('/health', (c) =>
|
||||
c.json({ ok: true })
|
||||
);
|
||||
|
||||
healthRouter.get('/', (c) =>
|
||||
c.json({
|
||||
service: 'arcrun-cypher-executor',
|
||||
version: '1.0.0',
|
||||
status: 'ok',
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { graphSchema } from '../lib/schemas';
|
||||
|
||||
export const validateRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /validate — 驗證圖定義(不執行)
|
||||
validateRouter.post('/validate', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = graphSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return c.json({ valid: false, errors: parsed.error.issues }, 400);
|
||||
}
|
||||
|
||||
const nodeIds = new Set(parsed.data.nodes.map(n => n.id));
|
||||
const invalidEdges = parsed.data.edges.filter(e => !nodeIds.has(e.from) || !nodeIds.has(e.to));
|
||||
|
||||
if (invalidEdges.length > 0) {
|
||||
return c.json({
|
||||
valid: false,
|
||||
errors: invalidEdges.map(e => `邊 ${e.from} → ${e.to} 指向不存在的節點`),
|
||||
}, 400);
|
||||
}
|
||||
|
||||
return c.json({ valid: true, nodeCount: parsed.data.nodes.length, edgeCount: parsed.data.edges.length });
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { validateAndParseWebhook } from '../actions/webhook-handlers';
|
||||
|
||||
export const webhooksCrudRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
type WebhookRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
description: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// GET /webhooks/:token — 查詢 Webhook 基本資訊
|
||||
webhooksCrudRouter.get('/webhooks/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
const raw = await c.env.WEBHOOKS.get(token, 'text');
|
||||
if (!raw) return c.json({ error: 'not found' }, 404);
|
||||
|
||||
const record = await validateAndParseWebhook(raw);
|
||||
if (!record) return c.json({ error: '資料損毀' }, 500);
|
||||
|
||||
return c.json({
|
||||
token,
|
||||
description: record.description,
|
||||
created_at: record.created_at,
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /webhooks/:token — 更新 Webhook 定義
|
||||
webhooksCrudRouter.put('/webhooks/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
if (!token || token.length < 16) {
|
||||
return c.json({ error: 'invalid token' }, 400);
|
||||
}
|
||||
|
||||
const raw = await c.env.WEBHOOKS.get(token, 'text');
|
||||
if (!raw) return c.json({ error: 'webhook not found' }, 404);
|
||||
|
||||
const existing = await validateAndParseWebhook(raw);
|
||||
if (!existing) return c.json({ error: 'webhook 定義損毀' }, 500);
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) return c.json({ error: 'invalid json' }, 400);
|
||||
|
||||
const updatedRecord: WebhookRecord = {
|
||||
graph: existing.graph,
|
||||
description: existing.description,
|
||||
created_at: existing.created_at,
|
||||
};
|
||||
|
||||
if (body.description !== undefined) {
|
||||
updatedRecord.description = typeof body.description === 'string' ? body.description : existing.description;
|
||||
}
|
||||
|
||||
if (body.graph !== undefined) {
|
||||
updatedRecord.graph = body.graph;
|
||||
}
|
||||
|
||||
await c.env.WEBHOOKS.put(token, JSON.stringify(updatedRecord));
|
||||
|
||||
const baseUrl = new URL(c.req.url).origin;
|
||||
return c.json({
|
||||
token,
|
||||
webhook_url: `${baseUrl}/webhooks/${token}/trigger`,
|
||||
description: updatedRecord.description,
|
||||
created_at: updatedRecord.created_at,
|
||||
updated: true,
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /webhooks/:token — 刪除 Webhook
|
||||
webhooksCrudRouter.delete('/webhooks/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
if (!token || token.length < 16) {
|
||||
return c.json({ error: 'invalid token' }, 400);
|
||||
}
|
||||
|
||||
const existing = await c.env.WEBHOOKS.get(token, 'text');
|
||||
if (!existing) return c.json({ error: 'webhook not found' }, 404);
|
||||
|
||||
await c.env.WEBHOOKS.delete(token);
|
||||
return c.json({ deleted: true, token });
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { validateAndParseWebhook } from '../actions/webhook-handlers';
|
||||
|
||||
export const webhooksListRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// GET /webhooks — 列出所有 Webhooks(需要授權標頭)
|
||||
webhooksListRouter.get('/webhooks', async (c) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader) {
|
||||
return c.json({ error: 'unauthorized: missing Authorization header' }, 401);
|
||||
}
|
||||
|
||||
const list = await c.env.WEBHOOKS.list();
|
||||
const webhooks = [];
|
||||
|
||||
for (const key of list.keys) {
|
||||
const raw = await c.env.WEBHOOKS.get(key.name, 'text');
|
||||
if (!raw) continue;
|
||||
|
||||
const record = await validateAndParseWebhook(raw);
|
||||
if (!record) continue;
|
||||
|
||||
webhooks.push({
|
||||
token: key.name,
|
||||
description: record.description,
|
||||
created_at: record.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ webhooks, total: webhooks.length });
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { generateToken, validateAndParseWebhook, executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
import { resolveWebhookGraph } from '../actions/webhook-graph-resolver';
|
||||
|
||||
export const webhooksRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
type WebhookRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
description: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// POST /webhooks — 接受 graph、triplets 或直接 nodes/edges
|
||||
webhooksRouter.post('/webhooks', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) return c.json({ error: 'invalid json' }, 400);
|
||||
|
||||
const description = typeof body.description === 'string' ? body.description : '';
|
||||
const resolved = await resolveWebhookGraph(body as Record<string, unknown>, description, c.env);
|
||||
|
||||
if (resolved.error) {
|
||||
const statusCode = resolved.missingNodes ? 422 : 400;
|
||||
return c.json(
|
||||
{ error: resolved.error, ...(resolved.missingNodes && { missing: resolved.missingNodes }) },
|
||||
statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const record: WebhookRecord = {
|
||||
graph: resolved.resolvedGraph,
|
||||
description,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await c.env.WEBHOOKS.put(token, JSON.stringify(record));
|
||||
|
||||
const baseUrl = new URL(c.req.url).origin;
|
||||
return c.json({
|
||||
token,
|
||||
webhook_url: `${baseUrl}/webhooks/${token}/trigger`,
|
||||
description: record.description,
|
||||
created_at: record.created_at,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// POST /webhooks/:token/trigger — 觸發執行
|
||||
webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
if (!token || token.length < 16) {
|
||||
return c.json({ error: 'invalid token' }, 400);
|
||||
}
|
||||
|
||||
const raw = await c.env.WEBHOOKS.get(token, 'text');
|
||||
if (!raw) return c.json({ error: 'webhook not found' }, 404);
|
||||
|
||||
const record = await validateAndParseWebhook(raw);
|
||||
if (!record) return c.json({ error: 'webhook 定義損毀' }, 500);
|
||||
|
||||
let triggerContext: Record<string, unknown> = {};
|
||||
try {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (body && typeof body === 'object') {
|
||||
triggerContext = body as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// 無 body 時使用空 context
|
||||
}
|
||||
|
||||
const result = await executeWebhookGraph(c.env, record.graph, triggerContext, token);
|
||||
return c.json(result, result.success ? 200 : 500);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// arcrun cypher-executor 型別定義
|
||||
|
||||
export type Bindings = {
|
||||
// KV Context Store:節點 output 透過 KV 傳遞,解決同名欄位衝突
|
||||
EXEC_CONTEXT: KVNamespace;
|
||||
// Webhook Store:key = workflow name,value = Workflow JSON
|
||||
WEBHOOKS: KVNamespace;
|
||||
// Credential Store:AES-GCM 加密存放用戶 API token
|
||||
CREDENTIALS_KV: KVNamespace;
|
||||
// R2 Bucket:WASM 零件二進位
|
||||
WASM_BUCKET: R2Bucket;
|
||||
// Workers AI
|
||||
AI: Ai;
|
||||
// 環境變數
|
||||
ENVIRONMENT: string;
|
||||
ENCRYPTION_KEY: string; // hex-encoded 256-bit AES key(wrangler secret)
|
||||
MULTI_TENANT?: string; // "false" = Self-hosted 單租戶模式,預設 "true"
|
||||
};
|
||||
|
||||
// 圖結構定義
|
||||
export type GraphNode = {
|
||||
id: string;
|
||||
type: 'Input' | 'Component' | 'Output';
|
||||
componentId?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type EdgeType =
|
||||
| 'PIPE' | 'IF' | 'FOREACH' | 'CONTINUE' // 現有
|
||||
| 'IS_A' | 'ON_SUCCESS' | 'ON_FAIL' // 執行語意
|
||||
| 'ON_CLICK' | 'CALLS_SUBFLOW' // 觸發語意
|
||||
| 'CONTAINS' | 'HAS_STYLE' | 'HAS_BEHAVIOR'; // 結構語意(記錄圖結構,不執行)
|
||||
|
||||
export type GraphEdge = {
|
||||
from: string;
|
||||
to: string;
|
||||
type: EdgeType;
|
||||
condition?: string; // IF 的條件表達式
|
||||
iterator?: string; // FOREACH 的迭代變數名
|
||||
};
|
||||
|
||||
export type ExecutionGraph = {
|
||||
id: string;
|
||||
name: string;
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
};
|
||||
|
||||
// 執行結果
|
||||
export type ExecutionResult = {
|
||||
success: boolean;
|
||||
data: unknown;
|
||||
trace: TraceStep[];
|
||||
duration_ms: number;
|
||||
};
|
||||
|
||||
export type TraceStep = {
|
||||
nodeId: string;
|
||||
type: string;
|
||||
input: unknown;
|
||||
output: unknown;
|
||||
duration_ms: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// 零件執行器介面(直接可執行函數,不用動態 eval)
|
||||
export type ComponentRunner = (context: unknown) => unknown | Promise<unknown>;
|
||||
|
||||
// KV Context Store(BUILD-006):節點 output 命名空間前綴
|
||||
// KV key 格式:{run_id}:node:{node_id} value 是節點 output 的 JSON 字串
|
||||
// TTL = 3600 秒(1 小時),執行後自動清除
|
||||
export type KVContextStore = {
|
||||
runId: string;
|
||||
kv: KVNamespace;
|
||||
};
|
||||
|
||||
/** 從 KV 讀取節點 output(不存在時回傳 undefined)*/
|
||||
export async function kvGetNodeOutput(store: KVContextStore, nodeId: string): Promise<Record<string, unknown> | undefined> {
|
||||
try {
|
||||
const val = await store.kv.get(`${store.runId}:node:${nodeId}`, 'json');
|
||||
return val as Record<string, unknown> | undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 執行失敗時拋出的自訂 Error,攜帶完整 trace 與失敗節點資訊 */
|
||||
export class ExecutionError extends Error {
|
||||
readonly failed_node: string;
|
||||
readonly failed_input: unknown;
|
||||
readonly trace: TraceStep[];
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
failed_node: string,
|
||||
failed_input: unknown,
|
||||
trace: TraceStep[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ExecutionError';
|
||||
this.failed_node = failed_node;
|
||||
this.failed_input = failed_input;
|
||||
this.trace = trace;
|
||||
}
|
||||
}
|
||||
|
||||
/** 將節點 output 寫入 KV(TTL 1 小時)*/
|
||||
export async function kvSetNodeOutput(store: KVContextStore, nodeId: string, output: unknown): Promise<void> {
|
||||
try {
|
||||
await store.kv.put(
|
||||
`${store.runId}:node:${nodeId}`,
|
||||
JSON.stringify(output),
|
||||
{ expirationTtl: 3600 },
|
||||
);
|
||||
} catch {
|
||||
// KV 寫入失敗不影響執行(fallback 到記憶體 merge)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Cypher Executor 端到端測試
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('GET /', () => {
|
||||
it('回傳服務狀態', async () => {
|
||||
const res = await SELF.fetch('http://localhost/');
|
||||
const data = await res.json() as Record<string, unknown>;
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.service).toBe('arcrun-cypher-executor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /validate', () => {
|
||||
it('驗證合法的圖定義', async () => {
|
||||
const res = await SELF.fetch('http://localhost/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: 'test-graph',
|
||||
name: '測試圖',
|
||||
nodes: [
|
||||
{ id: 'n1', type: 'Input' },
|
||||
{ id: 'n2', type: 'Output' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'n1', to: 'n2', type: 'PIPE' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const data = await res.json() as { valid: boolean };
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('偵測無效邊', async () => {
|
||||
const res = await SELF.fetch('http://localhost/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: 'bad-graph',
|
||||
name: '壞圖',
|
||||
nodes: [{ id: 'n1', type: 'Input' }],
|
||||
edges: [{ from: 'n1', to: 'n999', type: 'PIPE' }],
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /execute', () => {
|
||||
it('PIPE 鏈: Input → passthrough → Output', async () => {
|
||||
const res = await SELF.fetch('http://localhost/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
graph: {
|
||||
id: 'g1',
|
||||
name: 'PIPE 測試',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: { message: 'hello' } },
|
||||
{ id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
|
||||
{ id: 'output', type: 'Output' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'pass', type: 'PIPE' },
|
||||
{ from: 'pass', to: 'output', type: 'PIPE' },
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
}),
|
||||
});
|
||||
const data = await res.json() as { success: boolean; data: { message: string }; trace: unknown[] };
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.message).toBe('hello');
|
||||
expect(data.trace.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('Component 執行: uppercase 轉換', async () => {
|
||||
const res = await SELF.fetch('http://localhost/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
graph: {
|
||||
id: 'g2',
|
||||
name: 'uppercase 測試',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: { text: 'hello world' } },
|
||||
{ id: 'upper', type: 'Component', componentId: 'comp_uppercase' },
|
||||
{ id: 'output', type: 'Output' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'upper', type: 'PIPE' },
|
||||
{ from: 'upper', to: 'output', type: 'PIPE' },
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
}),
|
||||
});
|
||||
const data = await res.json() as { success: boolean; data: { text: string } };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.text).toBe('HELLO WORLD');
|
||||
});
|
||||
|
||||
it('PIPE 鏈: 多層 counter 累加', async () => {
|
||||
const res = await SELF.fetch('http://localhost/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
graph: {
|
||||
id: 'g3',
|
||||
name: 'counter 測試',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: { count: 0 } },
|
||||
{ id: 'c1', type: 'Component', componentId: 'comp_counter' },
|
||||
{ id: 'c2', type: 'Component', componentId: 'comp_counter' },
|
||||
{ id: 'c3', type: 'Component', componentId: 'comp_counter' },
|
||||
{ id: 'output', type: 'Output' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'c1', type: 'PIPE' },
|
||||
{ from: 'c1', to: 'c2', type: 'PIPE' },
|
||||
{ from: 'c2', to: 'c3', type: 'PIPE' },
|
||||
{ from: 'c3', to: 'output', type: 'PIPE' },
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
}),
|
||||
});
|
||||
const data = await res.json() as { success: boolean; data: { count: number } };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.count).toBe(3);
|
||||
});
|
||||
|
||||
it('IF 條件分支', async () => {
|
||||
const res = await SELF.fetch('http://localhost/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
graph: {
|
||||
id: 'g4',
|
||||
name: 'IF 測試',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: { valid: true, text: 'go' } },
|
||||
{ id: 'upper', type: 'Component', componentId: 'comp_uppercase' },
|
||||
{ id: 'output', type: 'Output' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'upper', type: 'IF', condition: 'result.valid === true' },
|
||||
{ from: 'upper', to: 'output', type: 'PIPE' },
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
}),
|
||||
});
|
||||
const data = await res.json() as { success: boolean; data: { text: string } };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.text).toBe('GO');
|
||||
});
|
||||
|
||||
it('不存在的零件回傳失敗', async () => {
|
||||
const res = await SELF.fetch('http://localhost/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
graph: {
|
||||
id: 'g5',
|
||||
name: '失敗測試',
|
||||
nodes: [
|
||||
{ id: 'input', type: 'Input', data: {} },
|
||||
{ id: 'bad', type: 'Component', componentId: 'comp_not_exist' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'input', to: 'bad', type: 'PIPE' },
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
}),
|
||||
});
|
||||
const data = await res.json() as { success: boolean; error: string };
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('不存在');
|
||||
});
|
||||
|
||||
it('缺少必填欄位回傳 400', async () => {
|
||||
const res = await SELF.fetch('http://localhost/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ graph: { id: 'x' } }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* WASI shim 單元測試
|
||||
* Task 2.2 — Requirements: 3.1, 3.3
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createWasiShim } from '../src/lib/wasi-shim';
|
||||
|
||||
// 建立一個最小的 fake WebAssembly.Memory(用 ArrayBuffer 模擬)
|
||||
function makeFakeMemory(size = 65536): WebAssembly.Memory {
|
||||
// 用真實的 WebAssembly.Memory(Vitest 環境支援)
|
||||
return new WebAssembly.Memory({ initial: 1 });
|
||||
}
|
||||
|
||||
/** 在 memory 中寫入 iovec 陣列,回傳 iovs 指標 */
|
||||
function writeIovecs(
|
||||
view: DataView,
|
||||
iovecs: Array<{ buf: number; buf_len: number }>,
|
||||
startPtr: number,
|
||||
): number {
|
||||
for (let i = 0; i < iovecs.length; i++) {
|
||||
view.setUint32(startPtr + i * 8, iovecs[i].buf, true);
|
||||
view.setUint32(startPtr + i * 8 + 4, iovecs[i].buf_len, true);
|
||||
}
|
||||
return startPtr;
|
||||
}
|
||||
|
||||
describe('createWasiShim', () => {
|
||||
describe('fd_read(stdin)', () => {
|
||||
it('一次讀取完整 stdin', () => {
|
||||
const input = '{"key":"value"}';
|
||||
const shim = createWasiShim(input);
|
||||
const mem = makeFakeMemory();
|
||||
shim.setMemory(mem);
|
||||
|
||||
const view = new DataView(mem.buffer);
|
||||
const inputBytes = new TextEncoder().encode(input);
|
||||
|
||||
// 配置 buffer 區域(offset 100)和 iovec(offset 0)
|
||||
const bufPtr = 100;
|
||||
const iovsPtr = 0;
|
||||
const nreadPtr = 50;
|
||||
|
||||
writeIovecs(view, [{ buf: bufPtr, buf_len: inputBytes.length }], iovsPtr);
|
||||
|
||||
const fd_read = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_read;
|
||||
const result = fd_read(0, iovsPtr, 1, nreadPtr);
|
||||
|
||||
expect(result).toBe(0); // ESUCCESS
|
||||
const nread = view.getUint32(nreadPtr, true);
|
||||
expect(nread).toBe(inputBytes.length);
|
||||
|
||||
// 驗證讀取的內容
|
||||
const readBytes = new Uint8Array(mem.buffer, bufPtr, nread);
|
||||
expect(new TextDecoder().decode(readBytes)).toBe(input);
|
||||
});
|
||||
|
||||
it('分多次讀取 stdin', () => {
|
||||
const input = 'hello';
|
||||
const shim = createWasiShim(input);
|
||||
const mem = makeFakeMemory();
|
||||
shim.setMemory(mem);
|
||||
|
||||
const view = new DataView(mem.buffer);
|
||||
const fd_read = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_read;
|
||||
|
||||
// 第一次讀 3 bytes
|
||||
writeIovecs(view, [{ buf: 200, buf_len: 3 }], 0);
|
||||
fd_read(0, 0, 1, 50);
|
||||
expect(view.getUint32(50, true)).toBe(3);
|
||||
expect(new TextDecoder().decode(new Uint8Array(mem.buffer, 200, 3))).toBe('hel');
|
||||
|
||||
// 第二次讀剩餘 2 bytes
|
||||
writeIovecs(view, [{ buf: 300, buf_len: 10 }], 0);
|
||||
fd_read(0, 0, 1, 50);
|
||||
expect(view.getUint32(50, true)).toBe(2);
|
||||
expect(new TextDecoder().decode(new Uint8Array(mem.buffer, 300, 2))).toBe('lo');
|
||||
|
||||
// 第三次讀:stdin 已耗盡,nread = 0
|
||||
writeIovecs(view, [{ buf: 400, buf_len: 10 }], 0);
|
||||
fd_read(0, 0, 1, 50);
|
||||
expect(view.getUint32(50, true)).toBe(0);
|
||||
});
|
||||
|
||||
it('非 stdin fd 回傳 ENOSYS', () => {
|
||||
const shim = createWasiShim('');
|
||||
const mem = makeFakeMemory();
|
||||
shim.setMemory(mem);
|
||||
const view = new DataView(mem.buffer);
|
||||
writeIovecs(view, [{ buf: 100, buf_len: 10 }], 0);
|
||||
|
||||
const fd_read = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_read;
|
||||
expect(fd_read(1, 0, 1, 50)).toBe(76); // ENOSYS
|
||||
expect(fd_read(2, 0, 1, 50)).toBe(76);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fd_write(stdout/stderr)', () => {
|
||||
it('寫入 stdout(fd=1)並可透過 getStdout 讀取', () => {
|
||||
const shim = createWasiShim('');
|
||||
const mem = makeFakeMemory();
|
||||
shim.setMemory(mem);
|
||||
|
||||
const view = new DataView(mem.buffer);
|
||||
const data = new TextEncoder().encode('{"valid":true}');
|
||||
const bufPtr = 100;
|
||||
new Uint8Array(mem.buffer).set(data, bufPtr);
|
||||
writeIovecs(view, [{ buf: bufPtr, buf_len: data.length }], 0);
|
||||
|
||||
const fd_write = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_write;
|
||||
const result = fd_write(1, 0, 1, 50);
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(view.getUint32(50, true)).toBe(data.length);
|
||||
expect(shim.getStdout()).toBe('{"valid":true}');
|
||||
});
|
||||
|
||||
it('寫入 stderr(fd=2)並可透過 getStderr 讀取', () => {
|
||||
const shim = createWasiShim('');
|
||||
const mem = makeFakeMemory();
|
||||
shim.setMemory(mem);
|
||||
|
||||
const view = new DataView(mem.buffer);
|
||||
const data = new TextEncoder().encode('error message');
|
||||
const bufPtr = 100;
|
||||
new Uint8Array(mem.buffer).set(data, bufPtr);
|
||||
writeIovecs(view, [{ buf: bufPtr, buf_len: data.length }], 0);
|
||||
|
||||
const fd_write = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_write;
|
||||
fd_write(2, 0, 1, 50);
|
||||
|
||||
expect(shim.getStderr()).toBe('error message');
|
||||
expect(shim.getStdout()).toBe(''); // stdout 不受影響
|
||||
});
|
||||
|
||||
it('多次寫入 stdout 會合併', () => {
|
||||
const shim = createWasiShim('');
|
||||
const mem = makeFakeMemory();
|
||||
shim.setMemory(mem);
|
||||
|
||||
const view = new DataView(mem.buffer);
|
||||
const fd_write = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_write;
|
||||
|
||||
const write = (text: string, bufPtr: number) => {
|
||||
const data = new TextEncoder().encode(text);
|
||||
new Uint8Array(mem.buffer).set(data, bufPtr);
|
||||
writeIovecs(view, [{ buf: bufPtr, buf_len: data.length }], 0);
|
||||
fd_write(1, 0, 1, 50);
|
||||
};
|
||||
|
||||
write('{"valid":', 100);
|
||||
write('true}', 200);
|
||||
|
||||
expect(shim.getStdout()).toBe('{"valid":true}');
|
||||
});
|
||||
|
||||
it('非 stdout/stderr fd 回傳 ENOSYS', () => {
|
||||
const shim = createWasiShim('');
|
||||
const mem = makeFakeMemory();
|
||||
shim.setMemory(mem);
|
||||
const view = new DataView(mem.buffer);
|
||||
writeIovecs(view, [{ buf: 100, buf_len: 5 }], 0);
|
||||
|
||||
const fd_write = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_write;
|
||||
expect(fd_write(0, 0, 1, 50)).toBe(76); // stdin 不能寫
|
||||
expect(fd_write(3, 0, 1, 50)).toBe(76); // 其他 fd
|
||||
});
|
||||
});
|
||||
|
||||
describe('proc_exit', () => {
|
||||
it('proc_exit(0) 拋出 Error(正常結束)', () => {
|
||||
const shim = createWasiShim('');
|
||||
const proc_exit = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).proc_exit;
|
||||
expect(() => proc_exit(0)).toThrow('wasm exit: 0');
|
||||
});
|
||||
|
||||
it('proc_exit(1) 拋出 Error(錯誤結束)', () => {
|
||||
const shim = createWasiShim('');
|
||||
const proc_exit = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).proc_exit;
|
||||
expect(() => proc_exit(1)).toThrow('wasm exit: 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('其餘 syscall 回傳 ENOSYS', () => {
|
||||
it('fd_seek 回傳 ENOSYS', () => {
|
||||
const shim = createWasiShim('');
|
||||
const fd_seek = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_seek;
|
||||
expect(fd_seek(1, 0, 0, 0)).toBe(76);
|
||||
});
|
||||
|
||||
it('sock_connect 相關 syscall 回傳 ENOSYS', () => {
|
||||
const shim = createWasiShim('');
|
||||
const imports = shim.imports.wasi_snapshot_preview1 as Record<string, Function>;
|
||||
expect(imports.sock_recv()).toBe(76);
|
||||
expect(imports.sock_send()).toBe(76);
|
||||
expect(imports.sock_shutdown()).toBe(76);
|
||||
});
|
||||
|
||||
it('path_open 回傳 ENOSYS', () => {
|
||||
const shim = createWasiShim('');
|
||||
const imports = shim.imports.wasi_snapshot_preview1 as Record<string, Function>;
|
||||
expect(imports.path_open()).toBe(76);
|
||||
expect(imports.path_create_directory()).toBe(76);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMemory 未呼叫時', () => {
|
||||
it('fd_write 在 memory 未設定時拋出錯誤', () => {
|
||||
const shim = createWasiShim('');
|
||||
// 不呼叫 setMemory
|
||||
const fd_write = (shim.imports.wasi_snapshot_preview1 as Record<string, Function>).fd_write;
|
||||
expect(() => fd_write(1, 0, 1, 50)).toThrow('WASI memory not set');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ESNext"],
|
||||
"types": ["@cloudflare/workers-types/2023-07-01", "@cloudflare/vitest-pool-workers"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
|
||||
|
||||
export default defineWorkersConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: './wrangler.test.toml' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
name = "arcrun-cypher-executor"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
# 測試環境不啟用 Service Binding(Miniflare 無法解析外部服務)
|
||||
|
||||
# R2 mock(WASM 執行器測試用)
|
||||
[[r2_buckets]]
|
||||
binding = "WASM_BUCKET"
|
||||
bucket_name = "arcrun-wasm"
|
||||
|
||||
# KV mock(BUILD-006)
|
||||
[[kv_namespaces]]
|
||||
binding = "EXEC_CONTEXT"
|
||||
id = "test-exec-context"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "WEBHOOKS"
|
||||
id = "test-webhooks"
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "test"
|
||||
@@ -0,0 +1,35 @@
|
||||
name = "arcrun-cypher-executor"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-02-19"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
# KV Context Store:節點 output 透過 KV 傳遞,解決同名欄位衝突
|
||||
# TTL 設為 1 小時,執行完後自動清除
|
||||
[[kv_namespaces]]
|
||||
binding = "EXEC_CONTEXT"
|
||||
id = "" # 填入你的 KV Namespace ID
|
||||
|
||||
# Webhook Store:儲存 Workflow 定義,key = workflow name
|
||||
[[kv_namespaces]]
|
||||
binding = "WEBHOOKS"
|
||||
id = "" # 填入你的 KV Namespace ID
|
||||
|
||||
# Credential Store:AES-GCM 加密存放用戶 API token
|
||||
# Standard 模式:供 credential-injector 讀取加密 token
|
||||
[[kv_namespaces]]
|
||||
binding = "CREDENTIALS_KV"
|
||||
id = "" # 填入你的 Credentials KV Namespace ID
|
||||
|
||||
# R2 Bucket:WASM 零件二進位(arcrun.dev 公眾零件庫,或自架時填入自己的 bucket)
|
||||
[[r2_buckets]]
|
||||
binding = "WASM_BUCKET"
|
||||
bucket_name = "arcrun-wasm"
|
||||
|
||||
# Workers AI
|
||||
[ai]
|
||||
binding = "AI"
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "production"
|
||||
# MULTI_TENANT = "true" # Standard 模式(預設);設 "false" 啟用 Self-hosted 單租戶模式
|
||||
# ENCRYPTION_KEY 透過 wrangler secret 設定(hex-encoded 256-bit AES key)
|
||||
@@ -0,0 +1,51 @@
|
||||
canonical_id: "ai_transform_compile"
|
||||
display_name: "AI 轉換編譯"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [description]
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
description: 自然語言描述,如「把日期改成台灣格式」
|
||||
example_input: {}
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
transform_id:
|
||||
type: string
|
||||
fn_preview:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "正常編譯"
|
||||
given: '{"description":"把日期改成台灣格式","example_input":{}}'
|
||||
then_contains: '"transform_id"'
|
||||
- scenario: "缺少 description"
|
||||
given: '{}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [ai, transform, compile, nlp, codegen]
|
||||
description: "Phase 0 stub:接收自然語言描述,回傳 transform_id 和 fn_preview placeholder。Phase 2 接 AI host function。"
|
||||
config_example: |
|
||||
my_ai_compile: # 節點名稱(可自訂)
|
||||
description: "把日期改成台灣格式" # 自然語言轉換描述(必填)
|
||||
example_input: {"date": "2024-01-15"} # 範例輸入,用於輔助 AI 生成(選填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,53 @@
|
||||
// ai_transform_compile — 接收自然語言描述,輸出 transform stub
|
||||
// Phase 0: 輸出 placeholder,Phase 2 再接 AI host function
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Description string `json:"description"`
|
||||
ExampleInput json.RawMessage `json:"example_input"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.Description == "" {
|
||||
writeError("description 必填")
|
||||
return
|
||||
}
|
||||
|
||||
transformID := "at-" + strconv.FormatInt(time.Now().UnixNano()/1e6, 10)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"transform_id": transformID,
|
||||
"fn_preview": "// AI generated\n// TODO: Phase 2 will implement AI-powered code generation\nreturn input;",
|
||||
"description": input.Description,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
canonical_id: "ai_transform_run"
|
||||
display_name: "AI 轉換執行"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [transform_id, input]
|
||||
properties:
|
||||
transform_id:
|
||||
type: string
|
||||
description: 由 ai_transform_compile 回傳的 ID
|
||||
input: {}
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
result: {}
|
||||
transform_id:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "正常執行"
|
||||
given: '{"transform_id":"at-123","input":{"date":"2024-01-15"}}'
|
||||
then_contains: '"transform_id":"at-123"'
|
||||
- scenario: "缺少 transform_id"
|
||||
given: '{"input":{}}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [ai, transform, run, execute]
|
||||
description: "Phase 0 stub:使用 transform_id 執行轉換,目前直接回傳 input。Phase 2 接 AI host function。"
|
||||
config_example: |
|
||||
my_ai_run: # 節點名稱(可自訂)
|
||||
transform_id: "at-abc123" # 由 ai_transform_compile 回傳的轉換 ID(必填)
|
||||
input: {"date": "2024-01-15"} # 要套用轉換的輸入資料(必填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,48 @@
|
||||
// ai_transform_run — 使用已編譯的 transform_id 執行轉換
|
||||
// Phase 0: stub 實作,直接回傳 input
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
TransformID string `json:"transform_id"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.TransformID == "" {
|
||||
writeError("transform_id 必填")
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"result": input.Input,
|
||||
"transform_id": input.TransformID,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
canonical_id: "array_ops"
|
||||
display_name: "陣列操作"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [operation, input]
|
||||
properties:
|
||||
operation:
|
||||
type: string
|
||||
enum: [count, first, last, reverse, sum, average, min, max, sort, unique]
|
||||
input:
|
||||
type: array
|
||||
description: 輸入陣列(元素為數字或字串)
|
||||
args:
|
||||
type: object
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
result: {}
|
||||
operation:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "sort 數字陣列"
|
||||
given: '{"operation":"sort","input":[3,1,2]}'
|
||||
then_contains: '"result":[1,2,3]'
|
||||
- scenario: "sum 操作"
|
||||
given: '{"operation":"sum","input":[1,2,3]}'
|
||||
then_contains: '"result":6'
|
||||
- scenario: "空陣列 first"
|
||||
given: '{"operation":"first","input":[]}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, data, array, list, transform]
|
||||
description: "陣列操作:count/first/last/reverse/sum/average/min/max/sort/unique。"
|
||||
config_example: |
|
||||
my_array_op: # 節點名稱(可自訂)
|
||||
operation: "sort" # 運算類型(必填),可選值:count/first/last/reverse/sum/average/min/max/sort/unique
|
||||
input: [3, 1, 4, 1, 5, 9, 2, 6] # 輸入陣列,元素為數字或字串(必填)
|
||||
args: {} # 操作參數(選填,目前各 operation 不需額外參數)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,205 @@
|
||||
// array_ops — 陣列操作
|
||||
// 支援: count, first, last, reverse, sum, average, min, max, sort, unique
|
||||
// input 陣列元素支援 float64 或 string
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Operation string `json:"operation"`
|
||||
Input []json.RawMessage `json:"input"`
|
||||
Args map[string]string `json:"args"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.Operation == "" {
|
||||
writeError("operation 必填")
|
||||
return
|
||||
}
|
||||
|
||||
items := input.Input
|
||||
op := input.Operation
|
||||
|
||||
switch op {
|
||||
case "count":
|
||||
writeResult(op, len(items))
|
||||
case "first":
|
||||
if len(items) == 0 {
|
||||
writeError("陣列為空")
|
||||
return
|
||||
}
|
||||
writeResultRaw(op, items[0])
|
||||
case "last":
|
||||
if len(items) == 0 {
|
||||
writeError("陣列為空")
|
||||
return
|
||||
}
|
||||
writeResultRaw(op, items[len(items)-1])
|
||||
case "reverse":
|
||||
reversed := make([]json.RawMessage, len(items))
|
||||
for i, v := range items {
|
||||
reversed[len(items)-1-i] = v
|
||||
}
|
||||
writeResultRaw(op, reversed)
|
||||
case "sum":
|
||||
nums, err := toFloats(items)
|
||||
if err != nil {
|
||||
writeError(err.Error())
|
||||
return
|
||||
}
|
||||
sum := 0.0
|
||||
for _, n := range nums {
|
||||
sum += n
|
||||
}
|
||||
writeResult(op, sum)
|
||||
case "average":
|
||||
nums, err := toFloats(items)
|
||||
if err != nil {
|
||||
writeError(err.Error())
|
||||
return
|
||||
}
|
||||
if len(nums) == 0 {
|
||||
writeError("陣列為空")
|
||||
return
|
||||
}
|
||||
sum := 0.0
|
||||
for _, n := range nums {
|
||||
sum += n
|
||||
}
|
||||
writeResult(op, sum/float64(len(nums)))
|
||||
case "min":
|
||||
nums, err := toFloats(items)
|
||||
if err != nil {
|
||||
writeError(err.Error())
|
||||
return
|
||||
}
|
||||
if len(nums) == 0 {
|
||||
writeError("陣列為空")
|
||||
return
|
||||
}
|
||||
m := math.MaxFloat64
|
||||
for _, n := range nums {
|
||||
if n < m {
|
||||
m = n
|
||||
}
|
||||
}
|
||||
writeResult(op, m)
|
||||
case "max":
|
||||
nums, err := toFloats(items)
|
||||
if err != nil {
|
||||
writeError(err.Error())
|
||||
return
|
||||
}
|
||||
if len(nums) == 0 {
|
||||
writeError("陣列為空")
|
||||
return
|
||||
}
|
||||
m := -math.MaxFloat64
|
||||
for _, n := range nums {
|
||||
if n > m {
|
||||
m = n
|
||||
}
|
||||
}
|
||||
writeResult(op, m)
|
||||
case "sort":
|
||||
// 嘗試數字排序,失敗則字串排序
|
||||
nums, err := toFloats(items)
|
||||
if err == nil {
|
||||
sort.Float64s(nums)
|
||||
writeResult(op, nums)
|
||||
return
|
||||
}
|
||||
strs, err2 := toStrings(items)
|
||||
if err2 != nil {
|
||||
writeError("sort 只支援數字或字串陣列")
|
||||
return
|
||||
}
|
||||
sort.Strings(strs)
|
||||
writeResult(op, strs)
|
||||
case "unique":
|
||||
seen := map[string]bool{}
|
||||
var result []json.RawMessage
|
||||
for _, item := range items {
|
||||
key := string(item)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
if result == nil {
|
||||
result = []json.RawMessage{}
|
||||
}
|
||||
writeResultRaw(op, result)
|
||||
default:
|
||||
writeError("不支援的 operation: " + op)
|
||||
}
|
||||
}
|
||||
|
||||
func toFloats(items []json.RawMessage) ([]float64, error) {
|
||||
nums := make([]float64, len(items))
|
||||
for i, item := range items {
|
||||
var n float64
|
||||
if err := json.Unmarshal(item, &n); err != nil {
|
||||
return nil, &parseError{"元素 " + strconv.Itoa(i) + " 不是數字"}
|
||||
}
|
||||
nums[i] = n
|
||||
}
|
||||
return nums, nil
|
||||
}
|
||||
|
||||
func toStrings(items []json.RawMessage) ([]string, error) {
|
||||
strs := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
var s string
|
||||
if err := json.Unmarshal(item, &s); err != nil {
|
||||
return nil, &parseError{"元素 " + strconv.Itoa(i) + " 不是字串"}
|
||||
}
|
||||
strs[i] = s
|
||||
}
|
||||
return strs, nil
|
||||
}
|
||||
|
||||
type parseError struct{ msg string }
|
||||
|
||||
func (e *parseError) Error() string { return e.msg }
|
||||
|
||||
func writeResult(op string, result interface{}) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"result": result, "operation": op},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeResultRaw(op string, result interface{}) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"result": result, "operation": op},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
canonical_id: "cron"
|
||||
display_name: "定時排程"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [cron_expr]
|
||||
properties:
|
||||
cron_expr:
|
||||
type: string
|
||||
description: 標準 5 欄位 cron expression,如 0 9 * * *
|
||||
description:
|
||||
type: string
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
cron_id:
|
||||
type: string
|
||||
cron_expr:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "有效 cron expression"
|
||||
given: '{"cron_expr":"0 9 * * *","description":"每天早上9點"}'
|
||||
then_contains: '"enabled":true'
|
||||
- scenario: "無效 cron expression(欄位數不對)"
|
||||
given: '{"cron_expr":"0 9 * *"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 cron_expr"
|
||||
given: '{}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, cron, schedule, trigger, timer]
|
||||
description: "驗證 cron expression 格式並回傳 cron_id。實際排程由 Cypher Executor 負責。"
|
||||
config_example: |
|
||||
my_cron: # 節點名稱(可自訂)
|
||||
cron_expr: "0 9 * * *" # 標準 5 欄位 cron 表達式(必填),如:每天早上 9 點
|
||||
description: "每天早上9點執行" # 排程說明文字(選填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,123 @@
|
||||
// cron — 驗證 cron expression 格式,回傳 cron_id
|
||||
// 實際排程由 Cypher Executor 負責
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
CronExpr string `json:"cron_expr"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.CronExpr == "" {
|
||||
writeError("cron_expr 必填")
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateCronExpr(input.CronExpr); err != nil {
|
||||
writeError("無效的 cron expression: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cronID := "cron-" + strconv.FormatInt(time.Now().UnixNano()/1e6, 10)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"cron_id": cronID,
|
||||
"cron_expr": input.CronExpr,
|
||||
"enabled": true,
|
||||
"description": input.Description,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// validateCronExpr — 驗證標準 5 欄位 cron expression
|
||||
func validateCronExpr(expr string) error {
|
||||
fields := strings.Fields(expr)
|
||||
if len(fields) != 5 {
|
||||
return &cronError{"需要 5 個欄位(分 時 日 月 週),實際: " + strconv.Itoa(len(fields))}
|
||||
}
|
||||
|
||||
// 各欄位範圍: 分(0-59), 時(0-23), 日(1-31), 月(1-12), 週(0-7)
|
||||
ranges := [][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 7}}
|
||||
names := []string{"分鐘", "小時", "日", "月", "星期"}
|
||||
|
||||
for i, field := range fields {
|
||||
if err := validateCronField(field, ranges[i][0], ranges[i][1], names[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCronField(field string, min, max int, name string) error {
|
||||
if field == "*" {
|
||||
return nil
|
||||
}
|
||||
// 支援 */n 格式
|
||||
if strings.HasPrefix(field, "*/") {
|
||||
n, err := strconv.Atoi(field[2:])
|
||||
if err != nil || n <= 0 {
|
||||
return &cronError{name + " 步進值無效: " + field}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 支援 a-b 範圍
|
||||
if strings.Contains(field, "-") {
|
||||
parts := strings.SplitN(field, "-", 2)
|
||||
a, err1 := strconv.Atoi(parts[0])
|
||||
b, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil || a < min || b > max || a > b {
|
||||
return &cronError{name + " 範圍無效: " + field}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 支援逗號分隔
|
||||
if strings.Contains(field, ",") {
|
||||
for _, part := range strings.Split(field, ",") {
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil || n < min || n > max {
|
||||
return &cronError{name + " 值無效: " + part}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 單一數字
|
||||
n, err := strconv.Atoi(field)
|
||||
if err != nil || n < min || n > max {
|
||||
return &cronError{name + " 值超出範圍 [" + strconv.Itoa(min) + "-" + strconv.Itoa(max) + "]: " + field}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type cronError struct{ msg string }
|
||||
|
||||
func (e *cronError) Error() string { return e.msg }
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
canonical_id: "date_ops"
|
||||
display_name: "日期操作"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [operation]
|
||||
properties:
|
||||
operation:
|
||||
type: string
|
||||
enum: [now, format, parse]
|
||||
input:
|
||||
type: string
|
||||
description: ISO 日期字串(now 操作可省略)
|
||||
args:
|
||||
type: object
|
||||
properties:
|
||||
layout:
|
||||
type: string
|
||||
description: Go time layout(如 2006-01-02)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
result: {}
|
||||
operation:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "now 操作"
|
||||
given: '{"operation":"now"}'
|
||||
then_contains: '"success":true'
|
||||
- scenario: "parse 操作"
|
||||
given: '{"operation":"parse","input":"2024-01-15T10:30:00Z"}'
|
||||
then_contains: '"year":2024'
|
||||
- scenario: "無效日期"
|
||||
given: '{"operation":"parse","input":"not-a-date"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, data, date, time, transform]
|
||||
description: "日期操作:now(當前時間)、format(格式化)、parse(解析 ISO 字串)。"
|
||||
config_example: |
|
||||
my_date_op: # 節點名稱(可自訂)
|
||||
operation: "format" # 運算類型(必填),可選值:now/format/parse
|
||||
input: "2024-01-15T10:30:00Z" # ISO 日期字串(now 操作可省略,其餘必填)
|
||||
args: # 操作參數(選填)
|
||||
layout: "2006-01-02" # format 用:Go time layout 格式字串
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,103 @@
|
||||
// date_ops — 日期操作
|
||||
// 支援: now, format, parse
|
||||
// TinyGo time 套件支援有限,只實作基本功能
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Args struct {
|
||||
Layout string `json:"layout"`
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
Operation string `json:"operation"`
|
||||
Input string `json:"input"`
|
||||
Args Args `json:"args"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.Operation == "" {
|
||||
writeError("operation 必填")
|
||||
return
|
||||
}
|
||||
|
||||
switch input.Operation {
|
||||
case "now":
|
||||
result := time.Now().UTC().Format(time.RFC3339)
|
||||
writeResult("now", result)
|
||||
case "format":
|
||||
if input.Input == "" {
|
||||
writeError("format 需要 input 日期字串")
|
||||
return
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, input.Input)
|
||||
if err != nil {
|
||||
// 嘗試其他格式
|
||||
t, err = time.Parse("2006-01-02", input.Input)
|
||||
if err != nil {
|
||||
writeError("無法解析日期: " + err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
layout := input.Args.Layout
|
||||
if layout == "" {
|
||||
layout = time.RFC3339
|
||||
}
|
||||
writeResult("format", t.Format(layout))
|
||||
case "parse":
|
||||
if input.Input == "" {
|
||||
writeError("parse 需要 input 日期字串")
|
||||
return
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, input.Input)
|
||||
if err != nil {
|
||||
t, err = time.Parse("2006-01-02", input.Input)
|
||||
if err != nil {
|
||||
writeError("無法解析日期: " + err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeResult("parse", map[string]interface{}{
|
||||
"iso": t.UTC().Format(time.RFC3339),
|
||||
"year": t.Year(),
|
||||
"month": int(t.Month()),
|
||||
"day": t.Day(),
|
||||
"hour": t.Hour(),
|
||||
"min": t.Minute(),
|
||||
"sec": t.Second(),
|
||||
})
|
||||
default:
|
||||
writeError("不支援的 operation: " + input.Operation)
|
||||
}
|
||||
}
|
||||
|
||||
func writeResult(op string, result interface{}) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"result": result, "operation": op},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
canonical_id: "filter"
|
||||
display_name: "過濾陣列"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [items, condition]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
description: 要過濾的陣列
|
||||
condition:
|
||||
type: object
|
||||
required: [key, op, value]
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: 要比較的欄位名稱
|
||||
op:
|
||||
type: string
|
||||
enum: [eq, ne, gt, lt, contains]
|
||||
value:
|
||||
type: string
|
||||
description: 比較值
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
count:
|
||||
type: number
|
||||
gherkin_tests:
|
||||
- scenario: "過濾 status=active 的元素"
|
||||
given: '{"items":[{"status":"active"},{"status":"inactive"}],"condition":{"key":"status","op":"eq","value":"active"}}'
|
||||
then_contains: '{"success":true'
|
||||
- scenario: "空陣列輸入"
|
||||
given: '{"items":[],"condition":{"key":"status","op":"eq","value":"active"}}'
|
||||
then_contains: '{"success":true'
|
||||
- scenario: "缺少 condition.key"
|
||||
given: '{"items":[],"condition":{"op":"eq","value":"x"}}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, filter, array, condition]
|
||||
description: "依條件過濾陣列,回傳符合條件的元素。支援 eq/ne/gt/lt/contains 運算子。"
|
||||
config_example: |
|
||||
my_filter: # 節點名稱(可自訂)
|
||||
items: "{{upstream.results}}" # 要過濾的陣列(必填)
|
||||
condition: # 過濾條件(必填)
|
||||
key: status # 要比較的欄位名稱(必填)
|
||||
op: eq # 運算子:eq / ne / gt / lt / contains(必填)
|
||||
value: active # 比較值(必填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,122 @@
|
||||
// filter — 依條件過濾陣列
|
||||
// op 支援: eq, ne, gt, lt, contains
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Condition struct {
|
||||
Key string `json:"key"`
|
||||
Op string `json:"op"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
Condition Condition `json:"condition"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.Condition.Key == "" {
|
||||
writeError("condition.key 必填")
|
||||
return
|
||||
}
|
||||
|
||||
var filtered []json.RawMessage
|
||||
for _, item := range input.Items {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(item, &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
fieldRaw, ok := obj[input.Condition.Key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if matchCondition(fieldRaw, input.Condition.Op, input.Condition.Value) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
|
||||
if filtered == nil {
|
||||
filtered = []json.RawMessage{}
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"items": filtered,
|
||||
"count": len(filtered),
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func matchCondition(fieldRaw json.RawMessage, op, expected string) bool {
|
||||
// 取得欄位字串值
|
||||
var strVal string
|
||||
var numVal float64
|
||||
isNum := false
|
||||
|
||||
// 嘗試解析為數字
|
||||
if err := json.Unmarshal(fieldRaw, &numVal); err == nil {
|
||||
isNum = true
|
||||
strVal = strconv.FormatFloat(numVal, 'f', -1, 64)
|
||||
} else {
|
||||
// 嘗試解析為字串
|
||||
if err := json.Unmarshal(fieldRaw, &strVal); err != nil {
|
||||
strVal = string(fieldRaw)
|
||||
}
|
||||
}
|
||||
|
||||
switch strings.ToLower(op) {
|
||||
case "eq":
|
||||
return strVal == expected
|
||||
case "ne":
|
||||
return strVal != expected
|
||||
case "gt":
|
||||
if !isNum {
|
||||
return false
|
||||
}
|
||||
threshold, err := strconv.ParseFloat(expected, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return numVal > threshold
|
||||
case "lt":
|
||||
if !isNum {
|
||||
return false
|
||||
}
|
||||
threshold, err := strconv.ParseFloat(expected, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return numVal < threshold
|
||||
case "contains":
|
||||
return strings.Contains(strVal, expected)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
canonical_id: "foreach_control"
|
||||
display_name: "迴圈控制"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
description: 要迭代的陣列
|
||||
item_key:
|
||||
type: string
|
||||
description: 每個元素注入的變數名,預設 item
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
count:
|
||||
type: number
|
||||
current_index:
|
||||
type: number
|
||||
current_item: {}
|
||||
item_key:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "正常迭代"
|
||||
given: '{"items":[1,2,3],"item_key":"item"}'
|
||||
then_contains: '"current_index":0'
|
||||
- scenario: "空陣列"
|
||||
given: '{"items":[]}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, control, foreach, loop, iteration]
|
||||
description: "輸出第一個元素供 Cypher Executor 迭代,current_index 從 0 開始。"
|
||||
config_example: |
|
||||
my_loop: # 節點名稱(可自訂)
|
||||
items: "{{upstream.results}}" # 要迭代的陣列(必填)
|
||||
item_key: item # 每個元素注入的變數名,預設 item(選填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,55 @@
|
||||
// foreach_control — 輸出第一個元素,Cypher Executor 負責迭代
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
ItemKey string `json:"item_key"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if len(input.Items) == 0 {
|
||||
writeError("items 不能為空")
|
||||
return
|
||||
}
|
||||
|
||||
itemKey := input.ItemKey
|
||||
if itemKey == "" {
|
||||
itemKey = "item"
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"items": input.Items,
|
||||
"count": len(input.Items),
|
||||
"current_index": 0,
|
||||
"current_item": input.Items[0],
|
||||
"item_key": itemKey,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
canonical_id: "gmail"
|
||||
display_name: "Gmail 發信"
|
||||
category: "api"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [to, subject, body, access_token]
|
||||
properties:
|
||||
to:
|
||||
type: string
|
||||
description: 收件人 Email
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
access_token:
|
||||
type: string
|
||||
description: Google OAuth access token
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 access_token"
|
||||
given: '{"to":"test@example.com","subject":"test","body":"hello"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 to"
|
||||
given: '{"subject":"test","body":"hello","access_token":"token"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [integration, google, gmail, email, oauth]
|
||||
description: "透過 Gmail API 發送 Email。透過 host function 呼叫,需要 Google OAuth access_token。"
|
||||
credentials_required:
|
||||
- key: gmail_token
|
||||
type: google_oauth
|
||||
description: "Google OAuth access token(gmail.send scope)"
|
||||
inject_as: access_token
|
||||
config_example: |
|
||||
send_email: # 節點名稱(可自訂)
|
||||
to: "" # 收件人 Email(必填)
|
||||
subject: "" # 主旨(必填)
|
||||
body: "" # 內文(必填)
|
||||
# access_token 由 credentials.yaml 的 gmail_token 自動注入
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,139 @@
|
||||
// gmail — 透過 Gmail API 發送 Email
|
||||
// 透過 host function 呼叫 Gmail API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.To == "" {
|
||||
writeError("to 必填")
|
||||
return
|
||||
}
|
||||
if input.Subject == "" {
|
||||
writeError("subject 必填")
|
||||
return
|
||||
}
|
||||
if input.AccessToken == "" {
|
||||
writeError("access_token 必填")
|
||||
return
|
||||
}
|
||||
|
||||
// 建立 RFC 2822 格式的 email,base64url 編碼
|
||||
emailLines := []string{
|
||||
"To: " + input.To,
|
||||
"Subject: " + input.Subject,
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
input.Body,
|
||||
}
|
||||
emailRaw := strings.Join(emailLines, "\r\n")
|
||||
encoded := base64URLEncode([]byte(emailRaw))
|
||||
|
||||
bodyData, _ := json.Marshal(map[string]string{"raw": encoded})
|
||||
|
||||
apiURL := "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
|
||||
method := "POST"
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + input.AccessToken,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
headersJSON, _ := json.Marshal(headers)
|
||||
|
||||
urlBytes := []byte(apiURL)
|
||||
methodBytes := []byte(method)
|
||||
bodyBytes := bodyData
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
result := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
uintptr(unsafe.Pointer(&headersJSON[0])), uint32(len(headersJSON)),
|
||||
uintptr(unsafe.Pointer(&bodyBytes[0])), uint32(len(bodyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("Gmail API 呼叫失敗")
|
||||
return
|
||||
}
|
||||
|
||||
responseStr := string(outBuf[:outLen])
|
||||
var responseData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(responseStr), &responseData); err != nil {
|
||||
responseData = map[string]interface{}{"raw": responseStr}
|
||||
}
|
||||
|
||||
messageID, _ := responseData["id"].(string)
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"message_id": messageID},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// base64URLEncode — 不依賴 encoding/base64(TinyGo 相容)
|
||||
func base64URLEncode(data []byte) string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
var sb strings.Builder
|
||||
for i := 0; i < len(data); i += 3 {
|
||||
b0 := data[i]
|
||||
var b1, b2 byte
|
||||
if i+1 < len(data) {
|
||||
b1 = data[i+1]
|
||||
}
|
||||
if i+2 < len(data) {
|
||||
b2 = data[i+2]
|
||||
}
|
||||
sb.WriteByte(chars[b0>>2])
|
||||
sb.WriteByte(chars[((b0&0x3)<<4)|(b1>>4)])
|
||||
if i+1 < len(data) {
|
||||
sb.WriteByte(chars[((b1&0xf)<<2)|(b2>>6)])
|
||||
}
|
||||
if i+2 < len(data) {
|
||||
sb.WriteByte(chars[b2&0x3f])
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
canonical_id: "google_sheets"
|
||||
display_name: "Google 試算表"
|
||||
category: "api"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [spreadsheet_id, range, access_token]
|
||||
properties:
|
||||
spreadsheet_id:
|
||||
type: string
|
||||
range:
|
||||
type: string
|
||||
description: 如 Sheet1!A1:B10
|
||||
action:
|
||||
type: string
|
||||
enum: [read, write]
|
||||
default: read
|
||||
values:
|
||||
type: array
|
||||
description: write 時的資料(二維陣列)
|
||||
access_token:
|
||||
type: string
|
||||
description: Google OAuth access token
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
values: {}
|
||||
range:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 access_token"
|
||||
given: '{"spreadsheet_id":"abc","range":"Sheet1!A1"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 spreadsheet_id"
|
||||
given: '{"range":"Sheet1!A1","access_token":"token"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [integration, google, sheets, oauth]
|
||||
description: "讀取或寫入 Google 試算表。透過 host function 呼叫 Google Sheets API,需要 OAuth access_token。"
|
||||
credentials_required:
|
||||
- key: google_oauth
|
||||
type: google_oauth
|
||||
description: "Google OAuth access token(spreadsheets scope)"
|
||||
inject_as: access_token
|
||||
config_example: |
|
||||
read_sheet: # 節點名稱(可自訂)
|
||||
spreadsheet_id: "" # 試算表 ID(必填)
|
||||
range: "" # 範圍,如 Sheet1!A1:B10(必填)
|
||||
# access_token 由 credentials.yaml 的 google_oauth 自動注入
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,135 @@
|
||||
// google_sheets — 讀取或寫入 Google 試算表
|
||||
// 透過 host function 呼叫 Google Sheets API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
SpreadsheetID string `json:"spreadsheet_id"`
|
||||
Range string `json:"range"`
|
||||
Action string `json:"action"`
|
||||
Values [][]json.RawMessage `json:"values"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.SpreadsheetID == "" {
|
||||
writeError("spreadsheet_id 必填")
|
||||
return
|
||||
}
|
||||
if input.Range == "" {
|
||||
writeError("range 必填")
|
||||
return
|
||||
}
|
||||
if input.AccessToken == "" {
|
||||
writeError("access_token 必填")
|
||||
return
|
||||
}
|
||||
|
||||
action := input.Action
|
||||
if action == "" {
|
||||
action = "read"
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + input.AccessToken,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
headersJSON, _ := json.Marshal(headers)
|
||||
|
||||
var apiURL, method, bodyStr string
|
||||
|
||||
switch action {
|
||||
case "read":
|
||||
apiURL = "https://sheets.googleapis.com/v4/spreadsheets/" + input.SpreadsheetID + "/values/" + input.Range
|
||||
method = "GET"
|
||||
bodyStr = ""
|
||||
case "write":
|
||||
apiURL = "https://sheets.googleapis.com/v4/spreadsheets/" + input.SpreadsheetID + "/values/" + input.Range + "?valueInputOption=RAW"
|
||||
method = "PUT"
|
||||
bodyData, _ := json.Marshal(map[string]interface{}{
|
||||
"range": input.Range,
|
||||
"majorDimension": "ROWS",
|
||||
"values": input.Values,
|
||||
})
|
||||
bodyStr = string(bodyData)
|
||||
default:
|
||||
writeError("不支援的 action: " + action)
|
||||
return
|
||||
}
|
||||
|
||||
urlBytes := []byte(apiURL)
|
||||
methodBytes := []byte(method)
|
||||
bodyBytes := []byte(bodyStr)
|
||||
if len(bodyBytes) == 0 {
|
||||
bodyBytes = []byte{}
|
||||
}
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
var bodyPtr uintptr
|
||||
if len(bodyBytes) > 0 {
|
||||
bodyPtr = uintptr(unsafe.Pointer(&bodyBytes[0]))
|
||||
}
|
||||
|
||||
result := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
uintptr(unsafe.Pointer(&headersJSON[0])), uint32(len(headersJSON)),
|
||||
bodyPtr, uint32(len(bodyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("Google Sheets API 呼叫失敗")
|
||||
return
|
||||
}
|
||||
|
||||
responseStr := string(outBuf[:outLen])
|
||||
var responseData interface{}
|
||||
if err := json.Unmarshal([]byte(responseStr), &responseData); err != nil {
|
||||
responseData = responseStr
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"values": responseData,
|
||||
"range": input.Range,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
canonical_id: "http_request"
|
||||
display_name: "HTTP 請求"
|
||||
category: "api"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [url]
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
description: 目標 URL(必填)
|
||||
method:
|
||||
type: string
|
||||
description: HTTP 方法(GET / POST / PUT / DELETE 等),預設 GET
|
||||
default: GET
|
||||
headers:
|
||||
type: object
|
||||
description: 自訂 HTTP headers(key-value 物件)
|
||||
additionalProperties:
|
||||
type: string
|
||||
body:
|
||||
description: 請求 body(任意 JSON)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
body:
|
||||
type: string
|
||||
description: HTTP 回應 body(字串)
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 url"
|
||||
given: '{"method":"GET"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "基本 GET 請求"
|
||||
given: '{"url":"https://example.com"}'
|
||||
then_contains: '{"success":true'
|
||||
tags: [integration, http, request, api]
|
||||
description: "發送任意 HTTP 請求並回傳 status 與 body。透過 host function 呼叫,.wasm 本身不含網路 syscall。headers 由用戶手動填入。"
|
||||
config_example: |
|
||||
http_call: # 節點名稱(可自訂)
|
||||
url: "" # 目標 URL(必填)
|
||||
method: "GET" # HTTP 方法(選填,預設 GET)
|
||||
headers: # 自訂 headers(選填,用戶手動填入)
|
||||
Content-Type: "application/json"
|
||||
Authorization: "Bearer <your_token>"
|
||||
body: {} # 請求 body(選填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user