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:
Claude
2026-04-16 04:06:25 +00:00
commit 2707fca32b
155 changed files with 17413 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.wrangler/
dist/
*.wasm
credentials.yaml
~/.arcrun/
.env
.env.*
+214
View File
@@ -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
# 每個 scenariogiven 輸入 → 輸出包含 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)
+234
View File
@@ -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、CLIacr | MIT License |
| **Hosted SaaS** | 一行指令取得 API Key,使用 arcrun.dev 的執行引擎與公眾零件庫,credential 永遠在你自己的 CF KV | 免費 |
| **InkStone 付費** | KBDB 向量搜尋、Graph 查詢、Persona SDK、MatchGPT | 付費方案 |
**你的 credential 和 workflow 永遠在你自己的 Cloudflare KVarcrun.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. 初始化 CLISelf-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
+49
View File
@@ -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"
}
}
}
}
+16
View File
@@ -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"
}
}
+43
View File
@@ -0,0 +1,43 @@
// initComponents:把所有內建零件上架到 Component Registryvia 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 };
}
+27
View File
@@ -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;
+156
View File
@@ -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 Storecron: + ai-transform: 前綴)
AI: Ai; // Workers AIai-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_STOREkey = 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_STOREkey = 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' } } } } } },
];
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
},
"include": ["src/**/*"]
}
+26
View File
@@ -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 Storecron: 前綴 = 排程定義,ai-transform: 前綴 = 編譯後轉換函式
[[kv_namespaces]]
binding = "U6U_STORE"
id = "" # 填入你的 KV Namespace ID
# Workers AIai-transform 零件的 compile 階段使用
[ai]
binding = "AI"
# Cron Trigger:每分鐘掃描並執行到期的排程工作流
[triggers]
crons = ["* * * * *"]
[vars]
ENVIRONMENT = "production"
+35
View File
@@ -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"
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* acr creds push [credentials.yaml]
* 讀取 credentials.yaml,加密後上傳至用戶自己的 CF KVkey 格式: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 KVarcrun 不會儲存它們。\n'));
}
+145
View File
@@ -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 KVarcrun 不會儲存它們。\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 TokenKV 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');
}
}
}
+68
View File
@@ -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);
}
}
+82
View File
@@ -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);
}
}
+282
View File
@@ -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;
}
+101
View File
@@ -0,0 +1,101 @@
/**
* acr push <workflow.yaml>
* 解析三元組,轉成執行圖,直接寫入用戶的 USER_KVkey = 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_KVkey = 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`);
}
+75
View File
@@ -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);
}
}
+148
View File
@@ -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];
}
+89
View File
@@ -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);
+115
View File
@@ -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 作為 fallbackdev 模式)
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');
}
+53
View File
@@ -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 Keyak_前綴)
// 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';
}
+82
View File
@@ -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];
}
+17
View File
@@ -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"]
}
+2805
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
}
+1648
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
// createCredential:儲存 credentialname + 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);
}
+28
View File
@@ -0,0 +1,28 @@
// cryptoAES-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:刪除指定 credentialby 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 } });
}
+26
View File
@@ -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 — 取得解密 secretCypher Executor inject 用)
app.post ('/credentials', handleCreateCredential);
app.get ('/credentials', handleListCredentials);
app.delete('/credentials/:name', handleDeleteCredential);
app.get ('/credentials/:name/secret', handleGetSecret);
export default app;
+24
View File
@@ -0,0 +1,24 @@
// u6u-credentials Worker 型別定義
export type Bindings = {
CREDENTIALS_KV: KVNamespace;
ENCRYPTION_KEY: string; // hex-encoded 256-bit AES keywrangler 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);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
},
"include": ["src/**/*"]
}
+10
View File
@@ -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' },
}),
],
});
+14
View File
@@ -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 keyhex),透過 wrangler secret set ENCRYPTION_KEY 設定
+21
View File
@@ -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"
}
}
+2007
View File
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 加密後的 credentialkey = 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 MVPstub(不寫入任何外部服務)
* 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;
}
/** 記錄執行結果(MVPno-opPhase 7 補充 analytics*/
export async function writeEvaluation(
_env: Bindings,
_record: EvaluationRecord,
): Promise<void> {
// Phase 7: POST to registry.arcrun.dev/analytics/record
}
/** 更新零件統計(MVPno-opPhase 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[] }> {
// 路徑 Atriplets 格式
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 };
}
// 路徑 Bgraph 格式
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 };
}
// 路徑 Cbody 直接就是 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 };
}
}
+337
View File
@@ -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 StoreBUILD-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 存入 KVkey = {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.keytruthy
*/
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 : [];
}
+30
View File
@@ -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);
};
}
+54
View File
@@ -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_BUCKETWorker 記憶體中已有實作)*/
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;
+306
View File
@@ -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',
},
},
},
};
+25
View File
@@ -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({}),
});
+243
View File
@@ -0,0 +1,243 @@
/**
* WASI preview1 shim
* stdin/stdout/stderr syscall
* syscall ENOSYS76
*
* 使 @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.Memoryinstantiate 後呼叫) */
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 fdstdout=1 stderr=2
* iovec { buf: i32, buf_len: i32 } 4 byteslittle-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;
}
+119
View File
@@ -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. 注入 memoryWASI 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 exportr2Key: ${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. 讀取 stdoutJSON.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();
}
+84
View File
@@ -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);
}
}
});
+49
View File
@@ -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);
});
+55
View File
@@ -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);
}
});
+16
View File
@@ -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',
})
);
+26
View File
@@ -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 });
});
+73
View File
@@ -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);
});
+118
View File
@@ -0,0 +1,118 @@
// arcrun cypher-executor 型別定義
export type Bindings = {
// KV Context Store:節點 output 透過 KV 傳遞,解決同名欄位衝突
EXEC_CONTEXT: KVNamespace;
// Webhook Storekey = workflow namevalue = Workflow JSON
WEBHOOKS: KVNamespace;
// Credential StoreAES-GCM 加密存放用戶 API token
CREDENTIALS_KV: KVNamespace;
// R2 BucketWASM 零件二進位
WASM_BUCKET: R2Bucket;
// Workers AI
AI: Ai;
// 環境變數
ENVIRONMENT: string;
ENCRYPTION_KEY: string; // hex-encoded 256-bit AES keywrangler 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 StoreBUILD-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 寫入 KVTTL 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
}
}
+194
View File
@@ -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);
});
});
+215
View File
@@ -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.MemoryVitest 環境支援)
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_readstdin', () => {
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)和 iovecoffset 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_writestdout/stderr', () => {
it('寫入 stdoutfd=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('寫入 stderrfd=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');
});
});
});
+16
View File
@@ -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"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.test.toml' },
},
},
},
});
+23
View File
@@ -0,0 +1,23 @@
name = "arcrun-cypher-executor"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
# 測試環境不啟用 Service BindingMiniflare 無法解析外部服務)
# R2 mockWASM 執行器測試用)
[[r2_buckets]]
binding = "WASM_BUCKET"
bucket_name = "arcrun-wasm"
# KV mockBUILD-006
[[kv_namespaces]]
binding = "EXEC_CONTEXT"
id = "test-exec-context"
[[kv_namespaces]]
binding = "WEBHOOKS"
id = "test-webhooks"
[vars]
ENVIRONMENT = "test"
+35
View File
@@ -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 StoreAES-GCM 加密存放用戶 API token
# Standard 模式:供 credential-injector 讀取加密 token
[[kv_namespaces]]
binding = "CREDENTIALS_KV"
id = "" # 填入你的 Credentials KV Namespace ID
# R2 BucketWASM 零件二進位(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: 輸出 placeholderPhase 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 不需額外參數)
+3
View File
@@ -0,0 +1,3 @@
module component
go 1.21
+205
View File
@@ -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點執行" # 排程說明文字(選填)
+3
View File
@@ -0,0 +1,3 @@
module component
go 1.21
+123
View File
@@ -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 格式字串
+3
View File
@@ -0,0 +1,3 @@
module component
go 1.21
+103
View File
@@ -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 # 比較值(必填)
+3
View File
@@ -0,0 +1,3 @@
module component
go 1.21
+122
View File
@@ -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 tokengmail.send scope"
inject_as: access_token
config_example: |
send_email: # 節點名稱(可自訂)
to: "" # 收件人 Email(必填)
subject: "" # 主旨(必填)
body: "" # 內文(必填)
# access_token 由 credentials.yaml 的 gmail_token 自動注入
+3
View File
@@ -0,0 +1,3 @@
module component
go 1.21
+139
View File
@@ -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 格式的 emailbase64url 編碼
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/base64TinyGo 相容)
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 tokenspreadsheets scope"
inject_as: access_token
config_example: |
read_sheet: # 節點名稱(可自訂)
spreadsheet_id: "" # 試算表 ID(必填)
range: "" # 範圍,如 Sheet1!A1:B10(必填)
# access_token 由 credentials.yaml 的 google_oauth 自動注入
+3
View File
@@ -0,0 +1,3 @@
module component
go 1.21
+135
View File
@@ -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 headerskey-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(選填)
+3
View File
@@ -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