497f92a268
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>
181 lines
4.8 KiB
Go
181 lines
4.8 KiB
Go
// 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)
|
||
}
|