Files
Arcrun/scripts/build-worker-artifacts.mjs
uncle6me-web 45169ab25e feat(build): 一個實例會用到的每一顆 worker 都在 Arcrun 編(D91/Arcrun#125)
官方編譯點從 5 顆擴到 23 顆,並把 portal 前端的產生器從 arcrun-rag 搬回來。

為什麼不是「順手多編幾顆」:
- 只編 5 顆 ⇒ 下游 bundle 裡就只有 5 顆 ⇒「零件用到才下載」沒有貨可以下載。
  而 auth_static_key 是每一支產品工作流解 {{credential.X}} 都會打的那一顆
  ⇒ 走網頁安裝器裝出來的實例,工作流一跑就 500(Arcrun#124 的 error code: 1042)。
- portal 前端(arcrun-rag-ui)當時仍由 arcrun-rag 自己拼裝原始碼
  ⇒ 使用者拿到的那顆 worker,沒有任何 Arcrun commit 說得出它的來源。
  leo:「今天開始出貨一律不准在 arcrun rag 或任何別的地方 build,這就是 arcrun 的專屬工作。」

做法:
- 零件 worker 改用**掃描** .component-builds/(不是手寫清單——手寫清單就是
  arcrun-rag#27/D48 那個病的形狀),worker 名一律讀該零件自己的 wrangler.toml。
- 可出貨的判準=**部署位元組在版控裡**。.gitignore 明文排除的三顆
  (claude_api/km_writer/kbdb_upsert_block,DECISIONS §1「錯做成零件」)
  因此自動落在公庫外,而且是**寫進 manifest.excluded 的**,不是安靜跳過。
- 輸出目錄改成每次只留這一輪該有的,清掉舊成品(拔掉棘輪)。
- UI 產生器原樣搬過來,兩道閘(世代閘、前端 JS 語法閘)一起搬——
  它們驗的是本 repo 的 console-ui/public,本來就該長在來源這一側。

實測(本機 worktree,依 lockfile 重裝依賴後):
- 23/23 編成功;四顆既有引擎成品**位元組完全重現** committed 值
  (cypher 8e6487478bc8/kbdb f15e16cca6ce/code 751634a3fc9a/mcp 9bc8dffb34d0/
   http-request cdd97364f277)⇒ 這次擴充沒有動到既有零件。
- arcrun-rag-ui 的產出與搬家前逐位元比對,**只差第一行註解**
  (那行必須改:它原本指向一支不再產生它的腳本)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:59:59 +08:00

425 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* build-worker-artifacts.mjs — **一個 Arcrun 實例會用到的每一顆 worker,都在這裡編**。
* 這是 D49/D91 的那個「唯一官方編譯點」:成品放 `.worker-builds/`、commit 進 repo
* 任何人(安裝器、出貨線、self-host 使用者)要用零件都來這裡拿,不准自己編一份。
*
* 背景(Arcrun#80arcrun-rag#39):這個 repo 過去只把 tier1 零件(TinyGo→wasm)的成品
* commit 進 `.component-builds/{name}/component.wasm`tier2cypher-executor / kbdb /
* http_request / code / mcp 這五顆 TS worker)只有原始碼,沒有編好的成品。於是
* arcrun-rag 的安裝器只好自己在**它那邊**跑 esbuild`installer/scripts/build-bundles.mjs`),
* 結果同一份原始碼在不同機器編出不同位元組(見下「已知踩坑」),
* 而且「這顆成品是哪個 commit 編的」只有整包一個 `source` 欄位,答不出單顆的來源。
*
* 🔴 2026-08-15D91Arcrun#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 根目錄**(不吃外部 cwdenv
* 路徑),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, 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-<kebab>`),方便安裝器直接對號。 */
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-15Arcrun#125D91):這裡以前只手寫了 `http_request` **一顆**
* 其餘 18 顆零件從來沒有官方成品。後果不是「少編了幾顆」,是
* **「用到才下載」沒有貨可以下載**——arcrun-rag 的 bundle 只裝得到本檔編出來的東西,
* 本檔沒編,公庫裡就永遠沒有它。而 `auth_static_key` 正是每一支產品工作流
* 解 `{{credential.X}}` 都會打的那一顆 ⇒ 凡走網頁安裝器裝出來的實例,
* 工作流一跑就 500Arcrun#124 的 `error code: 1042`)。
*
* 🔴 **不准改回手寫清單**:手寫清單就是 arcrun-rag#27D48 那個病的形狀
* ——「一份人維護的清單」與「磁碟上真的有什麼」必然漂移,而漂移的方向只有一個:
* 新增的零件沒人記得加進去,於是它對使用者而言不存在。
* 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_apikm_writerkbdb_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 });
// 🔴 輸出目錄要**只**留這次編出來的東西——包含「上次編了、這次不該再編」的那些。
// 不清掉就是棘輪(D48arcrun-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); });