6846d6ddae
一、文案(portal/console/kbdb hint):語意搜尋是一安裝就提供的功能,
降級=故障。橫幅改「語意搜尋目前故障/我們的問題/你不用做任何事」,
拿掉「還沒開通、想開通請匯出診斷檔」這種要使用者申請開通的假框架。
kbdb 降級回應加 degraded_reason(module_off / embed_query_failed)。
二、查詢向量化失敗不再偽裝成空結果(leo 點名的謊):
semanticSearch 舊行為「AI 額度用完 → 回 []」會讓使用者以為
自己的知識庫裡沒有這筆資料。改丟 EmbedQueryFailedError,
route 誠實降級 keyword+照實告知是暫時故障。
三、源頭機制(裝好的實例為什麼會失去語意搜尋):
- acr update:kbdb_embed 判斷 ===true → !==false。config 缺欄位時
redeploy 會把 [[vectorize]]+[ai] binding 靜默剝掉(wrangler deploy
整份覆蓋),一台正常實例就此壞掉。init 預設同步翻成 [Y/n]。
-(另 repo)deploy-all.mjs ensureVectorizeIndex 失敗改致命中止。
四、順手自癒:孤兒向量/下架殘影搜尋時背景清除;空結果且 pending>0
背景 backfill;no_index 拆「故障」vs「還沒有資料」兩態。
測試:kbdb 146/146(新增 degraded 6 案+selftest 1 案);cli 10/10;
瀏覽器端到端兩種故障畫面實測(local wrangler dev+portal 真登入)。
無 SDD 對應:leo 直令修故障(同 08-07 檢修孔前例的人閘直接授權路徑)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
8.6 KiB
TypeScript
160 lines
8.6 KiB
TypeScript
/**
|
||
* acr update — 從 Gitea 拉最新 archive,重新部署零件/引擎到用戶自己的 Cloudflare。
|
||
*
|
||
* 與 acr init --self-hosted 走同一條「下載 archive → 注入 KV id → wrangler deploy」的路
|
||
*(同一支 downloadAndDeploy),差別只在:init 是首次(建 KV/D1 + 寫 config),
|
||
* update 是沿用既有 config 重部署變動的 Worker。因共用 downloadAndDeploy 的內容指紋 manifest,
|
||
* 「新裝零件補上、內容未變者略過」對 update 天然成立 → install ≈ update(Arcrun#4)。
|
||
*
|
||
* 下載源(Arcrun#4,2026-07-07):由 GitHub codeload 改指 Gitea archive(git.uncle6.me),
|
||
* 讓 D20 防 flag 下不能碰 GitHub 的 self-hosted 用戶也能 acr update 補裝新零件(如 code 零件)。
|
||
*
|
||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3「acr update」
|
||
*/
|
||
|
||
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';
|
||
|
||
export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||
const config = loadConfig();
|
||
|
||
if (config.mode !== 'self-hosted') {
|
||
console.log(chalk.yellow('\n acr update 只用於 self-hosted 模式(部署在你自己的 Cloudflare)。'));
|
||
console.log(chalk.gray(' 目前模式:' + config.mode + '。如要 self-host,先跑 acr init --self-hosted。\n'));
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!config.cloudflare_account_id || !config.cf_api_token) {
|
||
console.log(chalk.yellow('\n config 缺 cloudflare_account_id / cf_api_token,無法部署。'));
|
||
console.log(chalk.gray(' 請重新跑 acr init --self-hosted。\n'));
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!wranglerAvailable()) {
|
||
console.log(chalk.yellow('\n ✗ 找不到 wrangler(Cloudflare CLI)。請先 npm i -g wrangler。\n'));
|
||
process.exit(1);
|
||
}
|
||
|
||
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'));
|
||
}
|
||
}
|
||
|
||
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,
|
||
// 語義查詢(issue #7):預設**開**,只有 config 顯式寫 kbdb_embed:false 才關。
|
||
// 🔴 2026-08-09 翻轉預設(leo:「語義搜尋已經確定是一安裝就提供的功能」)。
|
||
// 舊判斷 `=== true` 的實害:config 沒這個欄位(舊 config / 一鍵安裝實例本機補跑 update)
|
||
// 時 redeploy 會把 kbdb 的 [[vectorize]]+[ai] binding 靜默剝掉——一台**原本正常**的
|
||
// 實例就這樣失去語意搜尋,畫面上還被說成「還沒開通」。wrangler deploy 是整份覆蓋,
|
||
// binding 不在 toml 裡=直接消失,這正是「裝好的實例壞掉」的機制之一。
|
||
kbdbEmbed: config.kbdb_embed !== false,
|
||
};
|
||
|
||
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force });
|
||
|
||
if (result.implemented) {
|
||
// message 含部分失敗清單(「部署 X/Y 成功,N 失敗:✗ ...」)——必須印出來,
|
||
// 否則 worker 失敗被綠勾蓋掉(假綠):cypher 沒部上 → 後面 migrate 打舊 worker 404,
|
||
// 用戶重跑 N 次都不知道根因(壓測 2026-06-11 實證)。
|
||
if (result.message?.includes('失敗')) {
|
||
console.log(chalk.yellow(`\n ⚠ 部署部分失敗:`));
|
||
console.log(chalk.yellow(' ' + result.message.split('\n').join('\n ')));
|
||
} else {
|
||
console.log(chalk.green('\n ✓ 部署完成'));
|
||
}
|
||
// 重跑 seed(薄殼:呼叫 API /init/seed;冪等,覆寫既有)。
|
||
// 修壓測 §4.1.3「update 不做 seed,但 init 提示說 update 會重試 seed」的矛盾。
|
||
const cypherUrl = config.cypher_executor_url
|
||
?? result.cypherExecutorUrl
|
||
?? (ctx.workerSubdomain ? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev` : '');
|
||
if (cypherUrl) {
|
||
process.stdout.write(chalk.gray(' → 重新 seed recipe(API + auth,由 API 灌入)...'));
|
||
try {
|
||
const res = await fetch(`${cypherUrl}/init/seed`, { method: 'POST' });
|
||
const body = await res.json().catch(() => null) as { success?: boolean; message?: string } | null;
|
||
console.log(res.ok && body?.success
|
||
? chalk.green(` ✓ ${body.message ?? ''}`)
|
||
: chalk.yellow(` ⚠ ${body?.message ?? `HTTP ${res.status}`}`));
|
||
} catch (e) {
|
||
console.log(chalk.yellow(` ⚠ seed 失敗(${e instanceof Error ? e.message : e})`));
|
||
}
|
||
|
||
// kbdb-base 8.P0:一次性把舊的 per-key cron-idx:{apiKey}:{name} 折進單一 cron-idx:_all。
|
||
// 部署 8.P0 後既有 cron workflow 若不重 push 會停擺(scheduled 只讀新集中 key)→ 這裡冪等補上。
|
||
// 冪等、不刪舊 key、失敗不致命(重跑 acr update 會再試)。
|
||
process.stdout.write(chalk.gray(' → 遷移 cron index(舊 per-key → 集中 key,冪等)...'));
|
||
try {
|
||
const res = await fetch(`${cypherUrl}/webhooks/named/migrate-cron-index`, { method: 'POST' });
|
||
const rawText = await res.text();
|
||
let body: { success?: boolean; migrated?: number; skipped?: number; errors?: string[] } | null = null;
|
||
try { body = JSON.parse(rawText); } catch { /* 非 JSON(如 CF 錯誤頁)→ 用原文 */ }
|
||
if (res.ok && body?.success) {
|
||
console.log(chalk.green(` ✓ migrated ${body.migrated ?? 0}, skipped ${body.skipped ?? 0}`));
|
||
} else {
|
||
// 印 server 回的錯誤內容(截前 200 字)——只回 HTTP status 沒人能診斷
|
||
// (壓測 2026-06-11:404→500 重跑 3 次都看不到根因)。
|
||
const detail = (body?.errors?.join('; ') ?? rawText).slice(0, 200);
|
||
console.log(chalk.yellow(` ⚠ HTTP ${res.status}${detail ? `:${detail}` : ''}`));
|
||
console.log(chalk.yellow(' 404 = cypher worker 還是舊版(看上方部署是否有失敗);500 = server 端錯誤(看上行錯誤內容)'));
|
||
}
|
||
} catch (e) {
|
||
console.log(chalk.yellow(` ⚠ cron index 遷移失敗(${e instanceof Error ? e.message : e})`));
|
||
}
|
||
}
|
||
console.log('');
|
||
} else {
|
||
console.log(chalk.yellow(' ⚠ 更新尚未自動化:'));
|
||
console.log(chalk.gray(' ' + result.message.split('\n').join('\n ')) + '\n');
|
||
}
|
||
}
|
||
|
||
/** 從 cypher_executor_url(https://arcrun-cypher-executor.<sub>.workers.dev)抽 subdomain。*/
|
||
function extractSubdomain(url?: string): string {
|
||
if (!url) return '';
|
||
const m = url.match(/arcrun-cypher-executor\.([^.]+)\.workers\.dev/);
|
||
return m?.[1] ?? '';
|
||
}
|