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:
uncle6me-web
2026-06-03 15:52:38 +08:00
commit 922a57fe34
485 changed files with 89356 additions and 0 deletions
@@ -0,0 +1,653 @@
/**
* Auth Recipe Seeds
*
* 平台預建的 auth recipe 定義,部署時寫入 RECIPES KV。
* 新增服務 = 在此加一筆,不需改其他程式碼。
*
* KV key: auth_recipe:{service}
*/
import type { AuthRecipeDefinition } from '../routes/recipes';
const now = Date.now();
export const AUTH_RECIPE_SEEDS: AuthRecipeDefinition[] = [
// ── Static Key 類 ──────────────────────────────────────────────────────────
{
kind: 'auth_recipe',
service: 'notion',
version: 1,
primitive: 'static_key',
base_url: 'https://api.notion.com/v1',
display_name: 'Notion',
description: 'Notion API — 頁面、資料庫讀寫',
required_secrets: [
{
key: 'notion_token',
label: 'Internal Integration Token',
help: '至 https://www.notion.so/my-integrations 建立 Integration',
help_url: 'https://www.notion.so/my-integrations',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.notion_token}}',
'Notion-Version': '2022-06-28',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'slack',
version: 1,
primitive: 'static_key',
base_url: 'https://slack.com/api',
display_name: 'Slack',
description: 'Slack Bot API — 發訊息、查頻道',
required_secrets: [
{
key: 'slack_bot_token',
label: 'Bot User OAuth Token (xoxb-...)',
help: '至 https://api.slack.com/apps 建立 App,取得 Bot Token',
help_url: 'https://api.slack.com/apps',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.slack_bot_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'github',
version: 1,
primitive: 'static_key',
base_url: 'https://api.github.com',
display_name: 'GitHub',
description: 'GitHub REST API — repo、issue、PR 操作',
required_secrets: [
{
key: 'github_token',
label: 'Personal Access Token (classic 或 fine-grained)',
help: '至 https://github.com/settings/tokens 建立',
help_url: 'https://github.com/settings/tokens',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.github_token}}',
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'openai',
version: 1,
primitive: 'static_key',
base_url: 'https://api.openai.com/v1',
display_name: 'OpenAI',
description: 'OpenAI API — Chat Completions、Embeddings 等',
required_secrets: [
{
key: 'openai_api_key',
label: 'API Key (sk-...)',
help: '至 https://platform.openai.com/api-keys 建立',
help_url: 'https://platform.openai.com/api-keys',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.openai_api_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'anthropic',
version: 1,
primitive: 'static_key',
base_url: 'https://api.anthropic.com/v1',
display_name: 'Anthropic (Claude)',
description: 'Anthropic API — Claude 模型呼叫',
required_secrets: [
{
key: 'anthropic_api_key',
label: 'API Key',
help: '至 https://console.anthropic.com/settings/keys 建立',
help_url: 'https://console.anthropic.com/settings/keys',
},
],
inject: {
header: {
'x-api-key': '{{secret.anthropic_api_key}}',
'anthropic-version': '2023-06-01',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'airtable',
version: 1,
primitive: 'static_key',
base_url: 'https://api.airtable.com/v0',
display_name: 'Airtable',
description: 'Airtable API — 讀寫 Base 資料',
required_secrets: [
{
key: 'airtable_token',
label: 'Personal Access Token',
help: '至 https://airtable.com/create/tokens 建立',
help_url: 'https://airtable.com/create/tokens',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.airtable_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'discord',
version: 1,
primitive: 'static_key',
base_url: 'https://discord.com/api/v10',
display_name: 'Discord',
description: 'Discord Bot API — 發訊息、管理伺服器',
required_secrets: [
{
key: 'discord_bot_token',
label: 'Bot Token',
help: '至 https://discord.com/developers/applications 建立 Bot,取得 Token',
help_url: 'https://discord.com/developers/applications',
},
],
inject: {
header: {
Authorization: 'Bot {{secret.discord_bot_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'stripe',
version: 1,
primitive: 'static_key',
base_url: 'https://api.stripe.com/v1',
display_name: 'Stripe',
description: 'Stripe API — 支付、客戶、訂閱管理',
required_secrets: [
{
key: 'stripe_secret_key',
label: 'Secret Key (sk_live_... 或 sk_test_...)',
help: '至 https://dashboard.stripe.com/apikeys 取得',
help_url: 'https://dashboard.stripe.com/apikeys',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.stripe_secret_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'twilio',
version: 1,
primitive: 'static_key',
base_url: 'https://api.twilio.com/2010-04-01',
display_name: 'Twilio',
description: 'Twilio API — SMS、電話、WhatsApp',
required_secrets: [
{
key: 'twilio_account_sid',
label: 'Account SID',
help: '至 https://console.twilio.com/ 取得',
help_url: 'https://console.twilio.com/',
},
{
key: 'twilio_auth_token',
label: 'Auth Token',
help: '至 https://console.twilio.com/ 取得',
help_url: 'https://console.twilio.com/',
},
],
inject: {
header: {
Authorization: 'Basic {{secret.twilio_account_sid}}:{{secret.twilio_auth_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'sendgrid',
version: 1,
primitive: 'static_key',
base_url: 'https://api.sendgrid.com/v3',
display_name: 'SendGrid',
description: 'SendGrid Email API — 發送交易郵件',
required_secrets: [
{
key: 'sendgrid_api_key',
label: 'API Key (SG....)',
help: '至 https://app.sendgrid.com/settings/api_keys 建立',
help_url: 'https://app.sendgrid.com/settings/api_keys',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.sendgrid_api_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'hubspot',
version: 1,
primitive: 'static_key',
base_url: 'https://api.hubapi.com',
display_name: 'HubSpot',
description: 'HubSpot CRM API — 聯絡人、公司、交易管理',
required_secrets: [
{
key: 'hubspot_token',
label: 'Private App Access Token',
help: '至 HubSpot Settings → Integrations → Private Apps 建立',
help_url: 'https://developers.hubspot.com/docs/api/private-apps',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.hubspot_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'linear',
version: 1,
primitive: 'static_key',
base_url: 'https://api.linear.app',
display_name: 'Linear',
description: 'Linear API — Issue、Project 管理',
required_secrets: [
{
key: 'linear_api_key',
label: 'Personal API Key',
help: '至 https://linear.app/settings/api 建立',
help_url: 'https://linear.app/settings/api',
},
],
inject: {
header: {
Authorization: '{{secret.linear_api_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'shopify',
version: 1,
primitive: 'static_key',
base_url: 'https://{{secret.shopify_store}}.myshopify.com/admin/api/2024-01',
display_name: 'Shopify',
description: 'Shopify Admin API — 訂單、商品、客戶管理',
required_secrets: [
{
key: 'shopify_access_token',
label: 'Admin API Access Token',
help: '至 Shopify Admin → Apps → App and sales channel settings → Private apps',
help_url: 'https://shopify.dev/docs/apps/auth/admin-app-access-tokens',
},
{
key: 'shopify_store',
label: 'Store subdomain(不含 .myshopify.com',
help: '例如 my-store(對應 my-store.myshopify.com',
},
],
inject: {
header: {
'X-Shopify-Access-Token': '{{secret.shopify_access_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'resend',
version: 1,
primitive: 'static_key',
base_url: 'https://api.resend.com',
display_name: 'Resend',
description: 'Resend Email API — 發送交易郵件',
required_secrets: [
{
key: 'resend_api_key',
label: 'API Key (re_...)',
help: '至 https://resend.com/api-keys 建立',
help_url: 'https://resend.com/api-keys',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.resend_api_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'supabase',
version: 1,
primitive: 'static_key',
base_url: 'https://{{secret.supabase_project_ref}}.supabase.co/rest/v1',
display_name: 'Supabase',
description: 'Supabase REST API — 資料庫讀寫',
required_secrets: [
{
key: 'supabase_service_key',
label: 'Service Role Key (eyJ...)',
help: '至 Supabase Project Settings → API → service_role key',
help_url: 'https://supabase.com/dashboard',
},
{
key: 'supabase_project_ref',
label: 'Project Reference IDURL 中的 xxx.supabase.co 的 xxx',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.supabase_service_key}}',
apikey: '{{secret.supabase_service_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'typeform',
version: 1,
primitive: 'static_key',
base_url: 'https://api.typeform.com',
display_name: 'Typeform',
description: 'Typeform API — 表單、問卷回應讀取',
required_secrets: [
{
key: 'typeform_token',
label: 'Personal Access Token',
help: '至 https://admin.typeform.com/account#/section/tokens 建立',
help_url: 'https://developer.typeform.com/get-started/',
},
],
inject: {
header: {
Authorization: 'Bearer {{secret.typeform_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'jira',
version: 1,
primitive: 'static_key',
base_url: 'https://{{secret.jira_domain}}.atlassian.net/rest/api/3',
display_name: 'Jira',
description: 'Jira API — Issue、Sprint、Project 管理',
required_secrets: [
{
key: 'jira_api_token',
label: 'API Token',
help: '至 https://id.atlassian.com/manage-profile/security/api-tokens 建立',
help_url: 'https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/',
},
{
key: 'jira_email',
label: '你的 Atlassian 帳號 Email',
},
{
key: 'jira_domain',
label: 'Jira 子網域(xxx.atlassian.net 的 xxx',
},
],
inject: {
header: {
Authorization: 'Basic {{secret.jira_email}}:{{secret.jira_api_token}}',
Accept: 'application/json',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'gemini',
version: 1,
primitive: 'static_key',
base_url: 'https://generativelanguage.googleapis.com/v1beta',
display_name: 'Google Gemini',
description: 'Google Gemini API — generateContent / embedContent(使用 API Key',
required_secrets: [
{
key: 'gemini_api_key',
label: 'API Key',
help: '至 https://aistudio.google.com/apikey 建立',
help_url: 'https://aistudio.google.com/apikey',
},
],
inject: {
header: {
'x-goog-api-key': '{{secret.gemini_api_key}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'trello',
version: 1,
primitive: 'static_key',
base_url: 'https://api.trello.com/1',
display_name: 'Trello',
description: 'Trello API — boards / cards / listsAPI key + token 走 query string',
required_secrets: [
{
key: 'trello_api_key',
label: 'API Key',
help: '至 https://trello.com/power-ups/admin 建立 Power-Up 後取得',
help_url: 'https://trello.com/power-ups/admin',
},
{
key: 'trello_token',
label: 'Token',
help: '於 Power-Up 頁面點「Generate Token」授權後取得',
help_url: 'https://trello.com/power-ups/admin',
},
],
inject: {
query: {
key: '{{secret.trello_api_key}}',
token: '{{secret.trello_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'mailgun',
version: 1,
primitive: 'static_key',
base_url: 'https://api.mailgun.net/v3',
display_name: 'Mailgun',
description: 'Mailgun API — 寄信(username 固定 "api"password 為 Private API Key,走 Basic Auth',
required_secrets: [
{
key: 'mailgun_api_key',
label: 'Private API Key',
help: '至 Mailgun Dashboard → API Security → Sending Keys 建立',
help_url: 'https://app.mailgun.com/mg/sending/domains',
},
{
key: 'mailgun_domain',
label: 'Sending Domain',
help: '你在 Mailgun 設定好的 sending domain(例:mg.yourdomain.com',
help_url: 'https://app.mailgun.com/mg/sending/domains',
},
],
inject: {
header: {
Authorization: 'Basic api:{{secret.mailgun_api_key}}',
},
},
created_at: now,
updated_at: now,
},
// ── Service Account 類(Google 家族,共用同一份 service_account_json)────────
{
kind: 'auth_recipe',
service: 'google_sheets_sa',
version: 1,
primitive: 'service_account',
service_account_kind: 'google_jwt',
base_url: 'https://sheets.googleapis.com/v4',
display_name: 'Google Sheets (Service Account)',
description: 'Google Sheets API — 試算表讀寫(使用 Service Account',
token_exchange: {
endpoint: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
},
required_secrets: [
{
key: 'google_service_account',
label: 'Service Account JSON(整份貼上)',
type: 'json_blob',
help: '至 GCP Console → IAM → Service Accounts → Keys → Add Key → JSON,下載後整份貼入',
help_url: 'https://console.cloud.google.com/iam-admin/serviceaccounts',
},
],
inject: {
header: {
Authorization: 'Bearer {{runtime.access_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'google_gmail_sa',
version: 1,
primitive: 'service_account',
service_account_kind: 'google_jwt',
base_url: 'https://gmail.googleapis.com/gmail/v1',
display_name: 'Gmail (Service Account)',
description: 'Gmail API — 發送郵件(使用 Service Account + Domain-Wide Delegation',
token_exchange: {
endpoint: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/gmail.send'],
},
required_secrets: [
{
key: 'google_service_account',
label: 'Service Account JSON(整份貼上)',
type: 'json_blob',
help: '需要 Domain-Wide Delegation,至 GCP Console → IAM → Service Accounts 設定',
help_url: 'https://developers.google.com/workspace/guides/create-credentials#service-account',
},
],
inject: {
header: {
Authorization: 'Bearer {{runtime.access_token}}',
},
},
created_at: now,
updated_at: now,
},
{
kind: 'auth_recipe',
service: 'google_drive_sa',
version: 1,
primitive: 'service_account',
service_account_kind: 'google_jwt',
base_url: 'https://www.googleapis.com/drive/v3',
display_name: 'Google Drive (Service Account)',
description: 'Google Drive API — 檔案上傳、下載、管理(使用 Service Account',
token_exchange: {
endpoint: 'https://oauth2.googleapis.com/token',
scopes: ['https://www.googleapis.com/auth/drive'],
},
required_secrets: [
{
key: 'google_service_account',
label: 'Service Account JSON(整份貼上)',
type: 'json_blob',
help: '至 GCP Console → IAM → Service Accounts → Keys → Add Key → JSON',
help_url: 'https://console.cloud.google.com/iam-admin/serviceaccounts',
},
],
inject: {
header: {
Authorization: 'Bearer {{runtime.access_token}}',
},
},
created_at: now,
updated_at: now,
},
];
+365
View File
@@ -0,0 +1,365 @@
/**
* arcrun component loader
*
* 解析優先序:
*
* 0. trigger_workflow 內建 orchestration 零件(in-process call,繞 CF self-fetch 死鎖)
* 1. 內建零件(BUILTIN_COMPONENTS)— 純 JS,最快
* 2. 外部 URLhttps://...)— 直接 fetchn8n/MCP/任何 HTTP 服務
* 3. cmp_xxxxxxxx hash → 查 WEBHOOKS KV idx → canonical_id → 邏輯 Worker
* 4. rec_xxxxxxxx hash → 查 RECIPES KV idx → recipe 執行
* 5. 邏輯零件 canonical_id → Service Binding(同帳號不走公網)
* 5.5. Auth recipe(平台預建)→ Auth Recipe Runner
* 6. KV recipe canonical_id → 從 RECIPES KV 讀取 recipe → fetch 外部 API
* 7. WASM HTTP runnerauth primitive / API 零件 → 獨立 Worker URL
* 8. 找不到 → 報錯
*/
import { BUILTIN_COMPONENTS } from './constants';
import { isComponentHash, isRecipeHash } from './hash';
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
import type { AuthRecipeDefinition } from '../routes/recipes';
import type { Bindings, ComponentRunner, ServiceBinding } from '../types';
/**
* WASM HTTP runnercanonical_id → 對應獨立 Worker URL。
*
* 所有 WASM 零件(auth primitive / API 零件 / 未來用戶自製)都是獨立部署的 Worker,
* 以 `{canonical-id-kebab}.arcrun.dev` 為 URL 慣例。cypher-executor 不做 WASM
* instantiate,只做 HTTP fetch。這層是 API 零件(及 auth primitive)的唯一入口。
*
* R2 動態注入 WASM 路徑作廢(CF workerd 不支援以 R2 物件臨時 instantiate)。
*/
// TODO(架構債,2026-05-07):白名單寫死違反 arcrun 「新零件無需改 cypher-executor」承諾
// 應改為從 component-registry KV 動態查(registry 已有 backfill index,知道所有 canonical_id
// SDD 待開:cypher-executor-dynamic-component-discovery
const WASM_HTTP_RUNNER_IDS: ReadonlySet<string> = new Set([
// 通用 HTTP 零件
'http_request',
// gmail / telegram / line_notify / google_sheets 已降級為 recipe2026-05-29 Phase 2):
// recipe:gmail_send / telegram_send / line_notify_send / google_sheets_read|append
// 走 step 6 KV recipe 解析,不再是零件。零件目錄已刪。
'cron',
// Auth primitives
'auth_static_key',
'auth_service_account',
'auth_oauth2',
'auth_mtls',
]);
/**
* canonical_id → component worker URL(走 workers.dev 子域,避開同 zone 自循環死鎖)
*
* 為何不用 *.arcrun.devcypher-executor 本身綁 cypher.arcrun.dev/*
* fetch 同 zone *.arcrun.dev 會撞 CF 的 zone 自循環防護回 522。
* 詳見 arcrun.md P0 #92026-05-13)。
*
* subdomain 來自 wrangler.toml [vars] WORKER_SUBDOMAIN(預設 uncle6-meself-hosted fork 改自己的)。
*/
export function wasmWorkerUrl(canonicalId: string, subdomain: string): string {
const kebab = canonicalId.replace(/_/g, '-');
// 平台慣例:component worker 名稱 = `arcrun-{kebab}`(見 rule 03 / rule 05),
// 例如 canonical_id=http_request → worker 名 arcrun-http-request → URL arcrun-http-request.{subdomain}.workers.dev
return `https://arcrun-${kebab}.${subdomain}.workers.dev`;
}
/** 邏輯零件 canonical_id → Service Binding key */
const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
if_control: 'SVC_IF_CONTROL',
switch: 'SVC_SWITCH',
foreach_control: 'SVC_FOREACH_CONTROL',
filter: 'SVC_FILTER',
merge: 'SVC_MERGE',
try_catch: 'SVC_TRY_CATCH',
wait: 'SVC_WAIT',
set: 'SVC_SET',
array_ops: 'SVC_ARRAY_OPS',
string_ops: 'SVC_STRING_OPS',
number_ops: 'SVC_NUMBER_OPS',
date_ops: 'SVC_DATE_OPS',
validate_json: 'SVC_VALIDATE_JSON',
// ai_transform_compile / ai_transform_run 已刪除(2026-05-29):
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
};
export function createComponentLoader(env: Bindings) {
return async (componentId: string): Promise<ComponentRunner> => {
// 0. 平台內建 orchestration 零件(需要 env / 跨 workflow 能力)
// 這類零件「是 orchestrator 的職責」(不是業務邏輯),故不違反「業務邏輯走 WASM」規則。
// 目前只有 trigger_workflow:用 in-process call 觸發另一個 named workflow
// 繞掉 CF 同 zone self-fetch 死鎖(避免 cypher-executor 自打 http_request → 1042)。
if (componentId === 'trigger_workflow') {
return makeTriggerWorkflowRunner(env);
}
// 1. 內建零件(純 JS,最優先)
const builtin = BUILTIN_COMPONENTS.get(componentId);
if (builtin) return builtin;
// 2. 外部 URL
if (componentId.startsWith('http://') || componentId.startsWith('https://')) {
return makeHttpRunner(componentId);
}
// 3. cmp_hash → 查 WEBHOOKS KV idx → canonical_id → 邏輯 Worker
if (isComponentHash(componentId)) {
const canonicalId = await env.WEBHOOKS.get(`idx:${componentId}`);
if (canonicalId) {
const runner = makeLogicRunner(canonicalId, env);
if (runner) return runner;
}
throw new Error(`找不到零件 hash "${componentId}",請確認已透過 acr push 上傳`);
}
// 4. rec_hash → 查 RECIPES KV idx → recipe 執行
if (isRecipeHash(componentId)) {
const recipe = await resolveRecipe(componentId, env.RECIPES);
if (recipe) return makeRecipeRunner(recipe);
throw new Error(`找不到 recipe hash "${componentId}",請確認已透過 acr push 上傳`);
}
// 5. 邏輯零件 canonical_id → Service Binding
const logicRunner = makeLogicRunner(componentId, env);
if (logicRunner) return logicRunner;
// 5.5 Auth recipe(平台預建,auth_recipe:{service} in RECIPES KV
const authRecipe = await resolveAuthRecipe(componentId, env.RECIPES);
if (authRecipe) return makeAuthRecipeRunner(authRecipe);
// 6. KV recipe(動態,用戶 push 的)
const kvRecipe = await resolveRecipe(componentId, env.RECIPES);
if (kvRecipe) return makeRecipeRunner(kvRecipe);
// 7. WASM HTTP runner:auth primitive / API 零件 → 獨立 Worker URL
// 白名單見 WASM_HTTP_RUNNER_IDShttp_request、5 個待降級 API 零件、4 個 auth primitive)。
// 對應 Worker 部署於 arcrun-{canonical-id-kebab}.{WORKER_SUBDOMAIN}.workers.dev
// (見 P0 #9 / rule 03)。
if (WASM_HTTP_RUNNER_IDS.has(componentId)) {
return makeHttpRunner(wasmWorkerUrl(componentId, env.WORKER_SUBDOMAIN));
}
// 8. 找不到
throw new Error(
`找不到零件 "${componentId}"。\n` +
`邏輯零件:${Object.keys(LOGIC_BINDING_MAP).join(', ')}\n` +
`或傳入外部 URLhttps://...)、recipe hashrec_xxxxxxxx)、零件 hashcmp_xxxxxxxx`
);
};
}
// ── 執行器工廠 ────────────────────────────────────────────────────────────────
/**
* trigger_workflow 內建 orchestration 零件
*
* 用途:在 workflow A 內 in-process 觸發 workflow B,繞 CF 同 zone self-fetch 死鎖。
*
* 動機:mira_feed_watcher 之前用 http_request 自打 cypher.arcrun.dev → CF 1042。
* 就算改打 arcrun-cypher-executor.{subdomain}.workers.devWorker → 自身 URL 仍
* 被 CF 「self subrequest」防護擋(即使 hostname 不同)。
* 改用 in-process call executeWebhookGraph 徹底繞掉外部 HTTP。
*
* 不違反「業務邏輯走 WASM」鐵律:trigger_workflow 是 orchestrator 自己的 routing 能力
* (像 CALLS_SUBFLOW),不是業務邏輯(不解密 / 不簽 JWT / 不打外部 API)。
*
* Input ctx
* - workflow_name: string (必填,目標 workflow 名稱)
* - api_key: string (必填,KV 查 key prefix)
* - input: object (可選,傳給子 workflow 當 triggerContext)
* - wait: boolean (預設 trueawait 完成;false = fire-and-forget 用 waitUntil)
*
* 動態 import webhook-handlers 避循環依賴(webhook-handlers → component-loader → 自己)。
*/
function makeTriggerWorkflowRunner(env: Bindings): ComponentRunner {
return async (ctx: unknown) => {
const c = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
const workflowName = String(c.workflow_name ?? '');
const apiKey = String(c.api_key ?? '');
const input = (c.input && typeof c.input === 'object')
? c.input as Record<string, unknown>
: {};
const wait = c.wait !== false; // 預設 true
if (!workflowName) return { success: false, error: 'trigger_workflow 缺 workflow_name' };
if (!apiKey) return { success: false, error: 'trigger_workflow 缺 api_key' };
// 從 WEBHOOKS KV 撈目標 workflow 的 graph
const wfKey = `${apiKey}:wf:${workflowName}`;
const wfRaw = await env.WEBHOOKS.get(wfKey, 'text');
if (!wfRaw) return { success: false, error: `找不到 workflow "${workflowName}" (key=${wfKey})` };
let record: { graph?: Record<string, unknown> };
try { record = JSON.parse(wfRaw); }
catch { return { success: false, error: `workflow "${workflowName}" KV 內容非 JSON` }; }
if (!record.graph) return { success: false, error: `workflow "${workflowName}" 缺 graph 欄位` };
// 動態 import 避循環依賴
const { executeWebhookGraph } = await import('../actions/webhook-handlers');
const triggerContext = { ...input, _triggered_by: 'trigger_workflow' };
if (wait) {
const r = await executeWebhookGraph(env, record.graph, triggerContext, workflowName, apiKey);
// paused 是預期狀態(claude_api 等待外部 callback resume),不算失敗
// executeWebhookGraph 內部把 ExecutionError + "paused at node X" 包成 success:false + 含 error 字串
//
// 2026-05-16 rename per LI roadmap (block e924c231) 自評建議:
// 舊 `paused_awaiting_resume` 容易被誤讀成「掛起出問題」
// 新 `running_async` 強調「已接受,繼續在背景跑」— 行為一致,命名更清楚
const isPaused = !r.success && typeof r.error === 'string' && /workflow paused/i.test(r.error);
return {
success: r.success || isPaused,
triggered_workflow: workflowName,
status: r.success ? 'completed' : (isPaused ? 'running_async' : 'failed'),
sub_result: r,
};
} else {
// fire-and-forget — 不 await,但因為沒拿到 ctx.waitUntil,這裡 promise 可能被 cancel
// 目前不啟用,留 wait=true 為預設。未來想要 fire-and-forget 需 plumb ExecutionContext
void executeWebhookGraph(env, record.graph, triggerContext, workflowName, apiKey)
.catch((e) => console.error('[trigger_workflow] fire-and-forget fail', workflowName, e));
return { success: true, triggered_workflow: workflowName, mode: 'fire_and_forget' };
}
};
}
function makeHttpRunner(url: string): ComponentRunner {
return async (ctx: unknown) => {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ctx),
});
if (!res.ok) {
const text = await res.text();
return { success: false, status: res.status, error: text.slice(0, 200) };
}
try { return await res.json(); }
catch { return { success: true, data: await res.text() }; }
};
}
function makeLogicRunner(canonicalId: string, env: Bindings): ComponentRunner | null {
const bindingKey = LOGIC_BINDING_MAP[canonicalId];
if (!bindingKey) return null;
const svc = env[bindingKey] as ServiceBinding | undefined;
if (svc) {
return async (ctx: unknown) => {
const res = await svc.fetch(new Request('https://component/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ctx),
}));
if (!res.ok) {
const text = await res.text();
return { success: false, error: `${canonicalId} 回傳 ${res.status}: ${text.slice(0, 200)}` };
}
try { return await res.json(); }
catch { return { success: false, error: `${canonicalId} 回傳非 JSON` }; }
};
}
// Service Binding 未配置時 fallback 到公網(自製零件 or 開發環境)
// 走 workers.dev 子域避開同 zone 死鎖(P0 #9)
return makeHttpRunner(wasmWorkerUrl(canonicalId, env.WORKER_SUBDOMAIN));
}
function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): ComponentRunner {
return async (ctx: unknown) => {
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
// 模板替換:{{key}} 從 ctx 取;{{auth.K}} 從 _auth_path 取
// _auth_path 由 auth primitive 解密後注入,供 URL path 用,如 telegram /bot{{auth.token}}/
const authPath = (ctxObj._auth_path as Record<string, string>) ?? {};
const interpolate = (s: string) =>
s.replace(/\{\{(auth\.)?(\w+)\}\}/g, (_, authPrefix, k) =>
String(authPrefix ? (authPath[k] ?? '') : (ctxObj[k] ?? '')),
);
const method = (recipe.method ?? 'POST').toUpperCase();
const authHeaders = (ctxObj._auth_headers as Record<string, string>) ?? {};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...authHeaders,
};
for (const [k, v] of Object.entries(recipe.headers ?? {})) {
headers[k] = interpolate(v);
}
// body:把 recipe.body 裡的 {{key}} 都換掉
let bodyStr: string | undefined;
if (recipe.body) {
bodyStr = interpolate(JSON.stringify(recipe.body));
} else if (method !== 'GET') {
// 沒指定 body template → 用 ctx 當 body,但剔除 _ 前綴的內部欄位
// _path / _auth_headers / _auth_query / _auth_body 不該漏進下游請求)
const bodyObj = Object.fromEntries(
Object.entries(ctxObj).filter(([k]) => !k.startsWith('_')),
);
bodyStr = JSON.stringify(bodyObj);
}
const res = await fetch(interpolate(recipe.endpoint), {
method,
headers,
body: bodyStr,
});
const data = await readBodyOnce(res);
return { success: res.ok, status: res.status, data };
};
}
// ── Auth Recipe Runner ────────────────────────────────────────────────────────
//
// credential-injector 已先將認證資訊注入為 _auth_headers / _auth_query / _auth_body。
// 這裡只需要讀取這些欄位,合併進 fetch,再清除 _auth_* 不傳給下游。
function makeAuthRecipeRunner(recipe: AuthRecipeDefinition): ComponentRunner {
return async (ctx: unknown) => {
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
const authHeaders = (ctxObj._auth_headers as Record<string, string>) ?? {};
const authQuery = (ctxObj._auth_query as Record<string, string>) ?? {};
// _path 讓呼叫者指定 endpoint 後綴(e.g. /pages, /messages),可選
const path = typeof ctxObj._path === 'string' ? ctxObj._path : '';
const method = ((ctxObj.method as string) ?? 'POST').toUpperCase();
const url = new URL(recipe.base_url.replace(/\/$/, '') + path);
for (const [k, v] of Object.entries(authQuery)) {
url.searchParams.set(k, v);
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...authHeaders,
};
// body:剔除所有 _ 前綴的內部欄位,以及 method
const bodyObj = Object.fromEntries(
Object.entries(ctxObj).filter(([k]) => !k.startsWith('_') && k !== 'method'),
);
const res = await fetch(url.toString(), {
method,
headers,
body: method !== 'GET' ? JSON.stringify(bodyObj) : undefined,
});
const data = await readBodyOnce(res);
return { success: res.ok, status: res.status, data };
};
}
// 讀 response body 一次:先取 text,再嘗試 parse JSON。
// 不可用 `res.json().catch(() => res.text())` —— res.json() 失敗時 body 已被消費,
// 第二次讀會丟 "Body has already been used"。
async function readBodyOnce(res: Response): Promise<unknown> {
const text = await res.text();
try { return JSON.parse(text); }
catch { return text; }
}
+54
View File
@@ -0,0 +1,54 @@
import type { ComponentRunner, EdgeType } from '../types';
export const VALID_EDGE_TYPES = new Set([
// 現有
'PIPE', 'IF', 'FOREACH', 'CONTINUE',
// 新增:執行語意
'IS_A', 'ON_SUCCESS', 'ON_FAIL',
// 新增:觸發語意
'ON_CLICK', 'CALLS_SUBFLOW',
// 新增:結構語意(記錄圖結構,不執行)
'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR',
]);
/** 內建零件 ID 集合(不需要查 WASM_BUCKETWorker 記憶體中已有實作)*/
export const BUILTIN_IDS = new Set([
'webhook', 'comp_passthrough', 'comp_uppercase', 'comp_counter',
]);
/** 語意邊 → EdgeType 映射(ADR-057 u6u L1:支援中文語意關係詞)
* 完成後 → PIPE(成功後觸發下一個)
* 失敗時 → CONTINUE(失敗後繼續)
* 對每個 → FOREACH(迭代執行)
* 條件滿足時 → IF(條件分支)
*/
export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
// 中文語意詞
'完成後': 'PIPE',
'失敗時': 'ON_FAIL',
'對每個': 'FOREACH',
'條件滿足時': 'IF',
// 英文別名
'SUCCESS': 'ON_SUCCESS',
'FAIL': 'ON_FAIL',
'CLICK': 'ON_CLICK',
'SUBFLOW': 'CALLS_SUBFLOW',
};
/**
* 內建零件表(靜態函數,不需要 R2)
* WASM 零件從 WASM_BUCKET R2 直接讀取
*/
export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
['comp_passthrough', (ctx) => ctx],
['comp_uppercase', (ctx) => {
const c = ctx as Record<string, unknown>;
return { ...c, text: String(c.text || '').toUpperCase() };
}],
['comp_counter', (ctx) => {
const c = ctx as Record<string, unknown>;
return { ...c, count: (Number(c.count) || 0) + 1 };
}],
]);
export const SCORE_THRESHOLD = 0.5;
+92
View File
@@ -0,0 +1,92 @@
/**
* 最小 cron expression matcher5 欄位(minute hour dayOfMonth month dayOfWeek)。
*
* 用於 cypher-executor scheduled() handler — 把 workflow 註冊的 cron_expr 跟
* 每分鐘 tick 的 event.scheduledTime 比對,匹配就觸發該 workflow。
*
* 支援語法(夠用即可,未來再擴):
* `*` — 任何值
* `5` — 等於 5
* `*/N` — 每 N 個(N>0
* `5,10,15` — 任一
* `1-5` — range(含兩端)
*
* 不支援(暫):
* `?` / `L` / `W` / `#` 等延伸語法
* month / weekday 用名稱(jan/mon 等)
*
* 對應 SDD: arcrun.md 三-A P1 #3。
*/
/** 一個欄位(如 'minute')的值是否匹配 expr 段 */
function matchField(expr: string, value: number, min: number, max: number): boolean {
if (expr === '*') return true;
for (const part of expr.split(',')) {
if (matchPart(part.trim(), value, min, max)) return true;
}
return false;
}
function matchPart(part: string, value: number, min: number, max: number): boolean {
// `*/N`
if (part.startsWith('*/')) {
const step = parseInt(part.slice(2), 10);
if (!Number.isFinite(step) || step <= 0) return false;
return (value - min) % step === 0;
}
// `X-Y` 或 `X-Y/N`
if (part.includes('-')) {
const [rangePart, stepStr] = part.split('/');
const [aStr, bStr] = rangePart.split('-');
const a = parseInt(aStr, 10);
const b = parseInt(bStr, 10);
if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
if (value < a || value > b) return false;
if (stepStr === undefined) return true;
const step = parseInt(stepStr, 10);
if (!Number.isFinite(step) || step <= 0) return false;
return (value - a) % step === 0;
}
// `N`
const n = parseInt(part, 10);
if (!Number.isFinite(n)) return false;
if (n < min || n > max) return false;
return value === n;
}
/**
* 比對 cron expr 跟某個時間點。
* @param expr - 5 欄位 cronminute hour dom month dow
* @param date - 要比對的時間(UTC
*/
export function cronMatch(expr: string, date: Date): boolean {
const fields = expr.trim().split(/\s+/);
if (fields.length !== 5) return false;
const [m, h, dom, mon, dow] = fields;
// dow: 0=Sun ... 6=Sat (跟 JavaScript 一致;ISO Mon=1 暫不轉)
return (
matchField(m, date.getUTCMinutes(), 0, 59) &&
matchField(h, date.getUTCHours(), 0, 23) &&
matchField(dom, date.getUTCDate(), 1, 31) &&
matchField(mon, date.getUTCMonth() + 1, 1, 12) &&
matchField(dow, date.getUTCDay(), 0, 6)
);
}
/**
* 從 workflow YAML 的 config 找出 cron 零件節點的 cron_expr。
* 找不到回 null(代表此 workflow 不是 cron-triggered)。
*
* @param graph - acr push 解析後的 ExecutionGraph
*/
export function extractCronExpr(graph: unknown): string | null {
if (!graph || typeof graph !== 'object') return null;
const nodes = (graph as { nodes?: Array<{ id: string; componentId?: string; data?: Record<string, unknown> }> }).nodes;
if (!Array.isArray(nodes)) return null;
for (const node of nodes) {
if (node.componentId !== 'cron') continue;
const expr = node.data?.cron_expr;
if (typeof expr === 'string' && expr.trim()) return expr.trim();
}
return null;
}
@@ -0,0 +1,60 @@
// 資料外流警示 — 同意憑證機制(data-exfil-warning SDD §7 法律憑證 + §1b API 層)
//
// 觸發策略(richblack):只在「資料變成可被外部呼叫」時要求同意(暴露面)。
// webhook 部署(workflow 變對外 endpoint)、recipe push 都算。
//
// 同意 = 法律憑證:留 log(誰、何時、同意了什麼),真出事時有「用戶明示知情同意」證據,
// 避免 arcrun 訴訟風險。「以後不要警示」(suppress_future)本身也 log。
//
// 誠實限制:AI 能偽造 confirmed_by_human。本機制的價值是「法律歸責 + 可審」,不是技術防偽。
/** 暴露同意憑證(人類明示知情同意把某資源開放/送出) */
export interface ExposureConsent {
confirmed_by_human: true; // 必須為 literal true
understood: string; // 人類說明「我知道這會把什麼開放給誰」(非空)
confirmed_at: string; // ISO timestamp
suppress_future?: boolean; // 「以後不要對此資源警示」(本選擇也 log)
}
/**
* 判斷一個暴露動作是否已取得有效同意。
* @param consent 本次請求帶的同意憑證
* @param priorConsent 既有 record 裡存的同意(首次問、記住:§3)
* @returns null = 放行(已同意或已 suppress);string = 拒絕原因
*/
export function checkExposureConsent(
consent: ExposureConsent | undefined,
priorConsent: ExposureConsent | undefined,
): string | null {
// 既有同意且選了「以後不警示」→ 放行(首次問記住)
if (priorConsent?.suppress_future) return null;
// 既有有效同意(同資源已確認過)→ 放行
if (priorConsent?.confirmed_by_human === true) return null;
// 本次請求帶了有效同意 → 放行
if (
consent?.confirmed_by_human === true &&
typeof consent.understood === 'string' &&
consent.understood.trim() !== ''
) {
return null;
}
return (
'此動作會把資源變成可被外部呼叫(暴露/送出資料)。需人類明示同意。\n' +
'請用 CLI 互動確認(acr 會說明風險並提供保護選項),或帶 exposure_consent。\n' +
'arcrun 可幫你保護:要求呼叫者帶 API Key / 設權限 / 限流。'
);
}
/**
* 正規化要存進 record 的同意憑證(法律憑證,可審)。
* 優先用本次新同意,否則沿用既有。
*/
export function resolveConsentForRecord(
consent: ExposureConsent | undefined,
priorConsent: ExposureConsent | undefined,
): ExposureConsent | undefined {
if (consent?.confirmed_by_human === true) return consent;
return priorConsent;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* 穩定 ID 衍生工具
*
* 邏輯零件: cmp_<sha256(canonical_id)[:8]>
* API reciperec_<sha256(canonical_id)[:8]>
*
* 同一個 canonical_id 永遠得到同一個 hash
* 讓 workflow 可以用 hash 引用零件,不受改名影響。
*/
export async function deriveComponentHash(canonicalId: string): Promise<string> {
return 'cmp_' + await sha256Prefix(canonicalId);
}
export async function deriveRecipeHash(canonicalId: string): Promise<string> {
return 'rec_' + await sha256Prefix(canonicalId);
}
export function isComponentHash(id: string): boolean {
return /^cmp_[0-9a-f]{8}$/.test(id);
}
export function isRecipeHash(id: string): boolean {
return /^rec_[0-9a-f]{8}$/.test(id);
}
async function sha256Prefix(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
const buf = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(buf))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 8);
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Magic vars — workflow YAML 內建變數
*
* 對應 LI SDD M2.x improvementfeedback block c47bf70b)。
*
* 任何以 `_` 開頭的變數名都是 reserved(system)。常見:時間、執行 metadata。
* 用於 page_name / file path / URL 等需要時間戳的場景。
*
* 範例 YAML
* page_name: "roadmap-week-{{_iso_week}}" # roadmap-week-2026-W20
* page_name: "log-{{_today}}" # log-2026-05-16
* filename: "snapshot-{{_now_unix}}.json" # snapshot-1778940000123.json
*
* 不違反 §2.2:這是 orchestrator routing 提供的「環境變數」(像 shell 的 $DATE),
* 不涉及 secret / credential / JWT,跟既有 ctx 變數展開同層。
*/
/**
* 算 ISO 8601 週數(W01-W53)。
* 週一為週首,W01 含當年首個週四(ISO 標準)。
* https://en.wikipedia.org/wiki/ISO_week_date
*/
function isoWeekNumber(d: Date): { year: number; week: number } {
const target = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const dayNum = (target.getUTCDay() + 6) % 7; // Mon=0
target.setUTCDate(target.getUTCDate() - dayNum + 3);
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
const weekNum = 1 + Math.round(
((target.getTime() - firstThursday.getTime()) / 86400000 -
3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7
);
return { year: target.getUTCFullYear(), week: weekNum };
}
function pad2(n: number): string {
return n.toString().padStart(2, '0');
}
/**
* 建立 magic vars。每次 workflow 觸發時呼叫一次,貫穿整個執行。
*
* 設計:UTC 為基準(避免 worker 跨時區誤判)。需要本地時區的場景,
* 用戶可自己組(例如 yaml 寫 `{{_today_utc}}` + 自己處理偏移)。
*/
export function buildMagicVars(now: Date = new Date()): Record<string, string | number> {
const iso = now.toISOString(); // 2026-05-16T09:30:00.123Z
const yyyy = now.getUTCFullYear();
const mm = pad2(now.getUTCMonth() + 1);
const dd = pad2(now.getUTCDate());
const hh = pad2(now.getUTCHours());
const mi = pad2(now.getUTCMinutes());
const ss = pad2(now.getUTCSeconds());
const yesterday = new Date(now.getTime() - 86400000);
const yMm = pad2(yesterday.getUTCMonth() + 1);
const yDd = pad2(yesterday.getUTCDate());
const { year: isoYear, week: isoWeek } = isoWeekNumber(now);
return {
// 日期 / 時間(UTC
_today: `${yyyy}-${mm}-${dd}`, // 2026-05-16
_yesterday: `${yesterday.getUTCFullYear()}-${yMm}-${yDd}`, // 2026-05-15
_now: iso, // ISO 8601
_now_unix: now.getTime(), // unix ms
_now_unix_s: Math.floor(now.getTime() / 1000), // unix sec
// 個別欄位(給 path / page_name 拼)
_year: yyyy,
_month: mm,
_day: dd,
_hour: hh,
_minute: mi,
_second: ss,
// ISO 週(roadmap weekly archive 必備)
_iso_week: `${isoYear}-W${pad2(isoWeek)}`, // 2026-W20
_iso_week_num: isoWeek,
_iso_year: isoYear,
// 簡單時間 slotcron-friendly
_yyyymm: `${yyyy}${mm}`, // 202605
_yyyymmdd: `${yyyy}${mm}${dd}`, // 20260516
// 週幾(0=週日,1=週一 ... 6=週六;ISO 風格在 _iso_weekday
_weekday: now.getUTCDay(),
_iso_weekday: ((now.getUTCDay() + 6) % 7) + 1, // 1=Mon...7=Sun
};
}
+306
View File
@@ -0,0 +1,306 @@
export const OPENAPI_SPEC = {
openapi: '3.0.3',
info: {
title: 'arcrun cypher-executor API',
description: 'AI Workflow Execution Engine — 透過三元組 Triplet 或圖 Graph 定義工作流,系統執行並回傳結果',
version: '1.0.0',
contact: {
name: 'arcrun',
url: 'https://github.com/arcrun/arcrun',
},
},
servers: [
{ url: 'https://cypher.arcrun.dev', description: 'arcrun.dev Hosted' },
{ url: 'http://localhost:8787', description: 'Local Development' },
],
paths: {
'/': {
get: {
summary: 'Health Check',
tags: ['Health'],
responses: {
'200': {
description: 'Service is running',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
service: { type: 'string' },
version: { type: 'string' },
status: { type: 'string' },
},
},
},
},
},
},
},
},
'/cypher/search': {
post: {
summary: '搜尋工作流需要的零件',
tags: ['Cypher'],
description: '用三元組描述工作流,系統解析並從 Registry 查詢對應零件',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
triplets: {
type: 'array',
items: { type: 'string' },
example: ['start >> 完成後 >> get-data', 'get-data >> 完成後 >> done'],
description: '三元組陣列,格式:\"FROM >> ACTION >> TO\"',
},
auto_publish: {
type: 'boolean',
default: true,
description: '缺失的零件是否自動產生發佈',
},
},
required: ['triplets'],
},
},
},
},
responses: {
'200': {
description: '零件搜尋成功(含版本號和時戳,適合 Markdown 文檔追蹤)',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
version: { type: 'string', example: 'search-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp}' },
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
triplets: { type: 'array', items: { type: 'string' }, description: '回送的三元組列表' },
nodes: { type: 'object', description: '搜尋到的零件及其狀態' },
cypher: { type: 'object', description: '工作流圖(null 若有缺失零件)' },
missing: { type: 'array', items: { type: 'string' }, description: '缺失零件列表' },
auto_published: { type: 'object', description: '自動發佈的零件(若 auto_publish=true' },
},
},
},
},
},
'400': { description: '無法解析三元組' },
},
},
},
'/cypher/execute': {
post: {
summary: '執行工作流',
tags: ['Cypher'],
description: '直接執行 triplets,回傳完整執行結果。支援自動發佈缺失零件。',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
triplets: {
type: 'array',
items: { type: 'string' },
description: '三元組陣列,格式:"FROM >> ACTION >> TO"',
},
context: {
type: 'object',
description: '執行上下文,傳入各節點作為初始參數',
},
auto_publish: {
type: 'boolean',
default: true,
description: '缺失的零件是否自動產生臨時實作',
},
},
required: ['triplets'],
},
},
},
},
responses: {
'200': {
description: '執行成功(含版本號和時戳)',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
version: { type: 'string', example: 'execute-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp}' },
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
success: { type: 'boolean', enum: [true] },
data: { type: 'object', description: '執行結果' },
trace: { type: 'array', description: '執行跟蹤' },
duration_ms: { type: 'number' },
},
},
},
},
},
'500': {
description: '執行失敗或部份零件缺失(含版本號和時戳)',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
version: { type: 'string', example: 'execute-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp}' },
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
success: { type: 'boolean', enum: [false] },
error: { type: 'string' },
missing: { type: 'array', items: { type: 'string' }, description: '無法自動發佈的缺失零件' },
auto_published: {
type: 'object',
description: '自動發佈的零件資訊',
additionalProperties: {
type: 'object',
properties: {
ok: { type: 'boolean' },
componentId: { type: 'string' },
temporary_endpoint: { type: 'string', format: 'uri', description: '臨時實作的 URL' },
implement_by: { type: 'string', format: 'date-time', description: '實作截止時間' },
},
},
},
duration_ms: { type: 'number' },
},
},
},
},
},
},
},
},
'/webhooks': {
post: {
summary: '建立 Webhook',
tags: ['Webhooks'],
description: '將工作流註冊成 Webhook,得到公開 URL',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
triplets: {
type: 'array',
items: { type: 'string' },
},
description: { type: 'string' },
},
},
},
},
},
responses: {
'201': {
description: 'Webhook 建立成功',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
token: { type: 'string' },
webhook_url: { type: 'string', format: 'uri' },
description: { type: 'string' },
created_at: { type: 'string', format: 'date-time' },
},
},
},
},
},
},
},
get: {
summary: '列出所有 Webhooks',
tags: ['Webhooks'],
parameters: [
{
name: 'Authorization',
in: 'header',
required: true,
schema: { type: 'string', example: 'Bearer u6u_xxxxx' },
description: 'API Key 認證',
},
],
responses: {
'200': {
description: 'Webhooks 列表',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
webhooks: {
type: 'array',
items: {
type: 'object',
properties: {
token: { type: 'string' },
description: { type: 'string' },
created_at: { type: 'string', format: 'date-time' },
},
},
},
total: { type: 'number' },
},
},
},
},
},
'401': { description: '未授權' },
},
},
},
'/webhooks/{token}': {
get: {
summary: '查詢單個 Webhook',
tags: ['Webhooks'],
parameters: [
{
name: 'token',
in: 'path',
required: true,
schema: { type: 'string' },
},
],
responses: {
'200': {
description: 'Webhook 資訊',
},
'404': { description: 'Webhook 不存在' },
},
},
delete: {
summary: '刪除 Webhook',
tags: ['Webhooks'],
parameters: [
{
name: 'token',
in: 'path',
required: true,
schema: { type: 'string' },
},
],
responses: {
'200': { description: 'Webhook 已刪除' },
'404': { description: 'Webhook 不存在' },
},
},
},
},
components: {
securitySchemes: {
ApiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'Authorization',
},
},
},
};
+195
View File
@@ -0,0 +1,195 @@
/**
* Paused workflow runs:節點回 pending 時把 run state 持久化進 KV
* webhook callback 進來時撿回繼續執行
*
* SDD: matrix/arcrun/.agents/specs/resumable-workflow/design.md §2.1
*
* KV key: paused_run:{task_id}
* TTL: 24h(避免 KV 累積,超過就 GC)
*
* 設計筆記:
* - 用 task_id 當 keydaemon 派的 unique id),不用 run_id(同 run 可能多 paused 節點 v2
* - consume = load + delete 原子操作(避免重複 callback 重複執行)
*/
import type { ExecutionGraph, TraceStep } from '../types';
export interface PausedRunState {
run_id: string;
graph: ExecutionGraph;
paused_node_id: string;
paused_context: Record<string, unknown>;
paused_pending_result: Record<string, unknown>; // 節點回的 {pending, task_id, ...}
trace_so_far: TraceStep[];
api_key?: string;
expires_at: number; // unix ms
// resume 時用來 parse callback result 的 recipe output 規格(resumable + recipe 整合)
recipe_output_format?: 'text' | 'json';
recipe_output_required_fields?: string[];
}
const KEY_PREFIX = 'paused_run:';
/**
* Per-user paused index:列出某 api_key 當前 paused tasks 不依賴 CF KV list(強 eventual
* consistent30-60s 延遲)。改維護一個 user-keyed JSON listlist 操作改 single KV.get。
*
* Key: `paused_idx:{api_key}`
* Value: JSON Array<{task_id, paused_node_id, run_id, workflow_name?, expires_at, persisted_at}>
*
* 對應 LI SDD M2.1 — /executions/paused endpoint 即時性。
*/
const IDX_PREFIX = 'paused_idx:';
const TTL_SECONDS = 24 * 60 * 60;
export type PausedIndexEntry = {
task_id: string;
run_id: string;
paused_node_id: string;
workflow_name?: string;
expires_at: number;
persisted_at: number;
};
type KvBinding = {
get: (key: string) => Promise<string | null>;
put: (key: string, value: string, options?: { expirationTtl?: number }) => Promise<void>;
delete: (key: string) => Promise<void>;
};
async function readIndex(kv: KvBinding, apiKey: string): Promise<PausedIndexEntry[]> {
const raw = await kv.get(`${IDX_PREFIX}${apiKey}`);
if (!raw) return [];
try {
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
async function writeIndex(kv: KvBinding, apiKey: string, entries: PausedIndexEntry[]): Promise<void> {
// 過濾過期項目(避免 index 爆量)
const now = Date.now();
const fresh = entries.filter((e) => e.expires_at > now);
await kv.put(`${IDX_PREFIX}${apiKey}`, JSON.stringify(fresh), { expirationTtl: TTL_SECONDS });
}
export async function persistPausedRun(
kv: KvBinding,
taskId: string,
state: PausedRunState,
): Promise<void> {
// 1) 寫單一 task state
await kv.put(`${KEY_PREFIX}${taskId}`, JSON.stringify(state), { expirationTtl: TTL_SECONDS });
// 2) 維護 per-user index(讓 /executions/paused list 不靠 KV list 即時拿到)
if (state.api_key) {
const idx = await readIndex(kv, state.api_key);
// 去重(重複 paused 同 task_id 時覆蓋)
const filtered = idx.filter((e) => e.task_id !== taskId);
filtered.unshift({
task_id: taskId,
run_id: state.run_id,
paused_node_id: state.paused_node_id,
workflow_name: state.graph.name,
expires_at: state.expires_at,
persisted_at: Date.now(),
});
// 限 100 筆避免 index 無限長(超過捨棄最舊)
await writeIndex(kv, state.api_key, filtered.slice(0, 100));
}
}
export async function loadPausedRun(
kv: KvBinding,
taskId: string,
): Promise<PausedRunState | null> {
const raw = await kv.get(`${KEY_PREFIX}${taskId}`);
if (!raw) return null;
try {
return JSON.parse(raw) as PausedRunState;
} catch {
return null;
}
}
/**
* 列某 api_key 當前 paused tasks。走 per-user index(強 consistent,無 KV list 延遲)
*/
export async function listPausedRunsByApiKey(
kv: KvBinding,
apiKey: string,
limit = 20,
): Promise<PausedIndexEntry[]> {
const idx = await readIndex(kv, apiKey);
const now = Date.now();
return idx.filter((e) => e.expires_at > now).slice(0, limit);
}
/**
* 原子讀+刪:避免同 task_id 重複 callback 重複執行下游
* (CF KV 沒真原子操作,但 delete 失敗不影響 load 已成功)
*/
export async function consumePausedRun(
kv: KvBinding,
taskId: string,
): Promise<PausedRunState | null> {
const state = await loadPausedRun(kv, taskId);
if (!state) return null;
await kv.delete(`${KEY_PREFIX}${taskId}`).catch(() => {
// delete 失敗不擋,最多就重複執行一次(接受)
});
// 同步從 per-user index 移除
if (state.api_key) {
const idx = await readIndex(kv, state.api_key);
const filtered = idx.filter((e) => e.task_id !== taskId);
await writeIndex(kv, state.api_key, filtered).catch(() => {});
}
return state;
}
/** 偵測 component result 是否為「需要 resume」的 pending pattern */
export function isResumablePending(result: unknown): { task_id: string } | null {
if (!result || typeof result !== 'object') return null;
const r = result as Record<string, unknown>;
if (r.pending !== true) return null;
if (typeof r.task_id !== 'string' || !r.task_id) return null;
return { task_id: r.task_id };
}
/**
* Parse claude_api result with recipe output format.
* 同步路徑跟 resume 路徑都用同一個解析器,避免邏輯歪掉。
*
* 輸入:result(可能是 {data:{text:"..."}} 或 {text:"..."}
* 輸出:parsed object 或 fallback 結構
*/
export function parseRecipeOutput(
result: unknown,
format: 'text' | 'json' | undefined,
requiredFields: string[] | undefined,
): unknown {
if (format !== 'json' || !result || typeof result !== 'object') return result;
const r = result as Record<string, unknown>;
const text = (r.data as Record<string, unknown> | undefined)?.text ?? r.text;
if (typeof text !== 'string') return result;
// 剝除 ```json ... ``` markdown fenceClaude 常這樣包)
let jsonText = String(text).trim();
const fenceMatch = jsonText.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/);
if (fenceMatch) jsonText = fenceMatch[1].trim();
try {
const parsed = JSON.parse(jsonText);
if (requiredFields && parsed && typeof parsed === 'object') {
const missing = requiredFields.filter((f) => !(f in (parsed as Record<string, unknown>)));
if (missing.length > 0) {
return { success: false, error: `recipe output 缺欄位: ${missing.join(', ')}`, raw: parsed };
}
}
// 把 parsed 的欄位 spread 到 top-levelFOREACH / 下游 {{var}} 都好取
return { success: true, data: parsed, ...(parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {}) };
} catch (e) {
return { success: false, error: `recipe output JSON parse 失敗: ${e instanceof Error ? e.message : String(e)}`, raw_text: text };
}
}
@@ -0,0 +1,90 @@
/**
* prompt_recipe Zod schema
* SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.1
*
* 平行於既有 auth_recipe / api_recipe,存 RECIPES KV (key: `prompt_recipe:{name}`)
* 容器 + recipe 模式:claude_api 是容器,recipe 是配方
*/
import { z } from 'zod';
// ── Transform 白名單 ──────────────────────────────────────────────────────────
// 限制 transform 種類避免變 mini-DSL;超過範圍請寫零件
export const TRANSFORM_NAMES = [
'json_array', // array → JSON.stringify 整體
'to_string', // 任意值 → String(x)
'join', // array → join(sep)sep 預設換行
'markdown_list', // array → "- a\n- b\n- c"
'extract_field', // array of object → 抽 field 後的 array(再可串其他 transform
'first', // array → first element(取單一)
'pluck_content', // KBDB blocks array → 抽 content 後 join 雙換行(草稿合併常用)
] as const;
/** transform 表示法:name 或 name:arg(如 extract_field:page_name */
export const TransformSchema = z.string().regex(/^[a-z_]+(:.+)?$/, 'transform 必須為 name 或 name:arg 格式');
// ── Fragment:從 KBDB / KV 抓固定資料 ──────────────────────────────────────────
export const KBDBBlockFragmentSchema = z.object({
var: z.string().min(1), // prompt template 內的變數名
source: z.literal('kbdb_block'),
block_id: z.string().optional(), // 二擇一
block_page_name: z.string().optional(), // 比 block_id 穩定
field: z.string().default('content'), // 抓 block 的哪個欄位
});
export const KVFragmentSchema = z.object({
var: z.string().min(1),
source: z.literal('kv'),
key: z.string().min(1),
});
// discriminatedUnion 對 refined zod object 不支援,故拆成驗證後 + 單獨檢查 block_id|page_name
export const FragmentSchema = z.discriminatedUnion('source', [
KBDBBlockFragmentSchema,
KVFragmentSchema,
]).superRefine((d, ctx) => {
if (d.source === 'kbdb_block' && !d.block_id && !d.block_page_name) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'block_id 或 block_page_name 必填其一',
});
}
});
// ── Input:從 workflow context 取值(含 transform) ────────────────────────────
export const InputSchema = z.object({
var: z.string().min(1),
from: z.string().min(1), // JSONPath-lite,如 "ctx.read_drafts.blocks"
transform: TransformSchema.optional(),
default: z.unknown().optional(), // from 取不到時的預設值(避免炸 prompt)
});
// ── Prompt 組裝 ──────────────────────────────────────────────────────────────
export const PromptAssemblySchema = z.object({
system: z.string().min(1), // 模板,可含 {{var}}
user: z.string().min(1),
});
// ── 輸出規格 ──────────────────────────────────────────────────────────────────
export const OutputSpecSchema = z.object({
format: z.enum(['text', 'json']).default('text'),
// 若 format=json,可選 schema 做 parse 後驗證(簡化版,列必填欄位即可)
required_fields: z.array(z.string()).optional(),
});
// ── 完整 prompt_recipe 定義 ────────────────────────────────────────────────────
export const PromptRecipeSchema = z.object({
kind: z.literal('prompt_recipe'),
name: z.string().min(1).regex(/^[a-z][a-z0-9_]*$/, 'name 為 lowercase + underscore'),
version: z.number().int().positive().default(1),
description: z.string().optional(),
model: z.enum(['haiku', 'sonnet', 'opus']).default('sonnet'),
fragments: z.array(FragmentSchema).default([]),
inputs: z.array(InputSchema).default([]),
prompt_assembly: PromptAssemblySchema,
output: OutputSpecSchema.default({ format: 'text' }),
});
export type PromptRecipe = z.infer<typeof PromptRecipeSchema>;
export type Fragment = z.infer<typeof FragmentSchema>;
export type RecipeInput = z.infer<typeof InputSchema>;
+136
View File
@@ -0,0 +1,136 @@
/**
* Recipe expander:把 prompt_recipe 展開成 claude_api 的實際 input
* SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.2 + Phase 2.1
*
* 流程:
* 1. loadPromptRecipe 取定義
* 2. fragments → 用 KBDB API 抓 block content
* 3. inputs → 從 workflow context 取值 + 跑 transform
* 4. 套進 prompt_assembly.system / .user 的 {{var}} 模板
* 5. 回傳 { prompt, model, output_format, output_required_fields }
*/
import { loadPromptRecipe, RecipeLoadError } from './recipe-loader';
import { applyTransform } from './recipe-transforms';
import type { Fragment, RecipeInput } from './prompt-recipe-schema';
type ExpanderEnv = {
RECIPES: { get: (key: string) => Promise<string | null> };
KBDB_BASE_URL?: string;
};
export interface ExpandedRecipe {
prompt: string; // user promptsystem + user 用 \n\n--- system ---\n 分隔)
model: 'haiku' | 'sonnet' | 'opus';
output_format: 'text' | 'json';
output_required_fields?: string[];
}
/** 從 path 取嵌套值,例如 "ctx.read_drafts.blocks" / "loop.item" */
function getByPath(ctx: Record<string, unknown>, path: string): unknown {
const parts = path.split('.');
let cur: unknown = ctx;
for (const p of parts) {
if (cur === null || cur === undefined) return undefined;
if (typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[p];
}
return cur;
}
/** {{var}} 模板替換(top-level vars 物件) */
function interpolate(template: string, vars: Record<string, string>): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (vars[key] !== undefined ? vars[key] : `{{${key}}}`));
}
async function fetchKbdbBlock(
env: ExpanderEnv,
apiKey: string,
fragment: Extract<Fragment, { source: 'kbdb_block' }>,
): Promise<unknown> {
const base = (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
let url: string;
if (fragment.block_id) {
url = `${base}/blocks/${encodeURIComponent(fragment.block_id)}`;
} else {
url = `${base}/blocks?page_name=${encodeURIComponent(fragment.block_page_name!)}&limit=1`;
}
const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
if (!res.ok) throw new Error(`KBDB fragment 抓取失敗 (${res.status}): ${url}`);
const data = (await res.json()) as Record<string, unknown>;
// page_name 模式回 {blocks:[]}block_id 模式直接回 block 物件
const block: Record<string, unknown> = fragment.block_id
? data
: ((data.blocks as unknown[])?.[0] as Record<string, unknown>) ?? {};
if (!block) throw new Error(`KBDB block 不存在: ${fragment.block_id ?? fragment.block_page_name}`);
const fieldVal = block[fragment.field];
if (fieldVal === undefined) throw new Error(`block 缺欄位 "${fragment.field}"`);
return fieldVal;
}
async function resolveFragment(
env: ExpanderEnv,
apiKey: string,
frag: Fragment,
): Promise<{ var: string; value: unknown }> {
if (frag.source === 'kv') {
const val = await env.RECIPES.get(frag.key);
if (val === null) throw new Error(`KV 找不到 key: ${frag.key}`);
return { var: frag.var, value: val };
}
return { var: frag.var, value: await fetchKbdbBlock(env, apiKey, frag) };
}
function resolveInput(input: RecipeInput, ctx: Record<string, unknown>): { var: string; value: unknown } {
let val = getByPath(ctx, input.from);
const beforeDefault = val;
if (val === undefined) val = input.default;
try {
if (input.transform) val = applyTransform(val, input.transform);
return { var: input.var, value: val };
} catch (e) {
// 把 path 跟原值放進錯誤訊息,方便 debug recipe
const valType = Array.isArray(beforeDefault) ? `array(${beforeDefault.length})`
: beforeDefault === undefined ? 'undefined(default applied)'
: typeof beforeDefault;
throw new Error(`${e instanceof Error ? e.message : String(e)} [path=${input.from}, type=${valType}]`);
}
}
/** 主入口:展開 recipe → 組 prompt */
export async function expandPromptRecipe(
recipeRef: string,
ctx: Record<string, unknown>,
env: ExpanderEnv,
apiKey: string, // KBDB partner key(從 workflow auth 來)
): Promise<ExpandedRecipe> {
const recipe = await loadPromptRecipe(recipeRef, env.RECIPES);
const vars: Record<string, string> = {};
for (const frag of recipe.fragments) {
const { var: name, value } = await resolveFragment(env, apiKey, frag);
vars[name] = typeof value === 'string' ? value : JSON.stringify(value);
}
for (const inp of recipe.inputs) {
const { var: name, value } = resolveInput(inp, ctx);
vars[name] = typeof value === 'string' ? value : JSON.stringify(value);
}
const system = interpolate(recipe.prompt_assembly.system, vars);
const user = interpolate(recipe.prompt_assembly.user, vars);
// claude_api 容器目前吃單一 prompt 字串 → system + user 用分隔線拼
const prompt = `${system}\n\n--- USER ---\n\n${user}`;
return {
prompt,
model: recipe.model,
output_format: recipe.output.format,
output_required_fields: recipe.output.required_fields,
};
}
export { RecipeLoadError };
+50
View File
@@ -0,0 +1,50 @@
/**
* Prompt recipe loader:從 RECIPES KV 抓 prompt_recipe 定義並驗證
* SDD: matrix/arcrun/.agents/specs/recipe-system/design.md Phase 1.3
*
* KV key 格式:prompt_recipe:{name}
* KV valueJSON 字串(不用 YAML,避免引入 yaml parser 進 worker
*/
import { PromptRecipeSchema, type PromptRecipe } from './prompt-recipe-schema';
type KvBinding = { get: (key: string) => Promise<string | null> };
export class RecipeLoadError extends Error {
constructor(message: string, public readonly recipe: string) {
super(message);
}
}
/** 從 RECIPES KV 抓 + parse + validate */
export async function loadPromptRecipe(
recipeRef: string, // 完整 key 如 "prompt_recipe:wiki_synthesis",或裸名 "wiki_synthesis"
recipesKv: KvBinding,
): Promise<PromptRecipe> {
const key = recipeRef.startsWith('prompt_recipe:')
? recipeRef
: `prompt_recipe:${recipeRef}`;
const raw = await recipesKv.get(key);
if (!raw) {
throw new RecipeLoadError(`找不到 recipe: ${key}`, key);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new RecipeLoadError(
`recipe ${key} 不是合法 JSON: ${e instanceof Error ? e.message : String(e)}`,
key,
);
}
const result = PromptRecipeSchema.safeParse(parsed);
if (!result.success) {
const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
throw new RecipeLoadError(`recipe ${key} schema 驗證失敗: ${issues}`, key);
}
return result.data;
}
@@ -0,0 +1,58 @@
/**
* Recipe transform 白名單實作
* SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.1
*
* 每個 transform 接 unknown,回 unknown。
* 失敗策略:一律 throw,由 expander 包成 recipe 錯誤
*/
export type TransformFn = (value: unknown, arg?: string) => unknown;
const transforms: Record<string, TransformFn> = {
json_array: (v) => JSON.stringify(v ?? []),
to_string: (v) => {
if (v === null || v === undefined) return '';
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
},
join: (v, sep) => {
if (!Array.isArray(v)) throw new Error('join: input 不是 array');
return v.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join(sep ?? '\n');
},
markdown_list: (v) => {
if (!Array.isArray(v)) throw new Error('markdown_list: input 不是 array');
return v.map((x) => `- ${typeof x === 'string' ? x : JSON.stringify(x)}`).join('\n');
},
extract_field: (v, field) => {
if (!field) throw new Error('extract_field: 需要 field 參數,例如 extract_field:page_name');
if (!Array.isArray(v)) throw new Error('extract_field: input 不是 array');
return v.map((x) => (x && typeof x === 'object' ? (x as Record<string, unknown>)[field] : undefined));
},
first: (v) => {
if (!Array.isArray(v)) return v;
return v[0];
},
pluck_content: (v) => {
if (!Array.isArray(v)) throw new Error('pluck_content: input 不是 array');
return v
.map((b) => (b && typeof b === 'object' ? String((b as Record<string, unknown>).content ?? '') : ''))
.filter((s) => s.length > 0)
.join('\n\n---\n\n');
},
};
/** 解析 "name" 或 "name:arg" → 執行 transform */
export function applyTransform(value: unknown, spec: string): unknown {
const colonIdx = spec.indexOf(':');
const name = colonIdx === -1 ? spec : spec.slice(0, colonIdx);
const arg = colonIdx === -1 ? undefined : spec.slice(colonIdx + 1);
const fn = transforms[name];
if (!fn) throw new Error(`未知 transform: ${name}`);
return fn(value, arg);
}
+26
View File
@@ -0,0 +1,26 @@
import { z } from 'zod';
// 圖定義的 Zod Schema
export const graphSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
nodes: z.array(z.object({
id: z.string(),
type: z.enum(['Input', 'Component', 'Output']),
componentId: z.string().optional(),
label: z.string().optional(),
data: z.record(z.unknown()).optional(),
})),
edges: z.array(z.object({
from: z.string(),
to: z.string(),
type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE', 'IS_A', 'ON_SUCCESS', 'ON_FAIL', 'ON_CLICK', 'CALLS_SUBFLOW', 'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR']),
condition: z.string().optional(),
iterator: z.string().optional(),
})),
});
export const executeSchema = z.object({
graph: graphSchema,
context: z.record(z.unknown()).default({}),
});
+123
View File
@@ -0,0 +1,123 @@
/**
* Implicit telemetry — 對應 SDD .agents/specs/llm-interface/ M1.2
*
* 每次 deploy / run / validate 失敗,cypher-executor 自動寫 KBDB block
* type=agent-telemetry,含 event_type / workflow_name / error_code /
* duration_ms / api_key_hash / agent_user_agent。
*
* 隱私:api_key SHA-256 截 16 字元(不可逆,可聚合),workflow 內容不 log。
*
* 設計:不阻擋主流程,fetch fire-and-forget;錯誤只 console.warn 不 throw。
*
* 注意:本 module 屬 orchestrator 自身能力(觀測自己),不違反「業務邏輯走 WASM」鐵律。
* 跟 trigger_workflow / scheduled() 同類,是 cypher-executor 自我管理的一部分。
*/
import type { Bindings, ExecutionContext } from '../types';
export type TelemetryEvent =
| 'deploy_success'
| 'deploy_fail'
| 'run_success'
| 'run_fail'
| 'validation_error'
| 'mcp_tool_call'
| 'node_success' // 單一 node 跑完(給 step-level 效能分析用)
| 'node_failure'; // 單一 node 失敗
export interface TelemetryRecord {
event_type: TelemetryEvent;
workflow_name?: string;
component_id?: string;
error_code?: string;
duration_ms: number;
api_key_hash: string;
agent_user_agent?: string;
}
/**
* api_key → SHA-256 hex 截前 16 字元
* 不可逆,可用來聚合(同一用戶不同 event 統計),不會洩漏原 key
*/
export async function hashApiKey(apiKey: string): Promise<string> {
if (!apiKey) return 'anon';
const encoder = new TextEncoder();
const data = encoder.encode(apiKey);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray
.slice(0, 8) // 8 bytes = 16 hex chars
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* KBDB upsert URL(內部走 workers.dev 避同 zone 自循環)
* 對應 .claude/rules/03-component-architecture.md
*/
function kbdbCreateBlockUrl(env: Bindings): string {
const subdomain = env.WORKER_SUBDOMAIN || 'uncle6-me';
return `https://arcrun-kbdb-create-block.${subdomain}.workers.dev`;
}
/**
* 寫一筆 telemetry block 到 KBDB。fire-and-forget。
*
* 寫不進去也不擋主流程 —— 平台自己的觀測絕不能讓 user-facing 流程失敗。
*
* 用 ctx.waitUntil 確保即使主 request 已回,背景仍會跑完。
*/
export function recordTelemetry(
env: Bindings,
apiKey: string | undefined,
record: Omit<TelemetryRecord, 'api_key_hash'>,
ctx?: ExecutionContext,
): void {
const promise = (async () => {
try {
const api_key_hash = await hashApiKey(apiKey ?? '');
// platform telemetry 用一個系統 ak(讀 env.PLATFORM_API_KEY),所有 telemetry
// 都聚集在 platform user_id 下,避免污染用戶自己的 KBDB namespace
const platformKey = env.PLATFORM_API_KEY || apiKey || '';
if (!platformKey) {
// 沒 platform key + 沒用戶 key → 無處可寫,skip
console.warn('[telemetry] no api_key, skipping');
return;
}
const body = {
api_key: platformKey,
type: 'agent-telemetry',
source: 'cypher-executor',
user_id: 'platform_telemetry',
content: JSON.stringify(record),
metadata_json: JSON.stringify({ ...record, api_key_hash }),
tags_json: JSON.stringify([
'agent-telemetry',
`event:${record.event_type}`,
]),
};
const res = await fetch(kbdbCreateBlockUrl(env), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
console.warn(
'[telemetry] write failed',
res.status,
await res.text().catch(() => 'no body'),
);
}
} catch (e) {
console.warn('[telemetry] exception', e);
}
})();
if (ctx?.waitUntil) {
ctx.waitUntil(promise);
}
// 沒 ctx.waitUntil 的情況(直接從 host function call)也讓 promise 自己跑,可能被 cancel 也接受
}
+741
View File
@@ -0,0 +1,741 @@
/**
* WASI preview1 輕量 shim
* 只實作 stdin/stdout/stderr 所需的最小 syscall 集合。
* 其餘 syscall 一律回傳 ENOSYS(76),確保零件無法呼叫網路或檔案系統。
*
* 不依賴任何外部套件(不使用 @cloudflare/workers-wasi)。
* Requirements: 3.1, 3.3
*/
/**
* createArcrunHostFunctions 所需的最小 env 子集。
* 不直接依賴 cypher-executor 的 Bindings,讓 auth primitive Worker 這類
* 只綁 CREDENTIALS_KV / RECIPES / ENCRYPTION_KEY 的獨立 Worker 也能用。
*/
export interface ArcrunHostEnv {
CREDENTIALS_KV: KVNamespace;
RECIPES: KVNamespace;
ENCRYPTION_KEY: string;
}
const WASI_ESUCCESS = 0;
const WASI_ENOSYS = 76;
// fd 常數
const FD_STDIN = 0;
const FD_STDOUT = 1;
const FD_STDERR = 2;
export interface WasiShim {
/** WebAssembly.Imports 物件,傳入 WebAssembly.instantiate */
imports: WebAssembly.Imports;
/** 取得 stdout 的完整輸出(合併所有 chunks) */
getStdout(): string;
/** 取得 stderr 的完整輸出 */
getStderr(): string;
/** 注入 WebAssembly.Memoryinstantiate 後呼叫) */
setMemory(memory: WebAssembly.Memory): void;
/**
* 執行 WASM _start,自動使用 WebAssembly.promisingJSPI)讓 async host
* function 能正確 suspend/resume。若 JSPI 不可用則 fallback 同步執行。
* 必須在 setMemory() 之後呼叫。
*/
run(instance: WebAssembly.Instance): Promise<void>;
}
/**
* Host function 注入介面
* 讓 .wasm 零件能透過 host function 呼叫外部服務,而不需要網路 syscall
*
* 嚴格邊界:
* - encryption key 只在 `crypto_decrypt` host function 內部使用,永遠不傳給 WASM
* - `kv_get` 必須在 Worker 側檢查 key 前綴以防越權(見 auth-dispatcher.ts
*/
export interface WasiHostFunctions {
/** HTTP 請求 host function.wasm 呼叫此函數發出 HTTP 請求 */
http_request?: (url: string, method: string, headers: string, body: string) => Promise<string>;
/** KV 讀取:key 前綴由 Worker 路由到對應 binding,並做越權檢查 */
kv_get?: (key: string) => Promise<string | null>;
/** KV 寫入:用於快取 access_token 等短效值,ttlSeconds=0 表示不設 TTL */
kv_put?: (key: string, value: string, ttlSeconds: number) => Promise<void>;
/** AES-GCM 解密:encryption key 由 Worker 保管,不暴露給 WASM */
crypto_decrypt?: (encryptedB64: string, ivB64: string) => Promise<string>;
/** RS256 簽章:用 crypto.subtle 做 RSASSA-PKCS1-v1_5 + SHA-256 */
crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>;
/** HMAC-SHA256(data, ENCRYPTION_KEY) → raw bytes */
crypto_hmac_sha256?: (data: Uint8Array) => Promise<Uint8Array>;
/** AES-GCM 加密(plaintext, ENCRYPTION_KEY) → {encryptedB64, ivB64} */
crypto_aes_encrypt?: (plaintext: Uint8Array) => Promise<{ encryptedB64: string; ivB64: string }>;
/** crypto random bytes → hex string */
crypto_random_bytes?: (numBytes: number) => string;
}
/**
* 建立 WASI shim 實例
* @param stdinData - 要寫入 stdin 的 UTF-8 字串(通常是 JSON.stringify(input)
* @param hostFunctions - 可選的 host function 注入(讓 .wasm 呼叫外部服務)
*/
export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFunctions): WasiShim {
const stdinBytes = new TextEncoder().encode(stdinData);
let stdinOffset = 0;
const stdoutChunks: Uint8Array[] = [];
const stderrChunks: Uint8Array[] = [];
let memory: WebAssembly.Memory | null = null;
function getMemoryView(): DataView {
if (!memory) throw new Error('WASI memory not set — call setMemory() after instantiate');
return new DataView(memory.buffer);
}
// 寫入結果到 WASM 的 outPtr bufferhost function 共用)
// 回傳 0 = 成功,1 = memory 不可用
function writeOut(buf: ArrayBuffer, outPtr: number, outLenPtr: number, data: Uint8Array): number {
try {
new Uint8Array(buf, outPtr, data.length).set(data);
new DataView(buf).setUint32(outLenPtr, data.length, true);
return 0;
} catch {
return 1;
}
}
/**
* fd_write: 將 iovec 陣列的資料寫入 fdstdout=1 或 stderr=2
* iovec 結構:{ buf: i32, buf_len: i32 }(各 4 byteslittle-endian
*/
function fd_write(fd: number, iovs: number, iovs_len: number, nwritten_ptr: number): number {
if (fd !== FD_STDOUT && fd !== FD_STDERR) return WASI_ENOSYS;
const view = getMemoryView();
const buf = memory!.buffer;
let totalWritten = 0;
for (let i = 0; i < iovs_len; i++) {
const iov_base = view.getUint32(iovs + i * 8, true);
const iov_len = view.getUint32(iovs + i * 8 + 4, true);
if (iov_len === 0) continue;
const chunk = new Uint8Array(buf, iov_base, iov_len);
const copy = new Uint8Array(iov_len);
copy.set(chunk);
if (fd === FD_STDOUT) stdoutChunks.push(copy);
else stderrChunks.push(copy);
totalWritten += iov_len;
}
view.setUint32(nwritten_ptr, totalWritten, true);
return WASI_ESUCCESS;
}
/**
* fd_read: 從 stdin 讀取資料到 iovec 陣列
*/
function fd_read(fd: number, iovs: number, iovs_len: number, nread_ptr: number): number {
if (fd !== FD_STDIN) return WASI_ENOSYS;
const view = getMemoryView();
const buf = memory!.buffer;
let totalRead = 0;
for (let i = 0; i < iovs_len; i++) {
const iov_base = view.getUint32(iovs + i * 8, true);
const iov_len = view.getUint32(iovs + i * 8 + 4, true);
if (iov_len === 0) continue;
const remaining = stdinBytes.length - stdinOffset;
if (remaining <= 0) break;
const toCopy = Math.min(iov_len, remaining);
const dest = new Uint8Array(buf, iov_base, toCopy);
dest.set(stdinBytes.subarray(stdinOffset, stdinOffset + toCopy));
stdinOffset += toCopy;
totalRead += toCopy;
}
view.setUint32(nread_ptr, totalRead, true);
return WASI_ESUCCESS;
}
/**
* proc_exit: 零件呼叫 exit(),拋出 Error 中止執行
*/
function proc_exit(code: number): never {
throw new Error(`wasm exit: ${code}`);
}
/**
* random_get: 填充隨機 bytes(使用 Web Crypto API
*/
function random_get(buf_ptr: number, buf_len: number): number {
const view = new Uint8Array(memory!.buffer, buf_ptr, buf_len);
crypto.getRandomValues(view);
return WASI_ESUCCESS;
}
// ── Asyncify protocol ──────────────────────────────────────────────────────
// TinyGo WASI target 永遠使用 asyncify scheduler。Asyncify 讓 WASM 能在呼叫 host
// function 時「unwind」(保存 call stack),待 async 工作完成後再「rewind」(恢復)。
//
// 協議流程(每次 async host function 呼叫):
// 1. WASM 呼叫 host import(例如 http_request
// 2. Host 檢查 asyncify_get_state()
// - state=1Unwinding: 正在展開,host 應直接回傳 0(佔位值)
// - state=2Rewinding: 正在恢復,host 應回傳上一次 async 結果(已存在 asyncifyResult
// - state=0Normal: 正常執行,host 啟動 async 工作並呼叫 asyncify_start_unwind
// 3. WASM 的 _start 控制流回到 run()asyncify 讓 _start 提前返回)
// 4. run() await async 工作,呼叫 asyncify_start_rewind,再次呼叫 _start
// 5. WASM 從 host import 返回點繼續執行,host 回傳儲存的結果
//
// 注意:每次 _start 呼叫只能處理一個 async 中斷點。若 WASM 有多個連續的 async host call
// run() 會在 while 迴圈裡重複 rewind 直到 asyncify_get_state() == 0Normal)。
// Asyncify 資料緩衝區設定(TinyGo asyncify 用於保存 call stack
// 位址在 run() 中設定(WASM memory 末尾分配 1MB
let asyncifyDataPtr = 0;
const ASYNCIFY_BUF_SIZE = 1024 * 1024; // 1MB stack buffer
// 儲存 async host function 的結果和 Promise
let asyncifyPendingPromise: Promise<number> | null = null;
let asyncifyResult: number = 0;
// asyncify exportsrun() 設定後才可用)
let asyncifyExports: {
get_state: () => number;
start_unwind: (ptr: number) => void;
stop_unwind: () => void;
start_rewind: (ptr: number) => void;
stop_rewind: () => void;
} | null = null;
// JSPI helper:若環境支援 WebAssembly.Suspending,用它包裝 async import function
// 用於 scheduler=none 編譯的 WASM(無 asyncify exports
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function jspiSuspending<T extends (...args: any[]) => Promise<unknown>>(fn: T): T {
const SuspendingCtor = (WebAssembly as unknown as Record<string, unknown>)['Suspending'] as
(new (fn: T) => T) | undefined;
return SuspendingCtor ? new SuspendingCtor(fn) : fn;
}
// 建立一個 asyncify-aware 的 host function wrapper
// 協議:Normal 時啟動 async 工作並呼叫 start_unwindRewinding 時回傳已存的結果
// 用於 scheduler=asyncify 編譯的 WASM(有 asyncify exports
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function asyncifyWrap(fn: (...args: any[]) => Promise<number>): (...args: any[]) => number {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (...args: any[]): number => {
if (!memory) return 1;
const ax = asyncifyExports;
if (!ax) return 0; // asyncify 尚未初始化(sync fallback
const state = ax.get_state();
if (state === 2) {
// Rewinding:回傳上次 async 的真實結果
return asyncifyResult;
}
if (state === 1) {
// Unwinding 中:直接回傳 0WASM 在 unwind,不使用此值)
return 0;
}
// Normalstate=0):啟動 async 工作,觸發 asyncify unwind
asyncifyPendingPromise = fn(...args);
// asyncify_start_unwind 設定 WASM 內部 unwind flag
// host function 返回後 WASM 開始保存 call stack,最終 _start() 返回
ax.start_unwind(asyncifyDataPtr);
return 0; // WASM 忽略此值(正在 unwind
};
}
// 根據 WASM 是否有 asyncify exports 決定使用哪種包裝方式
// JSPI mode: scheduler=none WASM + WebAssembly.Suspending
// asyncify mode: scheduler=asyncify WASM + asyncify protocol
// 初始化時先用 asyncifyWraprun() 後若沒有 asyncify exports 就切換到 jspiSuspending
// 但因為 imports 在 instantiate 前就需要確定,這裡統一先用 asyncifyWrap
// run() 時若發現沒有 asyncify exports 且有 JSPI,則使用 JSPI 模式
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function hostWrap(fn: (...args: any[]) => Promise<number>): (...args: any[]) => number | Promise<number> {
// 嘗試使用 JSPI Suspending(若環境支援)
const SuspendingCtor = (WebAssembly as unknown as Record<string, unknown>)['Suspending'] as
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(new (fn: any) => any) | undefined;
if (SuspendingCtor) {
// JSPI 可用:包裝為 Suspending,讓 WASM 能 suspend 等待 async 結果
// 這適用於 scheduler=none 的 WASM(無 asyncify 干擾)
return new SuspendingCtor(fn);
}
// fallbackasyncify 協議(scheduler=asyncify WASM
return asyncifyWrap(fn);
}
const shim: WasiShim = {
imports: {
wasi_snapshot_preview1: { fd_write,
fd_read,
proc_exit,
random_get,
// 其餘 syscall 回傳 ENOSYS(不允許網路/檔案系統操作)
fd_seek: () => WASI_ENOSYS,
fd_close: () => WASI_ESUCCESS,
fd_fdstat_get: () => WASI_ENOSYS,
fd_prestat_get: () => WASI_ENOSYS,
fd_prestat_dir_name: () => WASI_ENOSYS,
environ_get: () => WASI_ESUCCESS,
environ_sizes_get: (count_ptr: number, size_ptr: number) => {
if (memory) {
const view = getMemoryView();
view.setUint32(count_ptr, 0, true);
view.setUint32(size_ptr, 0, true);
}
return WASI_ESUCCESS;
},
args_get: () => WASI_ESUCCESS,
args_sizes_get: (argc_ptr: number, argv_buf_size_ptr: number) => {
if (memory) {
const view = getMemoryView();
view.setUint32(argc_ptr, 0, true);
view.setUint32(argv_buf_size_ptr, 0, true);
}
return WASI_ESUCCESS;
},
clock_time_get: (id: number, precision: bigint, time_ptr: number) => {
if (memory) {
const view = getMemoryView();
const now = BigInt(Date.now()) * 1_000_000n;
view.setBigUint64(time_ptr, now, true);
}
return WASI_ESUCCESS;
},
clock_res_get: () => WASI_ENOSYS,
poll_oneoff: () => WASI_ENOSYS,
sched_yield: () => WASI_ESUCCESS,
proc_raise: () => WASI_ENOSYS,
sock_accept: () => WASI_ENOSYS,
sock_recv: () => WASI_ENOSYS,
sock_send: () => WASI_ENOSYS,
sock_shutdown: () => WASI_ENOSYS,
path_open: () => WASI_ENOSYS,
path_create_directory: () => WASI_ENOSYS,
path_remove_directory: () => WASI_ENOSYS,
path_rename: () => WASI_ENOSYS,
path_unlink_file: () => WASI_ENOSYS,
path_filestat_get: () => WASI_ENOSYS,
path_readlink: () => WASI_ENOSYS,
path_symlink: () => WASI_ENOSYS,
path_link: () => WASI_ENOSYS,
},
// u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務
// .wasm 零件用 //go:wasmimport u6u <name> 宣告
// 所有 async host function 透過 asyncifyWrap 包裝,實作 asyncify 協議
u6u: {
http_request: hostFunctions?.http_request
? hostWrap(async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number,
headersPtr: number, headersLen: number, bodyPtr: number, bodyLen: number,
outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
// 在 await 前讀完所有輸入(memory.buffer 在 await 後可能因 grow 而失效)
const snapBuf = memory.buffer;
const dec = new TextDecoder();
const url = dec.decode(new Uint8Array(snapBuf, urlPtr, urlLen));
const method = dec.decode(new Uint8Array(snapBuf, methodPtr, methodLen));
const headers = dec.decode(new Uint8Array(snapBuf, headersPtr, headersLen));
const body = dec.decode(new Uint8Array(snapBuf, bodyPtr, bodyLen));
try {
const result = await hostFunctions!.http_request!(url, method, headers, body);
// await 後重新拿 memory.buffergrow 會產生新的 ArrayBuffer
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
} catch {
return 1;
}
})
: () => 1,
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key
kv_get: hostFunctions?.kv_get
? hostWrap(async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) { console.error('[kv_get] memory null'); return 1; }
const key = new TextDecoder().decode(new Uint8Array(memory.buffer, keyPtr, keyLen));
console.error(`[kv_get] key="${key}" keyPtr=${keyPtr} keyLen=${keyLen} outPtr=${outPtr} outLenPtr=${outLenPtr}`);
try {
const result = await hostFunctions!.kv_get!(key);
console.error(`[kv_get] result=${result === null ? 'null' : result.slice(0, 80)}`);
if (result === null) return 2;
const encoded = new TextEncoder().encode(result);
const status = writeOut(memory.buffer, outPtr, outLenPtr, encoded);
console.error(`[kv_get] writeOut status=${status} encodedLen=${encoded.length} memBufLen=${memory.buffer.byteLength}`);
return status;
} catch (e) {
console.error(`[kv_get] error: ${e}`);
return 1;
}
})
: () => 1,
// kv_put(keyPtr, keyLen, valPtr, valLen, ttlSeconds) → 0 成功;1 錯誤
kv_put: hostFunctions?.kv_put
? hostWrap(async (keyPtr: number, keyLen: number, valPtr: number, valLen: number, ttlSeconds: number): Promise<number> => {
if (!memory) return 1;
const dec = new TextDecoder();
const key = dec.decode(new Uint8Array(memory.buffer, keyPtr, keyLen));
const value = dec.decode(new Uint8Array(memory.buffer, valPtr, valLen));
try {
await hostFunctions!.kv_put!(key, value, ttlSeconds);
return 0;
} catch {
return 1;
}
})
: () => 1,
// crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功
// 輸入皆為 base64 字串(WASM 從 KV 讀到什麼就送什麼)
crypto_decrypt: hostFunctions?.crypto_decrypt
? hostWrap(async (encPtr: number, encLen: number, ivPtr: number, ivLen: number,
outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
const dec = new TextDecoder();
const encB64 = dec.decode(new Uint8Array(memory.buffer, encPtr, encLen));
const ivB64 = dec.decode(new Uint8Array(memory.buffer, ivPtr, ivLen));
try {
const plaintext = await hostFunctions!.crypto_decrypt!(encB64, ivB64);
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(plaintext));
} catch {
return 1;
}
})
: () => 1,
// crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功
crypto_sign_rs256: hostFunctions?.crypto_sign_rs256
? hostWrap(async (dataPtr: number, dataLen: number, pkcs8Ptr: number, pkcs8Len: number,
outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
// await 前複製 typed array(避免 memory grow 後 buffer 失效)
const data = new Uint8Array(new Uint8Array(memory.buffer, dataPtr, dataLen));
const pkcs8 = new Uint8Array(new Uint8Array(memory.buffer, pkcs8Ptr, pkcs8Len));
try {
const sig = await hostFunctions!.crypto_sign_rs256!(data, pkcs8);
return writeOut(memory.buffer, outPtr, outLenPtr, sig);
} catch {
return 1;
}
})
: () => 1,
// crypto_hmac_sha256(dataPtr, dataLen, outPtr, outLenPtr) → 0 成功,output = raw bytes
crypto_hmac_sha256: hostFunctions?.crypto_hmac_sha256
? hostWrap(async (dataPtr: number, dataLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
const data = new Uint8Array(new Uint8Array(memory.buffer, dataPtr, dataLen));
try {
const sig = await hostFunctions!.crypto_hmac_sha256!(data);
return writeOut(memory.buffer, outPtr, outLenPtr, sig);
} catch {
return 1;
}
})
: () => 1,
// crypto_aes_encrypt(plaintextPtr, plaintextLen, outEncPtr, outEncLenPtr, outIvPtr, outIvLenPtr) → 0 成功
crypto_aes_encrypt: hostFunctions?.crypto_aes_encrypt
? hostWrap(async (plaintextPtr: number, plaintextLen: number,
outEncPtr: number, outEncLenPtr: number,
outIvPtr: number, outIvLenPtr: number): Promise<number> => {
if (!memory) return 1;
const plaintext = new Uint8Array(new Uint8Array(memory.buffer, plaintextPtr, plaintextLen));
try {
const { encryptedB64, ivB64 } = await hostFunctions!.crypto_aes_encrypt!(plaintext);
const encBytes = new TextEncoder().encode(encryptedB64);
const ivBytes = new TextEncoder().encode(ivB64);
const s1 = writeOut(memory.buffer, outEncPtr, outEncLenPtr, encBytes);
const s2 = writeOut(memory.buffer, outIvPtr, outIvLenPtr, ivBytes);
return s1 !== 0 ? s1 : s2;
} catch {
return 1;
}
})
: () => 1,
// crypto_random_bytes(numBytes, outPtr, outLenPtr) → 0 成功,output = hex string
crypto_random_bytes: hostFunctions?.crypto_random_bytes
? (numBytes: number, outPtr: number, outLenPtr: number): number => {
if (!memory) return 1;
try {
const hexStr = hostFunctions!.crypto_random_bytes!(numBytes);
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(hexStr));
} catch {
return 1;
}
}
: () => 1,
},
},
setMemory(mem: WebAssembly.Memory) {
memory = mem;
},
async run(instance: WebAssembly.Instance): Promise<void> {
const exp = instance.exports as Record<string, unknown>;
const startFn = (exp._start ?? exp.main) as (() => void) | undefined;
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const promisingFn = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
((fn: () => void) => () => Promise<void>) | undefined;
// 若環境支援 JSPICloudflare Workers 2025+),優先使用 WebAssembly.promising
// hostWrap() 已將 imports 包裝為 WebAssembly.Suspending,不需要 asyncify 協議
if (promisingFn) {
try {
await promisingFn(startFn)();
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
return;
}
// JSPI 不可用:使用 asyncify 協議(需要 WASM 有 asyncify exports
const asyncifyGetState = exp.asyncify_get_state as (() => number) | undefined;
const asyncifyStartUnwind = exp.asyncify_start_unwind as ((ptr: number) => void) | undefined;
const asyncifyStopUnwind = exp.asyncify_stop_unwind as (() => void) | undefined;
const asyncifyStartRewind = exp.asyncify_start_rewind as ((ptr: number) => void) | undefined;
const asyncifyStopRewind = exp.asyncify_stop_rewind as (() => void) | undefined;
if (asyncifyGetState && asyncifyStartUnwind && asyncifyStopUnwind &&
asyncifyStartRewind && asyncifyStopRewind) {
asyncifyExports = {
get_state: asyncifyGetState,
start_unwind: asyncifyStartUnwind,
stop_unwind: asyncifyStopUnwind,
start_rewind: asyncifyStartRewind,
stop_rewind: asyncifyStopRewind,
};
const mallocFn = exp.malloc as ((size: number) => number) | undefined;
if (mallocFn && memory) {
const totalSize = ASYNCIFY_BUF_SIZE;
asyncifyDataPtr = mallocFn(totalSize);
const view = new DataView(memory.buffer);
view.setInt32(asyncifyDataPtr, asyncifyDataPtr + 8, true);
view.setInt32(asyncifyDataPtr + 4, asyncifyDataPtr + totalSize, true);
} else if (memory) {
const memBytes = memory.buffer.byteLength;
asyncifyDataPtr = memBytes - ASYNCIFY_BUF_SIZE;
if (asyncifyDataPtr > 8) {
const view = new DataView(memory.buffer);
view.setInt32(asyncifyDataPtr, asyncifyDataPtr + 8, true);
view.setInt32(asyncifyDataPtr + 4, asyncifyDataPtr + ASYNCIFY_BUF_SIZE, true);
}
}
}
// JSPI 不可用且無 asyncify exports:同步執行(host function 不能 async
if (!asyncifyExports) {
try { startFn(); } catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
return;
}
// 主執行迴圈:每次呼叫 _start,若 asyncify 捕捉到 pending promise 就 await 再 rewind
let rewinding = false;
while (true) {
asyncifyPendingPromise = null;
try {
if (rewinding) {
asyncifyExports.start_rewind(asyncifyDataPtr);
startFn();
asyncifyExports.stop_rewind();
} else {
startFn();
}
} catch (e) {
if (e instanceof Error && e.message === 'wasm exit: 0') break;
throw e;
}
// 若 asyncifyWrap 觸發了 unwind_start 會因 unwind 返回(沒有 exit
// asyncifyWrap 已呼叫 start_unwind,這裡只需 stop_unwind 並 await promise
if (asyncifyPendingPromise !== null) {
asyncifyExports.stop_unwind();
asyncifyResult = await asyncifyPendingPromise;
asyncifyPendingPromise = null;
rewinding = true;
continue;
}
// 沒有 pending promise 且沒有 exit → 正常完成
break;
}
},
getStdout(): string {
if (stdoutChunks.length === 0) return '';
const total = stdoutChunks.reduce((n, c) => n + c.length, 0);
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of stdoutChunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder().decode(merged);
},
getStderr(): string {
if (stderrChunks.length === 0) return '';
const total = stderrChunks.reduce((n, c) => n + c.length, 0);
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of stderrChunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder().decode(merged);
},
};
return shim;
}
// ── Worker 端 host function 實作(Phase 0.6)──────────────────────────────────
//
// 唯一合法位置:AES-GCM 解密與 RS256 簽章只准出現在本檔(02-forbidden.md §2.2)。
// 由 component-loader 的 WASM runner 路徑呼叫,注入進 createWasiShim。
//
// 安全邊界:
// 1. `ENCRYPTION_KEY` 只在 `crypto_decrypt` 內部讀 env,絕不經 stdin/回傳值傳給 WASM
// 2. `kv_get` 依 key 前綴路由,且 `{api_key}:cred:*` 必須符合 stdin 傳入的 api_key(越權檢查)
// 3. 未知前綴回傳 nullWASM 收到 kv_get 回傳 2 = 找不到)
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
return bytes;
}
function base64ToUint8Array(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
/**
* 依 key 前綴路由到對應 KV binding,並做越權檢查。
* - `auth_recipe:{service}` → env.RECIPES
* - `{apiKey}:cred:{name}` → env.CREDENTIALS_KV(前綴必須等於 caller 的 apiKey
* - 其他前綴 → null(拒絕)
*/
async function routedKvGet(env: ArcrunHostEnv, apiKey: string, key: string): Promise<string | null> {
if (key.startsWith('auth_recipe:')) {
return env.RECIPES.get(key);
}
const credMatch = key.match(/^([^:]+):cred:.+$/);
if (credMatch) {
if (credMatch[1] !== apiKey) {
// 越權:WASM 嘗試讀其他租戶的 credential
return null;
}
return env.CREDENTIALS_KV.get(key);
}
return null;
}
/**
* 依 key 前綴路由寫入 KV。只允許寫 oauth2 cache key(短效 access_token)。
* - `{apiKey}:oauth2:{service}:*` → env.CREDENTIALS_KV(越權檢查)
*/
async function routedKvPut(env: ArcrunHostEnv, apiKey: string, key: string, value: string, ttlSeconds: number): Promise<void> {
const oauth2Match = key.match(/^([^:]+):oauth2:.+$/);
if (oauth2Match && oauth2Match[1] === apiKey) {
const opts = ttlSeconds > 0 ? { expirationTtl: ttlSeconds } : undefined;
await env.CREDENTIALS_KV.put(key, value, opts);
return;
}
// 其他 key 前綴拒絕寫入(安全邊界)
}
/**
* AES-GCM 解密。encryption key 由 env.ENCRYPTION_KEY 在本 function 內讀取,
* 永不傳給 WASM。輸入為 base64 字串,輸出為 UTF-8 plaintext。
*/
async function aesGcmDecrypt(env: ArcrunHostEnv, encryptedB64: string, ivB64: string): Promise<string> {
const keyBytes = hexToUint8Array(env.ENCRYPTION_KEY);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'],
);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToUint8Array(ivB64) },
cryptoKey,
base64ToUint8Array(encryptedB64),
);
return new TextDecoder().decode(plaintext);
}
/**
* RSASSA-PKCS1-v1_5 + SHA-256 簽章。private key 以 PKCS8 bytes 傳入(由 WASM 零件解析 PEM 後送進來)。
*/
async function rsaPkcs1Sha256Sign(data: Uint8Array, pkcs8: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey(
'pkcs8',
pkcs8,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', cryptoKey, data);
return new Uint8Array(sig);
}
/**
* 建立 arcrun host function 組合(kv_get / crypto_decrypt / crypto_sign_rs256)。
* 由 WASM runnercomponent-loader 的 WASM 路徑)呼叫,與 api_key 綁定以做越權檢查。
*
* http_request 不由本 factory 提供 — auth primitive WASM 與 API WASM 零件若需要
* 發 HTTP,由呼叫者(component-loader)另外注入,以便個別限制可達主機。
*/
export function createArcrunHostFunctions(env: ArcrunHostEnv, apiKey: string): WasiHostFunctions {
return {
kv_get: (key: string) => routedKvGet(env, apiKey, key),
kv_put: (key: string, value: string, ttlSeconds: number) => routedKvPut(env, apiKey, key, value, ttlSeconds),
crypto_decrypt: (encB64: string, ivB64: string) => aesGcmDecrypt(env, encB64, ivB64),
crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8),
};
}
/**
* 建立 platform_crypto host functions。
* 不需要 apiKey 或 KV routing,只提供加密操作。
* ENCRYPTION_KEY 在 closure 內,永不傳給 WASM。
*/
export function createPlatformCryptoHostFunctions(encryptionKey: string): WasiHostFunctions {
const toB64 = (buf: ArrayBuffer): string => btoa(String.fromCharCode(...new Uint8Array(buf)));
return {
crypto_hmac_sha256: async (data: Uint8Array): Promise<Uint8Array> => {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, data);
return new Uint8Array(sig);
},
crypto_aes_encrypt: async (plaintext: Uint8Array): Promise<{ encryptedB64: string; ivB64: string }> => {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, plaintext);
return { encryptedB64: toB64(enc), ivB64: toB64(iv.buffer) };
},
crypto_random_bytes: (numBytes: number): string => {
const arr = crypto.getRandomValues(new Uint8Array(numBytes));
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
},
};
}