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>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-ui-worker.mjs — 把 `console-ui/public` 內嵌成一顆單檔 worker(`arcrun-rag-ui`)。
|
||||
*
|
||||
* 🔴 2026-08-15(D91/Arcrun#125):這支是**從 arcrun-rag 搬過來的**。
|
||||
* leo 原話:「今天開始出貨一律不准在 arcrun rag 或任何別的地方 build,
|
||||
* **這就是 arcrun 的專屬工作**。你告訴我要把 cypher 搬到 arcrun,我說好,
|
||||
* 結果搞到現在還用違反的方式。」
|
||||
*
|
||||
* 實況(2026-08-14 查):五顆 tier2 worker 早就改成「向 Arcrun 取用成品」了
|
||||
* (`build-worker-artifacts.mjs` → `.worker-builds/`),**只有這顆 UI 沒搬**——
|
||||
* arcrun-rag 的 `installer/scripts/build-ui-bundle.mjs` 仍然自己讀本 repo 的
|
||||
* `console-ui/public`、自己拼裝出 worker 原始碼。於是:
|
||||
* · 用戶拿到的 portal 前端,其位元組沒有任何一個 Arcrun commit 說得出來源
|
||||
* · 「成品只有一個產地」這句話對六顆零件裡的五顆成立,對第六顆不成立
|
||||
* ⇒ 規則不是被誰違反,是**它從來沒有被機械驗證過**(同 D54/D64/D90 的形狀)。
|
||||
*
|
||||
* 搬過來之後,UI 與其他零件走同一條路:
|
||||
* 產地(本 repo)→ `.worker-builds/arcrun-rag-ui/worker.mjs` + manifest 指紋
|
||||
* → arcrun-rag 只做「複製 + 算版本」,不再有任何一行產生 worker 原始碼。
|
||||
*
|
||||
* ⚠️ 內嵌邏輯與兩道閘(世代閘、前端 JS 語法閘)**原樣搬過來,一個字沒改**——
|
||||
* 它們驗的是本 repo 的 `console-ui/public`,本來就該長在來源這一側。
|
||||
* 唯一改動:產出物開頭那行註解改成指向本檔(不留「描述不存在機制的註解」)。
|
||||
*
|
||||
* 對外只有一個 API:`buildUiWorker({ uiDir })` → `{ source, fingerprint, fileCount }`。
|
||||
* 寫檔與 manifest 由 `build-worker-artifacts.mjs` 統一處理(產物格式與其他零件一致)。
|
||||
*/
|
||||
import { readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative, extname } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* @param {{ uiDir: string }} opts uiDir=`console-ui/public` 的絕對路徑
|
||||
* @returns {Promise<{ source: string, fingerprint: string, fileCount: number }>}
|
||||
*/
|
||||
export async function buildUiWorker({ uiDir }) {
|
||||
const UI = uiDir;
|
||||
|
||||
const TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.mjs': 'application/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
const TEXT_EXT = new Set(['.html', '.js', '.mjs', '.css', '.json', '.svg']);
|
||||
// config.js 由 worker 動態產生(apiBase 注入),不吃靜態版
|
||||
const SKIP = new Set(['/config.js']);
|
||||
|
||||
// ⚠️ 一定要 sort():指紋是 JSON.stringify(files) 算的,鍵的順序=目錄列舉順序。
|
||||
// 不排序的話同樣的內容在不同機器會得到不同指紋(B 版就漏了這一步)。
|
||||
function walk(dir, base, out) {
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) { walk(p, base, out); continue; }
|
||||
const key = '/' + relative(base, p).split('\\').join('/');
|
||||
if (SKIP.has(key)) continue;
|
||||
const ext = extname(name);
|
||||
const type = TYPES[ext];
|
||||
if (!type) continue; // 未知型別不進 bundle
|
||||
if (TEXT_EXT.has(ext)) out[key] = { type, b64: false, data: readFileSync(p, 'utf8') };
|
||||
else out[key] = { type, b64: true, data: readFileSync(p).toString('base64') };
|
||||
}
|
||||
}
|
||||
|
||||
// ── 閘① t160 世代閘(B 版)─────────────────────────────────────────────
|
||||
// 打包前驗 portal 頁指紋——舊世代(缺「不需要人工新增」文案/含人工建庫「登記新庫」表單)
|
||||
// 直接拒打包。病史:t159 重打包吃了停在舊世代的分支 public/,把 leo 實例的 UI 打回
|
||||
// 被淘汰的人工登記典範。
|
||||
// 🔴 2026-08-02 修誤判:原本直接對整份原始碼 grep「登記新庫」,但**現行世代的 HTML
|
||||
// 註解裡就寫著「07-27 leo:拿掉『登記新庫』…」**——那是「已經拿掉了」的紀錄,
|
||||
// 是新世代的證據,卻被當成舊世代特徵 ⇒ 閘把對的東西擋下來。
|
||||
// 正解:**先剝掉註解再驗**,只看用戶真的會看到的 UI 內容。
|
||||
{
|
||||
const portalRaw = readFileSync(join(UI, 'portal', 'index.html'), 'utf8');
|
||||
const portal = portalRaw.replace(/<!--[\s\S]*?-->/g, '');
|
||||
if (!portal.includes('不需要人工新增') || portal.includes('登記新庫')) {
|
||||
console.error('✘ 世代閘:--ui 指向的 public 不是現行世代(缺「不需要人工新增」或含「登記新庫」)——拒絕打包舊 UI。');
|
||||
console.error(` 剝註解後:不需要人工新增=${portal.includes('不需要人工新增')}/登記新庫=${portal.includes('登記新庫')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const files = {};
|
||||
walk(UI, UI, files);
|
||||
if (!files['/portal/index.html']) throw new Error(`/portal/index.html 不在 ${UI} — 路徑錯了?`);
|
||||
|
||||
// ── 閘② t131 前端 JS 語法閘(A 版)────────────────────────────────────
|
||||
// 2026-07-29 事故:誤刪一個 `})();` ⇒ portal 白畫面,驗收沒抓到——
|
||||
// 因為測試只測後端、grep 只看字串,**沒有人驗過前端 JS 語法**。
|
||||
// 打包前用 node --check 驗每個 HTML 內嵌 script,語法錯就拒絕產出 bundle。
|
||||
{
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const { mkdtempSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'uicheck-'));
|
||||
for (const [name, f] of Object.entries(files)) {
|
||||
if (!name.endsWith('.html') || f.b64) continue;
|
||||
const blocks = [...String(f.data).matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)];
|
||||
blocks.forEach((m, i) => {
|
||||
const jsPath = join(tmp, `${name.replace(/[^\w]/g, '_')}_${i}.js`);
|
||||
writeFileSync(jsPath, m[1]);
|
||||
try {
|
||||
execFileSync(process.execPath, ['--check', jsPath], { stdio: 'pipe' });
|
||||
} catch (e) {
|
||||
const msg = String(e.stderr || e.message).split('\n').slice(0, 6).join('\n');
|
||||
throw new Error(`❌ ${name} 的第 ${i + 1} 個 <script> 有語法錯,拒絕打包(會導致白畫面):\n${msg}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log('✅ 前端 JS 語法閘通過');
|
||||
}
|
||||
|
||||
// 🔴 2026-08-02(leo 實撞:「一樣從 install 安裝,為什麼會裝到錯誤版本?」)
|
||||
// UI 內容指紋——**UI 過去沒有自己的版本號**,安裝器只能猜特徵
|
||||
// (t168 猜「/favicon.svg 開頭是不是 <svg」),而那只能分辨「有沒有 favicon 路由」
|
||||
// 這一個世代差 ⇒ 舊 UI 早就有 favicon 路由,於是被判定「已是新版」
|
||||
// ⇒ **整批跳過、UI 永遠不重推**,用戶重裝幾次都拿到舊前端。
|
||||
// 正解不是再想一個新特徵去猜(下個世代還會再猜錯),是**讓 UI 自己報版本**。
|
||||
const uiFingerprint = createHash('sha256').update(JSON.stringify(files)).digest('hex');
|
||||
|
||||
const worker = `// arcrun-rag-ui — 用戶實例的 GUI(console-ui/public 內嵌;由 Arcrun scripts/build-ui-worker.mjs 產生,勿手改)
|
||||
const FILES = ${JSON.stringify(files)};
|
||||
const UI_FINGERPRINT = ${JSON.stringify(uiFingerprint)};
|
||||
function b64ToBytes(b64) { const bin = atob(b64); const a = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) a[i] = bin.charCodeAt(i); return a; }
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
let p = url.pathname;
|
||||
// 🔴 UI 自報版本(安裝器據此判斷要不要重推,取代「猜 favicon 特徵」)。
|
||||
// 比照 cypher 的 /health。舊世代的 UI 沒有這條路由 ⇒ SPA fallback 回 HTML
|
||||
// ⇒ 安裝器解析 JSON 失敗=判定為舊版=重推。**天然向後相容,不需額外世代判斷。**
|
||||
// bundle_version 由安裝器以 ARCRUN_BUNDLE_VERSION 注入(cypher /health 同一個值);
|
||||
// 沒注入就回空字串,安裝器那端把空值一律當「舊的」處理。
|
||||
if (p === '/__version') {
|
||||
// 🔴 2026-08-08:多報 worker_subdomain / config_ok 兩格。
|
||||
// 這條路由是**安裝器唯一會去問實例的地方**(probeInstanceStale),
|
||||
// 以前它只報版本 ⇒「版本對、但 apiBase 空」的實例在安裝器眼中是「已是最新」
|
||||
// ⇒ 永遠不會被重推=壞掉的實例自己好不了。報了之後,安裝器把
|
||||
// config_ok:false 一律當成 stale ⇒ **下一次安裝/更新就自動治好它**。
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
ui_fingerprint: UI_FINGERPRINT,
|
||||
bundle_version: env.ARCRUN_BUNDLE_VERSION || '',
|
||||
worker_subdomain: env.WORKER_SUBDOMAIN || '',
|
||||
config_ok: Boolean(env.WORKER_SUBDOMAIN),
|
||||
}), {
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
||||
});
|
||||
}
|
||||
// config.js 動態產生:apiBase 指向同帳號的 cypher(WORKER_SUBDOMAIN 由安裝器注入)
|
||||
//
|
||||
// 🔴 2026-08-08 事故(leo 的 youlin 實例整頁不能用,頂端紅字「設定檔沒載入」):
|
||||
// 有人用 wrangler deploy 手動補這顆 worker ⇒ 繞過安裝器的變數注入
|
||||
// ⇒ WORKER_SUBDOMAIN 空 ⇒ 這裡**安靜地**退成 apiBase:""(而且回 HTTP 200!)
|
||||
// ⇒ 前端連不到自己的後端,但**部署是「成功」的**:worker 活著、curl 200、
|
||||
// /__version 版本也對——只有真人打開瀏覽器才看得出壞了。
|
||||
// 正解不是「退一個合理的預設值」(沒有合理預設,猜錯更慘),
|
||||
// 是**大聲失敗**:回 500+專用標頭,讓機器檢查一眼看穿。
|
||||
// 瀏覽器不執行非 2xx 的 <script>,所以用戶端行為與今天一致
|
||||
// (portal 照樣顯示「設定檔沒載入」紅字),差別是**機器也看得見了**。
|
||||
// no-store:這頁被邊緣快取過就會「修好了還是壞的」(08-08 實撞,害人誤判修復失敗)。
|
||||
if (p === '/config.js') {
|
||||
const sub = env.WORKER_SUBDOMAIN || '';
|
||||
if (!sub) {
|
||||
return new Response(CONFIG_MISSING_BODY, {
|
||||
status: 500,
|
||||
headers: {
|
||||
'content-type': TYPES_JS,
|
||||
'cache-control': 'no-store',
|
||||
'x-arcrun-config-error': 'WORKER_SUBDOMAIN-missing',
|
||||
},
|
||||
});
|
||||
}
|
||||
const apiBase = \`https://arcrun-cypher-executor.\${sub}.workers.dev\`;
|
||||
return new Response(\`window.ARCRUN_CONFIG = { apiBase: \${JSON.stringify(apiBase)} };\`, {
|
||||
headers: { 'content-type': TYPES_JS, 'cache-control': 'no-store' },
|
||||
});
|
||||
}
|
||||
if (p.endsWith('/')) p += 'index.html';
|
||||
let f = FILES[p] || FILES[p + '/index.html'] || FILES[p + '.html'];
|
||||
if (!f && !p.includes('.')) f = FILES[p + '/index.html'];
|
||||
if (!f) { p = '/portal/index.html'; f = FILES[p]; } // SPA fallback→portal
|
||||
if (!f) return new Response('not found', { status: 404 });
|
||||
const body = f.b64 ? b64ToBytes(f.data) : f.data;
|
||||
return new Response(body, { headers: { 'content-type': f.type, 'cache-control': 'no-cache' } });
|
||||
},
|
||||
};
|
||||
const TYPES_JS = 'application/javascript; charset=utf-8';
|
||||
const CONFIG_MISSING_BODY = [
|
||||
'/* ============================================================',
|
||||
' arcrun-rag-ui:WORKER_SUBDOMAIN 這個變數沒有被注入。',
|
||||
' 算不出 apiBase = 前端不知道自己的後端在哪 = 整個 portal 不能用。',
|
||||
'',
|
||||
' 成因幾乎一定是:這顆 worker 被手動 wrangler deploy 部署,繞過了注入。',
|
||||
' 修法(擇一):',
|
||||
' 1. 重跑一次安裝器(它會注入,且會把這顆判定為 stale 而重推)',
|
||||
' 2. node installer/scripts/deploy-ui.mjs --subdomain <你的 workers.dev 子網域>',
|
||||
' ============================================================ */',
|
||||
'throw new Error("arcrun-rag-ui: WORKER_SUBDOMAIN 未注入,apiBase 無法產生");',
|
||||
].join('\\n');
|
||||
`;
|
||||
return { source: worker, fingerprint: uiFingerprint, fileCount: Object.keys(files).length };
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-worker-artifacts.mjs — Arcrun#80:tier2 worker(TS→ 可部署 JS)的**唯一官方編譯點**。
|
||||
* 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 /
|
||||
@@ -9,6 +11,14 @@
|
||||
* 結果同一份原始碼在不同機器編出不同位元組(見下「已知踩坑」),
|
||||
* 而且「這顆成品是哪個 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 直接拿部署來源)。
|
||||
@@ -37,13 +47,15 @@
|
||||
* 輸出:.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 { 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 的根目錄」,
|
||||
@@ -52,17 +64,96 @@ 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 呼叫即將被本腳本的成品取代)。
|
||||
/** 引擎與工具 worker(各自的 dir/entry 不成規律,所以逐顆寫)。
|
||||
* name 用 arcrun-rag 那邊的慣例(`arcrun-<kebab>`),方便安裝器直接對號。 */
|
||||
const WORKERS = [
|
||||
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-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' },
|
||||
];
|
||||
|
||||
/** 零件 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');
|
||||
}
|
||||
@@ -143,7 +234,38 @@ function repoHead() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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}`);
|
||||
@@ -205,7 +327,8 @@ async function buildOne(w) {
|
||||
async function main() {
|
||||
console.log(`REPO = ${REPO}`);
|
||||
|
||||
const precheck = WORKERS.map((w) => ({ w, chk: checkNodeModules(w.dir) }));
|
||||
// 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');
|
||||
@@ -213,6 +336,10 @@ async function main() {
|
||||
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})`);
|
||||
|
||||
@@ -245,9 +372,17 @@ async function main() {
|
||||
}
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
for (const w of WORKERS) {
|
||||
const d = join(OUT, w.name);
|
||||
if (existsSync(d)) rmSync(d, { recursive: true, force: 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();
|
||||
@@ -258,6 +393,10 @@ async function main() {
|
||||
repo_head: head.sha,
|
||||
repo_dirty: head.dirty,
|
||||
workers: [],
|
||||
// 被刻意排除在公庫之外的零件——**寫出來**,不要安靜跳過:
|
||||
// 下游(arcrun-rag 的懶載清單)看得到「這顆不在公庫、以及為什麼」,
|
||||
// 才不會像 2026-08-14 那樣把「沒有貨」誤讀成「機制壞了」。
|
||||
excluded: EXCLUDED_COMPONENTS,
|
||||
notes: [],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user