fix(cli): 更新不再照名字找資源——已部署 worker 綁著什麼就是什麼(Arcrun#97)

病根:更新會「確保」它需要的資源存在,而它是**照名字找**的。
使用者的資源是安裝器建的(`arcrun-rag-<x>-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
This commit is contained in:
uncle6me-web
2026-08-12 13:30:17 +08:00
parent e69d6bbc03
commit 45a546a686
7 changed files with 1356 additions and 179 deletions
+235 -69
View File
@@ -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-1222/23 成功後重跑仍全部
@@ -107,7 +120,15 @@ export function buildDownloadHeaders(token = giteaToken()): Record<string, strin
}
/**
* init 要建立的 KV namespacetitle
* 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_KVregistry worker 用(component 投稿)。漏建會讓 registry deploy 失敗 →
* 壓測 §2.6/#11「20/21」根因(registry/wrangler.toml 綁 SUBMISSIONS_KV,但注入清單沒有它,
@@ -151,8 +172,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 單租戶旗標。trueself-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 URLmcp-account-source §3
message: string;
/** true = 資源解析階段就喊停(Arcrun#97),**一顆資源沒建、一個 worker 沒部**。
* 呼叫端要以非零結束並把 message 原文印出來,不要當成一般部分失敗帶過。*/
blocked?: boolean;
/** 這趟實際用上的資源(沿用/新建各是哪一顆)。呼叫端寫 config 用這個,不要自己再查一次。*/
resources?: Map<string, ResolvedResource>;
}
/** 偵測 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<DeployResult> {
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 模組上線。
// 失敗不致命(收進 failuresbase 仍可部署、維持 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 → 注入前的原文
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<string, ResolvedResource>();
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<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 +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<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',
@@ -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 storageregistry-canon Phase 1.5),且綁卡違背開源免費 → 一併移除。
*/
function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
function injectWranglerConfig(
tomlPath: string,
ctx: DeployContext,
resolved: Map<string, ResolvedResource>,
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, ResolvedResource>,
): string {
// cypher-executor 的 WORKER_SUBDOMAINvars)換成用戶帳號 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] 的 workermcp / 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, 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');
}
/**