From 45a546a68661fb8dfbc4835f8812e4585e436b8b Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 12 Aug 2026 13:30:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(cli):=20=E6=9B=B4=E6=96=B0=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=85=A7=E5=90=8D=E5=AD=97=E6=89=BE=E8=B3=87=E6=BA=90?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=B7=B2=E9=83=A8=E7=BD=B2=20worker=20?= =?UTF-8?q?=E7=B6=81=E8=91=97=E4=BB=80=E9=BA=BC=E5=B0=B1=E6=98=AF=E4=BB=80?= =?UTF-8?q?=E9=BA=BC=EF=BC=88Arcrun#97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 病根:更新會「確保」它需要的資源存在,而它是**照名字找**的。 使用者的資源是安裝器建的(`arcrun-rag--kv-webhooks`),更新找的是 `WEBHOOKS` ⇒ 找不到 ⇒ 新建一顆空的並綁上去。 2026-08-12 實撞(leo21c):一次例行更新後 KV 9 顆 → 18 顆、D1 1 顆 → 2 顆,worker 全綁到新建的空的 ⇒ 工作流一支都看不到、portal 登出、總圖空的、80 把 recipe 解不出來 leo 原話:「leo21c 是掛掉的」。資料沒掉,但從他的角度就是東西全不見了。 修法方向:**已經部署上去的 worker 綁著什麼,那就是事實**—— 名字是使用者那側的事,不是更新指令可以決定的。 📍 repo:matrix/arcrun(cli/)|📍 票:Leo/Arcrun#97 --- cli/src/commands/init.ts | 63 ++-- cli/src/commands/update.ts | 53 +-- cli/src/lib/cf-api.ts | 117 +++++- cli/src/lib/deploy.ts | 304 ++++++++++++---- cli/src/lib/preflight.ts | 58 +-- cli/src/lib/resource-resolver.ts | 408 +++++++++++++++++++++ cli/tests/resource-adoption.test.ts | 532 ++++++++++++++++++++++++++++ 7 files changed, 1356 insertions(+), 179 deletions(-) create mode 100644 cli/src/lib/resource-resolver.ts create mode 100644 cli/tests/resource-adoption.test.ts diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 855a8d7..9dd90eb 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -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): Promise = {}; - 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')); diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index d9a3b18..49023aa 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -14,11 +14,9 @@ import chalk from 'chalk'; import { loadConfig } from '../lib/config.js'; -import { CfAccountClient } from '../lib/cf-api.js'; import { wranglerAvailable, downloadAndDeploy, - REQUIRED_KV_NAMESPACES, type DeployContext, } from '../lib/deploy.js'; @@ -44,43 +42,15 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise { 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 = {}; - 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 +63,18 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise { kbdbEmbed: config.kbdb_embed !== false, }; - const result = await downloadAndDeploy(ctx, 'main', { force: opts.force }); + // 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 失敗:✗ ...」)——必須印出來, diff --git a/cli/src/lib/cf-api.ts b/cli/src/lib/cf-api.ts index e12e83d..64ccfd2 100644 --- a/cli/src/lib/cf-api.ts +++ b/cli/src/lib/cf-api.ts @@ -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; @@ -96,6 +98,16 @@ export class CfAccountClient { } private async cf(path: string, init?: RequestInit): Promise { + const { ok, status, result, error } = await this.cfRaw(path, init); + if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`); + return result as T; + } + + /** 同 cf(),但把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 */ + private async cfRaw( + 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): Promise { - 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 { const result = await this.cf<{ id: string; title: string }>( '/storage/kv/namespaces', { method: 'POST', body: JSON.stringify({ title }) }, @@ -139,6 +158,24 @@ 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 { + 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: [] }; + throw new Error(`讀 ${script} 綁定失敗:${res.error}`); + } + return { deployed: true, bindings: normalizeBindings(res.result?.bindings ?? []) }; + } + /** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/ async getWorkersSubdomain(): Promise { const result = await this.cf<{ subdomain: string }>('/workers/subdomain'); @@ -153,14 +190,66 @@ export class CfAccountClient { return map; } - async ensureD1Database(name: string, existing?: Map): Promise { - 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 { 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 { + const result = await this.cf>('/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 { + 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; +} + +/** 把 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; } diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index 0bf3dc0..2321589 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -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 成功後重跑仍全部 @@ -107,7 +120,15 @@ export function buildDownloadHeaders(token = giteaToken()): Record; // title → id - d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml + /** binding → KV namespace id。**由 downloadAndDeploy 內部的資源解析填入,呼叫端不要自己給** + * (Arcrun#97:呼叫端「照名字 ensure 一輪再傳進來」正是把使用者實例洗空的那條路)。*/ + kvNamespaceIds?: Record; + /** 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 多租戶,行為不變)。 @@ -190,6 +214,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; } /** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/ @@ -219,8 +248,10 @@ export function wranglerAvailable(): boolean { export async function downloadAndDeploy( ctx: DeployContext, ref = 'main', - opts: { force?: boolean } = {}, + opts: { force?: boolean; mode?: 'init' | 'update'; api?: ResourceApi } = {}, ): Promise { + const mode = opts.mode ?? 'update'; + const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken); // 1. 下載 + 解壓 Gitea archive tarball let root: string; try { @@ -262,28 +293,123 @@ 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(); // dir → 注入前的原文 + 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 不該存在;跳過而非亂猜 + for (const b of parsed.bindings) { + requirements.push({ ...b, worker: parsed.script }); + } + } + + let resolved = new Map(); + 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——你現在的實例維持原樣。`, + }; + } + 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)}`); } } // 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 +422,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)); // 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。 const hash = dirContentHash(dir, ctx.accountId); if (manifest[label] === hash) { @@ -434,35 +560,6 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise } } -/** - * 確保 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 { - 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 +568,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 { - 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 { + 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', @@ -596,21 +696,31 @@ 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, + original?: string, +): void { if (!existsSync(tomlPath)) return; - let toml = readFileSync(tomlPath, '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`); - } + // original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。 + const toml = original ?? readFileSync(tomlPath, 'utf8'); + writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved), 'utf8'); +} +/** + * 把一份 repo 內的 wrangler.toml 轉成「要部到這個用戶帳號上的樣子」。 + * + * 純函式(好離線測、也好當預覽用)。帶空 `resolved` 呼叫 = 預覽:得到的是 + * 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的 + * ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓: + * 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。 + */ +export function renderWranglerToml( + toml: string, + ctx: DeployContext, + resolved: Map, +): string { // cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) { toml = toml.replace( @@ -629,14 +739,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 +770,71 @@ function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void { toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2'); } - writeFileSync(tomlPath, toml, 'utf8'); + // 資源 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 { + if (resolved.size === 0) return toml; + + const VALUE_KEY: Record = { + 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'); } /** diff --git a/cli/src/lib/preflight.ts b/cli/src/lib/preflight.ts index 58bfb15..dfccc0c 100644 --- a/cli/src/lib/preflight.ts +++ b/cli/src/lib/preflight.ts @@ -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; + /** 部署實際用上的 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--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 }); } } diff --git a/cli/src/lib/resource-resolver.ts b/cli/src/lib/resource-resolver.ts new file mode 100644 index 0000000..9668995 --- /dev/null +++ b/cli/src/lib/resource-resolver.ts @@ -0,0 +1,408 @@ +/** + * resource-resolver.ts — 資源解析:「已部署的 worker 現在綁著什麼,那就是事實」 + * + * 🔴 Arcrun#97(2026-08-12 實害,leo 的實例中了): + * 舊做法叫「照名字 ensure」——`acr update` 拿 **binding 名**(`WEBHOOKS`)當成 Cloudflare 上的 + * **資源標題**去找,找不到就**新建一顆空的、然後綁到 worker 上**。 + * 安裝器建的資源不叫那個名字(它叫 `arcrun-rag--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[]; +} + +/** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */ +export interface ResourceApi { + getScriptBindings(script: string): Promise; + /** title → id */ + listKvNamespaces(): Promise>; + /** name → uuid */ + listD1Databases(): Promise>; + listVectorizeIndexes(): Promise; + createKvNamespace(title: string): Promise; + createD1Database(name: string): Promise; + createVectorizeIndex(name: string): Promise; +} + +/** 「這顆 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[]; +} + +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 = { + 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 { + 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(); + let readFailed = false; + for (const script of scripts) { + try { + const res = await api.getScriptBindings(script); + if (res.deployed) live.set(script, res.bindings); + } 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(); + 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>(); + const listExisting = async (kind: ResourceKind): Promise> => { + const hit = existingCache.get(kind); + if (hit) return hit; + let set: Set; + 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; + 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 }; +} + +/** + * 收斂「不同 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, +): PlannedCreate[] { + const declaredName = (kind: ResourceKind, binding: string): string | undefined => + byKey.get(bindingKey(kind, binding))?.[0]?.createName; + + const out: PlannedCreate[] = []; + const groups = new Map(); + + for (const c of create) { + const groupKey = `${c.kind}${c.createName}`; + + // ① 已經有 binding 沿用到同一顆(依 toml 宣告)→ 跟著沿用,不要另外建一顆。 + const twin = adopt.find( + (a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName, + ); + if (twin) { + adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from }); + continue; + } + + // ② 同一趟裡有多個 binding 要建同一顆 → 建一次,其他人共用。 + const head = groups.get(groupKey); + if (head) { + head.alsoBind.push(c.binding); + head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])]; + continue; + } + groups.set(groupKey, c); + out.push(c); + } + return out; +} + +/** + * 照 plan 動手:沿用的原樣帶出來,該建的才建。 + * 有任何 blocker 直接丟 ResourcePlanBlocked,**一顆都不建**。 + */ +export async function applyResourcePlan( + api: ResourceApi, + plan: ResourcePlan, +): Promise> { + if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers); + + const out = new Map(); + for (const a of plan.adopt) { + out.set(bindingKey(a.kind, a.binding), { + kind: a.kind, + binding: a.binding, + value: a.value, + origin: 'adopted', + from: a.from, + }); + } + const madeSoFar: string[] = []; + for (const c of plan.create) { + let value: string; + try { + if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName); + else if (c.kind === 'd1') value = await api.createD1Database(c.createName); + else value = await api.createVectorizeIndex(c.createName); + } catch (e) { + // 半途失敗:已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**—— + // 不講的話它們就是帳號上一批沒人認得的孤兒,而且下次重跑會再建一批。 + const orphans = madeSoFar.length > 0 + ? `\n 已經建好但還沒綁上任何 worker 的:${madeSoFar.join('、')}(重跑前可先刪掉,或留著讓下次沿用)` + : ''; + throw new Error(`建 ${KIND_LABEL[c.kind]}「${c.createName}」失敗:${msg(e)}${orphans}`); + } + madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`); + for (const binding of [c.binding, ...c.alsoBind]) { + out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' }); + } + } + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// wrangler.toml → 需求清單 +// ───────────────────────────────────────────────────────────────────────────── + +export interface WranglerRequirements { + /** worker script 名(toml 頂層 `name`)。空字串 = 這份 toml 沒宣告 name(不該發生)。 */ + script: string; + bindings: Array<{ kind: ResourceKind; binding: string; createName: string }>; +} + +/** wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表,兩邊才不會對不上。 */ +export const TABLE_KIND: Record = { + kv_namespaces: 'kv_namespace', + d1_databases: 'd1', + vectorize: 'vectorize', +}; + +/** + * 從 wrangler.toml 抽出「這顆 worker 需要哪些資源綁定」。 + * + * 刻意寫成行掃描而不引 TOML parser:注入端(injectWranglerConfig)本來就是純文字操作, + * 兩邊用同一種視角看這份檔案才不會對不上。註解掉的區塊**不算需求** + * (kbdb 的 `[[vectorize]]` 預設是註解狀態,要開語義查詢時才會被取消註解 → 那時才成為需求)。 + */ +export function parseWranglerRequirements(toml: string): WranglerRequirements { + let script = ''; + let seenTable = false; + const bindings: WranglerRequirements['bindings'] = []; + + let kind: ResourceKind | null = null; + let binding = ''; + let createName = ''; + + const flush = (): void => { + if (kind && binding) { + bindings.push({ kind, binding, createName: createName || binding }); + } + kind = null; + binding = ''; + createName = ''; + }; + + for (const raw of toml.split('\n')) { + const line = raw.trim(); + if (line === '' || line.startsWith('#')) continue; + + const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/); + if (table) { + flush(); + seenTable = true; + kind = TABLE_KIND[table[1]] ?? null; + continue; + } + + const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/); + if (!kv) continue; + const [, key, value] = kv; + + if (!seenTable && key === 'name') { + script = value; + continue; + } + if (!kind) continue; + if (key === 'binding') binding = value; + // 只有 D1/Vectorize 在 toml 裡帶得出「名字」;KV 沒有,退回用 binding 名(見 flush)。 + else if (key === 'database_name' || key === 'index_name') createName = value; + } + flush(); + + return { script, bindings }; +} diff --git a/cli/tests/resource-adoption.test.ts b/cli/tests/resource-adoption.test.ts new file mode 100644 index 0000000..a05307d --- /dev/null +++ b/cli/tests/resource-adoption.test.ts @@ -0,0 +1,532 @@ +/** + * Arcrun#97 迴歸守衛 —— 「跑完更新,使用者的東西還在原地」 + * + * 2026-08-12 實害:leo 跑了一次例行更新,跑完工作流一支都沒有、portal 把他登出、 + * 總圖是空的、子庫全不見。資料沒被刪,但 worker 被重新綁到 9 顆新建的空 KV + 1 顆空 D1 上。 + * + * 根因:更新「照名字」確保資源存在——拿 binding 名(WEBHOOKS)當 CF 上的資源標題去找, + * 安裝器建的資源叫 `arcrun-rag--kv-webhooks`,永遠對不上 ⇒ 每次更新都新建一顆綁上去。 + * + * 這份測試用一台**照安裝器命名慣例**的假實例(不是 leo 的實例,不碰 leo21c)跑真正的 + * 解析程式碼(planResources / applyResourcePlan / renderWranglerToml),對照更新前後: + * ① 工作流數、登入狀態、子庫數 ② 帳號上的資源顆數 ③ 找不到既有資源時要停手 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + planResources, + applyResourcePlan, + parseWranglerRequirements, + bindingKey, + ResourcePlanBlocked, + type BindingRequirement, + type ResourceApi, + type ScriptBindings, + type LiveBinding, + type ResolvedResource, +} from '../src/lib/resource-resolver.ts'; +import { + renderWranglerToml, + REQUIRED_KV_NAMESPACES, + type DeployContext, +} from '../src/lib/deploy.ts'; +import { CfAccountClient } from '../src/lib/cf-api.ts'; + +const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..'); + +/** 這台假實例上跑著的四顆 worker(有資源綁定的那幾顆)。 */ +const WORKER_TOMLS = [ + 'cypher-executor/wrangler.toml', + 'registry/wrangler.toml', + 'mcp/wrangler.toml', + 'kbdb/wrangler.toml', +]; + +const CTX: DeployContext = { + accountId: 'acct-test', + apiToken: 'tok-test', + workerSubdomain: 'yuga3bse', + selfHosted: true, + kbdbEmbed: true, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// 假的 Cloudflare 帳號:完全照「安裝器裝出來」的樣子命名 +// ───────────────────────────────────────────────────────────────────────────── + +const INSTANCE = 'yuga3bse'; + +interface FakeOpts { + /** 讓某顆 worker 的綁定讀取失敗(模擬 API 掛掉 / 權限不足)。 */ + failBindingsFor?: string; + /** 從帳號上「弄不見」某顆 KV,但 worker 上還綁著它(模擬資源被刪)。 */ + deleteKvTitle?: string; + /** 完全沒有任何已部署的 worker(模擬名字對不上 / token 看錯帳號)。 */ + nothingDeployed?: boolean; +} + +class FakeCloudflare implements ResourceApi { + /** title → id */ + kv = new Map(); + /** name → uuid */ + d1 = new Map(); + vectorize: string[] = []; + /** script → bindings */ + scripts = new Map(); + + /** 使用者的東西:kvId → (key → value) */ + kvData = new Map>(); + /** d1Id → 子庫名單 */ + d1Libraries = new Map(); + + /** 這趟總共建立了什麼(驗「顆數不增加」用)。 */ + createdKv: string[] = []; + createdD1: string[] = []; + createdVectorize: string[] = []; + + constructor(private opts: FakeOpts = {}) { + // 安裝器的命名慣例:arcrun-rag--kv- + for (const binding of REQUIRED_KV_NAMESPACES) { + const title = `arcrun-rag-${INSTANCE}-kv-${binding.toLowerCase()}`; + const id = `kvid-${binding.toLowerCase()}`; + this.kv.set(title, id); + this.kvData.set(id, new Map()); + } + this.d1.set(`arcrun-rag-${INSTANCE}-kbdb`, 'd1id-kbdb'); + this.vectorize.push(`arcrun-rag-${INSTANCE}-embed`); + + // 使用者的東西 + this.kvData.get('kvid-webhooks')!.set('webhook:leo:daily-digest', '{}'); + this.kvData.get('kvid-webhooks')!.set('webhook:leo:inbox-sync', '{}'); + this.kvData.get('kvid-webhooks')!.set('webhook:leo:rag-ingest', '{}'); + this.kvData.get('kvid-sessions_kv')!.set('session:leo-abc123', '{"user":"leo"}'); + this.d1Libraries.set('d1id-kbdb', ['general', '課程', '客戶', '研究']); + + if (!opts.nothingDeployed) { + const kvB = (b: string): LiveBinding => + ({ kind: 'kv_namespace', binding: b, value: `kvid-${b.toLowerCase()}` }); + this.scripts.set('arcrun-cypher-executor', [ + kvB('EXEC_CONTEXT'), kvB('WEBHOOKS'), kvB('CREDENTIALS_KV'), kvB('ANALYTICS_KV'), + kvB('RECIPES'), kvB('USERS_KV'), kvB('SESSIONS_KV'), + { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1id-kbdb' }, + ]); + this.scripts.set('arcrun-registry', [kvB('SUBMISSIONS_KV'), kvB('ANALYTICS_KV')]); + this.scripts.set('arcrun-mcp', [kvB('OAUTH_KV')]); + this.scripts.set('arcrun-kbdb', [ + { kind: 'd1', binding: 'DB', value: 'd1id-kbdb' }, + { kind: 'vectorize', binding: 'VECTORIZE', value: `arcrun-rag-${INSTANCE}-embed` }, + ]); + } + + if (opts.deleteKvTitle) this.kv.delete(opts.deleteKvTitle); + } + + async getScriptBindings(script: string): Promise { + if (this.opts.failBindingsFor === script) throw new Error('HTTP 500 (CF API 暫時掛掉)'); + const b = this.scripts.get(script); + return b ? { deployed: true, bindings: b } : { deployed: false, bindings: [] }; + } + async listKvNamespaces(): Promise> { return new Map(this.kv); } + async listD1Databases(): Promise> { return new Map(this.d1); } + async listVectorizeIndexes(): Promise { return [...this.vectorize]; } + async createKvNamespace(title: string): Promise { + const id = `NEW-kvid-${this.createdKv.length}`; + this.kv.set(title, id); + this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上 + this.createdKv.push(title); + return id; + } + async createD1Database(name: string): Promise { + const id = `NEW-d1id-${this.createdD1.length}`; + this.d1.set(name, id); + this.d1Libraries.set(id, []); + this.createdD1.push(name); + return id; + } + async createVectorizeIndex(name: string): Promise { + this.vectorize.push(name); + this.createdVectorize.push(name); + return name; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 共用:從真的 wrangler.toml 解析需求(走與 downloadAndDeploy 相同的路徑) +// ───────────────────────────────────────────────────────────────────────────── + +function collectRequirements(): { requirements: BindingRequirement[]; tomls: Map } { + const requirements: BindingRequirement[] = []; + const tomls = new Map(); + for (const rel of WORKER_TOMLS) { + const raw = readFileSync(join(REPO, rel), 'utf8'); + tomls.set(rel, raw); + const parsed = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map())); + for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script }); + } + return { requirements, tomls }; +} + +/** 模擬「部署」:把解析結果注入 toml,再從注入後的 toml 讀回 worker 實際會綁到的資源。 */ +function deployAndReadBindings( + tomls: Map, + resolved: Map, +): Map> { + const out = new Map>(); + for (const [rel, raw] of tomls) { + const rendered = renderWranglerToml(raw, CTX, resolved); + const script = parseWranglerRequirements(rendered).script; + const bound = new Map(); + let kind: string | null = null; + let binding = ''; + for (const line of rendered.split('\n')) { + const t = line.trim(); + if (t.startsWith('#')) continue; + const table = t.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/); + if (table) { kind = table[1]; binding = ''; continue; } + const m = t.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/); + if (!m) continue; + if (m[1] === 'binding') binding = m[2]; + else if (binding && ( + (kind === 'kv_namespaces' && m[1] === 'id') + || (kind === 'd1_databases' && m[1] === 'database_id') + || (kind === 'vectorize' && m[1] === 'index_name') + )) bound.set(binding, m[2]); + } + out.set(script, bound); + } + return out; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// ① 更新前後:工作流數、登入狀態、子庫數 —— 三個都不能少 +// ═════════════════════════════════════════════════════════════════════════════ + +test('#97 ①:安裝器裝出來的實例跑更新——工作流/登入/子庫更新前後完全一致', async () => { + const cf = new FakeCloudflare(); + const { requirements, tomls } = collectRequirements(); + + const before = { + workflows: cf.kvData.get('kvid-webhooks')!.size, + sessions: cf.kvData.get('kvid-sessions_kv')!.size, + libraries: cf.d1Libraries.get('d1id-kbdb')!.length, + }; + assert.deepEqual(before, { workflows: 3, sessions: 1, libraries: 4 }, '前置資料要先擺好'); + + const plan = await planResources(cf, requirements, 'update'); + assert.deepEqual(plan.blockers, [], '一台健康的實例不該有任何 blocker'); + const resolved = await applyResourcePlan(cf, plan); + + const bound = deployAndReadBindings(tomls, resolved); + + // 更新後,worker 綁到的還是使用者原本那幾顆(名字完全沒對上,但那不重要) + const cypher = bound.get('arcrun-cypher-executor')!; + assert.equal(cypher.get('WEBHOOKS'), 'kvid-webhooks'); + assert.equal(cypher.get('SESSIONS_KV'), 'kvid-sessions_kv'); + assert.equal(cypher.get('CREDENTIALS_DB'), 'd1id-kbdb'); + assert.equal(bound.get('arcrun-kbdb')!.get('DB'), 'd1id-kbdb'); + assert.equal(bound.get('arcrun-mcp')!.get('OAUTH_KV'), 'kvid-oauth_kv'); + assert.equal(bound.get('arcrun-registry')!.get('SUBMISSIONS_KV'), 'kvid-submissions_kv'); + assert.equal(bound.get('arcrun-kbdb')!.get('VECTORIZE'), `arcrun-rag-${INSTANCE}-embed`); + + const after = { + workflows: cf.kvData.get(cypher.get('WEBHOOKS')!)!.size, + sessions: cf.kvData.get(cypher.get('SESSIONS_KV')!)!.size, + libraries: cf.d1Libraries.get(bound.get('arcrun-kbdb')!.get('DB')!)!.length, + }; + assert.deepEqual(after, before, '更新後使用者看到的東西必須跟更新前一模一樣'); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// ② 帳號上的資源顆數不增加(災情當天:9 顆 KV → 18 顆、1 顆 D1 → 2 顆) +// ═════════════════════════════════════════════════════════════════════════════ + +test('#97 ②:更新不會在帳號上多生任何資源', async () => { + const cf = new FakeCloudflare(); + const kvBefore = cf.kv.size; + const d1Before = cf.d1.size; + const vecBefore = cf.vectorize.length; + assert.deepEqual([kvBefore, d1Before, vecBefore], [9, 1, 1]); + + const { requirements } = collectRequirements(); + const plan = await planResources(cf, requirements, 'update'); + await applyResourcePlan(cf, plan); + + assert.deepEqual(cf.createdKv, [], '不該新建任何 KV'); + assert.deepEqual(cf.createdD1, [], '不該新建任何 D1'); + assert.deepEqual(cf.createdVectorize, [], '不該新建任何 Vectorize index'); + assert.deepEqual([cf.kv.size, cf.d1.size, cf.vectorize.length], [9, 1, 1]); +}); + +test('#97 ②對照組:舊的「照名字 ensure」在同一台實例上會生 9 顆 KV + 1 顆 D1', async () => { + // 這段是**修好之前**的演算法(commit e69d6bb 時的 cli/src/commands/update.ts:52-68 與 + // cf-api.ts 的 ensureKvNamespace/ensureD1Database),照原樣重寫在這裡當對照組。 + // 目的:把「災情是怎麼發生的」釘成可執行的事實,而不是只留在 issue 的文字裡。 + const cf = new FakeCloudflare(); + const existing = await cf.listKvNamespaces(); + for (const title of REQUIRED_KV_NAMESPACES) { + if (!existing.get(title)) await cf.createKvNamespace(title); // ← 名字對不上 ⇒ 每個都新建 + } + const d1s = await cf.listD1Databases(); + if (!d1s.get('arcrun-kbdb')) await cf.createD1Database('arcrun-kbdb'); + + assert.equal(cf.createdKv.length, 9, '舊做法:9 顆 KV 全部重建(對上災情當天的數字)'); + assert.equal(cf.createdD1.length, 1, '舊做法:D1 也重建一顆'); + assert.equal(cf.kv.size, 18, '9 → 18'); + assert.equal(cf.d1.size, 2, '1 → 2'); + // 而且新建的那幾顆是空的 —— 使用者的工作流就是這樣「不見」的 + assert.equal(cf.kvData.get(cf.kv.get('WEBHOOKS')!)!.size, 0); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// ③ 反向驗證:找不到既有資源 → 停下來說清楚,不是安靜新建一顆綁上去 +// ═════════════════════════════════════════════════════════════════════════════ + +test('#97 ③-a:worker 綁著的 KV 在帳號上不見了 → 停手,一顆都不建', async () => { + const cf = new FakeCloudflare({ deleteKvTitle: `arcrun-rag-${INSTANCE}-kv-webhooks` }); + const { requirements } = collectRequirements(); + + const plan = await planResources(cf, requirements, 'update'); + assert.ok(plan.blockers.length > 0, '要有 blocker'); + const said = plan.blockers.join('\n'); + assert.match(said, /WEBHOOKS/, '要指名是哪個綁定'); + assert.match(said, /kvid-webhooks/, '要指名是哪一顆資源'); + assert.match(said, /找不到/, '要說清楚發生什麼事'); + assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), '絕不能把它排進「要新建」'); + + await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked); + assert.deepEqual(cf.createdKv, [], '被擋下時一顆資源都不能被建出來'); + assert.deepEqual(cf.createdD1, []); +}); + +test('#97 ③-b:讀不到某顆 worker 現在綁什麼 → 當「我不知道」而不是「它沒有」', async () => { + const cf = new FakeCloudflare({ failBindingsFor: 'arcrun-cypher-executor' }); + const { requirements } = collectRequirements(); + + const plan = await planResources(cf, requirements, 'update'); + assert.match(plan.blockers.join('\n'), /arcrun-cypher-executor/); + await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked); + assert.deepEqual(cf.createdKv, []); +}); + +test('#97 ③-c:update 卻一顆 worker 都找不到 → 停手,不當成全新安裝重建一整套', async () => { + const cf = new FakeCloudflare({ nothingDeployed: true }); + const { requirements } = collectRequirements(); + + const plan = await planResources(cf, requirements, 'update'); + assert.match(plan.blockers.join('\n'), /找不到任何一顆要更新的 worker/); + await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked); + assert.deepEqual(cf.createdKv, []); +}); + +test('#97 ③-d:同一個 binding 在不同 worker 上指向不同資源 → 不猜,停手', async () => { + const cf = new FakeCloudflare(); + // registry 的 ANALYTICS_KV 被指到別顆(真實情境:有人手動改過其中一邊) + cf.scripts.get('arcrun-registry')!.find((b) => b.binding === 'ANALYTICS_KV')!.value = 'kvid-other'; + cf.kv.set('some-other-kv', 'kvid-other'); + const { requirements } = collectRequirements(); + + const plan = await planResources(cf, requirements, 'update'); + assert.match(plan.blockers.join('\n'), /ANALYTICS_KV/); + assert.deepEqual(cf.createdKv, []); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// 合法的新建:只有「確定沒人綁過」時才准 +// ═════════════════════════════════════════════════════════════════════════════ + +test('#97:新版本新增的 binding(沒有任何已部署 worker 綁過)才准新建', async () => { + const cf = new FakeCloudflare(); + cf.scripts.set('arcrun-mcp', []); // mcp 已部署,但還沒有 OAUTH_KV(舊版本裝的) + cf.kv.delete(`arcrun-rag-${INSTANCE}-kv-oauth_kv`); + const { requirements } = collectRequirements(); + + const plan = await planResources(cf, requirements, 'update'); + assert.deepEqual(plan.blockers, []); + assert.deepEqual(plan.create.map((c) => c.binding), ['OAUTH_KV'], '只有這一個該建'); + await applyResourcePlan(cf, plan); + assert.deepEqual(cf.createdKv, ['OAUTH_KV']); + assert.equal(cf.kv.size, 9, '刪掉一顆、補建一顆 → 還是 9 顆'); +}); + +test('#97:全新帳號跑 init → 該建的都建(不會被 update 的停手規則卡住)', async () => { + const cf = new FakeCloudflare({ nothingDeployed: true }); + cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0; + const { requirements } = collectRequirements(); + + const plan = await planResources(cf, requirements, 'init'); + assert.deepEqual(plan.blockers, [], 'init 在空帳號上不該停手'); + await applyResourcePlan(cf, plan); + assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length); + assert.deepEqual(cf.createdD1, ['arcrun-kbdb']); + assert.equal(cf.createdVectorize.length, 1); +}); + +test('#97:一邊已部署一邊沒有 → 跟著沿用同一顆,不要另外建一顆空的', async () => { + const cf = new FakeCloudflare(); + cf.scripts.delete('arcrun-cypher-executor'); // cypher 還沒部(kbdb 已部,DB → d1id-kbdb) + const { requirements, tomls } = collectRequirements(); + + const plan = await planResources(cf, requirements, 'update'); + assert.deepEqual(plan.blockers, []); + assert.ok(!plan.create.some((c) => c.kind === 'd1'), 'CREDENTIALS_DB 不該被當成新資源建一顆'); + + const resolved = await applyResourcePlan(cf, plan); + const bound = deployAndReadBindings(tomls, resolved); + assert.equal(bound.get('arcrun-cypher-executor')!.get('CREDENTIALS_DB'), 'd1id-kbdb', + 'credential 目錄要跟 KBDB 在同一顆庫'); + assert.deepEqual(cf.createdD1, []); +}); + +test('#97:部署出去的 toml 不得殘留官方 prod 的資源 id(自架寫進官方庫 = 跨租戶外洩)', async () => { + // repo 的 toml 裡 database_id 預設是官方 prod D1。舊版在「D1 解析失敗」時只是把它跳過不注入, + // 於是自架用戶的 kbdb worker 就這樣綁著官方那顆庫部署出去。現在不是失敗就跳過,是整趟停手。 + const cf = new FakeCloudflare(); + const { requirements, tomls } = collectRequirements(); + const resolved = await applyResourcePlan(cf, await planResources(cf, requirements, 'update')); + + const OFFICIAL_D1 = '0c580910-e00b-4f8e-9c57-ac54ea52242f'; + for (const [rel, raw] of tomls) { + const rendered = renderWranglerToml(raw, CTX, resolved); + assert.doesNotMatch(rendered, new RegExp(OFFICIAL_D1), `${rel} 還帶著官方 prod D1 的 id`); + assert.doesNotMatch(rendered, /REPLACE_WITH_REAL_KV_ID/, `${rel} 還留著占位 KV id`); + } +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// 做法本身的看守:不准再出現「照名字 ensure」這種原語 +// ═════════════════════════════════════════════════════════════════════════════ + +test('#97 紅線:cf-api 不得再提供任何「找不到同名就順手建一顆」的 ensure 原語', () => { + const src = readFileSync(join(REPO, 'cli/src/lib/cf-api.ts'), 'utf8'); + assert.doesNotMatch(src, /\bensureKvNamespace\b|\bensureD1Database\b|\bensureVectorizeIndex\b/, + 'ensure* 是 #97 的凶器:把「查不到」當成「不存在」再自作主張新建。' + + '要建資源一律先過 resource-resolver 的 planResources。'); +}); + +test('#97 紅線:只有 resource-resolver 能決定「要不要建」,指令層不得自己呼叫 create*', () => { + for (const rel of ['cli/src/commands/init.ts', 'cli/src/commands/update.ts']) { + const src = readFileSync(join(REPO, rel), 'utf8'); + assert.doesNotMatch(src, /\.create(KvNamespace|D1Database|VectorizeIndex)\s*\(/, + `${rel} 不該自己建資源——那樣就繞過了「先看已部署的 worker 綁著什麼」這道判斷。`); + } +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// 底層零件 +// ═════════════════════════════════════════════════════════════════════════════ + +test('parseWranglerRequirements:讀得出 script 名與三種資源綁定,且不把註解掉的區塊當需求', () => { + const toml = [ + 'name = "arcrun-kbdb" # 註解不影響', + '', + '[[d1_databases]]', + 'binding = "DB"', + 'database_name = "arcrun-kbdb"', + 'database_id = "placeholder"', + '', + '[vars]', + 'ENVIRONMENT = "production"', + '', + '# [[vectorize]]', + '# binding = "VECTORIZE"', + '# index_name = "arcrun-kbdb-embed-m3"', + ].join('\n'); + const r = parseWranglerRequirements(toml); + assert.equal(r.script, 'arcrun-kbdb'); + assert.deepEqual(r.bindings, [{ kind: 'd1', binding: 'DB', createName: 'arcrun-kbdb' }]); +}); + +test('KV 沒有 title 欄位 → 真要新建時用 binding 名', () => { + const r = parseWranglerRequirements('name = "w"\n[[kv_namespaces]]\nbinding = "WEBHOOKS"\nid = "x"'); + assert.deepEqual(r.bindings, [{ kind: 'kv_namespace', binding: 'WEBHOOKS', createName: 'WEBHOOKS' }]); +}); + +test('注入是照 binding 對號,不是盲換「檔案裡第一個 database_id」', () => { + const cypher = readFileSync(join(REPO, 'cypher-executor/wrangler.toml'), 'utf8'); + const resolved = new Map([ + [bindingKey('d1', 'CREDENTIALS_DB'), { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'MINE', origin: 'adopted' }], + [bindingKey('kv_namespace', 'WEBHOOKS'), { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'KV-MINE', origin: 'adopted' }], + ]); + const out = renderWranglerToml(cypher, CTX, resolved); + const bound = parseWranglerRequirements(out); + assert.ok(bound.bindings.some((b) => b.binding === 'CREDENTIALS_DB')); + assert.match(out, /binding = "CREDENTIALS_DB"\ndatabase_name = "arcrun-kbdb"\ndatabase_id = "MINE"/); + assert.match(out, /binding = "WEBHOOKS"\nid = "KV-MINE"/); + // 沒被解析到的綁定不能被亂改(EXEC_CONTEXT 這次沒進 resolved) + assert.match(out, /binding = "EXEC_CONTEXT"\nid = "616967a852eb450a8c01731f71ac8edd"/); +}); + +test('renderWranglerToml 帶空 map = 預覽:解析看到的 binding 與注入後的完全一致', () => { + for (const rel of WORKER_TOMLS) { + const raw = readFileSync(join(REPO, rel), 'utf8'); + const preview = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map())); + const resolved = new Map( + preview.bindings.map((b) => [ + bindingKey(b.kind, b.binding), + { kind: b.kind, binding: b.binding, value: `v-${b.binding}`, origin: 'adopted' as const }, + ]), + ); + const after = parseWranglerRequirements(renderWranglerToml(raw, CTX, resolved)); + assert.deepEqual( + after.bindings.map((b) => `${b.kind}:${b.binding}`).sort(), + preview.bindings.map((b) => `${b.kind}:${b.binding}`).sort(), + `${rel}: 預覽與實際注入看到的綁定必須一致`, + ); + } +}); + +test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某顆 worker 部署失敗)', () => { + const { requirements } = collectRequirements(); + const kv = [...new Set(requirements.filter((r) => r.kind === 'kv_namespace').map((r) => r.binding))]; + assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort()); +}); + +test('CfAccountClient.getScriptBindings:404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => { + const orig = globalThis.fetch; + try { + globalThis.fetch = (async () => + new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 }) + ) as typeof fetch; + const cf = new CfAccountClient('a', 't'); + assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [] }); + + globalThis.fetch = (async () => + new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 }) + ) as typeof fetch; + await assert.rejects(() => new CfAccountClient('a', 't').getScriptBindings('x'), /boom/); + } finally { + globalThis.fetch = orig; + } +}); + +test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize 三種綁定形狀', async () => { + const orig = globalThis.fetch; + try { + globalThis.fetch = (async () => new Response(JSON.stringify({ + success: true, + result: { + bindings: [ + { type: 'kv_namespace', name: 'WEBHOOKS', namespace_id: 'kv1' }, + { type: 'd1', name: 'DB', id: 'db1' }, + { type: 'vectorize', name: 'VECTORIZE', index_name: 'idx1' }, + { type: 'plain_text', name: 'ENVIRONMENT', text: 'production' }, + { type: 'service', name: 'SVC_SET', service: 'arcrun-set' }, + ], + }, + }), { status: 200 })) as typeof fetch; + const res = await new CfAccountClient('a', 't').getScriptBindings('arcrun-cypher-executor'); + assert.equal(res.deployed, true); + assert.deepEqual(res.bindings, [ + { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv1' }, + { kind: 'd1', binding: 'DB', value: 'db1' }, + { kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' }, + ]); + } finally { + globalThis.fetch = orig; + } +});