feat(arcrun): add kbdb_upsert_block component for idempotent block writes

對應 mira 7B.3f:per-entity index-entry 維護需要「找有則 PATCH 沒找到 POST」,
arcrun workflow 沒 IF/branch 能力(已知限制 #1 + 新 P1 #1),用 kbdb_upsert_block
零件把分支邏輯封進零件內:GET /blocks?page_name=X → user_id filter → 找到 PATCH 沒找到 POST。
page_name 當 idempotency key,未來其他「找有則改沒則建」場景共用。

SDD:polaris/mira/.agents/specs/mira-app/design.md §3.5.12.4.1
     matrix/arcrun/.agents/specs/arcrun/arcrun.md 三-A P1 #1 + 三-B 新零件加入紀錄
This commit is contained in:
2026-05-14 10:18:21 +08:00
parent 519423cb0d
commit 4e746986b4
9 changed files with 1572 additions and 2 deletions
@@ -0,0 +1,90 @@
canonical_id: "kbdb_upsert_block"
display_name: "KBDB Upsert Block"
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, page_name, content]
properties:
api_key:
type: string
description: KBDB partner keyak_xxx
page_name:
type: string
description: 當 idempotency key。內部用 GET /blocks?page_name= 查找。
content:
type: string
description: block 內容(PATCH 時覆寫,CREATE 時新建)
type:
type: string
description: block type(建立時用,PATCH 時忽略)
parent_id:
type: string
description: 父 block id(建立時用,PATCH 時忽略)
user_id:
type: string
description: 建立時帶入 + lookup 時用來 filter(同 page_name 多 user 共存場景)
source:
type: string
description: 來源標記
tags_json:
type: string
description: tags JSON 字串(PATCH 時轉 array、CREATE 時直傳)
kbdb_url:
type: string
description: KBDB API base(預設 https://kbdb.finally.click
output_schema:
type: object
properties:
success:
type: boolean
action:
type: string
enum: [created, patched]
description: 實際做了哪個動作
data:
type: object
description: KBDB 回傳(含 block id 等)
error:
type: string
phase:
type: string
enum: [lookup, patch, create]
description: 出錯在哪個階段
gherkin_tests:
- scenario: "缺 page_name"
given: '{"api_key":"ak_x","content":"hi"}'
then_contains: '"success":false'
- scenario: "建立新 block"
given: '{"api_key":"ak_x","page_name":"new-page-uniq","content":"hello"}'
then_contains: '"action":"created"'
- scenario: "PATCH 既有 block"
given: '{"api_key":"ak_x","page_name":"existing-page","content":"updated"}'
then_contains: '"action":"patched"'
tags: [data, storage, kbdb, upsert, primitive, idempotent]
description: |
Upsert:用 page_name 當 idempotency key。內部 GET 找有沒有同 page_name 的 block
找到就 PATCH 不到就 POST 新建。解 arcrun workflow 缺 IF/branch 能力的缺口
arcrun.md P1 #1)。mira 7B.3f index-entry per-entity 維護是第一個使用者。
config_example: |
upsert_index_entry:
api_key: "{{api_key}}"
page_name: "index-{{entity}}"
parent_id: "{{mira_wiki_index_entities_id}}"
type: "index-entry"
user_id: "inkstone_mira_tools"
source: "ai-canon-wiki"
content: "{{compose_index_entry.data.text}}"
tags_json: '["mira-wiki", "ai-generated", "index"]'
@@ -0,0 +1,3 @@
module kbdb_upsert_block
go 1.21
@@ -0,0 +1,271 @@
// kbdb_upsert_block — 用 page_name 當 idempotency key 做 upsert
// 內部:GET /blocks?page_name=X → user_id filter → 找到 PATCH /blocks/:id 沒找到 POST /blocks
// 解 arcrun workflow 沒 IF/branch 能力的缺口(arcrun.md P1 #1
// 對應 SDDpolaris/mira/.agents/specs/mira-app/design.md §3.5.12.4.1
//
//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"` // 必填
PageName string `json:"page_name"` // 必填,當 idempotency key
Content string `json:"content"` // 必填
Type string `json:"type"` // optional(建立時用,PATCH 時忽略)
ParentID string `json:"parent_id"` // optional(建立時用,PATCH 時忽略)
UserID string `json:"user_id"` // optional(建立時用 + lookup filter
Source string `json:"source"` // optional
TagsJSON string `json:"tags_json"` // optional(完整覆寫)
}
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)
}
func writeResult(action string, data map[string]interface{}) {
out, _ := json.Marshal(map[string]interface{}{
"success": true,
"action": action,
"data": data,
})
os.Stdout.Write(out)
}
// urlEncode:跟 kbdb_get 一致,避免引入 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 httpCall(method, url string, headers map[string]string, body []byte) ([]byte, uint32) {
headersBytes, _ := json.Marshal(headers)
urlBytes := []byte(url)
methodBytes := []byte(method)
outBuf := make([]byte, 1<<20) // 1MB
var outLen uint32
urlPtr, urlLen := safePtr(urlBytes)
methodPtr, methodLen := safePtr(methodBytes)
headersPtr, headersLenU := safePtr(headersBytes)
bodyPtr, bodyLenU := safePtr(body)
result := hostHttpRequest(
urlPtr, urlLen,
methodPtr, methodLen,
headersPtr, headersLenU,
bodyPtr, bodyLenU,
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
return outBuf[:outLen], result
}
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.PageName == "" {
writeError("page_name 必填(upsert 的 idempotency key")
return
}
if input.Content == "" {
writeError("content 必填")
return
}
kbdbURL := input.KBDBUrl
if kbdbURL == "" {
kbdbURL = "https://kbdb.finally.click"
}
headers := map[string]string{
"Authorization": "Bearer " + input.APIKey,
}
// ── Step 1lookup by page_name ────────────────────────────────────
lookupURL := kbdbURL + "/blocks?page_name=" + urlEncode(input.PageName) +
"&limit=" + strconv.Itoa(10)
lookupResp, callResult := httpCall("GET", lookupURL, headers, nil)
if callResult != 0 {
writeError("KBDB lookup failed (host_http_request returned non-zero)")
return
}
var lookupParsed struct {
Blocks []map[string]interface{} `json:"blocks"`
Count int `json:"count"`
Error interface{} `json:"error"`
}
if err := json.Unmarshal(lookupResp, &lookupParsed); err != nil {
writeError("KBDB lookup returned non-JSON: " + string(lookupResp))
return
}
if lookupParsed.Error != nil {
errBytes, _ := json.Marshal(map[string]interface{}{
"success": false,
"error": lookupParsed.Error,
"phase": "lookup",
})
os.Stdout.Write(errBytes)
return
}
// ── Step 2:找符合 user_id 的第一筆 ──────────────────────────────
var existing map[string]interface{}
for _, b := range lookupParsed.Blocks {
if input.UserID == "" {
existing = b
break
}
if uid, ok := b["user_id"].(string); ok && uid == input.UserID {
existing = b
break
}
}
// ── Step 3:分支寫入 ───────────────────────────────────────────────
postHeaders := map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer " + input.APIKey,
}
if existing != nil {
// PATCH 路徑
existingID, _ := existing["id"].(string)
if existingID == "" {
writeError("lookup 找到 block 但 id 為空")
return
}
patchBody := make(map[string]interface{})
patchBody["content"] = input.Content
if input.Source != "" {
patchBody["source"] = input.Source
}
if input.TagsJSON != "" {
// PATCH endpoint 用 tags array 不是 tags_json string
var tagsArr []string
if err := json.Unmarshal([]byte(input.TagsJSON), &tagsArr); err == nil {
patchBody["tags"] = tagsArr
}
}
patchBodyBytes, _ := json.Marshal(patchBody)
patchURL := kbdbURL + "/blocks/" + existingID
patchResp, callResult := httpCall("PATCH", patchURL, postHeaders, patchBodyBytes)
if callResult != 0 {
writeError("KBDB PATCH failed (host_http_request returned non-zero)")
return
}
var patchParsed map[string]interface{}
if err := json.Unmarshal(patchResp, &patchParsed); err != nil {
writeError("KBDB PATCH returned non-JSON: " + string(patchResp))
return
}
if _, hasErr := patchParsed["error"]; hasErr {
errBytes, _ := json.Marshal(map[string]interface{}{
"success": false,
"error": patchParsed["error"],
"phase": "patch",
})
os.Stdout.Write(errBytes)
return
}
writeResult("patched", patchParsed)
return
}
// CREATE 路徑
postBody := make(map[string]interface{})
postBody["content"] = input.Content
postBody["page_name"] = input.PageName
if input.Type != "" {
postBody["type"] = input.Type
}
if input.ParentID != "" {
postBody["parent_id"] = input.ParentID
}
if input.UserID != "" {
postBody["user_id"] = input.UserID
}
if input.Source != "" {
postBody["source"] = input.Source
}
if input.TagsJSON != "" {
postBody["tags_json"] = input.TagsJSON
}
postBodyBytes, _ := json.Marshal(postBody)
postURL := kbdbURL + "/blocks"
postResp, callResult := httpCall("POST", postURL, postHeaders, postBodyBytes)
if callResult != 0 {
writeError("KBDB POST failed (host_http_request returned non-zero)")
return
}
var postParsed map[string]interface{}
if err := json.Unmarshal(postResp, &postParsed); err != nil {
writeError("KBDB POST returned non-JSON: " + string(postResp))
return
}
if _, hasErr := postParsed["error"]; hasErr {
errBytes, _ := json.Marshal(map[string]interface{}{
"success": false,
"error": postParsed["error"],
"phase": "create",
})
os.Stdout.Write(errBytes)
return
}
writeResult("created", postParsed)
}