feat(storage): 工作流與 recipe 的家搬到 KBDB,KV 降成可丟棄的快取(Arcrun#16+#17)

leo 08-12:「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,
數據,一個 entry」「如果零件和工作流的 recipe 不見了,是很可怕的事情」。
同日實害:一次例行更新讓九支工作流在畫面上全部消失。#97 已修掉直接原因
(別再照名字猜使用者的資源、別再擅自新建一顆空的綁上去);這裡修更下面那一句——
**資產本來就不該只存在於一個會被換掉的暫存層裡**。

做法(換 binding,不改四十幾處呼叫端):
- lib/asset-keys.ts    哪些 KV key 是資產、對應 KBDB 哪一列。**唯一**要人看懂的那張表。
- lib/durable-store.ts 讀=KV 先行、miss 回源 KBDB 並補快取;寫=先 KBDB 再 KV,
                       KBDB 失敗就拋錯(禁假綠);**列舉一律回源**——空 KV 列出來是
                       「零筆」而不是「查不到」,那正是東西消失的形狀。
- index.ts             入口把 WEBHOOKS/RECIPES 換成上面那層。逐處改寫一定會漏,
                       而漏掉的那一處就是下一次「東西不見了」的入口。
- routes/storage.ts    /storage/audit(搬前搬後各數一次)+ /storage/migrate-to-kbdb
                       (只增不刪、冪等、逐筆回報成敗)。
- kbdb                 migration 0005 seed 四列 template(零 schema 異動,手法同 0003/0004)
                       + PUT /entries/:id 指定 id 的整列 upsert(通用原語,不是為誰開特例)。
- 衍生資料(idx:*、cron-idx:_all)不進 KBDB,讀不到就從資產重算。

⚠️ 狀態=◐ 半通,**別因為程式碼看起來完整就先合併**。
已實測:5 份 migration 在本機 D1 全數套用(含 0005);兩顆 worker 都能以改動後的
程式碼在本機開起來;tsc 錯誤數 7→7(既有,未新增)。
**沒跑到**:「砍掉 KV、資產還在」那一次端到端驗證——本次施工環境的權限閘不放行
執行 vitest/node/curl。那一次已寫成 scripts/verify-kv-retirement.sh,
在能執行的機器上跑一次就是證據。建議順序:先跑腳本、綠了再合併。

規格層依 D35 走 pending-changes.md「P-KV」提案,等 leo confirm(現行 active SDD
是 workflow-discovery,本案不在它的 tasks 內,故不自建 SDD、不改 rules 那張儲存表)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-12 16:51:58 +08:00
parent a24f2912eb
commit 3d3973ecbc
11 changed files with 1234 additions and 3 deletions
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
#
# verify-kv-retirement.sh — 把「換掉 KV,資產還在」真的做一次
#
# KV 退休(Leo/Arcrun#16 + #17)。交辦的驗收條件逐字是:
# 「證明『換掉/重建那個暫存層,資產還在』——不是說明它會在,是**真的弄一次給我看**」
# 「既有的東西要能搬過去,而且搬的過程不能弄丟任何一筆(搬之前先數,搬之後再數)」
# 這支腳本就是那一次。它做的事,照順序:
#
# 1. 開一台**全新的空**本機實例(local D1 + local KV,跑真的 migrations
# 2. 用平常那條路(POST /webhooks/named、POST /recipes、POST /auth-recipes
# 放進 9 支工作流 + 3 份 recipe——9 是照 2026-08-12 那天真的消失的數量
# 3. 數一次(KV 幾筆、KBDB 幾筆)
# 4. **把整個 KV 層砍掉重建**(rm -rf 那顆 KV 的本機儲存 → 重開 worker)
# =模擬 Arcrun#97 那天發生的事:worker 被綁到一顆全新的空 KV
# 5. 再數一次,並且**真的觸發一支工作流**確認它還跑得動
#
# 通過的定義(不通就 exit 1,不留模稜兩可):
# 砍掉 KV 之後,列出來仍然是 9 支、recipe 仍在、工作流仍然跑得出結果。
#
# ⚠️ 全程只碰本機(--local + --persist-to 到暫存目錄),**不碰任何線上實例**。
# 腳本裡沒有任何 --remote、沒有任何真實帳號憑證。
#
# 用法: bash scripts/verify-kv-retirement.sh
# 需要: node 22+、pnpm、可執行 npx wrangler / curl 的 shell
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
KBDB_PORT=8801
CYPHER_PORT=8802
TOKEN="e2e-local-token"
TENANT="leo-e2e"
WF_COUNT=9
KBDB="http://127.0.0.1:${KBDB_PORT}"
CYPHER="http://127.0.0.1:${CYPHER_PORT}"
kbdb_pid=""; cypher_pid=""
cleanup() {
[ -n "$kbdb_pid" ] && kill "$kbdb_pid" 2>/dev/null || true
[ -n "$cypher_pid" ] && kill "$cypher_pid" 2>/dev/null || true
rm -rf "$WORK"
}
trap cleanup EXIT
say() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
fail() { printf '\n\033[31m❌ %s\033[0m\n' "$*"; exit 1; }
wait_for() { # wait_for <url> <label>
for _ in $(seq 1 60); do
if curl -sf -m 2 "$1" >/dev/null 2>&1; then return 0; fi
sleep 1
done
fail "$2 沒有起來($1"
}
start_cypher() {
( cd "$REPO_ROOT/cypher-executor" && \
npx wrangler dev --local --port "$CYPHER_PORT" \
--config wrangler.test.toml \
--persist-to "$WORK/cypher-state" \
--var "KBDB_BASE_URL:$KBDB" \
--var "KBDB_INTERNAL_TOKEN:$TOKEN" \
--show-interactive-dev-session=false >"$WORK/cypher.log" 2>&1 ) &
cypher_pid=$!
wait_for "$CYPHER/health" "cypher-executor"
}
# ── 1. 空實例:真的跑 migrations ───────────────────────────────────────────────
say "1. 開一台全新的空實例(local D1 + local KV,跑真的 migrations"
( cd "$REPO_ROOT/kbdb" && npx wrangler d1 migrations apply DB --local --persist-to "$WORK/kbdb-state" )
( cd "$REPO_ROOT/kbdb" && \
npx wrangler dev --local --port "$KBDB_PORT" \
--persist-to "$WORK/kbdb-state" \
--var "KBDB_INTERNAL_TOKEN:$TOKEN" \
--show-interactive-dev-session=false >"$WORK/kbdb.log" 2>&1 ) &
kbdb_pid=$!
wait_for "$KBDB/health" "kbdb"
start_cypher
# ── 2. 用平常那條路放資產進去 ─────────────────────────────────────────────────
say "2. 放進 $WF_COUNT 支工作流 + 3 份 recipe(走的是 acr push 用的同一組端點)"
for i in $(seq 1 "$WF_COUNT"); do
curl -sf -X POST "$CYPHER/webhooks/named" \
-H 'Content-Type: application/json' -H "X-Arcrun-API-Key: $TENANT" \
-d "{\"name\":\"wf_$i\",\"description\":\"驗證用工作流 $i:把 text 轉大寫\",
\"graph\":{\"id\":\"wf_$i\",\"nodes\":[{\"id\":\"upper\",\"type\":\"Component\",\"componentId\":\"comp_uppercase\"}],\"edges\":[]}}" \
>/dev/null || fail "部署 wf_$i 失敗"
done
for svc in alpha beta; do
curl -sf -X POST "$CYPHER/recipes" \
-H 'Content-Type: application/json' \
-d "{\"canonical_id\":\"${svc}_send\",\"endpoint\":\"https://example.invalid/$svc\",\"description\":\"驗證用 recipe $svc\"}" \
>/dev/null || fail "建立 recipe $svc 失敗"
done
curl -sf -X POST "$CYPHER/auth-recipes" \
-H 'Content-Type: application/json' \
-d '{"service":"alpha","primitive":"static_key","base_url":"https://example.invalid",
"required_secrets":[{"key":"alpha_token","label":"Token","help_url":"https://example.invalid/docs"}],
"inject":{"header":{"Authorization":"Bearer {{secret.alpha_token}}"}}}' \
>/dev/null || fail "建立 auth recipe 失敗"
# ── 3. 搬之前先數 ─────────────────────────────────────────────────────────────
say "3. 數一次(/storage/auditKV 幾筆、KBDB 幾筆、差幾筆)"
curl -s "$CYPHER/storage/audit" | tee "$WORK/audit-before.json"; echo
before_wf="$(curl -s "$CYPHER/webhooks/named" -H "X-Arcrun-API-Key: $TENANT" | grep -o '"total":[0-9]*' | cut -d: -f2)"
echo "部署後列出來的工作流數:$before_wf"
[ "$before_wf" = "$WF_COUNT" ] || fail "還沒開始拆就對不上:期望 $WF_COUNT,實得 $before_wf"
# ── 4. 把 KV 層砍掉重建(模擬 2026-08-12 那天) ────────────────────────────────
say "4. 砍掉整個 KV 層並重建 —— 模擬 Arcrun#97 那天『worker 被綁到一顆全新的空 KV』"
kill "$cypher_pid" 2>/dev/null || true; wait "$cypher_pid" 2>/dev/null || true; cypher_pid=""
rm -rf "$WORK/cypher-state" # ← 這一行就是「那個暫存層被換掉」
echo "已刪除:$WORK/cypher-statecypher 的整顆本機 KV"
start_cypher
# ── 5. 再數一次,而且真的跑一支 ───────────────────────────────────────────────
say "5. KV 全空之後,再數一次"
after_json="$(curl -s "$CYPHER/webhooks/named" -H "X-Arcrun-API-Key: $TENANT")"
echo "$after_json" | head -c 400; echo
after_wf="$(echo "$after_json" | grep -o '"total":[0-9]*' | cut -d: -f2)"
echo "KV 砍掉重建後列出來的工作流數:$after_wf"
[ "$after_wf" = "$WF_COUNT" ] || fail "工作流少了:期望 $WF_COUNT,實得 $after_wf —— 資產沒有被保住"
recipes_after="$(curl -s "$CYPHER/recipes" | grep -o '"count":[0-9]*' | cut -d: -f2)"
echo "KV 砍掉重建後的 recipe 數:$recipes_after"
[ "${recipes_after:-0}" -ge 2 ] || fail "recipe 少了:期望 >=2,實得 ${recipes_after:-0}"
auth_after="$(curl -s "$CYPHER/auth-recipes/alpha" | grep -c '"success":true' || true)"
[ "$auth_after" = "1" ] || fail "auth recipe 不見了"
echo "auth recipe alpha:仍在"
say "5b. 不只是列得出來——真的觸發一支工作流"
run="$(curl -s -X POST "$CYPHER/webhooks/named/wf_3/trigger" \
-H 'Content-Type: application/json' -H "X-Arcrun-API-Key: $TENANT" \
-d '{"text":"still here"}')"
echo "$run" | head -c 400; echo
echo "$run" | grep -q 'STILL HERE' || fail "工作流列得出來卻跑不動——那不算資產還在"
say "結論"
printf '\033[32m✅ 通:整個 KV 層被砍掉重建之後,%s 支工作流、recipe、auth recipe 全部還在,且工作流真的跑得出結果。\033[0m\n' "$WF_COUNT"
echo " 資產的家=KBDBD1,一份資產一列 entry);KV 只是快取,砍掉會自己長回來。"