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)
}