refactor(shared): 「該用哪些資源」搬出 CLI——一份實作,acr 與安裝器吃同一條規則
leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
「這個實例該用哪些資源」換到安裝器就要重寫一次 ⇒ 依 rules/07-thin-shell.md 的判準
它是**能力**,而它原本住在 cli/src/lib/resource-resolver.ts ⇒ 那本身就是違規。
後果已經真的發生:acr 那條有 Arcrun#97 的修法、安裝器那條沒有,於是安裝器照名字
找、找不到就建一顆空的綁上去 ⇒「我按了更新,工作流和登入全不見了」。
規則搬到 shared/resource-rule/(零依賴 ESM,Node 與 Workers runtime 都直接跑):
· rule.mjs 規則本體+把 CF 回應讀成事實的 normalizeLive*
· cf-resource-api.mjs ResourceApi 的 CF REST 實作——**眼睛也共用**:
兩條路各自解讀 CF 回應,只要一邊看不到既有綁定就會去新建,
#97 不需要規則寫錯就能重演
· installer-entry.mjs 安裝器唯一該碰的入口 resolveInstanceResources()
不是做成 cypher 端點的理由(自舉):這條規則要在「決定怎麼裝」的當下就用得到,
而那時 cypher 可能還不存在(安裝器的工作正是把它生出來);且輸入是使用者自己帳號的
綁定狀態,不該送去平台換答案。它是純函式,用不著變成服務。
只有一份,機械看守:
· 安裝器直接 import repo archive 裡的原稿,**不需要副本**
· acr 因為 npm pack 打不進套件目錄外的檔案,帶一份逐位元組鏡射
(scripts/sync-resource-rule.mjs 產生;build/test 先跑 --check,差一位元組就紅)
——同 cli/harness/ 產生物+世代閘的既有慣例
· cli/tests/single-implementation.test.ts 掃全 repo:7 支規則函式的實作只有一處
CLI 淨 -496 行(邏輯是搬走,不是複製)。cf-api.ts 的 CfAccountClient 保留公開介面,
ResourceApi 那七個方法全部委派共用 client。
驗證:cli 58/58 綠(含新增的兩條路一致性 fixture + 三種情境),tsc --noEmit 乾淨。
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* demo.mjs — 安裝器那條路的**可獨立執行**證明。
|
||||
*
|
||||
* node shared/resource-rule/tests/demo.mjs
|
||||
*
|
||||
* 這支只 import `shared/resource-rule/`,**沒有 node_modules、沒有建置步驟**——
|
||||
* 跑得起來本身就是「安裝器把 repo archive 拉下來就能直接用」這句話的證據。
|
||||
* (對照組:`acr` 那條要先 npm ci + TS 轉譯才跑得動。兩條路差在外殼,判斷是同一份。)
|
||||
*
|
||||
* 三種情境各跑一次,印出每個 binding 選到哪顆資源、以及這一趟建了幾顆。
|
||||
*/
|
||||
|
||||
import { resolveInstanceResources } from '../installer-entry.mjs';
|
||||
import { makeAccount, SCENARIOS, WORKER_NEEDS } from './fixture-account.mjs';
|
||||
|
||||
/** 用 fixture 的需求組出各 worker 的 wrangler.toml 內容。 */
|
||||
function tomls() {
|
||||
return Object.entries(WORKER_NEEDS).map(([script, need]) => {
|
||||
let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`;
|
||||
for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`;
|
||||
for (const d of need.d1) {
|
||||
t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
const order = /** @type {const} */ (['fresh', 'installed', 'renamed']);
|
||||
|
||||
console.log('安裝器那條路(只 import shared/resource-rule/,零依賴、零建置)\n');
|
||||
|
||||
for (const scenario of order) {
|
||||
const mode = scenario === 'fresh' ? 'init' : 'update';
|
||||
const account = makeAccount(scenario);
|
||||
const r = await resolveInstanceResources({
|
||||
accountId: 'acct-demo',
|
||||
apiToken: 'tok-demo',
|
||||
wranglerTomls: tomls(),
|
||||
mode,
|
||||
fetch: account.fetch,
|
||||
});
|
||||
|
||||
console.log(`── ${scenario}(mode=${mode}):${SCENARIOS[scenario].label}`);
|
||||
if (r.blocked) {
|
||||
console.log(' ⛔ 停手,一顆資源都沒建:');
|
||||
for (const b of r.blockers) console.log(` • ${b}`);
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(r.bindings).sort()) {
|
||||
console.log(` ${key.padEnd(30)} → ${r.bindings[key].padEnd(26)} ${r.origin[key]}`);
|
||||
}
|
||||
console.log(
|
||||
` 本趟新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length} 顆、` +
|
||||
`Vectorize ${account.created.vectorize.length} 顆` +
|
||||
`|沿用既有版本標籤 ARCRUN_BUNDLE_VERSION=` +
|
||||
`${r.liveVars['arcrun-cypher-executor']?.ARCRUN_BUNDLE_VERSION ?? '(無,全新安裝)'}\n`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* fixture-account.mjs — 一個假的 Cloudflare 帳號,做成 **`fetch` 替身**。
|
||||
*
|
||||
* 【為什麼是 fetch 替身,不是假的 ResourceApi 物件】
|
||||
* 本票要證的是「`acr` 那條與安裝器那條,跑出來的決定必須一致」。
|
||||
* 如果兩條路各自餵一個假的 `ResourceApi`,那就只測到了 `rule.mjs` 的判斷,
|
||||
* **完全跳過了「怎麼把 CF 回應讀成事實」**——而 Arcrun#97 的重演只需要眼睛不一樣就夠了
|
||||
* (一邊把 404 當錯誤、一邊漏認 `namespace_id`…)。
|
||||
* 從 `fetch` 這一層假起,兩條路就是真的走完整條鏈:HTTP → 解析 → 判斷。
|
||||
*
|
||||
* 零依賴、純 ESM,Node 與 Workers 都能跑。
|
||||
*/
|
||||
|
||||
/** arcrun 各 worker 在 wrangler.toml 裡宣告的 KV binding 名(= 需求,不是資源名)。 */
|
||||
export const KV_BINDINGS = [
|
||||
'WEBHOOKS', 'CREDENTIALS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV',
|
||||
'ANALYTICS_KV', 'EXEC_CONTEXT', 'SUBMISSIONS_KV', 'OAUTH_KV',
|
||||
];
|
||||
|
||||
/** 這台實例上有資源綁定的四顆 worker,以及各自需要的綁定。 */
|
||||
export const WORKER_NEEDS = {
|
||||
'arcrun-cypher-executor': {
|
||||
kv: ['EXEC_CONTEXT', 'WEBHOOKS', 'CREDENTIALS_KV', 'ANALYTICS_KV', 'RECIPES', 'USERS_KV', 'SESSIONS_KV'],
|
||||
d1: [{ binding: 'CREDENTIALS_DB', database_name: 'arcrun-kbdb' }],
|
||||
},
|
||||
'arcrun-registry': { kv: ['SUBMISSIONS_KV', 'ANALYTICS_KV'], d1: [] },
|
||||
'arcrun-mcp': { kv: ['OAUTH_KV'], d1: [] },
|
||||
'arcrun-kbdb': { kv: [], d1: [{ binding: 'DB', database_name: 'arcrun-kbdb' }] },
|
||||
};
|
||||
|
||||
/**
|
||||
* 把 WORKER_NEEDS 攤成 `BindingRequirement[]`——兩條路都用**同一份需求**進去,
|
||||
* 才能證明差異(如果有)來自實作而不是輸入。
|
||||
* @returns {Array<{kind: 'kv_namespace'|'d1', binding: string, worker: string, createName: string}>}
|
||||
*/
|
||||
export function requirements() {
|
||||
const out = [];
|
||||
for (const [worker, need] of Object.entries(WORKER_NEEDS)) {
|
||||
for (const b of need.kv) out.push({ kind: 'kv_namespace', binding: b, worker, createName: b });
|
||||
for (const d of need.d1) {
|
||||
out.push({ kind: 'd1', binding: d.binding, worker, createName: d.database_name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三種情境。`titleFor` 決定「使用者帳號上那顆資源實際叫什麼名字」——
|
||||
* 這正是 #97 的病根所在:規則**不准**拿名字當識別。
|
||||
*
|
||||
* @typedef {'fresh' | 'installed' | 'renamed'} Scenario
|
||||
*/
|
||||
|
||||
/** @type {Record<Scenario, {label: string, deployed: boolean, titleFor: (binding: string) => string}>} */
|
||||
export const SCENARIOS = {
|
||||
fresh: {
|
||||
label: '沒裝過(全新帳號,一顆 worker 都沒有)',
|
||||
deployed: false,
|
||||
titleFor: (b) => b,
|
||||
},
|
||||
installed: {
|
||||
label: '裝過了(安裝器命名慣例 arcrun-rag-<instance>-kv-<binding>)',
|
||||
deployed: true,
|
||||
titleFor: (b) => `arcrun-rag-yuga3bse-kv-${b.toLowerCase()}`,
|
||||
},
|
||||
renamed: {
|
||||
label: '資源在,但名字與預期完全不同(使用者自己改過/別的安裝器版本取的名)',
|
||||
deployed: true,
|
||||
// 刻意取成跟 binding 名毫無關聯的字串:只要規則有一絲「照名字對號」就會在這裡露餡。
|
||||
titleFor: (b) => `kv-${[...b].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(36)}`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 建一個假帳號 + 對應的 `fetch` 替身。
|
||||
*
|
||||
* @param {Scenario} scenario
|
||||
* @returns {{
|
||||
* fetch: typeof globalThis.fetch,
|
||||
* created: {kv: string[], d1: string[], vectorize: string[]},
|
||||
* userData: {workflows: string[], sessions: string[], libraries: string[]},
|
||||
* kvIdFor: (binding: string) => string | undefined,
|
||||
* d1Id: string,
|
||||
* requestLog: string[],
|
||||
* }}
|
||||
*/
|
||||
export function makeAccount(scenario) {
|
||||
const spec = SCENARIOS[scenario];
|
||||
/** title → id */
|
||||
const kv = new Map();
|
||||
/** name → uuid */
|
||||
const d1 = new Map();
|
||||
/** @type {string[]} */
|
||||
const vectorize = [];
|
||||
/** script → CF `/settings` 回應裡的 bindings[] 原始形狀 */
|
||||
const scripts = new Map();
|
||||
|
||||
const created = { kv: [], d1: [], vectorize: [] };
|
||||
const requestLog = [];
|
||||
|
||||
// 使用者的東西——驗「更新完還在不在」用。掛在資源 id 上,不是掛在名字上。
|
||||
const userData = {
|
||||
workflows: ['webhook:leo:daily-digest', 'webhook:leo:inbox-sync', 'webhook:leo:rag-ingest'],
|
||||
sessions: ['session:leo-abc123'],
|
||||
libraries: ['general', '課程', '客戶', '研究'],
|
||||
};
|
||||
|
||||
const kvIdByBinding = new Map();
|
||||
const D1_ID = 'd1id-kbdb-REAL';
|
||||
|
||||
if (spec.deployed) {
|
||||
// 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分)
|
||||
for (const b of KV_BINDINGS) {
|
||||
const id = `kvid-${b.toLowerCase()}-REAL`;
|
||||
kv.set(spec.titleFor(b), id);
|
||||
kvIdByBinding.set(b, id);
|
||||
}
|
||||
d1.set('arcrun-rag-yuga3bse-kbdb', D1_ID);
|
||||
|
||||
// 已部署的 worker 上綁著它們——**這才是規則要看的事實**
|
||||
for (const [script, need] of Object.entries(WORKER_NEEDS)) {
|
||||
const bindings = [];
|
||||
for (const b of need.kv) {
|
||||
bindings.push({ type: 'kv_namespace', name: b, namespace_id: kvIdByBinding.get(b) });
|
||||
}
|
||||
for (const d of need.d1) bindings.push({ type: 'd1', name: d.binding, id: D1_ID });
|
||||
// #106:plain_text var 也在同一份回應裡
|
||||
bindings.push({ type: 'plain_text', name: 'ARCRUN_BUNDLE_VERSION', text: '1.4.33' });
|
||||
scripts.set(script, bindings);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {unknown} result @param {number} [status] */
|
||||
const ok = (result, status = 200) =>
|
||||
new Response(JSON.stringify({ success: true, result, errors: [] }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
/** @param {string} message @param {number} status */
|
||||
const fail = (message, status) =>
|
||||
new Response(JSON.stringify({ success: false, result: null, errors: [{ message }] }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
/** @type {typeof globalThis.fetch} */
|
||||
// @ts-expect-error — 測試替身只實作用得到的那幾條路徑
|
||||
const fakeFetch = async (input, init) => {
|
||||
const url = new URL(typeof input === 'string' ? input : String(input));
|
||||
const path = url.pathname.replace(/^\/client\/v4\/accounts\/[^/]+/, '');
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
requestLog.push(`${method} ${path}${url.search}`);
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
||||
|
||||
// 已部署 worker 的綁定
|
||||
const m = path.match(/^\/workers\/scripts\/([^/]+)\/settings$/);
|
||||
if (m && method === 'GET') {
|
||||
const script = decodeURIComponent(m[1]);
|
||||
if (!scripts.has(script)) return fail('workers.api.error.script_not_found', 404);
|
||||
return ok({ bindings: scripts.get(script) });
|
||||
}
|
||||
|
||||
if (path === '/storage/kv/namespaces' && method === 'GET') {
|
||||
return ok([...kv].map(([title, id]) => ({ id, title })));
|
||||
}
|
||||
if (path === '/storage/kv/namespaces' && method === 'POST') {
|
||||
const id = `kvid-NEW-${created.kv.length + 1}`;
|
||||
kv.set(body.title, id);
|
||||
created.kv.push(body.title);
|
||||
return ok({ id, title: body.title });
|
||||
}
|
||||
if (path === '/d1/database' && method === 'GET') {
|
||||
return ok([...d1].map(([name, uuid]) => ({ uuid, name })));
|
||||
}
|
||||
if (path === '/d1/database' && method === 'POST') {
|
||||
const uuid = `d1id-NEW-${created.d1.length + 1}`;
|
||||
d1.set(body.name, uuid);
|
||||
created.d1.push(body.name);
|
||||
return ok({ uuid, name: body.name });
|
||||
}
|
||||
if (path === '/vectorize/v2/indexes' && method === 'GET') {
|
||||
return ok(vectorize.map((name) => ({ name })));
|
||||
}
|
||||
if (path === '/vectorize/v2/indexes' && method === 'POST') {
|
||||
vectorize.push(body.name);
|
||||
created.vectorize.push(body.name);
|
||||
return ok({ name: body.name });
|
||||
}
|
||||
|
||||
return fail(`fixture 沒有實作這條路徑:${method} ${path}`, 501);
|
||||
};
|
||||
|
||||
return {
|
||||
fetch: fakeFetch,
|
||||
created,
|
||||
userData,
|
||||
kvIdFor: (binding) => kvIdByBinding.get(binding),
|
||||
d1Id: D1_ID,
|
||||
requestLog,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user