Compare commits

..

10 Commits

Author SHA1 Message Date
uncle6me-web 3d3973ecbc 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>
2026-08-12 16:51:58 +08:00
uncle6me-web a24f2912eb chore(builds): 重編執行檔——把 wait 的修法真的放進引擎成品(Arcrun#93 的閘抓到的)
#101 併進 main 後,.worker-builds/arcrun-cypher-executor 還記著 a5e4caf,
比源碼 HEAD 少了 f1370e2(wait 搬回引擎那筆)。

⇒ 這正是 #93 那道閘存在的理由:**沒有它,這次出貨會送出一個
「引擎裡沒有 wait 修法」的成品**——leo 更新完照樣燒 CPU,而出貨線全綠。
閘寫出來的隔天就抓到一次,抓到的還是我。

arcrun-cypher-executor  source a5e4caff1370e22  sha256=8411ed59b7ad

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:30:26 +08:00
Leo 1791ffa497 Merge pull request '#101 等待搬回引擎——WASI 沙箱裡沒有「不花 CPU 地等」這種東西' (#103) from fix/wait-not-in-wasm-101 into main 2026-08-12 07:26:00 +00:00
Leo 296fb01247 Merge pull request 'portal 安裝完成清單拿掉「去 Google 申請 AI 金鑰」那一項(arcrun-rag#81)' (#102) from fix/portal-onboarding-drop-gemini-key-81 into main 2026-08-12 07:24:47 +00:00
uncle6me-web f1370e2275 fix(engine): 等待搬回引擎——WASI 沙箱裡沒有「不花 CPU 地等」這種東西(Arcrun#101)
leo 在 youlin stage 實測(只有 input >> wait 兩個節點):
  ms=3000 → 38.9s 後 503(1102) / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
四個值同一種死法、與 ms 無關 ⇒ 病不是「等待很貴」,是「等待從來沒成功過」。

修法:wait 移進 BUILTIN_COMPONENTS,由引擎 await 一個 timer。
只花 wall-clock、不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改。

🔴 誠實標明:原本註解斷言「Workers 時鐘在同步執行期間凍結,所以自旋永不結束」。
寫測試去證,反而被打臉——workerd 裡自旋 2553 圈後 Date.now() 就前進了。
那條假斷言已刪除(不是改鬆),完整機制降級為推測。修法不依賴它:
純 WASI 沙箱本來就沒有睡覺這個手段,會等的只有宿主。

實測:
  npx vitest run tests/wait-builtin.test.ts  → 12 passed (12)
  npx vitest run(全套)                      → 386 passed / 14 failed
                                              (14 = 動工前的既有紅燈數,未新增)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:15:08 +08:00
uncle6me-web d58a6e152d fix(portal): 安裝完成清單拿掉「去 Google 申請 AI 金鑰」那一項(arcrun-rag#81)
leo 2026-08-12:「已經改用 workers AI,刪掉。」

那一項長在**安裝完成清單裡而且是勾選項** ⇒ 使用者會以為不做這步就沒裝完,
而它要人離開流程、去第三方網站申請帳號、把金鑰貼進表單
——**這是整條安裝路徑上最重的一個動作,而它現在是白做的**。

連帶:步數不再寫死「還差 2 步」,改成由剩下的項目算出來
(少一項卻還寫 2,是另一種說謊)。
2026-08-12 14:44:38 +08:00
uncle6me-web 793a94ecb5 chore(worker-builds): 重編——#100 的修法要進執行檔才會送到用戶那台
cypher-executor source 525faaf → a5e4caf(sha 49d59597 → d1765930)。
今天第三次在同一件事上被提醒:改完源碼沒重編,出貨與安裝送出去的還是舊的。
2026-08-12 13:36:39 +08:00
uncle6me-web cbeddf7535 merge: 讀不到就說讀不到——總圖不再把「讀不到」畫成「你沒有」(Arcrun#100)
總管審過並自己跑了驗證:
  cypher-executor 374 pass(原 357,+17 條),14 個失敗與 main 基準線完全相同
  反向驗證:拿掉帶認證那一行 ⇒ 14 → 17 failed;還原 ⇒ 回到 14

它挖到兩件我沒交辦、而且比原題更嚴重的:
  · /triplets/stats 的 total 是**分頁長度不是總數**(預設 limit=100)
    ⇒ 只修 401 的話畫面會從「0」變成「100」——一樣是假的
  · 數 entries 原本不帶 owner 過濾 ⇒ **會混到別的租戶**(實測 459,137 vs leo 的 458,732)
2026-08-12 13:36:17 +08:00
uncle6me-web d7c6bd0680 merge: 更新不再照名字找資源——已部署 worker 綁著什麼就是什麼(Arcrun#97)
總管審過並自己跑了驗證(那條線被權限閘擋住,沒能執行):
  新測試 20 條全過(含四種「說不準就停手」情境)|CLI 全套 38 pass / 0 fail
  反向驗證:把「照名字 ensure」原語加回去 ⇒ 紅線測試當場變紅(0 pass / 1 fail)
  重編 dist:舊的 ensureKvNamespace/ensureD1Database 在產物裡 0 個

它做的比交辦的多:加了兩條紅線測試(cf-api 不得再提供「找不到同名就順手建一顆」的原語、
只有 resource-resolver 能決定要不要建),以及一條我沒想到的——
**部署出去的 toml 不得殘留官方帳號的資源 id**(自架寫進官方庫=跨租戶外洩)。

誠實標記:驗證是本機模擬(假 CF API),**沒有在真機上跑過**。
2026-08-12 13:36:17 +08:00
uncle6me-web a5e4caf5cb fix(portal): 讀不到就說讀不到——總圖不再把「讀不到」畫成「你沒有」(Arcrun#100)
leo 2026-08-12 打開總圖看到:「0 個實體 · 0 條關聯/知識庫還沒有任何關聯——
上傳文件後 AI 會自動織網」。**而他庫裡有 1854 條三元組。**
那句話會叫他去做一件不需要做的事。

三個獨立的洞疊起來才變成那句謊:
  ① console-dashboard.ts 打 kbdb-graph-plugin 沒帶認證(同段落打 kbdb 的兩支都有帶)
  ② 那顆 worker 不在更新的部署清單裡 ⇒ token 一換它就落單
  ③ **畫面把 null 畫成 0**——後端已經誠實回 null 了,是前端把它變成謊話

為什麼一直沒被發現:graph plugin 原本身上沒有 token ⇒ 門開著 ⇒ 沒帶也進得去。
2026-08-12 輪替後它有了 token,門關上,401 才浮出來。

📍 repo:matrix/arcrun(cypher-executor/src/routes/、console-ui/public/)
📍 票:Leo/Arcrun#100
2026-08-12 13:33:53 +08:00
25 changed files with 2064 additions and 80 deletions
+140 -16
View File
@@ -2650,7 +2650,7 @@ var init_recipes = __esm({
});
// cypher-executor/src/lib/constants.ts
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, BUILTIN_COMPONENTS;
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, WAIT_MAX_MS, BUILTIN_COMPONENTS;
var init_constants3 = __esm({
"cypher-executor/src/lib/constants.ts"() {
"use strict";
@@ -2698,6 +2698,7 @@ var init_constants3 = __esm({
"CLICK": "ON_CLICK",
"SUBFLOW": "CALLS_SUBFLOW"
};
WAIT_MAX_MS = 3e4;
BUILTIN_COMPONENTS = /* @__PURE__ */ new Map([
["comp_passthrough", (ctx) => ctx],
["comp_uppercase", (ctx) => {
@@ -2707,6 +2708,54 @@ var init_constants3 = __esm({
["comp_counter", (ctx) => {
const c = ctx;
return { ...c, count: (Number(c.count) || 0) + 1 };
}],
// ── wait:等待 N 毫秒後繼續(Arcrun#1012026-08-12)────────────────────────
//
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
//
// 舊實作是 registry/components/wait/main.goTinyGo → WASM),用 time.Sleep。
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
// poll_oneoff 實作成 ENOSYS`.component-builds/*/src/index.ts``poll_oneoff: () => 76`
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
//
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
// ⇒ 時鐘並沒有全程凍結。
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
//
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
//
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
//
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
// 吃 ms(必填 > 0)+可選 contextms > WAIT_MAX_MS 截斷;
// 回 { success: true, data: { ...context, waited_ms } }ms <= 0 回 success:false。
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
["wait", async (ctx) => {
const c = ctx && typeof ctx === "object" ? ctx : {};
const requested = typeof c.ms === "number" ? c.ms : Number(c.ms);
if (!Number.isFinite(requested) || requested <= 0) {
return { success: false, error: "ms \u5FC5\u9808\u5927\u65BC 0" };
}
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
await new Promise((resolve) => setTimeout(resolve, ms));
const passthrough = c.context && typeof c.context === "object" && !Array.isArray(c.context) ? c.context : {};
return { success: true, data: { ...passthrough, waited_ms: ms } };
}]
]);
}
@@ -3060,7 +3109,12 @@ var init_component_loader = __esm({
filter: "SVC_FILTER",
merge: "SVC_MERGE",
try_catch: "SVC_TRY_CATCH",
wait: "SVC_WAIT",
// wait 已於 Arcrun#1012026-08-12)移進 BUILTIN_COMPONENTSstep 1)——
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.113 個既有 binding 保留不新增),
// 拆綁定要重新部署、與本票無關。
set: "SVC_SET",
array_ops: "SVC_ARRAY_OPS",
string_ops: "SVC_STRING_OPS",
@@ -3145,6 +3199,11 @@ function graphBase(env) {
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, "");
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
}
function graphHeaders(env) {
const headers = {};
if (env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
return headers;
}
var kbdbProxyRouter, NEED_KEY;
var init_kbdb_proxy = __esm({
"cypher-executor/src/routes/kbdb-proxy.ts"() {
@@ -3278,8 +3337,7 @@ var init_kbdb_proxy = __esm({
kbdbProxyRouter.get("/kbdb/graph/neighbors/:name", async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const base = graphBase(c.env);
const headers = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param("name"))}`, { headers });
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
@@ -14537,6 +14595,20 @@ async function fetchJson(url, headers) {
return null;
}
}
async function fetchTripletTotal(env, tenant2) {
const { base, headers } = kbdbBase(env);
const data = await fetchJson(
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant2)}`,
headers
);
if (!data || !Array.isArray(data.stats)) return null;
let total = 0;
for (const row of data.stats) {
if (typeof row?.triplet_count !== "number") return null;
total += row.triplet_count;
}
return total;
}
async function fetchEntryTotal(env, filters) {
const { base, headers } = kbdbBase(env);
const params = new URLSearchParams({ ...filters, limit: "1" });
@@ -14644,6 +14716,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
kbdbHealth,
embedStatus,
graphStats,
tripletTotal,
entriesTotal,
wikiCardTotal,
workflowTotal
@@ -14655,7 +14728,13 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
cachedGiteaSprint(c.env, now2, (p) => c.executionCtx.waitUntil(p)),
fetchJson(`${kbdbUrl}/health`, kbdbHeaders),
fetchJson(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
fetchJson(`${graphUrl}/triplets/stats`),
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
fetchJson(
`${graphUrl}/triplets/stats`,
graphHeaders(c.env)
),
fetchTripletTotal(c.env, tenant2),
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732
fetchEntryTotal(c.env, { owner_id: tenant2 }),
fetchEntryTotal(c.env, { entry_type: "wiki_card", owner_id: tenant2 }),
@@ -14763,13 +14842,14 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
system: {
kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
embed: embedStatus ? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null } : null,
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
// ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關)
graph: { ok: graphStats !== null, triplets: tripletTotal },
workflow_total: workflowTotal
},
kb: {
entries_total: entriesTotal,
wiki_card_total: wikiCardTotal,
triplets_total: graphStats?.total ?? null
triplets_total: tripletTotal
},
generated_at: new Date(now2).toISOString()
});
@@ -14777,22 +14857,22 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
consoleDashboardRouter.get("/console/kb-scale-data", async (c) => {
const tenant2 = c.env.CONSOLE_TENANT || "leo";
const { base, headers } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
const now2 = Date.now();
const [wikiCards, graphStats, embedStatus] = await Promise.all([
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
// limit=1 順手拿最新一筆 created_atlist 為 created_at DESC)=「最近寫入時間」
fetchJson(
`${base}/entries?${new URLSearchParams({ owner_id: tenant2, entry_type: "wiki_card", limit: "1" }).toString()}`,
headers
),
fetchJson(`${graphUrl}/triplets/stats`),
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
fetchTripletTotal(c.env, tenant2),
fetchJson(`${base}/embed/backfill/status`, headers)
]);
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
return c.json({
wiki_card_total: typeof wikiCards?.total === "number" ? wikiCards.total : null,
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now2, latestMs),
triplets_total: typeof graphStats?.total === "number" ? graphStats.total : null,
triplets_total: tripletTotal,
embedded: embedStatus?.embedded ?? null,
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
generated_at: new Date(now2).toISOString()
@@ -14944,6 +15024,27 @@ function findBestNodeMatch(searchTerm, nodeNames) {
if (hits.length === 0) return null;
return hits.reduce((a, b) => a.length <= b.length ? a : b);
}
async function tripletCount(env, owner) {
try {
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
if (!res.ok) return null;
const body = await res.json().catch(() => null);
if (!body || !Array.isArray(body.stats)) return null;
let total = 0;
for (const row of body.stats) {
if (typeof row?.triplet_count !== "number") return null;
total += row.triplet_count;
}
return total;
} catch {
return null;
}
}
async function tripletCensus(env, tenant2) {
const owned = await tripletCount(env, tenant2);
if (owned !== 0) return { owned, any: null };
return { owned, any: await tripletCount(env, "") };
}
async function fuzzyFindNode(env, tenant2, searchTerm) {
try {
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
@@ -15045,8 +15146,7 @@ portalDataRouter.get(
return c.json(mapGraphWorkflowOutput(result.data));
}
const base = graphBase(c.env);
const headers = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
if (!res.ok) {
@@ -15081,12 +15181,19 @@ portalDataRouter.get(
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
}
const tenant2 = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}&limit=500`),
tripletCensus(c.env, tenant2)
]);
const tripletsTotal = census.owned;
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
}
const body = await res.json().catch(() => null);
const records = body && Array.isArray(body.records) ? body.records : [];
if (!body || !Array.isArray(body.records)) {
return c.json({ error: "\u4E09\u5143\u7D44\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
}
const records = body.records;
const EDGE_CAP = 500;
const seen = /* @__PURE__ */ new Set();
const edges = [];
@@ -15112,7 +15219,24 @@ portalDataRouter.get(
degree.set(o, (degree.get(o) ?? 0) + 1);
}
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
let emptyReason = null;
if (nodes.length === 0) {
if (census.owned === null) emptyReason = "unreadable";
else if (census.owned > 0) emptyReason = "scope_mismatch";
else if (census.any === null) emptyReason = "unreadable";
else emptyReason = census.any > 0 ? "scope_mismatch" : "confirmed_empty";
}
return c.json({
nodes,
edges,
node_count: nodes.length,
edge_count: edges.length,
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
truncated: truncated || records.length >= 500,
triplets_total: tripletsTotal,
empty_confirmed: nodes.length > 0 || emptyReason === "confirmed_empty",
empty_reason: emptyReason
});
})
);
portalDataRouter.get(
+5 -5
View File
@@ -1,18 +1,18 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-12T04:21:48.581Z",
"repo_head": "b302c03ea8076bcfe82bbcebb1523dcec2d1e830",
"generated_at": "2026-08-12T07:29:58.626Z",
"repo_head": "1791ffa4972b4135dacd4208e805f67b747479c4",
"repo_dirty": false,
"workers": [
{
"name": "arcrun-cypher-executor",
"source_dir": "cypher-executor",
"source_commit": "525faaf5d01e156a9b8f90808607bead92f40165",
"source_commit": "f1370e2275eea62b64a88821a096f2c2cfe76fb0",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 570290,
"content_sha256": "49d59597c01b5264875e0295c86c7bb2212bf54d3c5858cd4a167752370fa716",
"js_bytes": 577374,
"content_sha256": "8411ed59b7ad9e1a74ac0d8e3b620d7166e7d0178ac5939e6cc736f2e8d1d2be",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -277,9 +277,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
} else {
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
}
rows.push(sys.graph && sys.graph.ok
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
: sysRow('知識圖譜', '● 打不通', 'bad'));
// Arcrun#100:「服務活著嗎」與「庫裡有幾條」拆兩列。混一列時,圖服務打不通會把
// 「其實有 1854 條」整個吞掉,畫面看起來就像知識庫是空的。數字讀不到寫「讀不到」,不寫 0。
var gOk = !!(sys.graph && sys.graph.ok);
var tri = sys.graph && sys.graph.triplets != null ? sys.graph.triplets : null;
rows.push(sysRow('知識圖譜服務', gOk ? '● 正常' : '● 打不通', gOk ? 'ok' : 'bad'));
rows.push(sysRow('三元組(關聯)', tri == null ? '讀不到' : tri.toLocaleString() + ' 條', tri == null ? 'off' : ''));
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
// 三元組/已嵌入 已各有一列)
+5 -4
View File
@@ -934,14 +934,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
fetch(API_BASE + '/console/kb-scale-data')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
var n = function (v) { return v == null ? '' : v.toLocaleString(); };
// #100:讀不到就明說讀不到(原本靜默 return,會把上一輪的舊數字留在畫面上)
if (!d) { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; return; }
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
var parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)];
var latest = d.wiki_card_latest_ago_minutes;
$('se-scale').textContent = '精耕層 ' + parts.join('・') +
(latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');
})
.catch(function () { /* 規模感拿不到不擋搜尋 */ });
.catch(function () { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; });
}
$('se-sem').addEventListener('click', function () {
S.semantic = !S.semantic;
@@ -1504,7 +1505,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
]).then(function (rs) {
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;
var n = function (v) { return v == null ? '' : v.toLocaleString(); };
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
var rows = '';
rows += '<div class="kvline"><span class="muted">服務</span><span class="mono" style="font-size:14px">' + esc(svc.service || 'arcrun-cypher-executor') + '</span></div>';
rows += '<div class="kvline"><span class="muted">版本</span><span class="mono" style="color:var(--amber)">' + esc(svc.version || '—') + '</span></div>';
+52 -31
View File
@@ -1108,7 +1108,9 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
}
})();
// ── t53 完成安裝清單(進站必見,三件做完才消失)─────────────────────────────
// ── t53 完成安裝清單(進站必見,做完才消失)───────────────────────────────
// 件數演進:t53 三件 → t54 兩件(設定檔改由小幫手憑帳密自取)
// → arcrun-rag#81 一件(AI 問答改走 Workers AI,不再要用戶自備金鑰)。
function setupSteps() {
try { return JSON.parse(localStorage.getItem('arcrun_setup_steps') || '{}'); } catch (e) { return {}; }
}
@@ -1123,7 +1125,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var p = S.profile || {};
if (p.role !== 'admin') return;
var s = setupSteps();
if (s.daemon && s.key) return; // t54 起只剩兩件:設定改由小幫手輸入帳密自取,不再下載檔案
// arcrun-rag#81leo 08-12:「已經改用 workers AI,刪掉」):只剩「下載小幫手」一件。
// 舊的 s.key(貼 Google AI 金鑰)已整條移除,見下方 innerHTML 處的說明。
// 相容:舊瀏覽器 localStorage 裡殘留的 s.key 不再被讀 —— 沒設過 key 的人也不會被卡住。
if (s.daemon) return; // t54 起設定改由小幫手輸入帳密自取,不再下載檔案
var cfg = window.ARCRUN_CONFIG || {};
var dpick = daemonPick(); // t72 OS 分流(同一組判定,見上面 daemonPick)
var daemonUrl = dpick.sure ? dpick.pick.url : dpick.mac.url;
@@ -1134,7 +1139,20 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var el = document.createElement('div');
el.id = 'setup-checklist';
el.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:60;max-width:400px;width:calc(100% - 40px);padding:18px 20px;border-radius:14px;background:rgba(var(--amber-rgb),.10);border:1px solid rgba(var(--amber-rgb),.45);backdrop-filter:blur(8px);font-size:14px;line-height:1.65';
el.innerHTML = '<b>還差 ' + (2 - (s.daemon?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了</b>'
// 🔴 arcrun-rag#81leo 08-12):第二項「啟用 AI 問答(貼 Google AI 金鑰)」整條刪除。
// 為什麼不是「一個沒用的欄位」而已:它長在**安裝完成清單裡而且是勾選項**
// ⇒ 用戶會以為不做這步就沒裝完,而它要人離開流程、去第三方網站申請帳號、
// 把金鑰貼進表單——整條安裝路徑上最重的一個動作,而且是白做的。
// 真相:雲端問答走 t18108-04)改好的 Workers AI`env.AI` binding,免金鑰)——
// /portal/data/chat → tenant 的 rag_chat workflow → `workers_ai_chat` recipe
// api-recipe-seeds.ts:150endpoint `@cf/meta/llama-4-scout-17b-16e-instruct`)。
// 這裡貼的金鑰是打 POST /portal/admin/ai 存一筆雲端 gemini_api_key credential
// **現行問答鏈路一個地方都沒有讀它**。
// 後端 route 本身不動(同 08-09 拿掉檢修孔按鈕的處置:端點無害、已無任何 UI 呼叫,
// 純粹清路標);已經存過金鑰的人那筆 credential 也原封不動,不做刪除。
// ⚠️ 別把這個跟設定頁「AI 設定」面板講的地端萃取金鑰搞混——那把是填在
// **同步小幫手**托盤裡的,從來不經過這個清單。
el.innerHTML = '<b>還差 1 步,安裝就真的完成了</b>'
+ row(s.daemon, 'daemon',
'<b>下載同步小幫手</b>(把資料夾變成知識庫)<br>'
+ (daemonUrl
@@ -1156,34 +1174,13 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
// t54(leo:「最好的就是把它的帳密直接輸入」):設定不再是一個要下載的檔案——
// 小幫手第一次開啟會問網址+帳密,自己去換設定。
+ '<div class="muted" style="font-size:12.5px;margin-top:4px">裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了,不用下載設定檔。</div>')
+ row(s.key, 'key',
'<b>啟用 AI 問答</b><a href="https://aistudio.google.com" target="_blank" rel="noopener">aistudio.google.com</a> 免費申請)<br>'
+ '<input id="sc-key" type="password" placeholder="貼上 Google AI 金鑰" style="width:60%;padding:6px 8px;border-radius:8px;border:1px solid rgba(var(--ink-rgb),.25);background:rgba(var(--ink-rgb),.04);color:var(--ink)"> '
+ '<button class="btn3" id="sc-key-save" style="padding:6px 12px;border-radius:8px;cursor:pointer">啟用</button>'
+ '<div id="sc-key-msg" style="font-size:12.5px;min-height:1.1em"></div>')
+ '<div style="margin-top:10px;text-align:right"><button id="sc-later" style="border:none;background:none;color:inherit;cursor:pointer;font-size:12.5px;text-decoration:underline;opacity:.65">稍後再說</button></div>';
document.body.appendChild(el);
var dl = document.getElementById('sc-daemon');
if (dl) dl.addEventListener('click', function () { markStep('daemon'); });
// t54config.json 下載鈕已移除(設定由小幫手憑帳密自取)
var kb = document.getElementById('sc-key-save');
if (kb) kb.addEventListener('click', function () {
var k = (document.getElementById('sc-key').value || '').trim();
var m = document.getElementById('sc-key-msg');
if (!k) { m.textContent = '請先貼上金鑰'; return; }
kb.disabled = true; m.textContent = '啟用中…';
fetch(API_BASE + '/portal/admin/ai', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ gemini_api_key: k })
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
kb.disabled = false;
if (!x.ok || !x.d.success) { m.textContent = (x.d && x.d.error) || '啟用失敗,請再試一次'; return; }
markStep('key');
})
.catch(function () { kb.disabled = false; m.textContent = '網路好像有問題,請再試一次'; });
});
// arcrun-rag#81:金鑰輸入框的送出邏輯(POST /portal/admin/ai)一併移除——
// 只藏畫面留著那條路,等於這個要求還在,只是變得更難發現。
var later = document.getElementById('sc-later');
if (later) later.addEventListener('click', function () { el.remove(); }); // 只藏本次,下次進站再提醒
}
@@ -1486,7 +1483,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
$('se-q').value = name;
doGraphSearch(name);
}
// 讀不到就明說「讀不到」——標題列**絕不**留著 0 或舊數字(Arcrun#100leo 看到
// 「0 個實體・0 條關聯」以為要去上傳文件,其實庫裡有 1854 條,只是這支讀失敗了)。
function mapUnavailable(html) {
$('map-meta').textContent = '讀不到';
$('map-box').innerHTML = '<div class="err" style="padding:30px 10px">' + html + '</div>';
}
function loadMap() {
$('map-meta').textContent = '';
$('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">載入總圖中…</div>';
$('map-md-link').innerHTML = SOURCE_WEB_BASE
? '<a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
@@ -1495,14 +1499,31 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $('map-box').innerHTML = '<div class="err">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + '')) + '</div>'; return; }
var nodes = x.d.nodes || [];
var edges = x.d.edges || [];
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : '');
if (!x.ok) { mapUnavailable(esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ''))); return; }
// Arcrun#100:「0」只准在後端確認過真的是 0 的時候出現。
// 形狀不對 → 讀不到(不是空庫);nodes 為空但 empty_confirmed 不成立 → 讀不到。
if (!Array.isArray(x.d.nodes) || !Array.isArray(x.d.edges)) {
mapUnavailable('總圖回應格式不對——沒有拿到關聯資料。這不代表知識庫是空的。');
return;
}
var nodes = x.d.nodes, edges = x.d.edges;
var total = typeof x.d.triplets_total === 'number' ? x.d.triplets_total : null;
if (!nodes.length && x.d.empty_confirmed !== true) {
mapUnavailable(x.d.empty_reason === 'scope_mismatch'
? '讀不到你這個帳號的關聯資料——知識庫裡有三元組'
+ (total ? '(本帳號範圍算到 ' + total.toLocaleString() + ' 條)' : '')
+ ',但這張圖一條都抽不出來。<br>'
+ '<b>這不是「還沒有關聯」,不用去上傳文件</b>;比較像資料的歸屬範圍對不上,請通知管理員。'
: '讀不到知識庫的關聯資料,無法確認庫裡有沒有關聯。<br>'
+ '<b>這不是「還沒有關聯」,不用去上傳文件</b>——是這次讀取失敗,請稍後重整或通知管理員。');
return;
}
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯'
+ (total !== null && x.d.truncated ? '(全庫共 ' + total.toLocaleString() + ' 條,已達單次上限)' : x.d.truncated ? '・已達上限截斷' : '');
if (!nodes.length) { $('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。</div>'; return; }
renderMap(nodes, edges);
})
.catch(function (e) { $('map-box').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
.catch(function (e) { mapUnavailable('請求失敗:' + esc(friendlyErr(e))); });
}
function renderMap(nodes, edges) {
var N = nodes.length;
+12 -2
View File
@@ -24,6 +24,8 @@ import { consoleAuthRouter } from './routes/console-auth';
import { consoleDashboardRouter } from './routes/console-dashboard';
import { portalRouter } from './routes/portal';
import { portalDataRouter } from './routes/portal-data';
import { storageRouter } from './routes/storage';
import { withDurableStores } from './lib/durable-store';
const app = new Hono<{ Bindings: Bindings }>();
@@ -97,11 +99,19 @@ app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單
app.route('/', consoleDashboardRouter); // T-cockpit ②:駕駛艙 dashboard(聚合 KBDB dash_* entries,無需登入唯讀)
app.route('/', portalRouter); // portal-auth P2#24/#25):RAG Portal 多人授權——用戶模型+認證 API
app.route('/', portalDataRouter); // portal-auth P3/portal/data/* server-side enforceowner_idlibrary 注入,安全核心)
app.route('/', storageRouter); // KV 退休(#16/#17):資產遷移/盤點端點
// Worker 導出(fetch + scheduled
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick
// 邏輯在 src/scheduled.ts。對應 SDD: arcrun.md 三-A P1 #3。
//
// 🔴 KV 退休(Leo/Arcrun#16 + #17):WEBHOOKS / RECIPES 在這裡被換成 KBDB 撐腰的版本
//lib/durable-store.ts)。**這是唯一的接線點**——換在入口,四十幾處呼叫端一行不動,
// 也就沒有「某一處忘了改」這種漏洞(那正是資產會不見的入口)。
// 使用者的工作流與 recipe 從此住在 KBDBD1,一份資產一列 entry),KV 只是快取:
// KV 被換掉/重建之後,資料仍在,且會在下一次讀取時自己長回快取。
export default {
fetch: app.fetch,
scheduled: handleScheduled,
fetch: (req: Request, env: Bindings, ctx: ExecutionContext) => app.fetch(req, withDurableStores(env), ctx),
scheduled: (event: ScheduledController, env: Bindings, ctx: ExecutionContext) =>
handleScheduled(event, withDurableStores(env), ctx),
} satisfies ExportedHandler<Bindings>;
+156
View File
@@ -0,0 +1,156 @@
/**
* asset-keys KV key 使 KBDB
*
* KV 退Leo/Arcrun#16 + #17leo 2026-08-12
* KBDB KV RecipesCypher entry
* recipe
*
*
* ** KBDBKV **
* KV webhooks-named / portal / executions / component-loader /
* auth-dispatcher / wasi-shim
* 西 binding durable-store.ts
* 西** key **
*
* vs
* = 西使/AI recipe KBDB
* = 西 idx:*cron session
* TTL KVdurable-store
*
* KBDB
* 西
*
* KV key KBDB
* KBDB KVleo entry
* portal/console key ****
* entry_type + owner_id + page_name + KBDB
*/
/** KBDB 裡 arcrun 資產的 entry_type(每個都有一列 template 定義,見 kbdb/migrations/0005)。 */
export type AssetEntryType = 'workflow_def' | 'api_recipe' | 'auth_recipe' | 'prompt_recipe';
export interface AssetRef {
entry_type: AssetEntryType;
/** KBDB entries.id——由 key 決定,同一份資產永遠同一列(冪等,重跑遷移不長重複)。 */
entry_id: string;
/** 租戶。recipe 是整台實例共用的庫,故為 null(與現行 RECIPES KV 無租戶前綴一致)。 */
owner_id: string | null;
/** 該型別的自然鍵(workflow 名 / recipe uuid / service 名),對應 entries.page_name。 */
page_name: string;
/** 原本的 KV key——反向重建快取時要用(KBDB → KV 回填)。 */
kv_key: string;
}
/** entries.id 的前綴,跟別人的資料分得開,也讓 `arcrun:` 一眼看得出是誰的列。 */
const ID_PREFIX = 'arcrun';
/**
* KV key KBDB null KV
*
* **** KBDB
* KBDB 2026-08-08 credential
*/
export function classifyAssetKey(key: string): AssetRef | null {
// ── 明確排除的衍生資料(放最前面,免得被下面的樣式誤收)────────────────
// idx:* recipe/component 反查索引(canonical→uuid、hash→canonical
// cron-idx:* cron 排程索引(8.P0 的單一 key
// 兩者都能從資產重算,見 durable-store.ts 的 rehydrate*。
if (key.startsWith('idx:') || key.startsWith('cron-idx:')) return null;
// auth_recipe:{service} — 「怎麼認證」的定義。注意只有定義,沒有任何密文
//(憑證明文在 CF Workers Secrets,見 .claude/rules/01-tech-stack.md「Credential 儲存規範」)。
if (key.startsWith('auth_recipe:')) {
const service = key.slice('auth_recipe:'.length);
if (!service) return null;
return {
entry_type: 'auth_recipe',
entry_id: `${ID_PREFIX}:auth_recipe:${service}`,
owner_id: null,
page_name: service,
kv_key: key,
};
}
// prompt_recipe:{name}
if (key.startsWith('prompt_recipe:')) {
const name = key.slice('prompt_recipe:'.length);
if (!name) return null;
return {
entry_type: 'prompt_recipe',
entry_id: `${ID_PREFIX}:prompt_recipe:${name}`,
owner_id: null,
page_name: name,
kv_key: key,
};
}
// recipe:{uuid}recipe:{canonical_id}migration 前的舊 key,仍是資產,一樣要保住)
if (key.startsWith('recipe:')) {
const id = key.slice('recipe:'.length);
if (!id) return null;
return {
entry_type: 'api_recipe',
entry_id: `${ID_PREFIX}:recipe:${id}`,
owner_id: null,
page_name: id,
kv_key: key,
};
}
// {api_key}:wf:{name} — 具名工作流(acr push / portal 安裝器寫的那把)。
// 用**第一個** ':wf:' 切:api_key 不含冒號,而 workflow 名允許的字元集
//webhooks-named.ts 驗 /^[\w-]+$/)本來就不含冒號,所以切點唯一。
const wfAt = key.indexOf(':wf:');
if (wfAt > 0) {
const owner = key.slice(0, wfAt);
const name = key.slice(wfAt + ':wf:'.length);
if (!owner || !name) return null;
return {
entry_type: 'workflow_def',
entry_id: `${ID_PREFIX}:wf:${owner}:${name}`,
owner_id: owner,
page_name: name,
kv_key: key,
};
}
// 其餘一律衍生/暫存:匿名 webhook token、daemon-active、session、stats… 留在 KV。
return null;
}
/** 從 KBDB 的一列反推回原本的 KV key(快取回填、list 都要用)。 */
export function assetKvKey(entryType: AssetEntryType, ownerId: string | null, pageName: string): string {
switch (entryType) {
case 'workflow_def':
return `${ownerId ?? ''}:wf:${pageName}`;
case 'api_recipe':
return `recipe:${pageName}`;
case 'auth_recipe':
return `auth_recipe:${pageName}`;
case 'prompt_recipe':
return `prompt_recipe:${pageName}`;
}
}
/**
* KV list prefix KBDB
*
* list KBDB get KV ****
* KV list 2026-08-12
* get KV miss list
*
* null = prefix cron-idx: KV
*/
export function classifyListPrefix(prefix: string | undefined): { entry_type: AssetEntryType; owner_id?: string } | null {
if (!prefix) return null; // 無 prefix 的全域 listwebhooks-list)維持原行為
if (prefix.startsWith('idx:') || prefix.startsWith('cron-idx:')) return null;
if (prefix === 'auth_recipe:') return { entry_type: 'auth_recipe' };
if (prefix === 'prompt_recipe:') return { entry_type: 'prompt_recipe' };
if (prefix === 'recipe:') return { entry_type: 'api_recipe' };
// `{api_key}:wf:` — 列出某租戶的所有工作流(webhooks-named / portal-data 都用這個)
if (prefix.endsWith(':wf:')) {
const owner = prefix.slice(0, -':wf:'.length);
if (owner) return { entry_type: 'workflow_def', owner_id: owner };
}
return null;
}
+6 -1
View File
@@ -77,7 +77,12 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
filter: 'SVC_FILTER',
merge: 'SVC_MERGE',
try_catch: 'SVC_TRY_CATCH',
wait: 'SVC_WAIT',
// wait 已於 Arcrun#1012026-08-12)移進 BUILTIN_COMPONENTSstep 1)——
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.113 個既有 binding 保留不新增),
// 拆綁定要重新部署、與本票無關。
set: 'SVC_SET',
array_ops: 'SVC_ARRAY_OPS',
string_ops: 'SVC_STRING_OPS',
+62
View File
@@ -47,6 +47,13 @@ export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
'SUBFLOW': 'CALLS_SUBFLOW',
};
/**
* wait registry/components/wait/component.contract.yaml
* **調**Arcrun#101
*
*/
export const WAIT_MAX_MS = 30000;
/**
*
* WASM = Workercypher-executor HTTP URL R2
@@ -61,6 +68,61 @@ export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
const c = ctx as Record<string, unknown>;
return { ...c, count: (Number(c.count) || 0) + 1 };
}],
// ── wait:等待 N 毫秒後繼續(Arcrun#1012026-08-12)────────────────────────
//
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
//
// 舊實作是 registry/components/wait/main.goTinyGo → WASM),用 time.Sleep。
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
// poll_oneoff 實作成 ENOSYS`.component-builds/*/src/index.ts``poll_oneoff: () => 76`
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
//
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
// ⇒ 時鐘並沒有全程凍結。
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
//
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
//
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
//
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
// 吃 ms(必填 > 0)+可選 contextms > WAIT_MAX_MS 截斷;
// 回 { success: true, data: { ...context, waited_ms } }ms <= 0 回 success:false。
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
['wait', async (ctx) => {
const c = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
const requested = typeof c.ms === 'number' ? c.ms : Number(c.ms);
if (!Number.isFinite(requested) || requested <= 0) {
return { success: false, error: 'ms 必須大於 0' };
}
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
// 這一行就是整張票:await timer ⇒ 只走 wall-clock,不佔請求執行緒、不記 CPU。
await new Promise<void>((resolve) => setTimeout(resolve, ms));
const passthrough = (c.context && typeof c.context === 'object' && !Array.isArray(c.context))
? c.context as Record<string, unknown>
: {};
return { success: true, data: { ...passthrough, waited_ms: ms } };
}],
]);
export const SCORE_THRESHOLD = 0.5;
+448
View File
@@ -0,0 +1,448 @@
/**
* durable-store KV KBDB
*
* KV 退Leo/Arcrun#16 + #17leo 2026-08-12
* KV 使
* AI
* recipe
* 使
* cli/src/lib/resource-resolver.ts Arcrun#97
*
*
* ** KBDBD1 entryKV 退**
* KV KBDB
*
* binding
* WEBHOOKS / RECIPES webhooks-namedportalexecutionscomponent-loader
* auth-dispatcherwasi-shim **
* 西** worker binding
* key asset-keys.ts 西
*
*
* 1. **** KV KBDB KV
* 2. **** KBDB KV
* KBDB ****
* 3. **** KBDB** KV** KV
* KV
*
* idx:* / cron-idx:*
* KBDBKBDB ****
* rehydrateRecipeIndices / rehydrateCronIndex
*/
import { classifyAssetKey, classifyListPrefix, assetKvKey, type AssetRef, type AssetEntryType } from './asset-keys';
import { CRON_INDEX_KEY, cronEntryKey, type CronIndex } from './cron-index';
export interface KbdbEnv {
KBDB_BASE_URL?: string;
KBDB_INTERNAL_TOKEN?: string;
}
/** KBDB 位址與 token 的取法沿用既有慣例(lib/workflow-search.ts、routes/webhooks-named.ts 同款)。 */
function kbdbBase(env: KbdbEnv): string {
return (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
}
function kbdbHeaders(env: KbdbEnv): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.KBDB_INTERNAL_TOKEN) h['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
return h;
}
/** KBDB 一列 entry 的回應形狀(只取本檔用得到的欄位)。 */
interface KbdbEntry {
id: string;
content?: string | null;
entry_type?: string | null;
owner_id?: string | null;
page_name?: string | null;
metadata_json?: string | null;
updated_at?: number;
}
/** 資產寫進 metadata_json 的信封。definition = 原值 parse 過的物件;非 JSON 的原字串走 definition_raw。 */
interface AssetEnvelope {
arcrun_asset: true;
kv_key: string;
definition?: unknown;
definition_raw?: string;
/** api_recipe 專用:本部署目前安裝的是不是這一版(重建 idx:installed:* 用,見 rehydrateRecipeIndices)。 */
installed?: boolean;
}
export class KbdbUnavailableError extends Error {
constructor(op: string, detail: string) {
super(
`資產無法寫入 KBDB${op}):${detail}` +
'本次操作已中止且未寫入任何一邊——這是刻意的:寧可讓你現在看到失敗,' +
'也不要寫進只會被下次更新換掉的 KV、事後才發現東西不見了(Leo/Arcrun#16、#17)。',
);
this.name = 'KbdbUnavailableError';
}
}
/** 從資產定義裡挑一句「給人看也給搜尋看」的描述,當 entries.content。 */
function assetContent(type: AssetEntryType, def: unknown, pageName: string): string {
const d = (def ?? {}) as Record<string, unknown>;
const pick = (...keys: string[]): string => {
for (const k of keys) {
const v = d[k];
if (typeof v === 'string' && v.trim()) return v.trim();
}
return '';
};
switch (type) {
case 'workflow_def':
return pick('description') || pageName;
case 'api_recipe':
return pick('description', 'display_name', 'canonical_id') || pageName;
case 'auth_recipe':
return pick('description', 'display_name', 'service') || pageName;
case 'prompt_recipe':
return pick('description', 'name') || pageName;
}
}
/** 把 KBDB 一列還原成原本的 KV 值(字串)。不是資產信封(或壞掉)→ null,誠實當作沒有。 */
function entryToKvValue(entry: KbdbEntry | null | undefined): string | null {
if (!entry?.metadata_json) return null;
try {
const env = JSON.parse(entry.metadata_json) as AssetEnvelope;
if (!env || env.arcrun_asset !== true) return null;
if (typeof env.definition_raw === 'string') return env.definition_raw;
if (env.definition === undefined) return null;
return JSON.stringify(env.definition);
} catch {
return null;
}
}
/**
* KV binding KVNamespace
* request rehydrate per-instance
*/
export class DurableKv {
private rehydratedRecipeIdx = false;
private rehydratedCronIdx = false;
constructor(
private readonly kv: KVNamespace,
private readonly env: KbdbEnv,
) {}
/**
* KV****routes/storage.ts
* KV KBDB
* list KBDB
*
*/
get rawKv(): KVNamespace {
return this.kv;
}
// ── KBDB 存取原語 ──────────────────────────────────────────────────────
private async kbdbGetEntry(entryId: string): Promise<KbdbEntry | null> {
const res = await fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(entryId)}`, {
headers: kbdbHeaders(this.env),
});
if (res.status === 404) return null;
if (!res.ok) return null; // 讀不到就當沒有;快取仍可能有值,不炸讀取路徑
const json = (await res.json().catch(() => null)) as { entry?: KbdbEntry } | null;
return json?.entry ?? null;
}
private async kbdbListEntries(entryType: AssetEntryType, ownerId?: string): Promise<KbdbEntry[]> {
const params = new URLSearchParams({ entry_type: entryType, limit: '1000' });
if (ownerId) params.set('owner_id', ownerId);
const res = await fetch(`${kbdbBase(this.env)}/entries?${params.toString()}`, {
headers: kbdbHeaders(this.env),
});
if (!res.ok) throw new KbdbUnavailableError('list', `HTTP ${res.status}`);
const json = (await res.json().catch(() => null)) as { entries?: KbdbEntry[] } | null;
return json?.entries ?? [];
}
private async kbdbPutEntry(ref: AssetRef, envelope: AssetEnvelope): Promise<void> {
const content = assetContent(ref.entry_type, envelope.definition, ref.page_name);
const res = await fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(ref.entry_id)}`, {
method: 'PUT',
headers: kbdbHeaders(this.env),
body: JSON.stringify({
entry_type: ref.entry_type,
owner_id: ref.owner_id,
page_name: ref.page_name,
content,
// 刻意**不**標 embed:true:工作流的語意搜尋走既有的 entry_type='workflow' 那一列
//workflow-discovery 方案 C 的雙寫),這裡標了會變成同一支工作流嵌兩份向量。
metadata_json: JSON.stringify(envelope),
}),
});
if (!res.ok) throw new KbdbUnavailableError('put', `HTTP ${res.status} @ ${ref.entry_id}`);
}
private async kbdbDeleteEntry(entryId: string): Promise<void> {
const res = await fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(entryId)}`, {
method: 'DELETE',
headers: kbdbHeaders(this.env),
});
// 404 = 本來就沒有,對刪除而言是成功(冪等)。
if (!res.ok && res.status !== 404) throw new KbdbUnavailableError('delete', `HTTP ${res.status} @ ${entryId}`);
}
// ── 衍生索引重建(讀不到就重算,不進 KBDB)────────────────────────────
/**
* KBDB api_recipe recipe
* idx:{hash_id} canonical_id
* idx:canonical:{canonical} [uuid, ...]
* idx:installed:{canonical} uuid
*
* installed installed
* put() `idx:installed:` canonical
*退 updated_at
* installRecipeRecord
* **退**
*/
private async rehydrateRecipeIndices(): Promise<void> {
if (this.rehydratedRecipeIdx) return;
this.rehydratedRecipeIdx = true;
const entries = await this.kbdbListEntries('api_recipe');
const byCanonical = new Map<string, Array<{ uuid: string; installed: boolean; updated_at: number }>>();
const writes: Array<Promise<unknown>> = [];
for (const e of entries) {
const raw = entryToKvValue(e);
if (!raw) continue;
let def: { uuid?: string; canonical_id?: string; hash_id?: string };
try { def = JSON.parse(raw) as typeof def; } catch { continue; }
if (!def.canonical_id) continue;
if (def.hash_id) writes.push(this.kv.put(`idx:${def.hash_id}`, def.canonical_id));
if (!def.uuid) continue;
let installed = false;
try {
installed = (JSON.parse(e.metadata_json ?? '{}') as AssetEnvelope).installed === true;
} catch { /* 壞信封 → 當作沒標記,走 updated_at 退路 */ }
const list = byCanonical.get(def.canonical_id) ?? [];
list.push({ uuid: def.uuid, installed, updated_at: e.updated_at ?? 0 });
byCanonical.set(def.canonical_id, list);
}
for (const [canonical, versions] of byCanonical) {
writes.push(this.kv.put(`idx:canonical:${canonical}`, JSON.stringify(versions.map((v) => v.uuid))));
const chosen =
versions.find((v) => v.installed) ??
versions.reduce((a, b) => (b.updated_at > a.updated_at ? b : a));
writes.push(this.kv.put(`idx:installed:${canonical}`, chosen.uuid));
}
await Promise.all(writes);
}
/** 從 KBDB 的 workflow_def 列重建 cron 索引(單一 key,見 lib/cron-index.ts)。 */
private async rehydrateCronIndex(): Promise<void> {
if (this.rehydratedCronIdx) return;
this.rehydratedCronIdx = true;
const entries = await this.kbdbListEntries('workflow_def');
const index: CronIndex = {};
for (const e of entries) {
const raw = entryToKvValue(e);
if (!raw) continue;
let def: { cron_expr?: string };
try { def = JSON.parse(raw) as typeof def; } catch { continue; }
if (!def.cron_expr || !e.owner_id || !e.page_name) continue;
index[cronEntryKey(e.owner_id, e.page_name)] = def.cron_expr;
}
// 即使是空的也要寫回去:寫了之後 get 就命中,下一分鐘的 tick 不會再重算一次
//(不寫的話 scheduled() 每分鐘都會回源 KBDB 一趟,白花錢)。
await this.kv.put(CRON_INDEX_KEY, JSON.stringify(index));
}
// ── KVNamespace 介面 ───────────────────────────────────────────────────
async get(key: string, type?: 'text' | 'json' | 'arrayBuffer' | 'stream' | { type: string }): Promise<any> {
// 二進位/串流形態本 worker 沒有呼叫端在用(資產都是 JSON 文字)。原樣轉發,
// 不假裝支援——真有人開始用而拿不到 KBDB 回源,會在這裡被看見,不是靜默降級。
const t0 = typeof type === 'string' ? type : type?.type;
if (t0 === 'arrayBuffer' || t0 === 'stream') return this.kv.get(key, t0 as 'arrayBuffer');
const asText = (raw: string | null): unknown => {
if (raw === null) return null;
const t = typeof type === 'string' ? type : type?.type;
if (t === 'json') {
try { return JSON.parse(raw); } catch { return null; }
}
return raw;
};
const ref = classifyAssetKey(key);
if (!ref) {
// 衍生/暫存:原樣走 KV。讀不到而且是「算得出來」的索引 → 重算一次再讀。
const raw = await this.kv.get(key, 'text');
if (raw !== null) return asText(raw);
if (key.startsWith('idx:')) {
await this.rehydrateRecipeIndices().catch(() => {});
return asText(await this.kv.get(key, 'text'));
}
if (key === CRON_INDEX_KEY) {
await this.rehydrateCronIndex().catch(() => {});
return asText(await this.kv.get(key, 'text'));
}
return asText(raw);
}
// 資產:快取優先,miss 回源 KBDB 並補快取(被換掉的空 KV 就是這樣自己痊癒的)。
const cached = await this.kv.get(key, 'text');
if (cached !== null) return asText(cached);
const fromKbdb = entryToKvValue(await this.kbdbGetEntry(ref.entry_id));
if (fromKbdb === null) return asText(null);
await this.kv.put(key, fromKbdb).catch(() => {}); // 補快取失敗不影響這次讀取
return asText(fromKbdb);
}
async put(key: string, value: string | ArrayBuffer | ReadableStream, options?: KVNamespacePutOptions): Promise<void> {
// 帶 TTL=定義上就是暫存(daemon 回報、session…),不是資產,不進 KBDB。
if (options?.expirationTtl || options?.expiration || typeof value !== 'string') {
return this.kv.put(key, value as string, options);
}
// idx:installed:{canonical} 不是資產,但它記的是**使用者的選擇**(這個 canonical 目前
// 裝的是哪一版),純算不回來。所以把它記進對應那一版 recipe 資產的信封裡,
// KBDB 端不會多出一列「指標 entry」,重建時又還原得精確(見 rehydrateRecipeIndices)。
if (key.startsWith('idx:installed:')) {
await this.kv.put(key, value, options);
await this.markInstalledVersion(key.slice('idx:installed:'.length), value).catch(() => {
// 標記失敗不擋主流程:recipe 本體已經在 KBDB,最壞情況是重建時退回 updated_at 那條路。
});
return;
}
const ref = classifyAssetKey(key);
if (!ref) return this.kv.put(key, value, options);
let definition: unknown;
let definitionRaw: string | undefined;
try { definition = JSON.parse(value); } catch { definitionRaw = value; }
// 覆寫定義不該把「這版是目前安裝的那版」洗掉 → 只有 api_recipe 需要先讀回舊信封。
// 其他三型沒有這個欄位,省下這一次往返(每次 acr push 都會走到這裡)。
const previous = ref.entry_type === 'api_recipe' ? await this.readEnvelope(ref) : null;
// 先真相、後快取。KBDB 失敗就拋——不寫 KV、不回報成功(禁假綠,mindset §7)。
await this.kbdbPutEntry(ref, {
arcrun_asset: true,
kv_key: key,
...(definitionRaw !== undefined ? { definition_raw: definitionRaw } : { definition }),
...(previous?.installed ? { installed: true } : {}),
});
await this.kv.put(key, value, options);
}
async delete(key: string): Promise<void> {
const ref = classifyAssetKey(key);
if (ref) await this.kbdbDeleteEntry(ref.entry_id);
await this.kv.delete(key);
}
async list(options?: KVNamespaceListOptions): Promise<KVNamespaceListResult<unknown, string>> {
const target = classifyListPrefix(options?.prefix ?? undefined);
if (!target) return this.kv.list(options) as Promise<KVNamespaceListResult<unknown, string>>;
// 資產列舉一律回源(見檔頭規則 3)。順手把每一筆補進快取——列表回應本來就帶了完整內容,
// 呼叫端接著一筆筆 get 時就會全部命中,一次回源換掉 N 次往返。
const entries = await this.kbdbListEntries(target.entry_type, target.owner_id);
const keys: Array<{ name: string }> = [];
const warm: Array<Promise<unknown>> = [];
for (const e of entries) {
if (!e.page_name) continue;
// workflow_def 的 KV key 由租戶+名字組成,缺租戶就組不出正確的 key——
// 與其回一個 `:wf:x` 這種對不到任何東西的名字,不如跳過(列不出來看得見,
// 組錯名字則會安靜地讀到 null,那更難查)。
if (target.entry_type === 'workflow_def' && !e.owner_id) continue;
const name = assetKvKey(target.entry_type, e.owner_id ?? null, e.page_name);
keys.push({ name });
const raw = entryToKvValue(e);
if (raw !== null) warm.push(this.kv.put(name, raw).catch(() => {}));
}
await Promise.all(warm);
return { keys, list_complete: true, cacheStatus: null } as unknown as KVNamespaceListResult<unknown, string>;
}
/** KVNamespace 介面補齊(本 worker 沒有呼叫端在用,原樣轉發,不做資產處理)。 */
getWithMetadata(key: string, type?: any): Promise<any> {
return (this.kv as unknown as { getWithMetadata: (k: string, t?: any) => Promise<any> }).getWithMetadata(key, type);
}
// ── 內部小工具 ─────────────────────────────────────────────────────────
private async readEnvelope(ref: AssetRef): Promise<AssetEnvelope | null> {
const entry = await this.kbdbGetEntry(ref.entry_id);
if (!entry?.metadata_json) return null;
try {
const env = JSON.parse(entry.metadata_json) as AssetEnvelope;
return env?.arcrun_asset === true ? env : null;
} catch {
return null;
}
}
/** 把「這個 canonical 目前裝的是哪一版」記進該版 recipe 的信封(同 canonical 的其他版清掉旗標)。 */
private async markInstalledVersion(canonicalId: string, uuid: string): Promise<void> {
const entries = await this.kbdbListEntries('api_recipe');
const jobs: Array<Promise<unknown>> = [];
for (const e of entries) {
const raw = entryToKvValue(e);
if (!raw) continue;
let def: { uuid?: string; canonical_id?: string };
try { def = JSON.parse(raw) as typeof def; } catch { continue; }
if (def.canonical_id !== canonicalId || !def.uuid) continue;
const shouldBeInstalled = def.uuid === uuid;
let envelope: AssetEnvelope;
try { envelope = JSON.parse(e.metadata_json ?? '{}') as AssetEnvelope; } catch { continue; }
if ((envelope.installed === true) === shouldBeInstalled) continue; // 已經是對的,不白寫
envelope.installed = shouldBeInstalled;
jobs.push(
fetch(`${kbdbBase(this.env)}/entries/${encodeURIComponent(e.id)}`, {
method: 'PATCH',
headers: kbdbHeaders(this.env),
body: JSON.stringify({ metadata_json: JSON.stringify(envelope) }),
}),
);
}
await Promise.all(jobs);
}
}
/**
* worker WEBHOOKS / RECIPES KBDB
*
* **使西**recipe
* KVEXEC_CONTEXT SESSIONS_KVANALYTICS_KVCREDENTIALS_KV
* credential CF Workers Secrets + D1
* .claude/rules/01-tech-stack.md****
*
* KBDB_BASE_URL kbdbBase()
* 退 KV
*/
export function withDurableStores<T extends { WEBHOOKS?: KVNamespace; RECIPES?: KVNamespace } & KbdbEnv>(env: T): T {
const wrapped = { ...env } as T;
if (env.WEBHOOKS) wrapped.WEBHOOKS = new DurableKv(env.WEBHOOKS, env) as unknown as KVNamespace;
if (env.RECIPES) wrapped.RECIPES = new DurableKv(env.RECIPES, env) as unknown as KVNamespace;
return wrapped;
}
/**
* KV DurableKv.rawKv
* grep routes/storage.ts
*/
export function unwrapKv(binding: KVNamespace): KVNamespace {
const maybe = binding as unknown as { rawKv?: KVNamespace };
return maybe.rawKv ?? binding;
}
@@ -48,7 +48,7 @@
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { kbdbBase, graphBase } from './kbdb-proxy';
import { kbdbBase, graphBase, graphHeaders } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
import {
type KbdbEntry,
@@ -104,6 +104,31 @@ async function fetchJson<T>(url: string, headers?: Record<string, string>): Prom
}
}
/**
* ****null = 0
*
* 🔴 Arcrun#100 graph-plugin `/triplets/stats` `total`
* `total` **** COUNT `/records/by-template/triplet`KBDB
* `searchByTemplate` limit=100 500 owner 1854
* 100 401 0100
* KBDB `/records/triplet-stats` SQL COUNT(*) owner_id
* `{ success, stats: [{ library, triplet_count }] }`
*/
async function fetchTripletTotal(env: Bindings, tenant: string): Promise<number | null> {
const { base, headers } = kbdbBase(env);
const data = await fetchJson<{ stats?: { triplet_count?: unknown }[] }>(
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant)}`,
headers,
);
if (!data || !Array.isArray(data.stats)) return null;
let total = 0;
for (const row of data.stats) {
if (typeof row?.triplet_count !== 'number') return null; // 形狀不對 → 誠實回讀不到,不半信半疑加總
total += row.triplet_count;
}
return total;
}
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
const { base, headers } = kbdbBase(env);
@@ -254,6 +279,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
kbdbHealth,
embedStatus,
graphStats,
tripletTotal,
entriesTotal,
wikiCardTotal,
workflowTotal,
@@ -265,7 +291,13 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(
`${graphUrl}/triplets/stats`,
graphHeaders(c.env),
),
fetchTripletTotal(c.env, tenant),
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732
fetchEntryTotal(c.env, { owner_id: tenant }),
fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
@@ -400,13 +432,14 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
embed: embedStatus
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
: null,
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
// ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關)
graph: { ok: graphStats !== null, triplets: tripletTotal },
workflow_total: workflowTotal,
},
kb: {
entries_total: entriesTotal,
wiki_card_total: wikiCardTotal,
triplets_total: graphStats?.total ?? null,
triplets_total: tripletTotal,
},
generated_at: new Date(now).toISOString(),
});
@@ -420,15 +453,15 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
const tenant = c.env.CONSOLE_TENANT || 'leo';
const { base, headers } = kbdbBase(c.env);
const graphUrl = graphBase(c.env);
const now = Date.now();
const [wikiCards, graphStats, embedStatus] = await Promise.all([
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
// limit=1 順手拿最新一筆 created_atlist 為 created_at DESC)=「最近寫入時間」
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
headers,
),
fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`),
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
fetchTripletTotal(c.env, tenant),
fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
]);
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
@@ -436,7 +469,7 @@ consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
return c.json({
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null,
triplets_total: tripletTotal,
embedded: embedStatus?.embedded ?? null,
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
generated_at: new Date(now).toISOString(),
+16 -2
View File
@@ -223,13 +223,27 @@ export function graphBase(env: Bindings): string {
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
}
/**
* kbdb-graph-plugin internal headers** plugin **Arcrun#100
*
* plugin kbdb-graph-plugin/src/index.ts `/triplets` `/graph` `/search` `/entities`
* Bearer KBDB_INTERNAL_TOKEN 401
* neighborsportal-data neighborsconsole-dashboard stats
* `/triplets/stats` 401 0
*
*/
export function graphHeaders(env: Bindings): Record<string, string> {
const headers: Record<string, string> = {};
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
return headers;
}
// GET /kbdb/graph/neighbors/:name — 查某節點(entity/卡片名)的鄰居 + 邊。
// 查無 triplet 資料時 plugin 回空陣列——前端據此顯示「尚無關聯資料」(誠實,不編造關聯)。
kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const base = graphBase(c.env);
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
+81 -7
View File
@@ -24,7 +24,7 @@ import { Hono } from 'hono';
import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
import { graphBase } from './kbdb-proxy';
import { graphBase, graphHeaders } from './kbdb-proxy';
import { executeWebhookGraph } from '../actions/webhook-handlers';
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
@@ -132,7 +132,13 @@ function canReadLibrary(userLibraries: string[], library: string): boolean {
// execution_log/execution_log_usageKV 額度事故修復,2026-08-07):workflow 執行紀錄與其內部
// 用量計數器,entry_type 與既有 value/workflow 同層級的內部型別——一併排除,避免用戶搜尋知識時
// 混進執行 log(同層防線:本模組也從不設 metadata_json.embed=true,永不進語意搜尋索引)。
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow', 'execution_log', 'execution_log_usage']);
// KV 退休(#16/#17)新增四型:資產定義本體住進 KBDB 之後,它們是「系統的東西」而不是
// 使用者的知識卡——知識瀏覽/搜尋要跟 execution_log 一樣排除,否則 portal 會冒出
// 一堆 workflow_def / api_recipe 汙染結果。
const INTERNAL_ENTRY_TYPES = new Set([
'value', 'workflow', 'execution_log', 'execution_log_usage',
'workflow_def', 'api_recipe', 'auth_recipe', 'prompt_recipe',
]);
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
entries: T[],
@@ -186,6 +192,45 @@ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): stri
return hits.reduce((a, b) => a.length <= b.length ? a : b);
}
/**
* KBDB `/records/triplet-stats` SQL COUNTowner '' KBDB
* `?1 = '' OR e.owner_id = ?1`nullcaller 0
*/
async function tripletCount(env: Bindings, owner: string): Promise<number | null> {
try {
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
if (!body || !Array.isArray(body.stats)) return null;
let total = 0;
for (const row of body.stats) {
if (typeof row?.triplet_count !== 'number') return null;
total += row.triplet_count;
}
return total;
} catch {
return null;
}
}
/**
* Arcrun#100
*
* leo portal.ts §.5 daemon diagnostics**
* **t161 record owner_id None owner_id
* 0 ****
* owner
* owned>0
* owned=0 any=0 0
* owned=0 any>0 owner_id /
* owned=null
*/
async function tripletCensus(env: Bindings, tenant: string): Promise<{ owned: number | null; any: number | null }> {
const owned = await tripletCount(env, tenant);
if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次
return { owned, any: await tripletCount(env, '') };
}
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
try {
@@ -343,8 +388,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
// ② plugin fallbackMira/leo21c 相容)
const base = graphBase(c.env);
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
const headers = graphHeaders(c.env);
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
if (!res.ok) {
@@ -383,14 +427,23 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
return c.json({ error: '無知識圖譜檢視權限' }, 403);
}
const tenant = portalTenant(c.env);
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
const [res, census] = await Promise.all([
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}&limit=500`),
tripletCensus(c.env, tenant),
]);
const tripletsTotal = census.owned;
if (!res.ok) {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
const body = (await res.json().catch(() => null)) as
| { records?: { values?: Record<string, unknown> }[] }
| null;
const records = body && Array.isArray(body.records) ? body.records : [];
// #100:形狀不對 ≠ 沒有資料。原本 `: []` 會把「讀不出來」變成一張空圖,
// 前端照著印「0 個實體・0 條關聯」——那是畫面在說謊。讀不出來就誠實 502。
if (!body || !Array.isArray(body.records)) {
return c.json({ error: '三元組讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
}
const records = body.records;
const EDGE_CAP = 500;
const seen = new Set<string>();
const edges: { subject: string; predicate: string; object: string }[] = [];
@@ -413,7 +466,28 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
degree.set(o, (degree.get(o) ?? 0) + 1);
}
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
// #100:一張空圖有三種成因,前端必須分得出來(判準留在 server,不留給前端猜)——
// confirmed_empty :本租戶真的一條都沒有,全庫也沒有 → 才准印「0 個實體・0 條關聯」
// scope_mismatch :全庫有、本租戶查不到 → owner_id/範圍對不上,不是空庫(t161 前科)
// unreadable :連條數都讀不到 → 只能說讀不到
let emptyReason: 'confirmed_empty' | 'scope_mismatch' | 'unreadable' | null = null;
if (nodes.length === 0) {
if (census.owned === null) emptyReason = 'unreadable';
else if (census.owned > 0) emptyReason = 'scope_mismatch'; // 有條數卻抽不出邊
else if (census.any === null) emptyReason = 'unreadable';
else emptyReason = census.any > 0 ? 'scope_mismatch' : 'confirmed_empty';
}
return c.json({
nodes,
edges,
node_count: nodes.length,
edge_count: edges.length,
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
truncated: truncated || records.length >= 500,
triplets_total: tripletsTotal,
empty_confirmed: nodes.length > 0 || emptyReason === 'confirmed_empty',
empty_reason: emptyReason,
});
}),
);
+199
View File
@@ -0,0 +1,199 @@
/**
* /storage KV KBDB
*
* KV 退Leo/Arcrun#16 + #17
* 西****
* ****
* GET /storage/audit
* POST /storage/migrate-to-kbdb
*
*
* 1. **** KV
* KV 退
* 2. ****KBDB entry idasset-keys.ts
* missing
* 3. ****
* mindset §7 after before
*
* flag AI ** cron**
*KV list 1000/ list
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { unwrapKv } from '../lib/durable-store';
import { classifyAssetKey, type AssetEntryType } from '../lib/asset-keys';
export const storageRouter = new Hono<{ Bindings: Bindings }>();
/** 本卷管的四類資產,以及它們住在哪顆 KV。 */
const ASSET_SOURCES: Array<{ binding: 'WEBHOOKS' | 'RECIPES' }> = [
{ binding: 'WEBHOOKS' },
{ binding: 'RECIPES' },
];
interface ScannedKey {
key: string;
binding: 'WEBHOOKS' | 'RECIPES';
entry_type: AssetEntryType;
entry_id: string;
}
/** 掃一顆 KV 的全部 key(跟著 cursor 走完,不只第一頁),挑出屬於資產的。 */
async function scanAssetKeys(kv: KVNamespace, binding: 'WEBHOOKS' | 'RECIPES'): Promise<ScannedKey[]> {
const found: ScannedKey[] = [];
let cursor: string | undefined;
do {
const page = await kv.list(cursor ? { cursor } : {});
for (const k of page.keys) {
const ref = classifyAssetKey(k.name);
if (ref) found.push({ key: k.name, binding, entry_type: ref.entry_type, entry_id: ref.entry_id });
}
cursor = page.list_complete ? undefined : page.cursor;
} while (cursor);
return found;
}
function kbdb(env: Bindings): { base: string; headers: Record<string, string> } {
const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
return { base, headers };
}
/** KBDB 端某型別現有的 entry id 集合(用來算「哪幾筆還沒搬過去」)。 */
async function kbdbExistingIds(env: Bindings, entryType: AssetEntryType): Promise<Set<string>> {
const { base, headers } = kbdb(env);
const res = await fetch(`${base}/entries?entry_type=${encodeURIComponent(entryType)}&limit=1000`, { headers });
if (!res.ok) throw new Error(`KBDB 讀取失敗(${entryType}):HTTP ${res.status}`);
const json = (await res.json().catch(() => null)) as { entries?: Array<{ id: string }> } | null;
return new Set((json?.entries ?? []).map((e) => e.id));
}
const ASSET_TYPES: AssetEntryType[] = ['workflow_def', 'api_recipe', 'auth_recipe', 'prompt_recipe'];
interface Tally {
kv: Record<string, number>;
kbdb: Record<string, number>;
missing_in_kbdb: string[];
}
/** 兩邊各數一次,並列出「KV 有、KBDB 沒有」的那幾筆。 */
async function tally(env: Bindings): Promise<Tally> {
const scanned: ScannedKey[] = [];
for (const src of ASSET_SOURCES) {
const binding = env[src.binding];
if (!binding) continue;
scanned.push(...(await scanAssetKeys(unwrapKv(binding), src.binding)));
}
const kvCounts: Record<string, number> = {};
for (const t of ASSET_TYPES) kvCounts[t] = 0;
for (const s of scanned) kvCounts[s.entry_type] += 1;
const kbdbCounts: Record<string, number> = {};
const existing = new Map<AssetEntryType, Set<string>>();
for (const t of ASSET_TYPES) {
const ids = await kbdbExistingIds(env, t);
existing.set(t, ids);
kbdbCounts[t] = ids.size;
}
const missing = scanned
.filter((s) => !existing.get(s.entry_type)!.has(s.entry_id))
.map((s) => s.key);
return { kv: kvCounts, kbdb: kbdbCounts, missing_in_kbdb: missing };
}
// GET /storage/audit — 唯讀盤點。搬之前先看、搬之後再看,兩次數字自己會說話。
storageRouter.get('/storage/audit', async (c) => {
try {
const t = await tally(c.env);
return c.json({
success: true,
kv_asset_counts: t.kv,
kbdb_asset_counts: t.kbdb,
missing_in_kbdb: t.missing_in_kbdb,
missing_count: t.missing_in_kbdb.length,
verdict:
t.missing_in_kbdb.length === 0
? 'KV 裡的資產在 KBDB 都有一份——換掉 KV 不會弄丟東西。'
: `還有 ${t.missing_in_kbdb.length} 筆只存在於 KV。跑 POST /storage/migrate-to-kbdb 把它們搬過去。`,
});
} catch (e) {
// 誠實:盤點本身失敗就說失敗,不回一個看起來很乾淨的 0(那會被讀成「沒東西要搬」)。
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
}
});
// POST /storage/migrate-to-kbdb — 把 KV 裡的資產補進 KBDB。只增不刪、冪等、可重跑。
// body(都可省略):{ dry_run?: boolean }
storageRouter.post('/storage/migrate-to-kbdb', async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { dry_run?: boolean };
const dryRun = body.dry_run === true;
let before: Tally;
try {
before = await tally(c.env);
} catch (e) {
return c.json({ success: false, error: `搬遷前盤點失敗,未動任何資料:${e instanceof Error ? e.message : String(e)}` }, 502);
}
if (dryRun) {
return c.json({
success: true,
dry_run: true,
before: { kv: before.kv, kbdb: before.kbdb },
would_migrate: before.missing_in_kbdb,
would_migrate_count: before.missing_in_kbdb.length,
});
}
const migrated: string[] = [];
const errors: Array<{ key: string; error: string }> = [];
for (const src of ASSET_SOURCES) {
const wrapped = c.env[src.binding];
if (!wrapped) continue;
const raw = unwrapKv(wrapped);
for (const s of await scanAssetKeys(raw, src.binding)) {
try {
// 從**真** KV 讀原值,再用包裝過的 binding 寫回去——寫入路徑就是平常那條
//(先 KBDB 後快取),所以搬遷用的是跟日常寫入完全同一段程式碼,不另開一條會漂移的路。
const value = await raw.get(s.key, 'text');
if (value === null) continue; // 掃到當下剛好被刪:不是錯,跳過
await wrapped.put(s.key, value);
migrated.push(s.key);
} catch (e) {
errors.push({ key: s.key, error: e instanceof Error ? e.message : String(e) });
}
}
}
let after: Tally | null = null;
let afterError: string | null = null;
try {
after = await tally(c.env);
} catch (e) {
afterError = e instanceof Error ? e.message : String(e);
}
const clean = errors.length === 0 && after !== null && after.missing_in_kbdb.length === 0;
return c.json(
{
success: clean,
before: { kv: before.kv, kbdb: before.kbdb, missing_in_kbdb: before.missing_in_kbdb.length },
migrated,
migrated_count: migrated.length,
errors,
after: after
? { kv: after.kv, kbdb: after.kbdb, missing_in_kbdb: after.missing_in_kbdb }
: { error: afterError },
verdict: clean
? '搬完了:KV 裡的每一筆資產在 KBDB 都有對應的一列(missing 歸零)。KV 原始資料原封不動保留。'
: '**沒有搬乾淨**——看 errors 與 after.missing_in_kbdb。修掉原因後可以直接重跑(冪等,不會產生重複)。',
},
clean ? 200 : 500,
);
});
+91
View File
@@ -0,0 +1,91 @@
/**
* asset-keys KV key 使
*
* KV 退Leo/Arcrun#16 + #17 KV / KBDB /
* 西****
*
*
* - 2026-08-12
* - KBDB
*/
import { describe, it, expect } from 'vitest';
import { classifyAssetKey, classifyListPrefix, assetKvKey } from '../src/lib/asset-keys';
import { CRON_INDEX_KEY } from '../src/lib/cron-index';
describe('classifyAssetKey — 資產', () => {
it('具名工作流 {api_key}:wf:{name} → workflow_def,租戶與名字都拆得出來', () => {
const ref = classifyAssetKey('leo:wf:rag_chat');
expect(ref).not.toBeNull();
expect(ref!.entry_type).toBe('workflow_def');
expect(ref!.owner_id).toBe('leo');
expect(ref!.page_name).toBe('rag_chat');
expect(ref!.kv_key).toBe('leo:wf:rag_chat');
});
it('api recipeuuid key 與 migration 前的 canonical key 都算資產)', () => {
expect(classifyAssetKey('recipe:8f3b-uuid')!.entry_type).toBe('api_recipe');
expect(classifyAssetKey('recipe:telegram_send')!.entry_type).toBe('api_recipe');
});
it('auth recipe / prompt recipe', () => {
expect(classifyAssetKey('auth_recipe:notion')!.entry_type).toBe('auth_recipe');
expect(classifyAssetKey('auth_recipe:notion')!.page_name).toBe('notion');
expect(classifyAssetKey('prompt_recipe:wiki_synthesis')!.entry_type).toBe('prompt_recipe');
});
it('同一個 key 永遠對到同一個 entry_id(冪等的根據——重跑遷移不會長出重複列)', () => {
expect(classifyAssetKey('leo:wf:a')!.entry_id).toBe(classifyAssetKey('leo:wf:a')!.entry_id);
expect(classifyAssetKey('leo:wf:a')!.entry_id).not.toBe(classifyAssetKey('evan:wf:a')!.entry_id);
});
});
describe('classifyAssetKey — 衍生資料不可誤收', () => {
it('recipe 反查索引 idx:* 全部不是資產(算得回來,見 rehydrateRecipeIndices', () => {
expect(classifyAssetKey('idx:rec_f7e2a1b3')).toBeNull();
expect(classifyAssetKey('idx:canonical:telegram_send')).toBeNull();
expect(classifyAssetKey('idx:installed:telegram_send')).toBeNull();
});
it('cron 索引不是資產', () => {
expect(classifyAssetKey(CRON_INDEX_KEY)).toBeNull();
expect(classifyAssetKey('cron-idx:leo:daily')).toBeNull();
});
it('匿名 webhook token / 其他暫存 key 不是資產(本卷不碰,非漏收)', () => {
expect(classifyAssetKey('a1b2c3d4e5f6')).toBeNull();
expect(classifyAssetKey('daemon-active:leo')).toBeNull();
});
it('殘缺的 key 不當資產(寧可退回原本的 KV 行為,也不要建出半截的列)', () => {
expect(classifyAssetKey('recipe:')).toBeNull();
expect(classifyAssetKey('auth_recipe:')).toBeNull();
expect(classifyAssetKey(':wf:orphan')).toBeNull();
expect(classifyAssetKey('leo:wf:')).toBeNull();
});
});
describe('assetKvKey — 從 KBDB 反推回 KV key(回填快取與 list 都靠它)', () => {
it('四型都能原路折返', () => {
for (const key of ['leo:wf:rag_chat', 'recipe:telegram_send', 'auth_recipe:notion', 'prompt_recipe:x']) {
const ref = classifyAssetKey(key)!;
expect(assetKvKey(ref.entry_type, ref.owner_id, ref.page_name)).toBe(key);
}
});
});
describe('classifyListPrefix — 列舉走哪一邊', () => {
it('租戶工作流列舉 → 走 KBDB(帶 owner', () => {
expect(classifyListPrefix('leo:wf:')).toEqual({ entry_type: 'workflow_def', owner_id: 'leo' });
});
it('recipe / auth_recipe 列舉 → 走 KBDB', () => {
expect(classifyListPrefix('recipe:')).toEqual({ entry_type: 'api_recipe' });
expect(classifyListPrefix('auth_recipe:')).toEqual({ entry_type: 'auth_recipe' });
});
it('索引與無 prefix 的全域列舉 → 維持原本的 KV 行為', () => {
expect(classifyListPrefix('idx:')).toBeNull();
expect(classifyListPrefix('cron-idx:')).toBeNull();
expect(classifyListPrefix(undefined)).toBeNull();
});
});
@@ -0,0 +1,147 @@
/**
* Arcrun#100 0 0
*
* leo 0 0 AI
* 1854
*
*
* kbdb-graph-plugin `/triplets` Bearer cypher ** token**
* console-dashboard stats 401
* KBDB `/records/triplet-stats` SQL COUNT owner
* **** plugin `/triplets/stats` `total`KBDB 100/500
* 1854 100 401 0100
* null / 502 / empty_confirmed=false**退 0**
*
* KBDBgraph-plugin fetchMock hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test、
* KBDB_GRAPH_URL=https://graph.test)+disableNetConnect——絕不外連。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { graphHeaders, graphBase } from '../src/routes/kbdb-proxy';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
const GRAPH = 'https://graph.test';
const TENANT = 'leo'; // wrangler.test.toml CONSOLE_TENANT
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
/** KBDB `/records/triplet-stats` — 真 COUNT 的形狀:{ success, stats: [{library, triplet_count}] } */
function mockTripletStats(rows: { library: string; triplet_count: number }[] | null, status = 200) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(status, rows === null ? { success: false, error: 'boom' } : { success: true, stats: rows });
}
// ═══════════════ 1. graphHeaders:打 plugin 的 header 只有一份 ═══════════════
describe('graphHeaders#100 漂移的根:三處手拼 → 一支函式)', () => {
it('有 KBDB_INTERNAL_TOKEN → 帶 Bearerplugin 的 /triplets /graph /search /entities 全靠它)', () => {
expect(graphHeaders({ KBDB_INTERNAL_TOKEN: 'tok-abc' } as unknown as Bindings)).toEqual({
Authorization: 'Bearer tok-abc',
});
});
it('沒設 token → 空 headersplugin 未設 secret 時本來就開放,不硬塞空 Bearer)', () => {
expect(graphHeaders({} as unknown as Bindings)).toEqual({});
});
it('graphBase 仍照舊(KBDB_GRAPH_URL 優先、去尾斜線)', () => {
expect(graphBase({ KBDB_GRAPH_URL: 'https://graph.test/' } as unknown as Bindings)).toBe('https://graph.test');
});
});
// ═══════════════ 2. /console/kb-scale-data:數字對得上庫裡真正的數量 ═══════════════
describe('GET /console/kb-scale-data — 三元組數=KBDB 真 COUNT', () => {
it('庫裡 1854 條(跨三個庫)→ triplets_total 回 1854,不是 plugin 的分頁長度 100', async () => {
mockTripletStats([
{ library: 'general', triplet_count: 1200 },
{ library: 'finance', triplet_count: 600 },
{ library: 'ops', triplet_count: 54 },
]);
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
expect(res.status).toBe(200);
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBe(1854);
});
it('反向:triplet-stats 讀不到(500)→ triplets_total = null**不是 0**', async () => {
mockTripletStats(null, 500);
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
expect(res.status).toBe(200);
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBeNull();
expect(d.triplets_total).not.toBe(0); // 這一行就是 #100 的整個重點
});
it('反向:回應形狀不對(stats 不是陣列)→ null,不半信半疑當 0', async () => {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: 'oops' });
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBeNull();
});
it('真的是 0(庫存在但沒有任何三元組)→ 誠實回 0(0 只在這種時候出現)', async () => {
mockTripletStats([]);
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBe(0);
});
it('kb-scale-data 不再打 graph-plugin(沒有 plugin interceptor 也能拿到數字)', async () => {
mockTripletStats([{ library: 'general', triplet_count: 7 }]);
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
const d = (await res.json()) as { triplets_total: number | null };
expect(d.triplets_total).toBe(7); // 打 GRAPH 的話 disableNetConnect 會讓它變 null
});
});
// ═══════════════ 3. /console/dashboard-data:燈號問 plugin、數字問 KBDB ═══════════════
describe('GET /console/dashboard-data — 圖服務健康 vs 三元組數量是兩件事', () => {
it('打 plugin /triplets/stats **有帶 Bearer** → graph.ok=true;數量仍取 KBDB 真 COUNT', async () => {
// headers matcher:漏帶 Authorization 就配不到這個 interceptor → 請求失敗 → graph.ok=false
fetchMock
.get(GRAPH)
.intercept({
path: (p: string) => p.startsWith('/triplets/stats'),
method: 'GET',
headers: { authorization: `Bearer ${env.KBDB_INTERNAL_TOKEN}` },
})
.reply(200, { total: 100 }); // plugin 的分頁長度,故意與真值不同
mockTripletStats([{ library: 'general', triplet_count: 1854 }]);
const res = await SELF.fetch('http://localhost/console/dashboard-data');
expect(res.status).toBe(200);
const d = (await res.json()) as {
system: { graph: { ok: boolean; triplets: number | null } };
kb: { triplets_total: number | null };
};
expect(d.system.graph.ok).toBe(true); // 帶了 token 才會是 true#100 迴歸閘)
expect(d.system.graph.triplets).toBe(1854); // 不是 plugin 的 100
expect(d.kb.triplets_total).toBe(1854);
});
it('反向:plugin 打不通 → graph.ok=false,但三元組數照樣是真的(不被服務狀態吞掉)', async () => {
mockTripletStats([{ library: 'general', triplet_count: 1854 }]);
const res = await SELF.fetch('http://localhost/console/dashboard-data');
const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } };
expect(d.system.graph.ok).toBe(false);
expect(d.system.graph.triplets).toBe(1854);
});
it('反向:兩邊都讀不到 → ok=false + triplets=null(不是 0', async () => {
const res = await SELF.fetch('http://localhost/console/dashboard-data');
const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } };
expect(d.system.graph.ok).toBe(false);
expect(d.system.graph.triplets).toBeNull();
});
});
+88
View File
@@ -942,3 +942,91 @@ describe('GET /portal/daemon/diagnosticst213 daemon 版)', () => {
expect(JSON.stringify(body.notes)).not.toContain('截圖');
});
});
// ═══ Arcrun#100: 總圖的「0」只准在真的是 0 的時候出現 ═══
describe('GET /portal/data/graph/overview#100 空圖三態)', () => {
/** KBDB `/records/triplet-stats`:帶 owner 與不帶 owner 是兩條不同路徑,分別攔。 */
function mockCount(scoped: number | null, global?: number | null) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith(`/records/triplet-stats?owner_id=${TENANT}`), method: 'GET' })
.reply(scoped === null ? 500 : 200, scoped === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: scoped }] });
if (global !== undefined) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p === '/records/triplet-stats?owner_id=', method: 'GET' })
.reply(global === null ? 500 : 200, global === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: global }] });
}
}
function mockTriplets(body: object, status = 200) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(status, body);
}
async function overview(token: string) {
await seedSession(token, `rec_${token}`);
mockGetRecord(`rec_${token}`, userValues({ libraries: '["*"]', role: 'admin' }));
return get('/portal/data/graph/overview', { Authorization: `Bearer ${token}` });
}
it('有資料 → 照常回圖,並附上全庫真實條數', async () => {
mockTriplets({ success: true, records: [{ values: { subject: 'A', predicate: '連到', object: 'B' } }] });
mockCount(1854);
const res = await overview('tok-ov1');
expect(res.status).toBe(200);
const d = (await res.json()) as { node_count: number; triplets_total: number; empty_confirmed: boolean };
expect(d.node_count).toBe(2);
expect(d.triplets_total).toBe(1854);
expect(d.empty_confirmed).toBe(true);
});
it('真的空(本租戶 0、全庫也 0)→ empty_confirmed=true,畫面才准印 0', async () => {
mockTriplets({ success: true, records: [] });
mockCount(0, 0);
const res = await overview('tok-ov2');
const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
expect(d.node_count).toBe(0);
expect(d.empty_confirmed).toBe(true);
expect(d.empty_reason).toBe('confirmed_empty');
});
it('🔴 反向:本租戶查到 0、全庫卻有 1854(t161 owner_id 對不上)→ 不准說空,回 scope_mismatch', async () => {
mockTriplets({ success: true, records: [] });
mockCount(0, 1854);
const res = await overview('tok-ov3');
const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string };
expect(d.empty_confirmed).toBe(false);
expect(d.empty_reason).toBe('scope_mismatch');
});
it('🔴 反向:條數讀不到 → unreadable(不是 confirmed_empty,畫面顯示「讀不到」)', async () => {
mockTriplets({ success: true, records: [] });
mockCount(null);
const res = await overview('tok-ov4');
const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string; triplets_total: number | null };
expect(d.empty_confirmed).toBe(false);
expect(d.empty_reason).toBe('unreadable');
expect(d.triplets_total).toBeNull();
});
it('🔴 反向:有條數卻一條邊都抽不出來 → scope_mismatch,不是空庫', async () => {
mockTriplets({ success: true, records: [{ values: { subject: '', object: '' } }] });
mockCount(1854);
const res = await overview('tok-ov5');
const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
expect(d.node_count).toBe(0);
expect(d.empty_reason).toBe('scope_mismatch');
expect(d.empty_confirmed).toBe(false);
});
it('🔴 反向:KBDB 回應形狀不對(沒有 records 陣列)→ 502,不再回一張空圖', async () => {
mockTriplets({ success: true, items: [] }); // 欄位名不對=讀不出來
mockCount(1854);
const res = await overview('tok-ov6');
expect(res.status).toBe(502);
const d = (await res.json()) as { error: string };
expect(d.error).toContain('三元組讀取失敗');
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* waitArcrun#101
*
* leo 2026-08-12 youlin stage input >> wait
* ms=3000 38.9s 503(1102) / ms=20000 34.0s / ms=30000 34.9s / 3000 34.8s
* ms N N CPUms=3000 3
*
*
* wait TinyGo WASMtime.Sleep WASI poll_oneoffcomponent worker
* WASI shim poll_oneoff ENOSYS TinyGo 退 clock_time_get
* Workers I/O
*
*
* A. workerd
* B. wait timer
* C. workflow wait
* D. wait step 1 arcrun-wait worker fetch
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { env } from 'cloudflare:test';
import { BUILTIN_COMPONENTS, WAIT_MAX_MS } from '../src/lib/constants';
import { createComponentLoader } from '../src/lib/component-loader';
import type { Bindings, ComponentRunner } from '../src/types';
const wait = BUILTIN_COMPONENTS.get('wait') as ComponentRunner;
afterEach(() => {
vi.unstubAllGlobals();
});
// ── A. 反向驗證:舊路徑為什麼不可能便宜地等 ──────────────────────────────────
//
// 直接跑那顆 component.wasm 沒辦法寫成安全的測試 —— 它會把 isolate 卡到 CPU 上限,
// 測試無從中止(那正是 bug 本身)。所以這裡驗的是「**沙箱裡根本沒有睡覺這個手段**」。
//
// 🔴 這裡本來有一條斷言「Workers 的時鐘在同步執行期間凍結,所以自旋迴圈的結束條件
// 永遠不成立」。**實跑打臉了**:在 vitest-pool-workers 的 workerd 裡,2553 圈之後
// Date.now() 就前進了。⇒ 那條斷言被刪掉,不是改鬆——它從一開始就不是證據。
//
// 保留下來的是**查證得動的那一半**WASI shim 把 poll_oneoff 實作成 ENOSYS(76)
// TinyGo 的 time.Sleep 只有這一條路可走 ⇒ 拿不到「睡到某個時刻」的手段,
// 只能退化成自旋。至於「自旋為什麼會拖到 35 秒才死」的完整機制**仍是推測**,
// 證據是 leo 在 youlin stage 的四次實測(見檔頭),不是本檔任何一條斷言。
//
// ⇒ 而修法不依賴那個推測:純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)
// 本來就沒有「不花 CPU 地等」這種東西,會等的只有宿主。無論卡死的細節是什麼,
// 等待都該搬回引擎。
// 「poll_oneoff 是 ENOSYS」這件事查原始碼即可(`wasi-shim.ts:319` 的
// `poll_oneoff: () => WASI_ENOSYS`,以及 13 個 `.component-builds/*/src/index.ts`
// 的 `poll_oneoff: () => 76`)。**沒有為它硬寫一條測試**——寫得出來的只會是
// 「把字串抓出來比對」,那驗的是抓字串,不是行為。事實放註解,斷言留給真的驗行為的 B/C/D。
describe('A. 反向驗證:WASI 沙箱裡沒有「睡覺」這個手段', () => {
it('對照組:await 一個 timer 之後時鐘才會前進(=為什麼修法必須在引擎側 await)', async () => {
const t0 = Date.now();
await new Promise<void>((r) => setTimeout(r, 20));
expect(Date.now()).toBeGreaterThan(t0);
});
});
// ── B. 修法本體:等待是 timer,不是佔用執行緒 ────────────────────────────────
describe('B. 引擎側的 wait 真的讓出執行緒(等 30 秒與等 3 秒同價)', () => {
it('5 個 300ms 的 wait 併發跑完 ≈ 300ms 而非 1500ms(會 blocking 的實作做不到這件事)', async () => {
const started = Date.now();
const results = await Promise.all(
Array.from({ length: 5 }, () => wait({ ms: 300 })),
);
const elapsed = Date.now() - started;
for (const r of results) {
expect(r).toEqual({ success: true, data: { waited_ms: 300 } });
}
// 序列化(blocking)會是 ~1500ms;讓出執行緒則 5 個計時器同時走完 ≈ 300ms。
// 抓 900ms 當門檻:離 300 夠鬆、離 1500 夠遠。
expect(elapsed).toBeLessThan(900);
expect(elapsed).toBeGreaterThanOrEqual(300);
});
it('等待期間 event loop 沒被佔住:同時排的 timer 照樣先到', async () => {
const order: string[] = [];
const waited = Promise.resolve(wait({ ms: 400 })).then(() => { order.push('wait-400'); });
const ticked = new Promise<void>((r) => setTimeout(r, 50)).then(() => { order.push('tick-50'); });
await Promise.all([waited, ticked]);
expect(order).toEqual(['tick-50', 'wait-400']);
});
});
// ── C. 契約沒變:既有 wait 節點定義不用改 ────────────────────────────────────
//
// 逐條對 registry/components/wait/component.contract.yaml 的 gherkin_tests。
describe('C. I/O 契約與 WASM 版一致(既有 workflow 不必改定義)', () => {
it('contract gherkin:等待 100ms → waited_ms:100', async () => {
expect(await wait({ ms: 100 })).toEqual({ success: true, data: { waited_ms: 100 } });
});
it('contract gherkinms 為 0 時失敗(不是靜靜跳過)', async () => {
expect(await wait({ ms: 0 })).toEqual({ success: false, error: 'ms 必須大於 0' });
});
it('ms 缺漏 / 負數 / 非數字,一律誠實回 success:false,不假裝等過', async () => {
for (const bad of [undefined, null, -1, 'abc', {}, []]) {
expect(await wait({ ms: bad })).toEqual({ success: false, error: 'ms 必須大於 0' });
}
});
it('contract gherkinms=99999 截斷為上限 30000(不是報錯、也不是真的等 99 秒)', async () => {
// 不真的等 30 秒:換掉 setTimeout,攔下引擎「要求等多久」再立刻放行。
const asked: number[] = [];
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
asked.push(Number(delay));
fn();
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as unknown as typeof setTimeout);
expect(await wait({ ms: 99999 })).toEqual({ success: true, data: { waited_ms: WAIT_MAX_MS } });
expect(asked).toEqual([WAIT_MAX_MS]);
expect(WAIT_MAX_MS).toBe(30000); // 紅線:上限不准為了閃避資源限制被調小
});
it('ms=30000 一路走到底也只是「排一個 30 秒的 timer」,沒有任何同步佔用', async () => {
const asked: number[] = [];
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
asked.push(Number(delay));
fn();
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as unknown as typeof setTimeout);
expect(await wait({ ms: 30000 })).toEqual({ success: true, data: { waited_ms: 30000 } });
expect(asked).toEqual([30000]);
});
it('context 照契約透傳,並補上 waited_ms', async () => {
const r = await wait({ ms: 5, context: { order_id: 'A-1', payload: { n: 2 } } });
expect(r).toEqual({
success: true,
data: { order_id: 'A-1', payload: { n: 2 }, waited_ms: 5 },
});
});
it('node.data 經 interpolateData 後 ms 會是字串 —— 收得下(WASM 版在這裡直接 unmarshal 失敗)', async () => {
expect(await wait({ ms: '250' })).toEqual({ success: true, data: { waited_ms: 250 } });
});
});
// ── D. 路由:不再打 arcrun-wait worker ───────────────────────────────────────
describe('D. component-loader 把 wait 解到內建 runnerstep 1),不發任何 fetch', () => {
it('loader("wait") 跑起來不會對外送出任何請求', async () => {
const fakeEnv = { ...env, WORKER_SUBDOMAIN: 'test-sub' } as unknown as Bindings;
const fetchSpy = vi.fn(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
const runner = await createComponentLoader(fakeEnv)('wait');
const r = await runner({ ms: 10 });
expect(r).toEqual({ success: true, data: { waited_ms: 10 } });
// 修法前這裡會打 arcrun-wait.test-sub.workers.devSVC_WAIT 未綁時的 fallback),
// 那顆 worker 就是會燒到 1102 的那顆。
expect(fetchSpy).not.toHaveBeenCalled();
});
it('wait 仍在「執行期真的解析得動」的清單裡(/cypher/search 查得到)', async () => {
const { RUNTIME_NATIVE_COMPONENT_IDS } = await import('../src/lib/component-loader');
expect(RUNTIME_NATIVE_COMPONENT_IDS.has('wait')).toBe(true);
});
});
+4
View File
@@ -49,6 +49,10 @@ KBDB_BASE_URL = "https://kbdb.test"
CONSOLE_TENANT = "leo"
# portal-auth P3graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
KBDB_GRAPH_URL = "https://graph.test"
# Arcrun#100kbdb-graph-plugin 對 /triplets /graph /search /entities 掛 Bearer 閘。測試環境要有
# 這把(明顯的假字串、非真實金鑰)才驗得出「cypher 打 plugin 有沒有帶 token」——原本兩支
# /triplets/stats 漏帶 → 永遠 401 → 前端「三元組 0」。真實部署仍走 wrangler secret put。
KBDB_INTERNAL_TOKEN = "test-fake-not-a-real-token" # credential-ok:測試假值,同上方 CF_SECRETS_API_TOKEN 慣例
# D61ADR D61 / Leo/arcrun-rag#55):認證儲存(lib/portal-auth-store.ts)走 CF Workers
# Scripts secrets 管理 APIhttps://api.cloudflare.com/...),authStoreWritable() 只看這兩項
# 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的
@@ -0,0 +1,45 @@
-- arcrun 資產型別 template seed — KV 退休(Leo/Arcrun#16 + #17
--
-- 為什麼有這一檔(leo 2026-08-12 原話):
-- 「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,數據,一個 entry」
-- 「如果零件和工作流的 recipe 不見了,是很可怕的事情」
-- 同一天(2026-08-12)真的發作過:一次例行更新讓使用者的九支工作流在畫面上全部消失
-- (根因見 cli/src/lib/resource-resolver.ts 檔頭 Arcrun#97——舊 deploy 會照名字新建一顆空 KV
-- 再綁上去)。#97 修的是「不要再把 worker 綁到空的資源上」;本卷修的是更根本的一句:
-- **使用者的資產本來就不該只存在於一個會被換掉的暫存層裡。**
--
-- KBDB 鐵律(leo 2026-06-14D38):三張表打天下,永遠不加新 table,新資料類型一律用 template。
-- 本檔**零 schema 異動**——只 INSERT OR IGNORE 四列 template 定義,手法與同目錄
-- 0003_library_map.sql / 0004_execution_log_template.sql 完全相同。
--
-- 儲存精神比照 0004execution_log)與 recipe-stattemplate 只負責「schema 文件化 +
-- GET /templates 可發現」,實際一筆資產是 entries 表的**一列**——
-- entry_type = 'workflow_def' | 'api_recipe' | 'auth_recipe' | 'prompt_recipe'
-- owner_id = 租戶(workflow 才有;recipe 是整台實例共用的庫,故為 NULL)
-- page_name = 該型別的自然鍵(workflow 名 / recipe uuid / service 名)
-- content = 給人看也給語意搜尋看的一句描述
-- metadata_json = 定義本體(graph / endpoint / inject … 原樣 JSON
-- ——不走 entry_values 全展開的多列 record:一支 workflow 的 graph 是一整包巢狀 JSON
-- 拆成 slot 多列既不會變得比較好查,反而讓「一筆資產=一列」這件事不再成立
-- recipe_stat 與 execution_log 早已示範「template 存在 + entries 直接存」這個模式合法)。
--
-- 讀寫一律走 HTTP API/entries、/entries/:id),呼叫端是 cypher-executor 的
-- src/lib/durable-store.ts。牆外沒有任何一行 SQL。
INSERT OR IGNORE INTO templates (id, name, description, slots_json, created_by)
VALUES
('tpl-workflow-def', 'workflow_def',
'工作流定義本體(KV 退休 #17)。一支工作流=entries 一列;graph/config/cron_expr 打包進 metadata_jsonWEBHOOKS KV 降為可丟棄的快取',
'["name","description","graph","config","cron_expr","created_at"]', 'system'),
('tpl-api-recipe', 'api_recipe',
'API recipe 定義本體(KV 退休 #16)。一份 recipeentries 一列;endpoint/headers/body/auth 等打包進 metadata_jsonRECIPES KV 降為快取。idx:* 反查索引屬衍生資料,不進 KBDB,由 durable-store 從本型別重建',
'["uuid","canonical_id","hash_id","author","endpoint","method","auth_service","installed"]', 'system'),
('tpl-auth-recipe', 'auth_recipe',
'Auth recipe 定義本體(KV 退休 #16)。一個服務一列;primitive/base_url/required_secrets/inject 打包進 metadata_json。只存「怎麼認證」,不存任何密文(憑證明文在 CF Workers Secrets,見 .claude/rules/01-tech-stack.md',
'["service","primitive","base_url","version","required_secrets","inject"]', 'system'),
('tpl-prompt-recipe', 'prompt_recipe',
'Prompt recipe 定義本體(KV 退休 #16)。一份 prompt recipeentries 一列,定義打包進 metadata_json',
'["name","definition"]', 'system');
+45
View File
@@ -134,6 +134,51 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
}
/**
* idKV 退 #16/#17
*
* base `createEntry` id
* GET POST PATCH
*
*
* base entry_type
* arcrun workflow_def / api_recipe / auth_recipe
* ** schema ** entries
*
* PATCH
*
* created_at updated_at
*/
export async function upsertEntry(db: D1Database, id: string, input: Omit<CreateEntryInput, 'id'>): Promise<Entry> {
const existing = await getEntry(db, id);
if (!existing) return createEntry(db, { ...input, id });
await db
.prepare(
`UPDATE entries
SET content = ?, entry_type = ?, owner_id = ?, parent_id = ?, page_name = ?,
refs_json = ?, tags_json = ?, task_status = ?, confidence = ?, metadata_json = ?,
updated_at = unixepoch()
WHERE id = ?`,
)
.bind(
input.content ?? null,
input.entry_type,
input.owner_id ?? null,
input.parent_id ?? null,
input.page_name ?? null,
input.refs_json ?? '[]',
input.tags_json ?? '[]',
input.task_status ?? null,
input.confidence ?? null,
input.metadata_json ?? null,
id,
)
.run();
const row = await getEntry(db, id);
if (!row) throw new Error('upsertEntry: update succeeded but row not found');
return row;
}
/**
* owner entries deprecatedt135 by-name
* 沿 deprecated metadata_json.status='deprecated'
+16
View File
@@ -9,6 +9,7 @@ import {
getEntry,
listEntries,
updateEntry,
upsertEntry,
deleteEntry,
searchEntries,
isDeprecatedEntry,
@@ -426,6 +427,21 @@ entryRoutes.get('/backfill-library/status', async (c) => {
return c.json({ success: true, ...status });
});
// PUT /entries/:id — 以呼叫端指定的 id 整列覆寫(不存在就新建)。KV 退休 #16/#17。
//
// 與 POST / 的差別:POST 的 id 是隨機的,同一份資產每存一次多一列;PUT 讓「同一份資產永遠
// 是同一列」,所以重跑遷移、重複部署同一支工作流都不會長出重複資料(冪等)。
// 與 PATCH /:id 的差別:PATCH 是部分更新(沒帶的欄位留著),PUT 是整列取代
// ——呼叫端手上是完整定義時要的是後者,否則「刪掉一個欄位」永遠做不到。
entryRoutes.put('/:id', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body || !body.entry_type) return c.json({ success: false, error: 'entry_type required' }, 400);
const entry = await upsertEntry(c.env.DB, c.req.param('id'), body);
// 與 POST / 同款:標了 embed:true 的才進 Vectorizefire-and-forget、失敗不致命。
if (embedEnabled(c.env)) c.executionCtx.waitUntil(embedOnWrite(c.env, entry).catch(() => {}));
return c.json({ success: true, entry });
});
// PATCH /entries/:id
entryRoutes.patch('/:id', async (c) => {
const body = await c.req.json().catch(() => ({}));
+19 -1
View File
@@ -1,5 +1,23 @@
// wait — 等待指定毫秒數後繼續(最多 30 秒)
// 注意:TinyGo/WASM 環境中 time.Sleep 可能不可用,改用 busy-wait 模擬
//
// ⚠️ 已由引擎接手,這份 WASM 在 Cloudflare Workers 上跑不動(Arcrun#1012026-08-12)。
// 現行實作在 cypher-executor/src/lib/constants.ts 的 BUILTIN_COMPONENTS['wait']
// component-loader step 1 先命中,這顆 wasm 不會再被工作流呼叫到。
//
// 為什麼跑不動(不是「比較慢」,是「永遠不會結束」):
// 下面的 time.Sleep 在 TinyGo 走 WASI poll_oneoff,而 component worker 的 WASI shim
// 把 poll_oneoff 實作成 ENOSYS ⇒ TinyGo 排程器退化成迴圈重讀 clock_time_get 自旋;
// Workers 的時鐘在無 I/O 的同步執行期間是凍結的 ⇒ 結束條件永遠不成立 ⇒ 一路燒到
// CPU 上限被砍(error 1102)。leo 實測 ms=3000/20000/30000 全在 ~35 秒後 503
// 死法與 ms 無關 —— 這正是「迴圈沒結束」而非「等待很貴」的證據。
//
// 原本的舊註解寫「改用 busy-wait 模擬」是錯的:這個檔從來沒有 busy-wait,
// 一直是 time.Sleep。那句話誤導了後來每一個讀這個檔的人。
//
// 本次刻意不改行為、只改註解:手邊沒有 TinyGo 工具鏈,改了 main.go 卻沒重編,
// 會讓 repo 內已 commit 的 .component-builds/wait/component.wasm 與原始碼漂移
// rule 05「WASM 來源」:那份 wasm 是 self-host 用戶的部署來源)。
// 要退役這顆零件(刪目錄/下架 wait.arcrun.dev)是另一個決定,需人拍板。
package main
import (
+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 只是快取,砍掉會自己長回來。"
@@ -8,6 +8,75 @@
## 待裁決
### P-KVKV 退休:工作流與 recipe 的家搬到 KBDBLeo/Arcrun#16 + #17)— 2026-08-12
**觸發**leo 2026-08-12 原話——
> 「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,數據,一個 entry」
> 「因為對 KV 的使用有禁令,但卻會把資產放在這裡,這不是違法嗎?」
> 「現在是我幫他寫工作流,未來是他的 AI 自己寫工作流,
> **如果零件和工作流的 recipe 不見了,是很可怕的事情**
同日實害:一次例行更新讓使用者的九支工作流在畫面上全部消失(Arcrun#97)。
#97 已修掉直接原因(`cli/src/lib/resource-resolver.ts`:不再照名字猜使用者的資源、
不再擅自新建一顆空的綁上去)。**本案修的是更下面那一句**:使用者的資產本來就不該
只存在於一個會被換掉的暫存層裡——#97 修的是「別再換錯」,這裡修的是「換了也不會怎樣」。
**為什麼要走規格層(D35**`.claude/rules/01-tech-stack.md`「資料儲存」那張表把
workflow 定義寫在 `WEBHOOKS` KV、recipe 寫在 `RECIPES` KV。改掉真相來源=改規格。
現行 active SDD 是 `workflow-discovery`,本案不在它的 tasks 內,故依 D35 第 3 條
寫 proposal 停下等 leo confirm。**#16 已被多份 SDD 引用為前提**
`arcrun/artifact-sharing/` design K2「KBDB 是唯一公庫後端」、requirements Out of Scope、
tasks 1.55.2 都寫明「遷移本體由 #16 負責」),所以這不是新方向,是那些卷等的那一塊落地。
**提議的規格(三句)**
1. **資產的真相來源=KBDB**D1 `entries` 一列一份資產)。KV 降級為可丟棄的快取。
2. **零 SQL、永不加表**D38):新增四個 `entry_type`
`workflow_def` / `api_recipe` / `auth_recipe` / `prompt_recipe`),
各在 `templates` 表 seed 一列定義(`kbdb/migrations/0005_arcrun_asset_templates.sql`
手法同 0003/0004)。定義本體打包進 `metadata_json`,比照 `execution_log``recipe_stat` 既有先例。
3. **衍生資料不進 KBDB**`idx:*`recipe 反查)、`cron-idx:_all` 算得回來,
留在 KV,讀不到就從 KBDB 重算(不讓 KBDB 長出垃圾列)。
**實作形狀(已寫在 `feat/kv-retire-recipes-16-17` 分支,未合併)**
- 換 binding 而不是改呼叫端:`src/index.ts` 入口把 `WEBHOOKS``RECIPES`
換成 KBDB 撐腰的包裝(`src/lib/durable-store.ts`),四十幾處呼叫端一行不動。
理由:逐處改寫一定會漏,**漏掉的那一處就是下一次「東西不見了」的入口**。
- 「哪些 key 是資產」集中成一張表(`src/lib/asset-keys.ts`),是唯一需要人看懂的東西。
- 讀=KV 先行、miss 回源 KBDB 並補快取;寫=先 KBDB 再 KV,KBDB 失敗就拋錯(禁假綠);
**列舉一律走 KBDB**——空 KV 列出來是「零筆」而不是「查不到」,那正是消失的形狀。
- 搬遷與盤點:`GET /storage/audit``POST /storage/migrate-to-kbdb`(只增不刪、冪等、逐筆回報)。
- KBDB 端只加一個通用原語:`PUT /entries/:id`(指定 id 的整列 upsert),零 schema 異動。
**影響分析**
- 現行 active SDD `workflow-discovery`**不受影響**。它的 `entry_type='workflow'`
搜尋 entry 照舊雙寫,本案刻意用另一個型別 `workflow_def` 存定義本體、且不標 `embed`
以免同一支工作流嵌兩份向量。search/backfill 兩支端點一行未動。
- `artifact-sharing`:本案就是它 K2 等的 #16。落地後可拆 tasks 1.5 的 KV 過渡轉接(5.2)。
- credential**不碰**。憑證走 CF Workers Secrets D1 目錄(rule 01),不在本案範圍。
- 匿名 webhook`webhooks.ts``put(token, record)`):**目前沒搬**,仍是 KV-only。
它也是使用者建出來的東西,但不在 #16/#17 的字面範圍內——在此列出,請 leo 裁要不要納入。
- 效能:資產讀取多一層快取判斷;快取命中時與現況相同,miss 時多一次 KBDB 往返。
`list` 一律回源,但會順手把整批補進快取,所以「列出來再逐筆讀」總共只多一次往返。
**尚未完成/誠實限制(決定要不要 confirm 前請先看這段)**
- **端到端證據沒跑**。實作環境(雲端工人沙箱)不放行執行測試與 HTTP
`vitest``node``curl` 皆被權限閘擋下),所以「砍掉 KV、資產還在」這一次
**我沒有真的做出來給你看**。已跑到的只有:5 份 migration 在本機 D1 全部套用成功、
兩顆 worker 都能以改動後的程式碼在本機開起來、`tsc` 錯誤數與改動前一致(7 個既有錯,未新增)。
- 那一次驗證已經寫成可執行的腳本 `scripts/verify-kv-retirement.sh`
(開空實例 → 放 9 支工作流+3 份 recipe → 數一次 → **砍掉整個 KV 層重建** → 再數一次
→ 真的觸發一支確認跑得動),**在能執行的機器上跑一次就是那個證據**。
- 因此本案的狀態是 **◐ 半通**:程式碼與遷移路徑齊備,證據缺一份。
**建議 confirm 的順序是「先跑那支腳本、綠了再合併」**,不要因為程式碼看起來完整就先併——
這件事的整個重點就是不要再有「看起來好好的,其實東西不見了」。
**⏸ 停在這裡等 leo 裁**
① 方向 confirm 嗎(資產真相來源改 KBDB、KV 降快取)?
② 匿名 webhook 要不要一起納入?
③ KV 舊資料要不要清(本案只增不刪,清是另一個決定)?
---
### P2|fan-out 並行執行(一個節點的多條出邊目前是循序跑)— 2026-08-03
**觸發**:leo 08-03 原話——「這是在測試中的計畫,**希望體驗很好**,我發現用 gemma4 的反應非常慢。」