feat(arcrun): implement arcrun MVP — open-source AI workflow engine

Phase 1-5 complete per .agents/specs/u6u-core-mvp/:

**Phase 1 — Cherry-pick & cleanup**
- Create arcrun/ from cypher-executor, credentials, builtins, registry
- Remove 9 InkStone Service Bindings (KBDB, REGISTRY, CLINIC_*, AICEO, MINI_ME)
- Rewrite component-loader: 3-layer (builtin → WASM_BUCKET R2 → error)
- Remove autoPublishMissing.ts, proxy.ts (AICEO), execution-logger.ts (KBDB)
- Clean all KV namespace IDs and InkStone internal URLs from config files

**Phase 2 — contract.yaml completeness**
- Add credentials_required to gmail, google_sheets, telegram, line_notify
- Add config_example to all 21 components with annotated field descriptions

**Phase 3 — Credential injection**
- Add credential-injector.ts: AES-GCM decrypt from CREDENTIALS_KV
- Integrate into GraphExecutor before WASM execution
- Structured errors with repair instructions when credential missing

**Phase 4 — CLI (acr)**
- cli/package.json: arcrun package, bin: acr, deps: commander/js-yaml/chalk/ora
- 8 commands: init, creds push, push, run, validate, parts, list, logs
- Standard mode: writes directly to user's CF KV via CF REST API
- acr init: interactive setup with arcrun.dev API Key registration

**Phase 5 — Open source release prep**
- README.md: 5-minute quickstart, component table, workflow YAML syntax
- CONTRIBUTING.md: TinyGo dev env, component scaffolding, submission flow
- Security audit: no InkStone internal URLs/IDs in committed files
- .gitignore: exclude credentials.yaml, .wrangler, *.wasm

https://claude.ai/code/session_01BnCdSLVH8tUed9VrrPavgT
This commit is contained in:
Claude
2026-04-16 04:06:25 +00:00
commit 2707fca32b
155 changed files with 17413 additions and 0 deletions
@@ -0,0 +1,160 @@
/**
* Credential Injector
*
* 在 WASM 零件執行前,從 CREDENTIALS_KV 讀取加密 credential
* AES-GCM 解密後注入到 input 的對應欄位(inject_as)。
*
* 用戶的 workflow.yaml config 中不需要也不應該包含明文 token。
*
* 設計原則:
* - contract.yaml 的 credentials_required 宣告需要哪個 credential
* - CREDENTIALS_KV 存放 AES-GCM 加密後的 credentialkey = cred:{name}
* - 注入發生在 WASM 執行前,不修改 WEBHOOKS KV 中儲存的 workflow 定義
*/
import type { Bindings } from '../types';
export interface CredentialRequirement {
key: string; // CREDENTIALS_KV 的 key(如 gmail_token
type: string; // token 類型(如 google_oauth
description: string; // 說明
inject_as: string; // 注入到 input 的欄位名稱(如 access_token
}
/**
* 讀取並解析零件的 contract.yaml(從 WASM_BUCKET
* 回傳 credentials_required 陣列,若不存在則回傳空陣列
*/
async function loadCredentialsRequired(
componentId: string,
wasmBucket: R2Bucket,
): Promise<CredentialRequirement[]> {
const contractKey = `${componentId}/component.contract.yaml`;
const obj = await wasmBucket.get(contractKey);
if (!obj) return [];
const yamlText = await obj.text();
return parseCredentialsRequired(yamlText);
}
/**
* 從 YAML 文字解析 credentials_required 欄位
* 使用簡單的正規表達式解析(避免引入 js-yaml 依賴)
*/
function parseCredentialsRequired(yaml: string): CredentialRequirement[] {
const credsSection = yaml.match(/credentials_required:\s*([\s\S]*?)(?=\n\w|\n#|$)/);
if (!credsSection) return [];
const items: CredentialRequirement[] = [];
const blockText = credsSection[1];
// 解析 " - key: xxx" 開頭的項目
const itemMatches = blockText.split(/\n - /).slice(1);
for (const item of itemMatches) {
const key = item.match(/key:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
const type = item.match(/type:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
const description = item.match(/description:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim() ?? '';
const inject_as = item.match(/inject_as:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
if (key && type && inject_as) {
items.push({ key, type, description, inject_as });
}
}
return items;
}
/**
* AES-GCM 解密(與 credentials Worker 的加密邏輯對應)
* CREDENTIALS_KV 儲存格式:{ encrypted: base64, iv: base64 }
*/
async function decryptCredential(encryptedJson: string, encryptionKey: string): Promise<string> {
const { encrypted, iv } = JSON.parse(encryptedJson) as { encrypted: string; iv: string };
// 將 hex-encoded 256-bit key 轉為 CryptoKey
const keyBytes = hexToUint8Array(encryptionKey);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['decrypt'],
);
const ivBytes = base64ToUint8Array(iv);
const cipherBytes = base64ToUint8Array(encrypted);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivBytes },
cryptoKey,
cipherBytes,
);
return new TextDecoder().decode(decrypted);
}
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
}
return bytes;
}
function base64ToUint8Array(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* 執行 credential 注入
*
* @param componentId - 零件 canonical_id
* @param input - 節點的原始 input(來自 workflow config
* @param env - Cloudflare Worker Bindings
* @returns 注入 credential 後的 input
*
* @throws 若 credential 不存在,拋出結構化錯誤(含 key 名稱與修復步驟)
*/
export async function injectCredentials(
componentId: string,
input: Record<string, unknown>,
env: Bindings,
): Promise<Record<string, unknown>> {
// 讀取 contract.yaml 中的 credentials_required
const required = await loadCredentialsRequired(componentId, env.WASM_BUCKET);
if (required.length === 0) return input;
const enriched = { ...input };
for (const cred of required) {
const kvKey = `cred:${cred.key}`;
const record = await env.CREDENTIALS_KV.get(kvKey);
if (!record) {
throw new Error(
`缺少 credential${cred.key}${cred.description}\n` +
`修復步驟:\n` +
` 1. 在 credentials.yaml 中加入:\n` +
` ${cred.key}: "your-${cred.type}-token"\n` +
` 2. 執行:acr creds push`
);
}
try {
const decrypted = await decryptCredential(record, env.ENCRYPTION_KEY);
enriched[cred.inject_as] = decrypted;
} catch (e) {
throw new Error(
`credential "${cred.key}" 解密失敗:${e instanceof Error ? e.message : String(e)}\n` +
`修復步驟:重新執行 acr creds push 上傳正確的 credential。`
);
}
}
return enriched;
}
@@ -0,0 +1,112 @@
import type { Bindings, ExecutionGraph } from '../types';
import { ExecutionError } from '../types';
import { GraphExecutor } from '../graph-executor';
import { graphSchema } from '../lib/schemas';
import { createComponentLoader } from '../lib/component-loader';
import { writeEvaluation, updateComponentStats } from './execution-evaluator';
import { parseTriplets } from './triplet-parser';
import { searchNodes } from './search-nodes';
import { buildExecutionGraph } from './graph-builder';
export async function handleCypherSearch(
triplets: unknown[],
env: Bindings,
): Promise<{ nodes: Record<string, unknown>; cypher: unknown; missing: string[] }> {
const parsed = parseTriplets(triplets);
if (!parsed) {
throw new Error('無法解析任何節點');
}
const { nodeResults, missingNodes } = await searchNodes(parsed, env.WASM_BUCKET);
if (missingNodes.length > 0) {
return { nodes: nodeResults, cypher: null, missing: missingNodes };
}
const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result');
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: [] };
}
export async function handleCypherExecute(
triplets: unknown[],
context: Record<string, unknown> | undefined,
graphId: string,
graphName: string,
env: Bindings,
waitUntil: (promise: Promise<void>) => void,
): Promise<{ success: boolean; data?: unknown; error?: string; trace?: unknown; duration_ms: number; graph?: ExecutionGraph }> {
const parsed = parseTriplets(triplets as unknown[]);
if (!parsed) {
throw new Error('無法解析任何節點');
}
const { nodeResults, missingNodes } = await searchNodes(parsed, env.WASM_BUCKET);
if (missingNodes.length > 0) {
throw new Error(
`以下零件不存在於 WASM_BUCKET${missingNodes.join(', ')}\n` +
`修復:執行 acr parts 查看可用零件清單,或執行 acr validate <workflow.yaml> 進行完整驗證。`
);
}
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName);
const parseResult = graphSchema.safeParse(graph);
if (!parseResult.success) {
throw new Error('圖定義產生失敗');
}
const loader = createComponentLoader(env);
const executor = new GraphExecutor(loader, undefined, env);
const start = Date.now();
try {
const result = await executor.execute(parseResult.data as ExecutionGraph, context ?? {}, env.EXEC_CONTEXT);
const duration_ms = Date.now() - start;
// 非同步記錄統計(Phase 7 補充 analytics,目前為 no-op
const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId;
const runId = `${graphId}-${Date.now()}`;
waitUntil(writeEvaluation(env, {
run_id: runId,
workflow_id: graphId,
component_id: componentId,
verdict: 'success',
duration_ms,
evaluated_at: Date.now(),
}));
waitUntil(updateComponentStats(env, componentId, 'success', duration_ms));
return { success: true, data: result.data, trace: result.trace, duration_ms, graph };
} catch (err) {
const duration_ms = Date.now() - start;
const errMsg = err instanceof Error ? err.message : String(err);
const componentId = graph.nodes.find(n => n.componentId)?.componentId ?? graphId;
const runId = `${graphId}-${Date.now()}`;
waitUntil(writeEvaluation(env, {
run_id: runId,
workflow_id: graphId,
component_id: componentId,
verdict: 'failed',
duration_ms,
error_message: errMsg.slice(0, 200),
evaluated_at: Date.now(),
}));
waitUntil(updateComponentStats(env, componentId, 'failed', duration_ms));
if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map(s => ({
node: s.nodeId,
status: s.error ? 'failed' : 'success',
...(s.error ? { error: s.error } : {}),
}));
throw new Error(JSON.stringify({
success: false,
error: errMsg,
failed_node: err.failed_node,
failed_input: err.failed_input,
trace: traceFormatted,
duration_ms,
}));
}
throw err;
}
}
@@ -0,0 +1,36 @@
/**
* Execution Analytics — 零件執行後的統計記錄
*
* Phase 1 MVPstub(不寫入任何外部服務)
* Phase 7 補充:fire-and-forget POST 至 registry.arcrun.dev/analytics/record
*/
import type { Bindings } from '../types';
export interface EvaluationRecord {
run_id: string;
workflow_id: string;
component_id: string;
verdict: 'success' | 'failed' | 'timeout';
duration_ms: number;
error_message?: string;
evaluated_at: number;
}
/** 記錄執行結果(MVPno-opPhase 7 補充 analytics*/
export async function writeEvaluation(
_env: Bindings,
_record: EvaluationRecord,
): Promise<void> {
// Phase 7: POST to registry.arcrun.dev/analytics/record
}
/** 更新零件統計(MVPno-opPhase 7 補充)*/
export async function updateComponentStats(
_env: Bindings,
_componentId: string,
_verdict: 'success' | 'failed' | 'timeout',
_durationMs: number,
): Promise<void> {
// Phase 7: update ANALYTICS_KV via registry worker
}
@@ -0,0 +1,30 @@
import type { ParsedTriplets } from './triplet-parser';
import { toEdgeType } from './triplet-parser';
import type { SearchResult } from './search-nodes';
/** 從 nodeResults + parsed 組成可直接送入 /execute 的 ExecutionGraph */
export function buildExecutionGraph(
parsed: ParsedTriplets,
nodeResults: SearchResult['nodeResults'],
graphId: string,
graphName: string,
) {
const nodes = [...parsed.nodeNames].map(name => {
const nr = nodeResults[name]!;
const id = name.toLowerCase().replace(/\s+/g, '-');
return {
id,
type: nr.type,
componentId: nr.componentId,
label: name,
};
});
const edges = parsed.edges.map(e => ({
from: e.from.toLowerCase().replace(/\s+/g, '-'),
to: e.to.toLowerCase().replace(/\s+/g, '-'),
type: toEdgeType(e.label),
}));
return { id: graphId, name: graphName, nodes, edges };
}
@@ -0,0 +1,61 @@
import { BUILTIN_IDS } from '../lib/constants';
import type { ParsedTriplets, NodeRole } from './triplet-parser';
import { resolveNodeRole } from './triplet-parser';
import type { Bindings } from '../types';
export type SearchResult = {
nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }>;
missingNodes: string[];
};
/**
* 對所有節點進行解析,確認每個節點對應的零件是否存在。
*
* 優先序:
* 1. Input/Output 角色:自動標記,不需查找
* 2. 內建零件(BUILTIN_IDS):直接標記 found
* 3. WASM_BUCKET 查找:確認 {componentId}/{componentId}.wasm 是否存在
*/
export async function searchNodes(
parsed: ParsedTriplets,
wasmBucket: R2Bucket,
): Promise<SearchResult> {
const nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }> = {};
const missingNodes: string[] = [];
for (const nodeName of parsed.nodeNames) {
const role = resolveNodeRole(nodeName, parsed);
// 事件源節點(起始點):自動標記 Input,不查 WASM_BUCKET
if (role === 'Input') {
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
continue;
}
// 輸出節點
if (role === 'Output') {
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
continue;
}
// 內建零件:直接標記 found
if (BUILTIN_IDS.has(nodeName)) {
nodeResults[nodeName] = { status: 'found', componentId: nodeName, type: role };
continue;
}
// WASM_BUCKET 查找:確認 {nodeName}/{nodeName}.wasm 是否存在
// 節點名稱即零件 canonical_id(如 "gmail"、"telegram"
const wasmKey = `${nodeName}/${nodeName}.wasm`;
const obj = await wasmBucket.head(wasmKey);
if (obj) {
nodeResults[nodeName] = { status: 'found', componentId: nodeName, type: role };
} else {
nodeResults[nodeName] = { status: 'missing', type: role };
missingNodes.push(nodeName);
}
}
return { nodeResults, missingNodes };
}
@@ -0,0 +1,117 @@
import { SEMANTIC_EDGE_MAP, VALID_EDGE_TYPES } from '../lib/constants';
import type { EdgeType } from '../types';
export type ParsedTriplets = {
edges: Array<{ from: string; to: string; label: string }>;
nodeNames: Set<string>;
/** 出現在 from 但不出現在任何 to 的節點(事件源 / 起始點) */
sourceNodes: Set<string>;
/** 出現在 to 但不出現在任何 from 的節點(終點)*/
sinkNodes: Set<string>;
};
export type NodeRole = 'Input' | 'Component' | 'Output';
/**
* 解析後的零件 URI
* 支援格式:
* component://validate_json
* component://validate_json@stable
* component://validate_json@pinned:v1
* workflow://wf_save_to_db
* ui://u6u-btn
* style://glow-effect
*/
export interface ResolvedComponentId {
type: 'component' | 'workflow' | 'ui' | 'style';
canonicalId: string;
stability: 'floating' | 'stable' | 'pinned';
pinnedVersion?: string;
/** 原始 URI 字串 */
raw: string;
}
/** 解析零件 URI 協議 */
export function resolveComponentId(uri: string): ResolvedComponentId {
const raw = uri.trim();
// 解析協議前綴
let type: ResolvedComponentId['type'] = 'component';
let rest = raw;
if (raw.startsWith('component://')) {
type = 'component';
rest = raw.slice('component://'.length);
} else if (raw.startsWith('workflow://')) {
type = 'workflow';
rest = raw.slice('workflow://'.length);
} else if (raw.startsWith('ui://')) {
type = 'ui';
rest = raw.slice('ui://'.length);
} else if (raw.startsWith('style://')) {
type = 'style';
rest = raw.slice('style://'.length);
}
// 解析穩定性標籤
// component://id@stable
// component://id@pinned:v1
let canonicalId = rest;
let stability: ResolvedComponentId['stability'] = 'floating';
let pinnedVersion: string | undefined;
const atIdx = rest.indexOf('@');
if (atIdx > 0) {
canonicalId = rest.slice(0, atIdx);
const tag = rest.slice(atIdx + 1);
if (tag === 'stable') {
stability = 'stable';
} else if (tag.startsWith('pinned:')) {
stability = 'pinned';
pinnedVersion = tag.slice('pinned:'.length);
}
}
return { type, canonicalId, stability, pinnedVersion, raw };
}
/** 解析 triplets 字串陣列,回傳節點與邊的結構 */
export function parseTriplets(rawTriplets: unknown[]): ParsedTriplets | null {
const edges: Array<{ from: string; to: string; label: string }> = [];
const nodeNames = new Set<string>();
const fromSet = new Set<string>();
const toSet = new Set<string>();
for (const line of rawTriplets) {
if (typeof line !== 'string') continue;
const parts = line.split('>>').map((s: string) => s.trim());
if (parts.length !== 3) continue;
const [from, action, to] = parts;
edges.push({ from, to, label: action });
nodeNames.add(from);
nodeNames.add(to);
fromSet.add(from);
toSet.add(to);
}
if (nodeNames.size === 0) return null;
const sourceNodes = new Set([...fromSet].filter(n => !toSet.has(n)));
const sinkNodes = new Set([...toSet].filter(n => !fromSet.has(n)));
return { edges, nodeNames, sourceNodes, sinkNodes };
}
/** 根據節點在圖中的位置決定其 type */
export function resolveNodeRole(name: string, parsed: ParsedTriplets): NodeRole {
if (parsed.sourceNodes.has(name)) return 'Input';
if (parsed.sinkNodes.has(name)) return 'Output';
return 'Component';
}
/** 將 edge label 轉換為合法 EdgeType
* 優先序:VALID_EDGE_TYPES(完整匹配)→ SEMANTIC_EDGE_MAP(語意別名)→ 預設 PIPE */
export function toEdgeType(label: string): EdgeType {
const upper = label.toUpperCase();
if (VALID_EDGE_TYPES.has(upper)) return upper as EdgeType;
return (SEMANTIC_EDGE_MAP[label] ?? SEMANTIC_EDGE_MAP[upper] ?? 'PIPE') as EdgeType;
}
@@ -0,0 +1,63 @@
import type { Bindings } from '../types';
import { graphSchema } from '../lib/schemas';
import { parseTriplets } from './triplet-parser';
import { searchNodes } from './search-nodes';
import { buildExecutionGraph } from './graph-builder';
export async function resolveWebhookGraph(
body: Record<string, unknown>,
description: string,
env: Bindings,
): Promise<{ resolvedGraph: Record<string, unknown>; error?: string; missingNodes?: string[] }> {
// 路徑 Atriplets 格式
if (Array.isArray(body.triplets) && body.triplets.length > 0) {
const parsed = parseTriplets(body.triplets as unknown[]);
if (!parsed) return { resolvedGraph: {}, error: '無法解析 triplets' };
const { nodeResults, missingNodes } = await searchNodes(parsed, env.WASM_BUCKET);
if (missingNodes.length > 0) {
return { resolvedGraph: {}, error: `以下零件不存在:${missingNodes.join(', ')}。請執行 acr validate 確認所有零件已上傳。`, missingNodes };
}
const graphId = `webhook-${Date.now()}`;
const graphName = description || `Webhook ${new Date().toISOString()}`;
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName) as Record<string, unknown>;
const parseResult = graphSchema.safeParse(graph);
if (!parseResult.success) {
return { resolvedGraph: {}, error: '圖定義產生失敗' };
}
return { resolvedGraph: graph };
}
// 路徑 Bgraph 格式
if (body.graph && typeof body.graph === 'object') {
const graphWithDefaults = {
id: `webhook-${Date.now()}`,
name: description || `Webhook ${new Date().toISOString()}`,
...(body.graph as Record<string, unknown>),
};
const parsed = graphSchema.safeParse(graphWithDefaults);
if (!parsed.success) {
return { resolvedGraph: {}, error: '圖定義驗證失敗' };
}
return { resolvedGraph: graphWithDefaults };
}
// 路徑 Cbody 直接就是 graph
if (body.nodes && body.edges) {
const graphWithDefaults = {
id: `webhook-${Date.now()}`,
name: description || `Webhook ${new Date().toISOString()}`,
...body,
};
const parsed = graphSchema.safeParse(graphWithDefaults);
if (!parsed.success) {
return { resolvedGraph: {}, error: '圖定義驗證失敗' };
}
return { resolvedGraph: graphWithDefaults };
}
return { resolvedGraph: {}, error: '需提供 graph 物件或 triplets 陣列' };
}
@@ -0,0 +1,67 @@
import type { Bindings, ExecutionGraph } from '../types';
import { ExecutionError } from '../types';
import { GraphExecutor } from '../graph-executor';
import { graphSchema } from '../lib/schemas';
import { createComponentLoader } from '../lib/component-loader';
type WebhookRecord = {
graph: Record<string, unknown>;
description: string;
created_at: string;
};
export function generateToken(): string {
const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(tokenBytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
export async function validateAndParseWebhook(raw: string): Promise<WebhookRecord | null> {
try {
return JSON.parse(raw) as WebhookRecord;
} catch {
return null;
}
}
export async function executeWebhookGraph(
env: Bindings,
graph: Record<string, unknown>,
triggerContext: Record<string, unknown>,
token: string,
): Promise<{ success: boolean; data?: unknown; error?: string; trace?: unknown; duration_ms: number }> {
const parsed = graphSchema.safeParse(graph);
if (!parsed.success) {
return { success: false, error: '圖定義已失效', duration_ms: 0 };
}
const loader = createComponentLoader(env);
const executor = new GraphExecutor(loader, undefined, env);
const start = Date.now();
try {
const result = await executor.execute(
parsed.data as ExecutionGraph,
{ ...triggerContext, _webhook_token: token },
env.EXEC_CONTEXT,
);
const duration_ms = Date.now() - start;
return { success: true, data: result.data, duration_ms };
} catch (err) {
const duration_ms = Date.now() - start;
const errMsg = err instanceof Error ? err.message : String(err);
if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map(s => ({
node: s.nodeId,
status: s.error ? 'failed' : 'success',
...(s.error ? { error: s.error } : {}),
}));
return {
success: false,
error: errMsg,
trace: traceFormatted,
duration_ms,
};
}
return { success: false, error: errMsg, duration_ms };
}
}