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:
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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,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);
|
||||
}
|
||||
});
|
||||
@@ -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,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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user