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
+84
View File
@@ -0,0 +1,84 @@
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>; 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')}`;
try {
const result = await handleCypherExecute(
body.triplets as unknown[],
body.context,
graphId,
graphName,
c.env,
(p) => c.executionCtx.waitUntil(p),
);
// 包裝成開發友善格式(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);
}
}
});
+49
View File
@@ -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);
});
+55
View File
@@ -0,0 +1,55 @@
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 loader = createComponentLoader(c.env);
const executor = new GraphExecutor(loader);
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);
}
});
+16
View File
@@ -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',
})
);
+26
View File
@@ -0,0 +1,26 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { graphSchema } from '../lib/schemas';
export const validateRouter = new Hono<{ Bindings: Bindings }>();
// POST /validate — 驗證圖定義(不執行)
validateRouter.post('/validate', async (c) => {
const body = await c.req.json();
const parsed = graphSchema.safeParse(body);
if (!parsed.success) {
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) {
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 });
});
+73
View File
@@ -0,0 +1,73 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { generateToken, validateAndParseWebhook, executeWebhookGraph } from '../actions/webhook-handlers';
import { resolveWebhookGraph } from '../actions/webhook-graph-resolver';
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) {
const statusCode = resolved.missingNodes ? 422 : 400;
return c.json(
{ error: resolved.error, ...(resolved.missingNodes && { missing: resolved.missingNodes }) },
statusCode,
);
}
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 result = await executeWebhookGraph(c.env, record.graph, triggerContext, token);
return c.json(result, result.success ? 200 : 500);
});