arcrun — AI workflow execution engine (clean history)
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。 此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在 richblack/arcrun 與本地 backup 分支)。含: - acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe) - recipe push 把關(資料外流提醒 + 打通檢查) - 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1) - CLI / cypher-executor / registry / 完整 SDD Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
# registry/aliases.yaml
|
||||
#
|
||||
# Scope 級別的搜尋同義詞表。
|
||||
# 當 canonical_id 以某個 scope 為前綴時,Registry 建立 Vectorize 索引時
|
||||
# 會自動把對應的 aliases 合併進去,不需要零件作者手動填寫。
|
||||
#
|
||||
# 維護原則:
|
||||
# - 這裡只放 scope 的同義詞,不放動作詞(append / send / read)
|
||||
# - 中英文、縮寫、口語化說法都放進來
|
||||
# - 未來接入 KBDB 後,此表將成為 KBDB synonym graph 的初始資料
|
||||
#
|
||||
# 新增 scope:開 PR,在對應 category 下加一個新 key
|
||||
|
||||
scopes:
|
||||
|
||||
# ── 整合類(category: api)──────────────────────────────────────────────────
|
||||
|
||||
gmail:
|
||||
- email
|
||||
- mail
|
||||
- 電子郵件
|
||||
- 信件
|
||||
- google mail
|
||||
- 寄信
|
||||
- 發信
|
||||
|
||||
google_sheets:
|
||||
- gsheets
|
||||
- spreadsheet
|
||||
- google 試算表
|
||||
- 試算表
|
||||
- google sheet
|
||||
- sheets
|
||||
- excel # 使用者常用 excel 描述試算表需求
|
||||
|
||||
telegram:
|
||||
- tg
|
||||
- telegram bot
|
||||
- bot 通知
|
||||
- 機器人通知
|
||||
|
||||
line_notify:
|
||||
- line
|
||||
- line 通知
|
||||
- line bot
|
||||
- 推播
|
||||
|
||||
http_request:
|
||||
- http
|
||||
- api call
|
||||
- fetch
|
||||
- rest
|
||||
- webhook
|
||||
- curl
|
||||
- 外部 api
|
||||
- 呼叫 api
|
||||
|
||||
# ── 資料處理類(category: data)────────────────────────────────────────────
|
||||
|
||||
string:
|
||||
- 字串
|
||||
- text
|
||||
- 文字
|
||||
- str
|
||||
|
||||
array:
|
||||
- 陣列
|
||||
- list
|
||||
- 清單
|
||||
- 列表
|
||||
|
||||
date:
|
||||
- 日期
|
||||
- time
|
||||
- 時間
|
||||
- datetime
|
||||
- timestamp
|
||||
|
||||
number:
|
||||
- 數字
|
||||
- 數值
|
||||
- numeric
|
||||
- math
|
||||
- 計算
|
||||
|
||||
json:
|
||||
- 物件
|
||||
- object
|
||||
- 資料轉換
|
||||
|
||||
# ── 控制流類(category: logic)─────────────────────────────────────────────
|
||||
|
||||
if:
|
||||
- 條件
|
||||
- condition
|
||||
- 判斷
|
||||
- branch
|
||||
- 分支
|
||||
|
||||
foreach:
|
||||
- 迴圈
|
||||
- loop
|
||||
- iterate
|
||||
- 遍歷
|
||||
- each
|
||||
|
||||
switch:
|
||||
- 多路由
|
||||
- route
|
||||
- 路由
|
||||
- case
|
||||
|
||||
try_catch:
|
||||
- 錯誤處理
|
||||
- error handling
|
||||
- fallback
|
||||
- 例外
|
||||
|
||||
wait:
|
||||
- 延遲
|
||||
- delay
|
||||
- sleep
|
||||
- 等待
|
||||
|
||||
# ── AI 類(category: ai)────────────────────────────────────────────────────
|
||||
|
||||
ai:
|
||||
- llm
|
||||
- gpt
|
||||
- claude
|
||||
- 語言模型
|
||||
- 自然語言
|
||||
- 智慧
|
||||
- 生成
|
||||
@@ -0,0 +1,56 @@
|
||||
canonical_id: "array_ops"
|
||||
display_name: "陣列操作"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [operation, input]
|
||||
properties:
|
||||
operation:
|
||||
type: string
|
||||
enum: [count, first, last, reverse, sum, average, min, max, sort, unique]
|
||||
input:
|
||||
type: array
|
||||
description: 輸入陣列(元素為數字或字串)
|
||||
args:
|
||||
type: object
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
result: {}
|
||||
operation:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "sort 數字陣列"
|
||||
given: '{"operation":"sort","input":[3,1,2]}'
|
||||
then_contains: '"result":[1,2,3]'
|
||||
- scenario: "sum 操作"
|
||||
given: '{"operation":"sum","input":[1,2,3]}'
|
||||
then_contains: '"result":6'
|
||||
- scenario: "空陣列 first"
|
||||
given: '{"operation":"first","input":[]}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, data, array, list, transform]
|
||||
description: "陣列操作:count/first/last/reverse/sum/average/min/max/sort/unique。"
|
||||
config_example: |
|
||||
my_array_op: # 節點名稱(可自訂)
|
||||
operation: "sort" # 運算類型(必填),可選值:count/first/last/reverse/sum/average/min/max/sort/unique
|
||||
input: [3, 1, 4, 1, 5, 9, 2, 6] # 輸入陣列,元素為數字或字串(必填)
|
||||
args: {} # 操作參數(選填,目前各 operation 不需額外參數)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,205 @@
|
||||
// array_ops — 陣列操作
|
||||
// 支援: count, first, last, reverse, sum, average, min, max, sort, unique
|
||||
// input 陣列元素支援 float64 或 string
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Operation string `json:"operation"`
|
||||
Input []json.RawMessage `json:"input"`
|
||||
Args map[string]string `json:"args"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.Operation == "" {
|
||||
writeError("operation 必填")
|
||||
return
|
||||
}
|
||||
|
||||
items := input.Input
|
||||
op := input.Operation
|
||||
|
||||
switch op {
|
||||
case "count":
|
||||
writeResult(op, len(items))
|
||||
case "first":
|
||||
if len(items) == 0 {
|
||||
writeError("陣列為空")
|
||||
return
|
||||
}
|
||||
writeResultRaw(op, items[0])
|
||||
case "last":
|
||||
if len(items) == 0 {
|
||||
writeError("陣列為空")
|
||||
return
|
||||
}
|
||||
writeResultRaw(op, items[len(items)-1])
|
||||
case "reverse":
|
||||
reversed := make([]json.RawMessage, len(items))
|
||||
for i, v := range items {
|
||||
reversed[len(items)-1-i] = v
|
||||
}
|
||||
writeResultRaw(op, reversed)
|
||||
case "sum":
|
||||
nums, err := toFloats(items)
|
||||
if err != nil {
|
||||
writeError(err.Error())
|
||||
return
|
||||
}
|
||||
sum := 0.0
|
||||
for _, n := range nums {
|
||||
sum += n
|
||||
}
|
||||
writeResult(op, sum)
|
||||
case "average":
|
||||
nums, err := toFloats(items)
|
||||
if err != nil {
|
||||
writeError(err.Error())
|
||||
return
|
||||
}
|
||||
if len(nums) == 0 {
|
||||
writeError("陣列為空")
|
||||
return
|
||||
}
|
||||
sum := 0.0
|
||||
for _, n := range nums {
|
||||
sum += n
|
||||
}
|
||||
writeResult(op, sum/float64(len(nums)))
|
||||
case "min":
|
||||
nums, err := toFloats(items)
|
||||
if err != nil {
|
||||
writeError(err.Error())
|
||||
return
|
||||
}
|
||||
if len(nums) == 0 {
|
||||
writeError("陣列為空")
|
||||
return
|
||||
}
|
||||
m := math.MaxFloat64
|
||||
for _, n := range nums {
|
||||
if n < m {
|
||||
m = n
|
||||
}
|
||||
}
|
||||
writeResult(op, m)
|
||||
case "max":
|
||||
nums, err := toFloats(items)
|
||||
if err != nil {
|
||||
writeError(err.Error())
|
||||
return
|
||||
}
|
||||
if len(nums) == 0 {
|
||||
writeError("陣列為空")
|
||||
return
|
||||
}
|
||||
m := -math.MaxFloat64
|
||||
for _, n := range nums {
|
||||
if n > m {
|
||||
m = n
|
||||
}
|
||||
}
|
||||
writeResult(op, m)
|
||||
case "sort":
|
||||
// 嘗試數字排序,失敗則字串排序
|
||||
nums, err := toFloats(items)
|
||||
if err == nil {
|
||||
sort.Float64s(nums)
|
||||
writeResult(op, nums)
|
||||
return
|
||||
}
|
||||
strs, err2 := toStrings(items)
|
||||
if err2 != nil {
|
||||
writeError("sort 只支援數字或字串陣列")
|
||||
return
|
||||
}
|
||||
sort.Strings(strs)
|
||||
writeResult(op, strs)
|
||||
case "unique":
|
||||
seen := map[string]bool{}
|
||||
var result []json.RawMessage
|
||||
for _, item := range items {
|
||||
key := string(item)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
if result == nil {
|
||||
result = []json.RawMessage{}
|
||||
}
|
||||
writeResultRaw(op, result)
|
||||
default:
|
||||
writeError("不支援的 operation: " + op)
|
||||
}
|
||||
}
|
||||
|
||||
func toFloats(items []json.RawMessage) ([]float64, error) {
|
||||
nums := make([]float64, len(items))
|
||||
for i, item := range items {
|
||||
var n float64
|
||||
if err := json.Unmarshal(item, &n); err != nil {
|
||||
return nil, &parseError{"元素 " + strconv.Itoa(i) + " 不是數字"}
|
||||
}
|
||||
nums[i] = n
|
||||
}
|
||||
return nums, nil
|
||||
}
|
||||
|
||||
func toStrings(items []json.RawMessage) ([]string, error) {
|
||||
strs := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
var s string
|
||||
if err := json.Unmarshal(item, &s); err != nil {
|
||||
return nil, &parseError{"元素 " + strconv.Itoa(i) + " 不是字串"}
|
||||
}
|
||||
strs[i] = s
|
||||
}
|
||||
return strs, nil
|
||||
}
|
||||
|
||||
type parseError struct{ msg string }
|
||||
|
||||
func (e *parseError) Error() string { return e.msg }
|
||||
|
||||
func writeResult(op string, result interface{}) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"result": result, "operation": op},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeResultRaw(op string, result interface{}) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"result": result, "operation": op},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
canonical_id: "auth_oauth2"
|
||||
display_name: "Auth Primitive — OAuth2"
|
||||
category: "auth"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 200
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [action, api_key, service]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [authenticate, needs_refresh, refresh]
|
||||
description: |
|
||||
authenticate — 用 refresh_token 換 access_token,展開 inject 模板
|
||||
needs_refresh — 檢查 token 是否需要 refresh(expires_at < now+300s)
|
||||
refresh — 強制重新 refresh,更新 CREDENTIALS_KV 中的 access_token/expires_at
|
||||
api_key:
|
||||
type: string
|
||||
description: 租戶識別(ak_ 前綴),用來組 {api_key}:cred:{name} KV key
|
||||
service:
|
||||
type: string
|
||||
description: auth recipe 名稱,對應 auth_recipe:{service} 的 KV 記錄
|
||||
request:
|
||||
type: object
|
||||
description: 下游零件的 HTTP request 上下文(保留,auth_oauth2 當前不使用)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
needs_refresh:
|
||||
type: boolean
|
||||
description: action=needs_refresh 時有效
|
||||
auth_headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
auth_query:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
auth_body:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
runtime:
|
||||
type: object
|
||||
description: 含 access_token(action=authenticate/refresh 時有效)
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 api_key"
|
||||
given: '{"action":"authenticate","service":"google"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "找不到 auth recipe"
|
||||
given: '{"action":"authenticate","api_key":"ak_test","service":"nonexistent_oauth2_svc"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "needs_refresh 無 expires_at"
|
||||
given: '{"action":"needs_refresh","api_key":"ak_test","service":"google"}'
|
||||
then_contains: '"needs_refresh":true'
|
||||
tags: [auth, credential, primitive, oauth2]
|
||||
description: |
|
||||
OAuth2 auth primitive。讀取 auth_recipe(含 token_endpoint、client_id、client_secret)
|
||||
+ 解密 refresh_token + 呼叫 token endpoint 換 access_token + 展開 {{runtime.access_token}}。
|
||||
支援 authenticate / needs_refresh / refresh 三個 action。
|
||||
透過 host function kv_get + crypto_decrypt + http_request,plaintext 永不離開 WASM。
|
||||
config_example: |
|
||||
auth_step:
|
||||
component: "auth_oauth2"
|
||||
action: "authenticate"
|
||||
service: "google_drive" # 對應 auth_recipe:google_drive 的 KV 記錄
|
||||
@@ -0,0 +1,514 @@
|
||||
// auth_oauth2 — OAuth2 auth primitive
|
||||
//
|
||||
// 讀取 auth_recipe:{service}(含 token_endpoint、client_id、client_secret)
|
||||
// + 解密 refresh_token + POST token endpoint 換 access_token
|
||||
// + 展開 {{runtime.access_token}} 模板。
|
||||
//
|
||||
// Actions:
|
||||
// - authenticate: 用 refresh_token 換 access_token,回傳注入後的 headers/query/body
|
||||
// - needs_refresh: 檢查 access_token 是否快過期(expires_at < now+300s)
|
||||
// - refresh: 強制重新 refresh,更新 KV 中的 cached_access_token/expires_at
|
||||
//
|
||||
// Host imports:
|
||||
// - u6u.kv_get — 讀 RECIPES + CREDENTIALS_KV
|
||||
// - u6u.kv_put — 寫回 cached_access_token/expires_at
|
||||
// - u6u.crypto_decrypt — AES-GCM 解密 refresh_token
|
||||
// - u6u.http_request — POST token endpoint
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// ── host function 宣告 ───────────────────────────────────────────────────────
|
||||
|
||||
//go:wasmimport u6u kv_get
|
||||
func hostKvGet(
|
||||
keyPtr uintptr, keyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
//go:wasmimport u6u kv_put
|
||||
func hostKvPut(
|
||||
keyPtr uintptr, keyLen uint32,
|
||||
valPtr uintptr, valLen uint32,
|
||||
ttlSeconds uint32,
|
||||
) uint32
|
||||
|
||||
//go:wasmimport u6u crypto_decrypt
|
||||
func hostCryptoDecrypt(
|
||||
encPtr uintptr, encLen uint32,
|
||||
ivPtr uintptr, ivLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
//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 {
|
||||
Action string `json:"action"`
|
||||
APIKey string `json:"api_key"`
|
||||
Service string `json:"service"`
|
||||
Request json.RawMessage `json:"request,omitempty"`
|
||||
}
|
||||
|
||||
type SecretRequirement struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Optional bool `json:"optional,omitempty"`
|
||||
}
|
||||
|
||||
type AuthInjectSpec struct {
|
||||
Header map[string]string `json:"header,omitempty"`
|
||||
Query map[string]string `json:"query,omitempty"`
|
||||
Body map[string]string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
type OAuth2Config struct {
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
type AuthRecipe struct {
|
||||
Kind string `json:"kind"`
|
||||
Service string `json:"service"`
|
||||
Primitive string `json:"primitive"`
|
||||
OAuth2 *OAuth2Config `json:"oauth2,omitempty"`
|
||||
RequiredSecrets []SecretRequirement `json:"required_secrets"`
|
||||
Inject AuthInjectSpec `json:"inject"`
|
||||
}
|
||||
|
||||
type EncryptedRecord struct {
|
||||
Encrypted string `json:"encrypted"`
|
||||
IV string `json:"iv"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.Service == "" {
|
||||
writeError("service 必填")
|
||||
return
|
||||
}
|
||||
|
||||
action := input.Action
|
||||
if action == "" {
|
||||
action = "authenticate"
|
||||
}
|
||||
|
||||
// 讀 auth recipe
|
||||
recipeJSON, status := kvGet("auth_recipe:" + input.Service)
|
||||
if status == 2 {
|
||||
writeError("找不到 auth recipe: " + input.Service)
|
||||
return
|
||||
}
|
||||
if status != 0 {
|
||||
writeError("kv_get 失敗(auth_recipe)")
|
||||
return
|
||||
}
|
||||
|
||||
var recipe AuthRecipe
|
||||
if err := json.Unmarshal([]byte(recipeJSON), &recipe); err != nil {
|
||||
writeError("auth recipe JSON 解析失敗: " + err.Error())
|
||||
return
|
||||
}
|
||||
if recipe.Primitive != "oauth2" {
|
||||
writeError("auth recipe " + input.Service + " 的 primitive 不是 oauth2(是 " + recipe.Primitive + ")")
|
||||
return
|
||||
}
|
||||
if recipe.OAuth2 == nil || recipe.OAuth2.TokenEndpoint == "" {
|
||||
writeError("auth recipe " + input.Service + " 缺少 oauth2.token_endpoint")
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "needs_refresh":
|
||||
handleNeedsRefresh(input, recipe)
|
||||
case "refresh":
|
||||
handleRefresh(input, recipe)
|
||||
case "authenticate":
|
||||
handleAuthenticate(input, recipe)
|
||||
default:
|
||||
writeError("不支援的 action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
// ── action handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
func handleNeedsRefresh(input Input, recipe AuthRecipe) {
|
||||
// 讀 cached expires_at(若無,視為需要 refresh)
|
||||
expiresKey := input.APIKey + ":oauth2:" + input.Service + ":expires_at"
|
||||
expiresStr, status := kvGet(expiresKey)
|
||||
if status != 0 {
|
||||
// 找不到 = 需要 refresh
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"needs_refresh": true,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt, err := strconv.ParseInt(strings.TrimSpace(expiresStr), 10, 64)
|
||||
if err != nil {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"needs_refresh": true,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
// 提前 5 分鐘視為需要 refresh
|
||||
needsRefresh := time.Now().Unix()+300 >= expiresAt
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"needs_refresh": needsRefresh,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func handleRefresh(input Input, recipe AuthRecipe) {
|
||||
accessToken, expiresAt, ok := doRefresh(input, recipe)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 快取新 token
|
||||
cacheAccessToken(input.APIKey, input.Service, accessToken, expiresAt)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"runtime": map[string]string{"access_token": accessToken},
|
||||
"auth_headers": map[string]string{},
|
||||
"auth_query": map[string]string{},
|
||||
"auth_body": map[string]string{},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func handleAuthenticate(input Input, recipe AuthRecipe) {
|
||||
// 先嘗試讀 cached access_token
|
||||
cachedKey := input.APIKey + ":oauth2:" + input.Service + ":access_token"
|
||||
expiresKey := input.APIKey + ":oauth2:" + input.Service + ":expires_at"
|
||||
|
||||
cachedToken, cStatus := kvGet(cachedKey)
|
||||
expiresStr, eStatus := kvGet(expiresKey)
|
||||
|
||||
var accessToken string
|
||||
var expiresAt int64
|
||||
|
||||
useCache := false
|
||||
if cStatus == 0 && eStatus == 0 && cachedToken != "" {
|
||||
if exp, err := strconv.ParseInt(strings.TrimSpace(expiresStr), 10, 64); err == nil {
|
||||
if time.Now().Unix()+300 < exp {
|
||||
accessToken = cachedToken
|
||||
expiresAt = exp
|
||||
useCache = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !useCache {
|
||||
var ok bool
|
||||
accessToken, expiresAt, ok = doRefresh(input, recipe)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cacheAccessToken(input.APIKey, input.Service, accessToken, expiresAt)
|
||||
}
|
||||
|
||||
runtime := map[string]string{"access_token": accessToken}
|
||||
secrets := map[string]string{} // oauth2 inject 只用 runtime.*,不用 secret.*
|
||||
authHeaders := interpolateRecord(recipe.Inject.Header, secrets, runtime)
|
||||
authQuery := interpolateRecord(recipe.Inject.Query, secrets, runtime)
|
||||
authBody := interpolateRecord(recipe.Inject.Body, secrets, runtime)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"auth_headers": authHeaders,
|
||||
"auth_query": authQuery,
|
||||
"auth_body": authBody,
|
||||
"runtime": runtime,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// doRefresh 解密 refresh_token,打 token endpoint,回傳 (access_token, expires_at_unix, ok)
|
||||
func doRefresh(input Input, recipe AuthRecipe) (string, int64, bool) {
|
||||
if len(recipe.RequiredSecrets) == 0 {
|
||||
writeError("auth recipe " + input.Service + " 缺少 required_secrets(需要 refresh_token)")
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// 慣例:required_secrets[0] 是 refresh_token
|
||||
rtReq := recipe.RequiredSecrets[0]
|
||||
kvKey := input.APIKey + ":cred:" + rtReq.Key
|
||||
encJSON, s := kvGet(kvKey)
|
||||
if s == 2 {
|
||||
writeError("缺少 credential: " + rtReq.Key + "(" + rtReq.Label + ")。執行 acr creds push 推送")
|
||||
return "", 0, false
|
||||
}
|
||||
if s != 0 {
|
||||
writeError("kv_get 失敗(credential " + rtReq.Key + ")")
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
var rec EncryptedRecord
|
||||
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
|
||||
writeError("credential " + rtReq.Key + " 格式錯誤: " + err.Error())
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
refreshToken, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
|
||||
if !ok {
|
||||
writeError("credential " + rtReq.Key + " 解密失敗")
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// POST token endpoint(form-urlencoded)
|
||||
cfg := recipe.OAuth2
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", refreshToken)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
form.Set("client_secret", cfg.ClientSecret)
|
||||
formBody := form.Encode()
|
||||
|
||||
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
|
||||
respStr, ok2 := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody)
|
||||
if !ok2 {
|
||||
writeError("token endpoint HTTP 請求失敗")
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.Unmarshal([]byte(respStr), &tokenResp); err != nil {
|
||||
writeError("token endpoint 回應解析失敗: " + err.Error())
|
||||
return "", 0, false
|
||||
}
|
||||
if tokenResp.AccessToken == "" {
|
||||
msg := tokenResp.Error
|
||||
if tokenResp.ErrorDesc != "" {
|
||||
msg += ": " + tokenResp.ErrorDesc
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "access_token 為空"
|
||||
}
|
||||
writeError("token exchange 失敗: " + msg)
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
expiresIn := tokenResp.ExpiresIn
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = 3600
|
||||
}
|
||||
expiresAt := time.Now().Unix() + int64(expiresIn)
|
||||
|
||||
// 若 token endpoint 回傳新的 refresh_token(部分服務會 rotate),更新快取
|
||||
// 注意:寫回加密 KV 需要 host 支援;此處只快取 access_token(明文短效)
|
||||
// refresh_token 的 rotation 需要 kv_put_encrypted host function(未來擴充)
|
||||
|
||||
return tokenResp.AccessToken, expiresAt, true
|
||||
}
|
||||
|
||||
func cacheAccessToken(apiKey, service, accessToken string, expiresAt int64) {
|
||||
// 快取明文 access_token(短效,TTL = expires_at - now + 60s buffer)
|
||||
ttl := uint32(expiresAt - time.Now().Unix() + 60)
|
||||
if ttl > 7200 {
|
||||
ttl = 7200
|
||||
}
|
||||
|
||||
cachedKey := apiKey + ":oauth2:" + service + ":access_token"
|
||||
expiresKey := apiKey + ":oauth2:" + service + ":expires_at"
|
||||
expiresStr := strconv.FormatInt(expiresAt, 10)
|
||||
|
||||
kvPut(cachedKey, accessToken, ttl)
|
||||
kvPut(expiresKey, expiresStr, ttl)
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": msg,
|
||||
"auth_headers": map[string]string{},
|
||||
"auth_query": map[string]string{},
|
||||
"auth_body": map[string]string{},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func kvGet(key string) (string, uint32) {
|
||||
keyBytes := []byte(key)
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
status := hostKvGet(
|
||||
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", status
|
||||
}
|
||||
return string(outBuf[:outLen]), 0
|
||||
}
|
||||
|
||||
func kvPut(key, value string, ttlSeconds uint32) {
|
||||
keyBytes := []byte(key)
|
||||
valBytes := []byte(value)
|
||||
if len(keyBytes) == 0 || len(valBytes) == 0 {
|
||||
return
|
||||
}
|
||||
hostKvPut(
|
||||
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||
uintptr(unsafe.Pointer(&valBytes[0])), uint32(len(valBytes)),
|
||||
ttlSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
encBytes := []byte(encB64)
|
||||
ivBytes := []byte(ivB64)
|
||||
if len(encBytes) == 0 || len(ivBytes) == 0 {
|
||||
return "", false
|
||||
}
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
status := hostCryptoDecrypt(
|
||||
uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)),
|
||||
uintptr(unsafe.Pointer(&ivBytes[0])), uint32(len(ivBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
func httpRequest(reqURL, method, headersJSON, body string) (string, bool) {
|
||||
urlBytes := []byte(reqURL)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
if len(urlBytes) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
var bodyPtr uintptr
|
||||
if len(bodyBytes) > 0 {
|
||||
bodyPtr = uintptr(unsafe.Pointer(&bodyBytes[0]))
|
||||
}
|
||||
var headersPtr uintptr
|
||||
if len(headersBytes) > 0 {
|
||||
headersPtr = uintptr(unsafe.Pointer(&headersBytes[0]))
|
||||
}
|
||||
|
||||
status := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
headersPtr, uint32(len(headersBytes)),
|
||||
bodyPtr, uint32(len(bodyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
func interpolateTemplate(template string, secrets, runtime map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(template))
|
||||
i := 0
|
||||
for i < len(template) {
|
||||
start := strings.Index(template[i:], "{{")
|
||||
if start < 0 {
|
||||
b.WriteString(template[i:])
|
||||
break
|
||||
}
|
||||
b.WriteString(template[i : i+start])
|
||||
openIdx := i + start
|
||||
closeRel := strings.Index(template[openIdx+2:], "}}")
|
||||
if closeRel < 0 {
|
||||
b.WriteString(template[openIdx:])
|
||||
break
|
||||
}
|
||||
inner := template[openIdx+2 : openIdx+2+closeRel]
|
||||
advance := openIdx + 2 + closeRel + 2
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(inner, "secret."):
|
||||
key := inner[len("secret."):]
|
||||
b.WriteString(secrets[key])
|
||||
case strings.HasPrefix(inner, "runtime."):
|
||||
key := inner[len("runtime."):]
|
||||
b.WriteString(runtime[key])
|
||||
default:
|
||||
b.WriteString(template[openIdx:advance])
|
||||
}
|
||||
i = advance
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func interpolateRecord(record map[string]string, secrets, runtime map[string]string) map[string]string {
|
||||
if record == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
result := make(map[string]string, len(record))
|
||||
for k, v := range record {
|
||||
result[k] = interpolateTemplate(v, secrets, runtime)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
canonical_id: "auth_service_account"
|
||||
display_name: "Auth Primitive — Service Account (Google JWT)"
|
||||
category: "auth"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 100
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [action, api_key, service]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [authenticate]
|
||||
description: 目前僅支援 authenticate
|
||||
api_key:
|
||||
type: string
|
||||
description: 租戶識別(ak_ 前綴),用來組 {api_key}:cred:{name} KV key
|
||||
service:
|
||||
type: string
|
||||
description: auth recipe 名稱,對應 auth_recipe:{service} 的 KV 記錄
|
||||
request:
|
||||
type: object
|
||||
description: (保留)下游零件的 HTTP request 上下文
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
auth_headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
auth_query:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
auth_body:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
runtime:
|
||||
type: object
|
||||
description: 包含 access_token(token exchange 後取得)
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 api_key"
|
||||
given: '{"action":"authenticate","service":"google_sheets_sa"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "找不到 auth recipe"
|
||||
given: '{"action":"authenticate","api_key":"ak_nonexistent","service":"nonexistent"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [auth, credential, primitive, service_account, google]
|
||||
description: "Service Account auth primitive (Google JWT 方案)。讀取 auth_recipe + 解密 service_account_json → 解析 PEM private key → 組 JWT → crypto_sign_rs256 (host function) → token exchange endpoint → 取 access_token → 展開 {{runtime.access_token}} 模板。透過 host function crypto_sign_rs256,private key 僅以 PKCS8 bytes 傳給 host,解密後 plaintext 不離開 WASM。"
|
||||
config_example: |
|
||||
auth_step:
|
||||
component: "auth_service_account"
|
||||
action: "authenticate"
|
||||
service: "google_sheets_sa"
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,474 @@
|
||||
// auth_service_account — Google Service Account JWT auth primitive
|
||||
//
|
||||
// 讀取 auth_recipe:{service} + 解密 service_account_json + 組 JWT + RS256 簽章(透過 host)
|
||||
// + token exchange → access_token + 展開 {{runtime.access_token}}。
|
||||
//
|
||||
// Host imports:
|
||||
// - u6u.kv_get — 讀 RECIPES + CREDENTIALS_KV
|
||||
// - u6u.crypto_decrypt — AES-GCM 解密 service account JSON
|
||||
// - u6u.crypto_sign_rs256 — RSASSA-PKCS1-v1_5 + SHA-256 (PKCS8 private key)
|
||||
// - u6u.http_request — POST token exchange endpoint
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// ── host function 宣告 ───────────────────────────────────────────────────────
|
||||
|
||||
//go:wasmimport u6u kv_get
|
||||
func hostKvGet(
|
||||
keyPtr uintptr, keyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
//go:wasmimport u6u crypto_decrypt
|
||||
func hostCryptoDecrypt(
|
||||
encPtr uintptr, encLen uint32,
|
||||
ivPtr uintptr, ivLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
//go:wasmimport u6u crypto_sign_rs256
|
||||
func hostCryptoSignRS256(
|
||||
dataPtr uintptr, dataLen uint32,
|
||||
pkcs8Ptr uintptr, pkcs8Len uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
//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 {
|
||||
Action string `json:"action"`
|
||||
APIKey string `json:"api_key"`
|
||||
Service string `json:"service"`
|
||||
Request json.RawMessage `json:"request,omitempty"`
|
||||
}
|
||||
|
||||
type SecretRequirement struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Optional bool `json:"optional,omitempty"`
|
||||
}
|
||||
|
||||
type AuthInjectSpec struct {
|
||||
Header map[string]string `json:"header,omitempty"`
|
||||
Query map[string]string `json:"query,omitempty"`
|
||||
Body map[string]string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
type TokenExchange struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
type AuthRecipe struct {
|
||||
Kind string `json:"kind"`
|
||||
Service string `json:"service"`
|
||||
Primitive string `json:"primitive"`
|
||||
ServiceAccountKind string `json:"service_account_kind,omitempty"`
|
||||
TokenExchange *TokenExchange `json:"token_exchange,omitempty"`
|
||||
RequiredSecrets []SecretRequirement `json:"required_secrets"`
|
||||
Inject AuthInjectSpec `json:"inject"`
|
||||
}
|
||||
|
||||
type EncryptedRecord struct {
|
||||
Encrypted string `json:"encrypted"`
|
||||
IV string `json:"iv"`
|
||||
}
|
||||
|
||||
type ServiceAccountJSON struct {
|
||||
ClientEmail string `json:"client_email"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
type JWTHeader struct {
|
||||
Alg string `json:"alg"`
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
|
||||
type JWTPayload struct {
|
||||
Iss string `json:"iss"`
|
||||
Sub string `json:"sub"`
|
||||
Aud string `json:"aud"`
|
||||
Scope string `json:"scope"`
|
||||
Iat int64 `json:"iat"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.Service == "" {
|
||||
writeError("service 必填")
|
||||
return
|
||||
}
|
||||
if input.Action != "" && input.Action != "authenticate" {
|
||||
writeError("auth_service_account 僅支援 action=authenticate")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 讀 auth recipe
|
||||
recipeJSON, status := kvGet("auth_recipe:" + input.Service)
|
||||
if status == 2 {
|
||||
writeError("找不到 auth recipe: " + input.Service)
|
||||
return
|
||||
}
|
||||
if status != 0 {
|
||||
writeError("kv_get 失敗(auth_recipe)")
|
||||
return
|
||||
}
|
||||
|
||||
var recipe AuthRecipe
|
||||
if err := json.Unmarshal([]byte(recipeJSON), &recipe); err != nil {
|
||||
writeError("auth recipe JSON 解析失敗: " + err.Error())
|
||||
return
|
||||
}
|
||||
if recipe.Primitive != "service_account" {
|
||||
writeError("auth recipe " + input.Service + " 的 primitive 不是 service_account (是 " + recipe.Primitive + ")")
|
||||
return
|
||||
}
|
||||
if recipe.ServiceAccountKind != "google_jwt" {
|
||||
writeError("auth recipe " + input.Service + " 的 service_account_kind 必須是 google_jwt,實際: " + recipe.ServiceAccountKind)
|
||||
return
|
||||
}
|
||||
if recipe.TokenExchange == nil || recipe.TokenExchange.Endpoint == "" {
|
||||
writeError("auth recipe " + input.Service + " 缺少 token_exchange.endpoint")
|
||||
return
|
||||
}
|
||||
if len(recipe.RequiredSecrets) == 0 {
|
||||
writeError("auth recipe " + input.Service + " 缺少 required_secrets[0](SA JSON)")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 解密 service account JSON (慣例:required_secrets[0] 是 SA JSON)
|
||||
saReq := recipe.RequiredSecrets[0]
|
||||
kvKey := input.APIKey + ":cred:" + saReq.Key
|
||||
encJSON, s := kvGet(kvKey)
|
||||
if s == 2 {
|
||||
writeError("缺少 credential: " + saReq.Key + " (" + saReq.Label + ")。修復: 編輯 credentials.yaml 後執行 acr creds push")
|
||||
return
|
||||
}
|
||||
if s != 0 {
|
||||
writeError("kv_get 失敗(credential " + saReq.Key + ")")
|
||||
return
|
||||
}
|
||||
|
||||
var rec EncryptedRecord
|
||||
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
|
||||
writeError("credential " + saReq.Key + " 格式錯誤: " + err.Error())
|
||||
return
|
||||
}
|
||||
saJSONStr, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
|
||||
if !ok {
|
||||
writeError("credential " + saReq.Key + " 解密失敗")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 解析 service account JSON
|
||||
var sa ServiceAccountJSON
|
||||
if err := json.Unmarshal([]byte(saJSONStr), &sa); err != nil {
|
||||
writeError("service account JSON 格式錯誤: " + err.Error())
|
||||
return
|
||||
}
|
||||
if sa.ClientEmail == "" || sa.PrivateKey == "" {
|
||||
writeError("service account JSON 缺少 client_email 或 private_key")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. PEM → PKCS8 bytes (去 header/footer + base64 decode)
|
||||
pkcs8, err := pemToPkcs8(sa.PrivateKey)
|
||||
if err != nil {
|
||||
writeError("解析 service account private key 失敗: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 組 JWT header + payload (base64url-encoded)
|
||||
now := time.Now().Unix()
|
||||
header := JWTHeader{Alg: "RS256", Typ: "JWT"}
|
||||
payload := JWTPayload{
|
||||
Iss: sa.ClientEmail,
|
||||
Sub: sa.ClientEmail,
|
||||
Aud: recipe.TokenExchange.Endpoint,
|
||||
Scope: strings.Join(recipe.TokenExchange.Scopes, " "),
|
||||
Iat: now,
|
||||
Exp: now + 3600,
|
||||
}
|
||||
headerBytes, _ := json.Marshal(header)
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
|
||||
signingInput := base64.RawURLEncoding.EncodeToString(headerBytes) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(payloadBytes)
|
||||
|
||||
// 6. 呼叫 host 簽章 (RSASSA-PKCS1-v1_5 + SHA-256)
|
||||
signature, ok := cryptoSignRS256([]byte(signingInput), pkcs8)
|
||||
if !ok {
|
||||
writeError("JWT 簽章失敗(host function crypto_sign_rs256 回傳錯誤)")
|
||||
return
|
||||
}
|
||||
|
||||
jwt := signingInput + "." + base64.RawURLEncoding.EncodeToString(signature)
|
||||
|
||||
// 7. token exchange:POST form-urlencoded 到 token_exchange.endpoint
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||
form.Set("assertion", jwt)
|
||||
formBody := form.Encode()
|
||||
|
||||
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
|
||||
|
||||
respStr, ok := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody)
|
||||
if !ok {
|
||||
writeError("token exchange HTTP 失敗")
|
||||
return
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(respStr), &tokenResp); err != nil {
|
||||
writeError("token exchange 回應解析失敗: " + err.Error() + " (raw: " + respStr + ")")
|
||||
return
|
||||
}
|
||||
if tokenResp.AccessToken == "" {
|
||||
errMsg := tokenResp.Error
|
||||
if tokenResp.ErrorDesc != "" {
|
||||
errMsg += ": " + tokenResp.ErrorDesc
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = "access_token 為空 (raw: " + respStr + ")"
|
||||
}
|
||||
writeError("token exchange 失敗: " + errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// 8. 展開模板 (service_account 不用 secret.*,只用 runtime.access_token)
|
||||
secrets := map[string]string{}
|
||||
runtime := map[string]string{"access_token": tokenResp.AccessToken}
|
||||
authHeaders := interpolateRecord(recipe.Inject.Header, secrets, runtime)
|
||||
authQuery := interpolateRecord(recipe.Inject.Query, secrets, runtime)
|
||||
authBody := interpolateRecord(recipe.Inject.Body, secrets, runtime)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"auth_headers": authHeaders,
|
||||
"auth_query": authQuery,
|
||||
"auth_body": authBody,
|
||||
"runtime": runtime,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": msg,
|
||||
"auth_headers": map[string]string{},
|
||||
"auth_query": map[string]string{},
|
||||
"auth_body": map[string]string{},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// pemToPkcs8 從 PEM 取出 base64 body 再 decode 成 bytes。
|
||||
// 支援 "BEGIN PRIVATE KEY" / "BEGIN RSA PRIVATE KEY"(SA JSON 幾乎都是前者)。
|
||||
func pemToPkcs8(pem string) ([]byte, error) {
|
||||
// 移除所有 BEGIN/END 行與空白
|
||||
lines := strings.Split(pem, "\n")
|
||||
var b strings.Builder
|
||||
for _, line := range lines {
|
||||
l := strings.TrimSpace(line)
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(l, "-----BEGIN") || strings.HasPrefix(l, "-----END") {
|
||||
continue
|
||||
}
|
||||
b.WriteString(l)
|
||||
}
|
||||
cleaned := strings.ReplaceAll(b.String(), "\\n", "") // 防呆:JSON-escaped newline
|
||||
return base64.StdEncoding.DecodeString(cleaned)
|
||||
}
|
||||
|
||||
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
|
||||
func kvGet(key string) (string, uint32) {
|
||||
keyBytes := []byte(key)
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
status := hostKvGet(
|
||||
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", status
|
||||
}
|
||||
return string(outBuf[:outLen]), 0
|
||||
}
|
||||
|
||||
func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
encBytes := []byte(encB64)
|
||||
ivBytes := []byte(ivB64)
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
if len(encBytes) == 0 || len(ivBytes) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
status := hostCryptoDecrypt(
|
||||
uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)),
|
||||
uintptr(unsafe.Pointer(&ivBytes[0])), uint32(len(ivBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
// cryptoSignRS256 呼叫 host,回傳簽章 bytes
|
||||
func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) {
|
||||
if len(data) == 0 || len(pkcs8) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
outBuf := make([]byte, 1024) // RSA-2048 簽章 = 256 bytes,1KB 綽綽有餘
|
||||
var outLen uint32
|
||||
|
||||
status := hostCryptoSignRS256(
|
||||
uintptr(unsafe.Pointer(&data[0])), uint32(len(data)),
|
||||
uintptr(unsafe.Pointer(&pkcs8[0])), uint32(len(pkcs8)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return nil, false
|
||||
}
|
||||
return outBuf[:outLen], true
|
||||
}
|
||||
|
||||
// httpRequest 呼叫 host,回傳 response body 字串(host 側把 status + body 串好)
|
||||
func httpRequest(url, method, headersJSON, body string) (string, bool) {
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
if len(urlBytes) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
// bodyBytes 可能為空(GET),host function 允許 len=0
|
||||
var bodyPtr uintptr
|
||||
if len(bodyBytes) > 0 {
|
||||
bodyPtr = uintptr(unsafe.Pointer(&bodyBytes[0]))
|
||||
}
|
||||
var headersPtr uintptr
|
||||
if len(headersBytes) > 0 {
|
||||
headersPtr = uintptr(unsafe.Pointer(&headersBytes[0]))
|
||||
}
|
||||
|
||||
status := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
headersPtr, uint32(len(headersBytes)),
|
||||
bodyPtr, uint32(len(bodyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
// interpolateTemplate 展開 {{secret.X}} 與 {{runtime.X}}。未知 key 展開為空字串。
|
||||
// 其他 namespace 的 {{...}} 原樣保留。
|
||||
func interpolateTemplate(template string, secrets, runtime map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(template))
|
||||
i := 0
|
||||
for i < len(template) {
|
||||
start := strings.Index(template[i:], "{{")
|
||||
if start < 0 {
|
||||
b.WriteString(template[i:])
|
||||
break
|
||||
}
|
||||
b.WriteString(template[i : i+start])
|
||||
openIdx := i + start
|
||||
closeRel := strings.Index(template[openIdx+2:], "}}")
|
||||
if closeRel < 0 {
|
||||
b.WriteString(template[openIdx:])
|
||||
break
|
||||
}
|
||||
inner := template[openIdx+2 : openIdx+2+closeRel]
|
||||
advance := openIdx + 2 + closeRel + 2
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(inner, "secret."):
|
||||
key := inner[len("secret."):]
|
||||
b.WriteString(secrets[key])
|
||||
case strings.HasPrefix(inner, "runtime."):
|
||||
key := inner[len("runtime."):]
|
||||
b.WriteString(runtime[key])
|
||||
default:
|
||||
b.WriteString(template[openIdx:advance])
|
||||
}
|
||||
i = advance
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func interpolateRecord(
|
||||
record map[string]string,
|
||||
secrets, runtime map[string]string,
|
||||
) map[string]string {
|
||||
if record == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
result := make(map[string]string, len(record))
|
||||
for k, v := range record {
|
||||
result[k] = interpolateTemplate(v, secrets, runtime)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
canonical_id: "auth_static_key"
|
||||
display_name: "Auth Primitive — Static Key"
|
||||
category: "auth"
|
||||
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: [action, api_key, service]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [authenticate]
|
||||
description: 目前僅支援 authenticate;static_key 無 refresh 概念
|
||||
api_key:
|
||||
type: string
|
||||
description: 租戶識別(ak_ 前綴),用來組 {api_key}:cred:{name} KV key
|
||||
service:
|
||||
type: string
|
||||
description: auth recipe 名稱,對應 auth_recipe:{service} 的 KV 記錄
|
||||
request:
|
||||
type: object
|
||||
description: (保留)下游零件的 HTTP request 上下文;static_key 當前不使用
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
auth_headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
auth_query:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
auth_body:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
runtime:
|
||||
type: object
|
||||
description: Static key 不使用;欄位保留以對齊其他 auth primitive
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 api_key"
|
||||
given: '{"action":"authenticate","service":"openai"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "找不到 auth recipe"
|
||||
given: '{"action":"authenticate","api_key":"ak_nonexistent","service":"nonexistent"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [auth, credential, primitive, static_key]
|
||||
description: "Static key auth primitive。讀取 auth_recipe + 解密 required_secrets + 展開 {{secret.X}} 模板,回傳 auth_headers / auth_query / auth_body。涵蓋 Bearer token / API key / Basic auth / 自訂 header 等 80% 服務。透過 host function kv_get + crypto_decrypt,plaintext 永不離開 WASM。"
|
||||
config_example: |
|
||||
auth_step:
|
||||
component: "auth_static_key"
|
||||
action: "authenticate"
|
||||
service: "openai" # 對應 auth_recipe:openai 的 KV 記錄
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,301 @@
|
||||
// auth_static_key — static key auth primitive
|
||||
//
|
||||
// 讀取 auth_recipe:{service} + 解密 required_secrets + 展開 {{secret.X}} 模板,
|
||||
// 回傳 auth_headers / auth_query / auth_body。
|
||||
//
|
||||
// 所有外部 I/O 都透過 host function:
|
||||
// - u6u.kv_get — 依 key 前綴路由到 RECIPES / CREDENTIALS_KV (host 做越權檢查)
|
||||
// - u6u.crypto_decrypt — AES-GCM 解密 (encryption key 由 host 持有,不暴露給 WASM)
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// ── host function 宣告 ───────────────────────────────────────────────────────
|
||||
|
||||
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功 / 1 錯誤 / 2 找不到
|
||||
//
|
||||
//go:wasmimport u6u kv_get
|
||||
func hostKvGet(
|
||||
keyPtr uintptr, keyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
// crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功
|
||||
// enc/iv 為 base64 字串(即 KV 中儲存的格式)
|
||||
//
|
||||
//go:wasmimport u6u crypto_decrypt
|
||||
func hostCryptoDecrypt(
|
||||
encPtr uintptr, encLen uint32,
|
||||
ivPtr uintptr, ivLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
// ── 型別 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Input struct {
|
||||
Action string `json:"action"`
|
||||
APIKey string `json:"api_key"`
|
||||
Service string `json:"service"`
|
||||
Request json.RawMessage `json:"request,omitempty"`
|
||||
}
|
||||
|
||||
type SecretRequirement struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Optional bool `json:"optional,omitempty"`
|
||||
}
|
||||
|
||||
type AuthInjectSpec struct {
|
||||
Header map[string]string `json:"header,omitempty"`
|
||||
Query map[string]string `json:"query,omitempty"`
|
||||
Body map[string]string `json:"body,omitempty"`
|
||||
// Path:要注入 endpoint URL path 的 secret(如 telegram /bot{token}/)。
|
||||
// key = 模板變數名(recipe endpoint 用 {{auth.K}} 引用),value = {{secret.X}} 模板。
|
||||
Path map[string]string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type AuthRecipe struct {
|
||||
Kind string `json:"kind"`
|
||||
Service string `json:"service"`
|
||||
Primitive string `json:"primitive"`
|
||||
RequiredSecrets []SecretRequirement `json:"required_secrets"`
|
||||
Inject AuthInjectSpec `json:"inject"`
|
||||
}
|
||||
|
||||
type EncryptedRecord struct {
|
||||
Encrypted string `json:"encrypted"`
|
||||
IV string `json:"iv"`
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.Service == "" {
|
||||
writeError("service 必填")
|
||||
return
|
||||
}
|
||||
if input.Action != "" && input.Action != "authenticate" {
|
||||
writeError("auth_static_key 僅支援 action=authenticate")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 讀 auth recipe
|
||||
recipeJSON, status := kvGet("auth_recipe:" + input.Service)
|
||||
if status == 2 {
|
||||
writeError("找不到 auth recipe: " + input.Service)
|
||||
return
|
||||
}
|
||||
if status != 0 {
|
||||
writeError("kv_get 失敗(auth_recipe)")
|
||||
return
|
||||
}
|
||||
|
||||
var recipe AuthRecipe
|
||||
if err := json.Unmarshal([]byte(recipeJSON), &recipe); err != nil {
|
||||
writeError("auth recipe JSON 解析失敗: " + err.Error())
|
||||
return
|
||||
}
|
||||
if recipe.Primitive != "static_key" {
|
||||
writeError("auth recipe " + input.Service + " 的 primitive 不是 static_key (是 " + recipe.Primitive + ")")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 解密所有 non-optional required_secrets
|
||||
secrets := make(map[string]string)
|
||||
for _, req := range recipe.RequiredSecrets {
|
||||
if req.Optional {
|
||||
continue
|
||||
}
|
||||
kvKey := input.APIKey + ":cred:" + req.Key
|
||||
encJSON, s := kvGet(kvKey)
|
||||
if s == 2 {
|
||||
writeError("缺少 credential: " + req.Key + " (" + req.Label + ")。修復: 編輯 credentials.yaml 後執行 acr creds push")
|
||||
return
|
||||
}
|
||||
if s != 0 {
|
||||
writeError("kv_get 失敗(credential " + req.Key + ")")
|
||||
return
|
||||
}
|
||||
|
||||
var rec EncryptedRecord
|
||||
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
|
||||
writeError("credential " + req.Key + " 格式錯誤: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
plaintext, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
|
||||
if !ok {
|
||||
writeError("credential " + req.Key + " 解密失敗")
|
||||
return
|
||||
}
|
||||
secrets[req.Key] = plaintext
|
||||
}
|
||||
|
||||
// 3. 展開模板 (static_key 沒有 runtime,傳空 map)
|
||||
runtime := map[string]string{}
|
||||
authHeaders := interpolateRecord(recipe.Inject.Header, secrets, runtime)
|
||||
authQuery := interpolateRecord(recipe.Inject.Query, secrets, runtime)
|
||||
authBody := interpolateRecord(recipe.Inject.Body, secrets, runtime)
|
||||
authPath := interpolateRecord(recipe.Inject.Path, secrets, runtime)
|
||||
|
||||
// 3.5 Basic Auth 自動編碼:若 header 值為 "Basic <x>:<y>" (冒號代表未編碼的 user:pass),
|
||||
// 將冒號分隔部分做 base64。這涵蓋 twilio / jira / mailgun 等 Basic Auth recipe。
|
||||
// "Basic <already-base64>" (無冒號) 維持原樣,向後相容。
|
||||
// header key 不分大小寫比對 "authorization"。
|
||||
for k, v := range authHeaders {
|
||||
if !strings.EqualFold(k, "Authorization") {
|
||||
continue
|
||||
}
|
||||
const prefix = "Basic "
|
||||
if !strings.HasPrefix(v, prefix) {
|
||||
continue
|
||||
}
|
||||
payload := v[len(prefix):]
|
||||
if !strings.Contains(payload, ":") {
|
||||
continue
|
||||
}
|
||||
authHeaders[k] = prefix + base64.StdEncoding.EncodeToString([]byte(payload))
|
||||
}
|
||||
|
||||
// 4. 輸出
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"auth_headers": authHeaders,
|
||||
"auth_query": authQuery,
|
||||
"auth_body": authBody,
|
||||
"auth_path": authPath,
|
||||
"runtime": runtime,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": msg,
|
||||
"auth_headers": map[string]string{},
|
||||
"auth_query": map[string]string{},
|
||||
"auth_body": map[string]string{},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
|
||||
func kvGet(key string) (string, uint32) {
|
||||
keyBytes := []byte(key)
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
status := hostKvGet(
|
||||
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", status
|
||||
}
|
||||
return string(outBuf[:outLen]), 0
|
||||
}
|
||||
|
||||
// cryptoDecrypt 呼叫 host function 做 AES-GCM 解密
|
||||
// enc/iv 均為 base64 字串;回傳 UTF-8 plaintext
|
||||
func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
encBytes := []byte(encB64)
|
||||
ivBytes := []byte(ivB64)
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
// 處理空字串的防呆(TinyGo 取 &[]byte{}[0] 會 panic)
|
||||
if len(encBytes) == 0 || len(ivBytes) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
status := hostCryptoDecrypt(
|
||||
uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)),
|
||||
uintptr(unsafe.Pointer(&ivBytes[0])), uint32(len(ivBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
// interpolateTemplate 展開 {{secret.X}} 與 {{runtime.X}}。未知 key 展開為空字串(與 TS 版 parity)。
|
||||
// 其他 namespace 的 {{...}} 原樣保留(static_key 不解析)。
|
||||
func interpolateTemplate(template string, secrets, runtime map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(template))
|
||||
i := 0
|
||||
for i < len(template) {
|
||||
start := strings.Index(template[i:], "{{")
|
||||
if start < 0 {
|
||||
b.WriteString(template[i:])
|
||||
break
|
||||
}
|
||||
b.WriteString(template[i : i+start])
|
||||
openIdx := i + start
|
||||
closeRel := strings.Index(template[openIdx+2:], "}}")
|
||||
if closeRel < 0 {
|
||||
b.WriteString(template[openIdx:])
|
||||
break
|
||||
}
|
||||
inner := template[openIdx+2 : openIdx+2+closeRel]
|
||||
advance := openIdx + 2 + closeRel + 2
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(inner, "secret."):
|
||||
key := inner[len("secret."):]
|
||||
b.WriteString(secrets[key])
|
||||
case strings.HasPrefix(inner, "runtime."):
|
||||
key := inner[len("runtime."):]
|
||||
b.WriteString(runtime[key])
|
||||
default:
|
||||
// 非本 primitive 負責的 namespace,原樣寫回
|
||||
b.WriteString(template[openIdx:advance])
|
||||
}
|
||||
i = advance
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func interpolateRecord(
|
||||
record map[string]string,
|
||||
secrets, runtime map[string]string,
|
||||
) map[string]string {
|
||||
if record == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
result := make(map[string]string, len(record))
|
||||
for k, v := range record {
|
||||
result[k] = interpolateTemplate(v, secrets, runtime)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
canonical_id: "claude_api"
|
||||
display_name: "Claude AI 對話"
|
||||
category: "ai"
|
||||
version: "v2"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 80
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [mira_token, prompt]
|
||||
properties:
|
||||
mira_token:
|
||||
type: string
|
||||
description: Mira daemon Bearer token(Hetzner cloud-cto Mira daemon 的 MIRA_TOKEN)
|
||||
prompt:
|
||||
type: string
|
||||
description: 要送給 Mira 的訊息(已內建 Mira 副駕 persona,不需重複設角色)
|
||||
mira_url:
|
||||
type: string
|
||||
description: Mira daemon URL,預設 https://mira.uncle6.me
|
||||
default: "https://mira.uncle6.me"
|
||||
timeout_ms:
|
||||
type: integer
|
||||
description: Daemon 協商模式 timeout,預設 25000ms(協商上限)
|
||||
default: 25000
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: 同步完成時的回應
|
||||
properties:
|
||||
text: { type: string, description: Mira 的回覆文字 }
|
||||
task_id: { type: string }
|
||||
model: { type: string, description: 「實際 routing 用的模型(haiku / sonnet)」 }
|
||||
pending:
|
||||
type: boolean
|
||||
description: 「true 時表示 daemon 切到非同步模式,task 還在跑,需 polling」
|
||||
task_id:
|
||||
type: string
|
||||
description: pending=true 時用此 id polling
|
||||
poll_url:
|
||||
type: string
|
||||
description: GET 此 URL 查詢任務進度 / 結果
|
||||
error:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "缺 mira_token"
|
||||
given: '{"prompt":"hi"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "簡短對話 25s 內回完"
|
||||
given: '{"mira_token":"...","prompt":"1+1=?"}'
|
||||
then_contains: 'success'
|
||||
tags: [ai, llm, claude, mira, primitive]
|
||||
description: "呼叫 Mira daemon (Hetzner cloud-cto) 進行 AI 對話。Daemon 內部用 Claude Agent SDK,內建 Mira 副駕 persona,可長執行任務。所有 mira-app 的 AI workflow(自動回覆、wiki 合成、新聞註解)都用此零件。"
|
||||
config_example: |
|
||||
ai_reply:
|
||||
mira_token: "{{secret.mira_token}}"
|
||||
prompt: |
|
||||
用戶 leo 在 mira 河道發了這則貼文:
|
||||
「{{trigger.post_content}}」
|
||||
|
||||
請以副駕 AI 的身份留言回應,簡短繁中,務實。
|
||||
timeout_ms: 25000
|
||||
@@ -0,0 +1,3 @@
|
||||
module claude_api
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,180 @@
|
||||
// claude_api — 呼叫 Mira daemon(Hetzner 上跑的 Claude Agent SDK 服務)
|
||||
//
|
||||
// 架構決策(2026-05-06):
|
||||
// 不直打 Anthropic Messages API(OAuth token 限制 system prompt 角色 → rate_limit_error)
|
||||
// 改透過已部署的 cloud-cto Mira daemon (https://mira.uncle6.me/mira/execute)
|
||||
// 該 daemon 用 Claude Agent SDK,已內建 Mira persona,可長執行任務
|
||||
//
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §6(五個 P0 零件)
|
||||
//
|
||||
//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 {
|
||||
MiraURL string `json:"mira_url"` // 預設 https://mira.uncle6.me
|
||||
MiraToken string `json:"mira_token"` // Mira daemon Bearer token
|
||||
Prompt string `json:"prompt"` // 必填:要傳給 Mira 的訊息
|
||||
TimeoutMS int `json:"timeout_ms"` // 預設 25000(daemon 協商模式上限)
|
||||
Model string `json:"model"` // 'haiku' / 'sonnet' / 'opus',預設 haiku(daemon 端)
|
||||
CallbackURL string `json:"callback_url"` // optional:daemon 完成 task 時 POST 此 URL 通知(Resumable workflow,SDD: resumable-workflow)
|
||||
}
|
||||
|
||||
var dummy [1]byte
|
||||
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
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.MiraToken == "" {
|
||||
writeError("mira_token 必填(Mira daemon Bearer token)")
|
||||
return
|
||||
}
|
||||
if input.Prompt == "" {
|
||||
writeError("prompt 必填")
|
||||
return
|
||||
}
|
||||
|
||||
miraURL := input.MiraURL
|
||||
if miraURL == "" {
|
||||
miraURL = "https://mira.uncle6.me"
|
||||
}
|
||||
timeoutMS := input.TimeoutMS
|
||||
if timeoutMS <= 0 {
|
||||
// 預設 120s:daemon 協商期會在 25s 切非同步 + callback;
|
||||
// callback_url 存在時,timeout 上限不重要(daemon 會 fire callback 不論多久)
|
||||
timeoutMS = 120000
|
||||
}
|
||||
|
||||
// Mira daemon /execute 介面
|
||||
body := map[string]interface{}{
|
||||
"prompt": input.Prompt,
|
||||
"timeout_ms": timeoutMS,
|
||||
}
|
||||
if input.Model != "" {
|
||||
body["model"] = input.Model
|
||||
}
|
||||
if input.CallbackURL != "" {
|
||||
body["callback_url"] = input.CallbackURL
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + input.MiraToken,
|
||||
}
|
||||
headersBytes, _ := json.Marshal(headers)
|
||||
|
||||
url := miraURL + "/mira/execute"
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte("POST")
|
||||
|
||||
outBuf := make([]byte, 1024*1024) // 1MB
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLen := safePtr(headersBytes)
|
||||
bodyPtr, bodyLenU := safePtr(bodyBytes)
|
||||
|
||||
result := hostHttpRequest(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLen,
|
||||
bodyPtr, bodyLenU,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("Mira daemon request failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
|
||||
respStr := string(outBuf[:outLen])
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(respStr), &resp); err != nil {
|
||||
writeError("Mira returned non-JSON: " + respStr)
|
||||
return
|
||||
}
|
||||
|
||||
// 偵測錯誤回應
|
||||
if errObj, hasErr := resp["error"]; hasErr {
|
||||
errBytes, _ := json.Marshal(errObj)
|
||||
writeError("Mira error: " + string(errBytes))
|
||||
return
|
||||
}
|
||||
|
||||
// daemon 回應格式:
|
||||
// 同步完成: {"task_id":"...","status":"done","output":"...","model":"..."}
|
||||
// 非同步: {"task_id":"...","status":"running","estimated_seconds":N}
|
||||
|
||||
status, _ := resp["status"].(string)
|
||||
if status == "running" {
|
||||
// 還沒完成,回傳 task_id 給 caller 自己 polling
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"pending": true,
|
||||
"task_id": resp["task_id"],
|
||||
"estimated_seconds": resp["estimated_seconds"],
|
||||
"poll_url": miraURL + "/mira/execute/" + toString(resp["task_id"]),
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
// status == "done" 的場景
|
||||
out := map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"text": resp["output"],
|
||||
"task_id": resp["task_id"],
|
||||
"model": resp["model"],
|
||||
},
|
||||
}
|
||||
outJSON, _ := json.Marshal(out)
|
||||
os.Stdout.Write(outJSON)
|
||||
}
|
||||
|
||||
func toString(v interface{}) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
canonical_id: "cron"
|
||||
display_name: "定時排程"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [cron_expr]
|
||||
properties:
|
||||
cron_expr:
|
||||
type: string
|
||||
description: 標準 5 欄位 cron expression,如 0 9 * * *
|
||||
description:
|
||||
type: string
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
cron_id:
|
||||
type: string
|
||||
cron_expr:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "有效 cron expression"
|
||||
given: '{"cron_expr":"0 9 * * *","description":"每天早上9點"}'
|
||||
then_contains: '"enabled":true'
|
||||
- scenario: "無效 cron expression(欄位數不對)"
|
||||
given: '{"cron_expr":"0 9 * *"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 cron_expr"
|
||||
given: '{}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, cron, schedule, trigger, timer]
|
||||
description: "驗證 cron expression 格式並回傳 cron_id。實際排程由 Cypher Executor 負責。"
|
||||
config_example: |
|
||||
my_cron: # 節點名稱(可自訂)
|
||||
cron_expr: "0 9 * * *" # 標準 5 欄位 cron 表達式(必填),如:每天早上 9 點
|
||||
description: "每天早上9點執行" # 排程說明文字(選填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,123 @@
|
||||
// cron — 驗證 cron expression 格式,回傳 cron_id
|
||||
// 實際排程由 Cypher Executor 負責
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
CronExpr string `json:"cron_expr"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.CronExpr == "" {
|
||||
writeError("cron_expr 必填")
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateCronExpr(input.CronExpr); err != nil {
|
||||
writeError("無效的 cron expression: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
cronID := "cron-" + strconv.FormatInt(time.Now().UnixNano()/1e6, 10)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"cron_id": cronID,
|
||||
"cron_expr": input.CronExpr,
|
||||
"enabled": true,
|
||||
"description": input.Description,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// validateCronExpr — 驗證標準 5 欄位 cron expression
|
||||
func validateCronExpr(expr string) error {
|
||||
fields := strings.Fields(expr)
|
||||
if len(fields) != 5 {
|
||||
return &cronError{"需要 5 個欄位(分 時 日 月 週),實際: " + strconv.Itoa(len(fields))}
|
||||
}
|
||||
|
||||
// 各欄位範圍: 分(0-59), 時(0-23), 日(1-31), 月(1-12), 週(0-7)
|
||||
ranges := [][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 7}}
|
||||
names := []string{"分鐘", "小時", "日", "月", "星期"}
|
||||
|
||||
for i, field := range fields {
|
||||
if err := validateCronField(field, ranges[i][0], ranges[i][1], names[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCronField(field string, min, max int, name string) error {
|
||||
if field == "*" {
|
||||
return nil
|
||||
}
|
||||
// 支援 */n 格式
|
||||
if strings.HasPrefix(field, "*/") {
|
||||
n, err := strconv.Atoi(field[2:])
|
||||
if err != nil || n <= 0 {
|
||||
return &cronError{name + " 步進值無效: " + field}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 支援 a-b 範圍
|
||||
if strings.Contains(field, "-") {
|
||||
parts := strings.SplitN(field, "-", 2)
|
||||
a, err1 := strconv.Atoi(parts[0])
|
||||
b, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil || a < min || b > max || a > b {
|
||||
return &cronError{name + " 範圍無效: " + field}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 支援逗號分隔
|
||||
if strings.Contains(field, ",") {
|
||||
for _, part := range strings.Split(field, ",") {
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil || n < min || n > max {
|
||||
return &cronError{name + " 值無效: " + part}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 單一數字
|
||||
n, err := strconv.Atoi(field)
|
||||
if err != nil || n < min || n > max {
|
||||
return &cronError{name + " 值超出範圍 [" + strconv.Itoa(min) + "-" + strconv.Itoa(max) + "]: " + field}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type cronError struct{ msg string }
|
||||
|
||||
func (e *cronError) Error() string { return e.msg }
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
canonical_id: "date_ops"
|
||||
display_name: "日期操作"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [operation]
|
||||
properties:
|
||||
operation:
|
||||
type: string
|
||||
enum: [now, format, parse]
|
||||
input:
|
||||
type: string
|
||||
description: ISO 日期字串(now 操作可省略)
|
||||
args:
|
||||
type: object
|
||||
properties:
|
||||
layout:
|
||||
type: string
|
||||
description: Go time layout(如 2006-01-02)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
result: {}
|
||||
operation:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "now 操作"
|
||||
given: '{"operation":"now"}'
|
||||
then_contains: '"success":true'
|
||||
- scenario: "parse 操作"
|
||||
given: '{"operation":"parse","input":"2024-01-15T10:30:00Z"}'
|
||||
then_contains: '"year":2024'
|
||||
- scenario: "無效日期"
|
||||
given: '{"operation":"parse","input":"not-a-date"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, data, date, time, transform]
|
||||
description: "日期操作:now(當前時間)、format(格式化)、parse(解析 ISO 字串)。"
|
||||
config_example: |
|
||||
my_date_op: # 節點名稱(可自訂)
|
||||
operation: "format" # 運算類型(必填),可選值:now/format/parse
|
||||
input: "2024-01-15T10:30:00Z" # ISO 日期字串(now 操作可省略,其餘必填)
|
||||
args: # 操作參數(選填)
|
||||
layout: "2006-01-02" # format 用:Go time layout 格式字串
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,103 @@
|
||||
// date_ops — 日期操作
|
||||
// 支援: now, format, parse
|
||||
// TinyGo time 套件支援有限,只實作基本功能
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Args struct {
|
||||
Layout string `json:"layout"`
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
Operation string `json:"operation"`
|
||||
Input string `json:"input"`
|
||||
Args Args `json:"args"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.Operation == "" {
|
||||
writeError("operation 必填")
|
||||
return
|
||||
}
|
||||
|
||||
switch input.Operation {
|
||||
case "now":
|
||||
result := time.Now().UTC().Format(time.RFC3339)
|
||||
writeResult("now", result)
|
||||
case "format":
|
||||
if input.Input == "" {
|
||||
writeError("format 需要 input 日期字串")
|
||||
return
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, input.Input)
|
||||
if err != nil {
|
||||
// 嘗試其他格式
|
||||
t, err = time.Parse("2006-01-02", input.Input)
|
||||
if err != nil {
|
||||
writeError("無法解析日期: " + err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
layout := input.Args.Layout
|
||||
if layout == "" {
|
||||
layout = time.RFC3339
|
||||
}
|
||||
writeResult("format", t.Format(layout))
|
||||
case "parse":
|
||||
if input.Input == "" {
|
||||
writeError("parse 需要 input 日期字串")
|
||||
return
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, input.Input)
|
||||
if err != nil {
|
||||
t, err = time.Parse("2006-01-02", input.Input)
|
||||
if err != nil {
|
||||
writeError("無法解析日期: " + err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeResult("parse", map[string]interface{}{
|
||||
"iso": t.UTC().Format(time.RFC3339),
|
||||
"year": t.Year(),
|
||||
"month": int(t.Month()),
|
||||
"day": t.Day(),
|
||||
"hour": t.Hour(),
|
||||
"min": t.Minute(),
|
||||
"sec": t.Second(),
|
||||
})
|
||||
default:
|
||||
writeError("不支援的 operation: " + input.Operation)
|
||||
}
|
||||
}
|
||||
|
||||
func writeResult(op string, result interface{}) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"result": result, "operation": op},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
canonical_id: "filter"
|
||||
display_name: "過濾陣列"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [items, condition]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
description: 要過濾的陣列
|
||||
condition:
|
||||
type: object
|
||||
required: [key, op, value]
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: 要比較的欄位名稱
|
||||
op:
|
||||
type: string
|
||||
enum: [eq, ne, gt, lt, contains]
|
||||
value:
|
||||
type: string
|
||||
description: 比較值
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
count:
|
||||
type: number
|
||||
gherkin_tests:
|
||||
- scenario: "過濾 status=active 的元素"
|
||||
given: '{"items":[{"status":"active"},{"status":"inactive"}],"condition":{"key":"status","op":"eq","value":"active"}}'
|
||||
then_contains: '{"success":true'
|
||||
- scenario: "空陣列輸入"
|
||||
given: '{"items":[],"condition":{"key":"status","op":"eq","value":"active"}}'
|
||||
then_contains: '{"success":true'
|
||||
- scenario: "缺少 condition.key"
|
||||
given: '{"items":[],"condition":{"op":"eq","value":"x"}}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, filter, array, condition]
|
||||
description: "依條件過濾陣列,回傳符合條件的元素。支援 eq/ne/gt/lt/contains 運算子。"
|
||||
config_example: |
|
||||
my_filter: # 節點名稱(可自訂)
|
||||
items: "{{upstream.results}}" # 要過濾的陣列(必填)
|
||||
condition: # 過濾條件(必填)
|
||||
key: status # 要比較的欄位名稱(必填)
|
||||
op: eq # 運算子:eq / ne / gt / lt / contains(必填)
|
||||
value: active # 比較值(必填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,122 @@
|
||||
// filter — 依條件過濾陣列
|
||||
// op 支援: eq, ne, gt, lt, contains
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Condition struct {
|
||||
Key string `json:"key"`
|
||||
Op string `json:"op"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
Condition Condition `json:"condition"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if input.Condition.Key == "" {
|
||||
writeError("condition.key 必填")
|
||||
return
|
||||
}
|
||||
|
||||
var filtered []json.RawMessage
|
||||
for _, item := range input.Items {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(item, &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
fieldRaw, ok := obj[input.Condition.Key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if matchCondition(fieldRaw, input.Condition.Op, input.Condition.Value) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
|
||||
if filtered == nil {
|
||||
filtered = []json.RawMessage{}
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"items": filtered,
|
||||
"count": len(filtered),
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func matchCondition(fieldRaw json.RawMessage, op, expected string) bool {
|
||||
// 取得欄位字串值
|
||||
var strVal string
|
||||
var numVal float64
|
||||
isNum := false
|
||||
|
||||
// 嘗試解析為數字
|
||||
if err := json.Unmarshal(fieldRaw, &numVal); err == nil {
|
||||
isNum = true
|
||||
strVal = strconv.FormatFloat(numVal, 'f', -1, 64)
|
||||
} else {
|
||||
// 嘗試解析為字串
|
||||
if err := json.Unmarshal(fieldRaw, &strVal); err != nil {
|
||||
strVal = string(fieldRaw)
|
||||
}
|
||||
}
|
||||
|
||||
switch strings.ToLower(op) {
|
||||
case "eq":
|
||||
return strVal == expected
|
||||
case "ne":
|
||||
return strVal != expected
|
||||
case "gt":
|
||||
if !isNum {
|
||||
return false
|
||||
}
|
||||
threshold, err := strconv.ParseFloat(expected, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return numVal > threshold
|
||||
case "lt":
|
||||
if !isNum {
|
||||
return false
|
||||
}
|
||||
threshold, err := strconv.ParseFloat(expected, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return numVal < threshold
|
||||
case "contains":
|
||||
return strings.Contains(strVal, expected)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
canonical_id: "foreach_control"
|
||||
display_name: "迴圈控制"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
description: 要迭代的陣列
|
||||
item_key:
|
||||
type: string
|
||||
description: 每個元素注入的變數名,預設 item
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
count:
|
||||
type: number
|
||||
current_index:
|
||||
type: number
|
||||
current_item: {}
|
||||
item_key:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "正常迭代"
|
||||
given: '{"items":[1,2,3],"item_key":"item"}'
|
||||
then_contains: '"current_index":0'
|
||||
- scenario: "空陣列"
|
||||
given: '{"items":[]}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, control, foreach, loop, iteration]
|
||||
description: "輸出第一個元素供 Cypher Executor 迭代,current_index 從 0 開始。"
|
||||
config_example: |
|
||||
my_loop: # 節點名稱(可自訂)
|
||||
items: "{{upstream.results}}" # 要迭代的陣列(必填)
|
||||
item_key: item # 每個元素注入的變數名,預設 item(選填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,55 @@
|
||||
// foreach_control — 輸出第一個元素,Cypher Executor 負責迭代
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
ItemKey string `json:"item_key"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if len(input.Items) == 0 {
|
||||
writeError("items 不能為空")
|
||||
return
|
||||
}
|
||||
|
||||
itemKey := input.ItemKey
|
||||
if itemKey == "" {
|
||||
itemKey = "item"
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"items": input.Items,
|
||||
"count": len(input.Items),
|
||||
"current_index": 0,
|
||||
"current_item": input.Items[0],
|
||||
"item_key": itemKey,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [url]
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
description: 目標 URL(必填)
|
||||
method:
|
||||
type: string
|
||||
description: HTTP 方法(GET / POST / PUT / DELETE 等),預設 GET
|
||||
default: GET
|
||||
headers:
|
||||
type: object
|
||||
description: 自訂 HTTP headers(key-value 物件)
|
||||
additionalProperties:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
description: 模式 A — body 字串(自行 stringify 後傳)
|
||||
body_json:
|
||||
type: object
|
||||
description: 模式 B — body 物件,零件內部 JSON.stringify(yaml 端不用手組字串)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
body:
|
||||
type: string
|
||||
description: HTTP 回應 body(字串)
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 url"
|
||||
given: '{"method":"GET"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "基本 GET 請求"
|
||||
given: '{"url":"https://example.com"}'
|
||||
then_contains: '{"success":true'
|
||||
tags: [integration, http, request, api]
|
||||
description: "發送任意 HTTP 請求並回傳 status 與 body。透過 host function 呼叫,.wasm 本身不含網路 syscall。headers 由用戶手動填入。"
|
||||
config_example: |
|
||||
http_call: # 節點名稱(可自訂)
|
||||
url: "" # 目標 URL(必填)
|
||||
method: "GET" # HTTP 方法(選填,預設 GET)
|
||||
headers: # 自訂 headers(選填,用戶手動填入)
|
||||
Content-Type: "application/json"
|
||||
Authorization: "Bearer <your_token>"
|
||||
body: {} # 請求 body(選填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,138 @@
|
||||
// http_request — 發送任意 HTTP 請求,回傳 status + body
|
||||
// 透過 host function 發出 HTTP,.wasm 本身不含網路 syscall
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// host function 宣告(由 WASI shim 注入)
|
||||
//
|
||||
//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 {
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body string `json:"body"` // 模式 A:直接 string body
|
||||
BodyJSON map[string]interface{} `json:"body_json"` // 模式 B:物件,內部 stringify(避免 yaml 端要自己組 JSON 字串)
|
||||
}
|
||||
|
||||
// dummy byte for safe zero-length unsafe.Pointer operations
|
||||
var dummy [1]byte
|
||||
|
||||
// safePtr returns a valid pointer for an empty-or-nonempty byte slice.
|
||||
// TinyGo panics with "index out of range" when taking &b[0] on empty b.
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
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.URL == "" {
|
||||
writeError("url 必填")
|
||||
return
|
||||
}
|
||||
|
||||
method := input.Method
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
headersJSON := "{}"
|
||||
if len(input.Headers) > 0 {
|
||||
b, _ := json.Marshal(input.Headers)
|
||||
headersJSON = string(b)
|
||||
}
|
||||
|
||||
// body 來源優先順序:body_json(物件 → JSON 字串)> body(直接 string)
|
||||
bodyStr := input.Body
|
||||
if input.BodyJSON != nil {
|
||||
b, err := json.Marshal(input.BodyJSON)
|
||||
if err == nil {
|
||||
bodyStr = string(b)
|
||||
}
|
||||
}
|
||||
|
||||
urlBytes := []byte(input.URL)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(bodyStr)
|
||||
outBuf := make([]byte, 65536) // 64KB output buffer
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLen := safePtr(headersBytes)
|
||||
bodyPtr, bodyLen := safePtr(bodyBytes)
|
||||
|
||||
result := hostHttpRequest(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLen,
|
||||
bodyPtr, bodyLen,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("HTTP request failed")
|
||||
return
|
||||
}
|
||||
|
||||
responseStr := string(outBuf[:outLen])
|
||||
|
||||
// 2026-05-14:偵測 JSON `{"error":"..."}` 模式視為 4xx 失敗
|
||||
// 理由:host function 沒回 HTTP status code(架構債),先用 body 啟發式 catch。
|
||||
// 標準 API(cypher-executor / KBDB / 多數 REST)失敗時都回 {"error":...} JSON。
|
||||
// 對應 SDD: arcrun.md 三-A P1 #4「http_request status code 缺乏 surface」。
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(responseStr), &parsed); err == nil {
|
||||
if errVal, ok := parsed["error"]; ok && errVal != nil {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": errVal,
|
||||
"data": map[string]interface{}{"body": responseStr},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"body": responseStr},
|
||||
})
|
||||
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: "if_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: [condition]
|
||||
properties:
|
||||
condition:
|
||||
type: string
|
||||
description: 條件運算式,支援 key(truthy)、key == value、key > number、key < number
|
||||
input:
|
||||
type: object
|
||||
description: 條件運算式中參照的變數字典
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
type: boolean
|
||||
branch:
|
||||
type: string
|
||||
enum: ["true", "false"]
|
||||
gherkin_tests:
|
||||
- scenario: "條件成立走 true 分支"
|
||||
given: '{"condition":"status == active","input":{"status":"active"}}'
|
||||
then_contains: '"branch":"true"'
|
||||
- scenario: "條件不成立走 false 分支"
|
||||
given: '{"condition":"status == active","input":{"status":"inactive"}}'
|
||||
then_contains: '"branch":"false"'
|
||||
- scenario: "缺少 condition"
|
||||
given: '{"input":{"status":"active"}}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, control, if, condition, branch]
|
||||
description: "評估條件運算式,依結果路由到 true 或 false 分支。"
|
||||
config_example: |
|
||||
my_if: # 節點名稱(可自訂)
|
||||
condition: "status == active" # 條件運算式(必填)
|
||||
input: # 條件運算式中參照的變數字典(選填)
|
||||
status: "{{upstream.status}}"
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,138 @@
|
||||
// if_control — 單一條件判斷,true/false 兩個出口
|
||||
// condition 支援:key(truthy)、key == value、key > number、key < number
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Condition string `json:"condition"`
|
||||
Input map[string]interface{} `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.Condition == "" {
|
||||
writeError("condition 必填")
|
||||
return
|
||||
}
|
||||
|
||||
result := evaluateCondition(input.Condition, input.Input)
|
||||
branch := "false"
|
||||
if result {
|
||||
branch = "true"
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"result": result, "branch": branch},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func toString(v interface{}) string {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case float64:
|
||||
return strconv.FormatFloat(val, 'f', -1, 64)
|
||||
case bool:
|
||||
if val {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
b, _ := json.Marshal(val)
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
func evaluateCondition(condition string, ctx map[string]interface{}) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
expr := strings.TrimSpace(condition)
|
||||
|
||||
// key == value
|
||||
if idx := strings.Index(expr, "=="); idx > 0 {
|
||||
key := strings.TrimSpace(expr[:idx])
|
||||
expected := strings.Trim(strings.TrimSpace(expr[idx+2:]), `"'`)
|
||||
v, ok := ctx[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return toString(v) == expected
|
||||
}
|
||||
// key > number
|
||||
if idx := strings.Index(expr, ">"); idx > 0 {
|
||||
key := strings.TrimSpace(expr[:idx])
|
||||
threshold, err := strconv.ParseFloat(strings.TrimSpace(expr[idx+1:]), 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
v, ok := ctx[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
n, err := strconv.ParseFloat(toString(v), 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return n > threshold
|
||||
}
|
||||
// key < number
|
||||
if idx := strings.Index(expr, "<"); idx > 0 {
|
||||
key := strings.TrimSpace(expr[:idx])
|
||||
threshold, err := strconv.ParseFloat(strings.TrimSpace(expr[idx+1:]), 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
v, ok := ctx[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
n, err := strconv.ParseFloat(toString(v), 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return n < threshold
|
||||
}
|
||||
// truthy check
|
||||
v, ok := ctx[expr]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
case string:
|
||||
return val != ""
|
||||
case float64:
|
||||
return val != 0
|
||||
case nil:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
canonical_id: "kbdb_upsert_block"
|
||||
display_name: "KBDB Upsert Block"
|
||||
category: "data"
|
||||
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: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [api_key, page_name, content]
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: KBDB partner key(ak_xxx)
|
||||
page_name:
|
||||
type: string
|
||||
description: 當 idempotency key。內部用 GET /blocks?page_name= 查找。
|
||||
content:
|
||||
type: string
|
||||
description: block 內容(PATCH 時覆寫,CREATE 時新建)
|
||||
type:
|
||||
type: string
|
||||
description: block type(建立時用,PATCH 時忽略)
|
||||
parent_id:
|
||||
type: string
|
||||
description: 父 block id(建立時用,PATCH 時忽略)
|
||||
user_id:
|
||||
type: string
|
||||
description: 建立時帶入 + lookup 時用來 filter(同 page_name 多 user 共存場景)
|
||||
source:
|
||||
type: string
|
||||
description: 來源標記
|
||||
tags_json:
|
||||
type: string
|
||||
description: tags JSON 字串(PATCH 時轉 array、CREATE 時直傳)
|
||||
kbdb_url:
|
||||
type: string
|
||||
description: KBDB API base(預設 https://kbdb.finally.click)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
action:
|
||||
type: string
|
||||
enum: [created, patched]
|
||||
description: 實際做了哪個動作
|
||||
data:
|
||||
type: object
|
||||
description: KBDB 回傳(含 block id 等)
|
||||
error:
|
||||
type: string
|
||||
phase:
|
||||
type: string
|
||||
enum: [lookup, patch, create]
|
||||
description: 出錯在哪個階段
|
||||
gherkin_tests:
|
||||
- scenario: "缺 page_name"
|
||||
given: '{"api_key":"ak_x","content":"hi"}'
|
||||
then_contains: '"success":false'
|
||||
- scenario: "建立新 block"
|
||||
given: '{"api_key":"ak_x","page_name":"new-page-uniq","content":"hello"}'
|
||||
then_contains: '"action":"created"'
|
||||
- scenario: "PATCH 既有 block"
|
||||
given: '{"api_key":"ak_x","page_name":"existing-page","content":"updated"}'
|
||||
then_contains: '"action":"patched"'
|
||||
tags: [data, storage, kbdb, upsert, primitive, idempotent]
|
||||
description: |
|
||||
Upsert:用 page_name 當 idempotency key。內部 GET 找有沒有同 page_name 的 block,
|
||||
找到就 PATCH 不到就 POST 新建。解 arcrun workflow 缺 IF/branch 能力的缺口
|
||||
(arcrun.md P1 #1)。mira 7B.3f index-entry per-entity 維護是第一個使用者。
|
||||
config_example: |
|
||||
upsert_index_entry:
|
||||
api_key: "{{api_key}}"
|
||||
page_name: "index-{{entity}}"
|
||||
parent_id: "{{mira_wiki_index_entities_id}}"
|
||||
type: "index-entry"
|
||||
user_id: "inkstone_mira_tools"
|
||||
source: "ai-canon-wiki"
|
||||
content: "{{compose_index_entry.data.text}}"
|
||||
tags_json: '["mira-wiki", "ai-generated", "index"]'
|
||||
@@ -0,0 +1,3 @@
|
||||
module kbdb_upsert_block
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,280 @@
|
||||
// kbdb_upsert_block — 用 page_name 當 idempotency key 做 upsert
|
||||
// 內部:GET /blocks?page_name=X → user_id filter → 找到 PATCH /blocks/:id 沒找到 POST /blocks
|
||||
// 解 arcrun workflow 沒 IF/branch 能力的缺口(arcrun.md P1 #1)
|
||||
// 對應 SDD:polaris/mira/.agents/specs/mira-app/design.md §3.5.12.4.1
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"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 {
|
||||
KBDBUrl string `json:"kbdb_url"` // optional
|
||||
APIKey string `json:"api_key"` // 必填
|
||||
PageName string `json:"page_name"` // 必填,當 idempotency key
|
||||
Content string `json:"content"` // 必填
|
||||
Type string `json:"type"` // optional(建立時用,PATCH 時忽略)
|
||||
ParentID string `json:"parent_id"` // optional(建立時用,PATCH 時忽略)
|
||||
UserID string `json:"user_id"` // optional(建立時用 + lookup filter)
|
||||
Source string `json:"source"` // optional
|
||||
TagsJSON string `json:"tags_json"` // optional(完整覆寫)
|
||||
CreateOnly bool `json:"create_only"` // 2026-05-17 加:若 true + 已存在 → 不 PATCH,回 action="exists"
|
||||
// 用於 stub creation 場景(避免 stub 覆寫已存在的 full wiki)
|
||||
}
|
||||
|
||||
var dummy [1]byte
|
||||
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeResult(action string, data map[string]interface{}) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"action": action,
|
||||
"data": data,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// urlEncode:跟 kbdb_get 一致,避免引入 net/url
|
||||
func urlEncode(s string) string {
|
||||
var out []byte
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '_' || c == '.' || c == '~' {
|
||||
out = append(out, c)
|
||||
} else {
|
||||
const hex = "0123456789ABCDEF"
|
||||
out = append(out, '%', hex[c>>4], hex[c&0x0f])
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func httpCall(method, url string, headers map[string]string, body []byte) ([]byte, uint32) {
|
||||
headersBytes, _ := json.Marshal(headers)
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
|
||||
outBuf := make([]byte, 1<<20) // 1MB
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLenU := safePtr(headersBytes)
|
||||
bodyPtr, bodyLenU := safePtr(body)
|
||||
|
||||
result := hostHttpRequest(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLenU,
|
||||
bodyPtr, bodyLenU,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
return outBuf[:outLen], result
|
||||
}
|
||||
|
||||
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.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.PageName == "" {
|
||||
writeError("page_name 必填(upsert 的 idempotency key)")
|
||||
return
|
||||
}
|
||||
if input.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
|
||||
kbdbURL := input.KBDBUrl
|
||||
if kbdbURL == "" {
|
||||
kbdbURL = "https://kbdb.finally.click"
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
|
||||
// ── Step 1:lookup by page_name ────────────────────────────────────
|
||||
lookupURL := kbdbURL + "/blocks?page_name=" + urlEncode(input.PageName) +
|
||||
"&limit=" + strconv.Itoa(10)
|
||||
lookupResp, callResult := httpCall("GET", lookupURL, headers, nil)
|
||||
if callResult != 0 {
|
||||
writeError("KBDB lookup failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
|
||||
var lookupParsed struct {
|
||||
Blocks []map[string]interface{} `json:"blocks"`
|
||||
Count int `json:"count"`
|
||||
Error interface{} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(lookupResp, &lookupParsed); err != nil {
|
||||
writeError("KBDB lookup returned non-JSON: " + string(lookupResp))
|
||||
return
|
||||
}
|
||||
if lookupParsed.Error != nil {
|
||||
errBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": lookupParsed.Error,
|
||||
"phase": "lookup",
|
||||
})
|
||||
os.Stdout.Write(errBytes)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Step 2:找符合 user_id 的第一筆 ──────────────────────────────
|
||||
var existing map[string]interface{}
|
||||
for _, b := range lookupParsed.Blocks {
|
||||
if input.UserID == "" {
|
||||
existing = b
|
||||
break
|
||||
}
|
||||
if uid, ok := b["user_id"].(string); ok && uid == input.UserID {
|
||||
existing = b
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3:分支寫入 ───────────────────────────────────────────────
|
||||
postHeaders := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
// CreateOnly 模式:已存在 → 不動,回 action="exists"(給 stub creation 用,
|
||||
// 避免後續 raw 提到同 entity 時把完整 wiki 覆寫成 stub)
|
||||
if input.CreateOnly {
|
||||
writeResult("exists", existing)
|
||||
return
|
||||
}
|
||||
|
||||
// PATCH 路徑
|
||||
existingID, _ := existing["id"].(string)
|
||||
if existingID == "" {
|
||||
writeError("lookup 找到 block 但 id 為空")
|
||||
return
|
||||
}
|
||||
|
||||
patchBody := make(map[string]interface{})
|
||||
patchBody["content"] = input.Content
|
||||
if input.Source != "" {
|
||||
patchBody["source"] = input.Source
|
||||
}
|
||||
if input.TagsJSON != "" {
|
||||
// PATCH endpoint 用 tags array 不是 tags_json string
|
||||
var tagsArr []string
|
||||
if err := json.Unmarshal([]byte(input.TagsJSON), &tagsArr); err == nil {
|
||||
patchBody["tags"] = tagsArr
|
||||
}
|
||||
}
|
||||
patchBodyBytes, _ := json.Marshal(patchBody)
|
||||
|
||||
patchURL := kbdbURL + "/blocks/" + existingID
|
||||
patchResp, callResult := httpCall("PATCH", patchURL, postHeaders, patchBodyBytes)
|
||||
if callResult != 0 {
|
||||
writeError("KBDB PATCH failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
var patchParsed map[string]interface{}
|
||||
if err := json.Unmarshal(patchResp, &patchParsed); err != nil {
|
||||
writeError("KBDB PATCH returned non-JSON: " + string(patchResp))
|
||||
return
|
||||
}
|
||||
if _, hasErr := patchParsed["error"]; hasErr {
|
||||
errBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": patchParsed["error"],
|
||||
"phase": "patch",
|
||||
})
|
||||
os.Stdout.Write(errBytes)
|
||||
return
|
||||
}
|
||||
writeResult("patched", patchParsed)
|
||||
return
|
||||
}
|
||||
|
||||
// CREATE 路徑
|
||||
postBody := make(map[string]interface{})
|
||||
postBody["content"] = input.Content
|
||||
postBody["page_name"] = input.PageName
|
||||
if input.Type != "" {
|
||||
postBody["type"] = input.Type
|
||||
}
|
||||
if input.ParentID != "" {
|
||||
postBody["parent_id"] = input.ParentID
|
||||
}
|
||||
if input.UserID != "" {
|
||||
postBody["user_id"] = input.UserID
|
||||
}
|
||||
if input.Source != "" {
|
||||
postBody["source"] = input.Source
|
||||
}
|
||||
if input.TagsJSON != "" {
|
||||
postBody["tags_json"] = input.TagsJSON
|
||||
}
|
||||
postBodyBytes, _ := json.Marshal(postBody)
|
||||
|
||||
postURL := kbdbURL + "/blocks"
|
||||
postResp, callResult := httpCall("POST", postURL, postHeaders, postBodyBytes)
|
||||
if callResult != 0 {
|
||||
writeError("KBDB POST failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
var postParsed map[string]interface{}
|
||||
if err := json.Unmarshal(postResp, &postParsed); err != nil {
|
||||
writeError("KBDB POST returned non-JSON: " + string(postResp))
|
||||
return
|
||||
}
|
||||
if _, hasErr := postParsed["error"]; hasErr {
|
||||
errBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": postParsed["error"],
|
||||
"phase": "create",
|
||||
})
|
||||
os.Stdout.Write(errBytes)
|
||||
return
|
||||
}
|
||||
writeResult("created", postParsed)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
canonical_id: "km_writer"
|
||||
display_name: "KM Writer"
|
||||
category: "api"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [action, mira_url, token]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
description: "操作類型:read_journal | read_journal_date | append_journal | list_pages | read_page | write_page"
|
||||
enum: [read_journal, read_journal_date, append_journal, list_pages, read_page, write_page]
|
||||
mira_url:
|
||||
type: string
|
||||
description: "Mira 服務基礎 URL(例:https://mira.uncle6.me)"
|
||||
token:
|
||||
type: string
|
||||
description: "Mira MIRA_TOKEN(Bearer token)"
|
||||
content:
|
||||
type: string
|
||||
description: "內容(append_journal / write_page 時必填)"
|
||||
timestamp:
|
||||
type: string
|
||||
description: "ISO 8601 時間戳(append_journal 時選填,影響日期和時間顯示)"
|
||||
date:
|
||||
type: string
|
||||
description: "日期 YYYY-MM-DD(read_journal_date 時必填)"
|
||||
name:
|
||||
type: string
|
||||
description: "頁面名稱(read_page / write_page 時必填)"
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: "Mira API 回應資料"
|
||||
error:
|
||||
type: string
|
||||
description: "錯誤訊息(success=false 時)"
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 action"
|
||||
given: '{"mira_url":"https://mira.uncle6.me","token":"abc"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 token"
|
||||
given: '{"action":"list_pages","mira_url":"https://mira.uncle6.me"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [km, journal, logseq, mira, knowledge-management]
|
||||
description: "讀寫 Mira leo-graph 的 journals 和 pages。透過 host function 呼叫 Mira /km/* API,支援讀取、新增日誌條目,以及讀寫頁面。"
|
||||
config_example: |
|
||||
append_to_journal:
|
||||
action: "append_journal"
|
||||
mira_url: "https://mira.uncle6.me"
|
||||
token: "<mira_token>"
|
||||
content: "今天完成了 arcrun km_writer 元件"
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,177 @@
|
||||
// km_writer — 讀寫 Mira leo-graph(journals + pages)
|
||||
// 透過 host function 呼叫 Mira /km/* API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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
|
||||
|
||||
// Input actions:
|
||||
// read_journal — GET today's journal (requires: mira_url, token)
|
||||
// read_journal_date — GET journal by date (requires: mira_url, token, date)
|
||||
// append_journal — POST append entry (requires: mira_url, token, content; optional: timestamp)
|
||||
// list_pages — GET all pages (requires: mira_url, token)
|
||||
// read_page — GET page by name (requires: mira_url, token, name)
|
||||
// write_page — PUT write page (requires: mira_url, token, name, content)
|
||||
|
||||
type Input struct {
|
||||
Action string `json:"action"`
|
||||
MiraURL string `json:"mira_url"`
|
||||
Token string `json:"token"`
|
||||
Content string `json:"content"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var inp Input
|
||||
if err := json.Unmarshal(raw, &inp); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if inp.Action == "" {
|
||||
writeError("action 必填")
|
||||
return
|
||||
}
|
||||
if inp.MiraURL == "" {
|
||||
writeError("mira_url 必填")
|
||||
return
|
||||
}
|
||||
if inp.Token == "" {
|
||||
writeError("token 必填")
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := fmt.Sprintf(`{"Authorization":"Bearer %s","Content-Type":"application/json"}`, inp.Token)
|
||||
|
||||
switch inp.Action {
|
||||
case "read_journal":
|
||||
result := doRequest(inp.MiraURL+"/km/journal", "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "read_journal_date":
|
||||
if inp.Date == "" {
|
||||
writeError("date 必填(格式 YYYY-MM-DD)")
|
||||
return
|
||||
}
|
||||
result := doRequest(inp.MiraURL+"/km/journal/"+inp.Date, "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "append_journal":
|
||||
if inp.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
bodyMap := map[string]string{"content": inp.Content}
|
||||
if inp.Timestamp != "" {
|
||||
bodyMap["timestamp"] = inp.Timestamp
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(bodyMap)
|
||||
result := doRequest(inp.MiraURL+"/km/journal", "POST", authHeader, string(bodyBytes))
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "list_pages":
|
||||
result := doRequest(inp.MiraURL+"/km/pages", "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "read_page":
|
||||
if inp.Name == "" {
|
||||
writeError("name 必填")
|
||||
return
|
||||
}
|
||||
result := doRequest(inp.MiraURL+"/km/page/"+inp.Name, "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "write_page":
|
||||
if inp.Name == "" {
|
||||
writeError("name 必填")
|
||||
return
|
||||
}
|
||||
if inp.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
bodyMap := map[string]string{"content": inp.Content}
|
||||
bodyBytes, _ := json.Marshal(bodyMap)
|
||||
result := doRequest(inp.MiraURL+"/km/page/"+inp.Name, "PUT", authHeader, string(bodyBytes))
|
||||
os.Stdout.Write(result)
|
||||
|
||||
default:
|
||||
writeError("未知 action: " + inp.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func doRequest(url, method, headersJSON, body string) []byte {
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
outBuf := make([]byte, 131072) // 128KB
|
||||
var outLen uint32
|
||||
|
||||
if len(bodyBytes) == 0 {
|
||||
bodyBytes = []byte{}
|
||||
}
|
||||
|
||||
var bodyPtr uintptr
|
||||
var bodyLen uint32
|
||||
if len(bodyBytes) > 0 {
|
||||
bodyPtr = uintptr(unsafe.Pointer(&bodyBytes[0]))
|
||||
bodyLen = uint32(len(bodyBytes))
|
||||
}
|
||||
|
||||
code := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
uintptr(unsafe.Pointer(&headersBytes[0])), uint32(len(headersBytes)),
|
||||
bodyPtr, bodyLen,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if code != 0 {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": "HTTP request failed"})
|
||||
return out
|
||||
}
|
||||
|
||||
responseStr := string(outBuf[:outLen])
|
||||
|
||||
// Try to parse the response as JSON to forward it
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(responseStr), &parsed); err != nil {
|
||||
// Not JSON — wrap it
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": true, "data": responseStr})
|
||||
return out
|
||||
}
|
||||
|
||||
// Forward the parsed response as-is, wrapped in success
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": true, "data": parsed})
|
||||
return out
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
canonical_id: "merge"
|
||||
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: [inputs]
|
||||
properties:
|
||||
inputs:
|
||||
type: array
|
||||
description: 要合併的物件陣列,後者欄位覆蓋前者
|
||||
items:
|
||||
type: object
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: 所有輸入物件合併後的結果
|
||||
gherkin_tests:
|
||||
- scenario: "合併兩個物件"
|
||||
given: '{"inputs":[{"a":1},{"b":2}]}'
|
||||
then_contains: '"a":1'
|
||||
- scenario: "後者欄位覆蓋前者"
|
||||
given: '{"inputs":[{"a":1},{"a":2}]}'
|
||||
then_contains: '"a":2'
|
||||
- scenario: "inputs 為空陣列時失敗"
|
||||
given: '{"inputs":[]}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, merge, combine, object, context]
|
||||
description: "將多個物件合併為一個,後者欄位覆蓋前者同名欄位。"
|
||||
config_example: |
|
||||
my_merge: # 節點名稱(可自訂)
|
||||
inputs: # 要合併的物件陣列(必填)
|
||||
- "{{node_a.data}}" # 第一個來源物件
|
||||
- "{{node_b.data}}" # 第二個來源物件(後者覆蓋前者同名欄位)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,44 @@
|
||||
// merge — 合併多個輸入物件為一個
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Inputs []map[string]interface{} `json:"inputs"`
|
||||
}
|
||||
|
||||
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.Inputs) == 0 {
|
||||
writeError("inputs 陣列不能為空")
|
||||
return
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for _, obj := range input.Inputs {
|
||||
for k, v := range obj {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": true, "data": result})
|
||||
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: "number_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: [round, floor, ceil, abs, add, subtract, multiply, divide, mod, min, max, format]
|
||||
input:
|
||||
type: number
|
||||
args:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
type: number
|
||||
decimals:
|
||||
type: number
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
result: {}
|
||||
operation:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "round 操作"
|
||||
given: '{"operation":"round","input":3.14}'
|
||||
then_contains: '"result":3'
|
||||
- scenario: "add 操作"
|
||||
given: '{"operation":"add","input":10,"args":{"value":5}}'
|
||||
then_contains: '"result":15'
|
||||
- scenario: "除以零"
|
||||
given: '{"operation":"divide","input":10,"args":{"value":0}}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, data, number, math, transform]
|
||||
description: "數字操作:round/floor/ceil/abs/add/subtract/multiply/divide/mod/min/max/format。"
|
||||
config_example: |
|
||||
my_number_op: # 節點名稱(可自訂)
|
||||
operation: "add" # 運算類型(必填),可選值:round/floor/ceil/abs/add/subtract/multiply/divide/mod/min/max/format
|
||||
input: 10 # 輸入數字(必填)
|
||||
args: # 操作參數,依 operation 而定(選填)
|
||||
value: 5 # add/subtract/multiply/divide/mod/min/max 用:第二個運算元
|
||||
decimals: 2 # round/format 用:小數位數
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,100 @@
|
||||
// number_ops — 數字操作
|
||||
// 支援: round, floor, ceil, abs, add, subtract, multiply, divide, mod, min, max, format
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Args struct {
|
||||
Value float64 `json:"value"`
|
||||
Decimals int `json:"decimals"`
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
Operation string `json:"operation"`
|
||||
Input float64 `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
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
|
||||
switch input.Operation {
|
||||
case "round":
|
||||
result = math.Round(input.Input)
|
||||
case "floor":
|
||||
result = math.Floor(input.Input)
|
||||
case "ceil":
|
||||
result = math.Ceil(input.Input)
|
||||
case "abs":
|
||||
result = math.Abs(input.Input)
|
||||
case "add":
|
||||
result = input.Input + input.Args.Value
|
||||
case "subtract":
|
||||
result = input.Input - input.Args.Value
|
||||
case "multiply":
|
||||
result = input.Input * input.Args.Value
|
||||
case "divide":
|
||||
if input.Args.Value == 0 {
|
||||
writeError("除數不能為 0")
|
||||
return
|
||||
}
|
||||
result = input.Input / input.Args.Value
|
||||
case "mod":
|
||||
if input.Args.Value == 0 {
|
||||
writeError("除數不能為 0")
|
||||
return
|
||||
}
|
||||
result = math.Mod(input.Input, input.Args.Value)
|
||||
case "min":
|
||||
result = math.Min(input.Input, input.Args.Value)
|
||||
case "max":
|
||||
result = math.Max(input.Input, input.Args.Value)
|
||||
case "format":
|
||||
decimals := input.Args.Decimals
|
||||
if decimals < 0 {
|
||||
decimals = 0
|
||||
}
|
||||
result = strconv.FormatFloat(input.Input, 'f', decimals, 64)
|
||||
default:
|
||||
writeError("不支援的 operation: " + input.Operation)
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"result": result,
|
||||
"operation": input.Operation,
|
||||
},
|
||||
})
|
||||
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: "platform_crypto"
|
||||
display_name: "Platform Crypto Primitive"
|
||||
category: "platform"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "stable"
|
||||
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: [action]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [generate_api_key, encrypt, random_token]
|
||||
email:
|
||||
type: string
|
||||
description: generate_api_key 用
|
||||
plaintext:
|
||||
type: string
|
||||
description: encrypt 用
|
||||
bytes:
|
||||
type: integer
|
||||
description: random_token 用,預設 32
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
api_key:
|
||||
type: string
|
||||
description: generate_api_key 結果,ak_ 前綴
|
||||
encrypted:
|
||||
type: string
|
||||
description: encrypt 結果,base64
|
||||
iv:
|
||||
type: string
|
||||
description: encrypt 結果,base64
|
||||
token:
|
||||
type: string
|
||||
description: random_token 結果,hex
|
||||
tags: [platform, crypto, internal]
|
||||
description: |
|
||||
平台內部 crypto primitive。
|
||||
- generate_api_key: HMAC-SHA256(email, ENCRYPTION_KEY) → ak_xxx
|
||||
- encrypt: AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}(base64)
|
||||
- random_token: crypto random bytes → hex string
|
||||
ENCRYPTION_KEY 由 host 持有,永不進入 WASM。
|
||||
@@ -0,0 +1,206 @@
|
||||
// platform_crypto — Arcrun 平台內部 crypto primitive
|
||||
//
|
||||
// Actions:
|
||||
// generate_api_key — HMAC-SHA256(email, ENCRYPTION_KEY) → ak_{hex[:32]}
|
||||
// encrypt — AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}(base64)
|
||||
// random_token — crypto random bytes → hex string
|
||||
//
|
||||
// ENCRYPTION_KEY 由 host 持有,永不進入 WASM。
|
||||
//
|
||||
// Host imports:
|
||||
// u6u.crypto_hmac_sha256 — HMAC-SHA256(data, key=ENCRYPTION_KEY) → raw bytes
|
||||
// u6u.crypto_aes_encrypt — AES-GCM(plaintext, key=ENCRYPTION_KEY) → encrypted_b64 + iv_b64
|
||||
// u6u.crypto_random_bytes — crypto-random bytes → hex string
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// ── host function 宣告 ───────────────────────────────────────────────────────
|
||||
|
||||
// crypto_hmac_sha256(dataPtr, dataLen, outPtr, outLenPtr) → 0 成功
|
||||
// key = host 的 ENCRYPTION_KEY,output = raw bytes(hex encode 由 WASM 做)
|
||||
//
|
||||
//go:wasmimport u6u crypto_hmac_sha256
|
||||
func hostCryptoHmacSha256(
|
||||
dataPtr uintptr, dataLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
// crypto_aes_encrypt(plaintextPtr, plaintextLen, outEncPtr, outEncLenPtr, outIvPtr, outIvLenPtr) → 0 成功
|
||||
// output: encrypted(base64)放 outEnc,iv(base64)放 outIv
|
||||
//
|
||||
//go:wasmimport u6u crypto_aes_encrypt
|
||||
func hostCryptoAesEncrypt(
|
||||
plaintextPtr uintptr, plaintextLen uint32,
|
||||
outEncPtr uintptr, outEncLenPtr uintptr,
|
||||
outIvPtr uintptr, outIvLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
// crypto_random_bytes(numBytes, outPtr, outLenPtr) → 0 成功
|
||||
// output: hex string
|
||||
//
|
||||
//go:wasmimport u6u crypto_random_bytes
|
||||
func hostCryptoRandomBytes(
|
||||
numBytes uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
// ── 型別 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Input struct {
|
||||
Action string `json:"action"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Plaintext string `json:"plaintext,omitempty"`
|
||||
Bytes int `json:"bytes,omitempty"`
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
switch input.Action {
|
||||
case "generate_api_key":
|
||||
if input.Email == "" {
|
||||
writeError("email 必填")
|
||||
return
|
||||
}
|
||||
sig, ok := hmacSha256([]byte(input.Email))
|
||||
if !ok {
|
||||
writeError("HMAC-SHA256 失敗")
|
||||
return
|
||||
}
|
||||
apiKey := "ak_" + hex(sig)[:32]
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"api_key": apiKey,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
|
||||
case "encrypt":
|
||||
if input.Plaintext == "" {
|
||||
writeError("plaintext 必填")
|
||||
return
|
||||
}
|
||||
encB64, ivB64, ok := aesEncrypt([]byte(input.Plaintext))
|
||||
if !ok {
|
||||
writeError("AES-GCM 加密失敗")
|
||||
return
|
||||
}
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"encrypted": encB64,
|
||||
"iv": ivB64,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
|
||||
case "random_token":
|
||||
n := input.Bytes
|
||||
if n <= 0 {
|
||||
n = 32
|
||||
}
|
||||
token, ok := randomBytes(uint32(n))
|
||||
if !ok {
|
||||
writeError("random bytes 失敗")
|
||||
return
|
||||
}
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"token": token,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
|
||||
default:
|
||||
writeError("不支援的 action: " + input.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": msg,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func hmacSha256(data []byte) ([]byte, bool) {
|
||||
if len(data) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
outBuf := make([]byte, 64) // SHA-256 = 32 bytes raw
|
||||
var outLen uint32
|
||||
status := hostCryptoHmacSha256(
|
||||
uintptr(unsafe.Pointer(&data[0])), uint32(len(data)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return nil, false
|
||||
}
|
||||
return outBuf[:outLen], true
|
||||
}
|
||||
|
||||
func aesEncrypt(plaintext []byte) (string, string, bool) {
|
||||
if len(plaintext) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
encBuf := make([]byte, 65536)
|
||||
ivBuf := make([]byte, 64)
|
||||
var encLen, ivLen uint32
|
||||
status := hostCryptoAesEncrypt(
|
||||
uintptr(unsafe.Pointer(&plaintext[0])), uint32(len(plaintext)),
|
||||
uintptr(unsafe.Pointer(&encBuf[0])), uintptr(unsafe.Pointer(&encLen)),
|
||||
uintptr(unsafe.Pointer(&ivBuf[0])), uintptr(unsafe.Pointer(&ivLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return string(encBuf[:encLen]), string(ivBuf[:ivLen]), true
|
||||
}
|
||||
|
||||
func randomBytes(n uint32) (string, bool) {
|
||||
outBuf := make([]byte, n*2+4) // hex = 2 chars per byte
|
||||
var outLen uint32
|
||||
status := hostCryptoRandomBytes(
|
||||
n,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
// hex encodes raw bytes to lowercase hex string
|
||||
func hex(b []byte) string {
|
||||
const hexChars = "0123456789abcdef"
|
||||
out := make([]byte, len(b)*2)
|
||||
for i, v := range b {
|
||||
out[i*2] = hexChars[v>>4]
|
||||
out[i*2+1] = hexChars[v&0xf]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// strings import 只為了 strings.Builder(interpolate 用,這裡不需要但 import 要保留給未來)
|
||||
var _ = strings.Builder{}
|
||||
@@ -0,0 +1,64 @@
|
||||
canonical_id: "set"
|
||||
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
|
||||
properties:
|
||||
assignments:
|
||||
type: array
|
||||
description: 賦值清單,每筆含 key 與 value(與 values 擇一必填)
|
||||
items:
|
||||
type: object
|
||||
required: [key, value]
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
value: {}
|
||||
values:
|
||||
type: object
|
||||
description: 鍵值對物件,與 assignments 擇一必填
|
||||
context:
|
||||
type: object
|
||||
description: 上游傳入的上下文,設定結果會合併覆寫
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: context 加上所有設定後的變數
|
||||
gherkin_tests:
|
||||
- scenario: "用 assignments 設定變數"
|
||||
given: '{"assignments":[{"key":"name","value":"Alice"}]}'
|
||||
then_contains: '"name":"Alice"'
|
||||
- scenario: "用 values 設定變數"
|
||||
given: '{"values":{"name":"Bob","age":30}}'
|
||||
then_contains: '"name":"Bob"'
|
||||
- scenario: "未提供 assignments 或 values 時失敗"
|
||||
given: '{"context":{"x":1}}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, set, assign, variable, context]
|
||||
description: "設定或覆寫變數,支援 assignments 陣列或 values 物件兩種格式,結果合併自 context。"
|
||||
config_example: |
|
||||
my_set: # 節點名稱(可自訂)
|
||||
assignments: # 賦值清單(與 values 擇一必填)
|
||||
- key: status
|
||||
value: active
|
||||
- key: count
|
||||
value: 0
|
||||
context: # 上游上下文,設定結果會合併覆寫(選填)
|
||||
payload: "{{upstream.data}}"
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,59 @@
|
||||
// set — 設定/覆寫變數,傳遞到下一個節點
|
||||
// 支援 assignments 陣列或 values 物件兩種格式
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Assignments []Assignment `json:"assignments"`
|
||||
Values map[string]interface{} `json:"values"`
|
||||
Context map[string]interface{} `json:"context"`
|
||||
}
|
||||
|
||||
type Assignment struct {
|
||||
Key string `json:"key"`
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range input.Context {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
if len(input.Assignments) > 0 {
|
||||
for _, a := range input.Assignments {
|
||||
result[a.Key] = a.Value
|
||||
}
|
||||
} else if len(input.Values) > 0 {
|
||||
for k, v := range input.Values {
|
||||
result[k] = v
|
||||
}
|
||||
} else {
|
||||
writeError("需提供 assignments 陣列或 values 物件")
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": true, "data": result})
|
||||
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,58 @@
|
||||
canonical_id: "string_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: [upper, lower, trim, capitalize, replace, split, join, includes, starts_with, ends_with, length, substring]
|
||||
input:
|
||||
type: string
|
||||
args:
|
||||
type: object
|
||||
description: 操作參數(依 operation 而定)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
result: {}
|
||||
operation:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "upper 操作"
|
||||
given: '{"operation":"upper","input":"hello"}'
|
||||
then_contains: '"result":"HELLO"'
|
||||
- scenario: "replace 操作"
|
||||
given: '{"operation":"replace","input":"hello world","args":{"from":"world","to":"u6u"}}'
|
||||
then_contains: '"result":"hello u6u"'
|
||||
- scenario: "不支援的 operation"
|
||||
given: '{"operation":"unknown","input":"test"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, data, string, transform, text]
|
||||
description: "字串操作:upper/lower/trim/capitalize/replace/split/join/includes/starts_with/ends_with/length/substring。"
|
||||
config_example: |
|
||||
my_string_op: # 節點名稱(可自訂)
|
||||
operation: "replace" # 運算類型(必填),可選值:upper/lower/trim/capitalize/replace/split/join/includes/starts_with/ends_with/length/substring
|
||||
input: "hello world" # 輸入字串(必填)
|
||||
args: # 操作參數,依 operation 而定(選填)
|
||||
from: "world" # replace 用:要被取代的子字串
|
||||
to: "arcrun" # replace 用:取代後的字串
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,116 @@
|
||||
// string_ops — 字串操作
|
||||
// 支援: upper, lower, trim, capitalize, replace, split, join, includes,
|
||||
// starts_with, ends_with, length, substring
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Args struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Sep string `json:"sep"`
|
||||
Items []string `json:"items"`
|
||||
Substr string `json:"substr"`
|
||||
Prefix string `json:"prefix"`
|
||||
Suffix string `json:"suffix"`
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
|
||||
switch input.Operation {
|
||||
case "upper":
|
||||
result = strings.ToUpper(input.Input)
|
||||
case "lower":
|
||||
result = strings.ToLower(input.Input)
|
||||
case "trim":
|
||||
result = strings.TrimSpace(input.Input)
|
||||
case "capitalize":
|
||||
if len(input.Input) == 0 {
|
||||
result = ""
|
||||
} else {
|
||||
result = strings.ToUpper(input.Input[:1]) + strings.ToLower(input.Input[1:])
|
||||
}
|
||||
case "replace":
|
||||
result = strings.ReplaceAll(input.Input, input.Args.From, input.Args.To)
|
||||
case "split":
|
||||
sep := input.Args.Sep
|
||||
if sep == "" {
|
||||
sep = ","
|
||||
}
|
||||
result = strings.Split(input.Input, sep)
|
||||
case "join":
|
||||
sep := input.Args.Sep
|
||||
result = strings.Join(input.Args.Items, sep)
|
||||
case "includes":
|
||||
result = strings.Contains(input.Input, input.Args.Substr)
|
||||
case "starts_with":
|
||||
result = strings.HasPrefix(input.Input, input.Args.Prefix)
|
||||
case "ends_with":
|
||||
result = strings.HasSuffix(input.Input, input.Args.Suffix)
|
||||
case "length":
|
||||
result = len([]rune(input.Input))
|
||||
case "substring":
|
||||
runes := []rune(input.Input)
|
||||
start := input.Args.Start
|
||||
end := input.Args.End
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end <= 0 || end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
if start > end {
|
||||
start = end
|
||||
}
|
||||
result = string(runes[start:end])
|
||||
default:
|
||||
writeError("不支援的 operation: " + input.Operation)
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"result": result,
|
||||
"operation": input.Operation,
|
||||
},
|
||||
})
|
||||
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,66 @@
|
||||
canonical_id: "switch"
|
||||
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: [value, cases]
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
description: 要比對的值
|
||||
cases:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
match:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
default_branch:
|
||||
type: string
|
||||
description: 無匹配時的預設分支
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
branch:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "匹配到 case"
|
||||
given: '{"value":"a","cases":[{"match":"a","branch":"branch_a"}],"default_branch":"default"}'
|
||||
then_contains: '"branch":"branch_a"'
|
||||
- scenario: "走 default 分支"
|
||||
given: '{"value":"z","cases":[{"match":"a","branch":"branch_a"}],"default_branch":"fallback"}'
|
||||
then_contains: '"branch":"fallback"'
|
||||
- scenario: "無效 JSON"
|
||||
given: 'not-json'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, switch, branch, route, condition]
|
||||
description: "依值路由到對應分支,支援多個 case 和 default 分支。"
|
||||
config_example: |
|
||||
my_switch: # 節點名稱(可自訂)
|
||||
value: "{{upstream.status}}" # 要比對的值(必填)
|
||||
cases: # case 清單(必填)
|
||||
- match: active # 比對值
|
||||
branch: branch_active # 對應分支名稱
|
||||
- match: inactive
|
||||
branch: branch_inactive
|
||||
default_branch: branch_default # 無匹配時的預設分支(選填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,61 @@
|
||||
// switch — 依值路由到對應分支
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Case struct {
|
||||
Match string `json:"match"`
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
Value string `json:"value"`
|
||||
Cases []Case `json:"cases"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
for _, c := range input.Cases {
|
||||
if c.Match == input.Value {
|
||||
writeSuccess(c.Branch)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
branch := input.DefaultBranch
|
||||
if branch == "" {
|
||||
branch = "default"
|
||||
}
|
||||
writeSuccess(branch)
|
||||
}
|
||||
|
||||
func writeSuccess(branch string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"branch": branch},
|
||||
})
|
||||
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,53 @@
|
||||
canonical_id: "try_catch"
|
||||
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
|
||||
properties:
|
||||
result: {}
|
||||
error:
|
||||
type: string
|
||||
description: 上游錯誤訊息,非空則走 catch 分支
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
branch:
|
||||
type: string
|
||||
enum: [try, catch]
|
||||
result: {}
|
||||
error:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "無錯誤走 try"
|
||||
given: '{"result":{"value":42},"error":""}'
|
||||
then_contains: '"branch":"try"'
|
||||
- scenario: "有錯誤走 catch"
|
||||
given: '{"result":null,"error":"something went wrong"}'
|
||||
then_contains: '"branch":"catch"'
|
||||
- scenario: "無效 JSON"
|
||||
given: 'not-json'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, control, try, catch, error, handling]
|
||||
description: "判斷上游結果是否有 error,決定走 try 或 catch 分支。"
|
||||
config_example: |
|
||||
my_try_catch: # 節點名稱(可自訂)
|
||||
result: "{{upstream.data}}" # 上游回傳的結果,任意型別(選填)
|
||||
error: "{{upstream.error}}" # 上游錯誤訊息,非空則走 catch 分支(選填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,55 @@
|
||||
// try_catch — 判斷上游結果是否有 error,決定走 try 或 catch 分支
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
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.Error != "" {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"branch": "catch",
|
||||
"error": input.Error,
|
||||
},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"branch": "try",
|
||||
"result": input.Result,
|
||||
},
|
||||
})
|
||||
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,42 @@
|
||||
# validate_json
|
||||
|
||||
u6u 第一個 WASM 零件。驗證輸入字串是否為合法 JSON 格式。
|
||||
|
||||
## 編譯
|
||||
|
||||
需要安裝 [TinyGo](https://tinygo.org/getting-started/install/):
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install tinygo
|
||||
|
||||
# 編譯為 WASM
|
||||
tinygo build -o validate_json.wasm -target=wasi .
|
||||
```
|
||||
|
||||
## 本地測試
|
||||
|
||||
```bash
|
||||
# 合法 JSON → {"valid":true}
|
||||
echo '{"json_string":"{\"key\":\"value\"}"}' | wasmtime validate_json.wasm
|
||||
|
||||
# 非法 JSON → {"valid":false,"error":"..."}
|
||||
echo '{"json_string":"not-json"}' | wasmtime validate_json.wasm
|
||||
|
||||
# 空字串 → {"valid":false,"error":"json_string is required"}
|
||||
echo '{"json_string":""}' | wasmtime validate_json.wasm
|
||||
```
|
||||
|
||||
## 提交至 Component Registry
|
||||
|
||||
```bash
|
||||
# 驗證合約格式
|
||||
curl -X POST https://component-registry.finally.click/components/validate-contract \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @component.contract.yaml
|
||||
|
||||
# 提交零件(multipart)
|
||||
curl -X POST https://component-registry.finally.click/components \
|
||||
-F "contract=@component.contract.yaml;type=application/yaml" \
|
||||
-F "wasm=@validate_json.wasm;type=application/wasm"
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
canonical_id: "validate_json"
|
||||
display_name: "JSON 格式驗證器"
|
||||
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:
|
||||
- json_string
|
||||
properties:
|
||||
json_string:
|
||||
type: string
|
||||
description: "待驗證的 JSON 字串"
|
||||
|
||||
output_schema:
|
||||
type: object
|
||||
required:
|
||||
- valid
|
||||
properties:
|
||||
valid:
|
||||
type: boolean
|
||||
description: "是否為合法 JSON"
|
||||
error:
|
||||
type: string
|
||||
description: "驗證失敗時的錯誤訊息(valid=false 時存在)"
|
||||
|
||||
gherkin_tests:
|
||||
- scenario: "合法 JSON 物件通過驗證"
|
||||
given: '{"json_string":"{\"key\":\"value\"}"}'
|
||||
then_contains: '{"valid":true}'
|
||||
|
||||
- scenario: "合法 JSON 陣列通過驗證"
|
||||
given: '{"json_string":"[1,2,3]"}'
|
||||
then_contains: '{"valid":true}'
|
||||
|
||||
- scenario: "非法 JSON 字串回傳錯誤"
|
||||
given: '{"json_string":"not-json"}'
|
||||
then_contains: '{"valid":false,"error":'
|
||||
|
||||
- scenario: "空字串回傳錯誤"
|
||||
given: '{"json_string":""}'
|
||||
then_contains: '{"valid":false,"error":"json_string is required"}'
|
||||
|
||||
- scenario: "缺少 json_string 欄位回傳錯誤"
|
||||
given: '{}'
|
||||
then_contains: '{"valid":false,"error":"json_string is required"}'
|
||||
|
||||
tags:
|
||||
- "validation"
|
||||
- "json"
|
||||
- "utility"
|
||||
- "logic"
|
||||
|
||||
description: "驗證輸入字串是否為合法 JSON 格式。輸入 json_string 欄位,回傳 valid(布林值)與 error(失敗時的錯誤訊息)。"
|
||||
config_example: |
|
||||
my_validate_json: # 節點名稱(可自訂)
|
||||
json_string: '{"key":"value"}' # 待驗證的 JSON 字串(必填)
|
||||
@@ -0,0 +1,3 @@
|
||||
module validate_json
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,71 @@
|
||||
// validate_json — u6u 第一個 WASM 零件
|
||||
// 驗證輸入字串是否為合法 JSON 格式
|
||||
//
|
||||
// 白名單 import(TinyGo 規範):
|
||||
// - "os" 只用 os.Stdin / os.Stdout
|
||||
// - "io" io.ReadAll(os.Stdin)
|
||||
// - "encoding/json" json.Unmarshal / json.Marshal
|
||||
//
|
||||
// 禁止:goroutine、channel、net/*、os.Open、syscall.*、第三方 module
|
||||
//
|
||||
// 編譯指令:
|
||||
// tinygo build -o validate_json.wasm -target=wasi .
|
||||
//
|
||||
// 本地測試:
|
||||
// echo '{"json_string":"{\"key\":\"value\"}"}' | wasmtime validate_json.wasm
|
||||
// echo '{"json_string":"not-json"}' | wasmtime validate_json.wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Input 對應 input_schema
|
||||
type Input struct {
|
||||
JSONString string `json:"json_string"`
|
||||
}
|
||||
|
||||
// Output 對應 output_schema
|
||||
type Output struct {
|
||||
Valid bool `json:"valid"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 1. 讀取 stdin
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeOutput(Output{Valid: false, Error: "failed to read stdin: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 解析 input JSON
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeOutput(Output{Valid: false, Error: "invalid input JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 驗證 json_string 欄位
|
||||
if input.JSONString == "" {
|
||||
writeOutput(Output{Valid: false, Error: "json_string is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 嘗試解析 json_string
|
||||
var target interface{}
|
||||
if err := json.Unmarshal([]byte(input.JSONString), &target); err != nil {
|
||||
writeOutput(Output{Valid: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeOutput(Output{Valid: true})
|
||||
}
|
||||
|
||||
func writeOutput(out Output) {
|
||||
data, _ := json.Marshal(out)
|
||||
os.Stdout.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
canonical_id: "wait"
|
||||
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: [ms]
|
||||
properties:
|
||||
ms:
|
||||
type: integer
|
||||
description: 等待毫秒數,最大 30000(30 秒)
|
||||
context:
|
||||
type: object
|
||||
description: 透傳到下一個節點的上下文資料
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: 透傳的 context 加上 waited_ms 欄位
|
||||
properties:
|
||||
waited_ms:
|
||||
type: integer
|
||||
gherkin_tests:
|
||||
- scenario: "等待 100ms"
|
||||
given: '{"ms":100}'
|
||||
then_contains: '"waited_ms":100'
|
||||
- scenario: "超過上限自動截斷為 30000ms"
|
||||
given: '{"ms":99999}'
|
||||
then_contains: '"waited_ms":30000'
|
||||
- scenario: "ms 為 0 時失敗"
|
||||
given: '{"ms":0}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [builtin, wait, delay, sleep, timing]
|
||||
description: "等待指定毫秒數後繼續,最長 30 秒,並透傳 context 資料。"
|
||||
config_example: |
|
||||
my_wait: # 節點名稱(可自訂)
|
||||
ms: 1000 # 等待毫秒數,最大 30000(必填)
|
||||
context: # 透傳到下一個節點的資料(選填)
|
||||
payload: "{{upstream.data}}"
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,52 @@
|
||||
// wait — 等待指定毫秒數後繼續(最多 30 秒)
|
||||
// 注意:TinyGo/WASM 環境中 time.Sleep 可能不可用,改用 busy-wait 模擬
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
Ms int `json:"ms"`
|
||||
Context map[string]interface{} `json:"context"`
|
||||
}
|
||||
|
||||
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.Ms <= 0 {
|
||||
writeError("ms 必須大於 0")
|
||||
return
|
||||
}
|
||||
ms := input.Ms
|
||||
if ms > 30000 {
|
||||
ms = 30000
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(ms) * time.Millisecond)
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range input.Context {
|
||||
result[k] = v
|
||||
}
|
||||
result["waited_ms"] = ms
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": true, "data": result})
|
||||
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,42 @@
|
||||
# Arcrun Examples Library
|
||||
|
||||
> 給 AI 操盤手快速參考的 workflow 範本庫。每個範例都是可直接 `acr push` 部署的 YAML。
|
||||
>
|
||||
> 對應 SDD: `matrix/arcrun/.agents/specs/llm-interface/` Milestone 3.3
|
||||
|
||||
## 結構
|
||||
|
||||
每個範例一個資料夾:
|
||||
|
||||
```
|
||||
{slug}/
|
||||
├── workflow.yaml 可直接 push 部署
|
||||
├── description.md 解決什麼問題 / 怎麼觸發 / 預期結果
|
||||
└── tags.json ["webhook", "llm", "cron", ...] 用於搜尋
|
||||
```
|
||||
|
||||
## 範例列表
|
||||
|
||||
| Slug | 場景 |
|
||||
|---|---|
|
||||
| `webhook-to-http` | 簡單轉發:webhook → 打另一個 API |
|
||||
| `cron-watcher` | 每 5 分鐘掃資料庫 → 觸發子 workflow(mira pattern) |
|
||||
| `llm-classify` | claude_api 分類文字 → 寫 KBDB |
|
||||
| `rag-search-answer` | 從 KBDB 找 context → claude 回答 |
|
||||
| `email-summary` | gmail 收信 → claude 摘要 → telegram 推 |
|
||||
| `pdf-to-blocks` | 上傳 PDF → 轉文字 → 切 chunk → 存 KBDB |
|
||||
| `github-issue-bot` | 收 GH webhook → claude 分析 → 留 comment |
|
||||
| `daily-digest` | cron → 多源聚合(KBDB / RSS / 等) → 推送 |
|
||||
| `parallel-fanout` | 一份輸入分發多 workflow 並行處理 |
|
||||
| `error-retry` | try_catch + wait + retry 重試外部 API |
|
||||
|
||||
## 如何用(AI 視角)
|
||||
|
||||
1. `arcrun_search_examples('rag context answer')` → 命中 `rag-search-answer`
|
||||
2. 拿 YAML,按自己需求改 prompt / 資料來源
|
||||
3. `arcrun_validate_yaml` → `arcrun_push_workflow` → 完成
|
||||
|
||||
## CI 自動同步
|
||||
|
||||
GH Actions 監聽本目錄變動 → 把每個範例 PATCH 進 KBDB type=workflow-example
|
||||
(含 YAML + description + tags)。MCP `search_examples` 走 KBDB semantic search。
|
||||
@@ -0,0 +1,31 @@
|
||||
# cron-watcher
|
||||
|
||||
## 解決什麼問題
|
||||
定期巡 KBDB(或任何資料源),找到「未處理」的紀錄,每筆觸發一個處理 workflow。
|
||||
**最常見的 pattern**:mira 就是這樣把河道貼文自動跑 wiki_synthesis。
|
||||
|
||||
## 怎麼觸發
|
||||
不用手動觸發 — 部署後自動每 5 分鐘跑。
|
||||
|
||||
cron 解析在 `acr push` 時自動偵測首節點是 `cron` 零件,存進 `WEBHOOKS:cron-idx:` 索引,
|
||||
`scheduled()` handler 每分鐘 tick 對齊。
|
||||
|
||||
## 改成你自己的
|
||||
- `watch_cron.cron_expr` 改頻率(標準 5 欄 cron 語法)
|
||||
- `list_unprocessed` 改你的 KBDB query(type / source / tag 等)
|
||||
- `filter_new.condition` 改你的「未處理」定義
|
||||
- `trigger_processor.workflow_name` 改你的處理 workflow
|
||||
|
||||
## 為什麼用 trigger_workflow 不用 http_request
|
||||
|
||||
CF Workers 有 self-fetch 防護:cypher-executor 自打 `cypher.arcrun.dev/*` 或自己的
|
||||
`arcrun-cypher-executor.*.workers.dev` 都被攔(CF 1042)。
|
||||
|
||||
`trigger_workflow` 是 cypher-executor 內建的 orchestration 零件,直接 in-process
|
||||
call `executeWebhookGraph`,**不走外部 HTTP**,徹底繞掉 self-fetch。
|
||||
|
||||
## 學到什麼
|
||||
- cron + FOREACH + trigger_workflow 三件套
|
||||
- `{{api_key}}` 從 trigger context 自動帶(cron 觸發時 cypher-executor 自動塞進去)
|
||||
- `對每個 X >> Y` 中文關係詞(也接受 `FOREACH X`)
|
||||
- filter 零件用 `condition.op: eq` 配 `tags_json: "[]"` 偵測「無 tag」
|
||||
@@ -0,0 +1 @@
|
||||
["cron", "watcher", "kbdb", "foreach", "trigger_workflow", "common-pattern"]
|
||||
@@ -0,0 +1,38 @@
|
||||
name: cron_watcher_example
|
||||
description: 每 5 分鐘掃 KBDB 未處理的 note → 對每筆觸發子 workflow
|
||||
|
||||
flow:
|
||||
- "watch_cron >> ON_SUCCESS >> list_unprocessed"
|
||||
- "list_unprocessed >> ON_SUCCESS >> filter_new"
|
||||
- "filter_new >> 對每個 item >> trigger_processor"
|
||||
|
||||
config:
|
||||
watch_cron:
|
||||
component: cron
|
||||
cron_expr: "*/5 * * * *"
|
||||
description: "每 5 分鐘掃一次"
|
||||
|
||||
list_unprocessed:
|
||||
component: kbdb_get
|
||||
api_key: "{{api_key}}"
|
||||
type: "note"
|
||||
source: "user-input"
|
||||
limit: 20
|
||||
|
||||
filter_new:
|
||||
component: filter
|
||||
items: "{{list_unprocessed.blocks}}"
|
||||
condition:
|
||||
key: "tags_json"
|
||||
op: "eq"
|
||||
value: "[]"
|
||||
|
||||
# trigger_workflow 是內建 orchestration 零件,in-process call 另一個 workflow
|
||||
# **千萬不要用 http_request 自打 cypher-executor 自己的 webhook** — 會撞 CF self-fetch 死鎖
|
||||
trigger_processor:
|
||||
component: trigger_workflow
|
||||
workflow_name: "your_processor_workflow" # ← 改成你的處理 workflow 名
|
||||
api_key: "{{api_key}}"
|
||||
input:
|
||||
api_key: "{{api_key}}"
|
||||
block_id: "{{item.id}}"
|
||||
@@ -0,0 +1,28 @@
|
||||
# daily-digest
|
||||
|
||||
## 解決什麼問題
|
||||
資訊焦慮:HN / GitHub trending / 自己筆記每天都看不完。
|
||||
每天早上一份 LLM 整理過的精選,3 分鐘看完今天的 signal。
|
||||
|
||||
## 怎麼觸發
|
||||
不用,cron 排程每天 00:00 UTC(台灣 08:00)自動跑。
|
||||
|
||||
## 改成你自己的
|
||||
- 加 / 減 source:dev.to RSS、Twitter list、Slack、自家 KBDB tag 等
|
||||
- 摘要 prompt 改為你的口味(嚴肅 / 幽默 / 簡短)
|
||||
- 推送目的可換:email、Notion 加一個 page、KBDB 存歷史
|
||||
|
||||
## 為什麼這 pattern 重要
|
||||
**Fan-in** 是 arcrun 的特色:3 條 source 並行 fetch,cypher-executor 自動等全部完成才跑 compose。
|
||||
不用寫 promise.all、不用怕 race,宣告式描述「compose 依賴這 3 個」即可。
|
||||
|
||||
## 變體
|
||||
- 加 priority:若 KBDB 有 `tag:urgent` 的 note,置頂
|
||||
- 接 calendar:把今天 meeting 也塞進摘要
|
||||
- 接 weather + 通勤路況(API call 多源)
|
||||
|
||||
## 學到什麼
|
||||
- **Fan-in / fan-out**:cypher binding YAML 多條邊指向同一節點就是 fan-in
|
||||
- 系統變數:`{{_today}}` / `{{_yesterday}}` / `{{_now}}` 內建可用
|
||||
- cron 多步排程:一個 cron 觸發 3 條並行 fetch chain
|
||||
- `kbdb_get` 用 `source` 篩特定來源(這裡只收 leo 直接寫的,不收 AI 生成的)
|
||||
@@ -0,0 +1 @@
|
||||
["cron", "digest", "fan-in", "multi-source", "llm", "telegram", "common-pattern"]
|
||||
@@ -0,0 +1,62 @@
|
||||
name: daily_digest
|
||||
description: 每天早上聚合多源資料 (KBDB / RSS / GitHub trending) → claude 摘要 → telegram
|
||||
|
||||
flow:
|
||||
- "morning_cron >> ON_SUCCESS >> fetch_kbdb_yesterday"
|
||||
- "morning_cron >> ON_SUCCESS >> fetch_rss"
|
||||
- "morning_cron >> ON_SUCCESS >> fetch_github_trending"
|
||||
- "fetch_kbdb_yesterday >> ON_SUCCESS >> compose_digest"
|
||||
- "fetch_rss >> ON_SUCCESS >> compose_digest"
|
||||
- "fetch_github_trending >> ON_SUCCESS >> compose_digest"
|
||||
- "compose_digest >> ON_SUCCESS >> push_digest"
|
||||
|
||||
config:
|
||||
morning_cron:
|
||||
component: cron
|
||||
cron_expr: "0 0 * * *" # UTC 00:00 = 台灣 08:00
|
||||
|
||||
fetch_kbdb_yesterday:
|
||||
component: kbdb_get
|
||||
api_key: "{{api_key}}"
|
||||
type: "note"
|
||||
source: "km-writer-direct"
|
||||
limit: 50
|
||||
|
||||
fetch_rss:
|
||||
component: http_request
|
||||
url: "https://hnrss.org/frontpage?count=10"
|
||||
method: GET
|
||||
|
||||
fetch_github_trending:
|
||||
component: http_request
|
||||
url: "https://api.github.com/search/repositories?q=created:>{{_yesterday}}+stars:>500&sort=stars&order=desc&per_page=5"
|
||||
method: GET
|
||||
headers:
|
||||
Accept: "application/vnd.github+json"
|
||||
|
||||
# compose 收三條 fan-in(cypher-executor 自動等三個 source 都完成才跑)
|
||||
compose_digest:
|
||||
component: claude_api
|
||||
timeout_ms: 60000
|
||||
_recipe_output_format: text
|
||||
prompt: |
|
||||
整理 leo 今天的「晨間訊息摘要」。三部分各取重點 5-8 條:
|
||||
|
||||
## 我昨天寫的(KBDB notes)
|
||||
{{fetch_kbdb_yesterday.blocks}}
|
||||
|
||||
## Hacker News
|
||||
{{fetch_rss.data}}
|
||||
|
||||
## GitHub 熱門新 repo
|
||||
{{fetch_github_trending.data}}
|
||||
|
||||
格式:markdown bullets,每條 < 30 字,標明來源。
|
||||
|
||||
push_digest:
|
||||
component: telegram
|
||||
chat_id: "{{secret.LEO_TELEGRAM_CHAT_ID}}"
|
||||
text: |
|
||||
☀️ 早安 {{_today}}
|
||||
|
||||
{{compose_digest.data.text}}
|
||||
@@ -0,0 +1,31 @@
|
||||
# email-summary
|
||||
|
||||
## 解決什麼問題
|
||||
信箱爆炸不想一封一封看?每天早上 8 點收到一份 LLM 整理過的「今天該注意的事」。
|
||||
|
||||
## 前置
|
||||
- 設好 gmail auth credential(`acr creds push gmail`,OAuth2 flow)
|
||||
- 設好 telegram bot + chat_id(推送目的地)
|
||||
|
||||
## 怎麼觸發
|
||||
不用手動,cron 排程每天 08:00 自動跑。
|
||||
|
||||
## 改成你自己的
|
||||
- `daily_cron.cron_expr` 改時區(注意 cypher-executor 跑 UTC,台灣要 -8h)
|
||||
- `fetch_unread.query` 改 gmail 搜尋條件
|
||||
- 摘要 prompt 改成你的優先級邏輯
|
||||
- 推送可換 line_notify、slack、或寫進 KBDB 等
|
||||
|
||||
## 為什麼這 pattern 重要
|
||||
最典型「多服務串聯」case:data source(gmail)+ LLM 處理 + 通知。
|
||||
arcrun 三件套各自獨立、用 cypher binding YAML 串起來。
|
||||
|
||||
## 進階
|
||||
- 加 `if_control` 節點:若摘要無新急件,跳過 telegram 不打擾
|
||||
- 加 KBDB 存歷史摘要(type=daily-digest)方便回看
|
||||
- 接 ai-meka workflow 自動排日程(急件 → calendar event)
|
||||
|
||||
## 學到什麼
|
||||
- `cron` 排程 + 多步串聯標準骨架
|
||||
- `{{secret.X}}` 走 credential 系統取得 sensitive value(不寫死 YAML)
|
||||
- gmail / telegram 都是 arcrun 內建零件(list_components 看完整清單)
|
||||
@@ -0,0 +1 @@
|
||||
["cron", "gmail", "llm", "telegram", "digest", "automation", "common-pattern"]
|
||||
@@ -0,0 +1,43 @@
|
||||
name: email_summary
|
||||
description: 每天 8am 撈 gmail 最近未讀 → claude 摘要 → telegram 推送
|
||||
|
||||
flow:
|
||||
- "daily_cron >> ON_SUCCESS >> fetch_unread"
|
||||
- "fetch_unread >> ON_SUCCESS >> summarize"
|
||||
- "summarize >> ON_SUCCESS >> push_to_telegram"
|
||||
|
||||
config:
|
||||
daily_cron:
|
||||
component: cron
|
||||
cron_expr: "0 8 * * *" # 每天 08:00 UTC(依需求調時區)
|
||||
|
||||
fetch_unread:
|
||||
component: gmail
|
||||
action: "list"
|
||||
query: "is:unread newer_than:1d"
|
||||
max_results: 20
|
||||
|
||||
summarize:
|
||||
component: claude_api
|
||||
timeout_ms: 60000
|
||||
_recipe_output_format: text
|
||||
prompt: |
|
||||
你是 leo 的 email 助理。把下列 {{fetch_unread.count}} 封信濃縮成
|
||||
一份「今天該注意的事」摘要:
|
||||
|
||||
**格式**:
|
||||
- 急件(需 24h 內回):list
|
||||
- 帳單 / 重要通知:list
|
||||
- 一般資訊(可週末看):list
|
||||
- 廣告 / spam:忽略
|
||||
|
||||
Emails:
|
||||
{{fetch_unread.messages}}
|
||||
|
||||
push_to_telegram:
|
||||
component: telegram
|
||||
chat_id: "{{secret.LEO_TELEGRAM_CHAT_ID}}"
|
||||
text: |
|
||||
📬 今日 email 摘要
|
||||
|
||||
{{summarize.data.text}}
|
||||
@@ -0,0 +1,38 @@
|
||||
# error-retry
|
||||
|
||||
## 解決什麼問題
|
||||
外部 API 偶發 500 / timeout 是常態。寫死「打一次就放棄」太脆弱。
|
||||
這個 pattern 提供標準 retry chain:失敗 → 等 5 秒 → 重試一次 → 還失敗才通知人。
|
||||
|
||||
## 怎麼觸發
|
||||
```bash
|
||||
curl -X POST https://cypher.arcrun.dev/webhooks/named/error_retry/trigger \
|
||||
-d '{
|
||||
"api_key":"ak_xxx",
|
||||
"target_url":"https://flaky-api.example.com/endpoint",
|
||||
"payload":{"x":1},
|
||||
"workflow_name":"my_workflow"
|
||||
}'
|
||||
```
|
||||
|
||||
## 改成你自己的
|
||||
- `wait_a_bit.seconds` 改延遲(指數 backoff:5, 15, 45 秒)
|
||||
- 串更多 retry 節點(generic 寫 3-4 次足夠)
|
||||
- `final_fail_notify` 換 email / pagerduty / slack 等
|
||||
- 加 `if_control` 判斷 error 類型(4xx 不重試、5xx 重試)
|
||||
|
||||
## 為什麼這 pattern 重要
|
||||
- arcrun 的 `ON_FAIL` 邊是宣告式 error handling,比寫 try/catch 直觀
|
||||
- `wait` 零件不消耗 CPU(cypher-executor 排程 sleep 後恢復),比 setTimeout 健康
|
||||
- 失敗最終要通知人,不能默默吞 — 通知本身也是 workflow 的責任
|
||||
|
||||
## 變體
|
||||
- **Circuit breaker**:3 次連續失敗 → 寫 KBDB `circuit:open` flag → 後續 trigger 直接跳過
|
||||
- **Dead letter queue**:失敗的 input 寫 KBDB type=dlq-input,方便事後手動重跑
|
||||
- **Idempotency key**:retry 時帶同樣的 request_id,避免下游重複處理
|
||||
|
||||
## 學到什麼
|
||||
- `ON_FAIL` 邊:節點失敗時走哪條
|
||||
- `wait` 零件:宣告式 delay,不阻塞 worker(推到 paused-resume)
|
||||
- `{{node_id.error}}` 取得失敗節點的錯誤訊息
|
||||
- 把「最終失敗通知」當 workflow 一部分,不靠系統外部 monitoring
|
||||
@@ -0,0 +1 @@
|
||||
["error-handling", "retry", "wait", "try-catch", "robustness", "advanced"]
|
||||
@@ -0,0 +1,43 @@
|
||||
name: error_retry
|
||||
description: try_catch + wait + retry 模式:外部 API 偶發掛掉時自動重試
|
||||
|
||||
flow:
|
||||
- "input >> ON_SUCCESS >> try_call"
|
||||
- "try_call >> ON_SUCCESS >> done"
|
||||
- "try_call >> ON_FAIL >> wait_a_bit"
|
||||
- "wait_a_bit >> ON_SUCCESS >> retry_call"
|
||||
- "retry_call >> ON_SUCCESS >> done"
|
||||
- "retry_call >> ON_FAIL >> final_fail_notify"
|
||||
|
||||
config:
|
||||
try_call:
|
||||
component: http_request
|
||||
url: "{{input.target_url}}"
|
||||
method: POST
|
||||
body_json:
|
||||
payload: "{{input.payload}}"
|
||||
|
||||
wait_a_bit:
|
||||
component: wait
|
||||
seconds: 5
|
||||
|
||||
# 第二次嘗試。生產環境通常 retry 2-3 次配指數 backoff
|
||||
retry_call:
|
||||
component: http_request
|
||||
url: "{{input.target_url}}"
|
||||
method: POST
|
||||
body_json:
|
||||
payload: "{{input.payload}}"
|
||||
_retry: 1
|
||||
|
||||
done:
|
||||
component: comp_passthrough
|
||||
# 純記錄成功,下游若需要可繼續鏈
|
||||
|
||||
final_fail_notify:
|
||||
component: telegram
|
||||
chat_id: "{{secret.LEO_TELEGRAM_CHAT_ID}}"
|
||||
text: |
|
||||
⚠️ workflow {{input.workflow_name}} 兩次重試都失敗
|
||||
target: {{input.target_url}}
|
||||
last error: {{retry_call.error}}
|
||||
@@ -0,0 +1,33 @@
|
||||
# github-issue-bot
|
||||
|
||||
## 解決什麼問題
|
||||
開源專案維護苦:每天好幾個 issue 進來,要先看 → 分流 → 引導用戶補資訊。
|
||||
這個 bot 自動做第一輪:分類 / 評估嚴重度 / 留有意義的 comment / 加 label。
|
||||
|
||||
## 前置
|
||||
1. 在 GitHub repo settings → Webhooks → 加 webhook:
|
||||
- URL: `https://cypher.arcrun.dev/webhooks/named/github_issue_bot/trigger`
|
||||
- Content type: `application/json`
|
||||
- Events: `Issues (opened)`
|
||||
- 加 secret header `X-Arcrun-API-Key: ak_xxx`
|
||||
2. 設 credential `GITHUB_BOT_TOKEN`(一支 PAT 或 GitHub App token)
|
||||
|
||||
## 預期結果
|
||||
新 issue 開出來 30 秒後,bot 就 comment + 加標籤了。
|
||||
|
||||
## 改成你自己的
|
||||
- prompt 改為你的專案 conventions(用詞、語氣)
|
||||
- severity / category enum 改為你的分類
|
||||
- 加 conditional:critical 自動 telegram 通知 maintainer
|
||||
- 加 KBDB 存歷史 issue + claude 分析 → 用 RAG 找重複 issue
|
||||
- 加 `if_control`:若 issue body 有 `traceback` 自動 reproduce
|
||||
|
||||
## 為什麼這 pattern 重要
|
||||
- LLM 做「結構化判斷」比寫 if-else 強:能讀人類自然語言、抓上下文、判斷模糊邊界
|
||||
- GitHub webhook → workflow 是最常見「外部 event → 處理」場景,所有 SaaS webhook 都類似
|
||||
|
||||
## 學到什麼
|
||||
- 多步串聯(analyze → comment → label)每步都有 next,ON_SUCCESS 串
|
||||
- `{{analyze.X}}` 從 claude_api JSON 自動展開到下游
|
||||
- 同一個 API(GitHub)多次 call 共享 `Authorization` header
|
||||
- 嚴重度 / 類別這類 LLM 判斷,用 enum + required_fields 確保結構穩定
|
||||
@@ -0,0 +1 @@
|
||||
["github", "webhook", "llm", "automation", "triage", "external-api"]
|
||||
@@ -0,0 +1,51 @@
|
||||
name: github_issue_bot
|
||||
description: GH webhook 收新 issue → claude 分析 → 自動留 comment + 加 label
|
||||
|
||||
flow:
|
||||
- "input >> ON_SUCCESS >> analyze"
|
||||
- "analyze >> ON_SUCCESS >> add_comment"
|
||||
- "add_comment >> ON_SUCCESS >> add_labels"
|
||||
|
||||
config:
|
||||
analyze:
|
||||
component: claude_api
|
||||
timeout_ms: 30000
|
||||
_recipe_output_format: json
|
||||
_recipe_output_required_fields:
|
||||
- severity
|
||||
- category
|
||||
- first_response
|
||||
prompt: |
|
||||
你是 GitHub issue 第一線分流員。對下列 issue 給出:
|
||||
- severity: "critical" | "high" | "medium" | "low"
|
||||
- category: "bug" | "feature" | "doc" | "question" | "other"
|
||||
- first_response: 一段 markdown,禮貌、有用、不假裝是真人
|
||||
|
||||
若是 bug,guide 用戶提供 reproduce steps;若 question 直接回答;
|
||||
若 feature 引導去 discussion;若 doc 直接收。
|
||||
|
||||
Issue:
|
||||
Title: {{input.issue.title}}
|
||||
Body: {{input.issue.body}}
|
||||
|
||||
add_comment:
|
||||
component: http_request
|
||||
url: "https://api.github.com/repos/{{input.repository.full_name}}/issues/{{input.issue.number}}/comments"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Bearer {{secret.GITHUB_BOT_TOKEN}}"
|
||||
Accept: "application/vnd.github+json"
|
||||
body_json:
|
||||
body: "{{analyze.first_response}}"
|
||||
|
||||
add_labels:
|
||||
component: http_request
|
||||
url: "https://api.github.com/repos/{{input.repository.full_name}}/issues/{{input.issue.number}}/labels"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Bearer {{secret.GITHUB_BOT_TOKEN}}"
|
||||
body_json:
|
||||
labels:
|
||||
- "auto-triaged"
|
||||
- "severity:{{analyze.severity}}"
|
||||
- "type:{{analyze.category}}"
|
||||
@@ -0,0 +1,35 @@
|
||||
# llm-classify
|
||||
|
||||
## 解決什麼問題
|
||||
LLM 結構化輸出最常見場景:把自由文字分到固定 category。
|
||||
claude_api 用 `_recipe_output_format: json` 自動 parse + validate 必填欄位。
|
||||
|
||||
## 怎麼觸發
|
||||
```bash
|
||||
curl -X POST https://cypher.arcrun.dev/webhooks/named/llm_classify_example/trigger \
|
||||
-H "X-Arcrun-API-Key: ak_xxx" \
|
||||
-d '{"api_key":"ak_xxx","text":"How to deploy Cloudflare Workers in production?"}'
|
||||
```
|
||||
|
||||
## 預期結果
|
||||
- claude 回 JSON `{category, confidence, reason}`
|
||||
- KBDB 寫一筆 block,tags_json 含 `category:tech`
|
||||
- response 回 `{success: true, data: {id, ...}}`
|
||||
|
||||
## 為什麼這 pattern 重要
|
||||
- `_recipe_output_format: json` + `_recipe_output_required_fields` 是 claude_api 的 magic:
|
||||
Claude 回 JSON 後 cypher-executor 自動:
|
||||
1. 剝 ```json fence
|
||||
2. parse
|
||||
3. 驗 required fields 存在
|
||||
4. 把每個欄位(category / confidence / reason)放到 ctx 頂層,下游 `{{category}}` 直接用
|
||||
- 不用寫 parse / validate / shape 邏輯,純 prompt + schema
|
||||
|
||||
## 改成你自己的
|
||||
- prompt 改你的分類規則(category 清單可長可短)
|
||||
- 下游 save_with_tag 可換成 telegram 推播 / gmail / 等
|
||||
- 若需要多步分類(先粗分後細分),鏈兩個 claude_api 節點即可
|
||||
|
||||
## 注意
|
||||
- claude_api 走 mira daemon (Phase A),會 paused 一陣子等 callback resume
|
||||
- 若 prompt 抽不出 required_fields,會 validation_failed 不寫 KBDB(safer than partial save)
|
||||
@@ -0,0 +1 @@
|
||||
["llm", "claude", "classify", "structured-output", "kbdb", "common-pattern"]
|
||||
@@ -0,0 +1,32 @@
|
||||
name: llm_classify_example
|
||||
description: webhook 收文字 → claude 分類 → 寫 KBDB 加 tag
|
||||
|
||||
flow:
|
||||
- "input >> ON_SUCCESS >> classify"
|
||||
- "classify >> ON_SUCCESS >> save_with_tag"
|
||||
|
||||
config:
|
||||
classify:
|
||||
component: claude_api
|
||||
timeout_ms: 30000
|
||||
_recipe_output_format: json
|
||||
_recipe_output_required_fields:
|
||||
- category
|
||||
- confidence
|
||||
prompt: |
|
||||
分類以下文字到下列其中一個 category:
|
||||
- tech / business / personal / other
|
||||
|
||||
只回 JSON:
|
||||
{"category": "tech", "confidence": 0.85, "reason": "..."}
|
||||
|
||||
文字:{{input.text}}
|
||||
|
||||
save_with_tag:
|
||||
component: kbdb_create_block
|
||||
api_key: "{{api_key}}"
|
||||
type: "note"
|
||||
source: "llm-classified"
|
||||
user_id: "ai_classifier"
|
||||
content: "{{input.text}}"
|
||||
tags_json: '["llm-classified", "category:{{category}}"]'
|
||||
@@ -0,0 +1,38 @@
|
||||
# parallel-fanout
|
||||
|
||||
## 解決什麼問題
|
||||
同一份輸入要做多種處理(摘要 / 翻譯 / 分類 / 等)。
|
||||
不想等順序執行(總時長 = 全部加總)→ 並行(總時長 = 最慢一個)。
|
||||
|
||||
## 怎麼觸發
|
||||
```bash
|
||||
curl -X POST https://cypher.arcrun.dev/webhooks/named/parallel_fanout/trigger \
|
||||
-H "X-Arcrun-API-Key: ak_xxx" \
|
||||
-d '{"api_key":"ak_xxx","text":"...","target_lang":"en"}'
|
||||
```
|
||||
|
||||
## 預期行為
|
||||
- 3 個子 workflow 同時啟動,各自獨立執行
|
||||
- 主 workflow 返回所有子 workflow 都 trigger 成功的時間(毫秒級)
|
||||
- 子 workflow 完成的結果**不會回到** parent —— 各自寫各自的 KBDB / 通知
|
||||
|
||||
## 改成你自己的
|
||||
- 增 / 減 dispatch 節點數
|
||||
- workflow_name 換你的真實處理 workflow
|
||||
- 若需要等子 workflow 都完成 → 子 workflow 寫完成標記到 KBDB,parent 後續 cron 撿
|
||||
|
||||
## 變體:等所有子 workflow 完成
|
||||
arcrun 預設 trigger_workflow 是 fire-and-await(paused 也算 success)。
|
||||
若要嚴格「等到完成」,要:
|
||||
1. 子 workflow 末步寫 `done:{request_id}` block 到 KBDB
|
||||
2. parent 加 polling 節點 + wait 重試
|
||||
(M2 之後會出 wait_for_workflows 內建零件)
|
||||
|
||||
## 為什麼這 pattern 重要
|
||||
- arcrun 是 multi-tenant / multi-tier 平台。Fan-out 讓你能 build「主 controller + N 個 worker」架構
|
||||
- 比 promise.all 更穩:每個子 workflow 獨立 paused/resume,互不污染狀態
|
||||
|
||||
## 學到什麼
|
||||
- **Fan-out**:一個節點多條 ON_SUCCESS 邊出去,並行執行
|
||||
- `trigger_workflow` 是內建 orchestration 零件(cypher-executor in-process call,繞 CF self-fetch)
|
||||
- input 變數在 fan-out 時複製給每條分支(不互相影響)
|
||||
@@ -0,0 +1 @@
|
||||
["fan-out", "parallel", "trigger_workflow", "multi-step", "advanced"]
|
||||
@@ -0,0 +1,35 @@
|
||||
name: parallel_fanout
|
||||
description: 一份輸入分發多個子 workflow 並行處理(trigger_workflow 模式)
|
||||
|
||||
flow:
|
||||
- "input >> ON_SUCCESS >> dispatch_to_summary"
|
||||
- "input >> ON_SUCCESS >> dispatch_to_translate"
|
||||
- "input >> ON_SUCCESS >> dispatch_to_classify"
|
||||
|
||||
config:
|
||||
# 三個並行子 workflow 觸發。各自獨立執行、互不影響、不等彼此
|
||||
# cypher-executor 處理 fan-out:三條邊同源 (input) → 三個目標各自跑
|
||||
dispatch_to_summary:
|
||||
component: trigger_workflow
|
||||
workflow_name: "llm_classify_example" # 改成你的 summary workflow
|
||||
api_key: "{{api_key}}"
|
||||
input:
|
||||
api_key: "{{api_key}}"
|
||||
text: "{{input.text}}"
|
||||
|
||||
dispatch_to_translate:
|
||||
component: trigger_workflow
|
||||
workflow_name: "your_translate_workflow"
|
||||
api_key: "{{api_key}}"
|
||||
input:
|
||||
api_key: "{{api_key}}"
|
||||
text: "{{input.text}}"
|
||||
target_lang: "{{input.target_lang}}"
|
||||
|
||||
dispatch_to_classify:
|
||||
component: trigger_workflow
|
||||
workflow_name: "llm_classify_example"
|
||||
api_key: "{{api_key}}"
|
||||
input:
|
||||
api_key: "{{api_key}}"
|
||||
text: "{{input.text}}"
|
||||
@@ -0,0 +1,40 @@
|
||||
# pdf-to-blocks
|
||||
|
||||
## 解決什麼問題
|
||||
研究 / 學習:丟一份 PDF 進來,自動轉文字 + 切 chunk + 存 KBDB,之後可 RAG search。
|
||||
適合做:論文閱讀庫、合約查詢、技術文件 RAG。
|
||||
|
||||
## 怎麼觸發
|
||||
```bash
|
||||
curl -X POST https://cypher.arcrun.dev/webhooks/named/pdf_to_blocks/trigger \
|
||||
-H "X-Arcrun-API-Key: ak_xxx" \
|
||||
-d '{
|
||||
"api_key":"ak_xxx",
|
||||
"pdf_url":"https://arxiv.org/pdf/2411.02959.pdf",
|
||||
"title":"HtmlRAG",
|
||||
"user_id":"inkstone_leo_research"
|
||||
}'
|
||||
```
|
||||
|
||||
## 怎麼用後續
|
||||
搭配 `rag-search-answer` workflow:
|
||||
```bash
|
||||
curl ... rag_search_answer/trigger \
|
||||
-d '{"question":"HtmlRAG 對 Markdown 的優勢是什麼?", "user_id":"inkstone_leo_research"}'
|
||||
```
|
||||
→ claude 從你剛 ingest 的 PDF chunks 找 context 回答
|
||||
|
||||
## 改成你自己的
|
||||
- 替換 convert 來源(cto.finally.click 也有 convert,自家環境可用)
|
||||
- `kbdb_ingest` 預設 chunk ~500 字,要改在 KBDB 端設
|
||||
- `source: "pdf:{url}"` 是 idempotency key — 同 URL 重複 ingest 會被偵測
|
||||
|
||||
## 變體
|
||||
- 接 `claude_api` 在 ingest 後跑「自動 tag」流程(每個 chunk 抽 keyword tag)
|
||||
- 接 `email-summary` pattern:訂閱 arxiv RSS → 自動 PDF 收進來
|
||||
- 把 ingest 結果 trigger `wiki_synthesis`(mira 用此 chain)
|
||||
|
||||
## 學到什麼
|
||||
- KBDB 有 `/convert` endpoint 直接吃 PDF / DOC,不用自己處理 OCR
|
||||
- `kbdb_ingest` 自動 chunking + embedding 一條龍
|
||||
- `source: "{type}:{key}"` 是 KBDB idempotency 慣例
|
||||
@@ -0,0 +1 @@
|
||||
["pdf", "ingest", "kbdb", "rag-prep", "chunking", "knowledge-base"]
|
||||
@@ -0,0 +1,25 @@
|
||||
name: pdf_to_blocks
|
||||
description: 收 PDF URL → 轉文字 → 切 chunk → 存 KBDB 每塊一個 block
|
||||
|
||||
flow:
|
||||
- "input >> ON_SUCCESS >> convert_pdf"
|
||||
- "convert_pdf >> ON_SUCCESS >> ingest_to_kbdb"
|
||||
|
||||
config:
|
||||
convert_pdf:
|
||||
component: http_request
|
||||
url: "https://kbdb.finally.click/convert"
|
||||
method: POST
|
||||
body_json:
|
||||
file_url: "{{input.pdf_url}}"
|
||||
format: "text"
|
||||
|
||||
# kbdb_ingest 自動 chunk + 寫 blocks(每塊 ~500 字)
|
||||
# source 用 file_url 當去重 key(同 PDF 重 ingest 不會重複建)
|
||||
ingest_to_kbdb:
|
||||
component: kbdb_ingest
|
||||
api_key: "{{api_key}}"
|
||||
page_name: "pdf-{{input.title}}"
|
||||
text: "{{convert_pdf.data.text}}"
|
||||
source: "pdf:{{input.pdf_url}}"
|
||||
user_id: "{{input.user_id}}"
|
||||
@@ -0,0 +1,36 @@
|
||||
# rag-search-answer
|
||||
|
||||
## 解決什麼問題
|
||||
最經典 RAG:用戶問問題 → KBDB semantic search 找相關 blocks → 餵 claude 回答。
|
||||
比直接問 claude 強:claude 有了實際 context,不會編、可引用、回答跟你的資料一致。
|
||||
|
||||
## 怎麼觸發
|
||||
```bash
|
||||
curl -X POST https://cypher.arcrun.dev/webhooks/named/rag_search_answer/trigger \
|
||||
-H "X-Arcrun-API-Key: ak_xxx" \
|
||||
-d '{
|
||||
"api_key":"ak_xxx",
|
||||
"question":"如何避免 CF self-fetch 死鎖?",
|
||||
"user_id":"inkstone_mira_tools"
|
||||
}'
|
||||
```
|
||||
|
||||
## 改成你自己的
|
||||
- `search_kbdb.topK` 改 N(取多少 context,3-10 常見)
|
||||
- `search_kbdb.user_id` 改為 query 該用戶下的 blocks,或拿掉撈全庫
|
||||
- prompt 改為你的 domain(客服 / 法律 / 醫療 / 技術文件)
|
||||
- 進階:加 `_recipe_output_format: json` 讓 claude 回結構化 {answer, citations[]}
|
||||
|
||||
## 為什麼這 pattern 重要
|
||||
RAG 是 LLM 真正派上用場的場景。沒 RAG,LLM 在你私有資料上的回答是猜的。
|
||||
|
||||
## 變體
|
||||
- **多輪 RAG**:先 claude 改寫 question → KBDB search → claude 答(query rewriting)
|
||||
- **多源**:KBDB + web search + DB query → merge → claude
|
||||
- **filter**:claude 先判斷 "需要 RAG 嗎?" → 不需要直接回(省 search latency)
|
||||
- **followup**:把 claude 答案 + 用戶 question 一起存 KBDB,下次同問題直接 cache hit
|
||||
|
||||
## 學到什麼
|
||||
- `kbdb_search` 走 semantic(embedding),不是字面比對 — query 不用打對關鍵字
|
||||
- `{{search_kbdb.results}}` 自動展開為 markdown 列表(component contract)
|
||||
- claude prompt 內注入 context 是 RAG 的核心,不需要 vector DB 之外的額外組件
|
||||
@@ -0,0 +1 @@
|
||||
["rag", "llm", "claude", "kbdb", "semantic-search", "qa", "common-pattern"]
|
||||
@@ -0,0 +1,33 @@
|
||||
name: rag_search_answer
|
||||
description: 收問題 → 從 KBDB semantic search → 把 top context 餵 claude 回答
|
||||
|
||||
flow:
|
||||
- "input >> ON_SUCCESS >> search_kbdb"
|
||||
- "search_kbdb >> ON_SUCCESS >> answer_with_context"
|
||||
|
||||
config:
|
||||
search_kbdb:
|
||||
component: kbdb_search
|
||||
api_key: "{{api_key}}"
|
||||
query: "{{input.question}}"
|
||||
topK: 5
|
||||
user_id: "{{input.user_id}}" # 可選,限定某用戶 namespace
|
||||
|
||||
answer_with_context:
|
||||
component: claude_api
|
||||
timeout_ms: 45000
|
||||
_recipe_output_format: text
|
||||
prompt: |
|
||||
你是知識庫助手。根據下列 context 回答問題。
|
||||
|
||||
**規則**:
|
||||
1. 只用 context 內的資訊,不外推
|
||||
2. context 沒講的,老實說「資料庫裡查不到」,不要編
|
||||
3. 引用時標 [block_id],方便用戶追原始
|
||||
|
||||
Context:
|
||||
{{search_kbdb.results}}
|
||||
|
||||
問題:{{input.question}}
|
||||
|
||||
回答:
|
||||
@@ -0,0 +1,27 @@
|
||||
# webhook-to-http
|
||||
|
||||
## 解決什麼問題
|
||||
最小可用範例:用戶 POST 到 arcrun webhook,arcrun 把整個 payload 轉發到另一個 HTTP API。
|
||||
適合測試 arcrun 連通性、做簡單的 API 橋接、event forwarding。
|
||||
|
||||
## 怎麼觸發
|
||||
```bash
|
||||
curl -X POST https://cypher.arcrun.dev/webhooks/named/webhook_to_http/trigger \
|
||||
-H "X-Arcrun-API-Key: ak_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"hello": "world"}'
|
||||
```
|
||||
|
||||
## 預期結果
|
||||
- response 含 `success: true` 跟下游 httpbin 回的 echo
|
||||
- 下游 URL 收到 `{received: {hello: "world"}, timestamp: "2026-..."}`
|
||||
|
||||
## 改成你自己的
|
||||
- `forward.url` 改你想打的 API
|
||||
- `body_json` 改你要送的 payload schema
|
||||
- 需要 auth header → `forward.headers` 加(或用 credentials 機制)
|
||||
|
||||
## 學到什麼
|
||||
- 最簡單的 flow:input → 單一節點
|
||||
- `{{input}}` 取得 trigger 時 POST 進來的整份 JSON
|
||||
- `body_json` 結構化 body(不是 string)
|
||||
@@ -0,0 +1 @@
|
||||
["webhook", "http", "starter", "forward", "bridge"]
|
||||
@@ -0,0 +1,16 @@
|
||||
name: webhook_to_http
|
||||
description: 收 webhook → 轉發到另一個 HTTP API
|
||||
|
||||
flow:
|
||||
- "input >> ON_SUCCESS >> forward"
|
||||
|
||||
config:
|
||||
forward:
|
||||
component: http_request
|
||||
url: "https://httpbin.org/post"
|
||||
method: POST
|
||||
headers:
|
||||
Content-Type: "application/json"
|
||||
body_json:
|
||||
received: "{{input}}"
|
||||
timestamp: "{{_now}}"
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "arcrun-registry",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0",
|
||||
"zod": "~3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250219.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0",
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+1886
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
// Backfill component metadata 進 registry index
|
||||
// SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 1
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/backfill-index.mjs --dry-run # 看會送什麼
|
||||
// node scripts/backfill-index.mjs # 真的灌
|
||||
//
|
||||
// 流程:
|
||||
// 1. 掃 ../components/*/component.contract.yaml
|
||||
// 2. 解析 YAML(用 zero-dep 簡易 parser,contract 是 well-formed YAML)
|
||||
// 3. 對每個 contract POST registry.arcrun.dev/components/index-only
|
||||
// 4. 印 success / already_indexed / fail 統計
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const COMPONENTS_DIR = join(__dirname, '..', 'components');
|
||||
const REGISTRY_URL = process.env.REGISTRY_URL ?? 'https://registry.arcrun.dev';
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
|
||||
// YAML 是 well-formed contract.yaml,用 js-yaml 解析最穩
|
||||
async function parseYaml(text) {
|
||||
const { load } = await import('js-yaml');
|
||||
return load(text);
|
||||
}
|
||||
|
||||
function listComponents() {
|
||||
return readdirSync(COMPONENTS_DIR)
|
||||
.filter((name) => {
|
||||
const p = join(COMPONENTS_DIR, name);
|
||||
return statSync(p).isDirectory();
|
||||
})
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function readContract(name) {
|
||||
const path = join(COMPONENTS_DIR, name, 'component.contract.yaml');
|
||||
const text = readFileSync(path, 'utf-8');
|
||||
return parseYaml(text);
|
||||
}
|
||||
|
||||
async function postIndexOnly(contract) {
|
||||
const res = await fetch(`${REGISTRY_URL}/components/index-only`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ contract }),
|
||||
});
|
||||
const body = await res.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
parsed = { raw: body };
|
||||
}
|
||||
return { status: res.status, body: parsed };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== arcrun Component Registry Backfill ===');
|
||||
console.log(`Registry: ${REGISTRY_URL}`);
|
||||
console.log(`Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`);
|
||||
console.log();
|
||||
|
||||
const names = listComponents();
|
||||
console.log(`Found ${names.length} components in ${COMPONENTS_DIR}`);
|
||||
console.log();
|
||||
|
||||
const stats = { created: 0, already: 0, fail: 0 };
|
||||
|
||||
for (const name of names) {
|
||||
let contract;
|
||||
try {
|
||||
contract = await readContract(name);
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${name.padEnd(28)} READ FAIL: ${e.message}`);
|
||||
stats.fail++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cid = contract.canonical_id ?? '(no canonical_id)';
|
||||
const ver = contract.version ?? '(no version)';
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(` → ${name.padEnd(28)} ${cid} ${ver}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const { status, body } = await postIndexOnly(contract);
|
||||
if (status === 200 && body.already_indexed) {
|
||||
console.log(` = ${name.padEnd(28)} ${cid} ${ver} [already]`);
|
||||
stats.already++;
|
||||
} else if (status === 201) {
|
||||
console.log(` ✓ ${name.padEnd(28)} ${cid} ${ver} [${body.component_hash_id}]`);
|
||||
stats.created++;
|
||||
} else {
|
||||
console.log(` ✗ ${name.padEnd(28)} ${cid} ${ver} HTTP ${status}: ${JSON.stringify(body).slice(0, 200)}`);
|
||||
stats.fail++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${name.padEnd(28)} POST FAIL: ${e.message}`);
|
||||
stats.fail++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log('=== Summary ===');
|
||||
console.log(`Created: ${stats.created}`);
|
||||
console.log(`Already indexed: ${stats.already}`);
|
||||
console.log(`Failed: ${stats.fail}`);
|
||||
process.exit(stats.fail > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('Fatal:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user