fix(arcrun): address PR #2 review findings
Security: - init.ts: remove cf_api_token from POST /register (only email sent to arcrun.dev) - cf-api.ts: remove base64 fallback in encryptCredential, throw clear error if key missing Correctness: - submitComponent.ts: replace KBDB dependency with SUBMISSIONS_KV + R2 (standalone) - registry/types.ts: remove KBDB_URL/KBDB_INTERNAL_TOKEN, add SUBMISSIONS_KV/ANALYTICS_KV - webhooks.ts: add waitUntil(writeExecutionVerdict) for fire-and-forget analytics - execution-logger.ts: create missing module (was imported but didn't exist) - cypher-executor/types.ts + wrangler.toml: add ANALYTICS_KV binding - gmail/telegram/google_sheets/line_notify/http_request: no_network_syscall false (api category) - init.ts: replace require() with await import() for ES module compatibility Cleanup: - Remove arcrun/builtins/ (dead code — initComponents used old HTTP endpoint model, all 21 components now in TinyGo WASM under registry/components/) Docs: - tasks.md: update to reflect completed work and remaining items Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
-49
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@inkstone/u6u-builtins-worker",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "@inkstone/u6u-builtins-worker",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"hono": "^4.7.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@cloudflare/workers-types": "^4.20250219.0",
|
|
||||||
"typescript": "^5.7.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@cloudflare/workers-types": {
|
|
||||||
"version": "4.20260329.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260329.1.tgz",
|
|
||||||
"integrity": "sha512-LxBHrYYI/AZ6OCbUzRqRgg6Rt1qev2KxN2NNd3saye41AO2g52cYvHV+ohts5oPnrIUD7YRjbgN/J3NU7e7m5A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT OR Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/hono": {
|
|
||||||
"version": "4.12.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
|
|
||||||
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "5.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "arcrun-builtins",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "wrangler dev",
|
|
||||||
"deploy": "wrangler deploy"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"hono": "^4.7.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@cloudflare/workers-types": "^4.20250219.0",
|
|
||||||
"typescript": "^5.7.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// initComponents:把所有內建零件上架到 Component Registry(via Service Binding)
|
|
||||||
import type { Bindings } from '../types';
|
|
||||||
import { buildComponentDefs } from '../types';
|
|
||||||
|
|
||||||
async function publishOne(
|
|
||||||
registry: Fetcher,
|
|
||||||
def: ReturnType<typeof buildComponentDefs>[number],
|
|
||||||
): Promise<{ id: string; status: number; ok: boolean; error?: unknown }> {
|
|
||||||
const payload = {
|
|
||||||
id: def.id,
|
|
||||||
name: def.name,
|
|
||||||
description: def.description,
|
|
||||||
url: def.url,
|
|
||||||
method: def.method,
|
|
||||||
tags: def.tags,
|
|
||||||
input_schema: JSON.stringify(def.input_schema),
|
|
||||||
output_schema: JSON.stringify(def.output_schema),
|
|
||||||
author: 'u6u-builtins',
|
|
||||||
version: '1.0.0',
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await registry.fetch('http://registry/components', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = await res.json().catch(() => ({ error: 'parse error' }));
|
|
||||||
// 201 = 新建成功;409 = 已存在(也算 ok)
|
|
||||||
const isOk = res.status === 201 || res.status === 409 || res.ok;
|
|
||||||
return { id: def.id, status: res.status, ok: isOk, ...(!isOk && { error: body }) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initComponents(
|
|
||||||
env: Bindings,
|
|
||||||
): Promise<{ ok: number; failed: number; results: unknown[] }> {
|
|
||||||
const defs = buildComponentDefs(env.WORKER_BASE_URL);
|
|
||||||
const results = await Promise.all(defs.map(def => publishOne(env.REGISTRY, def)));
|
|
||||||
|
|
||||||
const ok = results.filter(r => r.ok).length;
|
|
||||||
const failed = results.filter(r => !r.ok).length;
|
|
||||||
return { ok, failed, results };
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// u6u-builtins Worker
|
|
||||||
// 所有零件已遷移至 u6u-core/registry/components/ 的 TinyGo .wasm 版本
|
|
||||||
// 此 Worker 保留 /init 端點供初始化 Component Registry 使用
|
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
|
||||||
import { cors } from 'hono/cors';
|
|
||||||
import type { Bindings } from './types';
|
|
||||||
import { initComponents } from './actions/initComponents';
|
|
||||||
|
|
||||||
const app = new Hono<{ Bindings: Bindings }>();
|
|
||||||
app.use('*', cors());
|
|
||||||
|
|
||||||
app.get('/', c => c.json({
|
|
||||||
service: 'u6u-builtins',
|
|
||||||
version: '2.0.0',
|
|
||||||
status: 'ok',
|
|
||||||
note: '所有零件已遷移至 WASM,請使用 Component Registry',
|
|
||||||
}));
|
|
||||||
|
|
||||||
// POST /init — 把所有零件上架到 Component Registry(冪等,可重複執行)
|
|
||||||
app.post('/init', async c => {
|
|
||||||
const result = await initComponents(c.env);
|
|
||||||
const allOk = result.failed === 0;
|
|
||||||
return c.json({ success: allOk, ...result }, allOk ? 200 : 207);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
// u6u-builtins Worker 型別定義
|
|
||||||
|
|
||||||
export type Bindings = {
|
|
||||||
REGISTRY: Fetcher; // Component Registry Service Binding
|
|
||||||
CYPHER: Fetcher; // Cypher Executor Service Binding(排程執行用)
|
|
||||||
U6U_STORE: KVNamespace; // KV Store(cron: + ai-transform: 前綴)
|
|
||||||
AI: Ai; // Workers AI(ai-transform compile 用)
|
|
||||||
WORKER_BASE_URL: string; // 本 Worker 對外 URL(用於上架時填入 url 欄位)
|
|
||||||
ENVIRONMENT: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionResponse<T = unknown> =
|
|
||||||
| { success: true; data: T }
|
|
||||||
| { success: false; error: string };
|
|
||||||
|
|
||||||
// componentDefs:所有內建零件的定義清單(資料層,initComponents.ts 使用)
|
|
||||||
export interface ComponentDef {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
url: string;
|
|
||||||
method: 'POST';
|
|
||||||
tags: string;
|
|
||||||
input_schema: object;
|
|
||||||
output_schema: object;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CronJob:排程定義(儲存在 U6U_STORE,key = cron:{id})
|
|
||||||
export interface CronJob {
|
|
||||||
id: string;
|
|
||||||
cron_expr: string; // 標準 5 欄位 cron expression
|
|
||||||
triplets?: string[]; // 三元組格式工作流(與 graph_token 二選一)
|
|
||||||
graph_token?: string; // 已存在的 webhook token
|
|
||||||
description: string;
|
|
||||||
created_at: string;
|
|
||||||
last_run?: string;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// AiTransform:已編譯的 AI 轉換函式(儲存在 U6U_STORE,key = ai-transform:{id})
|
|
||||||
export interface AiTransform {
|
|
||||||
id: string;
|
|
||||||
description: string; // 自然語言描述
|
|
||||||
fn_body: string; // 產生的 JS 函式 body(可直接 new Function 執行)
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildComponentDefs(baseUrl: string): ComponentDef[] {
|
|
||||||
return [
|
|
||||||
// === 既有零件 ===
|
|
||||||
{ id: 'http-request', name: 'http-request',
|
|
||||||
description: '發送 HTTP 請求(GET/POST/PUT/DELETE),回傳 status 和 response body。支援自訂 headers 和 body。',
|
|
||||||
url: `${baseUrl}/http-request`, method: 'POST', tags: 'builtin,http,request,api',
|
|
||||||
input_schema: { type: 'object', required: ['url'], properties: { url: { type: 'string' }, method: { type: 'string', enum: ['GET','POST','PUT','DELETE','PATCH'] }, headers: { type: 'object' }, body: {} } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { status: { type: 'number' }, body: {} } } } } },
|
|
||||||
{ id: 'set', name: 'set',
|
|
||||||
description: '設定變數(key-value),把結果傳遞到下一個節點。支援 assignments 陣列或 values 物件兩種格式。',
|
|
||||||
url: `${baseUrl}/set`, method: 'POST', tags: 'builtin,variable,set,transform',
|
|
||||||
input_schema: { type: 'object', properties: { assignments: { type: 'array', items: { type: 'object' } }, values: { type: 'object' }, context: { type: 'object' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object' } } } },
|
|
||||||
{ id: 'filter', name: 'filter',
|
|
||||||
description: '依條件過濾陣列,回傳符合條件的元素。',
|
|
||||||
url: `${baseUrl}/filter`, method: 'POST', tags: 'builtin,filter,array,condition',
|
|
||||||
input_schema: { type: 'object', required: ['items','condition'], properties: { items: { type: 'array' }, condition: { type: 'object' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { items: { type: 'array' }, count: { type: 'number' } } } } } },
|
|
||||||
{ id: 'switch', name: 'switch',
|
|
||||||
description: '依條件路由,多個出口分支。',
|
|
||||||
url: `${baseUrl}/switch`, method: 'POST', tags: 'builtin,switch,branch,route,condition',
|
|
||||||
input_schema: { type: 'object', required: ['value','cases'], properties: { value: {}, cases: { type: 'array' }, default_branch: { type: 'string' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { branch: { type: 'string' } } } } } },
|
|
||||||
{ id: 'merge', name: 'merge',
|
|
||||||
description: '合併多個輸入物件為一個。',
|
|
||||||
url: `${baseUrl}/merge`, method: 'POST', tags: 'builtin,merge,combine,object',
|
|
||||||
input_schema: { type: 'object', required: ['inputs'], properties: { inputs: { type: 'array', items: { type: 'object' } } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object' } } } },
|
|
||||||
{ id: 'wait', name: 'wait',
|
|
||||||
description: '等待指定毫秒數後繼續,最多 30 秒。',
|
|
||||||
url: `${baseUrl}/wait`, method: 'POST', tags: 'builtin,wait,delay,throttle',
|
|
||||||
input_schema: { type: 'object', required: ['ms'], properties: { ms: { type: 'number' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object' } } } },
|
|
||||||
{ id: 'google-sheets', name: 'google-sheets',
|
|
||||||
description: '讀取或寫入 Google 試算表。需要 Google OAuth access_token。',
|
|
||||||
url: `${baseUrl}/google-sheets`, method: 'POST', tags: 'integration,google,sheets,oauth',
|
|
||||||
input_schema: { type: 'object', required: ['spreadsheet_id','range','access_token'], properties: { spreadsheet_id: { type: 'string' }, range: { type: 'string' }, action: { type: 'string', enum: ['read','write'] }, values: { type: 'array' }, access_token: { type: 'string' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object' } } } },
|
|
||||||
{ id: 'gmail', name: 'gmail',
|
|
||||||
description: '透過 Gmail 發送 Email。需要 Google OAuth access_token。',
|
|
||||||
url: `${baseUrl}/gmail`, method: 'POST', tags: 'integration,google,gmail,email,oauth',
|
|
||||||
input_schema: { type: 'object', required: ['to','subject','body','access_token'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' }, access_token: { type: 'string' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object' } } } },
|
|
||||||
{ id: 'line-notify', name: 'line-notify',
|
|
||||||
description: '發送 LINE Notify 訊息。需要 LINE Channel Access Token。',
|
|
||||||
url: `${baseUrl}/line-notify`, method: 'POST', tags: 'integration,line,notify,message',
|
|
||||||
input_schema: { type: 'object', required: ['message','token'], properties: { message: { type: 'string' }, token: { type: 'string' }, image_url: { type: 'string' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object' } } } },
|
|
||||||
{ id: 'telegram', name: 'telegram',
|
|
||||||
description: '透過 Telegram Bot 發送訊息。需要 bot_token 和 chat_id。',
|
|
||||||
url: `${baseUrl}/telegram`, method: 'POST', tags: 'integration,telegram,bot,message',
|
|
||||||
input_schema: { type: 'object', required: ['chat_id','text','bot_token'], properties: { chat_id: { type: 'string' }, text: { type: 'string' }, bot_token: { type: 'string' }, parse_mode: { type: 'string' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: {} } } },
|
|
||||||
// === P1 新增:Cron ===
|
|
||||||
{ id: 'cron', name: 'cron',
|
|
||||||
description: '建立定時排程工作流。指定 cron expression(如 0 9 * * *),到時間自動執行指定工作流。',
|
|
||||||
url: `${baseUrl}/cron`, method: 'POST', tags: 'builtin,cron,schedule,trigger,timer',
|
|
||||||
input_schema: { type: 'object', required: ['cron_expr'], properties: { cron_expr: { type: 'string', description: '標準 cron expression,如 0 9 * * *' }, triplets: { type: 'array', items: { type: 'string' } }, graph_token: { type: 'string' }, description: { type: 'string' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { cron_id: { type: 'string' }, cron_expr: { type: 'string' }, enabled: { type: 'boolean' } } } } } },
|
|
||||||
// === P2 新增:控制流 ===
|
|
||||||
{ id: 'if', name: 'if',
|
|
||||||
description: '單一條件判斷,true/false 兩個出口。condition 支援 JS 表達式(如 x > 5)。',
|
|
||||||
url: `${baseUrl}/if`, method: 'POST', tags: 'builtin,control,if,branch,condition',
|
|
||||||
input_schema: { type: 'object', required: ['condition'], properties: { condition: { type: 'string', description: 'JS 表達式,如 x > 5' }, input: { type: 'object', description: '提供給 condition 的變數' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { result: { type: 'boolean' }, branch: { type: 'string', enum: ['true', 'false'] } } } } } },
|
|
||||||
{ id: 'foreach', name: 'foreach',
|
|
||||||
description: '對輸入陣列的每個元素執行一次後續工作流(依序)。',
|
|
||||||
url: `${baseUrl}/foreach`, method: 'POST', tags: 'builtin,control,foreach,loop,iteration',
|
|
||||||
input_schema: { type: 'object', required: ['items'], properties: { items: { type: 'array', description: '要迭代的陣列' }, item_key: { type: 'string', description: '每個元素注入的變數名,預設 item' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { items: { type: 'array' }, count: { type: 'number' }, current_index: { type: 'number' }, current_item: {} } } } } },
|
|
||||||
{ id: 'try-catch', name: 'try-catch',
|
|
||||||
description: '錯誤處理分支:執行失敗時走 catch 出口繼續,不中斷整個工作流。',
|
|
||||||
url: `${baseUrl}/try-catch`, method: 'POST', tags: 'builtin,control,try,catch,error,handling',
|
|
||||||
input_schema: { type: 'object', required: ['action'], properties: { action: { type: 'object', description: '要嘗試執行的動作(url + body)' }, fallback: { description: '失敗時的預設輸出' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { branch: { type: 'string', enum: ['try', 'catch'] }, result: {}, error: { type: 'string' } } } } } },
|
|
||||||
// === P3 新增:資料處理 ===
|
|
||||||
{ id: 'string-ops', name: 'string-ops',
|
|
||||||
description: '字串操作:capitalize, trim, replace, split, join, substring, upper, lower, includes, startsWith, endsWith, regex match/extract/replace。',
|
|
||||||
url: `${baseUrl}/string-ops`, method: 'POST', tags: 'builtin,data,string,transform,text',
|
|
||||||
input_schema: { type: 'object', required: ['operation','input'], properties: { operation: { type: 'string' }, input: { type: 'string' }, args: { description: '操作參數(依 operation 而定)' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { result: {}, operation: { type: 'string' } } } } } },
|
|
||||||
{ id: 'number-ops', name: 'number-ops',
|
|
||||||
description: '數字操作:round, floor, ceil, abs, format, add, subtract, multiply, divide, mod, min, max, clamp。',
|
|
||||||
url: `${baseUrl}/number-ops`, method: 'POST', tags: 'builtin,data,number,math,transform',
|
|
||||||
input_schema: { type: 'object', required: ['operation','input'], properties: { operation: { type: 'string' }, input: { type: 'number' }, args: { description: '操作參數(依 operation 而定)' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { result: {}, operation: { type: 'string' } } } } } },
|
|
||||||
{ id: 'array-ops', name: 'array-ops',
|
|
||||||
description: '陣列操作:map, sort, max, min, sum, average, count, first, last, flatten, unique, reverse, chunk。',
|
|
||||||
url: `${baseUrl}/array-ops`, method: 'POST', tags: 'builtin,data,array,list,transform',
|
|
||||||
input_schema: { type: 'object', required: ['operation','input'], properties: { operation: { type: 'string' }, input: { type: 'array' }, args: { description: '操作參數(依 operation 而定)' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { result: {}, operation: { type: 'string' } } } } } },
|
|
||||||
{ id: 'date-ops', name: 'date-ops',
|
|
||||||
description: '日期操作:now, format, add, subtract, diff, parse, startOf, endOf, isBefore, isAfter。',
|
|
||||||
url: `${baseUrl}/date-ops`, method: 'POST', tags: 'builtin,data,date,time,transform',
|
|
||||||
input_schema: { type: 'object', required: ['operation'], properties: { operation: { type: 'string' }, input: { type: 'string', description: 'ISO 日期字串(now 操作可省略)' }, args: { description: '操作參數(依 operation 而定)' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { result: {}, operation: { type: 'string' } } } } } },
|
|
||||||
// === P4 新增:AI Transform ===
|
|
||||||
{ id: 'ai-transform-compile', name: 'ai-transform-compile',
|
|
||||||
description: 'AI compile:輸入自然語言描述,AI 產生確定性 JS 轉換函式並儲存。返回 transform_id + 函式預覽。',
|
|
||||||
url: `${baseUrl}/ai-transform/compile`, method: 'POST', tags: 'ai,transform,compile,nlp,codegen',
|
|
||||||
input_schema: { type: 'object', required: ['description'], properties: { description: { type: 'string', description: '自然語言描述,如「把日期改成台灣格式 YYYY/MM/DD」' }, example_input: { description: '範例輸入(幫助 AI 理解)' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { transform_id: { type: 'string' }, fn_preview: { type: 'string' }, description: { type: 'string' } } } } } },
|
|
||||||
{ id: 'ai-transform-run', name: 'ai-transform-run',
|
|
||||||
description: 'AI run:使用已編譯的 transform_id 機械式執行轉換(不再呼叫 AI)。',
|
|
||||||
url: `${baseUrl}/ai-transform/run`, method: 'POST', tags: 'ai,transform,run,execute',
|
|
||||||
input_schema: { type: 'object', required: ['transform_id','input'], properties: { transform_id: { type: 'string', description: '由 compile 端點回傳的 ID' }, input: { description: '要轉換的資料' } } },
|
|
||||||
output_schema: { type: 'object', properties: { success: { type: 'boolean' }, data: { type: 'object', properties: { result: {}, transform_id: { type: 'string' } } } } } },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ES2022",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"types": ["@cloudflare/workers-types"],
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"]
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
name = "arcrun-builtins"
|
|
||||||
main = "src/index.ts"
|
|
||||||
compatibility_date = "2025-02-19"
|
|
||||||
compatibility_flags = ["nodejs_compat"]
|
|
||||||
workers_dev = true
|
|
||||||
|
|
||||||
# Service Binding:呼叫 Cypher Executor 執行排程工作流
|
|
||||||
[[services]]
|
|
||||||
binding = "CYPHER"
|
|
||||||
service = "arcrun-cypher-executor" # 填入你的 cypher-executor Worker 名稱
|
|
||||||
|
|
||||||
# KV Store:cron: 前綴 = 排程定義,ai-transform: 前綴 = 編譯後轉換函式
|
|
||||||
[[kv_namespaces]]
|
|
||||||
binding = "U6U_STORE"
|
|
||||||
id = "" # 填入你的 KV Namespace ID
|
|
||||||
|
|
||||||
# Workers AI:ai-transform 零件的 compile 階段使用
|
|
||||||
[ai]
|
|
||||||
binding = "AI"
|
|
||||||
|
|
||||||
# Cron Trigger:每分鐘掃描並執行到期的排程工作流
|
|
||||||
[triggers]
|
|
||||||
crons = ["* * * * *"]
|
|
||||||
|
|
||||||
[vars]
|
|
||||||
ENVIRONMENT = "production"
|
|
||||||
@@ -51,7 +51,7 @@ async function initStandard(rl: ReturnType<typeof createInterface>): Promise<voi
|
|||||||
const res = await fetch(ARCRUN_REGISTER_URL, {
|
const res = await fetch(ARCRUN_REGISTER_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, cf_api_token: cfApiToken }),
|
body: JSON.stringify({ email }), // CF API Token 永遠不離開本機
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -137,9 +137,10 @@ function createCredentialsYamlIfMissing(): void {
|
|||||||
// 確保 .gitignore 排除 credentials.yaml
|
// 確保 .gitignore 排除 credentials.yaml
|
||||||
const gitignorePath = join(process.cwd(), '.gitignore');
|
const gitignorePath = join(process.cwd(), '.gitignore');
|
||||||
if (existsSync(gitignorePath)) {
|
if (existsSync(gitignorePath)) {
|
||||||
const content = require('node:fs').readFileSync(gitignorePath, 'utf8');
|
const { readFileSync, appendFileSync } = await import('node:fs');
|
||||||
|
const content = readFileSync(gitignorePath, 'utf8');
|
||||||
if (!content.includes('credentials.yaml')) {
|
if (!content.includes('credentials.yaml')) {
|
||||||
require('node:fs').appendFileSync(gitignorePath, '\ncredentials.yaml\n');
|
appendFileSync(gitignorePath, '\ncredentials.yaml\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,10 +77,11 @@ export class CfKvClient {
|
|||||||
|
|
||||||
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
|
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
|
||||||
export async function encryptCredential(value: string, encryptionKey: string): Promise<string> {
|
export async function encryptCredential(value: string, encryptionKey: string): Promise<string> {
|
||||||
// 若沒有設定 encryption key,使用 base64 作為 fallback(dev 模式)
|
if (!encryptionKey || encryptionKey.length < 64) {
|
||||||
if (!encryptionKey || encryptionKey.length < 32) {
|
throw new Error(
|
||||||
const b64 = Buffer.from(value).toString('base64');
|
'ARCRUN_ENCRYPTION_KEY 未設定或長度不足(需要 256-bit hex,即 64 個十六進位字元)\n' +
|
||||||
return JSON.stringify({ encrypted: b64, iv: 'dev-mode', mode: 'base64' });
|
'生成指令:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyBytes = hexToUint8Array(encryptionKey);
|
const keyBytes = hexToUint8Array(encryptionKey);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Execution Logger — 執行結果寫入 ANALYTICS_KV(fire-and-forget)
|
||||||
|
*
|
||||||
|
* 設計:每次 workflow 執行後,將統計數據寫入 ANALYTICS_KV(key = stats:{workflowId})。
|
||||||
|
* Phase 7 可升級為 POST 至 registry.arcrun.dev/analytics/record。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Bindings, GraphNode } from '../types';
|
||||||
|
|
||||||
|
export interface ExecutionVerdict {
|
||||||
|
workflow_id: string;
|
||||||
|
component_ids: string[];
|
||||||
|
verdict: 'success' | 'failed';
|
||||||
|
duration_ms: number;
|
||||||
|
message: string;
|
||||||
|
recorded_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 寫入執行結果至 ANALYTICS_KV(fire-and-forget,不阻擋主流程)
|
||||||
|
* 由 c.executionCtx.waitUntil() 包裹呼叫
|
||||||
|
*/
|
||||||
|
export async function writeExecutionVerdict(
|
||||||
|
env: Bindings,
|
||||||
|
workflowId: string,
|
||||||
|
nodes: GraphNode[],
|
||||||
|
verdict: 'success' | 'failed',
|
||||||
|
durationMs: number,
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const componentIds = nodes
|
||||||
|
.filter(n => n.type === 'Component' && n.componentId)
|
||||||
|
.map(n => n.componentId!);
|
||||||
|
|
||||||
|
const record: ExecutionVerdict = {
|
||||||
|
workflow_id: workflowId,
|
||||||
|
component_ids: componentIds,
|
||||||
|
verdict,
|
||||||
|
duration_ms: durationMs,
|
||||||
|
message,
|
||||||
|
recorded_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ANALYTICS_KV key = stats:{workflowId}:{timestamp}(避免覆蓋)
|
||||||
|
const key = `stats:${workflowId}:${Date.now()}`;
|
||||||
|
await env.ANALYTICS_KV.put(key, JSON.stringify(record), {
|
||||||
|
expirationTtl: 60 * 60 * 24 * 90, // 保留 90 天
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// fire-and-forget:不拋錯,不影響主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
import { generateToken, validateAndParseWebhook, executeWebhookGraph } from '../actions/webhook-handlers';
|
import { generateToken, validateAndParseWebhook, executeWebhookGraph } from '../actions/webhook-handlers';
|
||||||
import { resolveWebhookGraph } from '../actions/webhook-graph-resolver';
|
import { resolveWebhookGraph } from '../actions/webhook-graph-resolver';
|
||||||
|
import { writeExecutionVerdict } from '../actions/execution-logger';
|
||||||
|
|
||||||
export const webhooksRouter = new Hono<{ Bindings: Bindings }>();
|
export const webhooksRouter = new Hono<{ Bindings: Bindings }>();
|
||||||
|
|
||||||
@@ -69,5 +70,14 @@ webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await executeWebhookGraph(c.env, record.graph, triggerContext, token);
|
const result = await executeWebhookGraph(c.env, record.graph, triggerContext, token);
|
||||||
|
|
||||||
|
// fire-and-forget analytics(不阻擋回應)
|
||||||
|
const graph = record.graph as { id?: string; nodes?: unknown[] };
|
||||||
|
const workflowId = graph.id ?? token;
|
||||||
|
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as import('../types').GraphNode[]) : [];
|
||||||
|
c.executionCtx.waitUntil(
|
||||||
|
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||||
|
);
|
||||||
|
|
||||||
return c.json(result, result.success ? 200 : 500);
|
return c.json(result, result.success ? 200 : 500);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export type Bindings = {
|
|||||||
WEBHOOKS: KVNamespace;
|
WEBHOOKS: KVNamespace;
|
||||||
// Credential Store:AES-GCM 加密存放用戶 API token
|
// Credential Store:AES-GCM 加密存放用戶 API token
|
||||||
CREDENTIALS_KV: KVNamespace;
|
CREDENTIALS_KV: KVNamespace;
|
||||||
|
// Analytics:執行統計(fire-and-forget,key = stats:{workflowId}:{timestamp})
|
||||||
|
ANALYTICS_KV: KVNamespace;
|
||||||
// R2 Bucket:WASM 零件二進位
|
// R2 Bucket:WASM 零件二進位
|
||||||
WASM_BUCKET: R2Bucket;
|
WASM_BUCKET: R2Bucket;
|
||||||
// Workers AI
|
// Workers AI
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ id = "" # 填入你的 KV Namespace ID
|
|||||||
binding = "CREDENTIALS_KV"
|
binding = "CREDENTIALS_KV"
|
||||||
id = "" # 填入你的 Credentials KV Namespace ID
|
id = "" # 填入你的 Credentials KV Namespace ID
|
||||||
|
|
||||||
|
# Analytics:執行統計(fire-and-forget,保留 90 天)
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "ANALYTICS_KV"
|
||||||
|
id = "" # 填入你的 Analytics KV Namespace ID
|
||||||
|
|
||||||
# R2 Bucket:WASM 零件二進位(arcrun.dev 公眾零件庫,或自架時填入自己的 bucket)
|
# R2 Bucket:WASM 零件二進位(arcrun.dev 公眾零件庫,或自架時填入自己的 bucket)
|
||||||
[[r2_buckets]]
|
[[r2_buckets]]
|
||||||
binding = "WASM_BUCKET"
|
binding = "WASM_BUCKET"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ runtime_compat:
|
|||||||
constraints:
|
constraints:
|
||||||
max_size_kb: 2048
|
max_size_kb: 2048
|
||||||
max_cold_start_ms: 50
|
max_cold_start_ms: 50
|
||||||
no_network_syscall: true
|
no_network_syscall: false
|
||||||
no_filesystem_syscall: true
|
no_filesystem_syscall: true
|
||||||
io_model: "stdin_stdout_json"
|
io_model: "stdin_stdout_json"
|
||||||
input_schema:
|
input_schema:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ runtime_compat:
|
|||||||
constraints:
|
constraints:
|
||||||
max_size_kb: 2048
|
max_size_kb: 2048
|
||||||
max_cold_start_ms: 50
|
max_cold_start_ms: 50
|
||||||
no_network_syscall: true
|
no_network_syscall: false
|
||||||
no_filesystem_syscall: true
|
no_filesystem_syscall: true
|
||||||
io_model: "stdin_stdout_json"
|
io_model: "stdin_stdout_json"
|
||||||
input_schema:
|
input_schema:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ runtime_compat:
|
|||||||
constraints:
|
constraints:
|
||||||
max_size_kb: 2048
|
max_size_kb: 2048
|
||||||
max_cold_start_ms: 50
|
max_cold_start_ms: 50
|
||||||
no_network_syscall: true
|
no_network_syscall: false
|
||||||
no_filesystem_syscall: true
|
no_filesystem_syscall: true
|
||||||
io_model: "stdin_stdout_json"
|
io_model: "stdin_stdout_json"
|
||||||
input_schema:
|
input_schema:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ runtime_compat:
|
|||||||
constraints:
|
constraints:
|
||||||
max_size_kb: 2048
|
max_size_kb: 2048
|
||||||
max_cold_start_ms: 50
|
max_cold_start_ms: 50
|
||||||
no_network_syscall: true
|
no_network_syscall: false
|
||||||
no_filesystem_syscall: true
|
no_filesystem_syscall: true
|
||||||
io_model: "stdin_stdout_json"
|
io_model: "stdin_stdout_json"
|
||||||
input_schema:
|
input_schema:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ runtime_compat:
|
|||||||
constraints:
|
constraints:
|
||||||
max_size_kb: 2048
|
max_size_kb: 2048
|
||||||
max_cold_start_ms: 50
|
max_cold_start_ms: 50
|
||||||
no_network_syscall: true
|
no_network_syscall: false
|
||||||
no_filesystem_syscall: true
|
no_filesystem_syscall: true
|
||||||
io_model: "stdin_stdout_json"
|
io_model: "stdin_stdout_json"
|
||||||
input_schema:
|
input_schema:
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
// 零件提交:沙盒驗收 → 寫入 KBDB Block → 上傳 R2
|
// 零件提交:沙盒驗收 → 寫入 SUBMISSIONS_KV → 上傳 R2
|
||||||
// Requirements: 2.1, 2.2, 2.3
|
// Requirements: 2.1, 2.2, 2.3
|
||||||
|
//
|
||||||
|
// arcrun registry 不依賴 KBDB(InkStone 內部服務)。
|
||||||
|
// 零件元數據存入 SUBMISSIONS_KV(key = comp:{canonical_id}:{version})。
|
||||||
|
// WASM 二進位存入 WASM_BUCKET R2(key = components/{id}/{version}.wasm)。
|
||||||
|
|
||||||
import { runSandboxAcceptance } from './sandboxAcceptance';
|
import { runSandboxAcceptance } from './sandboxAcceptance';
|
||||||
import type { ComponentContract, SandboxResult, Bindings } from '../types';
|
import type { ComponentContract, SandboxResult, Bindings } from '../types';
|
||||||
@@ -15,71 +19,55 @@ export async function submitComponent(
|
|||||||
return sandboxResult;
|
return sandboxResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockId = `comp-${contract.canonical_id}-${contract.version}`;
|
const kvKey = `comp:${contract.canonical_id}:${contract.version}`;
|
||||||
const r2Key = `components/${contract.canonical_id}/${contract.version}.wasm`;
|
const r2Key = `components/${contract.canonical_id}/${contract.version}.wasm`;
|
||||||
|
|
||||||
// 2. 上傳 .wasm 至 R2
|
// 2. 冪等:若已存在相同 (id, version) 直接回傳
|
||||||
|
const existing = await env.SUBMISSIONS_KV.get(kvKey);
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
component_id: contract.canonical_id,
|
||||||
|
version: contract.version,
|
||||||
|
wasm_r2_key: r2Key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 上傳 .wasm 至 R2
|
||||||
await env.WASM_BUCKET.put(r2Key, wasmBytes, {
|
await env.WASM_BUCKET.put(r2Key, wasmBytes, {
|
||||||
httpMetadata: { contentType: 'application/wasm' },
|
httpMetadata: { contentType: 'application/wasm' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 寫入 KBDB Block(冪等:先嘗試取得,存在則更新,不存在則建立)
|
// 4. 寫入 SUBMISSIONS_KV(元數據 + 初始統計)
|
||||||
const kbdbUrl = env.KBDB_URL || 'https://kbdb.finally.click';
|
const record = {
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${env.KBDB_INTERNAL_TOKEN}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const slots: Record<string, string> = {
|
|
||||||
canonical_id: contract.canonical_id,
|
canonical_id: contract.canonical_id,
|
||||||
display_name: contract.display_name,
|
display_name: contract.display_name,
|
||||||
category: contract.category,
|
category: contract.category,
|
||||||
version: contract.version,
|
version: contract.version,
|
||||||
|
author: contract.author ?? '',
|
||||||
wasi_target: contract.wasi_target,
|
wasi_target: contract.wasi_target,
|
||||||
stability: contract.stability,
|
stability: contract.stability,
|
||||||
runtime_compat: JSON.stringify(contract.runtime_compat),
|
runtime_compat: contract.runtime_compat,
|
||||||
component_type: contract.component_type ?? 'wasm',
|
component_type: contract.component_type ?? 'wasm',
|
||||||
max_size_kb: String(contract.constraints.max_size_kb),
|
constraints: contract.constraints,
|
||||||
max_cold_start_ms: String(contract.constraints.max_cold_start_ms),
|
input_schema: contract.input_schema,
|
||||||
no_network_syscall: String(contract.constraints.no_network_syscall),
|
output_schema: contract.output_schema,
|
||||||
input_schema: JSON.stringify(contract.input_schema),
|
gherkin_tests: contract.gherkin_tests,
|
||||||
output_schema: JSON.stringify(contract.output_schema),
|
|
||||||
gherkin_tests: JSON.stringify(contract.gherkin_tests),
|
|
||||||
wasm_r2_key: r2Key,
|
wasm_r2_key: r2Key,
|
||||||
description: contract.description ?? '',
|
description: contract.description ?? '',
|
||||||
tags: JSON.stringify(contract.tags ?? []),
|
tags: contract.tags ?? [],
|
||||||
success_rate: '1',
|
// 初始統計
|
||||||
avg_duration_ms: '0',
|
success_rate: 1,
|
||||||
call_count: '0',
|
avg_duration_ms: 0,
|
||||||
status: 'active',
|
call_count: 0,
|
||||||
deprecated_at: '',
|
// 可見性:預設 author_only,人工審核通過後改為 public
|
||||||
|
visibility: 'author_only' as const,
|
||||||
|
status: 'active' as const,
|
||||||
|
submitted_at: new Date().toISOString(),
|
||||||
|
deprecated_at: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (contract.cypher_binding_url) slots.cypher_binding_url = contract.cypher_binding_url;
|
await env.SUBMISSIONS_KV.put(kvKey, JSON.stringify(record));
|
||||||
if (contract.service_binding_key) slots.service_binding_key = contract.service_binding_key;
|
|
||||||
|
|
||||||
// 冪等:先查是否存在
|
|
||||||
const existRes = await fetch(`${kbdbUrl}/records/${blockId}`, { headers });
|
|
||||||
|
|
||||||
if (existRes.ok) {
|
|
||||||
// 已存在:更新 slots
|
|
||||||
await fetch(`${kbdbUrl}/records/${blockId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ values: slots }),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 不存在:建立新 Block
|
|
||||||
await fetch(`${kbdbUrl}/records`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
record_id: blockId,
|
|
||||||
template_id: 'tpl-component',
|
|
||||||
values: slots,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { z } from 'zod';
|
|||||||
export type Bindings = {
|
export type Bindings = {
|
||||||
WASM_BUCKET: R2Bucket;
|
WASM_BUCKET: R2Bucket;
|
||||||
AI: Ai;
|
AI: Ai;
|
||||||
KBDB_URL: string;
|
SUBMISSIONS_KV: KVNamespace; // 零件元數據 + 可見性狀態(key = comp:{id}:{version})
|
||||||
KBDB_INTERNAL_TOKEN: string;
|
ANALYTICS_KV: KVNamespace; // 執行統計匯總(key = stats:{id}:{version})
|
||||||
ENVIRONMENT: string;
|
ENVIRONMENT: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user