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:
@@ -0,0 +1,180 @@
|
||||
// claude_api — 呼叫 Mira daemon(Hetzner 上跑的 Claude Agent SDK 服務)
|
||||
//
|
||||
// 架構決策(2026-05-06):
|
||||
// 不直打 Anthropic Messages API(OAuth 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"` // 預設 25000(daemon 協商模式上限)
|
||||
Model string `json:"model"` // 'haiku' / 'sonnet' / 'opus',預設 haiku(daemon 端)
|
||||
CallbackURL string `json:"callback_url"` // optional:daemon 完成 task 時 POST 此 URL 通知(Resumable workflow,SDD: 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 {
|
||||
// 預設 120s:daemon 協商期會在 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 key(pk_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 模式回多個 block;block_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
|
||||
@@ -0,0 +1,184 @@
|
||||
// kbdb_get — 從 KBDB 讀 block(GET /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
|
||||
}
|
||||
|
||||
// 構造 URL:block_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) // 1MB(list 可能很大)
|
||||
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 != "" {
|
||||
// 單一 block:KBDB 直接回 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)
|
||||
}
|
||||
@@ -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 簡易 parser,contract 是 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);
|
||||
});
|
||||
@@ -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 一致性」的 SSOT,CI 改邏輯時 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 → 只寫 KV(hash_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,
|
||||
};
|
||||
}
|
||||
@@ -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 id(canonical_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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 用)
|
||||
// 只接 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;
|
||||
|
||||
@@ -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 Schema(Zod)─────────────────────────────────────────
|
||||
|
||||
// max_cold_start_ms 上限放寬至 500(從 50):實測 auth/ai 類零件含 crypto/init 步驟通常 100-300ms
|
||||
// no_network_syscall / no_filesystem_syscall 都改 optional:auth/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,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 storage(cypher-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"
|
||||
|
||||
Reference in New Issue
Block a user