#!/usr/bin/env node /** * build-worker-artifacts.mjs — **一個 Arcrun 實例會用到的每一顆 worker,都在這裡編**。 * 這是 D49/D91 的那個「唯一官方編譯點」:成品放 `.worker-builds/`、commit 進 repo, * 任何人(安裝器、出貨線、self-host 使用者)要用零件都來這裡拿,不准自己編一份。 * * 背景(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` 欄位,答不出單顆的來源。 * * 🔴 2026-08-15(D91/Arcrun#125)本檔的涵蓋範圍從 5 顆擴到 23 顆,理由不是「順手多編幾顆」: * · 只編 5 顆 ⇒ 下游 bundle 裡就只有 5 顆 ⇒ **「零件用到才下載」沒有貨可以下載**。 * 而 `auth_static_key` 是每一支產品工作流解 `{{credential.X}}` 都會打的那一顆 * ⇒ 走網頁安裝器裝出來的實例,工作流一跑就 500(Arcrun#124)。 * · portal 前端(`arcrun-rag-ui`)當時仍由 arcrun-rag 自己拼裝 * ⇒ 使用者拿到的那顆 worker,沒有任何 Arcrun commit 說得出它的來源。 * ⇒ 現在三種東西走同一條路、同一份 manifest:引擎 worker/零件 worker/內嵌 UI worker。 * * 本腳本要解的: * 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//worker.mjs (+ *.wasm)、.worker-builds/manifest.json */ import esbuild from 'esbuild'; import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, rmSync, readdirSync, statSync } 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'; // UI worker 的內嵌器(2026-08-15 從 arcrun-rag 搬回來,D91)。 import { buildUiWorker } from './build-ui-worker.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'); /** 引擎與工具 worker(各自的 dir/entry 不成規律,所以逐顆寫)。 * name 用 arcrun-rag 那邊的慣例(`arcrun-`),方便安裝器直接對號。 */ const ENGINE_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-code', dir: 'registry/components/code', entry: 'index.ts' }, { name: 'arcrun-mcp', dir: 'mcp', entry: 'src/index.ts' }, ]; /** 零件 worker 的家。底下每個子目錄=一顆零件(TinyGo 編好的 `component.wasm` * +一層 TS 外殼),格式一致,所以用**掃描**而不是手寫清單。 */ const COMPONENT_BUILDS = '.component-builds'; /** * 掃出所有零件 worker。 * * 🔴 2026-08-15(Arcrun#125/D91):這裡以前只手寫了 `http_request` **一顆**, * 其餘 18 顆零件從來沒有官方成品。後果不是「少編了幾顆」,是 * **「用到才下載」沒有貨可以下載**——arcrun-rag 的 bundle 只裝得到本檔編出來的東西, * 本檔沒編,公庫裡就永遠沒有它。而 `auth_static_key` 正是每一支產品工作流 * 解 `{{credential.X}}` 都會打的那一顆 ⇒ 凡走網頁安裝器裝出來的實例, * 工作流一跑就 500(Arcrun#124 的 `error code: 1042`)。 * * 🔴 **不准改回手寫清單**:手寫清單就是 arcrun-rag#27/D48 那個病的形狀 * ——「一份人維護的清單」與「磁碟上真的有什麼」必然漂移,而漂移的方向只有一個: * 新增的零件沒人記得加進去,於是它對使用者而言不存在。 * worker 名一律讀該零件自己的 `wrangler.toml`(那是它部署時真正會叫的名字), * 不由本檔從目錄名推導——推導=再造一份會漂的真相。 */ function discoverComponentWorkers() { const base = join(REPO, COMPONENT_BUILDS); if (!existsSync(base)) return { workers: [], excluded: [] }; const workers = []; const excluded = []; for (const id of readdirSync(base).sort()) { const dir = join(COMPONENT_BUILDS, id); const abs = join(REPO, dir); if (!statSync(abs).isDirectory()) continue; const entry = 'src/index.ts'; if (!existsSync(join(abs, entry))) continue; // 不是一顆 worker(例如暫存目錄) const name = readWorkerName(join(abs, 'wrangler.toml')); if (!name) throw new Error(`${dir}/wrangler.toml 沒有 name=這顆零件部署後會叫什麼名字沒人說得出來`); // 🔴 可出貨的判準=**它的部署位元組在版控裡**,不是「這台機器的磁碟上有」。 // `.gitignore` 明文排除三顆(claude_api/km_writer/kbdb_upsert_block)——理由不是 // 「還沒編」,是 DECISIONS §1「它們被錯做成零件,該降級成工作流/recipe」。 // 本機磁碟上仍留著上次編的 wasm,所以「有沒有檔案」分辨不出這件事,「有沒有進版控」可以。 // ⇒ 用這個判準,未來任何一顆被判定不該出貨的零件,**不必回來改本檔**就會自動落在公庫外。 if (!isTracked(join(dir, 'component.wasm'))) { excluded.push({ name, dir, reason: 'component.wasm 不在版控(.gitignore 明文排除=這顆不是可出貨的零件)' }); continue; } workers.push({ name, dir, entry, component: id }); } return { workers, excluded }; } /** 這個路徑有沒有被 git 追蹤(=任何 clone 都拿得到)。 */ function isTracked(relPath) { try { const out = execSync(`git ls-files -- ${JSON.stringify(relPath)}`, { cwd: REPO, encoding: 'utf8' }).trim(); return !!out; } catch { return false; } } /** 從 wrangler.toml 讀 `name = "..."`(worker 部署後的真名)。 */ function readWorkerName(tomlPath) { if (!existsSync(tomlPath)) return null; const m = readFileSync(tomlPath, 'utf8').match(/^\s*name\s*=\s*["']([^"']+)["']/m); return m ? m[1] : null; } /** 使用者實例的 GUI:沒有原始碼要打包,是把 `console-ui/public` 內嵌成單檔 worker。 * 2026-08-15 從 arcrun-rag 搬回來(D91)——見 scripts/build-ui-worker.mjs 檔頭。 */ const UI_WORKER = { name: 'arcrun-rag-ui', dir: 'console-ui/public', builder: 'ui', // 這顆沒有 wrangler.toml(它不由 wrangler 部署,是安裝器用 CF API 上傳的), // 所以 compat 值寫在這裡。**沿用搬家前 arcrun-rag manifest 條目的原值**, // 讓這次搬家只改「誰產生它」,不改「它是什麼」。 compat_date: '2026-07-01', compat_flags: [], }; const { workers: COMPONENT_WORKERS, excluded: EXCLUDED_COMPONENTS } = discoverComponentWorkers(); const WORKERS = [...ENGINE_WORKERS, ...COMPONENT_WORKERS, UI_WORKER]; 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 }; } } /** UI worker:沒有原始碼要打包,是把 `console-ui/public` 內嵌成一顆單檔 worker。 * 產出格式與 esbuild 那條一致(worker.mjs + manifest 條目),下游因此不必分辨它是哪種。 */ async function buildUiOne(w) { const outDir = join(OUT, w.name); mkdirSync(outDir, { recursive: true }); const { source, fingerprint, fileCount } = await buildUiWorker({ uiDir: join(REPO, w.dir) }); const outFile = join(outDir, 'worker.mjs'); writeFileSync(outFile, source); const jsBuf = readFileSync(outFile); 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: w.compat_date, compat_flags: w.compat_flags, // 內嵌 UI 不需要任何 binding;它只吃兩個由安裝器注入的 var // (WORKER_SUBDOMAIN 算 apiBase、ARCRUN_BUNDLE_VERSION 回報版本)。 requires: { kv: [], d1: [], vectorize: 0, ai: false, vars: {} }, // UI 自己的內容指紋——安裝器用它判斷「這台實例的前端要不要重推」。 ui_fingerprint: fingerprint, ui_file_count: fileCount, warnings: [], }; } async function buildOne(w) { if (w.builder === 'ui') return buildUiOne(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}`); // UI worker 沒有依賴要鎖(它內嵌的是靜態檔,不 import 任何套件)⇒ 不進這道閘。 const precheck = WORKERS.filter((w) => w.builder !== 'ui').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); } if (EXCLUDED_COMPONENTS.length) { console.log(`\nℹ️ 刻意不進公庫的零件(${EXCLUDED_COMPONENTS.length}):`); for (const e of EXCLUDED_COMPONENTS) console.log(` - ${e.name}:${e.reason}`); } 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 }); // 🔴 輸出目錄要**只**留這次編出來的東西——包含「上次編了、這次不該再編」的那些。 // 不清掉就是棘輪(D48/arcrun-rag#27 的真兇):東西一旦掉進成品目錄就永遠留著, // 沒有任何路徑會把它拿掉,於是「公庫裡有什麼」跟「本檔說該有什麼」慢慢分家。 // 實撞(2026-08-15):claude_api 被判定不該出貨後,它上一輪的成品仍留在 .worker-builds/。 const want = new Set(WORKERS.map((w) => w.name)); for (const name of readdirSync(OUT)) { if (name === 'manifest.json') continue; const d = join(OUT, name); if (!statSync(d).isDirectory()) continue; if (!want.has(name)) console.log(` 🧹 清掉不該在公庫裡的舊成品:${name}`); 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: [], // 被刻意排除在公庫之外的零件——**寫出來**,不要安靜跳過: // 下游(arcrun-rag 的懶載清單)看得到「這顆不在公庫、以及為什麼」, // 才不會像 2026-08-14 那樣把「沒有貨」誤讀成「機制壞了」。 excluded: EXCLUDED_COMPONENTS, 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); });