arcrun — AI workflow execution engine (clean history)
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。 此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在 richblack/arcrun 與本地 backup 分支)。含: - acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe) - recipe push 把關(資料外流提醒 + 打通檢查) - 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1) - CLI / cypher-executor / registry / 完整 SDD Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Auth Dispatcher
|
||||
*
|
||||
* 對需要認證的零件,在執行前 HTTP POST 到對應的 auth primitive Worker,
|
||||
* 取回 auth_headers / auth_query / auth_body 合併進節點 context。
|
||||
*
|
||||
* 嚴格邊界(rule 02 §2.2):
|
||||
* - 本檔**不做**任何 credential 解密 / template 展開 / JWT 簽章
|
||||
* - 那些全部在 auth primitive WASM 零件內執行(透過 host function `crypto_decrypt` 等)
|
||||
* - 本檔只做「查 recipe 決定走哪個 primitive Worker」+「HTTP fetch 取回注入結果」
|
||||
*
|
||||
* 目前階段接上 `auth_static_key` + `auth_service_account` + `auth_oauth2`,
|
||||
* Phase 4 剩 `auth_mtls`(mTLS handshake 在 Worker runtime 層)。
|
||||
*
|
||||
* 執行時機:graph-executor 在節點 runner 執行前呼叫,取回的 ctx 會:
|
||||
* 1. 先試本 dispatcher(命中才 return enriched ctx)
|
||||
* 2. 沒命中 fallback 到 `injectCredentials`(Phase 1.9 才刪除)
|
||||
*/
|
||||
|
||||
import type { Bindings } from '../types';
|
||||
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
|
||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||
|
||||
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
|
||||
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']);
|
||||
|
||||
/** auth primitive 本身的 componentId(避免自引用) */
|
||||
const AUTH_PRIMITIVE_IDS = new Set([
|
||||
'auth_static_key',
|
||||
'auth_service_account',
|
||||
'auth_oauth2',
|
||||
'auth_mtls',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 試著對零件做 auth 注入。
|
||||
* - 命中(有對應 auth recipe 且 primitive 已支援)→ 回傳注入後的 ctx
|
||||
* - 未命中 → 回傳 null(呼叫端繼續跑舊路徑)
|
||||
*/
|
||||
export async function tryAuthDispatch(
|
||||
componentId: string,
|
||||
input: Record<string, unknown>,
|
||||
env: Bindings,
|
||||
apiKey: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (AUTH_PRIMITIVE_IDS.has(componentId)) {
|
||||
// auth primitive 本身不需要再做 auth
|
||||
return null;
|
||||
}
|
||||
|
||||
// 決定 auth service name:
|
||||
// 1. 若 API recipe 宣告了 auth_service(例 recipe:kbdb_get → "kbdb")→ 用它,
|
||||
// 讓多個 recipe 共用同一把 auth_recipe(不必每個 action 複製 auth recipe)。
|
||||
// 2. 否則 fallback 到把 componentId 當 service name(向後相容舊行為)。
|
||||
let service = componentId;
|
||||
const apiRecipe = await resolveRecipe(componentId, env.RECIPES);
|
||||
if (apiRecipe?.auth_service) {
|
||||
service = apiRecipe.auth_service;
|
||||
}
|
||||
|
||||
const recipe = await resolveAuthRecipe(service, env.RECIPES);
|
||||
if (!recipe) return null;
|
||||
if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null;
|
||||
|
||||
// 走新路徑:HTTP POST 到對應 auth primitive Worker
|
||||
// 走 workers.dev 避開同 zone 死鎖(P0 #9)
|
||||
const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN);
|
||||
const res = await fetch(primitiveUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'authenticate',
|
||||
api_key: apiKey,
|
||||
service,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`auth primitive "${recipe.primitive}" 回傳 ${res.status}: ${text.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await res.json().catch(() => null) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
auth_headers?: Record<string, string>;
|
||||
auth_query?: Record<string, string>;
|
||||
auth_body?: Record<string, string>;
|
||||
auth_path?: Record<string, string>;
|
||||
} | null;
|
||||
|
||||
if (!result || result.success === false) {
|
||||
throw new Error(
|
||||
`auth primitive 失敗: ${result?.error ?? '未知錯誤'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
_auth_headers: result.auth_headers ?? {},
|
||||
_auth_query: result.auth_query ?? {},
|
||||
_auth_body: result.auth_body ?? {},
|
||||
_auth_path: result.auth_path ?? {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Credential Injector
|
||||
*
|
||||
* 執行順序:
|
||||
* 1. 檢查是否有對應的 auth recipe(auth_recipe:{componentId} in RECIPES KV)
|
||||
* → 有:走 auth recipe 路徑(支援 static_key, service_account)
|
||||
* → 無:走舊有 flat injection 路徑(向後相容)
|
||||
*
|
||||
* Auth Recipe 路徑:
|
||||
* - static_key:展開 inject.header/query/body 的 {{secret.KEY}} 模板
|
||||
* - service_account:JWT signing → token exchange → 展開 {{runtime.access_token}}
|
||||
* - 注入結果以 _auth_headers / _auth_query / _auth_body 攜帶,不污染業務欄位
|
||||
*
|
||||
* 舊有路徑(向後相容):
|
||||
* - 從 RECIPES KV 讀取 credentials_required(動態 recipe)
|
||||
* - 或從 BUILTIN_CREDENTIALS_MAP(內建清單)
|
||||
* - 解密後以 inject_as 欄位名稱直接注入 context
|
||||
*/
|
||||
|
||||
import type { Bindings } from '../types';
|
||||
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
|
||||
import type { AuthRecipeDefinition } from '../routes/recipes';
|
||||
|
||||
export interface CredentialRequirement {
|
||||
key: string; // CREDENTIALS_KV 的 credential 名稱(如 gmail_token)
|
||||
inject_as: string; // 注入到 input 的欄位名稱(如 access_token)
|
||||
}
|
||||
|
||||
/** 內建 API recipe 的 credentials_required(對應 component-loader 的 BUILTIN_API_RECIPES)*/
|
||||
const BUILTIN_CREDENTIALS_MAP: Record<string, CredentialRequirement[]> = {
|
||||
gmail: [{ key: 'gmail_token', inject_as: 'access_token' }],
|
||||
google_sheets: [{ key: 'google_oauth', inject_as: 'access_token' }],
|
||||
telegram: [{ key: 'telegram_bot_token', inject_as: 'bot_token' }],
|
||||
line_notify: [{ key: 'line_token', inject_as: 'token' }],
|
||||
};
|
||||
|
||||
// ── AES-GCM 解密 ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function decryptCredential(encryptedJson: string, encryptionKey: string): Promise<string> {
|
||||
const { encrypted, iv } = JSON.parse(encryptedJson) as { encrypted: string; iv: string };
|
||||
|
||||
const keyBytes = hexToUint8Array(encryptionKey);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'],
|
||||
);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: base64ToUint8Array(iv) },
|
||||
cryptoKey,
|
||||
base64ToUint8Array(encrypted),
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
function hexToUint8Array(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function base64ToUint8Array(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// ── 解密所有 required_secrets → { key: decryptedValue } ──────────────────────
|
||||
|
||||
async function decryptSecrets(
|
||||
recipe: AuthRecipeDefinition,
|
||||
apiKey: string,
|
||||
env: Bindings,
|
||||
): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const req of recipe.required_secrets) {
|
||||
if (req.optional) continue;
|
||||
|
||||
const kvKey = `${apiKey}:cred:${req.key}`;
|
||||
const record = await env.CREDENTIALS_KV.get(kvKey);
|
||||
|
||||
if (!record) {
|
||||
throw new Error(
|
||||
`缺少 credential:${req.key}(${req.label})\n` +
|
||||
`修復步驟:\n` +
|
||||
` 1. 在 credentials.yaml 加入 ${req.key}: "your-value"\n` +
|
||||
` 2. 執行:acr creds push`,
|
||||
);
|
||||
}
|
||||
|
||||
result[req.key] = await decryptCredential(record, env.ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Template 展開:{{secret.KEY}} 和 {{runtime.KEY}} ─────────────────────────
|
||||
|
||||
function interpolateTemplate(
|
||||
template: string,
|
||||
secrets: Record<string, string>,
|
||||
runtime: Record<string, string>,
|
||||
): string {
|
||||
return template.replace(/\{\{(secret|runtime)\.(\w+)\}\}/g, (_, ns, key) => {
|
||||
if (ns === 'secret') return secrets[key] ?? '';
|
||||
if (ns === 'runtime') return runtime[key] ?? '';
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
function interpolateRecord(
|
||||
record: Record<string, string>,
|
||||
secrets: Record<string, string>,
|
||||
runtime: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
result[k] = interpolateTemplate(v, secrets, runtime);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Auth Recipe 注入(新路徑)────────────────────────────────────────────────
|
||||
|
||||
async function injectFromAuthRecipe(
|
||||
recipe: AuthRecipeDefinition,
|
||||
input: Record<string, unknown>,
|
||||
env: Bindings,
|
||||
apiKey: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// 解密所有 required_secrets
|
||||
const secrets = await decryptSecrets(recipe, apiKey, env);
|
||||
|
||||
// runtime token:service_account 路徑已改走 auth-dispatcher → auth_service_account WASM;
|
||||
// 這條 TS fallback 只處理 static_key (runtime 為空即可),service_account 永遠不會走到這裡
|
||||
const runtime: Record<string, string> = {};
|
||||
|
||||
if (recipe.primitive === 'service_account') {
|
||||
throw new Error(
|
||||
`service_account primitive 應由 auth-dispatcher → auth_service_account WASM 處理,` +
|
||||
`不應進到 credential-injector TS fallback (service=${recipe.service})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 展開 inject 模板
|
||||
const authHeaders = recipe.inject.header
|
||||
? interpolateRecord(recipe.inject.header, secrets, runtime)
|
||||
: {};
|
||||
const authQuery = recipe.inject.query
|
||||
? interpolateRecord(recipe.inject.query, secrets, runtime)
|
||||
: {};
|
||||
const authBody = recipe.inject.body
|
||||
? interpolateRecord(recipe.inject.body, secrets, runtime)
|
||||
: {};
|
||||
|
||||
return {
|
||||
...input,
|
||||
_auth_headers: authHeaders,
|
||||
_auth_query: authQuery,
|
||||
_auth_body: authBody,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 舊有路徑:flat injection(向後相容)──────────────────────────────────────
|
||||
|
||||
async function loadCredentialsRequired(
|
||||
componentId: string,
|
||||
env: Bindings,
|
||||
): Promise<CredentialRequirement[]> {
|
||||
const recipe = await resolveRecipe(componentId, env.RECIPES);
|
||||
if (recipe?.credentials_required?.length) {
|
||||
return recipe.credentials_required;
|
||||
}
|
||||
return BUILTIN_CREDENTIALS_MAP[componentId] ?? [];
|
||||
}
|
||||
|
||||
// ── 主入口 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 執行 credential 注入。
|
||||
*
|
||||
* @param componentId - 零件 canonical_id 或 hash
|
||||
* @param input - 節點的 merged context
|
||||
* @param env - Cloudflare Worker Bindings
|
||||
* @param apiKey - 用戶的 API Key(ak_前綴),作為 KV namespace
|
||||
*/
|
||||
export async function injectCredentials(
|
||||
componentId: string,
|
||||
input: Record<string, unknown>,
|
||||
env: Bindings,
|
||||
apiKey?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// 沒有 api_key → local 模式,略過
|
||||
if (!apiKey) return input;
|
||||
|
||||
// ── 新路徑:auth recipe ──
|
||||
const authRecipe = await resolveAuthRecipe(componentId, env.RECIPES);
|
||||
if (authRecipe) {
|
||||
return injectFromAuthRecipe(authRecipe, input, env, apiKey);
|
||||
}
|
||||
|
||||
// ── 舊路徑:flat injection(向後相容)──
|
||||
const required = await loadCredentialsRequired(componentId, env);
|
||||
if (required.length === 0) return input;
|
||||
|
||||
const enriched = { ...input };
|
||||
|
||||
for (const cred of required) {
|
||||
const kvKey = `${apiKey}:cred:${cred.key}`;
|
||||
const record = await env.CREDENTIALS_KV.get(kvKey);
|
||||
|
||||
if (!record) {
|
||||
throw new Error(
|
||||
`缺少 credential:${cred.key}\n` +
|
||||
`修復步驟:\n` +
|
||||
` 1. 在 credentials.yaml 中加入 ${cred.key}: "your-token"\n` +
|
||||
` 2. 執行:acr creds push`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptCredential(record, env.ENCRYPTION_KEY);
|
||||
enriched[cred.inject_as] = decrypted;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`credential "${cred.key}" 解密失敗:${e instanceof Error ? e.message : String(e)}\n` +
|
||||
`修復步驟:重新執行 acr creds push。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Bindings, ExecutionGraph } from '../types';
|
||||
import { ExecutionError, WorkflowPaused } from '../types';
|
||||
import { GraphExecutor } from '../graph-executor';
|
||||
import { graphSchema } from '../lib/schemas';
|
||||
import { createComponentLoader } from '../lib/component-loader';
|
||||
import { writeEvaluation, updateComponentStats } from './execution-evaluator';
|
||||
import { parseTriplets } from './triplet-parser';
|
||||
import { searchNodes } from './search-nodes';
|
||||
import { buildExecutionGraph } from './graph-builder';
|
||||
|
||||
export async function handleCypherSearch(
|
||||
triplets: unknown[],
|
||||
env: Bindings,
|
||||
): Promise<{ nodes: Record<string, unknown>; cypher: unknown; missing: string[] }> {
|
||||
const parsed = parseTriplets(triplets);
|
||||
if (!parsed) {
|
||||
throw new Error('無法解析任何節點');
|
||||
}
|
||||
|
||||
const { nodeResults } = searchNodes(parsed);
|
||||
|
||||
const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result');
|
||||
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: [] };
|
||||
}
|
||||
|
||||
export async function handleCypherExecute(
|
||||
triplets: unknown[],
|
||||
context: Record<string, unknown> | undefined,
|
||||
graphId: string,
|
||||
graphName: string,
|
||||
config: Record<string, Record<string, unknown>> | undefined,
|
||||
env: Bindings,
|
||||
waitUntil: (promise: Promise<void>) => void,
|
||||
apiKey?: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
trace?: unknown;
|
||||
duration_ms: number;
|
||||
graph?: ExecutionGraph;
|
||||
// resumable workflow: 節點 pending 時回 paused(不算 success 也不算 fail)
|
||||
paused?: boolean;
|
||||
task_id?: string;
|
||||
run_id?: string;
|
||||
paused_node_id?: string;
|
||||
}> {
|
||||
const parsed = parseTriplets(triplets as unknown[]);
|
||||
if (!parsed) {
|
||||
throw new Error('無法解析任何節點');
|
||||
}
|
||||
|
||||
const { nodeResults } = searchNodes(parsed, config);
|
||||
|
||||
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName, config);
|
||||
const parseResult = graphSchema.safeParse(graph);
|
||||
if (!parseResult.success) {
|
||||
throw new Error('圖定義產生失敗');
|
||||
}
|
||||
|
||||
const loader = createComponentLoader(env);
|
||||
const executor = new GraphExecutor(loader, undefined, env, apiKey);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await executor.execute(parseResult.data as ExecutionGraph, context ?? {}, env.EXEC_CONTEXT);
|
||||
const duration_ms = Date.now() - start;
|
||||
|
||||
// 非同步記錄統計(Phase 7 補充 analytics,目前為 no-op)
|
||||
const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId;
|
||||
const runId = `${graphId}-${Date.now()}`;
|
||||
waitUntil(writeEvaluation(env, {
|
||||
run_id: runId,
|
||||
workflow_id: graphId,
|
||||
component_id: componentId,
|
||||
verdict: 'success',
|
||||
duration_ms,
|
||||
evaluated_at: Date.now(),
|
||||
}));
|
||||
waitUntil(updateComponentStats(env, componentId, 'success', duration_ms));
|
||||
|
||||
return { success: true, data: result.data, trace: result.trace, duration_ms, graph };
|
||||
} catch (err) {
|
||||
const duration_ms = Date.now() - start;
|
||||
|
||||
// Resumable workflow: 節點回 pending → 回 paused 結構,不算成功也不算失敗
|
||||
// SDD: resumable-workflow/design.md
|
||||
if (err instanceof WorkflowPaused) {
|
||||
return {
|
||||
success: true,
|
||||
paused: true,
|
||||
task_id: err.task_id,
|
||||
run_id: err.run_id,
|
||||
paused_node_id: err.paused_node_id,
|
||||
trace: err.trace_so_far,
|
||||
duration_ms,
|
||||
graph,
|
||||
};
|
||||
}
|
||||
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId;
|
||||
const runId = `${graphId}-${Date.now()}`;
|
||||
waitUntil(writeEvaluation(env, {
|
||||
run_id: runId,
|
||||
workflow_id: graphId,
|
||||
component_id: componentId,
|
||||
verdict: 'failed',
|
||||
duration_ms,
|
||||
error_message: errMsg.slice(0, 200),
|
||||
evaluated_at: Date.now(),
|
||||
}));
|
||||
waitUntil(updateComponentStats(env, componentId, 'failed', duration_ms));
|
||||
if (err instanceof ExecutionError) {
|
||||
const traceFormatted = err.trace.map(s => ({
|
||||
node: s.nodeId,
|
||||
status: s.error ? 'failed' : 'success',
|
||||
...(s.error ? { error: s.error } : {}),
|
||||
}));
|
||||
throw new Error(JSON.stringify({
|
||||
success: false,
|
||||
error: errMsg,
|
||||
failed_node: err.failed_node,
|
||||
failed_input: err.failed_input,
|
||||
trace: traceFormatted,
|
||||
duration_ms,
|
||||
}));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Execution Analytics — 零件執行後的統計記錄
|
||||
*
|
||||
* Phase 1 MVP:stub(不寫入任何外部服務)
|
||||
* Phase 7 補充:fire-and-forget POST 至 registry.arcrun.dev/analytics/record
|
||||
*/
|
||||
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export interface EvaluationRecord {
|
||||
run_id: string;
|
||||
workflow_id: string;
|
||||
component_id: string;
|
||||
verdict: 'success' | 'failed' | 'timeout';
|
||||
duration_ms: number;
|
||||
error_message?: string;
|
||||
evaluated_at: number;
|
||||
}
|
||||
|
||||
/** 記錄執行結果(MVP:no-op,Phase 7 補充 analytics)*/
|
||||
export async function writeEvaluation(
|
||||
_env: Bindings,
|
||||
_record: EvaluationRecord,
|
||||
): Promise<void> {
|
||||
// Phase 7: POST to registry.arcrun.dev/analytics/record
|
||||
}
|
||||
|
||||
/** 更新零件統計(MVP:no-op,Phase 7 補充)*/
|
||||
export async function updateComponentStats(
|
||||
_env: Bindings,
|
||||
_componentId: string,
|
||||
_verdict: 'success' | 'failed' | 'timeout',
|
||||
_durationMs: number,
|
||||
): Promise<void> {
|
||||
// Phase 7: update ANALYTICS_KV via registry worker
|
||||
}
|
||||
@@ -0,0 +1,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:不拋錯,不影響主流程
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ParsedTriplets } from './triplet-parser';
|
||||
import { toEdgeType } from './triplet-parser';
|
||||
import type { SearchResult } from './search-nodes';
|
||||
|
||||
/** 從 nodeResults + parsed 組成可直接送入 /execute 的 ExecutionGraph
|
||||
*
|
||||
* config 格式(來自 workflow YAML 的 config 欄位):
|
||||
* { node_name: { component: "cmp_xxxxxxxx" | "rec_xxxxxxxx" | canonical_id, ...params } }
|
||||
*
|
||||
* 若 config[node].component 存在,以它覆蓋 searchNodes 偵測到的 componentId。
|
||||
* config[node] 的其他欄位作為節點靜態參數(node.data),合併進執行 context。
|
||||
*/
|
||||
export function buildExecutionGraph(
|
||||
parsed: ParsedTriplets,
|
||||
nodeResults: SearchResult['nodeResults'],
|
||||
graphId: string,
|
||||
graphName: string,
|
||||
config?: Record<string, Record<string, unknown>>,
|
||||
) {
|
||||
const nodes = [...parsed.nodeNames].map(name => {
|
||||
const nr = nodeResults[name]!;
|
||||
const id = name.toLowerCase().replace(/\s+/g, '-');
|
||||
const nodeConfig = config?.[name] ?? {};
|
||||
|
||||
// config[name].component 可以是 hash 或 canonical_id,覆蓋自動偵測的 componentId
|
||||
const componentId = (nodeConfig.component as string | undefined) ?? nr.componentId;
|
||||
|
||||
// 其他 config 欄位作為 node.data(靜態參數)
|
||||
const { component: _component, ...staticParams } = nodeConfig;
|
||||
const data = Object.keys(staticParams).length > 0 ? staticParams : undefined;
|
||||
|
||||
return { id, type: nr.type, componentId, label: name, data };
|
||||
});
|
||||
|
||||
const edges = parsed.edges.map(e => {
|
||||
// 「對每個 X」label 抽 iterator:cypher binding 表達 FOREACH 的迭代變數
|
||||
// 例:'A >> 對每個 paragraph >> B' → type=FOREACH, iterator='paragraph'
|
||||
// getIterableFromContext 會找 ctx.paragraphs(複數)或 ctx.paragraph
|
||||
let iterator: string | undefined;
|
||||
let label = e.label;
|
||||
const foreachMatch = label.match(/^(?:對每個|FOREACH)\s+(\w+)$/i);
|
||||
if (foreachMatch) {
|
||||
iterator = foreachMatch[1];
|
||||
label = '對每個'; // 改回標準 label 走 SEMANTIC_EDGE_MAP
|
||||
}
|
||||
const edge: { from: string; to: string; type: ReturnType<typeof toEdgeType>; iterator?: string } = {
|
||||
from: e.from.toLowerCase().replace(/\s+/g, '-'),
|
||||
to: e.to.toLowerCase().replace(/\s+/g, '-'),
|
||||
type: toEdgeType(label),
|
||||
};
|
||||
if (iterator) edge.iterator = iterator;
|
||||
return edge;
|
||||
});
|
||||
|
||||
return { id: graphId, name: graphName, nodes, edges };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ParsedTriplets, NodeRole } from './triplet-parser';
|
||||
import { resolveNodeRole } from './triplet-parser';
|
||||
|
||||
export type SearchResult = {
|
||||
nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }>;
|
||||
missingNodes: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 對所有節點進行解析,確認每個節點對應的零件 ID。
|
||||
*
|
||||
* 注意:此步驟只做靜態解析,不做遠端查找。
|
||||
* 零件是否真的存在由 component-loader 在執行時決定(Service Binding / KV / URL)。
|
||||
*
|
||||
* 優先序:
|
||||
* 1. Input/Output 角色:自動標記,componentId = 小寫節點名稱
|
||||
* 2. config[nodeName].component 已指定:使用 config 提供的 componentId
|
||||
* 3. 其他:componentId = 節點名稱(交給 component-loader 在執行時解析)
|
||||
*/
|
||||
export function searchNodes(
|
||||
parsed: ParsedTriplets,
|
||||
config?: Record<string, Record<string, unknown>>,
|
||||
): SearchResult {
|
||||
const nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }> = {};
|
||||
|
||||
for (const nodeName of parsed.nodeNames) {
|
||||
const role = resolveNodeRole(nodeName, parsed);
|
||||
|
||||
if (role === 'Input' || role === 'Output') {
|
||||
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
|
||||
continue;
|
||||
}
|
||||
|
||||
const configComponent = config?.[nodeName]?.component as string | undefined;
|
||||
const componentId = configComponent ?? nodeName;
|
||||
nodeResults[nodeName] = { status: 'found', componentId, type: role };
|
||||
}
|
||||
|
||||
return { nodeResults, missingNodes: [] };
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { SEMANTIC_EDGE_MAP, VALID_EDGE_TYPES } from '../lib/constants';
|
||||
import type { EdgeType } from '../types';
|
||||
|
||||
export type ParsedTriplets = {
|
||||
edges: Array<{ from: string; to: string; label: string }>;
|
||||
nodeNames: Set<string>;
|
||||
/** 出現在 from 但不出現在任何 to 的節點(事件源 / 起始點) */
|
||||
sourceNodes: Set<string>;
|
||||
/** 出現在 to 但不出現在任何 from 的節點(終點)*/
|
||||
sinkNodes: Set<string>;
|
||||
};
|
||||
|
||||
export type NodeRole = 'Input' | 'Component' | 'Output';
|
||||
|
||||
/**
|
||||
* 解析後的零件 URI
|
||||
* 支援格式:
|
||||
* component://validate_json
|
||||
* component://validate_json@stable
|
||||
* component://validate_json@pinned:v1
|
||||
* workflow://wf_save_to_db
|
||||
* ui://u6u-btn
|
||||
* style://glow-effect
|
||||
*/
|
||||
export interface ResolvedComponentId {
|
||||
type: 'component' | 'workflow' | 'ui' | 'style';
|
||||
canonicalId: string;
|
||||
stability: 'floating' | 'stable' | 'pinned';
|
||||
pinnedVersion?: string;
|
||||
/** 原始 URI 字串 */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/** 解析零件 URI 協議 */
|
||||
export function resolveComponentId(uri: string): ResolvedComponentId {
|
||||
const raw = uri.trim();
|
||||
|
||||
// 解析協議前綴
|
||||
let type: ResolvedComponentId['type'] = 'component';
|
||||
let rest = raw;
|
||||
|
||||
if (raw.startsWith('component://')) {
|
||||
type = 'component';
|
||||
rest = raw.slice('component://'.length);
|
||||
} else if (raw.startsWith('workflow://')) {
|
||||
type = 'workflow';
|
||||
rest = raw.slice('workflow://'.length);
|
||||
} else if (raw.startsWith('ui://')) {
|
||||
type = 'ui';
|
||||
rest = raw.slice('ui://'.length);
|
||||
} else if (raw.startsWith('style://')) {
|
||||
type = 'style';
|
||||
rest = raw.slice('style://'.length);
|
||||
}
|
||||
|
||||
// 解析穩定性標籤
|
||||
// component://id@stable
|
||||
// component://id@pinned:v1
|
||||
let canonicalId = rest;
|
||||
let stability: ResolvedComponentId['stability'] = 'floating';
|
||||
let pinnedVersion: string | undefined;
|
||||
|
||||
const atIdx = rest.indexOf('@');
|
||||
if (atIdx > 0) {
|
||||
canonicalId = rest.slice(0, atIdx);
|
||||
const tag = rest.slice(atIdx + 1);
|
||||
if (tag === 'stable') {
|
||||
stability = 'stable';
|
||||
} else if (tag.startsWith('pinned:')) {
|
||||
stability = 'pinned';
|
||||
pinnedVersion = tag.slice('pinned:'.length);
|
||||
}
|
||||
}
|
||||
|
||||
return { type, canonicalId, stability, pinnedVersion, raw };
|
||||
}
|
||||
|
||||
/** 解析 triplets 字串陣列,回傳節點與邊的結構 */
|
||||
export function parseTriplets(rawTriplets: unknown[]): ParsedTriplets | null {
|
||||
const edges: Array<{ from: string; to: string; label: string }> = [];
|
||||
const nodeNames = new Set<string>();
|
||||
const fromSet = new Set<string>();
|
||||
const toSet = new Set<string>();
|
||||
|
||||
for (const line of rawTriplets) {
|
||||
if (typeof line !== 'string') continue;
|
||||
const parts = line.split('>>').map((s: string) => s.trim());
|
||||
if (parts.length !== 3) continue;
|
||||
const [from, action, to] = parts;
|
||||
edges.push({ from, to, label: action });
|
||||
nodeNames.add(from);
|
||||
nodeNames.add(to);
|
||||
fromSet.add(from);
|
||||
toSet.add(to);
|
||||
}
|
||||
|
||||
if (nodeNames.size === 0) return null;
|
||||
|
||||
const sourceNodes = new Set([...fromSet].filter(n => !toSet.has(n)));
|
||||
const sinkNodes = new Set([...toSet].filter(n => !fromSet.has(n)));
|
||||
return { edges, nodeNames, sourceNodes, sinkNodes };
|
||||
}
|
||||
|
||||
/** 保留字節點名稱 — 明確宣告為 Input 或 Output 端點 */
|
||||
const INPUT_NAMES = new Set(['input', 'trigger', 'webhook', 'start']);
|
||||
const OUTPUT_NAMES = new Set(['output', 'result', 'end', 'done']);
|
||||
|
||||
/** 根據節點在圖中的位置決定其 type
|
||||
*
|
||||
* 規則:
|
||||
* - 名稱在 INPUT_NAMES → Input(無論位置)
|
||||
* - 名稱在 OUTPUT_NAMES → Output(無論位置)
|
||||
* - sourceNode(只出現在 from)且名稱不在 INPUT_NAMES → Component(例如 cron 作為觸發源)
|
||||
* - sinkNode(只出現在 to)且名稱不在 OUTPUT_NAMES → Component(最常見情況:最後一個實際零件)
|
||||
* - 其他中間節點 → Component
|
||||
*/
|
||||
export function resolveNodeRole(name: string, parsed: ParsedTriplets): NodeRole {
|
||||
if (INPUT_NAMES.has(name.toLowerCase())) return 'Input';
|
||||
if (OUTPUT_NAMES.has(name.toLowerCase())) return 'Output';
|
||||
if (parsed.sourceNodes.has(name)) return 'Input';
|
||||
return 'Component';
|
||||
}
|
||||
|
||||
/** 將 edge label 轉換為合法 EdgeType
|
||||
* 優先序:VALID_EDGE_TYPES(完整匹配)→ SEMANTIC_EDGE_MAP(語意別名)→ 預設 PIPE */
|
||||
export function toEdgeType(label: string): EdgeType {
|
||||
const upper = label.toUpperCase();
|
||||
if (VALID_EDGE_TYPES.has(upper)) return upper as EdgeType;
|
||||
return (SEMANTIC_EDGE_MAP[label] ?? SEMANTIC_EDGE_MAP[upper] ?? 'PIPE') as EdgeType;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Bindings } from '../types';
|
||||
import { graphSchema } from '../lib/schemas';
|
||||
import { parseTriplets } from './triplet-parser';
|
||||
import { searchNodes } from './search-nodes';
|
||||
import { buildExecutionGraph } from './graph-builder';
|
||||
|
||||
export async function resolveWebhookGraph(
|
||||
body: Record<string, unknown>,
|
||||
description: string,
|
||||
env: Bindings,
|
||||
): Promise<{ resolvedGraph: Record<string, unknown>; error?: string }> {
|
||||
// 路徑 A:triplets 格式
|
||||
if (Array.isArray(body.triplets) && body.triplets.length > 0) {
|
||||
const parsed = parseTriplets(body.triplets as unknown[]);
|
||||
if (!parsed) return { resolvedGraph: {}, error: '無法解析 triplets' };
|
||||
|
||||
const { nodeResults } = searchNodes(parsed);
|
||||
|
||||
const graphId = `webhook-${Date.now()}`;
|
||||
const graphName = description || `Webhook ${new Date().toISOString()}`;
|
||||
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName) as Record<string, unknown>;
|
||||
|
||||
const parseResult = graphSchema.safeParse(graph);
|
||||
if (!parseResult.success) {
|
||||
return { resolvedGraph: {}, error: '圖定義產生失敗' };
|
||||
}
|
||||
|
||||
return { resolvedGraph: graph };
|
||||
}
|
||||
|
||||
// 路徑 B:graph 格式
|
||||
if (body.graph && typeof body.graph === 'object') {
|
||||
const graphWithDefaults = {
|
||||
id: `webhook-${Date.now()}`,
|
||||
name: description || `Webhook ${new Date().toISOString()}`,
|
||||
...(body.graph as Record<string, unknown>),
|
||||
};
|
||||
const parsed = graphSchema.safeParse(graphWithDefaults);
|
||||
if (!parsed.success) {
|
||||
return { resolvedGraph: {}, error: '圖定義驗證失敗' };
|
||||
}
|
||||
return { resolvedGraph: graphWithDefaults };
|
||||
}
|
||||
|
||||
// 路徑 C:body 直接就是 graph
|
||||
if (body.nodes && body.edges) {
|
||||
const graphWithDefaults = {
|
||||
id: `webhook-${Date.now()}`,
|
||||
name: description || `Webhook ${new Date().toISOString()}`,
|
||||
...body,
|
||||
};
|
||||
const parsed = graphSchema.safeParse(graphWithDefaults);
|
||||
if (!parsed.success) {
|
||||
return { resolvedGraph: {}, error: '圖定義驗證失敗' };
|
||||
}
|
||||
return { resolvedGraph: graphWithDefaults };
|
||||
}
|
||||
|
||||
return { resolvedGraph: {}, error: '需提供 graph 物件或 triplets 陣列' };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Bindings, ExecutionGraph, ExecutionContext } from '../types';
|
||||
import { ExecutionError } from '../types';
|
||||
import { GraphExecutor } from '../graph-executor';
|
||||
import { graphSchema } from '../lib/schemas';
|
||||
import { createComponentLoader } from '../lib/component-loader';
|
||||
import { recordTelemetry } from '../lib/telemetry';
|
||||
|
||||
type WebhookRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
description: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export function generateToken(): string {
|
||||
const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
return Array.from(tokenBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export async function validateAndParseWebhook(raw: string): Promise<WebhookRecord | null> {
|
||||
try {
|
||||
return JSON.parse(raw) as WebhookRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeWebhookGraph(
|
||||
env: Bindings,
|
||||
graph: Record<string, unknown>,
|
||||
triggerContext: Record<string, unknown>,
|
||||
token: string,
|
||||
apiKey?: string,
|
||||
ctx?: ExecutionContext, // 可選 — 用 waitUntil 把 telemetry 推到背景
|
||||
userAgent?: string, // MCP / SDK client 帶過來
|
||||
): Promise<{ success: boolean; data?: unknown; error?: string; trace?: unknown; duration_ms: number }> {
|
||||
const parsed = graphSchema.safeParse(graph);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: '圖定義已失效', duration_ms: 0 };
|
||||
}
|
||||
|
||||
const loader = createComponentLoader(env);
|
||||
const executor = new GraphExecutor(loader, undefined, env, apiKey);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await executor.execute(
|
||||
parsed.data as ExecutionGraph,
|
||||
{ ...triggerContext, _webhook_token: token },
|
||||
env.EXEC_CONTEXT,
|
||||
);
|
||||
const duration_ms = Date.now() - start;
|
||||
|
||||
// Implicit telemetry:成功 run(含 paused 也算「成功啟動」由 trigger_workflow 那層分類)
|
||||
recordTelemetry(env, apiKey, {
|
||||
event_type: 'run_success',
|
||||
workflow_name: token,
|
||||
duration_ms,
|
||||
agent_user_agent: userAgent,
|
||||
}, ctx);
|
||||
|
||||
return { success: true, data: result.data, duration_ms };
|
||||
} catch (err) {
|
||||
const duration_ms = Date.now() - start;
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const isPaused = /workflow paused/i.test(errMsg);
|
||||
|
||||
// Implicit telemetry:paused 算 run_success;真錯才 run_fail
|
||||
recordTelemetry(env, apiKey, {
|
||||
event_type: isPaused ? 'run_success' : 'run_fail',
|
||||
workflow_name: token,
|
||||
error_code: isPaused ? 'paused_awaiting_resume' : 'execution_error',
|
||||
duration_ms,
|
||||
agent_user_agent: userAgent,
|
||||
}, ctx);
|
||||
|
||||
if (err instanceof ExecutionError) {
|
||||
const traceFormatted = err.trace.map(s => ({
|
||||
node: s.nodeId,
|
||||
status: s.error ? 'failed' : 'success',
|
||||
...(s.error ? { error: s.error } : {}),
|
||||
}));
|
||||
return {
|
||||
success: false,
|
||||
error: errMsg,
|
||||
trace: traceFormatted,
|
||||
duration_ms,
|
||||
};
|
||||
}
|
||||
return { success: false, error: errMsg, duration_ms };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
// arcrun 圖遍歷引擎 — 支援完整 Cypher 語意關係
|
||||
import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types';
|
||||
import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError, WorkflowPaused } from './types';
|
||||
import { injectCredentials } from './actions/credential-injector';
|
||||
import { tryAuthDispatch } from './actions/auth-dispatcher';
|
||||
import { expandPromptRecipe } from './lib/recipe-expander';
|
||||
import { persistPausedRun, isResumablePending, parseRecipeOutput } from './lib/paused-runs';
|
||||
import { buildMagicVars } from './lib/magic-vars';
|
||||
import { recordTelemetry } from './lib/telemetry';
|
||||
|
||||
export type ComponentLoader = (componentId: string) => Promise<ComponentRunner>;
|
||||
export type WorkflowLoader = (workflowId: string) => Promise<ExecutionGraph>;
|
||||
|
||||
// Fan-in 狀態:入度 > 1 的節點需要等所有上游完成後才執行
|
||||
type FanInState = Map<string, { ctx: Record<string, unknown>; remaining: number }>;
|
||||
|
||||
export class GraphExecutor {
|
||||
private loader: ComponentLoader;
|
||||
private workflowLoader?: WorkflowLoader;
|
||||
private env?: Bindings;
|
||||
private apiKey?: string;
|
||||
public recordComponentReference?: (componentId: string, workflowId: string) => Promise<void>;
|
||||
|
||||
// resumable workflow(SDD: resumable-workflow/design.md)
|
||||
// 暫停時持久化 state 用,需在 execute 進入時設定
|
||||
private currentGraph?: ExecutionGraph;
|
||||
private currentRunId?: string;
|
||||
|
||||
constructor(loader: ComponentLoader, workflowLoader?: WorkflowLoader, env?: Bindings, apiKey?: string) {
|
||||
this.loader = loader;
|
||||
this.workflowLoader = workflowLoader;
|
||||
this.env = env;
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async execute(
|
||||
graph: ExecutionGraph,
|
||||
initialContext: Record<string, unknown>,
|
||||
kvNamespace?: KVNamespace | undefined,
|
||||
): Promise<{
|
||||
data: unknown;
|
||||
trace: TraceStep[];
|
||||
}> {
|
||||
const trace: TraceStep[] = [];
|
||||
|
||||
// 建立 KV Context Store(BUILD-006)
|
||||
// run_id = graphId + timestamp,確保每次執行獨立
|
||||
const kvStore: KVContextStore | undefined = kvNamespace
|
||||
? { runId: `${graph.id}-${Date.now()}`, kv: kvNamespace }
|
||||
: undefined;
|
||||
|
||||
// resumable workflow:記住當前 graph + run_id 給 pending 暫停用
|
||||
this.currentGraph = graph;
|
||||
this.currentRunId = kvStore?.runId ?? `${graph.id}-${Date.now()}`;
|
||||
|
||||
// Magic vars:注入 _today / _now / _iso_week 等系統變數(LI SDD M2.x)
|
||||
// initialContext 寫前,magic vars 寫後 → magic vars 永遠 win(防 user accidentally 用 _ prefix)
|
||||
// 同時保留 user 既有 ctx,magic vars 不破壞既有 workflow(_ prefix reserved)
|
||||
const ctxWithMagic: Record<string, unknown> = {
|
||||
...initialContext,
|
||||
...buildMagicVars(),
|
||||
};
|
||||
|
||||
// 找出所有起點(沒有任何邊指向的節點)
|
||||
const hasIncoming = new Set(graph.edges.map(e => e.to));
|
||||
const startNodes = graph.nodes.filter(n => !hasIncoming.has(n.id));
|
||||
|
||||
if (startNodes.length === 0) {
|
||||
return { data: ctxWithMagic, trace };
|
||||
}
|
||||
|
||||
// 建立 fan-in 狀態(入度 > 1 的節點需要等所有上游)
|
||||
const fanIn: FanInState = new Map();
|
||||
for (const node of graph.nodes) {
|
||||
const inDeg = graph.edges.filter(e => e.to === node.id).length;
|
||||
if (inDeg > 1) {
|
||||
fanIn.set(node.id, { ctx: { ...ctxWithMagic }, remaining: inDeg });
|
||||
}
|
||||
}
|
||||
|
||||
// 並行執行所有起點
|
||||
const results = await Promise.all(
|
||||
startNodes.map(node =>
|
||||
this.executeNode(node, graph, ctxWithMagic, new Set(), trace, fanIn, kvStore)
|
||||
)
|
||||
);
|
||||
|
||||
// 合併所有起點的輸出
|
||||
// 注意:若結果是 string(如 HTML),不可直接展開 — 展開 string 會產生字元索引物件
|
||||
let mergedResult: unknown;
|
||||
if (results.length === 1) {
|
||||
mergedResult = results[0];
|
||||
} else {
|
||||
mergedResult = results.reduce(
|
||||
(acc: Record<string, unknown>, r: unknown) => ({
|
||||
...acc,
|
||||
...(typeof r === 'object' && r !== null ? (r as Record<string, unknown>) : {}),
|
||||
}),
|
||||
{} as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
return { data: mergedResult, trace };
|
||||
}
|
||||
|
||||
/**
|
||||
* 從 paused state 繼續執行 workflow
|
||||
* SDD: resumable-workflow/design.md §3.2
|
||||
*
|
||||
* 流程:
|
||||
* 1. 把 paused_node 當已執行(result = callbackResult,注入進 context)
|
||||
* 2. 找出 paused_node 的所有下游節點當新起點
|
||||
* 3. 執行下游節點直到結束(或再次 paused)
|
||||
*/
|
||||
async resumeFromPaused(args: {
|
||||
graph: ExecutionGraph;
|
||||
paused_node_id: string;
|
||||
paused_context: Record<string, unknown>; // paused 當下的 context
|
||||
callback_result: Record<string, unknown>; // daemon callback 給的 result(取代 paused result)
|
||||
prior_trace: TraceStep[];
|
||||
kvNamespace?: KVNamespace;
|
||||
recipe_output_format?: 'text' | 'json';
|
||||
recipe_output_required_fields?: string[];
|
||||
}): Promise<{ data: unknown; trace: TraceStep[] }> {
|
||||
const { graph, paused_node_id, paused_context, prior_trace, kvNamespace } = args;
|
||||
let { callback_result } = args;
|
||||
|
||||
// Recipe output parsing:跟立刻回路徑同樣解析(spread parsed 欄位到 top-level)
|
||||
// SDD: recipe-system + resumable-workflow
|
||||
callback_result = parseRecipeOutput(
|
||||
callback_result,
|
||||
args.recipe_output_format,
|
||||
args.recipe_output_required_fields,
|
||||
) as Record<string, unknown>;
|
||||
|
||||
this.currentGraph = graph;
|
||||
this.currentRunId = `${graph.id}-resume-${Date.now()}`;
|
||||
|
||||
const trace: TraceStep[] = [...prior_trace];
|
||||
const kvStore: KVContextStore | undefined = kvNamespace
|
||||
? { runId: this.currentRunId, kv: kvNamespace }
|
||||
: undefined;
|
||||
|
||||
// 把 callback_result 寫進 paused_node 的 KV output(讓下游讀得到)
|
||||
if (kvStore) {
|
||||
await kvSetNodeOutput(kvStore, paused_node_id, callback_result);
|
||||
}
|
||||
|
||||
// 把 callback_result spread 進 context(替代 paused 結果)+ node-id namespace
|
||||
// 2026-05-14 補:以前漏 namespace,導致下游 `{{paused_node_id.data.text}}` 模板抓不到,
|
||||
// 必須跟同步路徑(propagateCtx)行為一致。
|
||||
const mergedContext: Record<string, unknown> = {
|
||||
...paused_context,
|
||||
...(callback_result && typeof callback_result === 'object' ? callback_result : {}),
|
||||
[paused_node_id]: callback_result,
|
||||
};
|
||||
if (kvStore) {
|
||||
if (!mergedContext._kv_outputs) mergedContext._kv_outputs = {};
|
||||
(mergedContext._kv_outputs as Record<string, unknown>)[paused_node_id] = callback_result;
|
||||
}
|
||||
|
||||
// 找下游節點
|
||||
const downstreamEdges = graph.edges.filter(e => e.from === paused_node_id);
|
||||
if (downstreamEdges.length === 0) {
|
||||
// paused_node 是最後一個節點 → 直接結束
|
||||
return { data: callback_result, trace };
|
||||
}
|
||||
|
||||
// 重建 fanIn(針對下游可能 fan-in 的節點)
|
||||
const fanIn: FanInState = new Map();
|
||||
for (const node of graph.nodes) {
|
||||
const inDeg = graph.edges.filter(e => e.to === node.id).length;
|
||||
if (inDeg > 1) {
|
||||
fanIn.set(node.id, { ctx: { ...mergedContext }, remaining: inDeg });
|
||||
}
|
||||
}
|
||||
|
||||
// 對每個下游節點,建立新 visited Set 避免 paused_node 自己被再跑一次
|
||||
const visited = new Set<string>([`${paused_node_id}:${JSON.stringify(paused_context).slice(0, 50)}`]);
|
||||
|
||||
const downstreamNodes = downstreamEdges
|
||||
.map(e => graph.nodes.find(n => n.id === e.to))
|
||||
.filter((n): n is GraphNode => !!n);
|
||||
|
||||
const results = await Promise.all(
|
||||
downstreamNodes.map(node =>
|
||||
this.executeNode(node, graph, mergedContext, visited, trace, fanIn, kvStore)
|
||||
)
|
||||
);
|
||||
|
||||
let mergedResult: unknown;
|
||||
if (results.length === 1) {
|
||||
mergedResult = results[0];
|
||||
} else {
|
||||
mergedResult = results.reduce(
|
||||
(acc: Record<string, unknown>, r: unknown) => ({
|
||||
...acc,
|
||||
...(typeof r === 'object' && r !== null ? (r as Record<string, unknown>) : {}),
|
||||
}),
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
return { data: mergedResult, trace };
|
||||
}
|
||||
|
||||
private async executeNode(
|
||||
node: GraphNode,
|
||||
graph: ExecutionGraph,
|
||||
context: unknown,
|
||||
visited: Set<string>,
|
||||
trace: TraceStep[],
|
||||
fanIn: FanInState,
|
||||
kvStore?: KVContextStore,
|
||||
): Promise<unknown> {
|
||||
const nodeKey = `${node.id}:${JSON.stringify(context).slice(0, 50)}`;
|
||||
if (visited.has(nodeKey)) return context;
|
||||
visited.add(nodeKey);
|
||||
|
||||
const start = Date.now();
|
||||
let result: unknown = context;
|
||||
let nodeInput: unknown = context;
|
||||
|
||||
try {
|
||||
switch (node.type) {
|
||||
case 'Input':
|
||||
result = node.data ?? context;
|
||||
nodeInput = result;
|
||||
break;
|
||||
|
||||
case 'Component': {
|
||||
if (!node.componentId) throw new Error(`節點 ${node.id} 缺少 componentId`);
|
||||
const runner = await this.loader(node.componentId);
|
||||
const ctx = context as Record<string, unknown>;
|
||||
// node.data 的 string 值支援 {{variable}} 替換(從 context 取值)
|
||||
const resolvedData = interpolateData(node.data, ctx);
|
||||
// 優先順序:node.data(靜態參數,如 pattern/sheet)> context(全局參數)
|
||||
let mergedContext: Record<string, unknown> = {
|
||||
...ctx,
|
||||
...resolvedData,
|
||||
};
|
||||
|
||||
// Resumable workflow callback_url 注入(SDD: resumable-workflow/design.md §2.2)
|
||||
// claude_api 容器拿到後會透傳給 Mira daemon,daemon task 完成時 POST 進來
|
||||
// hostname 暫從 PUBLIC_BASE_URL 取,沒設則用 cypher.arcrun.dev 預設
|
||||
if (node.componentId === 'claude_api') {
|
||||
const baseUrl = (this.env as { PUBLIC_BASE_URL?: string } | undefined)?.PUBLIC_BASE_URL
|
||||
?? 'https://cypher.arcrun.dev';
|
||||
mergedContext.callback_url = `${baseUrl.replace(/\/$/, '')}/workflows/resume`;
|
||||
}
|
||||
|
||||
// Recipe expansion:若 node.data.recipe 存在,展開成實際 prompt 並併進 mergedContext
|
||||
// SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.2
|
||||
if (typeof resolvedData.recipe === 'string' && this.env?.RECIPES) {
|
||||
try {
|
||||
const expanded = await expandPromptRecipe(
|
||||
resolvedData.recipe,
|
||||
ctx,
|
||||
this.env as { RECIPES: { get: (k: string) => Promise<string | null> }; KBDB_BASE_URL?: string },
|
||||
this.apiKey ?? '',
|
||||
);
|
||||
mergedContext = {
|
||||
...mergedContext,
|
||||
prompt: expanded.prompt,
|
||||
model: expanded.model,
|
||||
_recipe_output_format: expanded.output_format,
|
||||
_recipe_output_required_fields: expanded.output_required_fields,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(`recipe 展開失敗 (${resolvedData.recipe}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token
|
||||
if (this.env) {
|
||||
// 先試 auth dispatcher(新路徑,走 auth primitive WASM Worker via HTTP)
|
||||
// 命中才 return;否則 fallback 到舊 injectCredentials(Phase 1.9 會刪除)
|
||||
if (this.apiKey) {
|
||||
const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey);
|
||||
if (dispatched) {
|
||||
mergedContext = dispatched;
|
||||
} else {
|
||||
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey);
|
||||
}
|
||||
} else {
|
||||
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
nodeInput = mergedContext;
|
||||
result = await runner(mergedContext);
|
||||
|
||||
// Resumable workflow:偵測 pending,持久化 paused state 後 throw WorkflowPaused
|
||||
// SDD: resumable-workflow/design.md §3.2.1
|
||||
// 注意:放在 recipe output parsing 之前 — pending 結果不該被當 JSON 解析
|
||||
const pending = isResumablePending(result);
|
||||
if (pending && this.env?.EXEC_CONTEXT && this.currentGraph && this.currentRunId) {
|
||||
// 把這個節點的執行紀錄寫進 trace(status=paused)
|
||||
trace.push({
|
||||
nodeId: node.id,
|
||||
type: node.type,
|
||||
input: nodeInput,
|
||||
output: result,
|
||||
duration_ms: Date.now() - start,
|
||||
});
|
||||
await persistPausedRun(this.env.EXEC_CONTEXT, pending.task_id, {
|
||||
run_id: this.currentRunId,
|
||||
graph: this.currentGraph,
|
||||
paused_node_id: node.id,
|
||||
paused_context: context as Record<string, unknown>,
|
||||
paused_pending_result: result as Record<string, unknown>,
|
||||
trace_so_far: trace,
|
||||
api_key: this.apiKey,
|
||||
expires_at: Date.now() + 24 * 60 * 60 * 1000,
|
||||
recipe_output_format: mergedContext._recipe_output_format as 'text' | 'json' | undefined,
|
||||
recipe_output_required_fields: mergedContext._recipe_output_required_fields as string[] | undefined,
|
||||
});
|
||||
throw new WorkflowPaused(pending.task_id, this.currentRunId, node.id, trace);
|
||||
}
|
||||
|
||||
// Recipe output parsing:用 parseRecipeOutput 統一處理(立刻回 + resume 長回兩條路共用)
|
||||
// SDD: recipe-system + resumable-workflow
|
||||
// 解完後 parsed JSON 的 top-level 欄位(如 paragraphs / triplets)spread 到 result,
|
||||
// 讓下游 FOREACH 跟 {{var}} 模板直接可取
|
||||
result = parseRecipeOutput(
|
||||
result,
|
||||
mergedContext._recipe_output_format as 'text' | 'json' | undefined,
|
||||
mergedContext._recipe_output_required_fields as string[] | undefined,
|
||||
);
|
||||
|
||||
// BUILD-006:將節點 output 存入 KV(key = {run_id}:node:{node_id})
|
||||
// 這讓下游節點可以透過 KV 讀取上游的具名 output,解決同名欄位衝突
|
||||
if (kvStore && result !== null && result !== undefined) {
|
||||
await kvSetNodeOutput(kvStore, node.id, result);
|
||||
}
|
||||
|
||||
// Phase 2:記錄 component 被引用(追蹤生命週期)
|
||||
// 由 component-registry 追蹤使用狀態,決定是否保留
|
||||
// 在後台執行,不阻擋主流程
|
||||
void this.recordComponentReference?.(node.componentId, graph.id).catch(() => {
|
||||
// 記錄失敗不應該中止執行
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Output':
|
||||
result = context;
|
||||
break;
|
||||
}
|
||||
} catch (e: any) {
|
||||
// WorkflowPaused 不是錯誤,是「workflow 暫停」訊號,直接往上傳
|
||||
// SDD: resumable-workflow/design.md
|
||||
if (e instanceof WorkflowPaused) throw e;
|
||||
|
||||
const errMsg = e.message || String(e);
|
||||
const duration_ms = Date.now() - start;
|
||||
trace.push({
|
||||
nodeId: node.id,
|
||||
type: node.type,
|
||||
input: nodeInput,
|
||||
output: null,
|
||||
error: errMsg,
|
||||
duration_ms,
|
||||
});
|
||||
// Step-level telemetry:node 失敗事件(LI SDD M2.x 自評建議)
|
||||
if (this.env && node.type === 'Component') {
|
||||
recordTelemetry(this.env, this.apiKey, {
|
||||
event_type: 'node_failure',
|
||||
workflow_name: graph.name,
|
||||
component_id: node.componentId,
|
||||
error_code: 'node_error',
|
||||
duration_ms,
|
||||
});
|
||||
}
|
||||
// 若已是 ExecutionError(上游節點拋出),保留原始 trace 繼續往上傳
|
||||
if (e instanceof ExecutionError) throw e;
|
||||
throw new ExecutionError(
|
||||
`Node ${node.id} failed: ${errMsg}`,
|
||||
node.id,
|
||||
nodeInput,
|
||||
trace,
|
||||
);
|
||||
}
|
||||
|
||||
const duration_ms = Date.now() - start;
|
||||
trace.push({
|
||||
nodeId: node.id,
|
||||
type: node.type,
|
||||
input: nodeInput,
|
||||
output: result,
|
||||
duration_ms,
|
||||
});
|
||||
|
||||
// Step-level telemetry:node 成功事件(只記 Component,Input/Output 跳過)
|
||||
// LI SDD M2.x:給 weekly_review 提的「效能基準線」建議用 — 每個 node duration 都可追
|
||||
if (this.env && node.type === 'Component') {
|
||||
recordTelemetry(this.env, this.apiKey, {
|
||||
event_type: 'node_success',
|
||||
workflow_name: graph.name,
|
||||
component_id: node.componentId,
|
||||
duration_ms,
|
||||
});
|
||||
}
|
||||
|
||||
// 處理出邊
|
||||
const outEdges = graph.edges.filter(e => e.from === node.id);
|
||||
|
||||
for (const edge of outEdges) {
|
||||
const nextNode = graph.nodes.find(n => n.id === edge.to);
|
||||
if (!nextNode) continue;
|
||||
|
||||
switch (edge.type as EdgeType) {
|
||||
case 'PIPE': {
|
||||
const pipeContext: Record<string, unknown> = propagateCtx(context, result, node.id);
|
||||
|
||||
if (kvStore) {
|
||||
const kvOutput = await kvGetNodeOutput(kvStore, node.id);
|
||||
if (kvOutput !== undefined) {
|
||||
if (!pipeContext._kv_outputs) pipeContext._kv_outputs = {};
|
||||
(pipeContext._kv_outputs as Record<string, unknown>)[node.id] = kvOutput;
|
||||
}
|
||||
}
|
||||
|
||||
const fanInState = fanIn.get(nextNode.id);
|
||||
if (fanInState) {
|
||||
Object.assign(fanInState.ctx, pipeContext);
|
||||
fanInState.remaining--;
|
||||
if (fanInState.remaining === 0) {
|
||||
result = await this.executeNode(nextNode, graph, fanInState.ctx, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
} else {
|
||||
result = await this.executeNode(nextNode, graph, pipeContext, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ON_SUCCESS': {
|
||||
if (!isFailure(result)) {
|
||||
const mergedCtx = propagateCtx(context, result, node.id);
|
||||
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ON_FAIL': {
|
||||
if (isFailure(result)) {
|
||||
const mergedCtx = propagateCtx(context, result, node.id);
|
||||
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'IF': {
|
||||
const passes = evaluateCondition(edge.condition ?? 'true', result);
|
||||
if (passes) {
|
||||
const mergedCtx = propagateCtx(context, result, node.id);
|
||||
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'FOREACH': {
|
||||
const iteratorKey = edge.iterator ?? 'item';
|
||||
// 找 iterable 順序:先看上游 output (result),沒有再看完整 context (含上游 chain 累積的 fields)
|
||||
// 2026-05-13:原本只看 result,但 result 是當前節點 output (如 create_wiki_page 只回 {data, success})
|
||||
// 不含更上游節點給的 paragraphs。propagateCtx 已把 paragraphs spread 進 ctx,FOREACH 該能取到
|
||||
let items = getIterableFromContext(result, iteratorKey);
|
||||
if (items.length === 0) {
|
||||
items = getIterableFromContext(context, iteratorKey);
|
||||
}
|
||||
const iterResults: unknown[] = [];
|
||||
|
||||
// FOREACH itemContext 順序:propagateCtx + 加 iterator key
|
||||
const baseForeachCtx = propagateCtx(context, result, node.id);
|
||||
for (const item of items) {
|
||||
const itemContext = {
|
||||
...baseForeachCtx,
|
||||
[iteratorKey]: item,
|
||||
};
|
||||
const itemResult = await this.executeNode(nextNode, graph, itemContext, new Set(), trace, fanIn, kvStore);
|
||||
iterResults.push(itemResult);
|
||||
}
|
||||
|
||||
result = { ...(result as Record<string, unknown>), results: iterResults };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CALLS_SUBFLOW': {
|
||||
// 從 workflowLoader 載入子 Workflow,以當前 context 執行,輸出合併回主流程
|
||||
const subWorkflowId = nextNode.componentId?.replace('workflow://', '') ?? nextNode.id;
|
||||
if (this.workflowLoader) {
|
||||
const subGraph = await this.workflowLoader(subWorkflowId);
|
||||
const subExecutor = new GraphExecutor(this.loader, this.workflowLoader);
|
||||
const subResult = await subExecutor.execute(
|
||||
subGraph,
|
||||
result as Record<string, unknown>,
|
||||
kvStore?.kv,
|
||||
);
|
||||
result = {
|
||||
...(result as Record<string, unknown>),
|
||||
...(subResult.data as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ON_CLICK': {
|
||||
const mergedCtx = propagateCtx(context, result, node.id);
|
||||
result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'IS_A': {
|
||||
// 節點類型宣告:記錄 componentId,不執行
|
||||
// IS_A 邊的 to 是零件 URI(如 component://validate_json)
|
||||
// 這個資訊已在 graph-builder 階段處理,執行時不需要額外動作
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CONTAINS':
|
||||
case 'HAS_STYLE':
|
||||
case 'HAS_BEHAVIOR': {
|
||||
// 結構語意:只記錄圖結構,不執行
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CONTINUE':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** 給下游節點組 ctx:merge 原 context + 上游 output (spread) + 上游 output 用 node id namespace
|
||||
* 讓下游能用:
|
||||
* {{api_key}}(global,從 baseCtx)
|
||||
* {{data.text}}(上一節點 output spread 進來,會被下下個節點覆蓋)
|
||||
* {{classify.data.text}}(指名某節點 output,永不被覆蓋因 node id 唯一)
|
||||
* 優先順位:baseCtx(含先前 node namespace)< 上游 output spread < 當前 node namespace
|
||||
*/
|
||||
function propagateCtx(
|
||||
context: unknown,
|
||||
upstreamResult: unknown,
|
||||
upstreamNodeId: string,
|
||||
): Record<string, unknown> {
|
||||
const baseCtx = (typeof context === 'object' && context !== null) ? context as Record<string, unknown> : {};
|
||||
const baseResult = (typeof upstreamResult === 'object' && upstreamResult !== null) ? upstreamResult as Record<string, unknown> : {};
|
||||
return {
|
||||
...baseCtx,
|
||||
...baseResult,
|
||||
[upstreamNodeId]: upstreamResult,
|
||||
};
|
||||
}
|
||||
|
||||
/** node.data 內所有 string 值(含 nested object / array)支援 {{variable}} 替換
|
||||
* 支援嵌套 path:{{item.content}} → ctx.item.content
|
||||
* 支援 array index:{{paragraphs.0.entity}} → ctx.paragraphs[0].entity
|
||||
* 非 string 值(object/array)遞迴展開內部 string;undefined / null / number / bool 保留原值
|
||||
* 2026-05-13 加遞迴:原本只跑 top-level,set 零件 values 嵌套 / 任何零件 body 內含 {{x.y}} 用不了。
|
||||
* 2026-05-14 加 single-ref pass-through:若整個 string 是 `{{x}}` 且 x 是 array / object,
|
||||
* 回 raw value 不 stringify(讓 filter `items: "{{list.blocks}}"` 能拿到真陣列)。
|
||||
* 多 ref 或混合文字仍 stringify 為字串。
|
||||
*/
|
||||
function interpolateString(s: string, ctx: Record<string, unknown>): unknown {
|
||||
// 整個值是單一 {{x}} 引用 → 回 raw value(保留 array / object 型別)
|
||||
const single = s.match(/^\s*\{\{([\w.]+)\}\}\s*$/);
|
||||
if (single) {
|
||||
const val = getNestedValue(ctx, single[1]);
|
||||
return val === undefined ? s : val;
|
||||
}
|
||||
// 多 ref / 混合文字 → 一律拼成 string
|
||||
return s.replace(/\{\{([\w.]+)\}\}/g, (_, key: string) => {
|
||||
const val = getNestedValue(ctx, key);
|
||||
if (val === undefined) return `{{${key}}}`;
|
||||
if (typeof val === 'string') return val;
|
||||
return JSON.stringify(val);
|
||||
});
|
||||
}
|
||||
|
||||
function interpolateValue(v: unknown, ctx: Record<string, unknown>): unknown {
|
||||
if (typeof v === 'string') return interpolateString(v, ctx);
|
||||
if (Array.isArray(v)) return v.map(item => interpolateValue(item, ctx));
|
||||
if (v !== null && typeof v === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||
result[k] = interpolateValue(val, ctx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function interpolateData(
|
||||
data: Record<string, unknown> | undefined,
|
||||
ctx: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!data) return {};
|
||||
return interpolateValue(data, ctx) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 從 ctx 用 dot path 取嵌套值:'a.b.0.c' → ctx.a.b[0].c */
|
||||
function getNestedValue(ctx: 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;
|
||||
}
|
||||
|
||||
/** 判斷節點執行結果是否為失敗:success === false 或含有 error key */
|
||||
function isFailure(result: unknown): boolean {
|
||||
if (!result || typeof result !== 'object') return false;
|
||||
const r = result as Record<string, unknown>;
|
||||
return r['success'] === false || 'error' in r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全條件評估(不使用 new Function)
|
||||
* 支援格式:ctx.key === value, ctx.key > value, ctx.key(truthy)
|
||||
*/
|
||||
function evaluateCondition(condition: string, context: unknown): boolean {
|
||||
if (!context || typeof context !== 'object') return false;
|
||||
const ctx = context as Record<string, unknown>;
|
||||
|
||||
// 正規化:把 result. 替換為空(直接存取 key)
|
||||
const expr = condition.replace(/result\./g, '').replace(/ctx\./g, '');
|
||||
|
||||
// 簡單 === 比較
|
||||
const eqMatch = expr.match(/^(\w+)\s*===?\s*(.+)$/);
|
||||
if (eqMatch) {
|
||||
const key = eqMatch[1];
|
||||
const rawVal = eqMatch[2].trim();
|
||||
const expected = rawVal === 'true' ? true : rawVal === 'false' ? false : rawVal.replace(/['"]/g, '');
|
||||
return ctx[key] === expected;
|
||||
}
|
||||
|
||||
// 簡單 > 比較
|
||||
const gtMatch = expr.match(/^(\w+)\s*>\s*(\d+)$/);
|
||||
if (gtMatch) {
|
||||
return Number(ctx[gtMatch[1]]) > Number(gtMatch[2]);
|
||||
}
|
||||
|
||||
// truthy check
|
||||
const key = expr.trim();
|
||||
if (key && key in ctx) return !!ctx[key];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getIterableFromContext(context: unknown, key: string): unknown[] {
|
||||
if (!context || typeof context !== 'object') return [];
|
||||
// 多種 plural 變體:entity → entities / paragraph → paragraphs / box → boxes / 等
|
||||
// 2026-05-17:原本只試 key+'s','entity+s=entitys' ≠ 'entities' 無法命中,加 irregular
|
||||
const variants = [
|
||||
key + 's', // paragraph → paragraphs
|
||||
key.replace(/y$/, 'ies'), // entity → entities
|
||||
key.replace(/(s|x|z|ch|sh)$/, '$1es'), // box → boxes
|
||||
key, // singular fallback
|
||||
];
|
||||
const obj = context as Record<string, unknown>;
|
||||
|
||||
// 先看 top-level(最常見)
|
||||
for (const v of variants) {
|
||||
if (Array.isArray(obj[v])) return obj[v] as unknown[];
|
||||
}
|
||||
|
||||
// 若找不到,掃一層內部 object 看 nested(巢狀 FOREACH 場景:
|
||||
// 外層 FOREACH 把 paragraph 注入 ctx,內層 FOREACH 要找 paragraph.triplets)
|
||||
for (const val of Object.values(obj)) {
|
||||
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
|
||||
for (const v of variants) {
|
||||
const nested = (val as Record<string, unknown>)[v];
|
||||
if (Array.isArray(nested)) return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// arcrun cypher-executor Worker — AI 工作流執行引擎
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { ExecutionContext } from '@cloudflare/workers-types';
|
||||
import type { Bindings } from './types';
|
||||
import { handleScheduled } from './scheduled';
|
||||
import { healthRouter } from './routes/health';
|
||||
import { executeRouter } from './routes/execute';
|
||||
import { cypherRouter } from './routes/cypher';
|
||||
import { validateRouter } from './routes/validate';
|
||||
import { docsRouter } from './routes/docs';
|
||||
import { webhooksRouter } from './routes/webhooks';
|
||||
import { webhooksCrudRouter } from './routes/webhooks-crud';
|
||||
import { webhooksListRouter } from './routes/webhooks-list';
|
||||
import { registerRouter } from './routes/register';
|
||||
import { recipesRouter } from './routes/recipes';
|
||||
import { credentialsRouter } from './routes/credentials';
|
||||
import { webhooksNamedRouter } from './routes/webhooks-named';
|
||||
import { authRouter } from './routes/auth';
|
||||
import { resumeRouter } from './routes/resume';
|
||||
import { executionsRouter } from './routes/executions';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// 全域 CORS(允許 arcrun.dev landing page 帶 credentials 存取)
|
||||
app.use('*', cors({
|
||||
origin: ['https://arcrun.dev', 'https://www.arcrun.dev'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization', 'X-Arcrun-API-Key'],
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// 掛載所有路由器
|
||||
app.route('/', docsRouter);
|
||||
app.route('/', healthRouter);
|
||||
app.route('/', executeRouter);
|
||||
app.route('/', cypherRouter);
|
||||
app.route('/', validateRouter);
|
||||
app.route('/', webhooksRouter);
|
||||
app.route('/', webhooksNamedRouter); // 必須在 webhooksCrudRouter 前(避免 /webhooks/:token 攔截 /webhooks/named)
|
||||
app.route('/', webhooksCrudRouter);
|
||||
app.route('/', webhooksListRouter);
|
||||
app.route('/', registerRouter);
|
||||
app.route('/', recipesRouter);
|
||||
app.route('/', credentialsRouter);
|
||||
app.route('/', authRouter);
|
||||
app.route('/', resumeRouter);
|
||||
app.route('/', executionsRouter); // LI SDD M2.1: /executions/* + /workflows/:name/executions
|
||||
|
||||
// Worker 導出(fetch + scheduled)
|
||||
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick;
|
||||
// 邏輯在 src/scheduled.ts。對應 SDD: arcrun.md 三-A P1 #3。
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
scheduled: handleScheduled,
|
||||
} satisfies ExportedHandler<Bindings>;
|
||||
@@ -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 ID(URL 中的 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 / lists(API 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,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* arcrun component loader
|
||||
*
|
||||
* 解析優先序:
|
||||
*
|
||||
* 0. trigger_workflow 內建 orchestration 零件(in-process call,繞 CF self-fetch 死鎖)
|
||||
* 1. 內建零件(BUILTIN_COMPONENTS)— 純 JS,最快
|
||||
* 2. 外部 URL(https://...)— 直接 fetch,n8n/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 runner(auth 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 runner:canonical_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 已降級為 recipe(2026-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.dev:cypher-executor 本身綁 cypher.arcrun.dev/*,
|
||||
* fetch 同 zone *.arcrun.dev 會撞 CF 的 zone 自循環防護回 522。
|
||||
* 詳見 arcrun.md P0 #9(2026-05-13)。
|
||||
*
|
||||
* subdomain 來自 wrangler.toml [vars] WORKER_SUBDOMAIN(預設 uncle6-me,self-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_IDS(http_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` +
|
||||
`或傳入外部 URL(https://...)、recipe hash(rec_xxxxxxxx)、零件 hash(cmp_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.dev,Worker → 自身 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 (預設 true,await 完成;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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ComponentRunner, EdgeType } from '../types';
|
||||
|
||||
export const VALID_EDGE_TYPES = new Set([
|
||||
// 現有
|
||||
'PIPE', 'IF', 'FOREACH', 'CONTINUE',
|
||||
// 新增:執行語意
|
||||
'IS_A', 'ON_SUCCESS', 'ON_FAIL',
|
||||
// 新增:觸發語意
|
||||
'ON_CLICK', 'CALLS_SUBFLOW',
|
||||
// 新增:結構語意(記錄圖結構,不執行)
|
||||
'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR',
|
||||
]);
|
||||
|
||||
/** 內建零件 ID 集合(不需要查 WASM_BUCKET,Worker 記憶體中已有實作)*/
|
||||
export const BUILTIN_IDS = new Set([
|
||||
'webhook', 'comp_passthrough', 'comp_uppercase', 'comp_counter',
|
||||
]);
|
||||
|
||||
/** 語意邊 → EdgeType 映射(ADR-057 u6u L1:支援中文語意關係詞)
|
||||
* 完成後 → PIPE(成功後觸發下一個)
|
||||
* 失敗時 → CONTINUE(失敗後繼續)
|
||||
* 對每個 → FOREACH(迭代執行)
|
||||
* 條件滿足時 → IF(條件分支)
|
||||
*/
|
||||
export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
|
||||
// 中文語意詞
|
||||
'完成後': 'PIPE',
|
||||
'失敗時': 'ON_FAIL',
|
||||
'對每個': 'FOREACH',
|
||||
'條件滿足時': 'IF',
|
||||
// 英文別名
|
||||
'SUCCESS': 'ON_SUCCESS',
|
||||
'FAIL': 'ON_FAIL',
|
||||
'CLICK': 'ON_CLICK',
|
||||
'SUBFLOW': 'CALLS_SUBFLOW',
|
||||
};
|
||||
|
||||
/**
|
||||
* 內建零件表(靜態函數,不需要 R2)
|
||||
* WASM 零件從 WASM_BUCKET R2 直接讀取
|
||||
*/
|
||||
export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
|
||||
['comp_passthrough', (ctx) => ctx],
|
||||
['comp_uppercase', (ctx) => {
|
||||
const c = ctx as Record<string, unknown>;
|
||||
return { ...c, text: String(c.text || '').toUpperCase() };
|
||||
}],
|
||||
['comp_counter', (ctx) => {
|
||||
const c = ctx as Record<string, unknown>;
|
||||
return { ...c, count: (Number(c.count) || 0) + 1 };
|
||||
}],
|
||||
]);
|
||||
|
||||
export const SCORE_THRESHOLD = 0.5;
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 最小 cron expression matcher:5 欄位(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 欄位 cron(minute 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;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 穩定 ID 衍生工具
|
||||
*
|
||||
* 邏輯零件: cmp_<sha256(canonical_id)[:8]>
|
||||
* API recipe:rec_<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);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Magic vars — workflow YAML 內建變數
|
||||
*
|
||||
* 對應 LI SDD M2.x improvement(feedback 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,
|
||||
|
||||
// 簡單時間 slot(cron-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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
export const OPENAPI_SPEC = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'arcrun cypher-executor API',
|
||||
description: 'AI Workflow Execution Engine — 透過三元組 Triplet 或圖 Graph 定義工作流,系統執行並回傳結果',
|
||||
version: '1.0.0',
|
||||
contact: {
|
||||
name: 'arcrun',
|
||||
url: 'https://github.com/arcrun/arcrun',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{ url: 'https://cypher.arcrun.dev', description: 'arcrun.dev Hosted' },
|
||||
{ url: 'http://localhost:8787', description: 'Local Development' },
|
||||
],
|
||||
paths: {
|
||||
'/': {
|
||||
get: {
|
||||
summary: 'Health Check',
|
||||
tags: ['Health'],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Service is running',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
service: { type: 'string' },
|
||||
version: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/cypher/search': {
|
||||
post: {
|
||||
summary: '搜尋工作流需要的零件',
|
||||
tags: ['Cypher'],
|
||||
description: '用三元組描述工作流,系統解析並從 Registry 查詢對應零件',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triplets: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['start >> 完成後 >> get-data', 'get-data >> 完成後 >> done'],
|
||||
description: '三元組陣列,格式:\"FROM >> ACTION >> TO\"',
|
||||
},
|
||||
auto_publish: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '缺失的零件是否自動產生發佈',
|
||||
},
|
||||
},
|
||||
required: ['triplets'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: '零件搜尋成功(含版本號和時戳,適合 Markdown 文檔追蹤)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: 'search-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp})' },
|
||||
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
|
||||
triplets: { type: 'array', items: { type: 'string' }, description: '回送的三元組列表' },
|
||||
nodes: { type: 'object', description: '搜尋到的零件及其狀態' },
|
||||
cypher: { type: 'object', description: '工作流圖(null 若有缺失零件)' },
|
||||
missing: { type: 'array', items: { type: 'string' }, description: '缺失零件列表' },
|
||||
auto_published: { type: 'object', description: '自動發佈的零件(若 auto_publish=true)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'400': { description: '無法解析三元組' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/cypher/execute': {
|
||||
post: {
|
||||
summary: '執行工作流',
|
||||
tags: ['Cypher'],
|
||||
description: '直接執行 triplets,回傳完整執行結果。支援自動發佈缺失零件。',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triplets: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: '三元組陣列,格式:"FROM >> ACTION >> TO"',
|
||||
},
|
||||
context: {
|
||||
type: 'object',
|
||||
description: '執行上下文,傳入各節點作為初始參數',
|
||||
},
|
||||
auto_publish: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '缺失的零件是否自動產生臨時實作',
|
||||
},
|
||||
},
|
||||
required: ['triplets'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: '執行成功(含版本號和時戳)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: 'execute-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp})' },
|
||||
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
data: { type: 'object', description: '執行結果' },
|
||||
trace: { type: 'array', description: '執行跟蹤' },
|
||||
duration_ms: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'500': {
|
||||
description: '執行失敗或部份零件缺失(含版本號和時戳)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: 'execute-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp})' },
|
||||
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
|
||||
success: { type: 'boolean', enum: [false] },
|
||||
error: { type: 'string' },
|
||||
missing: { type: 'array', items: { type: 'string' }, description: '無法自動發佈的缺失零件' },
|
||||
auto_published: {
|
||||
type: 'object',
|
||||
description: '自動發佈的零件資訊',
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
componentId: { type: 'string' },
|
||||
temporary_endpoint: { type: 'string', format: 'uri', description: '臨時實作的 URL' },
|
||||
implement_by: { type: 'string', format: 'date-time', description: '實作截止時間' },
|
||||
},
|
||||
},
|
||||
},
|
||||
duration_ms: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/webhooks': {
|
||||
post: {
|
||||
summary: '建立 Webhook',
|
||||
tags: ['Webhooks'],
|
||||
description: '將工作流註冊成 Webhook,得到公開 URL',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triplets: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
description: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'201': {
|
||||
description: 'Webhook 建立成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
webhook_url: { type: 'string', format: 'uri' },
|
||||
description: { type: 'string' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
get: {
|
||||
summary: '列出所有 Webhooks',
|
||||
tags: ['Webhooks'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'Authorization',
|
||||
in: 'header',
|
||||
required: true,
|
||||
schema: { type: 'string', example: 'Bearer u6u_xxxxx' },
|
||||
description: 'API Key 認證',
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Webhooks 列表',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
webhooks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
total: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': { description: '未授權' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/webhooks/{token}': {
|
||||
get: {
|
||||
summary: '查詢單個 Webhook',
|
||||
tags: ['Webhooks'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'token',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Webhook 資訊',
|
||||
},
|
||||
'404': { description: 'Webhook 不存在' },
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
summary: '刪除 Webhook',
|
||||
tags: ['Webhooks'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'token',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': { description: 'Webhook 已刪除' },
|
||||
'404': { description: 'Webhook 不存在' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
ApiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'Authorization',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,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 當 key(daemon 派的 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
|
||||
* consistent,30-60s 延遲)。改維護一個 user-keyed JSON list,list 操作改 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 fence(Claude 常這樣包)
|
||||
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-level,FOREACH / 下游 {{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>;
|
||||
@@ -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 prompt(system + 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 };
|
||||
@@ -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 value:JSON 字串(不用 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);
|
||||
}
|
||||
@@ -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({}),
|
||||
});
|
||||
@@ -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 也接受
|
||||
}
|
||||
@@ -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.Memory(instantiate 後呼叫) */
|
||||
setMemory(memory: WebAssembly.Memory): void;
|
||||
/**
|
||||
* 執行 WASM _start,自動使用 WebAssembly.promising(JSPI)讓 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 buffer(host 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 陣列的資料寫入 fd(stdout=1 或 stderr=2)
|
||||
* iovec 結構:{ buf: i32, buf_len: i32 }(各 4 bytes,little-endian)
|
||||
*/
|
||||
function fd_write(fd: number, iovs: number, iovs_len: number, nwritten_ptr: number): number {
|
||||
if (fd !== FD_STDOUT && fd !== FD_STDERR) return WASI_ENOSYS;
|
||||
const view = getMemoryView();
|
||||
const buf = memory!.buffer;
|
||||
let totalWritten = 0;
|
||||
|
||||
for (let i = 0; i < iovs_len; i++) {
|
||||
const iov_base = view.getUint32(iovs + i * 8, true);
|
||||
const iov_len = view.getUint32(iovs + i * 8 + 4, true);
|
||||
if (iov_len === 0) continue;
|
||||
const chunk = new Uint8Array(buf, iov_base, iov_len);
|
||||
const copy = new Uint8Array(iov_len);
|
||||
copy.set(chunk);
|
||||
if (fd === FD_STDOUT) stdoutChunks.push(copy);
|
||||
else stderrChunks.push(copy);
|
||||
totalWritten += iov_len;
|
||||
}
|
||||
|
||||
view.setUint32(nwritten_ptr, totalWritten, true);
|
||||
return WASI_ESUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* fd_read: 從 stdin 讀取資料到 iovec 陣列
|
||||
*/
|
||||
function fd_read(fd: number, iovs: number, iovs_len: number, nread_ptr: number): number {
|
||||
if (fd !== FD_STDIN) return WASI_ENOSYS;
|
||||
const view = getMemoryView();
|
||||
const buf = memory!.buffer;
|
||||
let totalRead = 0;
|
||||
|
||||
for (let i = 0; i < iovs_len; i++) {
|
||||
const iov_base = view.getUint32(iovs + i * 8, true);
|
||||
const iov_len = view.getUint32(iovs + i * 8 + 4, true);
|
||||
if (iov_len === 0) continue;
|
||||
|
||||
const remaining = stdinBytes.length - stdinOffset;
|
||||
if (remaining <= 0) break;
|
||||
|
||||
const toCopy = Math.min(iov_len, remaining);
|
||||
const dest = new Uint8Array(buf, iov_base, toCopy);
|
||||
dest.set(stdinBytes.subarray(stdinOffset, stdinOffset + toCopy));
|
||||
stdinOffset += toCopy;
|
||||
totalRead += toCopy;
|
||||
}
|
||||
|
||||
view.setUint32(nread_ptr, totalRead, true);
|
||||
return WASI_ESUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* proc_exit: 零件呼叫 exit(),拋出 Error 中止執行
|
||||
*/
|
||||
function proc_exit(code: number): never {
|
||||
throw new Error(`wasm exit: ${code}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* random_get: 填充隨機 bytes(使用 Web Crypto API)
|
||||
*/
|
||||
function random_get(buf_ptr: number, buf_len: number): number {
|
||||
const view = new Uint8Array(memory!.buffer, buf_ptr, buf_len);
|
||||
crypto.getRandomValues(view);
|
||||
return WASI_ESUCCESS;
|
||||
}
|
||||
|
||||
// ── 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=1(Unwinding): 正在展開,host 應直接回傳 0(佔位值)
|
||||
// - state=2(Rewinding): 正在恢復,host 應回傳上一次 async 結果(已存在 asyncifyResult)
|
||||
// - state=0(Normal): 正常執行,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() == 0(Normal)。
|
||||
|
||||
// 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 exports(run() 設定後才可用)
|
||||
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_unwind;Rewinding 時回傳已存的結果
|
||||
// 用於 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 中:直接回傳 0(WASM 在 unwind,不使用此值)
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Normal(state=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
|
||||
// 初始化時先用 asyncifyWrap,run() 後若沒有 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);
|
||||
}
|
||||
|
||||
// fallback:asyncify 協議(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.buffer(grow 會產生新的 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;
|
||||
|
||||
// 若環境支援 JSPI(Cloudflare 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. 未知前綴回傳 null(WASM 收到 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 runner(component-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('');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// arcrun OAuth 登入路由
|
||||
// GET /auth/google/start → redirect to Google OAuth
|
||||
// GET /auth/github/start → redirect to GitHub OAuth
|
||||
// GET /auth/callback → exchange code, create session
|
||||
// POST /auth/logout → clear session cookie
|
||||
// GET /me → current user info
|
||||
// PUT /me/api-key/rotate → generate new api key
|
||||
// DELETE /me/api-key → revoke api key
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const authRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type UserRecord = {
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
api_key: string;
|
||||
provider: 'google' | 'github';
|
||||
provider_id: string;
|
||||
created_at: string;
|
||||
revoked?: boolean;
|
||||
};
|
||||
|
||||
type SessionRecord = {
|
||||
user_key: string; // "user:{provider}:{provider_id}"
|
||||
api_key: string;
|
||||
email: string;
|
||||
expires_at: number; // unix timestamp ms
|
||||
};
|
||||
|
||||
type OAuthStateRecord = {
|
||||
provider: 'google' | 'github';
|
||||
redirect_back: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getLandingOrigin(c: { req: { raw: Request } }): string {
|
||||
const origin = c.req.raw.headers.get('origin');
|
||||
// 允許的 landing origins
|
||||
const allowed = ['https://arcrun.dev', 'https://www.arcrun.dev'];
|
||||
if (origin && allowed.includes(origin)) return origin;
|
||||
return 'https://arcrun.dev';
|
||||
}
|
||||
|
||||
/** 產生 API Key(HMAC-SHA256 of email,與 /register 相同邏輯) */
|
||||
async function generateApiKey(email: string, encryptionKey: string): Promise<string> {
|
||||
const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32));
|
||||
const msgData = new TextEncoder().encode(email);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
|
||||
);
|
||||
const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
|
||||
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return 'ak_' + hex.slice(0, 32);
|
||||
}
|
||||
|
||||
/** AES-GCM 加密,回傳 {encrypted, iv}(base64),與 SDK 格式相同 */
|
||||
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: 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, new TextEncoder().encode(plaintext));
|
||||
const toB64 = (buf: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
|
||||
return { encrypted: toB64(enc), iv: toB64(iv) };
|
||||
}
|
||||
|
||||
/** 幂等寫入 auth_recipe 到 RECIPES KV(若已存在相同版本則跳過) */
|
||||
async function upsertAuthRecipe(recipes: KVNamespace, recipe: Record<string, unknown>): Promise<void> {
|
||||
const key = `auth_recipe:${recipe.service}`;
|
||||
const existing = await recipes.get(key);
|
||||
if (existing) return; // 已存在,不覆蓋(用戶可能已自訂)
|
||||
await recipes.put(key, JSON.stringify({ ...recipe, created_at: Date.now(), updated_at: Date.now() }));
|
||||
}
|
||||
|
||||
/** 產生隨機 token(用於 session ID 和 state) */
|
||||
function randomToken(bytes = 32): string {
|
||||
const arr = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** 從 Cookie header 取 session ID */
|
||||
function getSessionId(req: Request): string | null {
|
||||
const cookie = req.headers.get('cookie') ?? '';
|
||||
const match = cookie.match(/arcrun_session=([a-f0-9]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** 從 Request 取 API Key(X-Arcrun-API-Key header 或 Authorization: Bearer) */
|
||||
function getApiKeyFromRequest(req: Request): string | null {
|
||||
const direct = req.headers.get('x-arcrun-api-key');
|
||||
if (direct) return direct;
|
||||
const auth = req.headers.get('authorization') ?? '';
|
||||
const match = auth.match(/^Bearer\s+(ak_\S+)/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** 驗證 session → 回傳 user record,或 null */
|
||||
async function resolveSession(c: { req: { raw: Request }; env: Bindings }): Promise<UserRecord | null> {
|
||||
const sessId = getSessionId(c.req.raw);
|
||||
if (sessId) {
|
||||
const sess = await c.env.SESSIONS_KV.get<SessionRecord>(`sess:${sessId}`, 'json');
|
||||
if (sess && sess.expires_at > Date.now()) {
|
||||
const user = await c.env.USERS_KV.get<UserRecord>(sess.user_key, 'json');
|
||||
if (user && !user.revoked) return user;
|
||||
}
|
||||
}
|
||||
// Fallback: API Key header
|
||||
const apiKey = getApiKeyFromRequest(c.req.raw);
|
||||
if (apiKey) {
|
||||
// 掃描 USERS_KV by api_key 太慢;改用 reverse index: apikey:{ak_...} → user_key
|
||||
const userKey = await c.env.USERS_KV.get(`apikey:${apiKey}`);
|
||||
if (userKey) {
|
||||
const user = await c.env.USERS_KV.get<UserRecord>(userKey, 'json');
|
||||
if (user && !user.revoked && user.api_key === apiKey) return user;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Google OAuth ─────────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.get('/auth/google/start', async (c) => {
|
||||
const clientId = c.env.GOOGLE_CLIENT_ID;
|
||||
if (!clientId) return c.json({ error: 'Google OAuth not configured' }, 503);
|
||||
|
||||
const state = randomToken(16);
|
||||
const stateRecord: OAuthStateRecord = {
|
||||
provider: 'google',
|
||||
redirect_back: c.req.query('redirect') ?? '/dashboard',
|
||||
created_at: Date.now(),
|
||||
};
|
||||
// state TTL = 10 minutes
|
||||
await c.env.SESSIONS_KV.put(`state:${state}`, JSON.stringify(stateRecord), { expirationTtl: 600 });
|
||||
|
||||
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
});
|
||||
|
||||
return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302);
|
||||
});
|
||||
|
||||
// ─── GitHub OAuth ─────────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.get('/auth/github/start', async (c) => {
|
||||
const clientId = c.env.GITHUB_CLIENT_ID;
|
||||
if (!clientId) return c.json({ error: 'GitHub OAuth not configured' }, 503);
|
||||
|
||||
const state = randomToken(16);
|
||||
const stateRecord: OAuthStateRecord = {
|
||||
provider: 'github',
|
||||
redirect_back: c.req.query('redirect') ?? '/dashboard',
|
||||
created_at: Date.now(),
|
||||
};
|
||||
await c.env.SESSIONS_KV.put(`state:${state}`, JSON.stringify(stateRecord), { expirationTtl: 600 });
|
||||
|
||||
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'read:user user:email',
|
||||
state,
|
||||
});
|
||||
|
||||
return Response.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
|
||||
});
|
||||
|
||||
// ─── OAuth Callback ───────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.get('/auth/callback', async (c) => {
|
||||
const code = c.req.query('code');
|
||||
const state = c.req.query('state');
|
||||
const error = c.req.query('error');
|
||||
|
||||
const landingOrigin = getLandingOrigin(c);
|
||||
|
||||
if (error || !code || !state) {
|
||||
return Response.redirect(`${landingOrigin}/login?error=${encodeURIComponent(error ?? 'cancelled')}`, 302);
|
||||
}
|
||||
|
||||
// Validate state
|
||||
const stateRecord = await c.env.SESSIONS_KV.get<OAuthStateRecord>(`state:${state}`, 'json');
|
||||
if (!stateRecord) {
|
||||
return Response.redirect(`${landingOrigin}/login?error=invalid_state`, 302);
|
||||
}
|
||||
await c.env.SESSIONS_KV.delete(`state:${state}`);
|
||||
|
||||
const encryptionKey = c.env.ENCRYPTION_KEY;
|
||||
if (!encryptionKey) {
|
||||
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
|
||||
}
|
||||
|
||||
try {
|
||||
let email: string;
|
||||
let displayName: string;
|
||||
let avatarUrl: string | undefined;
|
||||
let providerId: string;
|
||||
const provider = stateRecord.provider;
|
||||
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
|
||||
|
||||
if (provider === 'google') {
|
||||
// Exchange code for token
|
||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
|
||||
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error('google token exchange failed');
|
||||
const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string };
|
||||
|
||||
// Get user info
|
||||
const userRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||
});
|
||||
if (!userRes.ok) throw new Error('google userinfo failed');
|
||||
const userInfo = await userRes.json() as {
|
||||
sub: string; email: string; name: string; picture?: string;
|
||||
};
|
||||
email = userInfo.email.toLowerCase();
|
||||
displayName = userInfo.name;
|
||||
avatarUrl = userInfo.picture;
|
||||
providerId = userInfo.sub;
|
||||
|
||||
// 存 Google refresh_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
|
||||
// Google 只在首次授權時回傳 refresh_token,後續登入 tokenData.refresh_token 為 undefined
|
||||
if (tokenData.refresh_token) {
|
||||
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:google_refresh_token`;
|
||||
const encrypted = await aesEncrypt(tokenData.refresh_token, encryptionKey);
|
||||
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
|
||||
|
||||
// 種 auth_recipe:google_user(用戶自己的 Google OAuth2)
|
||||
void upsertAuthRecipe(c.env.RECIPES, {
|
||||
kind: 'auth_recipe',
|
||||
service: 'google_user',
|
||||
version: 1,
|
||||
primitive: 'oauth2',
|
||||
base_url: 'https://www.googleapis.com',
|
||||
display_name: 'Google(用戶帳號)',
|
||||
oauth2: {
|
||||
token_endpoint: 'https://oauth2.googleapis.com/token',
|
||||
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
|
||||
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||
scopes: ['https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/spreadsheets'],
|
||||
},
|
||||
required_secrets: [{ key: 'google_refresh_token', label: 'Google Refresh Token' }],
|
||||
inject: { header: { Authorization: 'Bearer {{runtime.access_token}}' } },
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
// GitHub: exchange code for token
|
||||
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: c.env.GITHUB_CLIENT_ID ?? '',
|
||||
client_secret: c.env.GITHUB_CLIENT_SECRET ?? '',
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error('github token exchange failed');
|
||||
const tokenData = await tokenRes.json() as { access_token: string; token_type?: string };
|
||||
|
||||
// Get user info
|
||||
const userRes = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
'User-Agent': 'arcrun',
|
||||
'Accept': 'application/vnd.github+json',
|
||||
},
|
||||
});
|
||||
if (!userRes.ok) throw new Error('github user fetch failed');
|
||||
const userInfo = await userRes.json() as {
|
||||
id: number; login: string; name?: string; avatar_url?: string; email?: string;
|
||||
};
|
||||
|
||||
// GitHub email might be null if private; fetch emails list
|
||||
let ghEmail = userInfo.email ?? '';
|
||||
if (!ghEmail) {
|
||||
const emailsRes = await fetch('https://api.github.com/user/emails', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
'User-Agent': 'arcrun',
|
||||
'Accept': 'application/vnd.github+json',
|
||||
},
|
||||
});
|
||||
if (emailsRes.ok) {
|
||||
const emails = await emailsRes.json() as { email: string; primary: boolean; verified: boolean }[];
|
||||
const primary = emails.find(e => e.primary && e.verified);
|
||||
ghEmail = primary?.email ?? emails[0]?.email ?? '';
|
||||
}
|
||||
}
|
||||
if (!ghEmail) throw new Error('github email not available');
|
||||
email = ghEmail.toLowerCase();
|
||||
displayName = userInfo.name ?? userInfo.login;
|
||||
avatarUrl = userInfo.avatar_url;
|
||||
providerId = String(userInfo.id);
|
||||
|
||||
// 存 GitHub access_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
|
||||
// GitHub 沒有 refresh_token,access_token 長效(直到 revoke)
|
||||
if (tokenData.access_token) {
|
||||
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:github_access_token`;
|
||||
const encrypted = await aesEncrypt(tokenData.access_token, encryptionKey);
|
||||
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
|
||||
|
||||
// GitHub access_token 長效無 refresh 概念,用 static_key primitive
|
||||
void upsertAuthRecipe(c.env.RECIPES, {
|
||||
kind: 'auth_recipe',
|
||||
service: 'github_user',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.github.com',
|
||||
display_name: 'GitHub(用戶帳號)',
|
||||
required_secrets: [{ key: 'github_access_token', label: 'GitHub Access Token' }],
|
||||
inject: { header: { Authorization: 'Bearer {{secret.github_access_token}}' } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert user record
|
||||
const userKey = `user:${provider}:${providerId}`;
|
||||
const existing = await c.env.USERS_KV.get<UserRecord>(userKey, 'json');
|
||||
|
||||
let apiKey: string;
|
||||
if (existing && !existing.revoked) {
|
||||
// Existing user — keep their api key
|
||||
apiKey = existing.api_key;
|
||||
// Update display info
|
||||
const updated: UserRecord = { ...existing, display_name: displayName, avatar_url: avatarUrl };
|
||||
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
|
||||
} else {
|
||||
// New user — generate api key (same HMAC logic as /register)
|
||||
apiKey = await generateApiKey(email, encryptionKey);
|
||||
const newUser: UserRecord = {
|
||||
email, display_name: displayName, avatar_url: avatarUrl,
|
||||
api_key: apiKey, provider, provider_id: providerId,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
await c.env.USERS_KV.put(userKey, JSON.stringify(newUser));
|
||||
// Reverse index for API-Key-based auth
|
||||
await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey);
|
||||
}
|
||||
|
||||
// Create session (TTL 7 days)
|
||||
const sessionId = randomToken(32);
|
||||
const session: SessionRecord = {
|
||||
user_key: userKey,
|
||||
api_key: apiKey,
|
||||
email,
|
||||
expires_at: Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
await c.env.SESSIONS_KV.put(`sess:${sessionId}`, JSON.stringify(session), {
|
||||
expirationTtl: 7 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
const redirectBack = stateRecord.redirect_back.startsWith('/') ? stateRecord.redirect_back : '/dashboard';
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `${landingOrigin}${redirectBack}`,
|
||||
'Set-Cookie': `arcrun_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=${7 * 24 * 60 * 60}`,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[auth/callback]', err);
|
||||
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Logout ───────────────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.post('/auth/logout', async (c) => {
|
||||
const sessId = getSessionId(c.req.raw);
|
||||
if (sessId) {
|
||||
await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
|
||||
}
|
||||
const landingOrigin = getLandingOrigin(c);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `${landingOrigin}/`,
|
||||
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ─── /me ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.get('/me', async (c) => {
|
||||
const user = await resolveSession(c);
|
||||
if (!user) return c.json({ error: 'not authenticated' }, 401);
|
||||
return c.json({
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
avatar_url: user.avatar_url,
|
||||
api_key: user.api_key,
|
||||
provider: user.provider,
|
||||
created_at: user.created_at,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rotate API Key ───────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.put('/me/api-key/rotate', async (c) => {
|
||||
const user = await resolveSession(c);
|
||||
if (!user) return c.json({ error: 'not authenticated' }, 401);
|
||||
|
||||
// Generate new random key (not HMAC — rotated keys are random)
|
||||
const newRaw = randomToken(24);
|
||||
const newKey = 'ak_' + newRaw;
|
||||
|
||||
const oldKey = user.api_key;
|
||||
const userKey = `user:${user.provider}:${user.provider_id}`;
|
||||
|
||||
const updated: UserRecord = { ...user, api_key: newKey };
|
||||
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
|
||||
|
||||
// Update reverse index
|
||||
await c.env.USERS_KV.delete(`apikey:${oldKey}`);
|
||||
await c.env.USERS_KV.put(`apikey:${newKey}`, userKey);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
api_key: newKey,
|
||||
message: 'API Key rotated. Your existing workflow credentials are still stored under the old key namespace.',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Revoke API Key ───────────────────────────────────────────────────────────
|
||||
|
||||
authRouter.delete('/me/api-key', async (c) => {
|
||||
const user = await resolveSession(c);
|
||||
if (!user) return c.json({ error: 'not authenticated' }, 401);
|
||||
|
||||
const userKey = `user:${user.provider}:${user.provider_id}`;
|
||||
const revoked: UserRecord = { ...user, revoked: true };
|
||||
await c.env.USERS_KV.put(userKey, JSON.stringify(revoked));
|
||||
await c.env.USERS_KV.delete(`apikey:${user.api_key}`);
|
||||
|
||||
// Clear session cookie
|
||||
const sessId = getSessionId(c.req.raw);
|
||||
if (sessId) await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
|
||||
|
||||
return new Response(JSON.stringify({ success: true, message: 'API Key revoked.' }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Credentials API — 多租戶 credential 管理
|
||||
*
|
||||
* POST /credentials
|
||||
* Body: { name: string, encrypted: string, iv: string }
|
||||
* Header: X-Arcrun-API-Key
|
||||
* → 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV
|
||||
*
|
||||
* DELETE /credentials/:name
|
||||
* Header: X-Arcrun-API-Key
|
||||
* → 刪除 {api_key}:cred:{name}
|
||||
*
|
||||
* GET /credentials
|
||||
* Header: X-Arcrun-API-Key
|
||||
* → 列出當前 api_key 下所有 credential 名稱(不含加密值)
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /credentials — 上傳加密 credential
|
||||
credentialsRouter.post('/credentials', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null) as {
|
||||
name?: string;
|
||||
encrypted?: string;
|
||||
iv?: string;
|
||||
} | null;
|
||||
|
||||
if (!body?.name || !body.encrypted || !body.iv) {
|
||||
return c.json({ error: '缺少必要欄位:name, encrypted, iv' }, 400);
|
||||
}
|
||||
|
||||
const name = body.name.trim();
|
||||
if (!/^\w+$/.test(name)) {
|
||||
return c.json({ error: 'credential name 只能包含英文字母、數字和底線' }, 400);
|
||||
}
|
||||
|
||||
const kvKey = `${apiKey}:cred:${name}`;
|
||||
const record = JSON.stringify({ encrypted: body.encrypted, iv: body.iv });
|
||||
|
||||
await c.env.CREDENTIALS_KV.put(kvKey, record);
|
||||
|
||||
return c.json({ success: true, name });
|
||||
});
|
||||
|
||||
// DELETE /credentials/:name — 刪除 credential
|
||||
credentialsRouter.delete('/credentials/:name', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const name = c.req.param('name');
|
||||
const kvKey = `${apiKey}:cred:${name}`;
|
||||
await c.env.CREDENTIALS_KV.delete(kvKey);
|
||||
|
||||
return c.json({ success: true, name });
|
||||
});
|
||||
|
||||
// GET /credentials — 列出 credential 名稱(不含值)
|
||||
credentialsRouter.get('/credentials', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const prefix = `${apiKey}:cred:`;
|
||||
const list = await c.env.CREDENTIALS_KV.list({ prefix });
|
||||
const names = list.keys.map(k => k.name.slice(prefix.length));
|
||||
|
||||
return c.json({ credentials: names });
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { handleCypherSearch, handleCypherExecute } from '../actions/cypher-handlers';
|
||||
|
||||
export const cypherRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /cypher/search — 三元組 → 解析節點 → 語意搜尋零件 → 回傳 Cypher JSON (開發友善格式)
|
||||
cypherRouter.post('/cypher/search', async (c) => {
|
||||
const body = await c.req.json() as { triplets?: unknown };
|
||||
const rawTriplets = body?.triplets;
|
||||
|
||||
if (!Array.isArray(rawTriplets) || rawTriplets.length === 0) {
|
||||
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString();
|
||||
const versionId = `search-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
const result = await handleCypherSearch(rawTriplets, c.env);
|
||||
|
||||
const response = {
|
||||
version: versionId,
|
||||
timestamp,
|
||||
triplets: rawTriplets,
|
||||
nodes: result.nodes,
|
||||
cypher: result.cypher,
|
||||
missing: result.missing,
|
||||
};
|
||||
|
||||
return c.json(response);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: errMsg }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /cypher/execute — 三元組 → 一步執行(search + execute 合一)
|
||||
cypherRouter.post('/cypher/execute', async (c) => {
|
||||
const body = await c.req.json() as {
|
||||
triplets?: unknown;
|
||||
context?: Record<string, unknown>;
|
||||
config?: Record<string, Record<string, unknown>>; // node_name → {component, ...params}
|
||||
graph_id?: string;
|
||||
graph_name?: string;
|
||||
};
|
||||
|
||||
if (!Array.isArray(body?.triplets) || body.triplets.length === 0) {
|
||||
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
|
||||
}
|
||||
|
||||
const graphId = typeof body.graph_id === 'string' ? body.graph_id : `triplet-exec-${Date.now()}`;
|
||||
const graphName = typeof body.graph_name === 'string' ? body.graph_name : 'Triplet Execution';
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString();
|
||||
// 版本號格式:execute-v1-20260327-143022
|
||||
const versionId = `execute-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
|
||||
|
||||
try {
|
||||
const result = await handleCypherExecute(
|
||||
body.triplets as unknown[],
|
||||
body.context,
|
||||
graphId,
|
||||
graphName,
|
||||
body.config,
|
||||
c.env,
|
||||
(p) => c.executionCtx.waitUntil(p),
|
||||
apiKey,
|
||||
);
|
||||
// 包裝成開發友善格式(execute 成功時)
|
||||
const response = {
|
||||
version: versionId,
|
||||
timestamp,
|
||||
...result,
|
||||
};
|
||||
return c.json(response);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
try {
|
||||
const parsed = JSON.parse(errMsg);
|
||||
const response = {
|
||||
version: versionId,
|
||||
timestamp,
|
||||
...parsed,
|
||||
};
|
||||
return c.json(response, 500);
|
||||
} catch {
|
||||
return c.json({ version: versionId, timestamp, success: false, error: errMsg, duration_ms: 0 }, 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { OPENAPI_SPEC } from '../lib/openapi';
|
||||
|
||||
export const docsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// GET /openapi.json
|
||||
docsRouter.get('/openapi.json', (c) => {
|
||||
return c.json(OPENAPI_SPEC);
|
||||
});
|
||||
|
||||
// GET /docs — Swagger UI
|
||||
docsRouter.get('/docs', (c) => {
|
||||
const specStr = JSON.stringify(OPENAPI_SPEC);
|
||||
const htmlStr = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Cypher Executor API Docs</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@4/swagger-ui.css">
|
||||
<style>html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; } *, *:before, *:after { box-sizing: inherit; } body { margin:0; padding:0; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@4/swagger-ui-bundle.js"> </script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@4/swagger-ui-standalone-preset.js"> </script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
spec: ${specStr},
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "BaseLayout"
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
return c.html(htmlStr);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings, ExecutionGraph } from '../types';
|
||||
import { ExecutionError } from '../types';
|
||||
import { GraphExecutor } from '../graph-executor';
|
||||
import { executeSchema } from '../lib/schemas';
|
||||
import { createComponentLoader } from '../lib/component-loader';
|
||||
import { writeExecutionVerdict } from '../actions/execution-logger';
|
||||
|
||||
export const executeRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /execute — 執行一個完整的圖
|
||||
executeRouter.post('/execute', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = executeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: '圖定義驗證失敗', details: parsed.error.issues }, 400);
|
||||
}
|
||||
|
||||
const { graph, context } = parsed.data;
|
||||
const apiKey = c.req.header('x-arcrun-api-key') ?? undefined;
|
||||
const loader = createComponentLoader(c.env);
|
||||
const executor = new GraphExecutor(loader, undefined, c.env, apiKey);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// BUILD-006:傳入 KV namespace(若不存在則 fallback 到記憶體 merge)
|
||||
const result = await executor.execute(graph as ExecutionGraph, context, c.env.EXEC_CONTEXT);
|
||||
const duration_ms = Date.now() - start;
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成')
|
||||
);
|
||||
return c.json({ success: true, data: result.data, trace: result.trace, duration_ms });
|
||||
} catch (err) {
|
||||
const duration_ms = Date.now() - start;
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100))
|
||||
);
|
||||
if (err instanceof ExecutionError) {
|
||||
const traceFormatted = err.trace.map(s => ({
|
||||
node: s.nodeId,
|
||||
status: s.error ? 'failed' : 'success',
|
||||
...(s.error ? { error: s.error } : {}),
|
||||
}));
|
||||
return c.json({
|
||||
success: false,
|
||||
error: errMsg,
|
||||
failed_node: err.failed_node,
|
||||
failed_input: err.failed_input,
|
||||
trace: traceFormatted,
|
||||
duration_ms,
|
||||
}, 500);
|
||||
}
|
||||
return c.json({ success: false, error: errMsg, failed_node: null, trace: [], duration_ms }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Executions routes — LI SDD M2.1
|
||||
*
|
||||
* 對應 .agents/specs/llm-interface/ Milestone 2.1。給 AI 看 workflow 執行狀態的端點。
|
||||
*
|
||||
* - GET /executions/paused — 列當前所有 paused 的 workflow(等 callback resume)
|
||||
* - GET /executions/:task_id — 看單一 paused state 細節(含 trace、graph、node id)
|
||||
* - GET /workflows/:name/executions — 列某 workflow 最近 N 次執行 verdict
|
||||
*
|
||||
* 設計:純讀,無 side effect。所有路由要 api_key auth(防偷看他人 workflow state)。
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { listPausedRunsByApiKey } from '../lib/paused-runs';
|
||||
|
||||
export const executionsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
/**
|
||||
* GET /executions/paused — 列當前 api_key 下所有 paused workflow
|
||||
*
|
||||
* 走 per-user index `paused_idx:{api_key}`(單 KV get,強 consistent,無 KV list 延遲)
|
||||
* 取代舊的 `paused_run:*` prefix scan(CF KV list 30-60 秒 eventual consistent)
|
||||
*/
|
||||
executionsRouter.get('/executions/paused', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'auth_missing',
|
||||
human_message: '缺 X-Arcrun-API-Key header',
|
||||
next_actions: ['call /me 取得你的 ak_xxx,加進 header'],
|
||||
}, 401);
|
||||
}
|
||||
|
||||
const limitParam = c.req.query('limit');
|
||||
const limit = Math.min(Math.max(parseInt(limitParam || '20', 10), 1), 100);
|
||||
|
||||
const paused = await listPausedRunsByApiKey(c.env.EXEC_CONTEXT, apiKey, limit);
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
data: { count: paused.length, paused },
|
||||
hints: paused.length > 0
|
||||
? [`${paused.length} 個 workflow 等 callback resume。call get_execution_trace(task_id) 看細節`]
|
||||
: ['沒有任何 paused workflow'],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /executions/:task_id — 看單一 paused workflow 的 state(trace、graph、context)
|
||||
*
|
||||
* task_id 來源:trigger workflow 時 response 含 paused 結果,task_id 在 error 字串裡,
|
||||
* 或前端 list_paused_executions 回的 task_id。
|
||||
*
|
||||
* 隔離:只能讀自己 api_key 的 state。
|
||||
*/
|
||||
executionsRouter.get('/executions/:task_id', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'auth_missing',
|
||||
human_message: '缺 X-Arcrun-API-Key header',
|
||||
next_actions: ['加 X-Arcrun-API-Key header'],
|
||||
}, 401);
|
||||
}
|
||||
|
||||
const taskId = c.req.param('task_id');
|
||||
const raw = await c.env.EXEC_CONTEXT.get(`paused_run:${taskId}`);
|
||||
|
||||
if (!raw) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'not_found',
|
||||
human_message: `task_id "${taskId}" 沒對應的 paused state(可能已 resume 完、過 24h TTL 被 GC、或從未存在)`,
|
||||
next_actions: [
|
||||
'call /executions/paused 看當前所有 paused,確認 task_id 正確',
|
||||
'若該 workflow 不是 paused 型,看 /workflows/:name/executions 查歷史 verdict',
|
||||
],
|
||||
}, 404);
|
||||
}
|
||||
|
||||
let state: {
|
||||
run_id: string;
|
||||
graph?: unknown;
|
||||
paused_node_id: string;
|
||||
paused_context?: Record<string, unknown>;
|
||||
paused_pending_result?: Record<string, unknown>;
|
||||
trace_so_far?: unknown;
|
||||
api_key?: string;
|
||||
expires_at?: number;
|
||||
};
|
||||
try {
|
||||
state = JSON.parse(raw);
|
||||
} catch {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'internal_error',
|
||||
human_message: 'paused state JSON 損毀',
|
||||
next_actions: ['告訴 leo / 平台維護者'],
|
||||
}, 500);
|
||||
}
|
||||
|
||||
if (state.api_key !== apiKey) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'not_found', // 不洩漏存在性
|
||||
human_message: `task_id "${taskId}" 找不到`,
|
||||
next_actions: ['確認 task_id 屬於你 (用 /executions/paused 列出)'],
|
||||
}, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
data: {
|
||||
task_id: taskId,
|
||||
run_id: state.run_id,
|
||||
paused_node_id: state.paused_node_id,
|
||||
paused_context: state.paused_context,
|
||||
paused_pending_result: state.paused_pending_result,
|
||||
trace_so_far: state.trace_so_far,
|
||||
expires_at: state.expires_at,
|
||||
},
|
||||
hints: [
|
||||
'paused 狀態 = workflow 等 daemon callback。等對應 service 回 POST /workflows/resume 即可繼續',
|
||||
'若 daemon 掛了,看 expires_at — 過 24h KV TTL 會 GC 此 state',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /workflows/:name/executions — 看某 workflow 最近 N 次執行 verdict
|
||||
*
|
||||
* 走 ANALYTICS_KV `stats:{workflowId}:*` prefix scan。
|
||||
*
|
||||
* workflowId 等於 webhook name(execution-logger 寫入時用 graph.id ?? name)。
|
||||
*
|
||||
* 限制:ANALYTICS_KV list 沒辦法依 timestamp 排序,只能拿 key 後段 timestamp 排。
|
||||
*/
|
||||
executionsRouter.get('/workflows/:name/executions', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'auth_missing',
|
||||
human_message: '缺 X-Arcrun-API-Key header',
|
||||
next_actions: ['加 X-Arcrun-API-Key header'],
|
||||
}, 401);
|
||||
}
|
||||
|
||||
const name = c.req.param('name');
|
||||
const limitParam = c.req.query('limit');
|
||||
const limit = Math.min(Math.max(parseInt(limitParam || '10', 10), 1), 100);
|
||||
|
||||
// 確認 workflow 是該 api_key 的(防偷看他人)
|
||||
const wfRaw = await c.env.WEBHOOKS.get(`${apiKey}:wf:${name}`, 'text');
|
||||
if (!wfRaw) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'not_found',
|
||||
human_message: `workflow "${name}" 不存在或不屬於你`,
|
||||
next_actions: ['call /webhooks/named 看你有什麼 workflow'],
|
||||
}, 404);
|
||||
}
|
||||
|
||||
// 撈 stats:{name}:* 全 list(每個 key 含 timestamp 後綴)
|
||||
const list = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
|
||||
|
||||
// 按 timestamp 降序(key suffix 是 unix ms)
|
||||
const sorted = [...list.keys].sort((a, b) => {
|
||||
const ta = parseInt(a.name.split(':').pop() ?? '0', 10);
|
||||
const tb = parseInt(b.name.split(':').pop() ?? '0', 10);
|
||||
return tb - ta;
|
||||
}).slice(0, limit);
|
||||
|
||||
const executions = [];
|
||||
for (const key of sorted) {
|
||||
const raw = await c.env.ANALYTICS_KV.get(key.name);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
executions.push({
|
||||
timestamp: key.name.split(':').pop(),
|
||||
...record,
|
||||
});
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
data: {
|
||||
workflow_name: name,
|
||||
count: executions.length,
|
||||
executions,
|
||||
},
|
||||
hints: executions.length === 0
|
||||
? ['尚未有任何執行紀錄(或都過了 90d TTL)。先 call /webhooks/named/:name/trigger 跑一次']
|
||||
: [`最近 ${executions.length} 次。看到 verdict=failed 的,call /executions/:task_id 看 paused state 或繼續 debug`],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
healthRouter.get('/health', (c) =>
|
||||
c.json({ ok: true })
|
||||
);
|
||||
|
||||
healthRouter.get('/', (c) =>
|
||||
c.json({
|
||||
service: 'arcrun-cypher-executor',
|
||||
version: '1.0.0',
|
||||
status: 'ok',
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* /recipes — API recipe CRUD
|
||||
*
|
||||
* recipe 是「http_request + 參數模板」的具名封裝。
|
||||
* 不需要 deploy Worker,執行時由 cypher-executor 直接 fetch。
|
||||
*
|
||||
* KV 結構:
|
||||
* recipe:{canonical_id} → RecipeDefinition JSON
|
||||
* idx:{rec_hash} → canonical_id (反查索引)
|
||||
*
|
||||
* 引用方式(workflow config):
|
||||
* component: "rec_f7e2a1b3" → 永久穩定,不受改名影響
|
||||
* component: "slack" → 向前兼容,直接用 canonical_id 查
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { deriveRecipeHash } from '../lib/hash';
|
||||
import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent';
|
||||
import type { ExposureConsent } from '../lib/exposure-consent';
|
||||
|
||||
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
export interface RecipeDefinition {
|
||||
canonical_id: string;
|
||||
hash_id: string; // rec_xxxxxxxx
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
endpoint: string;
|
||||
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
|
||||
headers?: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
/**
|
||||
* 此 recipe 要用哪個 auth recipe(auth_recipe:{auth_service})。
|
||||
* 讓多個 recipe 共用同一把 auth(例:kbdb_get / kbdb_create_block 都設 "kbdb")。
|
||||
* 未設時 auth-dispatcher fallback 到把 canonical_id 當 service name(向後相容)。
|
||||
*/
|
||||
auth_service?: string;
|
||||
credentials_required?: Array<{
|
||||
key: string;
|
||||
inject_as: string;
|
||||
}>;
|
||||
// 資料外流警示:recipe 定義一個資料去向(endpoint)。push 需人類明示同意(法律憑證)。
|
||||
// SDD: data-exfil-warning §7(公私一視同仁)
|
||||
exposure_consent?: ExposureConsent;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// POST /recipes — 新增或更新 recipe
|
||||
recipesRouter.post('/recipes', async (c) => {
|
||||
let body: Partial<RecipeDefinition>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
|
||||
}
|
||||
|
||||
const canonicalId = (body.canonical_id ?? '').trim().toLowerCase();
|
||||
if (!canonicalId) return c.json({ success: false, error: 'canonical_id 必填' }, 400);
|
||||
if (!body.endpoint) return c.json({ success: false, error: 'endpoint 必填' }, 400);
|
||||
|
||||
const hashId = await deriveRecipeHash(canonicalId);
|
||||
const now = Date.now();
|
||||
|
||||
// 讀取現有版本(保留 created_at + 既有同意憑證)
|
||||
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null;
|
||||
|
||||
// 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
|
||||
const consentError = checkExposureConsent(body.exposure_consent, existing?.exposure_consent);
|
||||
if (consentError !== null) {
|
||||
return c.json({ success: false, error: consentError, requires: 'exposure_consent' }, 403);
|
||||
}
|
||||
|
||||
const recipe: RecipeDefinition = {
|
||||
canonical_id: canonicalId,
|
||||
hash_id: hashId,
|
||||
display_name: body.display_name,
|
||||
description: body.description,
|
||||
endpoint: body.endpoint,
|
||||
method: (body.method ?? 'POST').toUpperCase(),
|
||||
headers: body.headers,
|
||||
body: body.body,
|
||||
auth_service: body.auth_service,
|
||||
credentials_required: body.credentials_required,
|
||||
exposure_consent: resolveConsentForRecord(body.exposure_consent, existing?.exposure_consent),
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// 寫入兩個 KV key
|
||||
await Promise.all([
|
||||
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
|
||||
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
|
||||
]);
|
||||
|
||||
return c.json({ success: true, recipe });
|
||||
});
|
||||
|
||||
// GET /recipes/:id — 讀取 recipe(支援 canonical_id 或 rec_hash)
|
||||
recipesRouter.get('/recipes/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const recipe = await resolveRecipe(id, c.env.RECIPES);
|
||||
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
|
||||
return c.json({ success: true, recipe });
|
||||
});
|
||||
|
||||
// GET /recipes — 列出所有 recipe
|
||||
recipesRouter.get('/recipes', async (c) => {
|
||||
const list = await c.env.RECIPES.list({ prefix: 'recipe:' });
|
||||
const recipes = await Promise.all(
|
||||
list.keys.map(k => c.env.RECIPES.get(k.name, 'json'))
|
||||
);
|
||||
return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length });
|
||||
});
|
||||
|
||||
// DELETE /recipes/:id — 刪除 recipe
|
||||
recipesRouter.delete('/recipes/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const recipe = await resolveRecipe(id, c.env.RECIPES);
|
||||
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
|
||||
|
||||
await Promise.all([
|
||||
c.env.RECIPES.delete(`recipe:${recipe.canonical_id}`),
|
||||
c.env.RECIPES.delete(`idx:${recipe.hash_id}`),
|
||||
]);
|
||||
|
||||
return c.json({ success: true, deleted: recipe.canonical_id });
|
||||
});
|
||||
|
||||
/** 用 canonical_id 或 rec_hash 查 recipe */
|
||||
export async function resolveRecipe(
|
||||
id: string,
|
||||
kv: KVNamespace,
|
||||
): Promise<RecipeDefinition | null> {
|
||||
// rec_xxxxxxxx → 先查 idx 反查 canonical_id
|
||||
if (id.startsWith('rec_')) {
|
||||
const canonicalId = await kv.get(`idx:${id}`);
|
||||
if (!canonicalId) return null;
|
||||
return kv.get(`recipe:${canonicalId}`, 'json');
|
||||
}
|
||||
// 直接用 canonical_id
|
||||
return kv.get(`recipe:${id}`, 'json');
|
||||
}
|
||||
|
||||
// ── Auth Recipe ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AuthPrimitive = 'static_key' | 'oauth2' | 'service_account' | 'mtls';
|
||||
|
||||
export interface SecretRequirement {
|
||||
key: string; // CREDENTIALS_KV 的名稱(e.g. "notion_token")
|
||||
label: string; // CLI/UI 顯示(e.g. "Internal Integration Token")
|
||||
type?: 'string' | 'json_blob'; // default: string
|
||||
help?: string;
|
||||
help_url?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthInjectSpec {
|
||||
header?: Record<string, string>; // e.g. { Authorization: "Bearer {{secret.token}}" }
|
||||
query?: Record<string, string>;
|
||||
body?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AuthRecipeDefinition {
|
||||
kind: 'auth_recipe';
|
||||
service: string; // canonical_id,e.g. "notion"
|
||||
version: number;
|
||||
primitive: AuthPrimitive;
|
||||
base_url: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
|
||||
// service_account 專用
|
||||
service_account_kind?: 'google_jwt';
|
||||
token_exchange?: {
|
||||
endpoint: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
required_secrets: SecretRequirement[];
|
||||
inject: AuthInjectSpec;
|
||||
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/** 查 auth recipe(KV key: auth_recipe:{service})*/
|
||||
export async function resolveAuthRecipe(
|
||||
service: string,
|
||||
kv: KVNamespace,
|
||||
): Promise<AuthRecipeDefinition | null> {
|
||||
return kv.get(`auth_recipe:${service}`, 'json');
|
||||
}
|
||||
|
||||
// POST /auth-recipes — 新增或更新 auth recipe
|
||||
recipesRouter.post('/auth-recipes', async (c) => {
|
||||
let body: Partial<AuthRecipeDefinition>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
|
||||
}
|
||||
|
||||
const service = (body.service ?? '').trim().toLowerCase();
|
||||
if (!service) return c.json({ success: false, error: 'service 必填' }, 400);
|
||||
if (!body.primitive) return c.json({ success: false, error: 'primitive 必填' }, 400);
|
||||
if (!body.base_url) return c.json({ success: false, error: 'base_url 必填' }, 400);
|
||||
if (!body.required_secrets?.length) return c.json({ success: false, error: 'required_secrets 必填' }, 400);
|
||||
if (!body.inject) return c.json({ success: false, error: 'inject 必填' }, 400);
|
||||
|
||||
const now = Date.now();
|
||||
const existing = await c.env.RECIPES.get(`auth_recipe:${service}`, 'json') as AuthRecipeDefinition | null;
|
||||
|
||||
const recipe: AuthRecipeDefinition = {
|
||||
kind: 'auth_recipe',
|
||||
service,
|
||||
version: body.version ?? 1,
|
||||
primitive: body.primitive,
|
||||
base_url: body.base_url,
|
||||
display_name: body.display_name,
|
||||
description: body.description,
|
||||
service_account_kind: body.service_account_kind,
|
||||
token_exchange: body.token_exchange,
|
||||
required_secrets: body.required_secrets,
|
||||
inject: body.inject,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
await c.env.RECIPES.put(`auth_recipe:${service}`, JSON.stringify(recipe));
|
||||
return c.json({ success: true, recipe });
|
||||
});
|
||||
|
||||
// GET /auth-recipes — 列出所有 auth recipe
|
||||
recipesRouter.get('/auth-recipes', async (c) => {
|
||||
const list = await c.env.RECIPES.list({ prefix: 'auth_recipe:' });
|
||||
const recipes = await Promise.all(
|
||||
list.keys.map(k => c.env.RECIPES.get(k.name, 'json'))
|
||||
);
|
||||
return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length });
|
||||
});
|
||||
|
||||
// GET /auth-recipes/:service — 讀取單一 auth recipe
|
||||
recipesRouter.get('/auth-recipes/:service', async (c) => {
|
||||
const service = c.req.param('service');
|
||||
const recipe = await resolveAuthRecipe(service, c.env.RECIPES);
|
||||
if (!recipe) return c.json({ success: false, error: `找不到 auth recipe: ${service}` }, 404);
|
||||
return c.json({ success: true, recipe });
|
||||
});
|
||||
|
||||
// DELETE /auth-recipes/:service — 刪除 auth recipe
|
||||
recipesRouter.delete('/auth-recipes/:service', async (c) => {
|
||||
const service = c.req.param('service');
|
||||
const recipe = await resolveAuthRecipe(service, c.env.RECIPES);
|
||||
if (!recipe) return c.json({ success: false, error: `找不到 auth recipe: ${service}` }, 404);
|
||||
await c.env.RECIPES.delete(`auth_recipe:${service}`);
|
||||
return c.json({ success: true, deleted: service });
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// POST /register — API Key 發放
|
||||
// email → HMAC-SHA256(email, ENCRYPTION_KEY) → api_key (ak_ 前綴)
|
||||
// 同一個 email 永遠得到相同的 Key,無需資料庫
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const registerRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
registerRouter.post('/register', async (c) => {
|
||||
let email: string;
|
||||
try {
|
||||
const body = await c.req.json() as { email?: string };
|
||||
email = (body.email ?? '').trim().toLowerCase();
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
|
||||
}
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
return c.json({ success: false, error: 'email 格式不正確' }, 400);
|
||||
}
|
||||
|
||||
const encryptionKey = c.env.ENCRYPTION_KEY;
|
||||
if (!encryptionKey || encryptionKey.length < 32) {
|
||||
return c.json({ success: false, error: 'server configuration error' }, 500);
|
||||
}
|
||||
|
||||
// HMAC-SHA256(email, ENCRYPTION_KEY) → hex → 取前 32 字元 → ak_ 前綴
|
||||
const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32));
|
||||
const msgData = new TextEncoder().encode(email);
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
|
||||
);
|
||||
const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
|
||||
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const apiKey = 'ak_' + hex.slice(0, 32);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
api_key: apiKey,
|
||||
encryption_key: encryptionKey, // 用戶需要此 key 才能加密上傳 credential
|
||||
email,
|
||||
message: 'API Key 已發放,請妥善保存。相同 email 永遠得到相同的 Key。',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* POST /workflows/resume
|
||||
* Webhook callback 進來時,從 paused state 撿起來繼續跑下游節點
|
||||
* SDD: matrix/arcrun/.agents/specs/resumable-workflow/design.md Phase 3
|
||||
*
|
||||
* 安全:因為這是 daemon 主動 callback,沒有 partner key(daemon 不知道用戶 key)
|
||||
* 靠 task_id 為 nonce + 24h TTL + idempotent consume 保護
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { WorkflowPaused } from '../types';
|
||||
import { GraphExecutor } from '../graph-executor';
|
||||
import { createComponentLoader } from '../lib/component-loader';
|
||||
import { consumePausedRun } from '../lib/paused-runs';
|
||||
|
||||
export const resumeRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
resumeRouter.post('/workflows/resume', async (c) => {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: 'request body 必須為 JSON' }, 400);
|
||||
}
|
||||
|
||||
const taskId = typeof body.task_id === 'string' ? body.task_id : undefined;
|
||||
if (!taskId) {
|
||||
return c.json({ error: 'task_id 必填' }, 400);
|
||||
}
|
||||
|
||||
// consume = load + delete(idempotent:重複 callback 第二次找不到 state,回 200)
|
||||
const state = await consumePausedRun(c.env.EXEC_CONTEXT, taskId);
|
||||
if (!state) {
|
||||
return c.json({
|
||||
success: true,
|
||||
noop: true,
|
||||
reason: `paused state 不存在或已過期 (task_id=${taskId})`,
|
||||
});
|
||||
}
|
||||
|
||||
const callbackResult = {
|
||||
success: body.success ?? true,
|
||||
data: body.data,
|
||||
error: body.error,
|
||||
};
|
||||
|
||||
const loader = createComponentLoader(c.env);
|
||||
const executor = new GraphExecutor(loader, undefined, c.env, state.api_key);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await executor.resumeFromPaused({
|
||||
graph: state.graph,
|
||||
paused_node_id: state.paused_node_id,
|
||||
paused_context: state.paused_context,
|
||||
callback_result: callbackResult,
|
||||
prior_trace: state.trace_so_far,
|
||||
kvNamespace: c.env.EXEC_CONTEXT,
|
||||
recipe_output_format: state.recipe_output_format,
|
||||
recipe_output_required_fields: state.recipe_output_required_fields,
|
||||
});
|
||||
const duration_ms = Date.now() - start;
|
||||
return c.json({
|
||||
success: true,
|
||||
resumed: true,
|
||||
task_id: taskId,
|
||||
run_id: state.run_id,
|
||||
data: result.data,
|
||||
trace: result.trace,
|
||||
duration_ms,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowPaused) {
|
||||
// resume 後又遇到 pending(v2 nested 情境)— v1 仍持久化但回 paused-again
|
||||
return c.json({
|
||||
success: true,
|
||||
paused_again: true,
|
||||
task_id: err.task_id,
|
||||
run_id: err.run_id,
|
||||
paused_node_id: err.paused_node_id,
|
||||
});
|
||||
}
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ success: false, error: errMsg, task_id: taskId, run_id: state.run_id }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { graphSchema } from '../lib/schemas';
|
||||
import { recordTelemetry } from '../lib/telemetry';
|
||||
|
||||
export const validateRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /validate — 驗證圖定義(不執行)
|
||||
validateRouter.post('/validate', async (c) => {
|
||||
const start = Date.now();
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
const userAgent = c.req.header('User-Agent') ?? undefined;
|
||||
|
||||
const body = await c.req.json();
|
||||
const parsed = graphSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
recordTelemetry(c.env, apiKey, {
|
||||
event_type: 'validation_error',
|
||||
error_code: 'schema_failed',
|
||||
duration_ms: Date.now() - start,
|
||||
agent_user_agent: userAgent,
|
||||
}, c.executionCtx);
|
||||
return c.json({ valid: false, errors: parsed.error.issues }, 400);
|
||||
}
|
||||
|
||||
const nodeIds = new Set(parsed.data.nodes.map(n => n.id));
|
||||
const invalidEdges = parsed.data.edges.filter(e => !nodeIds.has(e.from) || !nodeIds.has(e.to));
|
||||
|
||||
if (invalidEdges.length > 0) {
|
||||
recordTelemetry(c.env, apiKey, {
|
||||
event_type: 'validation_error',
|
||||
error_code: 'edge_node_missing',
|
||||
duration_ms: Date.now() - start,
|
||||
agent_user_agent: userAgent,
|
||||
}, c.executionCtx);
|
||||
return c.json({
|
||||
valid: false,
|
||||
errors: invalidEdges.map(e => `邊 ${e.from} → ${e.to} 指向不存在的節點`),
|
||||
}, 400);
|
||||
}
|
||||
|
||||
return c.json({ valid: true, nodeCount: parsed.data.nodes.length, edgeCount: parsed.data.edges.length });
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { validateAndParseWebhook } from '../actions/webhook-handlers';
|
||||
|
||||
export const webhooksCrudRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
type WebhookRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
description: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// GET /webhooks/:token — 查詢 Webhook 基本資訊
|
||||
webhooksCrudRouter.get('/webhooks/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
const raw = await c.env.WEBHOOKS.get(token, 'text');
|
||||
if (!raw) return c.json({ error: 'not found' }, 404);
|
||||
|
||||
const record = await validateAndParseWebhook(raw);
|
||||
if (!record) return c.json({ error: '資料損毀' }, 500);
|
||||
|
||||
return c.json({
|
||||
token,
|
||||
description: record.description,
|
||||
created_at: record.created_at,
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /webhooks/:token — 更新 Webhook 定義
|
||||
webhooksCrudRouter.put('/webhooks/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
if (!token || token.length < 16) {
|
||||
return c.json({ error: 'invalid token' }, 400);
|
||||
}
|
||||
|
||||
const raw = await c.env.WEBHOOKS.get(token, 'text');
|
||||
if (!raw) return c.json({ error: 'webhook not found' }, 404);
|
||||
|
||||
const existing = await validateAndParseWebhook(raw);
|
||||
if (!existing) return c.json({ error: 'webhook 定義損毀' }, 500);
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) return c.json({ error: 'invalid json' }, 400);
|
||||
|
||||
const updatedRecord: WebhookRecord = {
|
||||
graph: existing.graph,
|
||||
description: existing.description,
|
||||
created_at: existing.created_at,
|
||||
};
|
||||
|
||||
if (body.description !== undefined) {
|
||||
updatedRecord.description = typeof body.description === 'string' ? body.description : existing.description;
|
||||
}
|
||||
|
||||
if (body.graph !== undefined) {
|
||||
updatedRecord.graph = body.graph;
|
||||
}
|
||||
|
||||
await c.env.WEBHOOKS.put(token, JSON.stringify(updatedRecord));
|
||||
|
||||
const baseUrl = new URL(c.req.url).origin;
|
||||
return c.json({
|
||||
token,
|
||||
webhook_url: `${baseUrl}/webhooks/${token}/trigger`,
|
||||
description: updatedRecord.description,
|
||||
created_at: updatedRecord.created_at,
|
||||
updated: true,
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /webhooks/:token — 刪除 Webhook
|
||||
webhooksCrudRouter.delete('/webhooks/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
if (!token || token.length < 16) {
|
||||
return c.json({ error: 'invalid token' }, 400);
|
||||
}
|
||||
|
||||
const existing = await c.env.WEBHOOKS.get(token, 'text');
|
||||
if (!existing) return c.json({ error: 'webhook not found' }, 404);
|
||||
|
||||
await c.env.WEBHOOKS.delete(token);
|
||||
return c.json({ deleted: true, token });
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { validateAndParseWebhook } from '../actions/webhook-handlers';
|
||||
|
||||
export const webhooksListRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// GET /webhooks — 列出所有 Webhooks(需要授權標頭)
|
||||
webhooksListRouter.get('/webhooks', async (c) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader) {
|
||||
return c.json({ error: 'unauthorized: missing Authorization header' }, 401);
|
||||
}
|
||||
|
||||
const list = await c.env.WEBHOOKS.list();
|
||||
const webhooks = [];
|
||||
|
||||
for (const key of list.keys) {
|
||||
const raw = await c.env.WEBHOOKS.get(key.name, 'text');
|
||||
if (!raw) continue;
|
||||
|
||||
const record = await validateAndParseWebhook(raw);
|
||||
if (!record) continue;
|
||||
|
||||
webhooks.push({
|
||||
token: key.name,
|
||||
description: record.description,
|
||||
created_at: record.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ webhooks, total: webhooks.length });
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Named Webhook(acr push 使用)
|
||||
*
|
||||
* POST /webhooks/named
|
||||
* Header: X-Arcrun-API-Key
|
||||
* Body: { name, graph, config?, description? }
|
||||
* → 以 {api_key}:wf:{name} 存入 WEBHOOKS KV
|
||||
* → 回傳 webhook_url
|
||||
*
|
||||
* POST /webhooks/named/:name/trigger
|
||||
* Header: X-Arcrun-API-Key
|
||||
* Body: 任意 JSON(作為 trigger context)
|
||||
* → 以 {api_key}:wf:{name} 讀取執行圖,執行後回傳結果
|
||||
*
|
||||
* GET /webhooks/named
|
||||
* Header: X-Arcrun-API-Key
|
||||
* → 列出當前 api_key 下所有 named webhook
|
||||
*
|
||||
* DELETE /webhooks/named/:name
|
||||
* Header: X-Arcrun-API-Key
|
||||
* → 刪除指定 workflow
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
import { writeExecutionVerdict } from '../actions/execution-logger';
|
||||
import type { GraphNode } from '../types';
|
||||
import { extractCronExpr } from '../lib/cron-match';
|
||||
import { recordTelemetry } from '../lib/telemetry';
|
||||
import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent';
|
||||
import type { ExposureConsent } from '../lib/exposure-consent';
|
||||
|
||||
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
type NamedWorkflowRecord = {
|
||||
name: string;
|
||||
graph: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
description: string;
|
||||
created_at: string;
|
||||
// 若首節點是 cron 零件,extract cron_expr 存進來供 scheduled() 比對
|
||||
// 對應 SDD: arcrun.md 三-A P1 #3
|
||||
cron_expr?: string;
|
||||
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
|
||||
// 存人類明示同意憑證(法律憑證,可審)。SDD: data-exfil-warning §7
|
||||
exposure_consent?: ExposureConsent;
|
||||
};
|
||||
|
||||
function kvKey(apiKey: string, name: string): string {
|
||||
return `${apiKey}:wf:${name}`;
|
||||
}
|
||||
|
||||
/** 輕量 cron index entry — scheduled() 只列這個 prefix(每分鐘 tick 不掃全量 KV)*/
|
||||
function cronIndexKey(apiKey: string, name: string): string {
|
||||
return `cron-idx:${apiKey}:${name}`;
|
||||
}
|
||||
|
||||
// POST /webhooks/named — 部署(acr push 呼叫)
|
||||
webhooksNamedRouter.post('/webhooks/named', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null) as {
|
||||
name?: string;
|
||||
graph?: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
description?: string;
|
||||
exposure_consent?: ExposureConsent;
|
||||
} | null;
|
||||
|
||||
if (!body?.name || !body.graph) {
|
||||
return c.json({ error: '缺少必要欄位:name, graph' }, 400);
|
||||
}
|
||||
|
||||
const name = body.name.trim();
|
||||
if (!/^[\w-]+$/.test(name)) {
|
||||
return c.json({ error: 'workflow name 只能包含英文字母、數字、底線和連字號' }, 400);
|
||||
}
|
||||
|
||||
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
|
||||
// 首次部署某 workflow 需人類明示同意;已同意(含 suppress_future)則放行(§3 首次問記住)。
|
||||
const priorRaw = await c.env.WEBHOOKS.get(kvKey(apiKey, name));
|
||||
const priorRecord = priorRaw ? (JSON.parse(priorRaw) as NamedWorkflowRecord) : null;
|
||||
const consentError = checkExposureConsent(body.exposure_consent, priorRecord?.exposure_consent);
|
||||
if (consentError !== null) {
|
||||
return c.json({ error: consentError, requires: 'exposure_consent' }, 403);
|
||||
}
|
||||
|
||||
// 偵測首節點是 cron 零件 → 抽 cron_expr 存進 record + 建輕量 index 給 scheduled()
|
||||
const cronExpr = extractCronExpr(body.graph);
|
||||
|
||||
const record: NamedWorkflowRecord = {
|
||||
name,
|
||||
graph: body.graph,
|
||||
config: body.config,
|
||||
description: typeof body.description === 'string' ? body.description : '',
|
||||
created_at: new Date().toISOString(),
|
||||
cron_expr: cronExpr ?? undefined,
|
||||
// 法律憑證:存人類明示同意(本次新同意或沿用既有)
|
||||
exposure_consent: resolveConsentForRecord(body.exposure_consent, priorRecord?.exposure_consent),
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
await c.env.WEBHOOKS.put(kvKey(apiKey, name), JSON.stringify(record));
|
||||
|
||||
// 維護 cron index:有 cron_expr 就寫 / 沒有就刪除(避免 push 改 yaml 拿掉 cron 後殘留)
|
||||
if (cronExpr) {
|
||||
await c.env.WEBHOOKS.put(cronIndexKey(apiKey, name), JSON.stringify({ cron_expr: cronExpr }));
|
||||
} else {
|
||||
await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name));
|
||||
}
|
||||
|
||||
// Implicit telemetry (LI M1.2)
|
||||
recordTelemetry(c.env, apiKey, {
|
||||
event_type: 'deploy_success',
|
||||
workflow_name: name,
|
||||
duration_ms: Date.now() - start,
|
||||
agent_user_agent: c.req.header('User-Agent') ?? undefined,
|
||||
}, c.executionCtx);
|
||||
|
||||
const baseUrl = new URL(c.req.url).origin;
|
||||
return c.json({
|
||||
name,
|
||||
webhook_url: `${baseUrl}/webhooks/named/${name}/trigger`,
|
||||
description: record.description,
|
||||
created_at: record.created_at,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// POST /webhooks/named/:name/trigger — 觸發執行
|
||||
webhooksNamedRouter.post('/webhooks/named/:name/trigger', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const name = c.req.param('name');
|
||||
const raw = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text');
|
||||
if (!raw) {
|
||||
return c.json({ error: `找不到 workflow "${name}",請先執行 acr push` }, 404);
|
||||
}
|
||||
|
||||
let record: NamedWorkflowRecord;
|
||||
try {
|
||||
record = JSON.parse(raw) as NamedWorkflowRecord;
|
||||
} catch {
|
||||
return c.json({ error: 'workflow 定義損毀' }, 500);
|
||||
}
|
||||
|
||||
let triggerContext: Record<string, unknown> = {};
|
||||
try {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (body && typeof body === 'object') {
|
||||
triggerContext = body as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// 無 body 時使用空 context
|
||||
}
|
||||
|
||||
const graph = record.graph as { id?: string; nodes?: unknown[] };
|
||||
const workflowId = graph.id ?? name;
|
||||
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as GraphNode[]) : [];
|
||||
const userAgent = c.req.header('User-Agent') ?? undefined;
|
||||
|
||||
// resumable-workflow SDD §5:?async=1 → 背景執行(waitUntil)+ 立回 202,不依賴呼叫端連線。
|
||||
// 不帶 ?async=1 維持原同步行為(向後相容)。
|
||||
if (c.req.query('async') === '1') {
|
||||
c.executionCtx.waitUntil(
|
||||
executeWebhookGraph(c.env, record.graph, triggerContext, name, apiKey, c.executionCtx, userAgent)
|
||||
.then(result =>
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||
),
|
||||
);
|
||||
return c.json({ accepted: true }, 202);
|
||||
}
|
||||
|
||||
const result = await executeWebhookGraph(
|
||||
c.env,
|
||||
record.graph,
|
||||
triggerContext,
|
||||
name,
|
||||
apiKey,
|
||||
c.executionCtx,
|
||||
userAgent,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// GET /webhooks/named — 列出當前 api_key 下所有 workflow
|
||||
webhooksNamedRouter.get('/webhooks/named', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const prefix = `${apiKey}:wf:`;
|
||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||
|
||||
const workflows = list.keys.map(k => {
|
||||
const name = k.name.slice(prefix.length);
|
||||
return { name };
|
||||
});
|
||||
|
||||
const baseUrl = new URL(c.req.url).origin;
|
||||
const result = workflows.map(w => ({
|
||||
name: w.name,
|
||||
webhook_url: `${baseUrl}/webhooks/named/${w.name}/trigger`,
|
||||
}));
|
||||
|
||||
return c.json({ workflows: result, total: result.length });
|
||||
});
|
||||
|
||||
// DELETE /webhooks/named/:name — 刪除 workflow
|
||||
webhooksNamedRouter.delete('/webhooks/named/:name', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const name = c.req.param('name');
|
||||
const existing = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text');
|
||||
if (!existing) {
|
||||
return c.json({ error: `找不到 workflow "${name}"` }, 404);
|
||||
}
|
||||
|
||||
await c.env.WEBHOOKS.delete(kvKey(apiKey, name));
|
||||
await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name));
|
||||
return c.json({ deleted: true, name });
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { generateToken, validateAndParseWebhook, executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
import { resolveWebhookGraph } from '../actions/webhook-graph-resolver';
|
||||
import { writeExecutionVerdict } from '../actions/execution-logger';
|
||||
|
||||
export const webhooksRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
type WebhookRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
description: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// POST /webhooks — 接受 graph、triplets 或直接 nodes/edges
|
||||
webhooksRouter.post('/webhooks', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) return c.json({ error: 'invalid json' }, 400);
|
||||
|
||||
const description = typeof body.description === 'string' ? body.description : '';
|
||||
const resolved = await resolveWebhookGraph(body as Record<string, unknown>, description, c.env);
|
||||
|
||||
if (resolved.error) {
|
||||
return c.json({ error: resolved.error }, 400);
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const record: WebhookRecord = {
|
||||
graph: resolved.resolvedGraph,
|
||||
description,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await c.env.WEBHOOKS.put(token, JSON.stringify(record));
|
||||
|
||||
const baseUrl = new URL(c.req.url).origin;
|
||||
return c.json({
|
||||
token,
|
||||
webhook_url: `${baseUrl}/webhooks/${token}/trigger`,
|
||||
description: record.description,
|
||||
created_at: record.created_at,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// POST /webhooks/:token/trigger — 觸發執行
|
||||
webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
if (!token || token.length < 16) {
|
||||
return c.json({ error: 'invalid token' }, 400);
|
||||
}
|
||||
|
||||
const raw = await c.env.WEBHOOKS.get(token, 'text');
|
||||
if (!raw) return c.json({ error: 'webhook not found' }, 404);
|
||||
|
||||
const record = await validateAndParseWebhook(raw);
|
||||
if (!record) return c.json({ error: 'webhook 定義損毀' }, 500);
|
||||
|
||||
let triggerContext: Record<string, unknown> = {};
|
||||
try {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (body && typeof body === 'object') {
|
||||
triggerContext = body as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// 無 body 時使用空 context
|
||||
}
|
||||
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
|
||||
const result = await executeWebhookGraph(c.env, record.graph, triggerContext, token, apiKey);
|
||||
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* scheduled() handler — 對應 wrangler.toml [triggers].crons 觸發。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 列出 WEBHOOKS KV 所有 webhook:{api_key}:{name} key
|
||||
* 2. 對每個 workflow 解析 cron_expr(acr push 時若首節點是 cron 零件會存進 record.cron_expr)
|
||||
* 3. 用 cronMatch() 比對 event.scheduledTime(UTC 分鐘精度)
|
||||
* 4. 匹配 → executeWebhookGraph 跑(waitUntil 背景,不擋)
|
||||
*
|
||||
* SDD: arcrun.md 三-A P1 #3
|
||||
*/
|
||||
|
||||
import type { ExecutionContext, ScheduledController } from '@cloudflare/workers-types';
|
||||
import type { Bindings } from './types';
|
||||
import { cronMatch } from './lib/cron-match';
|
||||
import { executeWebhookGraph } from './actions/webhook-handlers';
|
||||
|
||||
type StoredWorkflowRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
cron_expr?: string;
|
||||
// 其他欄位(id, name, created_at 等)忽略
|
||||
};
|
||||
|
||||
export async function handleScheduled(
|
||||
controller: ScheduledController,
|
||||
env: Bindings,
|
||||
ctx: ExecutionContext,
|
||||
): Promise<void> {
|
||||
const now = new Date(controller.scheduledTime);
|
||||
console.log('[scheduled] tick', now.toISOString(), 'controller.cron=', controller.cron);
|
||||
|
||||
// 只列 cron-idx: prefix,輕量 — acr push 時為 cron-tagged workflow 額外寫一筆 index
|
||||
// 主 workflow record 仍在 {apiKey}:wf:{name},需要時再 get
|
||||
const list = await env.WEBHOOKS.list({ prefix: 'cron-idx:' });
|
||||
|
||||
let triggered = 0;
|
||||
for (const entry of list.keys) {
|
||||
// key = cron-idx:{api_key}:{name}
|
||||
const parts = entry.name.split(':');
|
||||
if (parts.length < 3) continue;
|
||||
const apiKey = parts[1];
|
||||
const name = parts.slice(2).join(':'); // name 可能含 ':'(雖然 push handler 已用 /^[\w-]+$/ 擋)
|
||||
|
||||
// 從 cron-idx 拿 cron_expr(輕量)
|
||||
const idxRaw = await env.WEBHOOKS.get(entry.name, 'text');
|
||||
if (!idxRaw) continue;
|
||||
let idx: { cron_expr?: string };
|
||||
try { idx = JSON.parse(idxRaw); } catch { continue; }
|
||||
if (!idx.cron_expr) continue;
|
||||
if (!cronMatch(idx.cron_expr, now)) continue;
|
||||
|
||||
// 匹配才去讀完整 workflow record
|
||||
const wfKey = `${apiKey}:wf:${name}`;
|
||||
const wfRaw = await env.WEBHOOKS.get(wfKey, 'text');
|
||||
if (!wfRaw) {
|
||||
console.warn('[scheduled] cron-idx 對應 workflow 不存在', wfKey);
|
||||
continue;
|
||||
}
|
||||
let record: StoredWorkflowRecord;
|
||||
try { record = JSON.parse(wfRaw) as StoredWorkflowRecord; } catch { continue; }
|
||||
triggered++;
|
||||
|
||||
console.log('[scheduled] trigger', name, 'apiKey=', apiKey.slice(0, 12) + '...', 'cron=', idx.cron_expr);
|
||||
// 把 apiKey 也放進 triggerContext,讓 workflow 內節點能用 {{api_key}}(跟 webhook trigger 慣例一致)
|
||||
const triggerContext = {
|
||||
api_key: apiKey,
|
||||
_triggered_by: 'cron' as const,
|
||||
_scheduled_at: now.toISOString(),
|
||||
};
|
||||
ctx.waitUntil(
|
||||
executeWebhookGraph(env, record.graph, triggerContext, name, apiKey)
|
||||
.then(
|
||||
(r) => console.log('[scheduled] done', name, r.success, r.duration_ms + 'ms'),
|
||||
(e) => console.error('[scheduled] fail', name, e),
|
||||
),
|
||||
);
|
||||
}
|
||||
console.log(`[scheduled] scanned ${list.keys.length} cron-idx entries, ${triggered} triggered`);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// arcrun cypher-executor 型別定義
|
||||
|
||||
// Service Binding 型別(CF Workers 直接呼叫,不走公網)
|
||||
export type ServiceBinding = {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
};
|
||||
|
||||
export type Bindings = {
|
||||
// Logic component Service Bindings
|
||||
SVC_IF_CONTROL: ServiceBinding;
|
||||
SVC_SWITCH: ServiceBinding;
|
||||
SVC_FOREACH_CONTROL: ServiceBinding;
|
||||
SVC_FILTER: ServiceBinding;
|
||||
SVC_MERGE: ServiceBinding;
|
||||
SVC_TRY_CATCH: ServiceBinding;
|
||||
SVC_WAIT: ServiceBinding;
|
||||
SVC_SET: ServiceBinding;
|
||||
SVC_ARRAY_OPS: ServiceBinding;
|
||||
SVC_STRING_OPS: ServiceBinding;
|
||||
SVC_NUMBER_OPS: ServiceBinding;
|
||||
SVC_DATE_OPS: ServiceBinding;
|
||||
SVC_VALIDATE_JSON: ServiceBinding;
|
||||
// SVC_AI_TRANSFORM_* 已移除(Phase 2 刪 ai_transform 零件 + wrangler.toml service binding)
|
||||
// KV Context Store:節點 output 透過 KV 傳遞,解決同名欄位衝突
|
||||
EXEC_CONTEXT: KVNamespace;
|
||||
// Recipe Store:API recipe 定義(key: recipe:{canonical_id} 或 idx:{hash_id})
|
||||
RECIPES: KVNamespace;
|
||||
// Webhook Store:key = workflow name,value = Workflow JSON
|
||||
WEBHOOKS: KVNamespace;
|
||||
// Credential Store:AES-GCM 加密存放用戶 API token
|
||||
CREDENTIALS_KV: KVNamespace;
|
||||
// Analytics:執行統計(fire-and-forget,key = stats:{workflowId}:{timestamp})
|
||||
ANALYTICS_KV: KVNamespace;
|
||||
// R2 Bucket:WASM 零件二進位
|
||||
WASM_BUCKET: R2Bucket;
|
||||
// Users:OAuth 登入用戶帳號(key = user:{provider}:{provider_id})
|
||||
USERS_KV: KVNamespace;
|
||||
// Sessions:登入 session(key = sess:{session_id},TTL 7d)
|
||||
SESSIONS_KV: KVNamespace;
|
||||
// Workers AI
|
||||
AI: Ai;
|
||||
// 環境變數
|
||||
ENVIRONMENT: string;
|
||||
ENCRYPTION_KEY: string; // hex-encoded 256-bit AES key(wrangler secret)
|
||||
MULTI_TENANT?: string; // "false" = Self-hosted 單租戶模式,預設 "true"
|
||||
// OAuth Secrets(wrangler secret)
|
||||
GOOGLE_CLIENT_ID?: string;
|
||||
GOOGLE_CLIENT_SECRET?: string;
|
||||
GITHUB_CLIENT_ID?: string;
|
||||
GITHUB_CLIENT_SECRET?: string;
|
||||
SESSION_SIGNING_SECRET?: string; // 用於 HMAC session ID(可選,也可直接用 UUID)
|
||||
// KBDB 整合
|
||||
KBDB_INTERNAL_TOKEN?: string;
|
||||
// Component Worker subdomain(workers.dev 帳號 subdomain)
|
||||
// 必填:cypher-executor 用此組出 component worker URL(避開同 zone 自循環死鎖,見 P0 #9)
|
||||
// self-hosted fork 必須改 wrangler.toml [vars] 為自己的帳號 subdomain
|
||||
WORKER_SUBDOMAIN: string;
|
||||
// Platform telemetry api_key(可選,wrangler secret)
|
||||
// 對應 SDD .agents/specs/llm-interface/ M1.2
|
||||
// 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下
|
||||
// 沒設就 fallback 到當下用戶的 ak_,會寫進該用戶 KBDB namespace(次優但能用)
|
||||
PLATFORM_API_KEY?: string;
|
||||
};
|
||||
|
||||
// 重新 export Cloudflare Workers ExecutionContext 以便其他 module 用
|
||||
export type { ExecutionContext } from '@cloudflare/workers-types';
|
||||
|
||||
// 圖結構定義
|
||||
export type GraphNode = {
|
||||
id: string;
|
||||
type: 'Input' | 'Component' | 'Output';
|
||||
componentId?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type EdgeType =
|
||||
| 'PIPE' | 'IF' | 'FOREACH' | 'CONTINUE' // 現有
|
||||
| 'IS_A' | 'ON_SUCCESS' | 'ON_FAIL' // 執行語意
|
||||
| 'ON_CLICK' | 'CALLS_SUBFLOW' // 觸發語意
|
||||
| 'CONTAINS' | 'HAS_STYLE' | 'HAS_BEHAVIOR'; // 結構語意(記錄圖結構,不執行)
|
||||
|
||||
export type GraphEdge = {
|
||||
from: string;
|
||||
to: string;
|
||||
type: EdgeType;
|
||||
condition?: string; // IF 的條件表達式
|
||||
iterator?: string; // FOREACH 的迭代變數名
|
||||
};
|
||||
|
||||
export type ExecutionGraph = {
|
||||
id: string;
|
||||
name: string;
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
};
|
||||
|
||||
// 執行結果
|
||||
export type ExecutionResult = {
|
||||
success: boolean;
|
||||
data: unknown;
|
||||
trace: TraceStep[];
|
||||
duration_ms: number;
|
||||
};
|
||||
|
||||
export type TraceStep = {
|
||||
nodeId: string;
|
||||
type: string;
|
||||
input: unknown;
|
||||
output: unknown;
|
||||
duration_ms: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// 零件執行器介面(直接可執行函數,不用動態 eval)
|
||||
export type ComponentRunner = (context: unknown) => unknown | Promise<unknown>;
|
||||
|
||||
// KV Context Store(BUILD-006):節點 output 命名空間前綴
|
||||
// KV key 格式:{run_id}:node:{node_id} value 是節點 output 的 JSON 字串
|
||||
// TTL = 3600 秒(1 小時),執行後自動清除
|
||||
export type KVContextStore = {
|
||||
runId: string;
|
||||
kv: KVNamespace;
|
||||
};
|
||||
|
||||
/** 從 KV 讀取節點 output(不存在時回傳 undefined)*/
|
||||
export async function kvGetNodeOutput(store: KVContextStore, nodeId: string): Promise<Record<string, unknown> | undefined> {
|
||||
try {
|
||||
const val = await store.kv.get(`${store.runId}:node:${nodeId}`, 'json');
|
||||
return val as Record<string, unknown> | undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow 暫停(resumable workflow):
|
||||
* 節點回 pending → graph-executor 持久化 state + throw 此類,被頂層接住回 paused 狀態給 caller
|
||||
* SDD: matrix/arcrun/.agents/specs/resumable-workflow/design.md
|
||||
*/
|
||||
export class WorkflowPaused extends Error {
|
||||
readonly task_id: string;
|
||||
readonly run_id: string;
|
||||
readonly paused_node_id: string;
|
||||
readonly trace_so_far: TraceStep[];
|
||||
|
||||
constructor(task_id: string, run_id: string, paused_node_id: string, trace_so_far: TraceStep[]) {
|
||||
super(`workflow paused at node ${paused_node_id} waiting for task ${task_id}`);
|
||||
this.name = 'WorkflowPaused';
|
||||
this.task_id = task_id;
|
||||
this.run_id = run_id;
|
||||
this.paused_node_id = paused_node_id;
|
||||
this.trace_so_far = trace_so_far;
|
||||
}
|
||||
}
|
||||
|
||||
/** 執行失敗時拋出的自訂 Error,攜帶完整 trace 與失敗節點資訊 */
|
||||
export class ExecutionError extends Error {
|
||||
readonly failed_node: string;
|
||||
readonly failed_input: unknown;
|
||||
readonly trace: TraceStep[];
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
failed_node: string,
|
||||
failed_input: unknown,
|
||||
trace: TraceStep[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ExecutionError';
|
||||
this.failed_node = failed_node;
|
||||
this.failed_input = failed_input;
|
||||
this.trace = trace;
|
||||
}
|
||||
}
|
||||
|
||||
/** 將節點 output 寫入 KV(TTL 1 小時)*/
|
||||
export async function kvSetNodeOutput(store: KVContextStore, nodeId: string, output: unknown): Promise<void> {
|
||||
try {
|
||||
await store.kv.put(
|
||||
`${store.runId}:node:${nodeId}`,
|
||||
JSON.stringify(output),
|
||||
{ expirationTtl: 3600 },
|
||||
);
|
||||
} catch {
|
||||
// KV 寫入失敗不影響執行(fallback 到記憶體 merge)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user