Files
Arcrun/shared/resource-rule/tests/fixture-account.mjs
uncle6me-web bb548b6fdf 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 乾淨。
2026-08-12 23:37:01 +08:00

203 lines
7.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-check
/**
* fixture-account.mjs — 一個假的 Cloudflare 帳號,做成 **`fetch` 替身**。
*
* 【為什麼是 fetch 替身,不是假的 ResourceApi 物件】
* 本票要證的是「`acr` 那條與安裝器那條,跑出來的決定必須一致」。
* 如果兩條路各自餵一個假的 `ResourceApi`,那就只測到了 `rule.mjs` 的判斷,
* **完全跳過了「怎麼把 CF 回應讀成事實」**——而 Arcrun#97 的重演只需要眼睛不一樣就夠了
* (一邊把 404 當錯誤、一邊漏認 `namespace_id`…)。
* 從 `fetch` 這一層假起,兩條路就是真的走完整條鏈:HTTP → 解析 → 判斷。
*
* 零依賴、純 ESMNode 與 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 });
// #106plain_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,
};
}