b223a69884
leo 2026-08-12 實撞:藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組,
`arcrun_whoami` 顯示 admin/全部知識庫、`kbdb_search` 也查得到——只有地圖那格是空的。
病根(不是資料掉了,是讀寫兩端各拿一個來源):
寫入端 owner_id = `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,
leo = `bfezv28v`)
讀取端過濾 = `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
——repo toml 帶的**官方 prod 值**,而 `acr` 從來不注入 CONSOLE_TENANT
⇒ 那個 `"leo"` 不是理論邊角,是每台 self-hosted 實例的實際行為,1854 條全被濾掉。
與 #105(`env.MCP_OWNER_NAMESPACE || "leo"`)同一句話,換一個檔案。
租戶字串該從哪裡來(本票的核心判斷):
**從「寫入這批知識的那一方」來,不是從一份手抄的環境變數預設值來。**
不是「掛到每個帳號上」——portal 帳號共用同一台實例的知識庫(design D-2),
帳號之間的差別是 libraries 權限不是 owner_id;複製一份到帳號上只是多一個會過期的副本。
#105 真正的教訓是:過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、且要能機械驗證。
修法:
1. 唯一產地 `cypher-executor/src/lib/tenant.ts`
- `knowledgeOwner(env)` → branded `TenantId`:`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
丟 `TenantUnresolvedError`。**沒有字面預設值**——`|| 'leo'` 正是把「這台機器沒設定」
偽裝成「你沒有資料」的元凶。
- `accountTenant(env)` → 普通 `string`(帳號子 namespace `{tenant}::portal` 與 cypher
自己寫的設定用它)。**回 string 是刻意的**:型別上就不可能流進知識資料面。
- 資料面過濾一律經 `ownerQuery()` / `ownerField()`,只吃 `TenantId`。
2. 值的正解由 CLI 從真相源導出:`acr update` 把 config 的 `api_key` 注入成 `ARCRUN_NAMESPACE`,
但**先驗再寫**(`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫;查不到/問不到就一個字
都不動)。無條件覆蓋會把「知識本來就在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
——那是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。
未注入時回退 CONSOLE_TENANT ⇒ 對官方 prod 與未更新的實例,這次改動是惰性的。
3. 空地圖分四態(沿 #100「讀不到就說讀不到」):no_library_grant/filtered_out/
scope_mismatch/confirmed_empty。scope_mismatch 以前不存在,所以設定錯誤被畫成
「你沒有資料」。回應仍不含租戶字串(design §3.3 紅線)。
4. 同族一起修(同一道閘一次抓到):console-dashboard 4 處、console-auth 1 處
——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。
留下的閘(規則存在但沒機制驗證=會再犯第三次):
· 型別閘:TenantId 只能由 tenant.ts 產出 → 拿隨手一個 string 去過濾,tsc 當場不給過。
· 出貨閘:scripts/build-worker-artifacts.mjs 編 tier2 成品前先掃,違規 → 編不出成品。
· 閘自己可測:規則是純函式(tenant-source-rules.mjs),tests/tenant-gate.test.ts
逐條驗「5 種壞例子會擋」+「11 種合法寫法零誤攔」;掃描範圍只有 src/,擋不到自己。
規範寫入 .claude/rules/02-forbidden.md 第六類、system-dev/wiki/mistakes.md #26。
沒動:庫權限過濾(一字未改,回歸測試釘住)、帳號資料落點、任何金鑰、租戶字串仍不下發前端。
驗證:
cypher vitest 441 綠 / 14 紅,14 紅與 base commit e05518a 逐字相同(既有)
tsc 5 個既有錯誤,零新增
cli node:test 60/60 綠(含本次新增 12 條);tsc 零錯誤
閘 壞例子實跑 exit 1;build 實跑「建置中止」;乾淨時實跑通過
端到端 ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR ③)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
286 lines
14 KiB
JavaScript
286 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* build-worker-artifacts.mjs — Arcrun#80:tier2 worker(TS→ 可部署 JS)的**唯一官方編譯點**。
|
||
*
|
||
* 背景(Arcrun#80/arcrun-rag#39):這個 repo 過去只把 tier1 零件(TinyGo→wasm)的成品
|
||
* commit 進 `.component-builds/{name}/component.wasm`;tier2(cypher-executor / kbdb /
|
||
* http_request / code / mcp 這五顆 TS worker)只有原始碼,沒有編好的成品。於是
|
||
* arcrun-rag 的安裝器只好自己在**它那邊**跑 esbuild(`installer/scripts/build-bundles.mjs`),
|
||
* 結果同一份原始碼在不同機器編出不同位元組(見下「已知踩坑」),
|
||
* 而且「這顆成品是哪個 commit 編的」只有整包一個 `source` 欄位,答不出單顆的來源。
|
||
*
|
||
* 本腳本要解的:
|
||
* 1. 編譯只發生在這裡(Arcrun 本體),成品放固定位置 `.worker-builds/`,commit 進 repo——
|
||
* 與 `.component-builds/*.wasm` 同一個既有慣例(self-host 用戶從 repo 直接拿部署來源)。
|
||
* 2. 每顆成品自己記得「我是哪個 commit 編出來的」(`source_commit`,答到單顆目錄層級,
|
||
* 不是整包一個欄位)。
|
||
* 3. 同一個 commit、任何人任何時候編,位元組要相同——見下方兩個已知踩坑與對應解法。
|
||
*
|
||
* 已知踩坑(2026-08-10 實錄,docs-site/.../changelog.md 1.4.33 段;已回報 arcrun-rag#72):
|
||
* ① esbuild 的 bundle 輸出會把「入口路徑」寫進產物內部的檔案邊界註解,而該路徑預設是
|
||
* **相對於 esbuild 執行時的 cwd**——雲端容器跑在 `.../arcrun/`、地端跑在
|
||
* `.../matrix/arcrun/`,同一份原始碼因此編出不同註解。
|
||
* 解法:固定 `absWorkingDir` 為**本腳本自己算出的 repo 根目錄**(不吃外部 cwd/env
|
||
* 路徑),entry 一律用「相對 repo 根目錄」的相對路徑餵給 esbuild——不管這個 clone
|
||
* 實際被放在磁碟的哪個絕對路徑下,esbuild 內部算出的相對路徑字串都相同。
|
||
* ② 各 worker 目錄的 node_modules 若用不同套件管理器(pnpm store vs npm 平鋪)安裝,
|
||
* 可能夾帶不同版本的間接依賴(實測 ajv/uri-js 差 1019 行)。
|
||
* 解法:本腳本**不自己 npm/pnpm install**——強制要求呼叫者先用「該目錄既有的
|
||
* lockfile」(pnpm-lock.yaml 用 `pnpm install --frozen-lockfile`;package-lock.json
|
||
* 用 `npm ci`)裝好 node_modules,並在建置前檢查 lockfile 是否存在,
|
||
* lockfile 是「同一份依賴圖」的機械保證,比信任「兩台機器裝出來一樣」牢靠。
|
||
*
|
||
* 用法:
|
||
* node scripts/build-worker-artifacts.mjs [--check-only]
|
||
* --check-only:只驗證每個 worker 的 node_modules 是否已按 lockfile 裝好,不編譯。
|
||
*
|
||
* 輸出:.worker-builds/<name>/worker.mjs (+ *.wasm)、.worker-builds/manifest.json
|
||
*/
|
||
import esbuild from 'esbuild';
|
||
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, rmSync, readdirSync } from 'node:fs';
|
||
import { join, resolve, basename, relative } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { execSync } from 'node:child_process';
|
||
import { createHash } from 'node:crypto';
|
||
// Arcrun#108 出貨閘(見 main() 內註解)。規則本體與掃描器住在 cypher-executor/scripts/。
|
||
import { scanProject as scanTenantSources } from '../cypher-executor/scripts/check-tenant-source.mjs';
|
||
|
||
// REPO 一律用「本檔自己的位置」推導,不吃 cwd/env——這是踩坑①解法的地基:
|
||
// 不管這個 clone 被放在磁碟哪個絕對路徑,REPO 永遠是「這個 repo 的根目錄」,
|
||
// 下面所有 esbuild 呼叫都用「相對 REPO」的相對路徑,輸出字串才會與絕對路徑無關。
|
||
const REPO = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
|
||
const OUT = join(REPO, '.worker-builds');
|
||
const CHECK_ONLY = process.argv.includes('--check-only');
|
||
|
||
/** 五顆 tier2 worker——與 arcrun-rag `installer/scripts/bundle-components.mjs`
|
||
* CORE_COMPONENTS 的 build 參數對齊(那邊的 esbuild 呼叫即將被本腳本的成品取代)。
|
||
* name 用 arcrun-rag 那邊的慣例(`arcrun-<kebab>`),方便安裝器直接對號。 */
|
||
const WORKERS = [
|
||
{ name: 'arcrun-cypher-executor', dir: 'cypher-executor', entry: 'src/index.ts', stripServices: true },
|
||
{ name: 'arcrun-kbdb', dir: 'kbdb', entry: 'src/index.ts' },
|
||
{ name: 'arcrun-http-request', dir: '.component-builds/http_request', entry: 'src/index.ts' },
|
||
{ name: 'arcrun-code', dir: 'registry/components/code', entry: 'index.ts' },
|
||
{ name: 'arcrun-mcp', dir: 'mcp', entry: 'src/index.ts' },
|
||
];
|
||
|
||
function sha256(buf) {
|
||
return createHash('sha256').update(buf).digest('hex');
|
||
}
|
||
|
||
/** 檢查一個 worker 目錄的 node_modules 是否已按它自己的 lockfile 裝好(踩坑②的閘)。 */
|
||
function checkNodeModules(dir) {
|
||
const abs = join(REPO, dir);
|
||
const hasPnpmLock = existsSync(join(abs, 'pnpm-lock.yaml'));
|
||
const hasNpmLock = existsSync(join(abs, 'package-lock.json'));
|
||
if (!hasPnpmLock && !hasNpmLock) {
|
||
return { ok: false, reason: `${dir} 沒有 pnpm-lock.yaml 也沒有 package-lock.json——依賴版本無法鎖定` };
|
||
}
|
||
if (!existsSync(join(abs, 'node_modules'))) {
|
||
const cmd = hasPnpmLock ? 'pnpm install --frozen-lockfile' : 'npm ci';
|
||
return { ok: false, reason: `${dir}/node_modules 不存在——先在該目錄跑:${cmd}` };
|
||
}
|
||
return { ok: true, via: hasPnpmLock ? 'pnpm (frozen)' : 'npm ci' };
|
||
}
|
||
|
||
/** 極簡 wrangler.toml 讀取(沿用 arcrun-rag build-bundles.mjs 同款邏輯,只抓需要的欄位)。 */
|
||
function readToml(tomlPath) {
|
||
const t = existsSync(tomlPath) ? readFileSync(tomlPath, 'utf8') : '';
|
||
const spec = { kv: [], d1: [], vectorize: [], ai: null, vars: {}, compat_flags: [], compat_date: null };
|
||
const compatFlags = t.match(/compatibility_flags\s*=\s*\[([^\]]*)\]/);
|
||
if (compatFlags) spec.compat_flags = [...compatFlags[1].matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
|
||
const compatDate = t.match(/compatibility_date\s*=\s*["']([^"']+)["']/);
|
||
if (compatDate) spec.compat_date = compatDate[1];
|
||
for (const m of t.matchAll(/\[\[kv_namespaces\]\][\s\S]*?binding\s*=\s*["']([^"']+)["']/g)) spec.kv.push(m[1]);
|
||
for (const m of t.matchAll(/\[\[d1_databases\]\]([\s\S]*?)(?=\n\[|\n*$)/g)) {
|
||
const b = m[1].match(/binding\s*=\s*["']([^"']+)["']/);
|
||
const n = m[1].match(/database_name\s*=\s*["']([^"']+)["']/);
|
||
if (b) spec.d1.push({ binding: b[1], database_name: n ? n[1] : null });
|
||
}
|
||
for (const line of t.split('\n')) {
|
||
if (/^\s*\[\[vectorize\]\]/.test(line)) spec.vectorize.push(true);
|
||
}
|
||
if (/^\s*\[ai\]/m.test(t)) spec.ai = true;
|
||
const varsBlock = t.match(/\[vars\]([\s\S]*?)(?=\n\[|\n*$)/);
|
||
if (varsBlock) for (const m of varsBlock[1].matchAll(/^\s*([A-Z0-9_]+)\s*=\s*["']([^"']*)["']/gm)) spec.vars[m[1]] = m[2];
|
||
return spec;
|
||
}
|
||
|
||
/** esbuild plugin:.wasm import 攤平成同目錄檔名,記下要一起複製的 wasm part。 */
|
||
function wasmPlugin(wasmParts) {
|
||
return {
|
||
name: 'wasm-external',
|
||
setup(build) {
|
||
build.onResolve({ filter: /\.wasm$/ }, (args) => {
|
||
const abs = resolve(args.resolveDir, args.path);
|
||
const flat = basename(abs);
|
||
if (!wasmParts.find((w) => w.part === flat)) wasmParts.push({ part: flat, abs });
|
||
return { path: './' + flat, external: true };
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
/** 每個 worker 自己的 source_commit:答到單顆目錄層級,不是整包一個欄位(Arcrun#80 的核心要求)。 */
|
||
function sourceCommitFor(dir) {
|
||
try {
|
||
const hash = execSync(`git log -1 --format=%H -- ${JSON.stringify(dir)}`, { cwd: REPO, encoding: 'utf8' }).trim();
|
||
return hash || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function repoHead() {
|
||
try {
|
||
const sha = execSync('git rev-parse HEAD', { cwd: REPO, encoding: 'utf8' }).trim();
|
||
// .worker-builds 是本腳本自己的輸出目錄——它在「寫出成品之前」永遠是 untracked,
|
||
// 拿它判斷「原始碼乾不乾淨」是假陽性(自己把自己判成髒)。排除掉才是真正的
|
||
// 「原始碼有沒有未 commit 的變更」。
|
||
const dirty = execSync('git status --porcelain -- . ":(exclude).worker-builds"', { cwd: REPO, encoding: 'utf8' }).trim();
|
||
return { sha, dirty: !!dirty };
|
||
} catch {
|
||
return { sha: null, dirty: null };
|
||
}
|
||
}
|
||
|
||
async function buildOne(w) {
|
||
const dirAbs = join(REPO, w.dir);
|
||
const entryAbs = join(dirAbs, w.entry);
|
||
if (!existsSync(entryAbs)) throw new Error(`entry 不存在: ${entryAbs}`);
|
||
const outDir = join(OUT, w.name);
|
||
mkdirSync(outDir, { recursive: true });
|
||
|
||
// 踩坑①的解法核心:entry 用「相對 REPO」的路徑,absWorkingDir 固定為 REPO——
|
||
// esbuild 內部產生的檔案邊界字串因此只依賴這個相對路徑,與這個 clone 實際被
|
||
// 放在磁碟的哪個絕對路徑無關。
|
||
const entryRel = relative(REPO, entryAbs);
|
||
|
||
const wasmParts = [];
|
||
const result = await esbuild.build({
|
||
absWorkingDir: REPO,
|
||
entryPoints: [entryRel],
|
||
bundle: true,
|
||
format: 'esm',
|
||
platform: 'browser',
|
||
target: 'es2022',
|
||
outfile: relative(REPO, join(outDir, 'worker.mjs')),
|
||
external: ['cloudflare:*', 'node:*'],
|
||
plugins: [wasmPlugin(wasmParts)],
|
||
logLevel: 'silent',
|
||
metafile: true,
|
||
});
|
||
|
||
const modules = [];
|
||
for (const wp of wasmParts) {
|
||
if (!existsSync(wp.abs)) throw new Error(`wasm 找不到: ${wp.abs}(該 worker 需先 build/vendored wasm)`);
|
||
copyFileSync(wp.abs, join(outDir, wp.part));
|
||
modules.push({ name: wp.part, type: 'application/wasm', file: `${w.name}/${wp.part}`, sha256: sha256(readFileSync(wp.abs)) });
|
||
}
|
||
|
||
const spec = readToml(join(dirAbs, 'wrangler.toml'));
|
||
const jsBuf = readFileSync(join(outDir, 'worker.mjs'));
|
||
return {
|
||
name: w.name,
|
||
source_dir: w.dir,
|
||
source_commit: sourceCommitFor(w.dir),
|
||
main_module: 'worker.mjs',
|
||
main_file: `${w.name}/worker.mjs`,
|
||
js_bytes: jsBuf.length,
|
||
content_sha256: sha256(jsBuf),
|
||
modules,
|
||
compat_date: spec.compat_date,
|
||
compat_flags: spec.compat_flags,
|
||
requires: {
|
||
kv: spec.kv,
|
||
d1: spec.d1,
|
||
vectorize: spec.vectorize.length,
|
||
ai: !!spec.ai,
|
||
vars: spec.vars,
|
||
},
|
||
stripped: w.stripServices ? { services: 13 } : undefined,
|
||
warnings: result.warnings.map((x) => x.text),
|
||
};
|
||
}
|
||
|
||
async function main() {
|
||
console.log(`REPO = ${REPO}`);
|
||
|
||
const precheck = WORKERS.map((w) => ({ w, chk: checkNodeModules(w.dir) }));
|
||
const failed = precheck.filter((p) => !p.chk.ok);
|
||
if (failed.length) {
|
||
console.error('\n❌ 建置中止:以下 worker 尚未按 lockfile 裝好依賴(踩坑②的閘):\n');
|
||
for (const f of failed) console.error(` - ${f.chk.reason}`);
|
||
console.error('\n這是刻意設計:本腳本不自己跑 install,避免「install 方式不同 → 依賴版本不同 → 位元組不同」。');
|
||
process.exit(1);
|
||
}
|
||
console.log('✔ node_modules 檢查通過:');
|
||
for (const p of precheck) console.log(` ${p.w.dir} (${p.chk.via})`);
|
||
|
||
// ── 出貨閘:靜態租戶字串不得用於資料面過濾(Arcrun#108,#105 同族)─────────────
|
||
//
|
||
// 為什麼擋在**這裡**:這條路徑是成品的產地(.worker-builds/ → 使用者的機器)。
|
||
// 擋在這裡=違規的碼**編不出成品、出不了貨**,而不是「有人記得跑檢查才會發現」。
|
||
// leo 2026-08-12:「做一個平台要減少 hotfix。」規則存在但沒機制驗證,就是會再犯第三次。
|
||
//
|
||
// 規則本體是純函式(cypher-executor/scripts/tenant-source-rules.mjs),
|
||
// 由 cypher-executor/tests/tenant-gate.test.ts 逐條驗「壞例子會擋、合法寫法零誤攔」
|
||
// ——這道閘自己可測,也擋不到自己(掃描範圍只有 cypher-executor/src/)。
|
||
const tenantViolations = scanTenantSources(join(REPO, 'cypher-executor'));
|
||
if (tenantViolations.length) {
|
||
console.error('\n❌ 建置中止:cypher-executor 有「靜態租戶字串用於資料面過濾」的寫法(Arcrun#108 的閘):\n');
|
||
for (const v of tenantViolations) {
|
||
console.error(` [${v.rule}] ${v.file}:${v.line} ${v.text}`);
|
||
console.error(` → ${v.message}`);
|
||
}
|
||
console.error('\n知識資料面請用 knowledgeOwner(env) + ownerQuery()/ownerField()');
|
||
console.error('(cypher-executor/src/lib/tenant.ts 是租戶字串的唯一產地)。');
|
||
console.error('本機自查:cd cypher-executor && npm run check:tenant\n');
|
||
process.exit(1);
|
||
}
|
||
console.log('✔ 租戶來源檢查通過:cypher-executor 資料面 owner_id 全部來自 src/lib/tenant.ts');
|
||
|
||
if (CHECK_ONLY) {
|
||
console.log('\n--check-only:只驗證依賴就緒,不編譯。');
|
||
return;
|
||
}
|
||
|
||
mkdirSync(OUT, { recursive: true });
|
||
for (const w of WORKERS) {
|
||
const d = join(OUT, w.name);
|
||
if (existsSync(d)) rmSync(d, { recursive: true, force: true });
|
||
}
|
||
|
||
const head = repoHead();
|
||
const manifest = {
|
||
schema: 1,
|
||
built_for: 'arcrun-tier2-worker-artifacts',
|
||
generated_at: new Date().toISOString(),
|
||
repo_head: head.sha,
|
||
repo_dirty: head.dirty,
|
||
workers: [],
|
||
notes: [],
|
||
};
|
||
|
||
let failCount = 0;
|
||
for (const w of WORKERS) {
|
||
try {
|
||
const entry = await buildOne(w);
|
||
manifest.workers.push(entry);
|
||
const wasmNote = entry.modules.length ? ` +${entry.modules.length} wasm` : '';
|
||
console.log(`✔ ${w.name} js=${(entry.js_bytes / 1024).toFixed(0)}KB sha256=${entry.content_sha256.slice(0, 12)} source=${(entry.source_commit || '').slice(0, 8)}${wasmNote}`);
|
||
} catch (e) {
|
||
manifest.notes.push(`FAILED ${w.name}: ${e.message}`);
|
||
console.error(`✗ ${w.name}: ${e.message}`);
|
||
failCount++;
|
||
}
|
||
}
|
||
|
||
writeFileSync(join(OUT, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||
console.log(`\nmanifest → ${join(OUT, 'manifest.json')} (${manifest.workers.length}/${WORKERS.length} built)`);
|
||
if (failCount > 0 || head.dirty) {
|
||
if (head.dirty) console.error('⚠️ 工作區不乾淨(有未 commit 的變更)——這份成品的 repo_head 標記不完全可信,僅供本地驗證用。');
|
||
if (failCount > 0) process.exit(1);
|
||
}
|
||
}
|
||
main().catch((e) => { console.error(e); process.exit(1); });
|