feat(arcrun): recipe system + resumable workflow + component registry canon

Three new platform capabilities + one component (kbdb_get) to enable
real AI workflow execution through cypher binding YAML.

## Recipe System (容器 + Recipe 模式)
SDD: .agents/specs/recipe-system/

- prompt_recipe schema (Zod): fragments + inputs + assembly + output
- recipe-expander.ts: expand recipe ref → real prompt by fetching KBDB blocks
  + pulling context fields with transforms (pluck_content / extract_field / etc)
- 7 transform whitelist: json_array / to_string / join / markdown_list /
  extract_field / first / pluck_content
- graph-executor hooks: detect node.data.recipe → expand → inject into ctx
- output JSON parser (with markdown fence stripping for Claude-wrapped JSON)
- Stored in RECIPES KV under prompt_recipe:{name}

## Resumable Workflow (webhook callback resume)
SDD: .agents/specs/resumable-workflow/

- WorkflowPaused class + paused-runs.ts (persist/load/consume in EXEC_CONTEXT KV, 24h TTL)
- graph-executor: detect {pending:true, task_id} → persist state → throw WorkflowPaused
- cypher-handlers: catch → return {success:true, paused:true, task_id, run_id}
- POST /workflows/resume route: consume KV state → resumeFromPaused()
- Auto-inject callback_url for claude_api nodes (PUBLIC_BASE_URL or default cypher.arcrun.dev)
- claude_api/main.go: forward callback_url to Mira daemon, default timeout 25s→120s
- Idempotent (consume = load+delete)

## Component Registry Canon
SDD: .agents/specs/component-registry-canon/

- Add POST /components/index-only endpoint (metadata-only, no wasm/sandbox)
- Backfill script (mjs): scan registry/components/*/contract.yaml → submit to KV
- register-component.sh: SSOT for local + CI hook (deploy.yml change in next commit)
- Drop R2 dead storage from submitComponent + types + wrangler
- Schema relaxed: category enum + auth/ai/platform; cold_start 50→500ms; size 2→8MB

## kbdb_get component
- registry/components/kbdb_get/: TinyGo WASM, two modes (block_id / page_name list)
- .component-builds/kbdb_get/: WASI shim worker (kbdb-get.arcrun.dev)

End-to-end validation: AI uses MCP execute_workflow with recipe ref →
cypher-executor expands prompt from KBDB schema/skill blocks + drafts →
claude_api calls Mira daemon → daemon callback fires resume route →
workflow continues. Verified with real 2KB+ Karpathy LLM Wiki draft.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 15:52:19 +08:00
parent e2221161a8
commit 497f92a268
32 changed files with 3562 additions and 36 deletions
+180
View File
@@ -0,0 +1,180 @@
// claude_api — 呼叫 Mira daemonHetzner 上跑的 Claude Agent SDK 服務)
//
// 架構決策(2026-05-06):
// 不直打 Anthropic Messages APIOAuth token 限制 system prompt 角色 → rate_limit_error
// 改透過已部署的 cloud-cto Mira daemon (https://mira.uncle6.me/mira/execute)
// 該 daemon 用 Claude Agent SDK,已內建 Mira persona,可長執行任務
//
// SDD: polaris/mira/.agents/specs/mira-app/design.md §6(五個 P0 零件)
//
//go:build tinygo
package main
import (
"encoding/json"
"io"
"os"
"unsafe"
)
//go:wasmimport u6u http_request
func hostHttpRequest(
urlPtr uintptr, urlLen uint32,
methodPtr uintptr, methodLen uint32,
headersPtr uintptr, headersLen uint32,
bodyPtr uintptr, bodyLen uint32,
outPtr uintptr, outLenPtr uintptr,
) uint32
type Input struct {
MiraURL string `json:"mira_url"` // 預設 https://mira.uncle6.me
MiraToken string `json:"mira_token"` // Mira daemon Bearer token
Prompt string `json:"prompt"` // 必填:要傳給 Mira 的訊息
TimeoutMS int `json:"timeout_ms"` // 預設 25000daemon 協商模式上限)
Model string `json:"model"` // 'haiku' / 'sonnet' / 'opus',預設 haikudaemon 端)
CallbackURL string `json:"callback_url"` // optionaldaemon 完成 task 時 POST 此 URL 通知(Resumable workflowSDD: resumable-workflow
}
var dummy [1]byte
func safePtr(b []byte) (uintptr, uint32) {
if len(b) == 0 {
return uintptr(unsafe.Pointer(&dummy[0])), 0
}
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
}
func main() {
raw, err := io.ReadAll(os.Stdin)
if err != nil {
writeError("failed to read stdin: " + err.Error())
return
}
var input Input
if err := json.Unmarshal(raw, &input); err != nil {
writeError("invalid input JSON: " + err.Error())
return
}
if input.MiraToken == "" {
writeError("mira_token 必填(Mira daemon Bearer token")
return
}
if input.Prompt == "" {
writeError("prompt 必填")
return
}
miraURL := input.MiraURL
if miraURL == "" {
miraURL = "https://mira.uncle6.me"
}
timeoutMS := input.TimeoutMS
if timeoutMS <= 0 {
// 預設 120sdaemon 協商期會在 25s 切非同步 + callback
// callback_url 存在時,timeout 上限不重要(daemon 會 fire callback 不論多久)
timeoutMS = 120000
}
// Mira daemon /execute 介面
body := map[string]interface{}{
"prompt": input.Prompt,
"timeout_ms": timeoutMS,
}
if input.Model != "" {
body["model"] = input.Model
}
if input.CallbackURL != "" {
body["callback_url"] = input.CallbackURL
}
bodyBytes, _ := json.Marshal(body)
headers := map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer " + input.MiraToken,
}
headersBytes, _ := json.Marshal(headers)
url := miraURL + "/mira/execute"
urlBytes := []byte(url)
methodBytes := []byte("POST")
outBuf := make([]byte, 1024*1024) // 1MB
var outLen uint32
urlPtr, urlLen := safePtr(urlBytes)
methodPtr, methodLen := safePtr(methodBytes)
headersPtr, headersLen := safePtr(headersBytes)
bodyPtr, bodyLenU := safePtr(bodyBytes)
result := hostHttpRequest(
urlPtr, urlLen,
methodPtr, methodLen,
headersPtr, headersLen,
bodyPtr, bodyLenU,
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if result != 0 {
writeError("Mira daemon request failed (host_http_request returned non-zero)")
return
}
respStr := string(outBuf[:outLen])
var resp map[string]interface{}
if err := json.Unmarshal([]byte(respStr), &resp); err != nil {
writeError("Mira returned non-JSON: " + respStr)
return
}
// 偵測錯誤回應
if errObj, hasErr := resp["error"]; hasErr {
errBytes, _ := json.Marshal(errObj)
writeError("Mira error: " + string(errBytes))
return
}
// daemon 回應格式:
// 同步完成: {"task_id":"...","status":"done","output":"...","model":"..."}
// 非同步: {"task_id":"...","status":"running","estimated_seconds":N}
status, _ := resp["status"].(string)
if status == "running" {
// 還沒完成,回傳 task_id 給 caller 自己 polling
out, _ := json.Marshal(map[string]interface{}{
"success": true,
"pending": true,
"task_id": resp["task_id"],
"estimated_seconds": resp["estimated_seconds"],
"poll_url": miraURL + "/mira/execute/" + toString(resp["task_id"]),
})
os.Stdout.Write(out)
return
}
// status == "done" 的場景
out := map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"text": resp["output"],
"task_id": resp["task_id"],
"model": resp["model"],
},
}
outJSON, _ := json.Marshal(out)
os.Stdout.Write(outJSON)
}
func toString(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func writeError(msg string) {
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
os.Stdout.Write(out)
}
@@ -0,0 +1,69 @@
canonical_id: "kbdb_get"
display_name: "KBDB 讀取"
category: "data"
version: "v1"
wasi_target: "preview1"
stability: "floating"
runtime_compat:
- "cf-workers"
- "workerd"
- "wazero"
constraints:
max_size_kb: 2048
max_cold_start_ms: 50
no_network_syscall: false
no_filesystem_syscall: true
io_model: "stdin_stdout_json"
input_schema:
type: object
required: [api_key]
properties:
api_key:
type: string
description: KBDB partner keypk_live_xxx 或 ak_xxx
block_id:
type: string
description: 取單一 block。給 block_id 走 GET /blocks/{id},與 page_name 二擇一
page_name:
type: string
description: 按 page_name 查列表。走 GET /blocks?page_name=...&limit=N
limit:
type: integer
description: page_name 模式下的最大筆數,預設 50
default: 50
kbdb_url:
type: string
description: KBDB API base(選填,預設 https://kbdb.finally.click
default: "https://kbdb.finally.click"
output_schema:
type: object
properties:
success:
type: boolean
blocks:
type: array
description: page_name 模式回多個 blockblock_id 模式回 1 個(仍包成陣列方便下游 foreach)
items:
type: object
count:
type: integer
description: blocks.length
error:
type: string
gherkin_tests:
- scenario: "缺 api_key"
given: '{"page_name":"x"}'
then_contains: '{"success":false'
- scenario: "block_id 與 page_name 都沒給"
given: '{"api_key":"pk_live_x"}'
then_contains: '{"success":false'
tags: [data, storage, kbdb, get, query, primitive]
description: "從 KBDB 讀 block。支援兩種模式:(1) block_id 取單一 block(2) page_name 取列表。透過 host function http_request 呼叫 KBDB GET /blocks 或 /blocks/:id。Mira wiki 合成 / 各 source workflow 讀草稿 / 查 wiki schema 都走這條。"
config_example: |
read_schema: # 取單一 block
api_key: "{{secret.kbdb_key}}"
page_name: "mira-wiki-schema"
read_drafts: # 取列表
api_key: "{{secret.kbdb_key}}"
page_name: "{{prev.entity_name}}"
limit: 100
+184
View File
@@ -0,0 +1,184 @@
// kbdb_get — 從 KBDB 讀 blockGET /blocks?page_name=... 或 GET /blocks/:id
// thin wrapper:透過 host function http_request 呼叫 KBDB API
//
//go:build tinygo
package main
import (
"encoding/json"
"io"
"os"
"strconv"
"unsafe"
)
//go:wasmimport u6u http_request
func hostHttpRequest(
urlPtr uintptr, urlLen uint32,
methodPtr uintptr, methodLen uint32,
headersPtr uintptr, headersLen uint32,
bodyPtr uintptr, bodyLen uint32,
outPtr uintptr, outLenPtr uintptr,
) uint32
type Input struct {
KBDBUrl string `json:"kbdb_url"` // optional
APIKey string `json:"api_key"` // 必填
BlockID string `json:"block_id"` // 與 page_name 二擇一
PageName string `json:"page_name"` // 與 block_id 二擇一
Limit int `json:"limit"` // optional, default 50
}
var dummy [1]byte
func safePtr(b []byte) (uintptr, uint32) {
if len(b) == 0 {
return uintptr(unsafe.Pointer(&dummy[0])), 0
}
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
}
func writeError(msg string) {
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
os.Stdout.Write(out)
}
// urlEncode:簡易 query string encoder(只處理 KBDB 會用到的字元)
// 避免引入 net/url(白名單外)
func urlEncode(s string) string {
var out []byte
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '~' {
out = append(out, c)
} else {
const hex = "0123456789ABCDEF"
out = append(out, '%', hex[c>>4], hex[c&0x0f])
}
}
return string(out)
}
func main() {
raw, err := io.ReadAll(os.Stdin)
if err != nil {
writeError("failed to read stdin: " + err.Error())
return
}
var input Input
if err := json.Unmarshal(raw, &input); err != nil {
writeError("invalid input JSON: " + err.Error())
return
}
if input.APIKey == "" {
writeError("api_key 必填")
return
}
if input.BlockID == "" && input.PageName == "" {
writeError("block_id 或 page_name 必填其一")
return
}
kbdbURL := input.KBDBUrl
if kbdbURL == "" {
kbdbURL = "https://kbdb.finally.click"
}
limit := input.Limit
if limit <= 0 {
limit = 50
}
// 構造 URLblock_id 模式走 /blocks/:id(單一),page_name 模式走 /blocks?page_name=...&limit=N
var url string
if input.BlockID != "" {
url = kbdbURL + "/blocks/" + urlEncode(input.BlockID)
} else {
url = kbdbURL + "/blocks?page_name=" + urlEncode(input.PageName) +
"&limit=" + strconv.Itoa(limit)
}
headers := map[string]string{
"Authorization": "Bearer " + input.APIKey,
}
headersBytes, _ := json.Marshal(headers)
method := "GET"
urlBytes := []byte(url)
methodBytes := []byte(method)
outBuf := make([]byte, 1<<20) // 1MBlist 可能很大)
var outLen uint32
urlPtr, urlLen := safePtr(urlBytes)
methodPtr, methodLen := safePtr(methodBytes)
headersPtr, headersLenU := safePtr(headersBytes)
bodyPtr, bodyLenU := safePtr(nil)
result := hostHttpRequest(
urlPtr, urlLen,
methodPtr, methodLen,
headersPtr, headersLenU,
bodyPtr, bodyLenU,
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if result != 0 {
writeError("KBDB GET request failed (host_http_request returned non-zero)")
return
}
respStr := string(outBuf[:outLen])
// 解析回應
if input.BlockID != "" {
// 單一 blockKBDB 直接回 block 物件,包成 array 給下游 foreach
var block map[string]interface{}
if err := json.Unmarshal([]byte(respStr), &block); err != nil {
writeError("KBDB returned non-JSON: " + respStr)
return
}
if _, hasErr := block["error"]; hasErr {
out, _ := json.Marshal(map[string]interface{}{
"success": false, "error": block["error"],
})
os.Stdout.Write(out)
return
}
out, _ := json.Marshal(map[string]interface{}{
"success": true,
"blocks": []map[string]interface{}{block},
"count": 1,
})
os.Stdout.Write(out)
return
}
// page_name 列表模式:KBDB 回 {"blocks": [...], "count": N}
var listResp struct {
Blocks []map[string]interface{} `json:"blocks"`
Count int `json:"count"`
Error interface{} `json:"error"`
}
if err := json.Unmarshal([]byte(respStr), &listResp); err != nil {
writeError("KBDB returned non-JSON: " + respStr)
return
}
if listResp.Error != nil {
out, _ := json.Marshal(map[string]interface{}{
"success": false, "error": listResp.Error,
})
os.Stdout.Write(out)
return
}
out, _ := json.Marshal(map[string]interface{}{
"success": true,
"blocks": listResp.Blocks,
"count": listResp.Count,
})
os.Stdout.Write(out)
}
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
// Backfill component metadata 進 registry index
// SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 1
//
// 用法:
// node scripts/backfill-index.mjs --dry-run # 看會送什麼
// node scripts/backfill-index.mjs # 真的灌
//
// 流程:
// 1. 掃 ../components/*/component.contract.yaml
// 2. 解析 YAML(用 zero-dep 簡易 parsercontract 是 well-formed YAML
// 3. 對每個 contract POST registry.arcrun.dev/components/index-only
// 4. 印 success / already_indexed / fail 統計
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const COMPONENTS_DIR = join(__dirname, '..', 'components');
const REGISTRY_URL = process.env.REGISTRY_URL ?? 'https://registry.arcrun.dev';
const DRY_RUN = process.argv.includes('--dry-run');
// YAML 是 well-formed contract.yaml,用 js-yaml 解析最穩
async function parseYaml(text) {
const { load } = await import('js-yaml');
return load(text);
}
function listComponents() {
return readdirSync(COMPONENTS_DIR)
.filter((name) => {
const p = join(COMPONENTS_DIR, name);
return statSync(p).isDirectory();
})
.sort();
}
async function readContract(name) {
const path = join(COMPONENTS_DIR, name, 'component.contract.yaml');
const text = readFileSync(path, 'utf-8');
return parseYaml(text);
}
async function postIndexOnly(contract) {
const res = await fetch(`${REGISTRY_URL}/components/index-only`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contract }),
});
const body = await res.text();
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = { raw: body };
}
return { status: res.status, body: parsed };
}
async function main() {
console.log('=== arcrun Component Registry Backfill ===');
console.log(`Registry: ${REGISTRY_URL}`);
console.log(`Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`);
console.log();
const names = listComponents();
console.log(`Found ${names.length} components in ${COMPONENTS_DIR}`);
console.log();
const stats = { created: 0, already: 0, fail: 0 };
for (const name of names) {
let contract;
try {
contract = await readContract(name);
} catch (e) {
console.log(`${name.padEnd(28)} READ FAIL: ${e.message}`);
stats.fail++;
continue;
}
const cid = contract.canonical_id ?? '(no canonical_id)';
const ver = contract.version ?? '(no version)';
if (DRY_RUN) {
console.log(`${name.padEnd(28)} ${cid} ${ver}`);
continue;
}
try {
const { status, body } = await postIndexOnly(contract);
if (status === 200 && body.already_indexed) {
console.log(` = ${name.padEnd(28)} ${cid} ${ver} [already]`);
stats.already++;
} else if (status === 201) {
console.log(`${name.padEnd(28)} ${cid} ${ver} [${body.component_hash_id}]`);
stats.created++;
} else {
console.log(`${name.padEnd(28)} ${cid} ${ver} HTTP ${status}: ${JSON.stringify(body).slice(0, 200)}`);
stats.fail++;
}
} catch (e) {
console.log(`${name.padEnd(28)} POST FAIL: ${e.message}`);
stats.fail++;
}
}
console.log();
console.log('=== Summary ===');
console.log(`Created: ${stats.created}`);
console.log(`Already indexed: ${stats.already}`);
console.log(`Failed: ${stats.fail}`);
process.exit(stats.fail > 0 ? 1 : 0);
}
main().catch((e) => {
console.error('Fatal:', e);
process.exit(1);
});
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# 單一 component 註冊到 registry index
# SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 2
#
# 用法:
# bash scripts/register-component.sh <component_name>
# REGISTRY_URL=https://registry.arcrun.dev bash scripts/register-component.sh kbdb_ingest
#
# CI deploy 流程內也使用同樣邏輯(見 .github/workflows/deploy.yml 的 Register step
# 此 script 是「本地 / hook 一致性」的 SSOTCI 改邏輯時 script 跟著改
set -uo pipefail
REGISTRY_URL="${REGISTRY_URL:-https://registry.arcrun.dev}"
COMPONENT_NAME="${1:-}"
if [[ -z "$COMPONENT_NAME" ]]; then
echo "Usage: $0 <component_name>" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPONENT_DIR="$SCRIPT_DIR/../components/$COMPONENT_NAME"
CONTRACT="$COMPONENT_DIR/component.contract.yaml"
if [[ ! -f "$CONTRACT" ]]; then
echo "::warning::no component.contract.yaml at $COMPONENT_DIR" >&2
exit 0
fi
python3 -c "import yaml" 2>/dev/null || {
echo "需要 python3 + pyyaml" >&2
exit 1
}
contract_json=$(python3 -c "
import yaml, json
with open('$CONTRACT') as f:
c = yaml.safe_load(f)
print(json.dumps({'contract': c}))
") || {
echo "::warning::無法解析 $CONTRACT" >&2
exit 0
}
echo "Registering $COMPONENT_NAME to $REGISTRY_URL ..."
http_code=$(curl -s -o /tmp/reg-response.json -w "%{http_code}" \
-X POST "$REGISTRY_URL/components/index-only" \
-H "Content-Type: application/json" \
-d "$contract_json")
if [[ "$http_code" =~ ^(200|201)$ ]]; then
echo "$COMPONENT_NAME registered (HTTP $http_code)"
cat /tmp/reg-response.json
echo
else
echo "::warning::Registry 註冊失敗 HTTP $http_code" >&2
cat /tmp/reg-response.json || true
exit 1
fi
@@ -0,0 +1,85 @@
// 零件 metadata-only 索引:只寫 KV,不沙盒、不上 R2
// 用途:backfill 既有已部署但未索引的零件(cypher-executor 不從 R2 讀 wasm,零件用獨立 Worker URL
// SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 1
//
// 跟 submitComponent 對照:
// submitComponent → 跑沙盒 + 寫 R2 + 寫 KV
// indexOnlyComponent → 只寫 KVhash_id 規則一致:cmp_ + sha256(canonical_id)[:8]
//
// 冪等:相同 canonical_id + version 不重複寫
import type { ComponentContract, Bindings } from '../types';
async function deriveHashId(canonicalId: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(canonicalId);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return 'cmp_' + hex.slice(0, 8);
}
export interface IndexOnlyResult {
success: boolean;
component_hash_id: string;
canonical_id: string;
version: string;
already_indexed: boolean;
}
export async function indexOnlyComponent(
contract: ComponentContract,
env: Bindings,
): Promise<IndexOnlyResult> {
const hashId = await deriveHashId(contract.canonical_id);
const kvKey = `comp:${hashId}:${contract.version}`;
const existing = await env.SUBMISSIONS_KV.get(kvKey);
if (existing) {
return {
success: true,
component_hash_id: hashId,
canonical_id: contract.canonical_id,
version: contract.version,
already_indexed: true,
};
}
const record = {
component_hash_id: hashId,
canonical_id: contract.canonical_id,
display_name: contract.display_name,
category: contract.category,
version: contract.version,
wasi_target: contract.wasi_target,
stability: contract.stability,
runtime_compat: contract.runtime_compat,
component_type: contract.component_type ?? 'wasm',
constraints: contract.constraints,
input_schema: contract.input_schema,
output_schema: contract.output_schema,
gherkin_tests: contract.gherkin_tests,
description: contract.description ?? '',
aliases: contract.aliases ?? [],
tags: contract.tags ?? [],
success_rate: 1,
avg_duration_ms: 0,
call_count: 0,
visibility: 'public' as const,
status: 'active' as const,
submitted_at: new Date().toISOString(),
deprecated_at: null,
indexed_only: true,
};
await env.SUBMISSIONS_KV.put(kvKey, JSON.stringify(record));
await env.SUBMISSIONS_KV.put(`idx:${contract.canonical_id}`, hashId);
return {
success: true,
component_hash_id: hashId,
canonical_id: contract.canonical_id,
version: contract.version,
already_indexed: false,
};
}
+11 -23
View File
@@ -1,5 +1,9 @@
// 零件提交:沙盒驗收 → 派發 hash id → 寫入 SUBMISSIONS_KV → 上傳 R2
// 零件提交:沙盒驗收 → 派發 hash id → 寫入 SUBMISSIONS_KV
// Requirements: 2.1, 2.2, 2.3
// SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 1.5
//
// 2026-05-07:移除 R2 寫入。cypher-executor 已不從 R2 動態載 wasm(每個零件 = 獨立 Worker)。
// R2 是 dead storage,移除避免誤導 AI 以為零件部署需要 wasm bytes。
//
// KV key 設計:
// comp:{hash_id}:{version} → 零件元數據 JSON
@@ -8,7 +12,6 @@
// hash_id 派發規則:
// hash_id = 'cmp_' + sha256(canonical_id).slice(0, 8)
// 相同 canonical_id 永遠得到相同 hash_id(冪等)
// 不同 canonical_id 的 hash_id 碰撞機率極低(2^32 空間)
import { runSandboxAcceptance } from './sandboxAcceptance';
import type { ComponentContract, SandboxResult, Bindings } from '../types';
@@ -30,20 +33,18 @@ export async function submitComponent(
wasmBytes: Uint8Array,
contract: ComponentContract,
env: Bindings,
): Promise<SandboxResult & { wasm_r2_key?: string }> {
// 1. 沙盒驗收
): Promise<SandboxResult> {
// 1. 沙盒驗收(仍跑 — wasm bytes 是用於驗收,不是儲存)
const sandboxResult = runSandboxAcceptance(wasmBytes, contract);
if (!sandboxResult.success) {
return sandboxResult;
}
// 2. 派發 hash idcanonical_id 的確定性 hash,相同輸入永遠得到相同 id)
// 2. 派發 hash id
const hashId = await deriveHashId(contract.canonical_id);
const kvKey = `comp:${hashId}:${contract.version}`;
const r2Key = `components/${hashId}/${contract.version}.wasm`;
// 3. 冪等:若已存在相同 (hash_id, version) 直接回傳
// 3. 冪等
const existing = await env.SUBMISSIONS_KV.get(kvKey);
if (existing) {
return {
@@ -51,16 +52,10 @@ export async function submitComponent(
component_hash_id: hashId,
canonical_id: contract.canonical_id,
version: contract.version,
wasm_r2_key: r2Key,
};
}
// 4. 上傳 .wasm 至 R2
await env.WASM_BUCKET.put(r2Key, wasmBytes, {
httpMetadata: { contentType: 'application/wasm' },
});
// 5. 寫入 SUBMISSIONS_KV(元數據 + 初始統計)
// 4. 寫入 metadata
const record = {
component_hash_id: hashId,
canonical_id: contract.canonical_id,
@@ -75,25 +70,19 @@ export async function submitComponent(
input_schema: contract.input_schema,
output_schema: contract.output_schema,
gherkin_tests: contract.gherkin_tests,
wasm_r2_key: r2Key,
description: contract.description ?? '',
aliases: contract.aliases ?? [],
tags: contract.tags ?? [],
// 初始統計
success_rate: 1,
avg_duration_ms: 0,
call_count: 0,
// 可見性:預設 author_only,人工審核通過後改為 public
visibility: 'author_only' as const,
visibility: 'public' as const,
status: 'active' as const,
submitted_at: new Date().toISOString(),
deprecated_at: null,
};
await env.SUBMISSIONS_KV.put(kvKey, JSON.stringify(record));
// 6. 寫入 canonical_id → hash_id 反查索引
// 同一個 canonical_id 的所有版本共用同一個 hash_id,索引只需存一份
await env.SUBMISSIONS_KV.put(`idx:${contract.canonical_id}`, hashId);
return {
@@ -101,6 +90,5 @@ export async function submitComponent(
component_hash_id: hashId,
canonical_id: contract.canonical_id,
version: contract.version,
wasm_r2_key: r2Key,
};
}
+48
View File
@@ -1,10 +1,13 @@
// 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 { indexOnlyComponent } from '../actions/indexOnlyComponent';
const app = new Hono<{ Bindings: Bindings }>();
@@ -83,4 +86,49 @@ app.post('/', async c => {
return c.json(result, 201);
});
// POST /components/index-only — metadata-only 索引(給 backfill 用)
// 只接 contractJSON),不收 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;
+8 -5
View File
@@ -5,7 +5,6 @@ import { z } from 'zod';
// ── Cloudflare Bindings ──────────────────────────────────────────────────────
export type Bindings = {
WASM_BUCKET: R2Bucket;
AI: Ai;
// KV key 格式:
// comp:{hash_id}:{version} → 零件元數據(hash_id = cmp_ + sha256 前 8 碼)
@@ -17,10 +16,13 @@ export type Bindings = {
// ── Component Contract SchemaZod)─────────────────────────────────────────
// max_cold_start_ms 上限放寬至 500(從 50):實測 auth/ai 類零件含 crypto/init 步驟通常 100-300ms
// no_network_syscall / no_filesystem_syscall 都改 optionalauth/api 類零件需要網路 syscall
export const ConstraintsSchema = z.object({
max_size_kb: z.number().positive().max(2048),
max_cold_start_ms: z.number().positive().max(50),
no_network_syscall: z.boolean(),
max_size_kb: z.number().positive().max(8192),
max_cold_start_ms: z.number().positive().max(500),
no_network_syscall: z.boolean().optional(),
no_filesystem_syscall: z.boolean().optional(),
io_model: z.literal('stdin_stdout_json'),
});
@@ -36,7 +38,8 @@ export const ComponentContractSchema = z.object({
// 兩者都可以在 workflow 中引用,Registry 會互相解析
canonical_id: z.string().min(1).regex(/^[a-z][a-z0-9_]*$/, 'canonical_id 必須為小寫底線格式'),
display_name: z.string().min(1),
category: z.enum(['logic', 'api', 'ui', 'style', 'anim', 'data']),
// category 擴充:auth (auth primitive)、ai (Claude/AI 推論)、platform (平台底層 crypto/system)
category: z.enum(['logic', 'api', 'ui', 'style', 'anim', 'data', 'auth', 'ai', 'platform']),
version: z.string().min(1).regex(/^v\d+$/, 'version 格式必須為 vN'),
wasi_target: z.literal('preview1'),
stability: z.enum(['floating', 'stable', 'pinned']),
+3 -3
View File
@@ -3,9 +3,9 @@ main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
[[r2_buckets]]
binding = "WASM_BUCKET"
bucket_name = "arcrun-wasm"
# 2026-05-07:移除 WASM_BUCKET binding。R2 是 dead storagecypher-executor 不從 R2 讀),
# 砍掉避免新 contributor 誤以為零件部署需要 wasm bytes。bucket arcrun-wasm 30 天後砍。
# SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 1.5
[[kv_namespaces]]
binding = "SUBMISSIONS_KV"