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,147 @@
|
||||
// POST /components — 零件提交端點(沙盒驗收流程)
|
||||
// POST /components/index-only — metadata-only 索引(無 wasm、無沙盒,給 backfill 用)
|
||||
// Requirements: 2.1, 2.2, 2.3
|
||||
// SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { validateContract } from '../actions/validateContract';
|
||||
import { submitComponent } from '../actions/submitComponent';
|
||||
import type { SubmitOptions } from '../actions/submitComponent';
|
||||
import { indexOnlyComponent } from '../actions/indexOnlyComponent';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
app.post('/', async c => {
|
||||
// 接受 multipart/form-data:contract(JSON 字串)+ wasm(binary)
|
||||
let contract: unknown;
|
||||
let wasmBytes: Uint8Array;
|
||||
// G0/G4:人類確認 + 舉證 + 本地 Gherkin evidence(multipart 或 JSON 皆可帶)
|
||||
const submitOptions: SubmitOptions = {};
|
||||
|
||||
const contentType = c.req.header('content-type') ?? '';
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const formData = await c.req.formData();
|
||||
const contractStr = formData.get('contract');
|
||||
const wasmFile = formData.get('wasm');
|
||||
|
||||
if (!contractStr || typeof contractStr !== 'string') {
|
||||
return c.json({ success: false, error: '缺少 contract 欄位' }, 400);
|
||||
}
|
||||
if (!wasmFile || !(wasmFile instanceof File)) {
|
||||
return c.json({ success: false, error: '缺少 wasm 欄位' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
contract = JSON.parse(contractStr);
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'contract 必須為合法 JSON' }, 400);
|
||||
}
|
||||
|
||||
wasmBytes = new Uint8Array(await wasmFile.arrayBuffer());
|
||||
|
||||
const hc = formData.get('human_confirmation');
|
||||
if (typeof hc === 'string') { try { submitOptions.human_confirmation = JSON.parse(hc); } catch { /* ignore */ } }
|
||||
const ge = formData.get('gherkin_evidence');
|
||||
if (typeof ge === 'string') { try { submitOptions.gherkin_evidence = JSON.parse(ge); } catch { /* ignore */ } }
|
||||
if (formData.get('skip_acceptance') === 'true') submitOptions.skip_acceptance = true;
|
||||
} else {
|
||||
// 也支援純 JSON(用於測試,wasm 以 base64 傳入)
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'request body 必須為 multipart/form-data 或 JSON' }, 400);
|
||||
}
|
||||
|
||||
contract = body.contract;
|
||||
const wasmBase64 = body.wasm_base64;
|
||||
|
||||
if (!contract) {
|
||||
return c.json({ success: false, error: '缺少 contract 欄位' }, 400);
|
||||
}
|
||||
if (!wasmBase64 || typeof wasmBase64 !== 'string') {
|
||||
return c.json({ success: false, error: '缺少 wasm_base64 欄位' }, 400);
|
||||
}
|
||||
|
||||
// base64 decode
|
||||
const binaryStr = atob(wasmBase64);
|
||||
wasmBytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
wasmBytes[i] = binaryStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
if (body.human_confirmation) submitOptions.human_confirmation = body.human_confirmation as SubmitOptions['human_confirmation'];
|
||||
if (body.gherkin_evidence) submitOptions.gherkin_evidence = body.gherkin_evidence as SubmitOptions['gherkin_evidence'];
|
||||
if (body.skip_acceptance === true) submitOptions.skip_acceptance = true;
|
||||
}
|
||||
|
||||
// 驗證 contract 格式
|
||||
const validation = validateContract(contract);
|
||||
if (!validation.valid) {
|
||||
return c.json({
|
||||
success: false,
|
||||
failed_step: 'contract_validation',
|
||||
reason: `合約格式驗證失敗:${validation.errors.join(', ')}`,
|
||||
missing_fields: validation.missing_fields,
|
||||
guide_anchor: '#contract-example',
|
||||
}, 422);
|
||||
}
|
||||
|
||||
// 執行沙盒驗收(含 G0 人類閘門)+ 寫入 metadata
|
||||
const result = await submitComponent(wasmBytes, validation.contract!, c.env, submitOptions);
|
||||
|
||||
if (!result.success) {
|
||||
return c.json(result, 422);
|
||||
}
|
||||
|
||||
return c.json(result, 201);
|
||||
});
|
||||
|
||||
// POST /components/index-only — metadata-only 索引(給 backfill 用)
|
||||
// 只接 contract(JSON),不收 wasm bytes、不沙盒驗收
|
||||
// 用途:cypher-executor 已不從 R2 動態載 wasm(零件用獨立 Worker URL),
|
||||
// 故已部署但未索引的零件,只要把 metadata 寫進 KV 讓 search 找得到即可。
|
||||
app.post('/index-only', async c => {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
|
||||
}
|
||||
|
||||
const contract = body.contract;
|
||||
if (!contract) {
|
||||
return c.json({ success: false, error: '缺少 contract 欄位' }, 400);
|
||||
}
|
||||
|
||||
// index-only 是 backfill 用,比 submit 寬鬆:
|
||||
// 既有零件可能沒 gherkin_tests / 沒 description / aliases — 補預設讓索引能進
|
||||
if (typeof contract === 'object' && contract !== null) {
|
||||
const c2 = contract as Record<string, unknown>;
|
||||
if (!c2.gherkin_tests || (Array.isArray(c2.gherkin_tests) && c2.gherkin_tests.length < 2)) {
|
||||
c2.gherkin_tests = [
|
||||
{ scenario: 'placeholder happy', given: '{}', then_contains: '{' },
|
||||
{ scenario: 'placeholder fail', given: '{}', then_contains: '}' },
|
||||
];
|
||||
}
|
||||
if (!c2.description) c2.description = '';
|
||||
if (!c2.tags) c2.tags = [];
|
||||
}
|
||||
|
||||
const validation = validateContract(contract);
|
||||
if (!validation.valid) {
|
||||
return c.json({
|
||||
success: false,
|
||||
failed_step: 'contract_validation',
|
||||
reason: `合約格式驗證失敗:${validation.errors.join(', ')}`,
|
||||
missing_fields: validation.missing_fields,
|
||||
}, 422);
|
||||
}
|
||||
|
||||
const result = await indexOnlyComponent(validation.contract!, c.env);
|
||||
return c.json(result, result.already_indexed ? 200 : 201);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,15 @@
|
||||
// GET /components/guide
|
||||
// Requirements: 11.1, 11.2, 11.3
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { getGuide } from '../actions/getGuide';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
app.get('/', c => {
|
||||
const markdown = getGuide();
|
||||
return c.text(markdown, 200, { 'Content-Type': 'text/markdown; charset=utf-8' });
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,20 @@
|
||||
// POST /init — 確保 tpl-component template 存在(冪等)
|
||||
// Requirements: 12.1
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { ensureTemplate } from '../actions/ensureTemplate';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
app.post('/', async c => {
|
||||
try {
|
||||
const result = await ensureTemplate(c.env);
|
||||
return c.json({ success: true, ...result });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ success: false, error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,43 @@
|
||||
// GET /components/:id — 取得零件最優版本合約
|
||||
// GET /components/:id/versions — 取得所有版本清單(含評分)
|
||||
// GET /components/search?q=... — 語意搜尋零件
|
||||
// Requirements: 12.2, 12.3
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { getComponent, getComponentVersions, searchComponents } from '../actions/queryComponents';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// 語意搜尋(必須在 /:id 之前,避免 "search" 被當作 id)
|
||||
app.get('/search', async c => {
|
||||
const q = c.req.query('q');
|
||||
if (!q || q.trim() === '') {
|
||||
return c.json({ success: false, error: 'q 參數必填' }, 400);
|
||||
}
|
||||
|
||||
const results = await searchComponents(q.trim(), c.env);
|
||||
return c.json({ success: true, data: { results, count: results.length } });
|
||||
});
|
||||
|
||||
// 取得所有版本
|
||||
app.get('/:id/versions', async c => {
|
||||
const id = c.req.param('id');
|
||||
const versions = await getComponentVersions(id, c.env);
|
||||
if (versions.length === 0) {
|
||||
return c.json({ success: false, error: `零件 ${id} 不存在` }, 404);
|
||||
}
|
||||
return c.json({ success: true, data: { versions, count: versions.length } });
|
||||
});
|
||||
|
||||
// 取得最優版本
|
||||
app.get('/:id', async c => {
|
||||
const id = c.req.param('id');
|
||||
const component = await getComponent(id, c.env);
|
||||
if (!component) {
|
||||
return c.json({ success: false, error: `零件 ${id} 不存在` }, 404);
|
||||
}
|
||||
return c.json({ success: true, data: component });
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,31 @@
|
||||
// POST /components/validate-contract
|
||||
// Requirements: 1.1, 1.2, 11.5
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { validateContract } from '../actions/validateContract';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
app.post('/', async c => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ valid: false, missing_fields: [], errors: ['request body 必須為合法 JSON'] }, 400);
|
||||
}
|
||||
|
||||
const result = validateContract(body);
|
||||
|
||||
if (result.valid) {
|
||||
return c.json({ valid: true, missing_fields: [], errors: [] }, 200);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
valid: false,
|
||||
missing_fields: result.missing_fields,
|
||||
errors: result.errors,
|
||||
}, 422);
|
||||
});
|
||||
|
||||
export default app;
|
||||
Reference in New Issue
Block a user