diff --git a/cli/package.json b/cli/package.json index 9905c5f..2a6baf9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -12,7 +12,7 @@ "build:harness": "node scripts/build-harness-skill.mjs", "check:harness": "node scripts/check-harness-generation.mjs", "dev": "tsc --watch", - "test": "node --test \"tests/**/*.test.ts\"", + "test": "node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"", "prepublishOnly": "npm run build && chmod +x dist/index.js" }, "dependencies": { diff --git a/cli/src/lib/cf-api.ts b/cli/src/lib/cf-api.ts index 64ccfd2..378611e 100644 --- a/cli/src/lib/cf-api.ts +++ b/cli/src/lib/cf-api.ts @@ -170,10 +170,13 @@ export class CfAccountClient implements ResourceApi { const path = `/workers/scripts/${encodeURIComponent(script)}/settings`; const res = await this.cfRaw<{ bindings?: RawWorkerBinding[] }>(path); if (!res.ok) { - if (res.status === 404) return { deployed: false, bindings: [] }; + if (res.status === 404) return { deployed: false, bindings: [], vars: {} }; throw new Error(`讀 ${script} 綁定失敗:${res.error}`); } - return { deployed: true, bindings: normalizeBindings(res.result?.bindings ?? []) }; + const raw = res.result?.bindings ?? []; + // #106:同一份回應裡也帶著 plain_text var(實測 CF `/settings` 會回 `text` 值)。 + // 舊版只挑資源類、把 var 整批丟掉 → 重部署等於把它們洗掉。 + return { deployed: true, bindings: normalizeBindings(raw), vars: normalizeVars(raw) }; } /** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/ @@ -234,6 +237,22 @@ interface RawWorkerBinding { id?: string; database_id?: string; index_name?: string; + /** `plain_text` 綁定的值(#106;secret_text 不會回值,本來就讀不到,也不該讀)。 */ + text?: string; +} + +/** + * 抽出已部署 worker 上的 `plain_text` var(#106)。 + * + * 只收 `plain_text`——**`secret_text` 一律不碰**(CF 本來就不回值,也不該被 CLI 搬來搬去; + * wrangler deploy 不會動 secret,它們自己會留著)。 + */ +function normalizeVars(raw: RawWorkerBinding[]): Record { + const out: Record = {}; + for (const b of raw) { + if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text; + } + return out; } /** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */ diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index 2321589..029e714 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -98,6 +98,119 @@ function giteaToken(): string | undefined { return process.env.ARCRUN_GITEA_TOKEN || process.env.GITEA_TOKEN || undefined; } +/** + * 版本標籤的「發行頻道」來源(Arcrun#106)。 + * + * Portal 設定頁與 daemon `cloudVersionStale()` 都是拿**這支**回的 `release` 當「最新版」, + * 再跟實例 `/health` 的 `bundle_version` 比。CLI 更新完若不烙一個同一把尺量得出來的版號, + * 使用者就只會看到「無法讀取目前版本」或永遠「落後」。 + * fork/自架另有發行頻道者用 ARCRUN_RELEASE_API 覆蓋,不寫死。 + */ +const ARCRUN_RELEASE_API = process.env.ARCRUN_RELEASE_API ?? 'https://install.arcrun.dev/api/latest'; + +/** CLI 自己負責注入 / 自己烙的 var——**不從已部署的 worker 沿用**(沿用會蓋掉這趟算出來的正解)。 */ +export const CLI_MANAGED_VARS = [ + 'WORKER_SUBDOMAIN', // 由 ctx.workerSubdomain 注入 + 'CF_ACCOUNT_ID', // 由 ctx.accountId 注入 + 'MULTI_TENANT', // 由 selfHosted 注入 + 'KBDB_BASE_URL', // 由 workerSubdomain 組 + 'ARCRUN_BUNDLE_VERSION', // 版本標籤:每趟重烙,**絕不沿用舊值**(見 resolveBundleStamp) + 'ARCRUN_BUNDLE_COMMIT', +] as const; + +/** 烙版本標籤的那顆 worker(`/health` 就是它吐的)。其餘 worker 不需要版本標籤。 */ +export const VERSION_STAMP_WORKER = 'arcrun-cypher-executor'; + +/** 這趟部署要烙上去的版本標籤。 */ +export interface BundleStamp { + /** 寫進 `ARCRUN_BUNDLE_VERSION`。 */ + version: string; + /** 寫進 `ARCRUN_BUNDLE_COMMIT`(查得到才有)。 */ + commit?: string; + /** 給人看的一句話(CLI 會印出來),說明這個版號是怎麼來的。 */ + note: string; +} + +/** + * 算「這趟部署上去的東西,該叫幾版」(Arcrun#106)。 + * + * 🔴 為什麼**不是沿用實例上原本那個值**:那個值描述的是**當時裝上去的那份程式碼**。 + * 更新完程式碼換了,標籤沒換 = 一個永遠停在安裝當天的假標籤——比沒有標籤更糟, + * 因為 leo 會拿它當「我驗收過了」。版本標籤是**成品的屬性**,不是使用者的設定, + * 所以它是唯一一個「不沿用、每趟重烙」的 var(其餘 plain_text var 一律沿用,見 preservedVars)。 + * + * 誠實邊界(mindset §7,這段要留著): + * - CLI 部的是 `ARCRUN_REPO@ref` 的**原始碼**,發行版號(semver)是**安裝器頻道**在發的, + * 兩者不是同一套編號。這裡取的是「部署當下該頻道公告的 release」, + * 語義=「我跟這個頻道的最新發行同源」,並**另外把真正的 commit 一起烙上去** + * (`ARCRUN_BUNDLE_COMMIT`/`/health` 的 `bundle_commit`)→ 有沒有漂掉,看 commit 就查得出來。 + * - 查不到 release(離線/頻道掛了)→ **不猜、不掰**,退成 `YYYY-MM-DD+` 這個 + * 舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」—— + * 那正是我們想要的:**寧可說不準,也不要假裝已是最新**。 + */ +export async function resolveBundleStamp( + ref: string, + commit?: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const short = commit ? commit.slice(0, 7) : ref; + const today = new Date().toISOString().slice(0, 10); + try { + const res = await fetchImpl(ARCRUN_RELEASE_API, { signal: AbortSignal.timeout(15_000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as { release?: string } | null; + const release = String(body?.release ?? '').trim(); + if (!/^\d+\.\d+\.\d+$/.test(release)) throw new Error(`發行頻道回的版號不是 semver(${release || '空'})`); + return { + version: release, + commit, + note: `${release}(發行頻道 ${ARCRUN_RELEASE_API}${commit ? `;實際部署 commit ${short}` : ''})`, + }; + } catch (e) { + const version = `${today}+${short}`; + return { + version, + commit, + note: + `${version}(查不到發行版號:${e instanceof Error ? e.message : String(e)})` + + `\n → 誠實標成 commit 版;Portal 會顯示成「較舊版本」而不是假裝已是最新。`, + }; + } +} + +/** + * 把 `ref`(branch / tag / sha)解析成確切的 commit sha(Arcrun#106)。 + * + * 兩個用途:① 版本標籤要烙「真的部了哪個 commit」;② 解出來之後**直接用 sha 下載 archive**—— + * sha 是不可變的,順帶把 #13 P2 的「branch tarball 被中間層快取成舊的」整個病根拿掉。 + * 查不到就回 undefined(呼叫端退回原本的用 ref 下載,行為不變)——這條路徑不該讓更新失敗。 + */ +export async function resolveGiteaCommit( + ref: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const headers = buildDownloadHeaders(); + const tryUrls = [ + `${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/branches/${encodeURIComponent(ref)}`, + `${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/commits?sha=${encodeURIComponent(ref)}&limit=1&stat=false`, + ]; + for (const url of tryUrls) { + try { + const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(20_000) }); + if (!res.ok) continue; + const body = (await res.json()) as + | { commit?: { id?: string } } + | Array<{ sha?: string }> + | null; + const sha = Array.isArray(body) ? body[0]?.sha : body?.commit?.id; + if (typeof sha === 'string' && /^[0-9a-f]{7,64}$/i.test(sha)) return sha; + } catch { + /* 換下一種問法;全都問不到就回 undefined */ + } + } + return undefined; +} + /** * 組 Gitea archive 下載 URL(純函式,好離線測 URL 組裝)。 * Gitea archive API:`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。 @@ -253,9 +366,12 @@ export async function downloadAndDeploy( const mode = opts.mode ?? 'update'; const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken); // 1. 下載 + 解壓 Gitea archive tarball + // #106:先把 ref 解析成確切 commit,**用 sha 下載**(不可變 → 順帶解掉 branch tarball 被快取的老問題), + // 同一個 sha 稍後也會被烙成版本標籤。解不出來就照舊用 ref 下載(行為不變)。 + const commit = await resolveGiteaCommit(ref); let root: string; try { - root = await downloadRepoTarball(ref); + root = await downloadRepoTarball(commit ?? ref, commit ? ref : undefined); } catch (e) { return { implemented: true, @@ -310,6 +426,7 @@ export async function downloadAndDeploy( // 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。 const requirements: BindingRequirement[] = []; const tomlPreviews = new Map(); // dir → 注入前的原文 + const dirScript = new Map(); // dir → worker script 名(#106:var 沿用要逐顆對號) for (const dir of allDirs) { const tomlPath = join(dir, 'wrangler.toml'); if (!existsSync(tomlPath)) continue; @@ -318,12 +435,14 @@ export async function downloadAndDeploy( const preview = renderWranglerToml(raw, ctx, new Map()); const parsed = parseWranglerRequirements(preview); if (!parsed.script) continue; // 沒宣告 name 的 toml 不該存在;跳過而非亂猜 + dirScript.set(dir, parsed.script); for (const b of parsed.bindings) { requirements.push({ ...b, worker: parsed.script }); } } let resolved = new Map(); + let liveVars = new Map>(); if (requirements.length > 0) { process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...')); let plan; @@ -369,6 +488,7 @@ export async function downloadAndDeploy( message: `停手:\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`, }; } + liveVars = plan.liveVars; console.log(chalk.green(' ✓')); const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted'); const created = [...resolved.values()].filter((r) => r.origin === 'created'); @@ -407,6 +527,48 @@ export async function downloadAndDeploy( } } + // ── 2.8 var(plain_text):既有的沿用、版本標籤重烙(Arcrun#106)───────────────── + // + // 🔴 #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),但 **var 這批「櫃子上的標籤」沒人管**: + // wrangler deploy 是整份覆蓋,toml 沒寫的 var 直接消失。leo 2026-08-12 實撞的畫面 + // 「無法讀取目前版本(知識庫服務可能正在啟動)」就是 `ARCRUN_BUNDLE_VERSION` 被這樣洗掉的。 + // + // 兩種 var 走**相反**的規則,這是本次的核心判斷: + // · 設定類(PORTAL_MAIL_RELAY_BASE / CONSOLE_TENANT / …)=**使用者實例的事實** → 沿用 + // · 版本標籤(ARCRUN_BUNDLE_VERSION)=**這份成品的屬性** → 每趟重烙,沿用舊值就是假標籤 + // + // 範圍註記:`liveVars` 來自資源解析那一趟讀到的 worker(=有資源綁定的那些:cypher/kbdb/mcp/registry)。 + // 純零件 worker 沒有資源綁定、不在那份名單裡 → 這裡不會沿用它們的 var。目前它們的 var 只有 + // toml 自己帶的 `COMPONENT_ID`,沒有東西可丟;若哪天有人往零件 worker 注入設定,要在這裡補讀。 + const extraVarsByDir = new Map>(); + let stamp: BundleStamp | undefined; + if (dirScript.size > 0) { + const needStamp = [...dirScript.values()].includes(VERSION_STAMP_WORKER); + if (needStamp) { + process.stdout.write(chalk.gray(' → 算這趟要烙上去的版本標籤...')); + stamp = await resolveBundleStamp(ref, commit); + console.log(chalk.green(' ✓')); + console.log(chalk.gray(` ARCRUN_BUNDLE_VERSION = ${stamp.note}`)); + } + const preservedTotal: string[] = []; + for (const [dir, script] of dirScript) { + const raw = tomlPreviews.get(dir); + if (!raw) continue; + const keep = preservedVars(liveVars.get(script), raw); + for (const k of Object.keys(keep)) preservedTotal.push(`${script}:${k}`); + const vars: Record = { ...keep }; + if (stamp && script === VERSION_STAMP_WORKER) { + vars.ARCRUN_BUNDLE_VERSION = stamp.version; + if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit; + } + if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars); + } + if (preservedTotal.length > 0) { + console.log(chalk.gray(` 沿用你實例上既有的 ${preservedTotal.length} 個設定值(var):`)); + for (const item of preservedTotal) console.log(chalk.gray(` = ${item}`)); + } + } + // 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。 // 逐 worker 串流進度(每個含 pnpm install + wrangler deploy,沉默會讓人以為卡住—— // 壓測 2026-06-11 richblack 觀察:「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker)。 @@ -422,7 +584,7 @@ export async function downloadAndDeploy( const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, ''); process.stdout.write(chalk.gray(` [${i + 1}/${allDirs.length}] ${label} ...`)); try { - injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir)); + injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir), extraVarsByDir.get(dir)); // 注入後算指紋:與 manifest 比,相同 = 上次成功部過且內容沒變 → 跳過。 const hash = dirContentHash(dir, ctx.accountId); if (manifest[label] === hash) { @@ -599,11 +761,13 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: str * 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過快取,每次抓到 ref 的最新內容。 * * Arcrun#4:來源由 GitHub codeload 改為 Gitea archive API(走 GITEA_TOKEN,不寫死)。*/ -async function downloadRepoTarball(ref: string): Promise { +async function downloadRepoTarball(ref: string, fromRef?: string): Promise { // 唯一 cache-buster query param:對不同 query 視為不同請求 → 繞過 stale 快取。 const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const url = buildArchiveUrl(ref, bust); - console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${ref},約 10–30 秒,視網速)...`)); + // fromRef 有值 = ref 已被解析成 commit sha(#106),印出來讓人看得到「這趟到底部了哪個 commit」。 + const label = fromRef ? `${fromRef} → ${ref.slice(0, 7)}` : ref; + console.log(chalk.gray(` → 從 Gitea 下載最新版本(${ARCRUN_REPO}@${label},約 10–30 秒,視網速)...`)); const res = await fetch(url, { signal: AbortSignal.timeout(120_000), // 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball(#13 P2 假綠根因)。 @@ -701,11 +865,91 @@ function injectWranglerConfig( ctx: DeployContext, resolved: Map, original?: string, + extraVars: Record = {}, ): void { if (!existsSync(tomlPath)) return; // original = 資源解析階段讀到的原文。用它而不是重讀檔案,確保「解析看到的」與「寫回去的」同源。 const toml = original ?? readFileSync(tomlPath, 'utf8'); - writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved), 'utf8'); + writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved, extraVars), 'utf8'); +} + +/** + * 挑出「這顆已部署的 worker 上有、但這版 toml 不會自己帶的」plain_text var(Arcrun#106)。 + * + * 規則就一句:**已部署 worker 上掛著什麼 var,那就是事實**(#97 對資源講的那句話, + * 原封不動套用在標籤上)。所以預設全部沿用,只有兩種例外: + * ① `CLI_MANAGED_VARS`——這趟由 CLI 自己算(帳號 id/subdomain/單租戶旗標/版本標籤), + * 沿用等於拿舊值蓋掉正解。 + * ② 值一模一樣的(toml 已經寫了同樣的值)——寫進去只是雜訊,略過。 + * + * ⚠️ 這裡刻意**不**做「toml 有宣告就以 toml 為準」:那正是這次的病 + * ——repo toml 裡的 `CONSOLE_TENANT = "leo"`/`WORKER_SUBDOMAIN` 之類是**官方 prod 的值**, + * 拿它蓋掉使用者實例上的值,就是「更新一次把人家的設定洗成官方預設」。 + */ +export function preservedVars( + live: Record | undefined, + toml: string, +): Record { + const out: Record = {}; + if (!live) return out; + const managed = new Set(CLI_MANAGED_VARS); + for (const key of Object.keys(live).sort()) { + if (managed.has(key)) continue; + if (!/^[A-Za-z0-9_]+$/.test(key)) continue; // 怪名字不碰(applyVars 也會擋,這裡先濾掉不誤報) + if (readVar(toml, key) === live[key]) continue; // toml 已經是同一個值 → 不必動 + out[key] = live[key]; + } + return out; +} + +/** 讀 toml 裡某個 var 目前的值(只看未註解的行)。找不到回 undefined。 */ +function readVar(toml: string, key: string): string | undefined { + const m = toml.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm')); + return m?.[1]; +} + +/** TOML basic string 轉義(值裡可能有引號/反斜線,例如網址或 JSON 片段)。 */ +function tomlEscape(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +/** + * 把一組 var 寫進 toml 的 `[vars]`(Arcrun#106)。純函式。 + * + * 三種既有狀態各自處理(比照 injectMultiTenant,同一種文字操作層級): + * 1. 已有未註解的同名行 → 換值 + * 2. 只有被註解掉的同名行 → 取消註解並填值 + * 3. 都沒有 → 插在 `[vars]` header 下一行;連 `[vars]` 都沒有就在檔尾新開一段 + */ +export function applyVars(toml: string, vars: Record): string { + let out = toml; + for (const key of Object.keys(vars).sort()) { + // 只接受合法的 var 名(CF 那側本來就是這個字集)。怪名字寧可不寫,也不要拿它去組正規式。 + if (!/^[A-Za-z0-9_]+$/.test(key)) continue; + const value = tomlEscape(vars[key]); + // 🔴 一律用「函式版 replace」:值裡若有 `$&`/`$1` 這種字元,字串版 replace 會把它當成 + // 反向參照展開,寫出來的就不是使用者那個值了。 + if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(out)) { + out = out.replace( + new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(".*)$`, 'm'), + (_m, head: string, tail: string) => `${head}${value}${tail}`, + ); + continue; + } + if (new RegExp(`^\\s*#\\s*${key}\\s*=`, 'm').test(out)) { + out = out.replace( + new RegExp(`^(\\s*)#\\s*${key}\\s*=\\s*"[^"]*"(.*)$`, 'm'), + (_m, indent: string, tail: string) => `${indent}${key} = "${value}"${tail}`, + ); + continue; + } + if (/^\s*\[vars\]\s*$/m.test(out)) { + out = out.replace(/^(\s*\[vars\]\s*)$/m, (_m, header: string) => `${header}\n${key} = "${value}"`); + continue; + } + out = `${out.replace(/\s*$/, '')}\n\n[vars]\n${key} = "${value}"\n`; + } + return out; } /** @@ -715,11 +959,15 @@ function injectWranglerConfig( * 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的 * ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓: * 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。 + * + * `extraVars`(Arcrun#106):這顆 worker 要**沿用的既有 var** + 這趟要**重烙的版本標籤**。 + * 預覽時不傳(vars 不影響資源需求解析,傳不傳都是同一份需求清單)。 */ export function renderWranglerToml( toml: string, ctx: DeployContext, resolved: Map, + extraVars: Record = {}, ): string { // cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) { @@ -770,6 +1018,11 @@ export function renderWranglerToml( toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2'); } + // 沿用的既有 var + 這趟的版本標籤(#106)。**放在所有 CLI 注入之後**: + // CLI_MANAGED_VARS 已經在 preservedVars 排除掉,故這裡不會蓋掉上面剛算好的 + // WORKER_SUBDOMAIN / CF_ACCOUNT_ID / MULTI_TENANT / KBDB_BASE_URL。 + toml = applyVars(toml, extraVars); + // 資源 id 一律最後注入,且**照 binding 名逐個對號**(不是「檔案裡第一個 database_id」那種盲換)。 // 空 map = 預覽模式,這步什麼也不做。 return applyResolvedBindings(toml, resolved); diff --git a/cli/src/lib/resource-resolver.ts b/cli/src/lib/resource-resolver.ts index 9668995..9d41384 100644 --- a/cli/src/lib/resource-resolver.ts +++ b/cli/src/lib/resource-resolver.ts @@ -41,6 +41,16 @@ export interface ScriptBindings { /** false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 */ deployed: boolean; bindings: LiveBinding[]; + /** + * 這顆 worker 現在掛著的 `plain_text` var(名 → 值)。 + * + * 🔴 Arcrun#106:#97 只把「資源類」綁定當成事實沿用(KV/D1/Vectorize), + * plain_text var 整批沒人管 ⇒ 重部署把它們洗成 repo toml 的預設值。 + * 最痛的一個是 `ARCRUN_BUNDLE_VERSION`(安裝器注入的版本標籤)—— + * 更新完就消失,Portal 設定頁變成「無法讀取目前版本」。 + * **保留了櫃子,沒保留櫃子上的標籤**。這個欄位就是那些標籤。 + */ + vars?: Record; } /** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */ @@ -87,6 +97,14 @@ export interface ResourcePlan { create: PlannedCreate[]; /** 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 */ blockers: string[]; + /** + * 每顆**已部署** worker 現在掛著的 plain_text var(script → 名/值)。未部署的不在裡面。 + * + * Arcrun#106:讀綁定的時候本來就把整份 `bindings[]` 拿回來了,var 就在同一份回應裡—— + * 順手帶出來,**不另外打一次 API**,也不新增一種「查不到」的失敗模式 + * (讀不到綁定這件事已經在上面 blockers 那一關擋掉了)。 + */ + liveVars: Map>; } export interface ResolvedResource { @@ -137,11 +155,16 @@ export async function planResources( // 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。 const scripts = [...new Set(requirements.map((r) => r.worker))].sort(); const live = new Map(); + const liveVars = new Map>(); let readFailed = false; for (const script of scripts) { try { const res = await api.getScriptBindings(script); - if (res.deployed) live.set(script, res.bindings); + if (res.deployed) { + live.set(script, res.bindings); + // #106:同一份回應裡的 plain_text var 一起收下(呼叫端要拿它決定哪些 var 該沿用)。 + liveVars.set(script, res.vars ?? {}); + } } catch (e) { readFailed = true; blockers.push( @@ -242,7 +265,7 @@ export async function planResources( }); } - return { adopt, create: shareSameResource(adopt, create, byKey), blockers }; + return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars }; } /** diff --git a/cli/tests/register-ts-hooks.mjs b/cli/tests/register-ts-hooks.mjs new file mode 100644 index 0000000..968a004 --- /dev/null +++ b/cli/tests/register-ts-hooks.mjs @@ -0,0 +1,4 @@ +/** `node --import ./tests/register-ts-hooks.mjs --test ...` 的進入點:註冊 ts-hooks.mjs。 */ +import { register } from 'node:module'; + +register('./ts-hooks.mjs', import.meta.url); diff --git a/cli/tests/resource-adoption.test.ts b/cli/tests/resource-adoption.test.ts index a05307d..ee9e6e3 100644 --- a/cli/tests/resource-adoption.test.ts +++ b/cli/tests/resource-adoption.test.ts @@ -493,7 +493,7 @@ test('CfAccountClient.getScriptBindings:404 = 還沒部署;其他錯誤要 t new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 }) ) as typeof fetch; const cf = new CfAccountClient('a', 't'); - assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [] }); + assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [], vars: {} }); globalThis.fetch = (async () => new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 }) @@ -526,6 +526,8 @@ test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize { kind: 'd1', binding: 'DB', value: 'db1' }, { kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' }, ]); + // #106:plain_text 也要收下來(service 這種不認得的仍略過)。 + assert.deepEqual(res.vars, { ENVIRONMENT: 'production' }); } finally { globalThis.fetch = orig; } diff --git a/cli/tests/ts-hooks.mjs b/cli/tests/ts-hooks.mjs new file mode 100644 index 0000000..a87ce26 --- /dev/null +++ b/cli/tests/ts-hooks.mjs @@ -0,0 +1,21 @@ +/** + * 測試用 resolve hook:把 `./x.js` 這種 import 指回同名的 `./x.ts`(Arcrun#106 附帶修復)。 + * + * 為什麼需要:`src/` 內部的 import 一律寫成 `.js`(NodeNext 慣例,編譯後才會有那個檔), + * 但測試是**直接載入 `src/**\/*.ts`**、不經過 tsc(`outDir: dist`,所以 `src/` 底下永遠不會有 .js)。 + * Node 的型別剝離不會自己把 `.js` 對回 `.ts` ⇒ 三份測試在 node 22 上**一支都跑不起來** + * (`ERR_MODULE_NOT_FOUND: .../src/lib/cf-api.js`)——包含 #97 那份「使用者的東西還在不在」的迴歸守衛。 + * 跑不起來的守衛等於沒有守衛,所以這裡補上。 + * + * 只在「預設解析失敗」時才動作,且只換副檔名 → 對本來就解析得到的環境(新版 node / 已編譯)零影響。 + */ +export async function resolve(specifier, context, next) { + try { + return await next(specifier, context); + } catch (err) { + if (typeof specifier === 'string' && specifier.endsWith('.js')) { + return next(specifier.slice(0, -3) + '.ts', context); + } + throw err; + } +} diff --git a/cli/tests/version-label.test.ts b/cli/tests/version-label.test.ts new file mode 100644 index 0000000..5da79fb --- /dev/null +++ b/cli/tests/version-label.test.ts @@ -0,0 +1,243 @@ +/** + * Arcrun#106 迴歸守衛 —— 「更新完,設定頁還看得到版本號,而且是**這次**的版本號」 + * + * 2026-08-12 實害:leo 更新完 leo21c,Portal 設定頁的版本欄變成 + * 「無法讀取目前版本(知識庫服務可能正在啟動)」。 + * 根因:`ARCRUN_BUNDLE_VERSION` 是部署時注入的 plain_text var,**只有安裝器會注入**; + * CLI 這條路重部署時 wrangler 整份覆蓋 toml,沒寫的 var 直接消失 ⇒ 標籤被洗掉。 + * #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),**沒修「櫃子上的標籤」**。 + * + * 這份測試守兩件相反的事(本次的核心判斷): + * · 設定類 var(安裝器注入的 PORTAL_MAIL_RELAY_BASE 之類)=使用者實例的事實 → **沿用** + * · 版本標籤 ARCRUN_BUNDLE_VERSION =這份成品的屬性 → **每趟重烙,絕不沿用舊值** + * (沿用舊值 = 一個永遠停在安裝當天的假標籤,比沒有標籤更糟) + * + * 全部離線跑:真的 wrangler.toml + 真的 render/inject 程式碼,fetch 用假的,不碰任何實例。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + renderWranglerToml, + preservedVars, + applyVars, + resolveBundleStamp, + CLI_MANAGED_VARS, + VERSION_STAMP_WORKER, + type DeployContext, +} from '../src/lib/deploy.ts'; +import { planResources, type ResourceApi, type ScriptBindings } from '../src/lib/resource-resolver.ts'; + +const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..'); +const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8'); + +const CTX: DeployContext = { + accountId: 'acc-user-123', + apiToken: 'token', + workerSubdomain: 'user-sub', + selfHosted: true, + kbdbEmbed: true, +}; + +/** 一台「安裝器裝出來、已經跑過的」實例上,cypher worker 現在掛著的 plain_text var。 */ +const LIVE_VARS: Record = { + ARCRUN_BUNDLE_VERSION: '1.4.29', // 安裝當時的舊標籤 + PORTAL_MAIL_RELAY_BASE: 'https://mail.example.com', // 安裝器注入、repo toml 沒有 → 洗掉就寄不出信 + CONSOLE_TENANT: 'someone-else', // repo toml 寫死 "leo",不能拿官方值蓋掉人家的 + WORKER_SUBDOMAIN: 'user-sub', // CLI 自己算 + CF_ACCOUNT_ID: 'acc-user-123', // CLI 自己算 + MULTI_TENANT: 'false', // CLI 自己算 + ENVIRONMENT: 'production', // 與 toml 同值 → 不必重寫 +}; + +/** 從 render 過的 toml 讀 [vars] 區塊(只看未註解的行)。 */ +function readVars(toml: string): Record { + const out: Record = {}; + let inVars = false; + for (const raw of toml.split('\n')) { + const line = raw.trim(); + if (/^\[\[?[A-Za-z0-9_]+\]?\]$/.test(line)) { inVars = line === '[vars]'; continue; } + if (!inVars || line.startsWith('#')) continue; + const m = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/); + if (m) out[m[1]] = m[2]; + } + return out; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// ① 病灶本身:舊行為會把標籤洗掉 +// ═════════════════════════════════════════════════════════════════════════════ + +test('#106 ①:repo 的 cypher toml 本來就沒有 ARCRUN_BUNDLE_VERSION——不補就是洗掉(病灶重現)', () => { + const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map()); + assert.equal( + readVars(rendered).ARCRUN_BUNDLE_VERSION, + undefined, + '若這行開始有值,表示 toml 自己帶了版本標籤,本測試的前提要重寫', + ); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// ② 設定類 var:沿用實例上的事實 +// ═════════════════════════════════════════════════════════════════════════════ + +test('#106 ②:安裝器注入、repo toml 沒有的 var 會被沿用(不再被重部署洗掉)', () => { + const keep = preservedVars(LIVE_VARS, CYPHER_TOML); + assert.equal(keep.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com'); + // repo toml 寫死的是官方值,使用者實例上的值才是事實 + assert.equal(keep.CONSOLE_TENANT, 'someone-else'); + // 與 toml 同值 → 不需要重寫進去(雜訊) + assert.equal(keep.ENVIRONMENT, undefined); +}); + +test('#106 ③:CLI 自己算的 var 一律不沿用(沿用等於拿舊值蓋掉這趟的正解)', () => { + const keep = preservedVars({ ...LIVE_VARS, WORKER_SUBDOMAIN: 'OLD-sub', CF_ACCOUNT_ID: 'OLD-acc' }, CYPHER_TOML); + for (const managed of CLI_MANAGED_VARS) { + assert.equal(keep[managed], undefined, `${managed} 不該被沿用`); + } + // 而且注入完的 toml 裡,這些值仍是這趟算出來的那個 + const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map(), keep); + const vars = readVars(rendered); + assert.equal(vars.WORKER_SUBDOMAIN, 'user-sub'); + assert.equal(vars.CF_ACCOUNT_ID, 'acc-user-123'); + assert.equal(vars.MULTI_TENANT, 'false'); + assert.equal(vars.KBDB_BASE_URL, 'https://arcrun-kbdb.user-sub.workers.dev'); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// ③ 版本標籤:重烙,不沿用 +// ═════════════════════════════════════════════════════════════════════════════ + +test('#106 ④:版本標籤取「發行頻道公告的 release」+ 實際 commit,不是沿用舊值', async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ release: '1.4.41', pin: 'ba81439' }), { status: 200 })) as typeof fetch; + const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch); + assert.equal(stamp.version, '1.4.41'); + assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION); // ← 這就是本 issue + assert.equal(stamp.commit, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b'); + assert.match(stamp.version, /^\d+\.\d+\.\d+$/, 'Portal 拿它跟 /api/latest 比 semver,必須是純 semver'); +}); + +test('#106 ⑤:查不到發行版號時誠實標成 commit 版,**不**沿用舊值、也不掰一個 semver', async () => { + const fakeFetch = (async () => { throw new Error('offline'); }) as typeof fetch; + const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch); + assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+f87d0e9$/); + assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION); + assert.doesNotMatch(stamp.version, /^\d+\.\d+\.\d+$/, '掰一個 semver 會讓 Portal 假裝「已是最新版」'); +}); + +test('#106 ⑥:發行頻道回了不是 semver 的東西 → 當成查不到(不把垃圾當版號烙上去)', async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ release: 'latest' }), { status: 200 })) as typeof fetch; + const stamp = await resolveBundleStamp('main', 'abc1234def', fakeFetch); + assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+abc1234$/); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// ④ 端到端(離線):一台已安裝的實例跑一次更新,Portal 讀得到的那個欄位長什麼樣 +// ═════════════════════════════════════════════════════════════════════════════ + +test('#106 ⑦:模擬更新——版本標籤變新、設定 var 一個不少、資源沿用不受影響', async () => { + const api: ResourceApi = { + async getScriptBindings(script: string): Promise { + if (script !== VERSION_STAMP_WORKER) return { deployed: false, bindings: [], vars: {} }; + return { + deployed: true, + bindings: [ + { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv-webhooks' }, + { kind: 'kv_namespace', binding: 'CREDENTIALS_KV', value: 'kv-creds' }, + { kind: 'kv_namespace', binding: 'RECIPES', value: 'kv-recipes' }, + { kind: 'kv_namespace', binding: 'USERS_KV', value: 'kv-users' }, + { kind: 'kv_namespace', binding: 'SESSIONS_KV', value: 'kv-sessions' }, + { kind: 'kv_namespace', binding: 'ANALYTICS_KV', value: 'kv-analytics' }, + { kind: 'kv_namespace', binding: 'EXEC_CONTEXT', value: 'kv-exec' }, + { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1-kbdb' }, + ], + vars: LIVE_VARS, + }; + }, + async listKvNamespaces() { + return new Map([ + ['a', 'kv-webhooks'], ['b', 'kv-creds'], ['c', 'kv-recipes'], ['d', 'kv-users'], + ['e', 'kv-sessions'], ['f', 'kv-analytics'], ['g', 'kv-exec'], + ]); + }, + async listD1Databases() { return new Map([['arcrun-kbdb', 'd1-kbdb']]); }, + async listVectorizeIndexes() { return []; }, + async createKvNamespace() { throw new Error('這趟不該新建任何 KV'); }, + async createD1Database() { throw new Error('這趟不該新建 D1'); }, + async createVectorizeIndex() { throw new Error('這趟不該新建 Vectorize'); }, + }; + + const preview = renderWranglerToml(CYPHER_TOML, CTX, new Map()); + const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts'); + const parsed = parseWranglerRequirements(preview); + const plan = await planResources( + api, + parsed.bindings.map((b) => ({ ...b, worker: parsed.script })), + 'update', + ); + assert.deepEqual(plan.blockers, []); + // 讀綁定時順手把 var 帶回來——不另外打一次 API + assert.equal(plan.liveVars.get(VERSION_STAMP_WORKER)?.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com'); + + const fakeFetch = (async () => + new Response(JSON.stringify({ release: '1.4.41' }), { status: 200 })) as typeof fetch; + const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch); + const extra = { + ...preservedVars(plan.liveVars.get(parsed.script), CYPHER_TOML), + ARCRUN_BUNDLE_VERSION: stamp.version, + ARCRUN_BUNDLE_COMMIT: stamp.commit!, + }; + + const deployed = readVars(renderWranglerToml(CYPHER_TOML, CTX, new Map(), extra)); + + // ① Portal 設定頁讀的就是這個欄位——更新完必須有值,且是**這趟**的版本 + assert.equal(deployed.ARCRUN_BUNDLE_VERSION, '1.4.41'); + assert.equal(deployed.ARCRUN_BUNDLE_COMMIT, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b'); + // ② 安裝器注入的設定沒有在更新中消失 + assert.equal(deployed.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com'); + assert.equal(deployed.CONSOLE_TENANT, 'someone-else'); + // ③ CLI 自己算的仍然是這趟算出來的 + assert.equal(deployed.WORKER_SUBDOMAIN, 'user-sub'); + assert.equal(deployed.MULTI_TENANT, 'false'); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// ⑤ applyVars 的三種既有狀態 + 不弄壞別的區塊 +// ═════════════════════════════════════════════════════════════════════════════ + +test('#106 ⑧:applyVars——改既有行/取消註解/插進 [vars]/連 [vars] 都沒有時新開一段', () => { + assert.match(applyVars('[vars]\nA = "old"\n', { A: 'new' }), /^\[vars\]\nA = "new"\n$/); + assert.match(applyVars('[vars]\n# A = "old"\n', { A: 'new' }), /A = "new"/); + assert.match(applyVars('[vars]\nB = "b"\n', { A: 'a' }), /\[vars\]\nA = "a"\nB = "b"/); + const noVars = applyVars('name = "w"\n', { A: 'a' }); + assert.match(noVars, /\[vars\]\nA = "a"/); + assert.match(noVars, /^name = "w"/); +}); + +test('#106 ⑨:var 值裡的引號/反斜線會被轉義(不會產生壞掉的 toml)', () => { + const out = applyVars('[vars]\n', { A: 'say "hi"\\path' }); + assert.match(out, /A = "say \\"hi\\"\\\\path"/); +}); + +test('#106 ⑨b:值裡有 $& / $1 也照原樣寫出(replace 反向參照陷阱)', () => { + assert.match(applyVars('[vars]\nA = "old"\n', { A: 'x$&y$1z' }), /A = "x\$&y\$1z"/); + assert.match(applyVars('[vars]\n', { A: 'x$&y' }), /A = "x\$&y"/); + // 怪名字不寫進去(不拿它組正規式) + assert.equal(applyVars('[vars]\n', { 'BAD NAME': 'v' }), '[vars]\n'); +}); + +test('#106 ⑩:注入 var 不影響資源綁定解析(預覽與實際寫入看到的是同一份需求)', async () => { + const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts'); + const withoutVars = parseWranglerRequirements(renderWranglerToml(CYPHER_TOML, CTX, new Map())); + const withVars = parseWranglerRequirements( + renderWranglerToml(CYPHER_TOML, CTX, new Map(), { ARCRUN_BUNDLE_VERSION: '1.4.41', X: 'y' }), + ); + assert.equal(withVars.script, withoutVars.script); + assert.deepEqual(withVars.bindings, withoutVars.bindings); +}); diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 70694ec..0ee870a 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -2039,6 +2039,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { // 兩邊都是 semver(例 1.4.2),用數字逐段比,不用字串比('1.4.10' < '1.4.9' 會出錯)。 var INSTALLER_ORIGIN = 'https://install.arcrun.dev'; + // Arcrun#106:版號後面可以帶 build metadata(`1.4.41+d61`、`1.4.41+a1b2c3d`)—— + // 那是 semver 規格裡「比大小時要忽略」的那一段。舊寫法拿整串去比對正規式, + // 一律落到「較舊版本」(youlin 實例就是這樣,明明有版號卻顯示不出來)。 + // 這裡只取前面的 `x.y.z` 當比較用的核心,顯示仍顯示完整原字串。 + function semverCore(v) { + var m = String(v || '').match(/^(\d+\.\d+\.\d+)/); + return m ? m[1] : ''; + } + function cmpSemver(a, b) { var x = String(a || '').split('.').map(Number); var y = String(b || '').split('.').map(Number); @@ -2055,9 +2064,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { var btn = $('st-ver-update'); if (!line) return; + // #106:順便把 bundle_commit 帶回來(有注入才有)——版號是頻道編號,commit 才是「真的部了哪份碼」。 + var mineCommit = ''; var mineP = fetch(window.ARCRUN_API_BASE + '/health', { cache: 'no-store' }) .then(function (r) { return r.ok ? r.json() : null; }) - .then(function (j) { return (j && j.bundle_version) || ''; }) + .then(function (j) { + mineCommit = (j && j.bundle_commit) || ''; + return (j && j.bundle_version) || ''; + }) .catch(function () { return ''; }); var latestP = fetch(INSTALLER_ORIGIN + '/api/latest') .then(function (r) { return r.ok ? r.json() : null; }) @@ -2070,15 +2084,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { if (!mine) { line.textContent = '無法讀取目前版本(知識庫服務可能正在啟動)'; return; } // 舊實例的 bundle_version 是舊格式(2026-07-31+8e83589),比不了 semver。 // 這種情況一律當成「落後」——因為新版才會寫 semver 進來。 - var mineIsSemver = /^\d+\.\d+\.\d+$/.test(mine); + // #106:`1.4.41+` 這種帶 build metadata 的**是** semver,取核心比即可。 + var mineCore = semverCore(mine); + var mineIsSemver = !!mineCore; + // commit 是輔助資訊(有才顯示):版號說「哪一版」,commit 說「真的是哪份碼」。 + var commitNote = mineCommit ? ' commit ' + esc(String(mineCommit).slice(0, 7)) + '' : ''; if (!latest) { - line.textContent = '目前版本 ' + mine + '(暫時查不到最新版,稍後再試)'; + line.innerHTML = '目前版本 ' + esc(mine) + '(暫時查不到最新版,稍後再試)' + commitNote; return; } - var behind = !mineIsSemver || cmpSemver(mine, latest) < 0; + var behind = !mineIsSemver || cmpSemver(mineCore, latest) < 0; if (!behind) { - line.innerHTML = '目前版本 ' + esc(mine) + ' 已是最新版'; + line.innerHTML = '目前版本 ' + esc(mine) + ' 已是最新版' + commitNote; dot.style.display = 'none'; btn.style.display = 'none'; return; diff --git a/cypher-executor/src/routes/health.ts b/cypher-executor/src/routes/health.ts index a67069b..f7c9827 100644 --- a/cypher-executor/src/routes/health.ts +++ b/cypher-executor/src/routes/health.ts @@ -15,11 +15,19 @@ export const healthRouter = new Hono<{ Bindings: Bindings }>(); // 要在實例自己這一側就看得出來,不是等用戶登不進去才發現(#10「寧可明顯失敗」)。 // 只回統計不回內容(帳號數/有沒有 console 帳密/分片數),不洩漏任何 email 或雜湊。 // bundle_version 的既有行為不動(未注入就省略該欄——daemon 對空字串判 stale 是正確的)。 +// Arcrun#106(leo 08-12 實撞:更新完設定頁變成「無法讀取目前版本」): +// `bundle_version` 只在部署時被注入,而**只有安裝器會注入**——CLI 更新那條路重部署 +// 等於把這個標籤洗掉(wrangler deploy 整份覆蓋,toml 沒寫的 var 直接消失)。 +// 修在 CLI 那側(cli/src/lib/deploy.ts:既有 var 沿用 + 版本標籤每趟重烙)。 +// 這裡只多吐一個 `bundle_commit`:版號是「發行頻道的編號」,commit 才是「真的部了哪份碼」—— +// 兩個一起看才有辦法查「標籤有沒有跟成品漂掉」。沒注入就省略該欄(同 bundle_version 的既有行為)。 healthRouter.get('/health', (c) => { const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION; + const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT; return c.json({ ok: true, ...(bundleVersion ? { bundle_version: bundleVersion } : {}), + ...(bundleCommit ? { bundle_commit: bundleCommit } : {}), auth_store: authStoreStatus(c.env), // arcrun-rag#38/#69/#25(2026-08-11):安裝器判斷「要不要重推」只比 bundle_version—— // 但這次要修的洞是「installer 從沒注入過 PORTAL_MAIL_RELAY_BASE」,跟 bundle 內容 diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index fc6ea36..09470e8 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -75,6 +75,13 @@ export type Bindings = { * 未注入(本地 dev/舊實例)= undefined,/health 省略該欄。 */ ARCRUN_BUNDLE_VERSION?: string; + /** + * Arcrun#106:這份成品實際來自哪個 commit(40 碼 sha)。 + * `ARCRUN_BUNDLE_VERSION` 是**發行頻道的編號**(semver,Portal/daemon 拿它比新舊), + * 這個是**真的部了哪份碼**——兩個一起吐,標籤跟成品漂掉時查得出來。 + * 由 `acr init/update`(cli/src/lib/deploy.ts)注入;安裝器那條路沒有此 var → /health 省略該欄。 + */ + ARCRUN_BUNDLE_COMMIT?: string; // Platform telemetry api_key(可選,wrangler secret) // 對應 SDD .agents/specs/llm-interface/ M1.2 // 設了會把 agent-telemetry block 都聚集在 platform_telemetry user_id 下 @@ -103,9 +110,8 @@ export type Bindings = { GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token) GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch - // 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。 - // daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。 - ARCRUN_BUNDLE_VERSION?: string; + // (ARCRUN_BUNDLE_VERSION 原本在這裡重複宣告了一次——TS2300 重複識別字, + // #106 順手併回上面那一處,說明同源,行為零變化。) // MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。 // 真相住在 mcp worker 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份 // 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。 diff --git a/cypher-executor/tests/health.test.ts b/cypher-executor/tests/health.test.ts index 26f4233..9e73142 100644 --- a/cypher-executor/tests/health.test.ts +++ b/cypher-executor/tests/health.test.ts @@ -26,4 +26,33 @@ describe('GET /health — bundle_version 欄位', () => { expect(data.ok).toBe(true); expect(data.bundle_version).toBe('2026-07-28/6d06162'); }); + + // Arcrun#106:CLI 更新那條路會多烙一個 commit(版號=發行頻道編號,commit=真的部了哪份碼)。 + it('有 ARCRUN_BUNDLE_COMMIT 時一起回(acr update 注入情境)', async () => { + const fakeEnv = { + ARCRUN_BUNDLE_VERSION: '1.4.41', + ARCRUN_BUNDLE_COMMIT: 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', + } as unknown as Bindings; + const res = await healthRouter.fetch( + new Request('http://localhost/health'), + fakeEnv, + {} as ExecutionContext, + ); + const data = await res.json() as { bundle_version: string; bundle_commit: string }; + expect(data.bundle_version).toBe('1.4.41'); + expect(data.bundle_commit).toBe('f87d0e92f49690253e7c89c5badc82a08eb5d21b'); + }); + + // 安裝器那條路沒有這個 var(回歸:不能因為多了新欄位就讓舊路徑多吐一個空字串出來)。 + it('沒 ARCRUN_BUNDLE_COMMIT 就省略該欄(安裝器路徑不受影響)', async () => { + const fakeEnv = { ARCRUN_BUNDLE_VERSION: '1.4.41' } as unknown as Bindings; + const res = await healthRouter.fetch( + new Request('http://localhost/health'), + fakeEnv, + {} as ExecutionContext, + ); + const data = await res.json() as { bundle_version: string; bundle_commit?: string }; + expect(data.bundle_version).toBe('1.4.41'); + expect(data.bundle_commit).toBeUndefined(); + }); });