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:
uncle6me-web
2026-06-03 15:52:38 +08:00
commit 922a57fe34
485 changed files with 89356 additions and 0 deletions
@@ -0,0 +1,237 @@
/**
* Named Webhookacr 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 });
});