45169ab25e
官方編譯點從 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>
425 lines
22 KiB
JavaScript
425 lines
22 KiB
JavaScript
#!/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/<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-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); });
|