From 45169ab25e974085aaaebaceeb4a67f785239d1b Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 15 Aug 2026 00:17:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(build):=20=E4=B8=80=E5=80=8B=E5=AF=A6?= =?UTF-8?q?=E4=BE=8B=E6=9C=83=E7=94=A8=E5=88=B0=E7=9A=84=E6=AF=8F=E4=B8=80?= =?UTF-8?q?=E9=A1=86=20worker=20=E9=83=BD=E5=9C=A8=20Arcrun=20=E7=B7=A8?= =?UTF-8?q?=EF=BC=88D91=EF=BC=8FArcrun#125=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官方編譯點從 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 --- scripts/build-ui-worker.mjs | 208 +++++++++++++++++++++++++++++ scripts/build-worker-artifacts.mjs | 159 ++++++++++++++++++++-- 2 files changed, 357 insertions(+), 10 deletions(-) create mode 100644 scripts/build-ui-worker.mjs diff --git a/scripts/build-ui-worker.mjs b/scripts/build-ui-worker.mjs new file mode 100644 index 0000000..1ce075f --- /dev/null +++ b/scripts/build-ui-worker.mjs @@ -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(//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(/]*\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} 個