Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cac874601f | |||
| b223a69884 | |||
| e05518a2b4 | |||
| 21293568d5 | |||
| 53b05c6d3d | |||
| f87d0e92f4 | |||
| ba152bc83a | |||
| 89b80ff90e | |||
| 10d150ac2b | |||
| a24f2912eb | |||
| 1791ffa497 | |||
| 296fb01247 | |||
| f1370e2275 | |||
| d58a6e152d | |||
| 793a94ecb5 | |||
| cbeddf7535 | |||
| d7c6bd0680 | |||
| a5e4caf5cb | |||
| 45a546a686 | |||
| e69d6bbc03 | |||
| b302c03ea8 | |||
| c497ec418e | |||
| 6985bf4850 | |||
| 8e10f1d83e | |||
| 3eb8b31f2b | |||
| 8cee9c9f76 | |||
| 7e3ca4c1a1 | |||
| af3edff856 | |||
| 525faaf5d0 | |||
| 37e13fc9bf | |||
| 674e1b4fa2 | |||
| 1d6dde4a01 |
@@ -142,6 +142,37 @@ SDD 屬於架構決策,必須人確認。CC 不可以自行在 `docs/3-specs/`
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 第六類:租戶字串來源(Arcrun#108/#105 同族)
|
||||
|
||||
### 6.1 靜態租戶字串不得用於資料面過濾
|
||||
**知識資料面的 `owner_id`(三元組/entries/records/藏書地圖/工作流 KV)必須與寫入端同源。**
|
||||
寫入端只有一個真相源=使用者 `~/.arcrun/config.yaml` 的 `api_key`(=實例 namespace,
|
||||
CLI push/小幫手上傳/MCP 都用它)。讀取端拿另一份手抄的環境變數預設值 → 全被過濾掉。
|
||||
|
||||
實害:`portalTenant(env) = env.CONSOLE_TENANT || "leo"` 讓 leo 的 **1854 條三元組被過濾成 0 個庫**
|
||||
(#108);前一天 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"` 是同一句話(#105)。
|
||||
|
||||
**規則**:
|
||||
1. `cypher-executor/src/lib/tenant.ts` 是租戶字串的**唯一產地**。
|
||||
`CONSOLE_TENANT` / `ARCRUN_NAMESPACE` 只能在該檔被讀取。
|
||||
2. 知識資料面用 `knowledgeOwner(env)`(回 `TenantId`),過濾一律經
|
||||
`ownerQuery()` / `ownerField()`——它們只吃 `TenantId`,`tsc` 就擋掉「隨手一個 string」。
|
||||
3. 帳號層用 `accountTenant(env)`(回 `string`,**刻意不是 TenantId**):帳號子 namespace
|
||||
`{tenant}::portal` 與 cypher 自己寫的設定用它,型別上不可能流進知識資料面。
|
||||
4. 身分解析路徑上**不准有字面預設值**。解析不到 → 丟 `TenantUnresolvedError`,
|
||||
誠實回「讀不到」(不是「你沒有」,#100 同一條)。
|
||||
|
||||
**機械強制**(規則存在但沒機制驗證=它會再犯第三次):
|
||||
- 出貨閘:`scripts/build-worker-artifacts.mjs` 編 tier2 成品前先掃,違規 → **編不出成品**。
|
||||
- 本機自查:`cd cypher-executor && npm run check:tenant`(`npm test` 也會先跑它)。
|
||||
- 規則本體:`cypher-executor/scripts/tenant-source-rules.mjs`(純函式);
|
||||
閘自己的測試:`cypher-executor/tests/tenant-gate.test.ts`(壞例子會擋+合法寫法零誤攔)。
|
||||
|
||||
> 尚未接上 PreToolUse hook(`.claude/hooks/` 為受保護檔案,需人類加入)。
|
||||
> 要加的話:檢查器已備妥 `--stdin <相對路徑>` 模式,可在寫入前擋。
|
||||
|
||||
## Hook Block 訊息格式
|
||||
|
||||
當 hook 擋住一個操作時,訊息格式統一為:
|
||||
|
||||
@@ -52,6 +52,14 @@ scripts/__pycache__/
|
||||
# D1 備份/匯出(wrangler d1 export 產物,含整庫全量資料=機敏,絕不 commit)
|
||||
*.sql
|
||||
backup-*.sql
|
||||
# 🔴 但 migration 不是備份,它是**要出貨的程式碼**(2026-08-12 實撞):
|
||||
# 上面那條 `*.sql` 的用意是擋 D1 匯出(整庫全量資料=機敏),卻連 migration 一起吃掉。
|
||||
# 後果:0001-0004 因為在該規則之前就 commit 所以還在,**0005/0006 從此沒進過版控**
|
||||
# ⇒ 更新指令從 Gitea 抓 main,那兩個檔根本不在那裡 ⇒ 每個用戶都會收到
|
||||
# 「✗ D1 migration: 部署物缺 kbdb/migrations/0005…」——**不是誰忘了推,是規則吃掉的**。
|
||||
# ⇒ 與 `.component-builds/**/component.wasm` 同慣例(見 rules/05-deploy-convention.md
|
||||
# 「WASM 來源」段),用否定規則放行。備份檔仍由 `backup-*.sql` 與目錄位置擋住。
|
||||
!kbdb/migrations/*.sql
|
||||
|
||||
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
|
||||
.github-public/
|
||||
|
||||
@@ -2645,7 +2645,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";
|
||||
@@ -2693,6 +2693,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) => {
|
||||
@@ -2702,6 +2703,54 @@ var init_constants3 = __esm({
|
||||
["comp_counter", (ctx) => {
|
||||
const c = ctx;
|
||||
return { ...c, count: (Number(c.count) || 0) + 1 };
|
||||
}],
|
||||
// ── wait:等待 N 毫秒後繼續(Arcrun#101,2026-08-12)────────────────────────
|
||||
//
|
||||
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
|
||||
//
|
||||
// 舊實作是 registry/components/wait/main.go(TinyGo → 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)+可選 context;ms > 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 } };
|
||||
}]
|
||||
]);
|
||||
}
|
||||
@@ -3022,7 +3071,7 @@ async function readBodyOnce(res) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
var WASM_HTTP_RUNNER_IDS, LOGIC_BINDING_MAP;
|
||||
var WASM_HTTP_RUNNER_IDS, LOGIC_BINDING_MAP, RUNTIME_NATIVE_COMPONENT_IDS;
|
||||
var init_component_loader = __esm({
|
||||
"cypher-executor/src/lib/component-loader.ts"() {
|
||||
"use strict";
|
||||
@@ -3055,7 +3104,12 @@ var init_component_loader = __esm({
|
||||
filter: "SVC_FILTER",
|
||||
merge: "SVC_MERGE",
|
||||
try_catch: "SVC_TRY_CATCH",
|
||||
wait: "SVC_WAIT",
|
||||
// wait 已於 Arcrun#101(2026-08-12)移進 BUILTIN_COMPONENTS(step 1)——
|
||||
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
|
||||
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
|
||||
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
|
||||
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.1:13 個既有 binding 保留不新增),
|
||||
// 拆綁定要重新部署、與本票無關。
|
||||
set: "SVC_SET",
|
||||
array_ops: "SVC_ARRAY_OPS",
|
||||
string_ops: "SVC_STRING_OPS",
|
||||
@@ -3065,6 +3119,12 @@ var init_component_loader = __esm({
|
||||
// ai_transform_compile / ai_transform_run 已刪除(2026-05-29):
|
||||
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
|
||||
};
|
||||
RUNTIME_NATIVE_COMPONENT_IDS = /* @__PURE__ */ new Set([
|
||||
"trigger_workflow",
|
||||
...BUILTIN_COMPONENTS.keys(),
|
||||
...Object.keys(LOGIC_BINDING_MAP),
|
||||
...WASM_HTTP_RUNNER_IDS
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3134,6 +3194,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"() {
|
||||
@@ -3201,6 +3266,20 @@ var init_kbdb_proxy = __esm({
|
||||
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param("recordId"))}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
});
|
||||
kbdbProxyRouter.patch("/kbdb/records/:recordId", async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || typeof body.values !== "object" || body.values === null) {
|
||||
return c.json({ error: "values \u5FC5\u586B\uFF08{slot\u540D: \u5167\u5BB9}\uFF09" }, 400);
|
||||
}
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param("recordId"))}`, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ values: body.values })
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
});
|
||||
kbdbProxyRouter.get("/kbdb/search", async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
@@ -3253,8 +3332,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" } });
|
||||
@@ -9257,9 +9335,11 @@ function authStoreStatus(env) {
|
||||
var healthRouter = new Hono2();
|
||||
healthRouter.get("/health", (c) => {
|
||||
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
|
||||
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
|
||||
return c.json({
|
||||
ok: true,
|
||||
...bundleVersion ? { bundle_version: bundleVersion } : {},
|
||||
...bundleCommit ? { bundle_commit: bundleCommit } : {},
|
||||
auth_store: authStoreStatus(c.env),
|
||||
// arcrun-rag#38/#69/#25(2026-08-11):安裝器判斷「要不要重推」只比 bundle_version——
|
||||
// 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容
|
||||
@@ -9485,6 +9565,16 @@ async function searchNodes(parsed, config, env, mode = "discover", target) {
|
||||
nodeResults[nodeName] = { status: "found", componentId, type: role };
|
||||
continue;
|
||||
}
|
||||
if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
|
||||
nodeResults[nodeName] = {
|
||||
status: "found",
|
||||
componentId,
|
||||
type: role,
|
||||
source: "builtin",
|
||||
branch_hint: branchHintFor(componentId)
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (catalog.status === "unreachable") {
|
||||
nodeResults[nodeName] = { status: "unknown", componentId, type: role };
|
||||
continue;
|
||||
@@ -12460,6 +12550,45 @@ init_kbdb_proxy();
|
||||
|
||||
// cypher-executor/src/routes/console-auth.ts
|
||||
init_dist();
|
||||
|
||||
// cypher-executor/src/lib/tenant.ts
|
||||
var TenantUnresolvedError = class extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "TenantUnresolvedError";
|
||||
}
|
||||
};
|
||||
function knowledgeOwner(env) {
|
||||
const injected = (env.ARCRUN_NAMESPACE ?? "").trim();
|
||||
if (injected) return injected;
|
||||
const legacy = (env.CONSOLE_TENANT ?? "").trim();
|
||||
if (legacy) return legacy;
|
||||
throw new TenantUnresolvedError(
|
||||
"\u9019\u500B\u90E8\u7F72\u6C92\u6709\u77E5\u8B58\u547D\u540D\u7A7A\u9593\uFF08ARCRUN_NAMESPACE / CONSOLE_TENANT \u90FD\u6C92\u8A2D\uFF09\u2014\u2014\u4E0D\u77E5\u9053\u8981\u53BB\u54EA\u4E00\u683C\u627E\u8CC7\u6599\u3002\u8ACB\u8DD1 `acr update` \u8B93\u5B83\u5F9E\u4F60\u7684 ~/.arcrun/config.yaml \u6CE8\u5165\u3002"
|
||||
);
|
||||
}
|
||||
function tenantFromApiKey(apiKey) {
|
||||
const key = (apiKey ?? "").trim();
|
||||
if (!key) throw new TenantUnresolvedError("\u7F3A\u5C11 X-Arcrun-API-Key\uFF0C\u7121\u6CD5\u6C7A\u5B9A\u67E5\u8A62\u7BC4\u570D");
|
||||
return key;
|
||||
}
|
||||
function accountTenant(env) {
|
||||
return env.CONSOLE_TENANT || "leo";
|
||||
}
|
||||
function ownerQuery(tenant2) {
|
||||
return `owner_id=${encodeURIComponent(tenant2)}`;
|
||||
}
|
||||
function ownerField(tenant2) {
|
||||
return tenant2;
|
||||
}
|
||||
function censusQueryAllTenants() {
|
||||
return "owner_id=";
|
||||
}
|
||||
function isOwnedBy(value, tenant2) {
|
||||
return typeof value === "string" && value === tenant2;
|
||||
}
|
||||
|
||||
// cypher-executor/src/routes/console-auth.ts
|
||||
var consoleAuthRouter = new Hono2();
|
||||
var CREDS_KEY = "console:credentials";
|
||||
var SESSION_PREFIX = "console_sess:";
|
||||
@@ -12486,7 +12615,7 @@ async function hashPassword(password, salt) {
|
||||
return h;
|
||||
}
|
||||
function tenantOf(c) {
|
||||
return c.env.CONSOLE_TENANT || "leo";
|
||||
return knowledgeOwner(c.env);
|
||||
}
|
||||
async function loadCredentials(env) {
|
||||
let fromStore = readAuthStore(env).console;
|
||||
@@ -12757,10 +12886,10 @@ var DEFAULT_SESSION_TTL = 604800;
|
||||
var USER_TEMPLATE = "portal_user";
|
||||
var LIBRARY_TEMPLATE = "portal_library";
|
||||
function portalTenant(env) {
|
||||
return env.CONSOLE_TENANT || "leo";
|
||||
return accountTenant(env);
|
||||
}
|
||||
function portalNamespace(env) {
|
||||
return `${portalTenant(env)}::portal`;
|
||||
return `${accountTenant(env)}::portal`;
|
||||
}
|
||||
function sessionTtl(env) {
|
||||
const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? "", 10);
|
||||
@@ -12789,6 +12918,9 @@ async function run(c, fn) {
|
||||
if (e instanceof AuthStoreWriteError) {
|
||||
return c.json({ error: `\u8A8D\u8B49\u5132\u5B58\u5BEB\u5165\u5931\u6557\uFF1A${e.message}`, code: "auth_store_not_writable" }, 502);
|
||||
}
|
||||
if (e instanceof TenantUnresolvedError) {
|
||||
return c.json({ error: e.message, code: "tenant_unresolved" }, 500);
|
||||
}
|
||||
if (e instanceof KbdbError) return c.json({ error: `KBDB \u4E0D\u53EF\u9054\u6216\u56DE\u932F\uFF1A${e.message}` }, 502);
|
||||
throw e;
|
||||
}
|
||||
@@ -13190,7 +13322,12 @@ portalRouter.post(
|
||||
session_token: token,
|
||||
display_name: rec.values.display_name ?? "",
|
||||
role: rec.values.role ?? "user",
|
||||
libraries: parseLibraries(rec.values.libraries)
|
||||
libraries: parseLibraries(rec.values.libraries),
|
||||
// session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據),
|
||||
// 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
|
||||
// access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、
|
||||
// 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
|
||||
session_expires_in: sessionTtl(c.env)
|
||||
// 絕不回租戶字串(design §3.3:portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*)
|
||||
});
|
||||
})
|
||||
@@ -13666,10 +13803,9 @@ portalRouter.post(
|
||||
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const daemonCfg = {
|
||||
cypher_url: new URL(c.req.url).origin,
|
||||
namespace: tenant2,
|
||||
namespace: knowledgeOwner(c.env),
|
||||
library: "kb",
|
||||
email,
|
||||
instance_name: String(rec.values.display_name ?? "")
|
||||
@@ -13685,7 +13821,7 @@ portalRouter.post(
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const key = String(body?.key ?? "").trim();
|
||||
if (!key) return c.json({ error: "\u8ACB\u8CBC\u4E0A\u4F60\u7684 Google AI \u91D1\u9470" }, 400);
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const kvKey2 = `${tenant2}:wf:rag_chat`;
|
||||
const raw2 = await c.env.WEBHOOKS.get(kvKey2, "text");
|
||||
if (!raw2) return c.json({ error: "\u9019\u500B\u5BE6\u4F8B\u6C92\u6709\u5B89\u88DD AI \u554F\u7B54\u5DE5\u4F5C\u6D41" }, 404);
|
||||
@@ -13737,8 +13873,8 @@ portalRouter.get(
|
||||
});
|
||||
const known = new Set(out.map((l) => l.name));
|
||||
try {
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const ownerParam = `owner_id=${encodeURIComponent(tenant2)}`;
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const ownerParam = ownerQuery(tenant2);
|
||||
const [autoRes, cardRes, tripletRes] = await Promise.all([
|
||||
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
|
||||
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
|
||||
@@ -13887,8 +14023,8 @@ portalRouter.get(
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const ownerId = portalTenant(c.env);
|
||||
const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /execution-log/retention \u2192 ${res.status}`);
|
||||
const data = await res.json();
|
||||
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
|
||||
@@ -13904,10 +14040,10 @@ portalRouter.put(
|
||||
if (days !== null && days !== void 0 && (typeof days !== "number" || !Number.isFinite(days) || days <= 0)) {
|
||||
return c.json({ error: "retention_days \u5FC5\u9808\u662F\u6B63\u6574\u6578\uFF0C\u6216 null\uFF08\u4EE3\u8868\u4E0D\u522A\u9664\uFF09" }, 400);
|
||||
}
|
||||
const ownerId = portalTenant(c.env);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, "/execution-log/retention", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ owner_id: ownerId, retention_days: days === void 0 ? null : days })
|
||||
body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === void 0 ? null : days })
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention \u2192 ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -13924,10 +14060,10 @@ portalRouter.delete(
|
||||
const confirm = String(body?.confirm ?? "").trim();
|
||||
if (!confirm) return c.json({ error: 'body \u9808\u5E36 { confirm: "<\u5EAB\u540D>" } \u624D\u57F7\u884C\uFF08\u79FB\u9664\u6703\u5F71\u97FF\u8CC7\u6599\u53EF\u641C\u6027\uFF09' }, 400);
|
||||
if (confirm !== name) return c.json({ error: `confirm \u503C\u300C${confirm}\u300D\u8207\u5EAB\u540D\u300C${name}\u300D\u4E0D\u7B26` }, 400);
|
||||
const ownerId = portalTenant(c.env);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, "/entries/deprecate-by-library", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ owner_id: ownerId, library: name })
|
||||
body: JSON.stringify({ owner_id: ownerField(ownerId), library: name })
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library \u2192 ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -13983,8 +14119,8 @@ async function buildDiagnostics(env, tenant2) {
|
||||
let embedding = { checked: false };
|
||||
try {
|
||||
const [statusRes, selftestRes] = await Promise.all([
|
||||
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant2 }).toString()}`),
|
||||
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant2 }).toString()}`)
|
||||
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant2)}`),
|
||||
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant2)}`)
|
||||
]);
|
||||
const statusBody = await statusRes.json().catch(() => null);
|
||||
const selftestBody = await selftestRes.json().catch(() => null);
|
||||
@@ -14006,7 +14142,7 @@ async function buildDiagnostics(env, tenant2) {
|
||||
}
|
||||
let library_count = 0;
|
||||
let triplet_count = 0;
|
||||
const ownerParam = new URLSearchParams({ owner_id: tenant2 }).toString();
|
||||
const ownerParam = ownerQuery(tenant2);
|
||||
try {
|
||||
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
|
||||
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
|
||||
@@ -14030,7 +14166,7 @@ async function buildDiagnostics(env, tenant2) {
|
||||
let library_scope_check = { ran: false };
|
||||
if (library_count === 0 && triplet_count === 0) {
|
||||
try {
|
||||
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant2, limit: "1" }).toString()}`);
|
||||
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant2), limit: "1" }).toString()}`);
|
||||
const probeBody = await probeRes.json().catch(() => null);
|
||||
const total = probeBody?.total ?? 0;
|
||||
library_scope_check = {
|
||||
@@ -14053,7 +14189,7 @@ portalRouter.get(
|
||||
(c) => run(c, async () => {
|
||||
const apiKey = (c.req.header("X-Arcrun-API-Key") ?? "").trim();
|
||||
if (!apiKey) return c.json({ error: "\u7F3A\u5C11 X-Arcrun-API-Key header" }, 401);
|
||||
const core = await buildDiagnostics(c.env, apiKey);
|
||||
const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
|
||||
return c.json({
|
||||
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||||
instance_url: new URL(c.req.url).origin,
|
||||
@@ -14500,6 +14636,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" });
|
||||
@@ -14594,7 +14744,7 @@ async function cachedGiteaSprint(env, nowMs, waitUntil, fetcher = fetchGiteaSpri
|
||||
return { ...fresh, cache: "miss" };
|
||||
}
|
||||
consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const now2 = Date.now();
|
||||
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
@@ -14607,6 +14757,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
kbdbHealth,
|
||||
embedStatus,
|
||||
graphStats,
|
||||
tripletTotal,
|
||||
entriesTotal,
|
||||
wikiCardTotal,
|
||||
workflowTotal
|
||||
@@ -14618,7 +14769,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 }),
|
||||
@@ -14726,36 +14883,37 @@ 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()
|
||||
});
|
||||
});
|
||||
consoleDashboardRouter.get("/console/kb-scale-data", async (c) => {
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
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_at(list 為 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()
|
||||
@@ -14773,7 +14931,7 @@ consoleDashboardRouter.get("/console/settings-data", (c) => {
|
||||
consoleDashboardRouter.get("/console/triage-data", async (c) => {
|
||||
const ok = await validateConsoleSession(c.env, c.req.header("authorization"));
|
||||
if (!ok) return c.json({ error: "\u9700\u8981\u767B\u5165\uFF08console session\uFF09" }, 401);
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const [todoEntries, inboxEntries] = await Promise.all([
|
||||
fetchEntries(c.env, tenant2, "todo", 500),
|
||||
fetchEntries(c.env, tenant2, "inbox", 200)
|
||||
@@ -14788,7 +14946,7 @@ consoleDashboardRouter.post("/console/triage-check", async (c) => {
|
||||
const entryId = typeof body?.entry_id === "string" ? body.entry_id.trim() : "";
|
||||
if (!entryId) return c.json({ error: "entry_id \u5FC5\u586B" }, 400);
|
||||
const action = body?.action === "restore" ? "restore" : "check";
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const got = await fetchJson(
|
||||
`${base}/entries/${encodeURIComponent(entryId)}`,
|
||||
@@ -14816,7 +14974,7 @@ init_kbdb_proxy();
|
||||
init_webhook_handlers();
|
||||
var portalDataRouter = new Hono2();
|
||||
async function getTenantWorkflowGraph(env, name) {
|
||||
const raw2 = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, "text");
|
||||
const raw2 = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, "text");
|
||||
if (!raw2) return null;
|
||||
try {
|
||||
const rec = JSON.parse(raw2);
|
||||
@@ -14907,9 +15065,30 @@ 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 === null ? censusQueryAllTenants() : ownerQuery(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, null) };
|
||||
}
|
||||
async function fuzzyFindNode(env, tenant2, searchTerm) {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant2)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.records)) return null;
|
||||
@@ -14937,7 +15116,7 @@ portalDataRouter.get(
|
||||
if (libraries.length === 0) {
|
||||
return c.json({ success: true, entries: [], count: 0, mode: "keyword", note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002" });
|
||||
}
|
||||
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
|
||||
const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
|
||||
if (!libraries.includes("*")) params.set("library", libraries.join(","));
|
||||
if (c.req.query("mode") === "semantic") {
|
||||
params.set("mode", "semantic");
|
||||
@@ -14973,7 +15152,7 @@ portalDataRouter.get(
|
||||
const body = await res.json();
|
||||
const entry = body.entry;
|
||||
if (!entry) return notFound(c);
|
||||
if ((entry.owner_id ?? "") !== portalTenant(c.env)) return notFound(c);
|
||||
if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
|
||||
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
|
||||
return c.json({ success: true, entry });
|
||||
})
|
||||
@@ -14988,7 +15167,7 @@ portalDataRouter.get(
|
||||
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
|
||||
}
|
||||
const nodeName = normalizeCjkQuery(c.req.param("name"));
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const wfGraph = await getTenantWorkflowGraph(c.env, "graph_neighbors");
|
||||
if (wfGraph) {
|
||||
const depthRaw = c.req.query("depth") ?? "";
|
||||
@@ -15008,8 +15187,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) {
|
||||
@@ -15043,13 +15221,20 @@ portalDataRouter.get(
|
||||
if (!await hasGraphAccess(c.env, libraries)) {
|
||||
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 tenant2 = knowledgeOwner(c.env);
|
||||
const [res, census] = await Promise.all([
|
||||
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(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 = [];
|
||||
@@ -15075,7 +15260,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(
|
||||
@@ -15092,7 +15294,7 @@ portalDataRouter.get(
|
||||
wfGraph,
|
||||
{ question },
|
||||
"rag_chat",
|
||||
portalTenant(c.env),
|
||||
knowledgeOwner(c.env),
|
||||
c.executionCtx
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -15168,7 +15370,7 @@ portalDataRouter.get(
|
||||
if (!workflowsVisible(c.env, auth.user.values.role ?? "user")) {
|
||||
return c.json({ error: "\u9700\u8981 admin \u6B0A\u9650" }, 403);
|
||||
}
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const prefix = `${tenant2}:wf:`;
|
||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||
const workflows = await Promise.all(
|
||||
@@ -15190,7 +15392,7 @@ portalDataRouter.get(
|
||||
let last_execution = null;
|
||||
const execRes = await kbdbFetch(
|
||||
c.env,
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant2 }).toString()}`
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant2) }).toString()}`
|
||||
);
|
||||
const execBody = await execRes.json().catch(() => null);
|
||||
if (execRes.ok && execBody?.success && execBody.execution) {
|
||||
@@ -15202,12 +15404,207 @@ portalDataRouter.get(
|
||||
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
||||
})
|
||||
);
|
||||
function recordLibrary(values) {
|
||||
const lib = values?.library;
|
||||
return typeof lib === "string" && lib.trim() ? lib.trim() : null;
|
||||
}
|
||||
function canReadRecord(rec, tenant2, libraries) {
|
||||
if (!isOwnedBy(rec.owner_id, tenant2)) return false;
|
||||
const lib = recordLibrary(rec.values);
|
||||
return lib === null || canReadLibrary(libraries, lib);
|
||||
}
|
||||
portalDataRouter.get(
|
||||
"/portal/data/map",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: true,
|
||||
empty_reason: "no_library_grant",
|
||||
note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002"
|
||||
});
|
||||
}
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant2)}`);
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.libraries)) {
|
||||
return c.json({ error: "\u85CF\u66F8\u5730\u5716\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 libraries \u6E05\u55AE" }, 502);
|
||||
}
|
||||
const allowed = body.libraries.filter(
|
||||
(l) => typeof l?.library === "string" && canReadLibrary(libraries, l.library)
|
||||
);
|
||||
if (allowed.length > 0) {
|
||||
return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
|
||||
}
|
||||
if (body.libraries.length > 0) {
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: true,
|
||||
empty_reason: "filtered_out",
|
||||
note: "\u9019\u500B\u5E33\u865F\u76EE\u524D\u6C92\u6709\u4EFB\u4F55\u77E5\u8B58\u5EAB\u7684\u6AA2\u8996\u6B0A\u9650\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u958B\u901A\u3002"
|
||||
});
|
||||
}
|
||||
const census = await tripletCensus(c.env, tenant2);
|
||||
if (census.owned === null || census.owned === 0 && census.any === null) {
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: false,
|
||||
empty_reason: "unreadable",
|
||||
note: "\u8B80\u4E0D\u5230\u77E5\u8B58\u5EAB\u7684\u7D71\u8A08\uFF0C\u7121\u6CD5\u78BA\u8A8D\u5EAB\u88E1\u6709\u6C92\u6709\u6771\u897F\u2014\u2014\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u662F\u9019\u6B21\u8B80\u53D6\u5931\u6557\u3002\u8ACB\u7A0D\u5F8C\u91CD\u6574\u6216\u901A\u77E5\u7BA1\u7406\u54E1\u3002"
|
||||
});
|
||||
}
|
||||
if (census.owned === 0 && (census.any ?? 0) > 0) {
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: false,
|
||||
empty_reason: "scope_mismatch",
|
||||
instance_triplet_count: census.any,
|
||||
note: `\u8B80\u4E0D\u5230\u4F60\u9019\u500B\u5E33\u865F\u7BC4\u570D\u5167\u7684\u85CF\u66F8\u2014\u2014\u4F46\u9019\u53F0\u5BE6\u4F8B\u88E1\u6709 ${census.any} \u689D\u77E5\u8B58\u95DC\u806F\u3002\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u4E0D\u7528\u53BB\u91CD\u65B0\u4E0A\u50B3\uFF1B\u6BD4\u8F03\u50CF\u77E5\u8B58\u7684\u6B78\u5C6C\u547D\u540D\u7A7A\u9593\u5C0D\u4E0D\u4E0A\u3002\u8ACB\u901A\u77E5\u7BA1\u7406\u54E1\u8DD1\u4E00\u6B21 \`acr update\`\uFF08\u6703\u628A\u4F60\u5B89\u88DD\u6642\u7684\u547D\u540D\u7A7A\u9593\u540C\u6B65\u7D66\u96F2\u7AEF\uFF09\uFF0C\u6216\u6AA2\u67E5 ARCRUN_NAMESPACE \u8A2D\u5B9A\u3002`
|
||||
});
|
||||
}
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: true,
|
||||
empty_reason: "confirmed_empty",
|
||||
note: "\u77E5\u8B58\u5EAB\u9084\u6C92\u6709\u4EFB\u4F55\u5167\u5BB9\u2014\u2014\u4E0A\u50B3\u6587\u4EF6\u5F8C\u5C31\u6703\u51FA\u73FE\u5728\u9019\u88E1\u3002"
|
||||
});
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
"/portal/data/map/:library",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
const library = c.req.param("library");
|
||||
if (!canReadLibrary(libraries, library)) return notFound(c);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`
|
||||
);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
return new Response(res.body, { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
"/portal/data/templates",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const res = await kbdbFetch(c.env, "/templates");
|
||||
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
return new Response(res.body, { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
})
|
||||
);
|
||||
portalDataRouter.post(
|
||||
"/portal/data/templates",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || typeof body.name !== "string" || !body.name.trim() || !Array.isArray(body.slots)) {
|
||||
return c.json({ error: "name \u8207 slots[] \u5FC5\u586B" }, 400);
|
||||
}
|
||||
const res = await kbdbFetch(c.env, "/templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: body.name,
|
||||
slots: body.slots,
|
||||
description: typeof body.description === "string" ? body.description : void 0,
|
||||
created_by: knowledgeOwner(c.env)
|
||||
})
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
"/portal/data/records/by-template/:template",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/records/by-template/${encodeURIComponent(c.req.param("template"))}?${ownerQuery(tenant2)}`
|
||||
);
|
||||
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.records)) {
|
||||
return c.json({ error: "record \u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
|
||||
}
|
||||
const records = body.records.filter((r) => canReadRecord(r, tenant2, libraries));
|
||||
return c.json({ success: true, records, count: records.length });
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
"/portal/data/records/:recordId",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return notFound(c);
|
||||
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param("recordId"))}`);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
const body = await res.json().catch(() => null);
|
||||
const record = body?.record;
|
||||
if (!record) return notFound(c);
|
||||
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
|
||||
return c.json({ success: true, record });
|
||||
})
|
||||
);
|
||||
portalDataRouter.post(
|
||||
"/portal/data/records",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
return c.json({ error: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u7121\u6CD5\u5BEB\u5165" }, 403);
|
||||
}
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || typeof body.template !== "string" || !body.template.trim() || !body.values || typeof body.values !== "object") {
|
||||
return c.json({ error: "template \u8207 values \u5FC5\u586B" }, 400);
|
||||
}
|
||||
const values = body.values;
|
||||
const targetLib = recordLibrary(values);
|
||||
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
|
||||
return c.json({ error: `\u7121\u300C${targetLib}\u300D\u5EAB\u7684\u6B0A\u9650\uFF0C\u4E0D\u80FD\u5BEB\u5165\u8A72\u5EAB` }, 403);
|
||||
}
|
||||
const res = await kbdbFetch(c.env, "/records", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) })
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
"/portal/data/diagnostics",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const core = await buildDiagnostics(c.env, tenant2);
|
||||
return c.json({
|
||||
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||||
|
||||
@@ -2189,11 +2189,18 @@ async function deprecateEntriesByLibrary(db, ownerId, library) {
|
||||
var MAX_LIKE_Q_BYTES = 48;
|
||||
var MAX_LIKE_TERMS = 6;
|
||||
var utf8Len = (s) => new TextEncoder().encode(s).length;
|
||||
var LIKE_ESCAPE = "\\";
|
||||
var CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`;
|
||||
function escapeLikeLiteral(s) {
|
||||
return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch);
|
||||
}
|
||||
var likeBytes = (s) => utf8Len(escapeLikeLiteral(s));
|
||||
var likePattern = (s) => `%${escapeLikeLiteral(s)}%`;
|
||||
function chunkByBytes(s, maxBytes) {
|
||||
const out = [];
|
||||
let cur = "";
|
||||
for (const ch of s) {
|
||||
if (utf8Len(cur + ch) > maxBytes) {
|
||||
if (likeBytes(cur + ch) > maxBytes) {
|
||||
if (cur) out.push(cur);
|
||||
cur = ch;
|
||||
} else {
|
||||
@@ -2204,8 +2211,8 @@ function chunkByBytes(s, maxBytes) {
|
||||
return out;
|
||||
}
|
||||
function buildContentLike(q) {
|
||||
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
|
||||
return { conds: ["content LIKE ?"], params: [`%${q}%`], split: false };
|
||||
if (likeBytes(q) <= MAX_LIKE_Q_BYTES) {
|
||||
return { conds: [CONTENT_LIKE], params: [likePattern(q)], split: false };
|
||||
}
|
||||
const terms = [];
|
||||
for (const word of q.split(/\s+/).filter(Boolean)) {
|
||||
@@ -2217,8 +2224,8 @@ function buildContentLike(q) {
|
||||
}
|
||||
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? "");
|
||||
return {
|
||||
conds: terms.map(() => "content LIKE ?"),
|
||||
params: terms.map((t) => `%${t}%`),
|
||||
conds: terms.map(() => CONTENT_LIKE),
|
||||
params: terms.map(likePattern),
|
||||
split: true
|
||||
};
|
||||
}
|
||||
@@ -2335,7 +2342,7 @@ function buildSearchScore(q) {
|
||||
if (terms.length === 0) {
|
||||
const m = buildContentLike(trimmed);
|
||||
return {
|
||||
scoreExpr: m.conds.map(() => "CASE WHEN content LIKE ? THEN 1 ELSE 0 END").join(" + "),
|
||||
scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(" + "),
|
||||
scoreParams: m.params,
|
||||
terms: [],
|
||||
legacyShape: true
|
||||
@@ -2344,14 +2351,14 @@ function buildSearchScore(q) {
|
||||
const parts = [];
|
||||
const params = [];
|
||||
for (const { term, weight } of terms) {
|
||||
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
|
||||
params.push(`%${term}%`);
|
||||
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${weight} ELSE 0 END`);
|
||||
params.push(likePattern(term));
|
||||
}
|
||||
const single = terms.length === 1 && terms[0].term === trimmed;
|
||||
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||
if (!single && likeBytes(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||
const bonus = terms.reduce((s, t) => s + t.weight, 0);
|
||||
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
|
||||
params.push(`%${trimmed}%`);
|
||||
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
|
||||
params.push(likePattern(trimmed));
|
||||
}
|
||||
return { scoreExpr: parts.join(" + "), scoreParams: params, terms, legacyShape: single };
|
||||
}
|
||||
@@ -2408,6 +2415,57 @@ async function searchEntries(db, q, owner_id, entry_type, limit = 50, library, s
|
||||
return applyRelativeCut(res.results ?? []);
|
||||
}
|
||||
|
||||
// kbdb/src/actions/maintenance-quota.ts
|
||||
var DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT = 2e4;
|
||||
function maintenanceDailyLimit(env) {
|
||||
const raw2 = env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT;
|
||||
const n = raw2 ? parseInt(raw2, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT;
|
||||
}
|
||||
function utcDay() {
|
||||
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
||||
}
|
||||
function maintenanceUsageId() {
|
||||
return `kbdb-maintenance-usage:${utcDay()}`;
|
||||
}
|
||||
async function getMaintenanceUsageToday(db) {
|
||||
const row = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(maintenanceUsageId()).first();
|
||||
if (!row) return 0;
|
||||
try {
|
||||
const parsed = row.metadata_json ? JSON.parse(row.metadata_json) : {};
|
||||
return Number(parsed.writes) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
async function addMaintenanceUsage(db, by) {
|
||||
if (by <= 0) return;
|
||||
const id = maintenanceUsageId();
|
||||
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
|
||||
let prev = 0;
|
||||
if (existing) {
|
||||
try {
|
||||
const parsed = existing.metadata_json ? JSON.parse(existing.metadata_json) : {};
|
||||
prev = Number(parsed.writes) || 0;
|
||||
} catch {
|
||||
prev = 0;
|
||||
}
|
||||
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay(), writes: prev + by }), id).run();
|
||||
} else {
|
||||
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'kbdb_maintenance_usage', ?)`).bind(id, JSON.stringify({ day: utcDay(), writes: by })).run();
|
||||
}
|
||||
}
|
||||
async function maintenanceBudgetToday(env, db) {
|
||||
const limit = maintenanceDailyLimit(env);
|
||||
let used = 0;
|
||||
try {
|
||||
used = await getMaintenanceUsageToday(db);
|
||||
} catch {
|
||||
used = 0;
|
||||
}
|
||||
return { limit, used, remaining: Math.max(0, limit - used) };
|
||||
}
|
||||
|
||||
// kbdb/src/embed.ts
|
||||
var DEFAULT_EMBED_MODEL = "@cf/baai/bge-m3";
|
||||
var MIN_SCORE_ABS_FLOOR = 0.45;
|
||||
@@ -2449,7 +2507,7 @@ async function embedOnWrite(env, entry) {
|
||||
}
|
||||
}
|
||||
]);
|
||||
await env.DB.prepare("UPDATE entries SET is_embedded = 1 WHERE id = ?").bind(entry.id).run();
|
||||
await env.DB.prepare("UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id = ?").bind(embedModel(env), entry.id).run();
|
||||
return true;
|
||||
}
|
||||
function isEmbeddable(entry) {
|
||||
@@ -2476,12 +2534,47 @@ function parseMeta(json) {
|
||||
}
|
||||
}
|
||||
var BACKFILL_PREDICATE = "is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";
|
||||
async function backfillEmbeddings(env, opts = {}) {
|
||||
if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 };
|
||||
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
|
||||
const offset = Math.max(opts.offset ?? 0, 0);
|
||||
const basePredicate = opts.reindex ? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1" : BACKFILL_PREDICATE;
|
||||
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"];
|
||||
var DEFAULT_BACKFILL_DAILY_LIMIT = 1800;
|
||||
function backfillDailyLimit(env) {
|
||||
const raw2 = env.EMBED_BACKFILL_DAILY_LIMIT;
|
||||
const n = raw2 ? parseInt(raw2, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_BACKFILL_DAILY_LIMIT;
|
||||
}
|
||||
function utcDay2() {
|
||||
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
||||
}
|
||||
function backfillUsageId() {
|
||||
return `embed-backfill-usage:${utcDay2()}`;
|
||||
}
|
||||
async function getBackfillUsageToday(db) {
|
||||
const row = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(backfillUsageId()).first();
|
||||
if (!row) return 0;
|
||||
try {
|
||||
const parsed = row.metadata_json ? JSON.parse(row.metadata_json) : {};
|
||||
return Number(parsed.embedded) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
async function addBackfillUsage(db, by) {
|
||||
if (by <= 0) return;
|
||||
const id = backfillUsageId();
|
||||
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
|
||||
let prev = 0;
|
||||
if (existing) {
|
||||
try {
|
||||
const parsed = existing.metadata_json ? JSON.parse(existing.metadata_json) : {};
|
||||
prev = Number(parsed.embedded) || 0;
|
||||
} catch {
|
||||
prev = 0;
|
||||
}
|
||||
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay2(), embedded: prev + by }), id).run();
|
||||
} else {
|
||||
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'embed_backfill_usage', ?)`).bind(id, JSON.stringify({ day: utcDay2(), embedded: by })).run();
|
||||
}
|
||||
}
|
||||
function selectionCriteriaPredicate(opts) {
|
||||
const conds = [];
|
||||
const params = [];
|
||||
if (opts.owner_id) {
|
||||
conds.push("owner_id = ?");
|
||||
@@ -2491,12 +2584,55 @@ async function backfillEmbeddings(env, opts = {}) {
|
||||
conds.push("json_extract(metadata_json, '$.source') = ?");
|
||||
params.push(opts.source);
|
||||
}
|
||||
if (opts.library) {
|
||||
conds.push("COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ?");
|
||||
params.push(opts.library);
|
||||
}
|
||||
if (typeof opts.since === "number") {
|
||||
conds.push("created_at >= ?");
|
||||
params.push(opts.since);
|
||||
}
|
||||
if (typeof opts.until === "number") {
|
||||
conds.push("created_at < ?");
|
||||
params.push(opts.until);
|
||||
}
|
||||
return { conds, params };
|
||||
}
|
||||
async function backfillEmbeddings(env, opts = {}) {
|
||||
if (!embedEnabled(env)) {
|
||||
return {
|
||||
enabled: false,
|
||||
processed: 0,
|
||||
skipped: 0,
|
||||
remaining: 0,
|
||||
scanned: 0,
|
||||
quota_limit: 0,
|
||||
quota_used_today: 0,
|
||||
quota_exceeded: false
|
||||
};
|
||||
}
|
||||
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
|
||||
const offset = Math.max(opts.offset ?? 0, 0);
|
||||
const basePredicate = opts.reindex ? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1" : BACKFILL_PREDICATE;
|
||||
const sel = selectionCriteriaPredicate(opts);
|
||||
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", ...sel.conds];
|
||||
const params = [...sel.params];
|
||||
const where = conds.join(" AND ");
|
||||
const res = await env.DB.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all();
|
||||
const res = await env.DB.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all();
|
||||
const rows = res.results ?? [];
|
||||
const scanned = rows.length;
|
||||
const dailyCap = backfillDailyLimit(env);
|
||||
let usedToday = 0;
|
||||
try {
|
||||
usedToday = await getBackfillUsageToday(env.DB);
|
||||
} catch {
|
||||
usedToday = 0;
|
||||
}
|
||||
const remainingQuota = Math.max(0, dailyCap - usedToday);
|
||||
let processed = 0;
|
||||
const embeddable = rows.filter((e) => (e.content ?? "").trim().length > 0);
|
||||
const candidates = rows.filter((e) => (e.content ?? "").trim().length > 0);
|
||||
const embeddable = candidates.slice(0, remainingQuota);
|
||||
const quotaExceeded = candidates.length > embeddable.length;
|
||||
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
|
||||
const texts = embeddable.map((e) => (e.content ?? "").trim());
|
||||
const out = await env.AI.run(embedModel(env), { text: texts });
|
||||
@@ -2516,14 +2652,27 @@ async function backfillEmbeddings(env, opts = {}) {
|
||||
await env.VECTORIZE.upsert(vectors);
|
||||
const ids = vectors.map((v) => v.id);
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
await env.DB.prepare(`UPDATE entries SET is_embedded = 1 WHERE id IN (${placeholders})`).bind(...ids).run();
|
||||
await env.DB.prepare(`UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id IN (${placeholders})`).bind(embedModel(env), ...ids).run();
|
||||
processed = vectors.length;
|
||||
try {
|
||||
await addBackfillUsage(env.DB, processed);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
const remRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...params).first();
|
||||
const totalMatching = remRow?.c ?? 0;
|
||||
const remaining = opts.reindex ? Math.max(0, totalMatching - (offset + scanned)) : totalMatching;
|
||||
return { enabled: true, processed, skipped: scanned - processed, remaining, scanned };
|
||||
return {
|
||||
enabled: true,
|
||||
processed,
|
||||
skipped: scanned - processed,
|
||||
remaining,
|
||||
scanned,
|
||||
quota_limit: dailyCap,
|
||||
quota_used_today: usedToday + processed,
|
||||
quota_exceeded: quotaExceeded
|
||||
};
|
||||
}
|
||||
async function backfillStatus(env, opts = {}) {
|
||||
const conds = [];
|
||||
@@ -2541,6 +2690,74 @@ async function backfillStatus(env, opts = {}) {
|
||||
const embeddedRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE is_embedded = 1 AND json_extract(metadata_json, '$.embed') = 1${extra}`).bind(...params).first();
|
||||
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
|
||||
}
|
||||
async function reconcileEmbedGeneration(env, opts = {}) {
|
||||
if (!embedEnabled(env)) {
|
||||
return {
|
||||
enabled: false,
|
||||
checked: 0,
|
||||
confirmed_current: 0,
|
||||
reset_to_pending: 0,
|
||||
remaining: 0,
|
||||
scanned: 0,
|
||||
quota_limit: 0,
|
||||
quota_used_today: 0,
|
||||
quota_exceeded: false
|
||||
};
|
||||
}
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
||||
const currentModel = embedModel(env);
|
||||
const sel = selectionCriteriaPredicate(opts);
|
||||
const conds = [
|
||||
"is_embedded = 1",
|
||||
"(content_hash IS NULL OR content_hash != ?)",
|
||||
"COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'",
|
||||
...sel.conds
|
||||
];
|
||||
const params = [currentModel, ...sel.params];
|
||||
const where = conds.join(" AND ");
|
||||
const res = await env.DB.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ?`).bind(...params, limit).all();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
const budget = await maintenanceBudgetToday(env, env.DB);
|
||||
const ids = scannedIds.slice(0, budget.remaining);
|
||||
const quotaExceeded = scanned > ids.length;
|
||||
const checked = ids.length;
|
||||
let confirmed_current = 0;
|
||||
let reset_to_pending = 0;
|
||||
if (ids.length > 0 && env.VECTORIZE) {
|
||||
const found = await env.VECTORIZE.getByIds(ids);
|
||||
const foundIds = new Set(found.map((v) => v.id));
|
||||
const presentIds = ids.filter((id) => foundIds.has(id));
|
||||
const missingIds = ids.filter((id) => !foundIds.has(id));
|
||||
if (presentIds.length > 0) {
|
||||
const ph = presentIds.map(() => "?").join(",");
|
||||
await env.DB.prepare(`UPDATE entries SET content_hash = ? WHERE id IN (${ph})`).bind(currentModel, ...presentIds).run();
|
||||
confirmed_current = presentIds.length;
|
||||
}
|
||||
if (missingIds.length > 0) {
|
||||
const ph = missingIds.map(() => "?").join(",");
|
||||
await env.DB.prepare(`UPDATE entries SET is_embedded = 0, content_hash = NULL WHERE id IN (${ph})`).bind(...missingIds).run();
|
||||
reset_to_pending = missingIds.length;
|
||||
}
|
||||
}
|
||||
const remRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...params).first();
|
||||
const written = confirmed_current + reset_to_pending;
|
||||
try {
|
||||
await addMaintenanceUsage(env.DB, written);
|
||||
} catch {
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
checked,
|
||||
confirmed_current,
|
||||
reset_to_pending,
|
||||
remaining: remRow?.c ?? 0,
|
||||
scanned,
|
||||
quota_limit: budget.limit,
|
||||
quota_used_today: budget.used + written,
|
||||
quota_exceeded: quotaExceeded
|
||||
};
|
||||
}
|
||||
async function embedSelfTest(env, opts = {}) {
|
||||
if (!embedEnabled(env)) {
|
||||
return { enabled: false, tested: false, passed: null, note: "embed \u6A21\u7D44\u672A\u958B\uFF08\u7F3A Vectorize/AI binding\uFF09\uFF0C\u8A9E\u7FA9\u641C\u5C0B\u9019\u689D\u8DEF\u76EE\u524D\u4E0D\u5B58\u5728" };
|
||||
@@ -2647,6 +2864,90 @@ async function migrateLegacyCredentialsForOwner(db, ownerId) {
|
||||
return (after?.n ?? 0) - (before?.n ?? 0);
|
||||
}
|
||||
|
||||
// kbdb/src/actions/library-backfill.ts
|
||||
var MAX_PAGE_NAMES = 300;
|
||||
var HARD_LIMIT_CAP = 500;
|
||||
function criteriaPredicate(c) {
|
||||
const conds = [
|
||||
"(json_extract(metadata_json, '$.library') IS NULL OR json_extract(metadata_json, '$.library') = '')"
|
||||
];
|
||||
const params = [];
|
||||
if (c.owner_id) {
|
||||
conds.push("owner_id = ?");
|
||||
params.push(c.owner_id);
|
||||
}
|
||||
if (c.entry_type) {
|
||||
conds.push("entry_type = ?");
|
||||
params.push(c.entry_type);
|
||||
}
|
||||
if (c.page_names && c.page_names.length > 0) {
|
||||
const names = c.page_names.slice(0, MAX_PAGE_NAMES);
|
||||
conds.push(`page_name IN (${names.map(() => "?").join(",")})`);
|
||||
params.push(...names);
|
||||
}
|
||||
if (c.source_prefix) {
|
||||
conds.push("json_extract(metadata_json, '$.source') LIKE ? || '%'");
|
||||
params.push(c.source_prefix);
|
||||
}
|
||||
if (c.page_name_prefix) {
|
||||
conds.push("page_name LIKE ? || '%'");
|
||||
params.push(c.page_name_prefix);
|
||||
}
|
||||
if (typeof c.since === "number") {
|
||||
conds.push("created_at >= ?");
|
||||
params.push(c.since);
|
||||
}
|
||||
if (typeof c.until === "number") {
|
||||
conds.push("created_at < ?");
|
||||
params.push(c.until);
|
||||
}
|
||||
return { conds, params };
|
||||
}
|
||||
async function backfillEntryLibraryTags(db, env, opts) {
|
||||
const library = (opts.library ?? "").trim();
|
||||
if (!library) throw new Error("library required");
|
||||
const ownerId = (opts.owner_id ?? "").trim();
|
||||
if (!ownerId) throw new Error("owner_id required\uFF08\u6A19\u5EAB\u662F\u8DE8\u5927\u91CF\u65E2\u6709\u8CC7\u6599\u7684\u6279\u6B21\u5BEB\u5165\uFF0C\u4E0D\u51C6\u7121\u79DF\u6236\u7BC4\u570D\u5730\u6383\u5168\u5EAB\u2014\u20142026-08-11 leo \u76F4\u4EE4\uFF09");
|
||||
const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);
|
||||
const sel = criteriaPredicate({ ...opts, owner_id: ownerId });
|
||||
const where = sel.conds.join(" AND ");
|
||||
const params = sel.params;
|
||||
const res = await db.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ?`).bind(...params, limit).all();
|
||||
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||
const scanned = scannedIds.length;
|
||||
const budget = await maintenanceBudgetToday(env, db);
|
||||
const ids = scannedIds.slice(0, budget.remaining);
|
||||
const quotaExceeded = scanned > ids.length;
|
||||
let tagged = 0;
|
||||
if (ids.length > 0) {
|
||||
const ph = ids.map(() => "?").join(",");
|
||||
await db.prepare(
|
||||
`UPDATE entries SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.library', ?), updated_at = unixepoch() WHERE id IN (${ph})`
|
||||
).bind(library, ...ids).run();
|
||||
tagged = ids.length;
|
||||
}
|
||||
try {
|
||||
await addMaintenanceUsage(db, tagged);
|
||||
} catch {
|
||||
}
|
||||
const remRow = await db.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...params).first();
|
||||
return {
|
||||
library,
|
||||
scanned,
|
||||
tagged,
|
||||
remaining: remRow?.c ?? 0,
|
||||
quota_limit: budget.limit,
|
||||
quota_used_today: budget.used + tagged,
|
||||
quota_exceeded: quotaExceeded
|
||||
};
|
||||
}
|
||||
async function libraryBackfillStatus(db, opts = {}) {
|
||||
const sel = criteriaPredicate(opts);
|
||||
const where = sel.conds.join(" AND ");
|
||||
const row = await db.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...sel.params).first();
|
||||
return { pending: row?.c ?? 0 };
|
||||
}
|
||||
|
||||
// kbdb/src/routes/entries.ts
|
||||
var entryRoutes = new Hono2();
|
||||
function fireAndForget(c, p) {
|
||||
@@ -2873,6 +3174,39 @@ entryRoutes.patch("/deprecate-by-library", async (c) => {
|
||||
}
|
||||
return c.json({ success: true, deprecated_count: count, vectors_deleted });
|
||||
});
|
||||
entryRoutes.post("/backfill-library", async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const library = String(body.library ?? "").trim();
|
||||
const ownerId = String(body.owner_id ?? "").trim();
|
||||
if (!library || !ownerId) return c.json({ success: false, error: "library \u8207 owner_id \u5FC5\u586B" }, 400);
|
||||
try {
|
||||
const result = await backfillEntryLibraryTags(c.env.DB, c.env, {
|
||||
library,
|
||||
owner_id: ownerId,
|
||||
entry_type: body.entry_type || void 0,
|
||||
page_names: Array.isArray(body.page_names) && body.page_names.length > 0 ? body.page_names : void 0,
|
||||
source_prefix: body.source_prefix || void 0,
|
||||
page_name_prefix: body.page_name_prefix || void 0,
|
||||
since: body.since !== void 0 ? Number(body.since) : void 0,
|
||||
until: body.until !== void 0 ? Number(body.until) : void 0,
|
||||
limit: body.limit !== void 0 ? Number(body.limit) : void 0
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
|
||||
}
|
||||
});
|
||||
entryRoutes.get("/backfill-library/status", async (c) => {
|
||||
const status = await libraryBackfillStatus(c.env.DB, {
|
||||
owner_id: c.req.query("owner_id") || void 0,
|
||||
entry_type: c.req.query("entry_type") || void 0,
|
||||
source_prefix: c.req.query("source_prefix") || void 0,
|
||||
page_name_prefix: c.req.query("page_name_prefix") || void 0,
|
||||
since: c.req.query("since") ? Number(c.req.query("since")) : void 0,
|
||||
until: c.req.query("until") ? Number(c.req.query("until")) : void 0
|
||||
});
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
entryRoutes.patch("/:id", async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const entry = await updateEntry(c.env.DB, c.req.param("id"), body);
|
||||
@@ -2947,7 +3281,7 @@ async function createRecord(db, input) {
|
||||
});
|
||||
await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("ev"), recordId, tpl.id, slot, entry.id).run();
|
||||
}
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values };
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
|
||||
}
|
||||
async function updateRecord(db, recordId, values) {
|
||||
const evRes = await db.prepare(
|
||||
@@ -2978,7 +3312,7 @@ async function updateRecord(db, recordId, values) {
|
||||
}
|
||||
async function getRecord(db, recordId) {
|
||||
const res = await db.prepare(
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id = ?`
|
||||
).bind(recordId).all();
|
||||
@@ -2986,7 +3320,8 @@ async function getRecord(db, recordId) {
|
||||
if (rows.length === 0) return null;
|
||||
const values = {};
|
||||
for (const r of rows) values[r.slot] = r.content;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values };
|
||||
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
|
||||
}
|
||||
async function searchByTemplate(db, template, owner_id, limit = 100) {
|
||||
const tpl = await getTemplate(db, template);
|
||||
@@ -3005,17 +3340,18 @@ async function searchByTemplate(db, template, owner_id, limit = 100) {
|
||||
const chunk = ids.slice(i, i + 90);
|
||||
const placeholders = chunk.map(() => "?").join(",");
|
||||
const evRes = await db.prepare(
|
||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id IN (${placeholders})`
|
||||
).bind(...chunk).all();
|
||||
for (const r of evRes.results ?? []) {
|
||||
let rec = byId.get(r.record_id);
|
||||
if (!rec) {
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {} };
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
|
||||
byId.set(r.record_id, rec);
|
||||
}
|
||||
rec.values[r.slot] = r.content;
|
||||
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
|
||||
}
|
||||
}
|
||||
return ids.map((id) => byId.get(id)).filter((r) => !!r);
|
||||
@@ -3192,6 +3528,9 @@ embedRoutes.post("/backfill", async (c) => {
|
||||
limit: body.limit !== void 0 ? Number(body.limit) : void 0,
|
||||
owner_id: body.owner_id || void 0,
|
||||
source: body.source || void 0,
|
||||
library: body.library || void 0,
|
||||
since: body.since !== void 0 ? Number(body.since) : void 0,
|
||||
until: body.until !== void 0 ? Number(body.until) : void 0,
|
||||
// reindex(Arcrun#11):重推既有向量讓事後建立的 Vectorize metadata index 收錄(見 embed.ts)。
|
||||
reindex: body.reindex === true,
|
||||
offset: body.offset !== void 0 ? Number(body.offset) : void 0
|
||||
@@ -3205,6 +3544,23 @@ embedRoutes.get("/backfill/status", async (c) => {
|
||||
});
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
embedRoutes.post("/reconcile", async (c) => {
|
||||
if (!embedEnabled(c.env)) {
|
||||
return c.json(
|
||||
{ success: false, error: "embed module not enabled (need VECTORIZE + AI bindings)", capability_hint: OFF_HINT },
|
||||
409
|
||||
);
|
||||
}
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const result = await reconcileEmbedGeneration(c.env, {
|
||||
limit: body.limit !== void 0 ? Number(body.limit) : void 0,
|
||||
owner_id: body.owner_id || void 0,
|
||||
library: body.library || void 0,
|
||||
since: body.since !== void 0 ? Number(body.since) : void 0,
|
||||
until: body.until !== void 0 ? Number(body.until) : void 0
|
||||
});
|
||||
return c.json({ success: true, ...result });
|
||||
});
|
||||
embedRoutes.get("/selftest", async (c) => {
|
||||
const result = await embedSelfTest(c.env, { owner_id: c.req.query("owner_id") || void 0 });
|
||||
return c.json({ success: true, ...result });
|
||||
@@ -3402,14 +3758,18 @@ async function recomputeLibraryMap(db, input) {
|
||||
async function liveTripletCountsByLibrary(db, tripletTemplateId, owner_id) {
|
||||
const params = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
|
||||
const res = await db.prepare(
|
||||
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
|
||||
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
||||
FROM (
|
||||
SELECT DISTINCT ev.record_id
|
||||
SELECT ev.record_id AS rid,
|
||||
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""}
|
||||
GROUP BY ev.record_id
|
||||
) AS tr
|
||||
LEFT JOIN entry_values lev ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
|
||||
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
|
||||
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||
WHERE COALESCE(tr.status, 'active') = 'active'
|
||||
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`
|
||||
).bind(...params).all();
|
||||
const m = /* @__PURE__ */ new Map();
|
||||
@@ -3558,7 +3918,7 @@ function dailyLimit(env) {
|
||||
const n = raw2 ? parseInt(raw2, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_DAILY_LIMIT;
|
||||
}
|
||||
function utcDay() {
|
||||
function utcDay3() {
|
||||
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
||||
}
|
||||
function truncate(s, max) {
|
||||
@@ -3566,7 +3926,7 @@ function truncate(s, max) {
|
||||
return s.slice(0, Math.max(0, max - 1)) + "\u2026";
|
||||
}
|
||||
async function checkUsage(db, limit) {
|
||||
const id = `exlog-usage:${utcDay()}`;
|
||||
const id = `exlog-usage:${utcDay3()}`;
|
||||
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
|
||||
let count;
|
||||
if (existing) {
|
||||
@@ -3578,10 +3938,10 @@ async function checkUsage(db, limit) {
|
||||
prevWrites = 0;
|
||||
}
|
||||
count = prevWrites + 1;
|
||||
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay(), writes: count }), id).run();
|
||||
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay3(), writes: count }), id).run();
|
||||
} else {
|
||||
count = 1;
|
||||
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`).bind(id, JSON.stringify({ day: utcDay(), writes: count })).run();
|
||||
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`).bind(id, JSON.stringify({ day: utcDay3(), writes: count })).run();
|
||||
}
|
||||
if (count > limit) return "skip";
|
||||
if (count > limit * DEGRADE_RATIO) return "log_failure_only";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"built_for": "arcrun-tier2-worker-artifacts",
|
||||
"generated_at": "2026-08-11T05:33:37.988Z",
|
||||
"repo_head": "d8bbf2241bd6b117d76fb27d9e386ecfb0ffe8f7",
|
||||
"generated_at": "2026-08-12T16:16:33.433Z",
|
||||
"repo_head": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
|
||||
"repo_dirty": false,
|
||||
"workers": [
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"source_dir": "cypher-executor",
|
||||
"source_commit": "797e7f751cc42cb1f5d9e2e187f18cf51eb981a1",
|
||||
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 568855,
|
||||
"content_sha256": "66e2a6341854e8b2de0567a46282b94669e73b95d152b05b17b0f8b58e257fec",
|
||||
"js_bytes": 588397,
|
||||
"content_sha256": "e0026a23792f8b6b02e35c91081604b9a761c608a2cae0e6ee8ab0f34a484501",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -58,11 +58,11 @@
|
||||
{
|
||||
"name": "arcrun-kbdb",
|
||||
"source_dir": "kbdb",
|
||||
"source_commit": "a7e23badf2a771be779a861e69e7efa6e8141dfe",
|
||||
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 135910,
|
||||
"content_sha256": "5e5a7a030f4fd1f5549ace6791c3827b6497b0bfdd9add46af041af47c472905",
|
||||
"js_bytes": 149791,
|
||||
"content_sha256": "9c6d41895d78cbf3048fb539690591c86071b7d6ebf7fb0b8b51607c6b1f8ee9",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -148,11 +148,11 @@
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
|
||||
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1165130,
|
||||
"content_sha256": "be15033f32e605f03f69bd10cd87782dafa34dbafeee2ce367bd7361a062a291",
|
||||
"js_bytes": 1179229,
|
||||
"content_sha256": "3ebc0d441bc04ae56a1205507da701f93bed9efa9b1e53c1777c04cbef5bdb67",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"build:harness": "node scripts/build-harness-skill.mjs",
|
||||
"check:harness": "node scripts/check-harness-generation.mjs",
|
||||
"dev": "tsc --watch",
|
||||
"test": "node --test \"tests/**/*.test.ts\"",
|
||||
"test": "node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"",
|
||||
"prepublishOnly": "npm run build && chmod +x dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+23
-40
@@ -10,7 +10,6 @@ import chalk from 'chalk';
|
||||
import { saveConfig, type ArcrunConfig } from '../lib/config.js';
|
||||
import { CfAccountClient } from '../lib/cf-api.js';
|
||||
import {
|
||||
REQUIRED_KV_NAMESPACES,
|
||||
downloadAndDeploy,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
@@ -135,7 +134,7 @@ async function initStandard(rl: ReturnType<typeof createInterface>): Promise<voi
|
||||
|
||||
/**
|
||||
* Self-hosted installer:用戶只提供 CF Account ID + API Token,其餘自動。
|
||||
* 驗 token → 建 KV(冪等,數量見 REQUIRED_KV_NAMESPACES)→ 查 subdomain → 下載 release 部署 Worker
|
||||
* 驗 token → 查 subdomain → 下載部署物 → 解析資源(沿用既有/必要才新建)→ 部署 Worker
|
||||
* → seed auth+api recipe → 寫 config → 印手動 secret 提示。
|
||||
* SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md
|
||||
*/
|
||||
@@ -185,41 +184,13 @@ async function initSelfHosted(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. 建 KV namespace(冪等)
|
||||
// 2. KV / D1 / Vectorize 不在這裡預先建(Arcrun#97)。
|
||||
// 舊版在這一步「照名字 ensure」一輪再往下傳,acr update 沿用同一段程式碼
|
||||
// ⇒ 對一台安裝器裝出來的實例(資源名字不同)等於每次更新都重建一整套空的綁上去。
|
||||
// 現在資源解析統一在 downloadAndDeploy 內:**先看已部署的 worker 綁著什麼**,
|
||||
// 對得上就沿用、確定沒人綁過才建、說不準就停手。init 走 mode:'init'(允許從零建起)。
|
||||
// 不建 R2:R2 是 dead storage(registry-canon Phase 1.5),且 CF R2 首次啟用強制綁信用卡,
|
||||
// 違背 arcrun「開源免費自架,Workers + KV 免費額度即可運行」核心理念(壓測 2026-06-04 #3)。
|
||||
const kvNamespaceIds: Record<string, string> = {};
|
||||
try {
|
||||
const existing = await cf.listKvNamespaces();
|
||||
for (const title of REQUIRED_KV_NAMESPACES) {
|
||||
process.stdout.write(chalk.gray(` → KV ${title}...`));
|
||||
const id = await cf.ensureKvNamespace(title, existing);
|
||||
kvNamespaceIds[title] = id;
|
||||
console.log(chalk.green(' ✓'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(`\n ✗ 建立資源失敗:${e instanceof Error ? e.message : e}\n`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2.5 build D1 for KBDB Base (atomic universal table). Free on Workers Free, no credit card
|
||||
// (kbdb-base SDD Q4). idempotent: reuse if exists.
|
||||
let d1DatabaseId = '';
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb...'));
|
||||
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
const em = e instanceof Error ? e.message : String(e);
|
||||
console.log(chalk.yellow(`\n ⚠ D1 build failed (${em})`));
|
||||
if (/auth/i.test(em)) {
|
||||
// 最常見根因:CF token 沒勾 D1 權限(KV/Worker 建得起來但 D1 報 Authentication error)。
|
||||
console.log(chalk.yellow(' 多半是 CF token 缺 D1 權限 → 去 token 補勾「Account / D1 / Edit」'));
|
||||
console.log(chalk.gray(' 重產 token 填回 .env 後跑 acr update。D1 存 workflow/recipe,沒它後續會受限。'));
|
||||
} else {
|
||||
console.log(chalk.gray(' KBDB Base 暫不可用,可 acr update 重試。'));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用)
|
||||
let workerSubdomain = '';
|
||||
@@ -245,8 +216,20 @@ async function initSelfHosted(
|
||||
console.log(chalk.gray('\n → 下載部署物 + 部署 Worker(從 GitHub 拉預編譯 wasm,用你的 CF token 部署)...'));
|
||||
// selfHosted: true → deploy 注入 MULTI_TENANT="false"(mcp-account-source §5.5,修 MCP 401)。
|
||||
// init.ts 這條本就是 --self-hosted 分支(config.mode 稍後寫 'self-hosted')。
|
||||
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, kvNamespaceIds, d1DatabaseId, selfHosted: true, kbdbEmbed };
|
||||
const deploy = await downloadAndDeploy(deployCtx);
|
||||
const deployCtx: DeployContext = { accountId, apiToken: cfApiToken, workerSubdomain, selfHosted: true, kbdbEmbed };
|
||||
const deploy = await downloadAndDeploy(deployCtx, 'main', { mode: 'init', api: cf });
|
||||
|
||||
// 資源解析喊停(例:這台其實已經裝過、但某顆綁著的資源不見了)→ 什麼都沒建、什麼都沒部。
|
||||
if (deploy.blocked) {
|
||||
console.log(chalk.yellow('\n ⚠ 安裝沒有進行,你的 Cloudflare 帳號維持原樣。\n'));
|
||||
console.log(' ' + deploy.message.split('\n').join('\n '));
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 實際用上的資源(沿用既有的,或這次新建的)——寫 config / 驗收都以這份為準,不再自己查名字。
|
||||
const kvNamespaceIds = deployCtx.kvNamespaceIds ?? {};
|
||||
const d1DatabaseId = deployCtx.d1DatabaseId ?? '';
|
||||
const cypherUrl = deploy.cypherExecutorUrl
|
||||
?? (workerSubdomain ? `https://arcrun-cypher-executor.${workerSubdomain}.workers.dev` : '');
|
||||
// self-hosted 自己的 MCP worker URL(mcp-account-source §3:.mcp.json 指自己,不 fallback 官方)。
|
||||
@@ -290,8 +273,8 @@ async function initSelfHosted(
|
||||
// + 給一鍵補裝指令(不靜默印灰字)。假綠零容忍(mindset §7):看實際狀態,非看 config 寫了沒。
|
||||
const verify = await verifyInstall({
|
||||
cf,
|
||||
requiredKv: REQUIRED_KV_NAMESPACES,
|
||||
expectD1Name: d1DatabaseId ? 'arcrun-kbdb' : undefined,
|
||||
kvNamespaceIds,
|
||||
d1DatabaseId: d1DatabaseId || undefined,
|
||||
cypherUrl,
|
||||
});
|
||||
printPreflight('安裝驗收(裝完檢查)', verify.items);
|
||||
@@ -301,7 +284,7 @@ async function initSelfHosted(
|
||||
}
|
||||
|
||||
// 結果回報(誠實:部分失敗時明說,不假綠 — mindset §7)
|
||||
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${REQUIRED_KV_NAMESPACES.length} KV,免費額度即可,無需綁卡)`));
|
||||
console.log(chalk.green(`\n ✓ Cloudflare 資源就緒(${Object.keys(kvNamespaceIds).length} KV,免費額度即可,無需綁卡)`));
|
||||
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
|
||||
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
|
||||
|
||||
|
||||
+44
-36
@@ -14,11 +14,10 @@
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { loadConfig } from '../lib/config.js';
|
||||
import { CfAccountClient } from '../lib/cf-api.js';
|
||||
import {
|
||||
wranglerAvailable,
|
||||
downloadAndDeploy,
|
||||
REQUIRED_KV_NAMESPACES,
|
||||
namespaceHasKnowledge,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
|
||||
@@ -44,43 +43,15 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||||
|
||||
console.log(chalk.bold('\n acr update — 拉新 release 並重新部署\n'));
|
||||
|
||||
// 重新解析「全部」KV namespace id(冪等:已存在則重用),不只 config 存的兩個。
|
||||
// 壓測 §4.1.3:舊版 update 只注入 WEBHOOKS+CREDENTIALS_KV,其餘 6 個注入成空字串 →
|
||||
// 重部署反而可能弄壞需要 RECIPES/EXEC_CONTEXT/... 的 worker。改為與 init 同樣全建妥。
|
||||
const cf = new CfAccountClient(config.cloudflare_account_id, config.cf_api_token);
|
||||
const kvNamespaceIds: Record<string, string> = {};
|
||||
try {
|
||||
const existing = await cf.listKvNamespaces();
|
||||
for (const title of REQUIRED_KV_NAMESPACES) {
|
||||
kvNamespaceIds[title] = await cf.ensureKvNamespace(title, existing);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(`\n ✗ 解析 KV namespace 失敗:${e instanceof Error ? e.message : e}\n`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// D1(KBDB Base)冪等補建——之前只在 init 建,update 漏了,導致「init 時 D1 失敗(如 token 缺權限)
|
||||
// → 補好權限後沒有任何指令會補建 D1」(壓測 2026-06-09:D1 一直建不起來的真根因)。
|
||||
// update 既是「冪等重部署」就該與 init 一致把 D1 也 ensure 上。
|
||||
let d1DatabaseId = '';
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → D1 arcrun-kbdb(冪等)...'));
|
||||
d1DatabaseId = await cf.ensureD1Database('arcrun-kbdb');
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
const em = e instanceof Error ? e.message : String(e);
|
||||
console.log(chalk.yellow(` ⚠ ${em}`));
|
||||
if (/auth/i.test(em)) {
|
||||
console.log(chalk.yellow(' CF token 缺 D1 權限 → 補勾「Account / D1 / Edit」重產 token 填回 .env 再 acr update'));
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Arcrun#97:這裡**曾經**先「照名字 ensure」一輪 KV + D1 再往下傳。
|
||||
// binding 名(WEBHOOKS)被當成 CF 上的資源標題去找,安裝器建的資源不叫那個名字
|
||||
// ⇒ 每次都對不上 ⇒ 每次都新建一顆空的綁上去 ⇒ 使用者的工作流/登入/子庫從畫面上消失。
|
||||
// 現在資源解析整段搬進 downloadAndDeploy:先讀「你已部署的 worker 現在綁著什麼」再決定,
|
||||
// 而且是**下載完、看得到這版要哪些 binding 之後**才決定,不再由這裡預先造一批。
|
||||
const ctx: DeployContext = {
|
||||
accountId: config.cloudflare_account_id,
|
||||
apiToken: config.cf_api_token,
|
||||
workerSubdomain: extractSubdomain(config.cypher_executor_url),
|
||||
kvNamespaceIds,
|
||||
d1DatabaseId: d1DatabaseId || undefined,
|
||||
// self-hosted → 注入 MULTI_TENANT="false"(mcp-account-source §5.5,修 acr update 部署的 MCP 401)。
|
||||
// config 源頭:init 寫 multi_tenant:false + mode:'self-hosted'。acr update 只在 self-hosted 跑。
|
||||
selfHosted: config.mode === 'self-hosted' || config.multi_tenant === false,
|
||||
@@ -93,7 +64,44 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||||
kbdbEmbed: config.kbdb_embed !== false,
|
||||
};
|
||||
|
||||
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
|
||||
// Arcrun#108:把「你的知識住在哪個命名空間」同步給雲端——但**先驗再寫**。
|
||||
//
|
||||
// 病灶:你 push 工作流、小幫手上傳知識、MCP 查詢,用的都是 config 的 `api_key`;
|
||||
// 而 cypher 讀藏書地圖/搜尋/工作流時,過濾用的 owner_id 來自 worker 的環境變數
|
||||
// (repo toml 帶的官方預設 `CONSOLE_TENANT = "leo"`)。兩個來源對不上 ⇒ 你的東西全被濾掉。
|
||||
//
|
||||
// 為什麼不無條件寫:一鍵安裝的實例,知識可能本來就寫在 `CONSOLE_TENANT` 底下。
|
||||
// 無條件蓋成本機 api_key,會把一台**原本正常**的實例指向空的那一格
|
||||
// ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」。所以查得到才寫,查不到就不碰。
|
||||
if (config.api_key && config.cypher_executor_url) {
|
||||
process.stdout.write(chalk.gray(' → 核對雲端要用哪個知識命名空間...'));
|
||||
const hasKnowledge = await namespaceHasKnowledge(config.cypher_executor_url, config.api_key);
|
||||
if (hasKnowledge === true) {
|
||||
ctx.knowledgeNamespace = config.api_key;
|
||||
console.log(chalk.green(' ✓'));
|
||||
console.log(chalk.gray(` ARCRUN_NAMESPACE = ${config.api_key}(這個命名空間底下查得到你的知識庫)`));
|
||||
} else if (hasKnowledge === false) {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
console.log(chalk.gray(` ${config.api_key} 底下目前查不到任何知識庫 → 這趟不動雲端的命名空間設定`));
|
||||
console.log(chalk.gray(' (若藏書地圖是空的,請把這行連同 acr update 的輸出一起回報)'));
|
||||
} else {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
console.log(chalk.gray(' 問不到實例(可能正在啟動或版本較舊)→ 這趟不動雲端的命名空間設定'));
|
||||
}
|
||||
}
|
||||
|
||||
// mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套
|
||||
//(Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。
|
||||
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' });
|
||||
|
||||
// 資源解析階段喊停:什麼都沒建、什麼都沒部。原文照印,然後非零離開——
|
||||
// 不能混進「部分失敗」的黃字裡帶過(那正是使用者不會發現的那種失敗)。
|
||||
if (result.blocked) {
|
||||
console.log(chalk.yellow('\n ⚠ 更新沒有進行,你的實例維持原樣。\n'));
|
||||
console.log(' ' + result.message.split('\n').join('\n '));
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.implemented) {
|
||||
// message 含部分失敗清單(「部署 X/Y 成功,N 失敗:✗ ...」)——必須印出來,
|
||||
|
||||
+122
-14
@@ -3,6 +3,8 @@
|
||||
* 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI
|
||||
*/
|
||||
|
||||
import type { LiveBinding, ResourceApi, ScriptBindings } from './resource-resolver.js';
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
export interface CfKvClientOptions {
|
||||
@@ -83,7 +85,7 @@ export class CfKvClient {
|
||||
* 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
|
||||
*/
|
||||
export class CfAccountClient {
|
||||
export class CfAccountClient implements ResourceApi {
|
||||
private accountBase: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
@@ -96,6 +98,16 @@ export class CfAccountClient {
|
||||
}
|
||||
|
||||
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const { ok, status, result, error } = await this.cfRaw<T>(path, init);
|
||||
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
/** 同 cf(),但把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 */
|
||||
private async cfRaw<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<{ ok: boolean; status: number; result?: T; error?: string }> {
|
||||
const res = await fetch(`${this.accountBase}${path}`, {
|
||||
...init,
|
||||
headers: { ...this.headers, ...(init?.headers ?? {}) },
|
||||
@@ -104,10 +116,13 @@ export class CfAccountClient {
|
||||
| { success: boolean; result: T; errors?: Array<{ message: string }> }
|
||||
| null;
|
||||
if (!res.ok || !data?.success) {
|
||||
const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`;
|
||||
throw new Error(`CF API ${path} 失敗:${msg}`);
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
error: data?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
return data.result;
|
||||
return { ok: true, status: res.status, result: data.result };
|
||||
}
|
||||
|
||||
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
|
||||
@@ -126,12 +141,16 @@ export class CfAccountClient {
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 建立 KV namespace(若同名已存在則回傳既有 id,冪等)。*/
|
||||
async ensureKvNamespace(title: string, existing?: Map<string, string>): Promise<string> {
|
||||
const known = existing ?? (await this.listKvNamespaces());
|
||||
const found = known.get(title);
|
||||
if (found) return found;
|
||||
|
||||
/**
|
||||
* 無條件新建一顆 KV namespace。
|
||||
*
|
||||
* 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
|
||||
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路
|
||||
* (安裝器取的名字跟我們的 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。
|
||||
* 要不要建,一律先經過 resource-resolver 的 planResources 判斷;那裡只有在
|
||||
* 「確定沒有任何已部署的 worker 綁過這個 binding」時才會排進 create。
|
||||
*/
|
||||
async createKvNamespace(title: string): Promise<string> {
|
||||
const result = await this.cf<{ id: string; title: string }>(
|
||||
'/storage/kv/namespaces',
|
||||
{ method: 'POST', body: JSON.stringify({ title }) },
|
||||
@@ -139,6 +158,27 @@ export class CfAccountClient {
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。
|
||||
* CF:`GET /accounts/{id}/workers/scripts/{script}/settings` → `result.bindings[]`。
|
||||
*
|
||||
* - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。
|
||||
* - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」——
|
||||
* 把查不到當成不存在,就是 #97 的根因。
|
||||
*/
|
||||
async getScriptBindings(script: string): Promise<ScriptBindings> {
|
||||
const path = `/workers/scripts/${encodeURIComponent(script)}/settings`;
|
||||
const res = await this.cfRaw<{ bindings?: RawWorkerBinding[] }>(path);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return { deployed: false, bindings: [], vars: {} };
|
||||
throw new Error(`讀 ${script} 綁定失敗:${res.error}`);
|
||||
}
|
||||
const raw = res.result?.bindings ?? [];
|
||||
// #106:同一份回應裡也帶著 plain_text var(實測 CF `/settings` 會回 `text` 值)。
|
||||
// 舊版只挑資源類、把 var 整批丟掉 → 重部署等於把它們洗掉。
|
||||
return { deployed: true, bindings: normalizeBindings(raw), vars: normalizeVars(raw) };
|
||||
}
|
||||
|
||||
/** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
|
||||
async getWorkersSubdomain(): Promise<string> {
|
||||
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
|
||||
@@ -153,14 +193,82 @@ export class CfAccountClient {
|
||||
return map;
|
||||
}
|
||||
|
||||
async ensureD1Database(name: string, existing?: Map<string, string>): Promise<string> {
|
||||
const known = existing ?? (await this.listD1Databases());
|
||||
const found = known.get(name);
|
||||
if (found) return found;
|
||||
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。 */
|
||||
async createD1Database(name: string): Promise<string> {
|
||||
const result = await this.cf<{ uuid: string; name: string }>(
|
||||
'/d1/database',
|
||||
{ method: 'POST', body: JSON.stringify({ name }) },
|
||||
);
|
||||
return result.uuid;
|
||||
}
|
||||
|
||||
/** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */
|
||||
async listVectorizeIndexes(): Promise<string[]> {
|
||||
const result = await this.cf<Array<{ name: string }>>('/vectorize/v2/indexes');
|
||||
return (result ?? []).map(i => i.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建 KBDB embed 用的 Vectorize index(**bge-m3 = 1024 維 / cosine**,見 deploy.ts 常數說明)。
|
||||
* 已存在(409 / already exists)視為成功——並行或重跑不該炸。沒有 ensure 版本:
|
||||
* 「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。
|
||||
*/
|
||||
async createVectorizeIndex(name: string): Promise<string> {
|
||||
const res = await this.cfRaw<{ name: string }>('/vectorize/v2/indexes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
config: { dimensions: 1024, metric: 'cosine' },
|
||||
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
|
||||
}),
|
||||
});
|
||||
if (res.ok) return name;
|
||||
const detail = (res.error ?? '').toLowerCase();
|
||||
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
|
||||
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** CF `/settings` 回的 binding 原始形狀(同一種資源在不同 API 版本欄位名不一,故全都收)。 */
|
||||
interface RawWorkerBinding {
|
||||
type?: string;
|
||||
name?: string;
|
||||
namespace_id?: string;
|
||||
id?: string;
|
||||
database_id?: string;
|
||||
index_name?: string;
|
||||
/** `plain_text` 綁定的值(#106;secret_text 不會回值,本來就讀不到,也不該讀)。 */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 抽出已部署 worker 上的 `plain_text` var(#106)。
|
||||
*
|
||||
* 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被 CLI 搬來搬去;
|
||||
* wrangler deploy 不會動 secret,它們自己會留著)。
|
||||
*/
|
||||
function normalizeVars(raw: RawWorkerBinding[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const b of raw) {
|
||||
if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */
|
||||
function normalizeBindings(raw: RawWorkerBinding[]): LiveBinding[] {
|
||||
const out: LiveBinding[] = [];
|
||||
for (const b of raw) {
|
||||
if (!b?.name) continue;
|
||||
if (b.type === 'kv_namespace') {
|
||||
const value = b.namespace_id ?? b.id;
|
||||
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
|
||||
} else if (b.type === 'd1' || b.type === 'd1_database') {
|
||||
const value = b.id ?? b.database_id;
|
||||
if (value) out.push({ kind: 'd1', binding: b.name, value });
|
||||
} else if (b.type === 'vectorize') {
|
||||
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+542
-70
@@ -20,6 +20,19 @@ import { tmpdir, homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import chalk from 'chalk';
|
||||
import { CfAccountClient } from './cf-api.js';
|
||||
import {
|
||||
applyResourcePlan,
|
||||
bindingKey,
|
||||
parseWranglerRequirements,
|
||||
planResources,
|
||||
ResourcePlanBlocked,
|
||||
TABLE_KIND,
|
||||
type BindingRequirement,
|
||||
type ResourceApi,
|
||||
type ResourceKind,
|
||||
type ResolvedResource,
|
||||
} from './resource-resolver.js';
|
||||
|
||||
/** 部署狀態 manifest:記錄上次成功部署每個 worker 的內容指紋(content hash),
|
||||
* 讓 acr update 跳過未變動的 worker(壓測 2026-06-12:22/23 成功後重跑仍全部
|
||||
@@ -85,6 +98,119 @@ function giteaToken(): string | undefined {
|
||||
return process.env.ARCRUN_GITEA_TOKEN || process.env.GITEA_TOKEN || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本標籤的「發行頻道」來源(Arcrun#106)。
|
||||
*
|
||||
* Portal 設定頁與 daemon `cloudVersionStale()` 都是拿**這支**回的 `release` 當「最新版」,
|
||||
* 再跟實例 `/health` 的 `bundle_version` 比。CLI 更新完若不烙一個同一把尺量得出來的版號,
|
||||
* 使用者就只會看到「無法讀取目前版本」或永遠「落後」。
|
||||
* fork/自架另有發行頻道者用 ARCRUN_RELEASE_API 覆蓋,不寫死。
|
||||
*/
|
||||
const ARCRUN_RELEASE_API = process.env.ARCRUN_RELEASE_API ?? 'https://install.arcrun.dev/api/latest';
|
||||
|
||||
/** CLI 自己負責注入 / 自己烙的 var——**不從已部署的 worker 沿用**(沿用會蓋掉這趟算出來的正解)。 */
|
||||
export const CLI_MANAGED_VARS = [
|
||||
'WORKER_SUBDOMAIN', // 由 ctx.workerSubdomain 注入
|
||||
'CF_ACCOUNT_ID', // 由 ctx.accountId 注入
|
||||
'MULTI_TENANT', // 由 selfHosted 注入
|
||||
'KBDB_BASE_URL', // 由 workerSubdomain 組
|
||||
'ARCRUN_BUNDLE_VERSION', // 版本標籤:每趟重烙,**絕不沿用舊值**(見 resolveBundleStamp)
|
||||
'ARCRUN_BUNDLE_COMMIT',
|
||||
] as const;
|
||||
|
||||
/** 烙版本標籤的那顆 worker(`/health` 就是它吐的)。其餘 worker 不需要版本標籤。 */
|
||||
export const VERSION_STAMP_WORKER = 'arcrun-cypher-executor';
|
||||
|
||||
/** 這趟部署要烙上去的版本標籤。 */
|
||||
export interface BundleStamp {
|
||||
/** 寫進 `ARCRUN_BUNDLE_VERSION`。 */
|
||||
version: string;
|
||||
/** 寫進 `ARCRUN_BUNDLE_COMMIT`(查得到才有)。 */
|
||||
commit?: string;
|
||||
/** 給人看的一句話(CLI 會印出來),說明這個版號是怎麼來的。 */
|
||||
note: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 算「這趟部署上去的東西,該叫幾版」(Arcrun#106)。
|
||||
*
|
||||
* 🔴 為什麼**不是沿用實例上原本那個值**:那個值描述的是**當時裝上去的那份程式碼**。
|
||||
* 更新完程式碼換了,標籤沒換 = 一個永遠停在安裝當天的假標籤——比沒有標籤更糟,
|
||||
* 因為 leo 會拿它當「我驗收過了」。版本標籤是**成品的屬性**,不是使用者的設定,
|
||||
* 所以它是唯一一個「不沿用、每趟重烙」的 var(其餘 plain_text var 一律沿用,見 preservedVars)。
|
||||
*
|
||||
* 誠實邊界(mindset §7,這段要留著):
|
||||
* - CLI 部的是 `ARCRUN_REPO@ref` 的**原始碼**,發行版號(semver)是**安裝器頻道**在發的,
|
||||
* 兩者不是同一套編號。這裡取的是「部署當下該頻道公告的 release」,
|
||||
* 語義=「我跟這個頻道的最新發行同源」,並**另外把真正的 commit 一起烙上去**
|
||||
* (`ARCRUN_BUNDLE_COMMIT`/`/health` 的 `bundle_commit`)→ 有沒有漂掉,看 commit 就查得出來。
|
||||
* - 查不到 release(離線/頻道掛了)→ **不猜、不掰**,退成 `YYYY-MM-DD+<commit7>` 這個
|
||||
* 舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」——
|
||||
* 那正是我們想要的:**寧可說不準,也不要假裝已是最新**。
|
||||
*/
|
||||
export async function resolveBundleStamp(
|
||||
ref: string,
|
||||
commit?: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<BundleStamp> {
|
||||
const short = commit ? commit.slice(0, 7) : ref;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const res = await fetchImpl(ARCRUN_RELEASE_API, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { release?: string } | null;
|
||||
const release = String(body?.release ?? '').trim();
|
||||
if (!/^\d+\.\d+\.\d+$/.test(release)) throw new Error(`發行頻道回的版號不是 semver(${release || '空'})`);
|
||||
return {
|
||||
version: release,
|
||||
commit,
|
||||
note: `${release}(發行頻道 ${ARCRUN_RELEASE_API}${commit ? `;實際部署 commit ${short}` : ''})`,
|
||||
};
|
||||
} catch (e) {
|
||||
const version = `${today}+${short}`;
|
||||
return {
|
||||
version,
|
||||
commit,
|
||||
note:
|
||||
`${version}(查不到發行版號:${e instanceof Error ? e.message : String(e)})` +
|
||||
`\n → 誠實標成 commit 版;Portal 會顯示成「較舊版本」而不是假裝已是最新。`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `ref`(branch / tag / sha)解析成確切的 commit sha(Arcrun#106)。
|
||||
*
|
||||
* 兩個用途:① 版本標籤要烙「真的部了哪個 commit」;② 解出來之後**直接用 sha 下載 archive**——
|
||||
* sha 是不可變的,順帶把 #13 P2 的「branch tarball 被中間層快取成舊的」整個病根拿掉。
|
||||
* 查不到就回 undefined(呼叫端退回原本的用 ref 下載,行為不變)——這條路徑不該讓更新失敗。
|
||||
*/
|
||||
export async function resolveGiteaCommit(
|
||||
ref: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<string | undefined> {
|
||||
const headers = buildDownloadHeaders();
|
||||
const tryUrls = [
|
||||
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/branches/${encodeURIComponent(ref)}`,
|
||||
`${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/commits?sha=${encodeURIComponent(ref)}&limit=1&stat=false`,
|
||||
];
|
||||
for (const url of tryUrls) {
|
||||
try {
|
||||
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(20_000) });
|
||||
if (!res.ok) continue;
|
||||
const body = (await res.json()) as
|
||||
| { commit?: { id?: string } }
|
||||
| Array<{ sha?: string }>
|
||||
| null;
|
||||
const sha = Array.isArray(body) ? body[0]?.sha : body?.commit?.id;
|
||||
if (typeof sha === 'string' && /^[0-9a-f]{7,64}$/i.test(sha)) return sha;
|
||||
} catch {
|
||||
/* 換下一種問法;全都問不到就回 undefined */
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 組 Gitea archive 下載 URL(純函式,好離線測 URL 組裝)。
|
||||
* Gitea archive API:`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。
|
||||
@@ -107,7 +233,15 @@ export function buildDownloadHeaders(token = giteaToken()): Record<string, strin
|
||||
}
|
||||
|
||||
/**
|
||||
* init 要建立的 KV namespace(title)。
|
||||
* arcrun 各 worker 會用到的 KV **binding 名**清單。
|
||||
*
|
||||
* 🔴 Arcrun#97 之後,這份清單**不再是「要去 CF 上建的資源標題」**——
|
||||
* 真正要哪些綁定,是部署當下從每份 wrangler.toml 讀出來的(parseWranglerRequirements),
|
||||
* 要不要建則由 resource-resolver 依「已部署的 worker 綁著什麼」決定。
|
||||
* 這裡保留成一份**文件與離線測試用的期望清單**(測試會比對 toml 沒有漏綁),
|
||||
* 不再被任何執行路徑拿去「照名字 ensure」。
|
||||
*
|
||||
* 原始出處保留如下:
|
||||
* 前 7 個權威來源:.claude/rules/01-tech-stack.md 資料儲存表(cypher-executor 用)。
|
||||
* SUBMISSIONS_KV:registry worker 用(component 投稿)。漏建會讓 registry deploy 失敗 →
|
||||
* 壓測 §2.6/#11「20/21」根因(registry/wrangler.toml 綁 SUBMISSIONS_KV,但注入清單沒有它,
|
||||
@@ -151,8 +285,11 @@ export interface DeployContext {
|
||||
accountId: string;
|
||||
apiToken: string;
|
||||
workerSubdomain: string;
|
||||
kvNamespaceIds: Record<string, string>; // title → id
|
||||
d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml
|
||||
/** binding → KV namespace id。**由 downloadAndDeploy 內部的資源解析填入,呼叫端不要自己給**
|
||||
* (Arcrun#97:呼叫端「照名字 ensure 一輪再傳進來」正是把使用者實例洗空的那條路)。*/
|
||||
kvNamespaceIds?: Record<string, string>;
|
||||
/** KBDB Base D1 id;同上,由資源解析填入。*/
|
||||
d1DatabaseId?: string;
|
||||
// self-hosted 單租戶旗標。true(self-hosted)→ 注入 MULTI_TENANT="false" 到 worker [vars],
|
||||
// 讓 MCP partner-auth 走 namespace 明碼分支(mcp-account-source §5.5)。
|
||||
// 未設 / false → 不注入(官方 SaaS 多租戶,行為不變)。
|
||||
@@ -161,6 +298,44 @@ export interface DeployContext {
|
||||
// [[vectorize]]+[ai] binding(取消 wrangler.toml 註解段)→ embed 模組啟用。未設/false → 不建、不注入,
|
||||
// base 維持 LIKE keyword(free-tier 友善)。
|
||||
kbdbEmbed?: boolean;
|
||||
/**
|
||||
* Arcrun#108:這台實例的知識命名空間(=`~/.arcrun/config.yaml` 的 `api_key`),
|
||||
* 會寫進 cypher worker 的 `ARCRUN_NAMESPACE` var,讓「讀」用的 owner_id 與「寫」的一致。
|
||||
*
|
||||
* **只在驗證過該 namespace 底下真的有知識時才給值**(見 `resolveKnowledgeNamespace`)——
|
||||
* 給了就會覆蓋 worker 上的既有值,沒給則原封保留(preservedVars)。
|
||||
*/
|
||||
knowledgeNamespace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 這把 namespace 底下到底有沒有知識?(Arcrun#108 的「先驗再寫」)
|
||||
*
|
||||
* 打的是實例自己的 `GET /kbdb/map?owner_id=<ns>`(cypher 既有的純轉發端點,CLI 平常就在用
|
||||
* 這條路 + `X-Arcrun-API-Key`)。回傳:
|
||||
* true = 這個 namespace 底下查得到庫 → 寫 ARCRUN_NAMESPACE 是安全的
|
||||
* false = 查得到但是空的 → 不寫(可能知識其實在別的命名空間,蓋下去會把畫面弄空)
|
||||
* null = 問不到(實例還沒起來 / 舊版沒這條路 / 網路斷)→ 不寫,也不宣稱任何事
|
||||
*
|
||||
* 誠實邊界:這支只回答「有沒有」,不猜「應該是哪一個」。猜錯的代價是把人家的資料藏起來。
|
||||
*/
|
||||
export async function namespaceHasKnowledge(
|
||||
cypherUrl: string,
|
||||
namespace: string,
|
||||
): Promise<boolean | null> {
|
||||
if (!cypherUrl || !namespace) return null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${cypherUrl.replace(/\/+$/, '')}/kbdb/map?owner_id=${encodeURIComponent(namespace)}`,
|
||||
{ headers: { 'X-Arcrun-API-Key': namespace } },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { libraries?: unknown } | null;
|
||||
if (!body || !Array.isArray(body.libraries)) return null;
|
||||
return body.libraries.length > 0;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,6 +365,11 @@ export interface DeployResult {
|
||||
cypherExecutorUrl?: string;
|
||||
mcpUrl?: string; // self-hosted 自己的 MCP worker URL(mcp-account-source §3)
|
||||
message: string;
|
||||
/** true = 資源解析階段就喊停(Arcrun#97),**一顆資源沒建、一個 worker 沒部**。
|
||||
* 呼叫端要以非零結束並把 message 原文印出來,不要當成一般部分失敗帶過。*/
|
||||
blocked?: boolean;
|
||||
/** 這趟實際用上的資源(沿用/新建各是哪一顆)。呼叫端寫 config 用這個,不要自己再查一次。*/
|
||||
resources?: Map<string, ResolvedResource>;
|
||||
}
|
||||
|
||||
/** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/
|
||||
@@ -219,12 +399,17 @@ export function wranglerAvailable(): boolean {
|
||||
export async function downloadAndDeploy(
|
||||
ctx: DeployContext,
|
||||
ref = 'main',
|
||||
opts: { force?: boolean } = {},
|
||||
opts: { force?: boolean; mode?: 'init' | 'update'; api?: ResourceApi } = {},
|
||||
): Promise<DeployResult> {
|
||||
const mode = opts.mode ?? 'update';
|
||||
const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
|
||||
// 1. 下載 + 解壓 Gitea archive tarball
|
||||
// #106:先把 ref 解析成確切 commit,**用 sha 下載**(不可變 → 順帶解掉 branch tarball 被快取的老問題),
|
||||
// 同一個 sha 稍後也會被烙成版本標籤。解不出來就照舊用 ref 下載(行為不變)。
|
||||
const commit = await resolveGiteaCommit(ref);
|
||||
let root: string;
|
||||
try {
|
||||
root = await downloadRepoTarball(ref);
|
||||
root = await downloadRepoTarball(commit ?? ref, commit ? ref : undefined);
|
||||
} catch (e) {
|
||||
return {
|
||||
implemented: true,
|
||||
@@ -262,28 +447,184 @@ export async function downloadAndDeploy(
|
||||
}
|
||||
|
||||
const failures: string[] = [];
|
||||
const allDirs = [...tier1, ...tier2];
|
||||
|
||||
// 2.6 語義查詢(issue #7 / T2.4):開 kbdb_embed → 先確保 Vectorize index 存在(REST,冪等),
|
||||
// 再由 injectWranglerConfig 取消 kbdb toml 的 [[vectorize]]+[ai] 註解 → embed 模組上線。
|
||||
// 失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
|
||||
if (ctx.kbdbEmbed) {
|
||||
// ── 2.6 資源解析:先看「這些 worker 現在綁著什麼」,再決定沿用還是新建(Arcrun#97)──────
|
||||
//
|
||||
// 🔴 這一段取代了舊的「照名字 ensure 一輪 KV/D1/Vectorize 再注入」。
|
||||
// 舊做法用 binding 名當資源標題去找,對不上就新建一顆空的綁上去——
|
||||
// 安裝器建的資源本來就不叫那個名字,於是**每次更新都對不上、每次都新建**:
|
||||
// 2026-08-12 一次更新生了 9 顆 KV + 1 顆 D1,使用者的工作流/登入/子庫全部從畫面上消失。
|
||||
//
|
||||
// 現在:已部署 worker 上的綁定=事實,原樣沿用;只有「確定沒人綁過」才建;
|
||||
// 任何說不準的情況(讀不到綁定/綁著的資源不見了/同名綁定指向兩顆/一顆 worker 都找不到)
|
||||
// → 整趟停手,**在動任何東西之前**。
|
||||
//
|
||||
// 需求是從「注入後的 toml」解析的(renderWranglerToml 帶空 map 當預覽),
|
||||
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
|
||||
const requirements: BindingRequirement[] = [];
|
||||
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
|
||||
const dirScript = new Map<string, string>(); // dir → worker script 名(#106:var 沿用要逐顆對號)
|
||||
for (const dir of allDirs) {
|
||||
const tomlPath = join(dir, 'wrangler.toml');
|
||||
if (!existsSync(tomlPath)) continue;
|
||||
const raw = readFileSync(tomlPath, 'utf8');
|
||||
tomlPreviews.set(dir, raw);
|
||||
const preview = renderWranglerToml(raw, ctx, new Map());
|
||||
const parsed = parseWranglerRequirements(preview);
|
||||
if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜
|
||||
dirScript.set(dir, parsed.script);
|
||||
for (const b of parsed.bindings) {
|
||||
requirements.push({ ...b, worker: parsed.script });
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = new Map<string, ResolvedResource>();
|
||||
let liveVars = new Map<string, Record<string, string>>();
|
||||
if (requirements.length > 0) {
|
||||
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
|
||||
let plan;
|
||||
try {
|
||||
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
|
||||
await ensureVectorizeIndex(ctx);
|
||||
// Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建
|
||||
// metadata index,否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。
|
||||
await ensureVectorizeMetadataIndexes(ctx);
|
||||
plan = await planResources(api, requirements, mode);
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ✗'));
|
||||
return {
|
||||
implemented: true,
|
||||
blocked: true,
|
||||
message:
|
||||
`資源解析失敗(${e instanceof Error ? e.message : String(e)})。\n` +
|
||||
`沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
|
||||
};
|
||||
}
|
||||
if (plan.blockers.length > 0) {
|
||||
console.log(chalk.yellow(' ✗'));
|
||||
return {
|
||||
implemented: true,
|
||||
blocked: true,
|
||||
message:
|
||||
`停手:有 ${plan.blockers.length} 件事我不敢自己決定。\n` +
|
||||
plan.blockers.map((b) => ` • ${b}`).join('\n') +
|
||||
`\n\n沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
resolved = await applyResourcePlan(api, plan);
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ✗'));
|
||||
const raw = e instanceof Error ? e.message : String(e);
|
||||
const detail = e instanceof ResourcePlanBlocked
|
||||
? e.blockers.map((b) => ` • ${b}`).join('\n')
|
||||
: ` • ${raw}`;
|
||||
// D1 建不起來最常見的根因是 token 沒勾 D1 權限(KV/Worker 建得起來、只有 D1 報 auth error)。
|
||||
// 這句提示在改版前就有,別隨著搬家弄丟——它是使用者唯一能自己解掉的那個錯。
|
||||
const hint = /d1/i.test(raw) && /auth/i.test(raw)
|
||||
? '\n → CF token 缺 D1 權限:補勾「Account / D1 / Edit」重產 token 填回 .env 再跑一次。'
|
||||
: '';
|
||||
return {
|
||||
implemented: true,
|
||||
blocked: true,
|
||||
message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
|
||||
};
|
||||
}
|
||||
liveVars = plan.liveVars;
|
||||
console.log(chalk.green(' ✓'));
|
||||
const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
|
||||
const created = [...resolved.values()].filter((r) => r.origin === 'created');
|
||||
if (adopted.length > 0) {
|
||||
console.log(chalk.gray(` 沿用你既有的 ${adopted.length} 個資源(不論它們叫什麼名字):`));
|
||||
for (const r of adopted) console.log(chalk.gray(` = ${r.binding} → ${r.value}(讀自 ${r.from})`));
|
||||
}
|
||||
if (created.length > 0) {
|
||||
console.log(chalk.yellow(` 新建 ${created.length} 個(目前沒有任何已部署的 worker 綁著它們):`));
|
||||
for (const r of created) console.log(chalk.yellow(` + ${r.binding} → ${r.value}`));
|
||||
}
|
||||
}
|
||||
|
||||
// 解析結果回填 ctx,供 applyD1Migration / 呼叫端寫 config 使用。
|
||||
// KBDB 的 migration 打 kbdb worker 的 `DB`;沒有它才退回 cypher 的 `CREDENTIALS_DB`(同一顆庫)。
|
||||
ctx.kvNamespaceIds = Object.fromEntries(
|
||||
[...resolved.values()].filter((r) => r.kind === 'kv_namespace').map((r) => [r.binding, r.value]),
|
||||
);
|
||||
ctx.d1DatabaseId =
|
||||
resolved.get(bindingKey('d1', 'DB'))?.value
|
||||
?? resolved.get(bindingKey('d1', 'CREDENTIALS_DB'))?.value;
|
||||
|
||||
// 2.7 語義查詢(issue #7 / T2.4):index 本體已由上面的資源解析處理(沿用既有 / 需要才新建)。
|
||||
// 這裡只補 metadata index——Vectorize 要 filter 某欄位必須先為該欄建 index,
|
||||
// 否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0 命中(Arcrun#11 根因)。
|
||||
// 冪等;失敗不致命(收進 failures,base 仍可部署、維持 keyword)。
|
||||
const vectorizeIndex = resolved.get(bindingKey('vectorize', 'VECTORIZE'))?.value;
|
||||
if (vectorizeIndex) {
|
||||
try {
|
||||
process.stdout.write(chalk.gray(` → 語義查詢 metadata index(${vectorizeIndex})...`));
|
||||
await ensureVectorizeMetadataIndexes(ctx, vectorizeIndex);
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
failures.push(`Vectorize metadata index (${vectorizeIndex}): ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2.8 var(plain_text):既有的沿用、版本標籤重烙(Arcrun#106)─────────────────
|
||||
//
|
||||
// 🔴 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),但 **var 這批「櫃子上的標籤」沒人管**:
|
||||
// wrangler deploy 是整份覆蓋,toml 沒寫的 var 直接消失。leo 2026-08-12 實撞的畫面
|
||||
// 「無法讀取目前版本(知識庫服務可能正在啟動)」就是 `ARCRUN_BUNDLE_VERSION` 被這樣洗掉的。
|
||||
//
|
||||
// 兩種 var 走**相反**的規則,這是本次的核心判斷:
|
||||
// · 設定類(PORTAL_MAIL_RELAY_BASE / CONSOLE_TENANT / …)=**使用者實例的事實** → 沿用
|
||||
// · 版本標籤(ARCRUN_BUNDLE_VERSION)=**這份成品的屬性** → 每趟重烙,沿用舊值就是假標籤
|
||||
//
|
||||
// 範圍註記:`liveVars` 來自資源解析那一趟讀到的 worker(=有資源綁定的那些:cypher/kbdb/mcp/registry)。
|
||||
// 純零件 worker 沒有資源綁定、不在那份名單裡 → 這裡不會沿用它們的 var。目前它們的 var 只有
|
||||
// toml 自己帶的 `COMPONENT_ID`,沒有東西可丟;若哪天有人往零件 worker 注入設定,要在這裡補讀。
|
||||
const extraVarsByDir = new Map<string, Record<string, string>>();
|
||||
let stamp: BundleStamp | undefined;
|
||||
if (dirScript.size > 0) {
|
||||
const needStamp = [...dirScript.values()].includes(VERSION_STAMP_WORKER);
|
||||
if (needStamp) {
|
||||
process.stdout.write(chalk.gray(' → 算這趟要烙上去的版本標籤...'));
|
||||
stamp = await resolveBundleStamp(ref, commit);
|
||||
console.log(chalk.green(' ✓'));
|
||||
console.log(chalk.gray(` ARCRUN_BUNDLE_VERSION = ${stamp.note}`));
|
||||
}
|
||||
const preservedTotal: string[] = [];
|
||||
for (const [dir, script] of dirScript) {
|
||||
const raw = tomlPreviews.get(dir);
|
||||
if (!raw) continue;
|
||||
const keep = preservedVars(liveVars.get(script), raw);
|
||||
for (const k of Object.keys(keep)) preservedTotal.push(`${script}:${k}`);
|
||||
const vars: Record<string, string> = { ...keep };
|
||||
if (stamp && script === VERSION_STAMP_WORKER) {
|
||||
vars.ARCRUN_BUNDLE_VERSION = stamp.version;
|
||||
if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit;
|
||||
}
|
||||
// Arcrun#108:把「你的知識實際住在哪個命名空間」告訴雲端。
|
||||
//
|
||||
// 為什麼需要:cypher 讀藏書地圖/搜尋/工作流時要用一個 owner_id 去過濾,而它以前拿的是
|
||||
// repo toml 帶的官方預設值(`CONSOLE_TENANT = "leo"`)。寫入端(CLI push、小幫手上傳、
|
||||
// MCP)用的卻是你 `~/.arcrun/config.yaml` 的 `api_key` ⇒ 兩邊對不上就整個空掉
|
||||
//(leo 實撞:1854 條三元組被過濾成 0 個庫)。
|
||||
//
|
||||
// 🔴 **只在「這個 namespace 底下真的查得到知識」時才寫**(呼叫端已先驗過,見
|
||||
// resolveKnowledgeNamespace)。理由是反過來的那個災難:一鍵安裝的實例,知識可能
|
||||
// 本來就寫在 CONSOLE_TENANT 底下;若這裡無條件蓋成本機 api_key,會把一台**原本正常**
|
||||
// 的實例改成指向空的那一格——跟 #97/#106 同一類「更新一次把人家的東西弄不見」。
|
||||
// 驗不過就不寫;既有值由 preservedVars 原封保留,等於這趟什麼都沒改。
|
||||
if (ctx.knowledgeNamespace && script === VERSION_STAMP_WORKER) {
|
||||
vars.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
|
||||
}
|
||||
if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars);
|
||||
}
|
||||
if (preservedTotal.length > 0) {
|
||||
console.log(chalk.gray(` 沿用你實例上既有的 ${preservedTotal.length} 個設定值(var):`));
|
||||
for (const item of preservedTotal) console.log(chalk.gray(` = ${item}`));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
|
||||
// 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住——
|
||||
// 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。
|
||||
const allDirs = [...tier1, ...tier2];
|
||||
let deployed = 0;
|
||||
let skipped = 0;
|
||||
// 內容指紋 manifest:未變動且上次成功的 worker 跳過(key 用 worker 名,不用 temp 絕對路徑)。
|
||||
@@ -296,7 +637,7 @@ export async function downloadAndDeploy(
|
||||
const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
|
||||
process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`));
|
||||
try {
|
||||
injectWranglerConfig(tomlPath, ctx);
|
||||
injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir), extraVarsByDir.get(dir));
|
||||
// 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。
|
||||
const hash = dirContentHash(dir, ctx.accountId);
|
||||
if (manifest[label] === hash) {
|
||||
@@ -434,35 +775,6 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 確保 KBDB embed 用的 Vectorize index 存在(issue #7 / T2.4)。
|
||||
* REST `POST /accounts/{id}/vectorize/v2/indexes`(dimensions=1024 / metric=cosine,對齊 bge-m3)。
|
||||
* ⚠️ 這行別寫成 `**dimensions=1024**/metric`——`*` 緊接 `/` 會提早關掉 block comment(實撞 TS1127)。
|
||||
* 維度必須與 `kbdb/src/embed.ts` 的 `DEFAULT_EMBED_MODEL` 一致——不一致時 upsert 直接被 CF 拒絕。
|
||||
* 冪等:已存在(CF 回「already exists」類錯)視為成功,不報錯。用 init 已驗的 apiToken+accountId。
|
||||
*/
|
||||
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: KBDB_VECTORIZE_INDEX,
|
||||
config: { dimensions: 1024, metric: 'cosine' },
|
||||
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
|
||||
}),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
if (res.ok) return;
|
||||
// 冪等:已存在 → 視為成功(CF 回 409 或 errors 含 already exists / duplicate)。
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
|
||||
| null;
|
||||
const msg = (json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`).toLowerCase();
|
||||
if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) return;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */
|
||||
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const;
|
||||
|
||||
@@ -471,9 +783,12 @@ export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] a
|
||||
* Vectorize v2:要對某 metadata 欄位下 filter,必須先為該欄建 metadata index,否則帶過濾的語意查詢一律回 0。
|
||||
* REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`(indexType=string)。
|
||||
* 冪等:已存在(409 / already exists)視為成功。async 生效(建立後才 upsert 的向量才會被收錄 → 既有向量另需 reindex)。
|
||||
*
|
||||
* 🔴 index 名由呼叫端傳入(= 資源解析沿用到的那顆),**不是**寫死 KBDB_VECTORIZE_INDEX:
|
||||
* 使用者實例上那顆 index 叫什麼是他那側的事實,我們把 metadata index 建到「他真的在用的那顆」上。
|
||||
*/
|
||||
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/metadata_index/create`;
|
||||
async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: string): Promise<void> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/create`;
|
||||
for (const propertyName of KBDB_VECTORIZE_META_FIELDS) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -499,11 +814,13 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void>
|
||||
* 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過快取,每次抓到 ref 的最新內容。
|
||||
*
|
||||
* Arcrun#4:來源由 GitHub codeload 改為 Gitea archive API(走 GITEA_TOKEN,不寫死)。*/
|
||||
async function downloadRepoTarball(ref: string): Promise<string> {
|
||||
async function downloadRepoTarball(ref: string, fromRef?: string): Promise<string> {
|
||||
// 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。
|
||||
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const url = buildArchiveUrl(ref, bust);
|
||||
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${ref},約 10–30 秒,視網速)...`));
|
||||
// fromRef 有值 = ref 已被解析成 commit sha(#106),印出來讓人看得到「這趟到底部了哪個 commit」。
|
||||
const label = fromRef ? `${fromRef} → ${ref.slice(0, 7)}` : ref;
|
||||
console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${label},約 10–30 秒,視網速)...`));
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
// 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball(#13 P2 假綠根因)。
|
||||
@@ -596,21 +913,115 @@ export function discoverWorkerDirs(root: string): { tier1: string[]; tier2: stri
|
||||
* - 每個 worker toml 都有 `workers_dev = true` → strip routes 後純靠 workers.dev URL,自架可達。
|
||||
* - R2(`[[r2_buckets]]`)是 dead storage(registry-canon Phase 1.5),且綁卡違背開源免費 → 一併移除。
|
||||
*/
|
||||
function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
function injectWranglerConfig(
|
||||
tomlPath: string,
|
||||
ctx: DeployContext,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
original?: string,
|
||||
extraVars: Record<string, string> = {},
|
||||
): void {
|
||||
if (!existsSync(tomlPath)) return;
|
||||
let toml = readFileSync(tomlPath, 'utf8');
|
||||
// original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。
|
||||
const toml = original ?? readFileSync(tomlPath, 'utf8');
|
||||
writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved, extraVars), 'utf8');
|
||||
}
|
||||
|
||||
// 對每個已建立的 KV namespace:把對應 binding 的 id 換成用戶的。
|
||||
// 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。
|
||||
for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) {
|
||||
if (!id) continue;
|
||||
const re = new RegExp(
|
||||
`(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`,
|
||||
'g',
|
||||
);
|
||||
toml = toml.replace(re, `$1${id}$2`);
|
||||
/**
|
||||
* 挑出「這顆已部署的 worker 上有、但這版 toml 不會自己帶的」plain_text var(Arcrun#106)。
|
||||
*
|
||||
* 規則就一句:**已部署 worker 上掛著什麼 var,那就是事實**(#97 對資源講的那句話,
|
||||
* 原封不動套用在標籤上)。所以預設全部沿用,只有兩種例外:
|
||||
* ① `CLI_MANAGED_VARS`——這趟由 CLI 自己算(帳號 id/subdomain/單租戶旗標/版本標籤),
|
||||
* 沿用等於拿舊值蓋掉正解。
|
||||
* ② 值一模一樣的(toml 已經寫了同樣的值)——寫進去只是雜訊,略過。
|
||||
*
|
||||
* ⚠️ 這裡刻意**不**做「toml 有宣告就以 toml 為準」:那正是這次的病
|
||||
* ——repo toml 裡的 `CONSOLE_TENANT = "leo"`/`WORKER_SUBDOMAIN` 之類是**官方 prod 的值**,
|
||||
* 拿它蓋掉使用者實例上的值,就是「更新一次把人家的設定洗成官方預設」。
|
||||
*/
|
||||
export function preservedVars(
|
||||
live: Record<string, string> | undefined,
|
||||
toml: string,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!live) return out;
|
||||
const managed = new Set<string>(CLI_MANAGED_VARS);
|
||||
for (const key of Object.keys(live).sort()) {
|
||||
if (managed.has(key)) continue;
|
||||
if (!/^[A-Za-z0-9_]+$/.test(key)) continue; // 怪名字不碰(applyVars 也會擋,這裡先濾掉不誤報)
|
||||
if (readVar(toml, key) === live[key]) continue; // toml 已經是同一個值 → 不必動
|
||||
out[key] = live[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 讀 toml 裡某個 var 目前的值(只看未註解的行)。找不到回 undefined。 */
|
||||
function readVar(toml: string, key: string): string | undefined {
|
||||
const m = toml.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm'));
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
/** TOML basic string 轉義(值裡可能有引號/反斜線,例如網址或 JSON 片段)。 */
|
||||
function tomlEscape(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一組 var 寫進 toml 的 `[vars]`(Arcrun#106)。純函式。
|
||||
*
|
||||
* 三種既有狀態各自處理(比照 injectMultiTenant,同一種文字操作層級):
|
||||
* 1. 已有未註解的同名行 → 換值
|
||||
* 2. 只有被註解掉的同名行 → 取消註解並填值
|
||||
* 3. 都沒有 → 插在 `[vars]` header 下一行;連 `[vars]` 都沒有就在檔尾新開一段
|
||||
*/
|
||||
export function applyVars(toml: string, vars: Record<string, string>): string {
|
||||
let out = toml;
|
||||
for (const key of Object.keys(vars).sort()) {
|
||||
// 只接受合法的 var 名(CF 那側本來就是這個字集)。怪名字寧可不寫,也不要拿它去組正規式。
|
||||
if (!/^[A-Za-z0-9_]+$/.test(key)) continue;
|
||||
const value = tomlEscape(vars[key]);
|
||||
// 🔴 一律用「函式版 replace」:值裡若有 `$&`/`$1` 這種字元,字串版 replace 會把它當成
|
||||
// 反向參照展開,寫出來的就不是使用者那個值了。
|
||||
if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(out)) {
|
||||
out = out.replace(
|
||||
new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(".*)$`, 'm'),
|
||||
(_m, head: string, tail: string) => `${head}${value}${tail}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (new RegExp(`^\\s*#\\s*${key}\\s*=`, 'm').test(out)) {
|
||||
out = out.replace(
|
||||
new RegExp(`^(\\s*)#\\s*${key}\\s*=\\s*"[^"]*"(.*)$`, 'm'),
|
||||
(_m, indent: string, tail: string) => `${indent}${key} = "${value}"${tail}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (/^\s*\[vars\]\s*$/m.test(out)) {
|
||||
out = out.replace(/^(\s*\[vars\]\s*)$/m, (_m, header: string) => `${header}\n${key} = "${value}"`);
|
||||
continue;
|
||||
}
|
||||
out = `${out.replace(/\s*$/, '')}\n\n[vars]\n${key} = "${value}"\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一份 repo 內的 wrangler.toml 轉成「要部到這個用戶帳號上的樣子」。
|
||||
*
|
||||
* 純函式(好離線測、也好當預覽用)。帶空 `resolved` 呼叫 = 預覽:得到的是
|
||||
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
|
||||
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
|
||||
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
|
||||
*
|
||||
* `extraVars`(Arcrun#106):這顆 worker 要**沿用的既有 var** + 這趟要**重烙的版本標籤**。
|
||||
* 預覽時不傳(vars 不影響資源需求解析,傳不傳都是同一份需求清單)。
|
||||
*/
|
||||
export function renderWranglerToml(
|
||||
toml: string,
|
||||
ctx: DeployContext,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
extraVars: Record<string, string> = {},
|
||||
): string {
|
||||
// cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain
|
||||
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
|
||||
toml = toml.replace(
|
||||
@@ -629,14 +1040,6 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
// KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml)
|
||||
if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) {
|
||||
toml = toml.replace(
|
||||
/(database_id\s*=\s*")[^"]*(")/,
|
||||
`$1${ctx.d1DatabaseId}$2`,
|
||||
);
|
||||
}
|
||||
|
||||
// self-hosted:注入 MULTI_TENANT="false" 到 [vars](mcp-account-source §5.5)。
|
||||
// 修「部署沒注入 → worker c.env.MULTI_TENANT===undefined → MCP 走 partner-key → 401」。
|
||||
// 只對有 [vars] 的 worker(mcp / cypher-executor)生效;其餘無 [vars] 的不動。
|
||||
@@ -668,7 +1071,76 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
|
||||
}
|
||||
|
||||
writeFileSync(tomlPath, toml, 'utf8');
|
||||
// 沿用的既有 var + 這趟的版本標籤(#106)。**放在所有 CLI 注入之後**:
|
||||
// CLI_MANAGED_VARS 已經在 preservedVars 排除掉,故這裡不會蓋掉上面剛算好的
|
||||
// WORKER_SUBDOMAIN / CF_ACCOUNT_ID / MULTI_TENANT / KBDB_BASE_URL。
|
||||
toml = applyVars(toml, extraVars);
|
||||
|
||||
// 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。
|
||||
// 空 map = 預覽模式,這步什麼也不做。
|
||||
return applyResolvedBindings(toml, resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把解析好的資源 id 寫進對應的 binding 區塊。
|
||||
*
|
||||
* 逐個 `[[table]]` 區塊掃:先在區塊內找 `binding = "X"`,再改同一區塊裡的值欄位
|
||||
* (KV→`id`、D1→`database_id`、Vectorize→`index_name`)。
|
||||
* 🔴 刻意**不用**「全檔第一個 database_id」這種寫法:cypher(`CREDENTIALS_DB`)與
|
||||
* kbdb(`DB`)各有自己的 D1 綁定,盲換會把兩邊當成同一個東西——而使用者的實例
|
||||
* 完全可以兩邊指向不同庫。誰綁誰是使用者那側的事實,我們只是原樣搬過去。
|
||||
*/
|
||||
export function applyResolvedBindings(
|
||||
toml: string,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
): string {
|
||||
if (resolved.size === 0) return toml;
|
||||
|
||||
const VALUE_KEY: Record<ResourceKind, string> = {
|
||||
kv_namespace: 'id',
|
||||
d1: 'database_id',
|
||||
vectorize: 'index_name',
|
||||
};
|
||||
|
||||
const out: string[] = [];
|
||||
let block: string[] = [];
|
||||
let kind: ResourceKind | null = null;
|
||||
|
||||
const flush = (): void => {
|
||||
if (kind) {
|
||||
const binding = block
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => !l.startsWith('#'))
|
||||
.map((l) => l.match(/^binding\s*=\s*"([^"]*)"/)?.[1])
|
||||
.find((b): b is string => !!b);
|
||||
const hit = binding ? resolved.get(bindingKey(kind, binding)) : undefined;
|
||||
if (hit) {
|
||||
const key = VALUE_KEY[kind];
|
||||
const re = new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(")(.*)$`);
|
||||
const at = block.findIndex((l) => !l.trim().startsWith('#') && re.test(l));
|
||||
if (at >= 0) {
|
||||
block[at] = block[at].replace(re, `$1${hit.value}$2$3`);
|
||||
} else {
|
||||
// 區塊裡本來沒有這個欄位(例如新版 toml 只寫 binding)→ 補一行,不要靜默略過。
|
||||
block.push(`${key} = "${hit.value}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(...block);
|
||||
block = [];
|
||||
};
|
||||
|
||||
for (const line of toml.split('\n')) {
|
||||
const table = line.trim().match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
|
||||
if (table) {
|
||||
flush();
|
||||
kind = TABLE_KIND[table[1]] ?? null;
|
||||
}
|
||||
block.push(line);
|
||||
}
|
||||
flush();
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+38
-20
@@ -6,9 +6,11 @@
|
||||
* 不是假設齊備直接動手 → 缺一個就卡(test_arcrun/4 的 D1 大跑去讀原始碼自己想辦法)。
|
||||
* - **裝完驗收**:部署後逐項確認(KV / D1 / migration / cypher 可達),缺哪項明確報哪項
|
||||
* + 給一鍵補裝指令。不是靜默印灰字(原本 harness/MCP 失敗只 console.log 灰字,用戶不知道)。
|
||||
* - **冪等**:重跑檢查後「什麼也沒動」(ensureKvNamespace / ensureD1Database 本就冪等)。
|
||||
* - **冪等**:重跑檢查後「什麼也沒動」。
|
||||
*
|
||||
* 本檔只做「偵測 + 報告」,不自己建資源(建資源仍走 cf-api 的 ensure*,由 init 編排)。
|
||||
* 本檔只做「偵測 + 報告」,不自己建資源(要不要建由 resource-resolver 判斷,deploy.ts 編排)。
|
||||
* 🔴 Arcrun#97:報告裡的 fix 指令也算「產品的一部分」——一句「acr update(冪等重建)」
|
||||
* 接在誤報的「缺 KV」後面,就是把使用者直接推去執行那個把實例洗空的動作。
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -77,42 +79,58 @@ export function printPreflight(title: string, items: PreflightItem[]): void {
|
||||
*/
|
||||
export async function verifyInstall(opts: {
|
||||
cf: CfAccountClient;
|
||||
requiredKv: readonly string[];
|
||||
expectD1Name?: string;
|
||||
/** binding → KV namespace id(部署實際用上的那幾顆)。*/
|
||||
kvNamespaceIds: Record<string, string>;
|
||||
/** 部署實際用上的 D1 id(沒有 D1 就不傳)。*/
|
||||
d1DatabaseId?: string;
|
||||
cypherUrl?: string;
|
||||
}): Promise<{ items: PreflightItem[]; allOk: boolean }> {
|
||||
const items: PreflightItem[] = [];
|
||||
|
||||
// KV:實查 CF 上現有 namespace,比對必需清單
|
||||
// KV:核對「部署實際綁上去的那幾顆 id」在帳號上還在不在。
|
||||
// 🔴 Arcrun#97:這裡**不能**用「帳號上有沒有叫 WEBHOOKS 的 namespace」來驗。
|
||||
// 安裝器裝出來的實例,資源名字是 arcrun-rag-<instance>-kv-webhooks——照名字驗會誤報「缺」,
|
||||
// 而那句誤報底下就寫著「fix: acr update(冪等重建)」⇒ 使用者照做,就被重建成空的。
|
||||
// 驗的對象永遠是 id(我們真的綁上去的那顆),不是名字。
|
||||
const kvBindings = Object.entries(opts.kvNamespaceIds);
|
||||
try {
|
||||
const existing = await opts.cf.listKvNamespaces();
|
||||
const have = new Set(existing.keys());
|
||||
const missing = opts.requiredKv.filter((t) => !have.has(t));
|
||||
const ids = new Set((await opts.cf.listKvNamespaces()).values());
|
||||
const missing = kvBindings.filter(([, id]) => !ids.has(id)).map(([b]) => b);
|
||||
items.push(
|
||||
missing.length === 0
|
||||
? { name: `KV namespaces (${opts.requiredKv.length})`, ok: true }
|
||||
: { name: 'KV namespaces', ok: false, detail: `缺 ${missing.join(', ')}`, fix: 'acr update(冪等重建)' },
|
||||
? { name: `KV namespaces (${kvBindings.length})`, ok: true }
|
||||
: {
|
||||
name: 'KV namespaces',
|
||||
ok: false,
|
||||
detail: `這幾個 binding 綁著的 namespace 在帳號上找不到:${missing.join(', ')}`,
|
||||
fix: '先確認那幾顆是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: 'acr update' });
|
||||
items.push({ name: 'KV namespaces', ok: false, detail: msg(e), fix: '檢查 CF token 的 KV 讀取權限' });
|
||||
}
|
||||
|
||||
// D1:實查 CF 上是否有該庫
|
||||
if (opts.expectD1Name) {
|
||||
// D1:同理,核對實際綁上去的那顆 id 還在不在(不是核對有沒有叫 arcrun-kbdb 的庫)。
|
||||
if (opts.d1DatabaseId) {
|
||||
try {
|
||||
const dbs = await opts.cf.listD1Databases();
|
||||
const ids = new Set((await opts.cf.listD1Databases()).values());
|
||||
items.push(
|
||||
dbs.has(opts.expectD1Name)
|
||||
? { name: `D1 ${opts.expectD1Name}`, ok: true }
|
||||
: { name: `D1 ${opts.expectD1Name}`, ok: false, detail: '不存在', fix: 'CF token 補勾「Account / D1 / Edit」權限 → 重產 token 填回 .env → acr update' },
|
||||
ids.has(opts.d1DatabaseId)
|
||||
? { name: `D1 ${opts.d1DatabaseId}`, ok: true }
|
||||
: {
|
||||
name: `D1 ${opts.d1DatabaseId}`,
|
||||
ok: false,
|
||||
detail: '這顆 D1 在帳號上找不到',
|
||||
fix: '先確認它是被刪了還是 token 看不到——不要直接重跑安裝(會綁到空的)',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// D1 建失敗最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
|
||||
// D1 讀不到最常見根因:CF token 沒勾 D1 權限(KV/Worker 能建但 D1 報 Authentication error)。
|
||||
const m = msg(e);
|
||||
const fix = /auth/i.test(m)
|
||||
? 'token 缺 D1 權限:CF token 補勾「Account / D1 / Edit」→ 重產 token 填回 .env → acr update'
|
||||
: 'acr update(冪等重試)';
|
||||
items.push({ name: `D1 ${opts.expectD1Name}`, ok: false, detail: m, fix });
|
||||
: '檢查 CF token 的 D1 讀取權限';
|
||||
items.push({ name: `D1 ${opts.d1DatabaseId}`, ok: false, detail: m, fix });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* resource-resolver.ts — 資源解析:「已部署的 worker 現在綁著什麼,那就是事實」
|
||||
*
|
||||
* 🔴 Arcrun#97(2026-08-12 實害,leo 的實例中了):
|
||||
* 舊做法叫「照名字 ensure」——`acr update` 拿 **binding 名**(`WEBHOOKS`)當成 Cloudflare 上的
|
||||
* **資源標題**去找,找不到就**新建一顆空的、然後綁到 worker 上**。
|
||||
* 安裝器建的資源不叫那個名字(它叫 `arcrun-rag-<instance>-kv-webhooks`)⇒ 一次例行更新
|
||||
* 新建了 9 顆 KV、1 顆 D1,使用者的工作流/登入狀態/子庫**在畫面上全部消失**。
|
||||
* 資料沒有被刪,但 worker 被綁去空的那幾顆——從使用者的角度,他的東西就是不見了。
|
||||
*
|
||||
* 根因不是「KV 那段寫錯」,是**「用名字猜使用者的資源」這個做法本身**:
|
||||
* 名字是**使用者那側的事實**(安裝器要怎麼取名由它決定,而且它有權改),
|
||||
* 我們不能拿自己的命名慣例去對號入座,更不能在對不上的時候自作主張生一顆新的。
|
||||
* ——所以修法不是「多比對幾種名字」,是**不再用名字當識別**。
|
||||
*
|
||||
* ── 新規則(三句話)────────────────────────────────────────────────
|
||||
* 1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
|
||||
* 2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
|
||||
* 3. **只要有一點說不準就整趟停手**(讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
|
||||
* 該更新的 worker 一顆都不在),**什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
|
||||
*
|
||||
* ── 為什麼拆成 plan / apply 兩段 ─────────────────────────────────────
|
||||
* `planResources()` **完全不寫入**,只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。
|
||||
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。
|
||||
* ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,
|
||||
* 不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。
|
||||
*/
|
||||
|
||||
/** 這支負責的資源種類。要加新種類(R2/Queue/Hyperdrive…)就加在這裡,
|
||||
* 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。 */
|
||||
export type ResourceKind = 'kv_namespace' | 'd1' | 'vectorize';
|
||||
|
||||
/** 從已部署 worker 上讀回來的一條綁定。`value`:KV/D1 是資源 id,Vectorize 是 index 名。 */
|
||||
export interface LiveBinding {
|
||||
kind: ResourceKind;
|
||||
binding: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ScriptBindings {
|
||||
/** false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 */
|
||||
deployed: boolean;
|
||||
bindings: LiveBinding[];
|
||||
/**
|
||||
* 這顆 worker 現在掛著的 `plain_text` var(名 → 值)。
|
||||
*
|
||||
* 🔴 Arcrun#106:#97 只把「資源類」綁定當成事實沿用(KV/D1/Vectorize),
|
||||
* plain_text var 整批沒人管 ⇒ 重部署把它們洗成 repo toml 的預設值。
|
||||
* 最痛的一個是 `ARCRUN_BUNDLE_VERSION`(安裝器注入的版本標籤)——
|
||||
* 更新完就消失,Portal 設定頁變成「無法讀取目前版本」。
|
||||
* **保留了櫃子,沒保留櫃子上的標籤**。這個欄位就是那些標籤。
|
||||
*/
|
||||
vars?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */
|
||||
export interface ResourceApi {
|
||||
getScriptBindings(script: string): Promise<ScriptBindings>;
|
||||
/** title → id */
|
||||
listKvNamespaces(): Promise<Map<string, string>>;
|
||||
/** name → uuid */
|
||||
listD1Databases(): Promise<Map<string, string>>;
|
||||
listVectorizeIndexes(): Promise<string[]>;
|
||||
createKvNamespace(title: string): Promise<string>;
|
||||
createD1Database(name: string): Promise<string>;
|
||||
createVectorizeIndex(name: string): Promise<string>;
|
||||
}
|
||||
|
||||
/** 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。 */
|
||||
export interface BindingRequirement {
|
||||
kind: ResourceKind;
|
||||
binding: string;
|
||||
/** 需要它的 worker script 名(= wrangler.toml 的 `name`)。 */
|
||||
worker: string;
|
||||
createName: string;
|
||||
}
|
||||
|
||||
export interface PlannedAdopt {
|
||||
kind: ResourceKind;
|
||||
binding: string;
|
||||
value: string;
|
||||
/** 從哪顆已部署的 worker 上讀到的 */
|
||||
from: string;
|
||||
}
|
||||
|
||||
export interface PlannedCreate {
|
||||
kind: ResourceKind;
|
||||
binding: string;
|
||||
createName: string;
|
||||
wantedBy: string[];
|
||||
/** 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。 */
|
||||
alsoBind: string[];
|
||||
}
|
||||
|
||||
export interface ResourcePlan {
|
||||
adopt: PlannedAdopt[];
|
||||
create: PlannedCreate[];
|
||||
/** 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 */
|
||||
blockers: string[];
|
||||
/**
|
||||
* 每顆**已部署** worker 現在掛著的 plain_text var(script → 名/值)。未部署的不在裡面。
|
||||
*
|
||||
* Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡——
|
||||
* 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式
|
||||
* (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。
|
||||
*/
|
||||
liveVars: Map<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface ResolvedResource {
|
||||
kind: ResourceKind;
|
||||
binding: string;
|
||||
value: string;
|
||||
origin: 'adopted' | 'created';
|
||||
from?: string;
|
||||
}
|
||||
|
||||
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
|
||||
export class ResourcePlanBlocked extends Error {
|
||||
constructor(readonly blockers: string[]) {
|
||||
super(`資源解析被擋下(${blockers.length} 項)`);
|
||||
this.name = 'ResourcePlanBlocked';
|
||||
}
|
||||
}
|
||||
|
||||
export function bindingKey(kind: ResourceKind, binding: string): string {
|
||||
return `${kind}:${binding}`;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<ResourceKind, string> = {
|
||||
kv_namespace: 'KV namespace',
|
||||
d1: 'D1 資料庫',
|
||||
vectorize: 'Vectorize index',
|
||||
};
|
||||
|
||||
function msg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* 決定每個 binding 要沿用哪顆資源/要不要新建,**不寫入任何東西**。
|
||||
*
|
||||
* @param mode 'update' = 這台照定義已經裝過了(見下方「一顆都不在」規則);'init' = 全新安裝,允許從零建。
|
||||
*/
|
||||
export async function planResources(
|
||||
api: ResourceApi,
|
||||
requirements: readonly BindingRequirement[],
|
||||
mode: 'update' | 'init',
|
||||
): Promise<ResourcePlan> {
|
||||
const blockers: string[] = [];
|
||||
const adopt: PlannedAdopt[] = [];
|
||||
const create: PlannedCreate[] = [];
|
||||
|
||||
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
|
||||
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
|
||||
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
|
||||
const live = new Map<string, LiveBinding[]>();
|
||||
const liveVars = new Map<string, Record<string, string>>();
|
||||
let readFailed = false;
|
||||
for (const script of scripts) {
|
||||
try {
|
||||
const res = await api.getScriptBindings(script);
|
||||
if (res.deployed) {
|
||||
live.set(script, res.bindings);
|
||||
// #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。
|
||||
liveVars.set(script, res.vars ?? {});
|
||||
}
|
||||
} catch (e) {
|
||||
readFailed = true;
|
||||
blockers.push(
|
||||
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
|
||||
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
|
||||
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
|
||||
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
|
||||
blockers.push(
|
||||
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
|
||||
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
|
||||
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
|
||||
`已停手,沒有新建任何資源。`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
|
||||
const byKey = new Map<string, BindingRequirement[]>();
|
||||
for (const req of requirements) {
|
||||
const key = bindingKey(req.kind, req.binding);
|
||||
const list = byKey.get(key);
|
||||
if (list) list.push(req);
|
||||
else byKey.set(key, [req]);
|
||||
}
|
||||
|
||||
const existingCache = new Map<ResourceKind, Set<string>>();
|
||||
const listExisting = async (kind: ResourceKind): Promise<Set<string>> => {
|
||||
const hit = existingCache.get(kind);
|
||||
if (hit) return hit;
|
||||
let set: Set<string>;
|
||||
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
|
||||
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
|
||||
else set = new Set(await api.listVectorizeIndexes());
|
||||
existingCache.set(kind, set);
|
||||
return set;
|
||||
};
|
||||
|
||||
for (const [, reqs] of byKey) {
|
||||
const { kind, binding } = reqs[0];
|
||||
|
||||
const found: Array<{ value: string; script: string }> = [];
|
||||
for (const [script, bindings] of live) {
|
||||
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
|
||||
if (hit) found.push({ value: hit.value, script });
|
||||
}
|
||||
const distinct = [...new Set(found.map((f) => f.value))];
|
||||
|
||||
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
|
||||
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
|
||||
if (distinct.length > 1) {
|
||||
blockers.push(
|
||||
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
|
||||
`(${found.map((f) => `${f.script} → ${f.value}`).join('、')})。` +
|
||||
`分不出哪一顆才是你在用的,不猜——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
|
||||
if (distinct.length === 1) {
|
||||
const value = distinct[0];
|
||||
let existing: Set<string>;
|
||||
try {
|
||||
existing = await listExisting(kind);
|
||||
} catch (e) {
|
||||
blockers.push(
|
||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
|
||||
`(${msg(e)})。不確定就不動——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!existing.has(value)) {
|
||||
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
|
||||
blockers.push(
|
||||
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value},` +
|
||||
`但這顆在你的 Cloudflare 帳號上找不到了。` +
|
||||
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
|
||||
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
adopt.push({ kind, binding, value, from: found[0].script });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
|
||||
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
|
||||
create.push({
|
||||
kind,
|
||||
binding,
|
||||
createName: reqs[0].createName,
|
||||
wantedBy: [...new Set(reqs.map((r) => r.worker))],
|
||||
alsoBind: [],
|
||||
});
|
||||
}
|
||||
|
||||
return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars };
|
||||
}
|
||||
|
||||
/**
|
||||
* 收斂「不同 binding 其實是同一顆資源」的情況。
|
||||
*
|
||||
* 判準是 **toml 自己宣告的名字**(`database_name` / `index_name`),不是使用者那側的資源名——
|
||||
* cypher 的 `CREDENTIALS_DB` 與 kbdb 的 `DB` 都寫 `database_name = "arcrun-kbdb"`,
|
||||
* 那是**我們**在宣告「這兩個綁定指向同一顆庫」,跟 #97 那種「拿名字去猜使用者的資源」是兩回事。
|
||||
*
|
||||
* 沒有這一步會出兩種錯:
|
||||
* ① 全新安裝時建出兩顆同名 D1,KBDB 的資料與 credential 目錄從此分家。
|
||||
* ② 一邊已部署(沿用既有)、另一邊沒有(新建一顆空的)→ 半套資料,比全壞更難查。
|
||||
*/
|
||||
function shareSameResource(
|
||||
adopt: PlannedAdopt[],
|
||||
create: PlannedCreate[],
|
||||
byKey: Map<string, BindingRequirement[]>,
|
||||
): PlannedCreate[] {
|
||||
const declaredName = (kind: ResourceKind, binding: string): string | undefined =>
|
||||
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
|
||||
|
||||
const out: PlannedCreate[] = [];
|
||||
const groups = new Map<string, PlannedCreate>();
|
||||
|
||||
for (const c of create) {
|
||||
const groupKey = `${c.kind} | ||||