Merge PR #112: 藏書地圖看得到自己的知識——租戶字串改從寫入端來(Arcrun#108)
總管複驗(不聽自述,自己重跑並與 base 逐條比對):
cypher 14 failed / 441 passed(base 14 / 400)⇒ 多 41 條新測試全過
失敗清單 diff 無輸出=逐字元相同、零回歸
閘實測 照 #105/#108 的原句形狀種一個違規(env.CONSOLE_TENANT || leo)
→ 掃描器當場抓到並指名道姓;移除後恢復綠 ⇒ 不是假綠
這道閘的價值超過本票:它擋的是「身分來自環境變數」整族,
#105 那句話今天再寫一次也會被擋。符合 leo 08-12 立的
「做平台要減少 hotfix」——修掉 bug 不算完成,要留下下次再犯會被擋住的機制。
agent 誠實標的殘項(不擋併):
· kbdb_get_map 回 1851、Portal 總圖畫面、部分授權帳號實測——都要部署 leo21c,紅線沒碰
· 想把閘也接進 .claude/hooks/pre-write-guard.sh(寫入前就擋),該檔受保護改不動;
檢查器已備妥 --stdin 模式,總管代接
This commit was merged in pull request #112.
This commit is contained in:
@@ -17,6 +17,7 @@ import { loadConfig } from '../lib/config.js';
|
||||
import {
|
||||
wranglerAvailable,
|
||||
downloadAndDeploy,
|
||||
namespaceHasKnowledge,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
|
||||
@@ -63,6 +64,32 @@ export async function cmdUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||||
kbdbEmbed: config.kbdb_embed !== false,
|
||||
};
|
||||
|
||||
// Arcrun#108:把「你的知識住在哪個命名空間」同步給雲端——但**先驗再寫**。
|
||||
//
|
||||
// 病灶:你 push 工作流、小幫手上傳知識、MCP 查詢,用的都是 config 的 `api_key`;
|
||||
// 而 cypher 讀藏書地圖/搜尋/工作流時,過濾用的 owner_id 來自 worker 的環境變數
|
||||
// (repo toml 帶的官方預設 `CONSOLE_TENANT = "leo"`)。兩個來源對不上 ⇒ 你的東西全被濾掉。
|
||||
//
|
||||
// 為什麼不無條件寫:一鍵安裝的實例,知識可能本來就寫在 `CONSOLE_TENANT` 底下。
|
||||
// 無條件蓋成本機 api_key,會把一台**原本正常**的實例指向空的那一格
|
||||
// ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」。所以查得到才寫,查不到就不碰。
|
||||
if (config.api_key && config.cypher_executor_url) {
|
||||
process.stdout.write(chalk.gray(' → 核對雲端要用哪個知識命名空間...'));
|
||||
const hasKnowledge = await namespaceHasKnowledge(config.cypher_executor_url, config.api_key);
|
||||
if (hasKnowledge === true) {
|
||||
ctx.knowledgeNamespace = config.api_key;
|
||||
console.log(chalk.green(' ✓'));
|
||||
console.log(chalk.gray(` ARCRUN_NAMESPACE = ${config.api_key}(這個命名空間底下查得到你的知識庫)`));
|
||||
} else if (hasKnowledge === false) {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
console.log(chalk.gray(` ${config.api_key} 底下目前查不到任何知識庫 → 這趟不動雲端的命名空間設定`));
|
||||
console.log(chalk.gray(' (若藏書地圖是空的,請把這行連同 acr update 的輸出一起回報)'));
|
||||
} else {
|
||||
console.log(chalk.yellow(' ⚠'));
|
||||
console.log(chalk.gray(' 問不到實例(可能正在啟動或版本較舊)→ 這趟不動雲端的命名空間設定'));
|
||||
}
|
||||
}
|
||||
|
||||
// mode:'update' → 資源解析在「一顆該更新的 worker 都找不到」時會停手而不是重建一整套
|
||||
//(Arcrun#97 的另一道門:名字對不上時別假裝這是全新安裝)。
|
||||
const result = await downloadAndDeploy(ctx, 'main', { force: opts.force, mode: 'update' });
|
||||
|
||||
@@ -298,6 +298,44 @@ export interface DeployContext {
|
||||
// [[vectorize]]+[ai] binding(取消 wrangler.toml 註解段)→ embed 模組啟用。未設/false → 不建、不注入,
|
||||
// base 維持 LIKE keyword(free-tier 友善)。
|
||||
kbdbEmbed?: boolean;
|
||||
/**
|
||||
* Arcrun#108:這台實例的知識命名空間(=`~/.arcrun/config.yaml` 的 `api_key`),
|
||||
* 會寫進 cypher worker 的 `ARCRUN_NAMESPACE` var,讓「讀」用的 owner_id 與「寫」的一致。
|
||||
*
|
||||
* **只在驗證過該 namespace 底下真的有知識時才給值**(見 `resolveKnowledgeNamespace`)——
|
||||
* 給了就會覆蓋 worker 上的既有值,沒給則原封保留(preservedVars)。
|
||||
*/
|
||||
knowledgeNamespace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 這把 namespace 底下到底有沒有知識?(Arcrun#108 的「先驗再寫」)
|
||||
*
|
||||
* 打的是實例自己的 `GET /kbdb/map?owner_id=<ns>`(cypher 既有的純轉發端點,CLI 平常就在用
|
||||
* 這條路 + `X-Arcrun-API-Key`)。回傳:
|
||||
* true = 這個 namespace 底下查得到庫 → 寫 ARCRUN_NAMESPACE 是安全的
|
||||
* false = 查得到但是空的 → 不寫(可能知識其實在別的命名空間,蓋下去會把畫面弄空)
|
||||
* null = 問不到(實例還沒起來 / 舊版沒這條路 / 網路斷)→ 不寫,也不宣稱任何事
|
||||
*
|
||||
* 誠實邊界:這支只回答「有沒有」,不猜「應該是哪一個」。猜錯的代價是把人家的資料藏起來。
|
||||
*/
|
||||
export async function namespaceHasKnowledge(
|
||||
cypherUrl: string,
|
||||
namespace: string,
|
||||
): Promise<boolean | null> {
|
||||
if (!cypherUrl || !namespace) return null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${cypherUrl.replace(/\/+$/, '')}/kbdb/map?owner_id=${encodeURIComponent(namespace)}`,
|
||||
{ headers: { 'X-Arcrun-API-Key': namespace } },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { libraries?: unknown } | null;
|
||||
if (!body || !Array.isArray(body.libraries)) return null;
|
||||
return body.libraries.length > 0;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -561,6 +599,21 @@ export async function downloadAndDeploy(
|
||||
vars.ARCRUN_BUNDLE_VERSION = stamp.version;
|
||||
if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit;
|
||||
}
|
||||
// Arcrun#108:把「你的知識實際住在哪個命名空間」告訴雲端。
|
||||
//
|
||||
// 為什麼需要:cypher 讀藏書地圖/搜尋/工作流時要用一個 owner_id 去過濾,而它以前拿的是
|
||||
// repo toml 帶的官方預設值(`CONSOLE_TENANT = "leo"`)。寫入端(CLI push、小幫手上傳、
|
||||
// MCP)用的卻是你 `~/.arcrun/config.yaml` 的 `api_key` ⇒ 兩邊對不上就整個空掉
|
||||
//(leo 實撞:1854 條三元組被過濾成 0 個庫)。
|
||||
//
|
||||
// 🔴 **只在「這個 namespace 底下真的查得到知識」時才寫**(呼叫端已先驗過,見
|
||||
// resolveKnowledgeNamespace)。理由是反過來的那個災難:一鍵安裝的實例,知識可能
|
||||
// 本來就寫在 CONSOLE_TENANT 底下;若這裡無條件蓋成本機 api_key,會把一台**原本正常**
|
||||
// 的實例改成指向空的那一格——跟 #97/#106 同一類「更新一次把人家的東西弄不見」。
|
||||
// 驗不過就不寫;既有值由 preservedVars 原封保留,等於這趟什麼都沒改。
|
||||
if (ctx.knowledgeNamespace && script === VERSION_STAMP_WORKER) {
|
||||
vars.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
|
||||
}
|
||||
if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars);
|
||||
}
|
||||
if (preservedTotal.length > 0) {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Arcrun#108 迴歸守衛 —— 「雲端讀資料用的命名空間,要跟你寫資料用的那個一致」
|
||||
*
|
||||
* 2026-08-12 實害:leo 的藏書地圖回 0 個庫,實際有 1854 條三元組。
|
||||
* 根因:你 push 工作流、小幫手上傳知識、MCP 查詢都用 `~/.arcrun/config.yaml` 的 `api_key`
|
||||
* (leo = `bfezv28v`),但 cypher 讀取時的 owner_id 來自 worker 環境變數
|
||||
* ——而那個變數是 repo toml 帶的**官方 prod 值** `CONSOLE_TENANT = "leo"`。
|
||||
* 寫在 A、讀在 B,全被過濾掉。
|
||||
*
|
||||
* 這份測試守兩件相反的事(本次的核心判斷):
|
||||
* · 驗得到知識 → **寫** `ARCRUN_NAMESPACE`,讓讀寫兩端對齊
|
||||
* · 驗不到 / 問不到 → **一個字都不動**,既有值原封保留
|
||||
* (無條件覆蓋會把一台「知識本來就寫在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
|
||||
* ——那就是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟)
|
||||
*
|
||||
* 全部離線跑:真的 wrangler.toml + 真的 render 程式碼,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,
|
||||
namespaceHasKnowledge,
|
||||
VERSION_STAMP_WORKER,
|
||||
type DeployContext,
|
||||
} from '../src/lib/deploy.ts';
|
||||
|
||||
const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8');
|
||||
|
||||
/** leo 的真實命名空間(2026-08-11 回灌時定名,見 Leo/mira#8)。 */
|
||||
const LEO_NS = 'bfezv28v';
|
||||
|
||||
const CTX: DeployContext = {
|
||||
accountId: 'acc-user-123',
|
||||
apiToken: 'token',
|
||||
workerSubdomain: 'user-sub',
|
||||
selfHosted: true,
|
||||
kbdbEmbed: true,
|
||||
};
|
||||
|
||||
function readVars(toml: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
let inVars = false;
|
||||
for (const line of toml.split('\n')) {
|
||||
if (/^\s*\[vars\]/.test(line)) { inVars = true; continue; }
|
||||
if (/^\s*\[/.test(line)) { inVars = false; continue; }
|
||||
if (!inVars) continue;
|
||||
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
|
||||
if (m) out[m[1]] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 模擬 downloadAndDeploy 那段:沿用既有 var,再疊上這趟 CLI 算出來的值。 */
|
||||
function deployedVars(ctx: DeployContext, live: Record<string, string>): Record<string, string> {
|
||||
const keep = preservedVars(live, CYPHER_TOML);
|
||||
const extra: Record<string, string> = { ...keep };
|
||||
if (ctx.knowledgeNamespace) extra.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
|
||||
return readVars(renderWranglerToml(CYPHER_TOML, ctx, new Map(), extra));
|
||||
}
|
||||
|
||||
// ── ① 驗得到知識 → 寫進去 ────────────────────────────────────────────────────
|
||||
|
||||
test('#108 給了 knowledgeNamespace → cypher [vars] 出現 ARCRUN_NAMESPACE(讀寫兩端終於同一個值)', () => {
|
||||
const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, {});
|
||||
assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS);
|
||||
// CONSOLE_TENANT 一個字都不能動——它同時是帳號子 namespace 的組成,改了舊實例登不進去
|
||||
assert.equal(vars.CONSOLE_TENANT, 'leo');
|
||||
});
|
||||
|
||||
test('#108 蓋得過 worker 上的舊值(改名/搬遷後 acr update 要能修正,不是永遠沿用第一次那個)', () => {
|
||||
const vars = deployedVars({ ...CTX, knowledgeNamespace: LEO_NS }, { ARCRUN_NAMESPACE: 'stale-ns' });
|
||||
assert.equal(vars.ARCRUN_NAMESPACE, LEO_NS);
|
||||
});
|
||||
|
||||
// ── ② 驗不到 → 什麼都不動(比 bug 更糟的是把人家原本正常的實例弄空)──────────────
|
||||
|
||||
test('#108 沒給 knowledgeNamespace → 既有的 ARCRUN_NAMESPACE 原封保留(不因為這趟驗不到就洗掉)', () => {
|
||||
const vars = deployedVars(CTX, { ARCRUN_NAMESPACE: 'user-existing-ns' });
|
||||
assert.equal(vars.ARCRUN_NAMESPACE, 'user-existing-ns');
|
||||
});
|
||||
|
||||
test('#108 沒給、worker 上也沒有 → 不注入(回退 CONSOLE_TENANT,舊實例行為一字不變)', () => {
|
||||
const vars = deployedVars(CTX, {});
|
||||
assert.equal(vars.ARCRUN_NAMESPACE, undefined);
|
||||
assert.equal(vars.CONSOLE_TENANT, 'leo');
|
||||
});
|
||||
|
||||
test('#108 ARCRUN_NAMESPACE 不在 CLI_MANAGED_VARS:它「不是每趟重算」而是「驗到才寫」,' +
|
||||
'列進去會讓驗不到的那趟把既有值一起洗掉', async () => {
|
||||
const { CLI_MANAGED_VARS } = await import('../src/lib/deploy.ts');
|
||||
assert.equal((CLI_MANAGED_VARS as readonly string[]).includes('ARCRUN_NAMESPACE'), false);
|
||||
});
|
||||
|
||||
test('#108 只烙在 cypher 這顆 worker(其他 worker 不需要知識命名空間)', () => {
|
||||
assert.equal(VERSION_STAMP_WORKER, 'arcrun-cypher-executor');
|
||||
});
|
||||
|
||||
// ── ③ 「先驗再寫」那支探針的三態 ───────────────────────────────────────────────
|
||||
|
||||
test('namespaceHasKnowledge:這個命名空間底下查得到庫 → true(可以安全寫進去)', async () => {
|
||||
const calls: string[] = [];
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
|
||||
calls.push(String(url));
|
||||
assert.equal((init?.headers as Record<string, string>)['X-Arcrun-API-Key'], LEO_NS);
|
||||
return new Response(JSON.stringify({ success: true, libraries: [{ library: 'kb' }], count: 1 }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), true);
|
||||
assert.equal(calls[0], `https://cypher.example.dev/kbdb/map?owner_id=${LEO_NS}`);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('namespaceHasKnowledge:查得到但是空的 → false(知識可能在別的命名空間,不准蓋)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ success: true, libraries: [], count: 0 }), { status: 200 })) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), false);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('namespaceHasKnowledge:問不到(實例沒起來/舊版沒這條路/網路斷)→ null,不宣稱任何事', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async () => { throw new Error('ECONNREFUSED'); }) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('namespaceHasKnowledge:回應形狀不對 → null(讀不出來 ≠ 沒有資料,禁假綠)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ success: true }), { status: 200 })) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', LEO_NS), null);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('namespaceHasKnowledge:缺 url 或缺 namespace → null(不打任何請求)', async () => {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = (async () => { throw new Error('不該被呼叫'); }) as typeof fetch;
|
||||
try {
|
||||
assert.equal(await namespaceHasKnowledge('', LEO_NS), null);
|
||||
assert.equal(await namespaceHasKnowledge('https://cypher.example.dev', ''), null);
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user