Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 179dd60571 | |||
| 9ff933ba98 | |||
| 2fcae722e7 | |||
| cac874601f | |||
| b223a69884 | |||
| fad5da0e17 | |||
| 13155a1d7b | |||
| bb548b6fdf | |||
| e05518a2b4 | |||
| 21293568d5 | |||
| 53b05c6d3d | |||
| f87d0e92f4 | |||
| ba152bc83a | |||
| 89b80ff90e | |||
| 10d150ac2b |
@@ -142,6 +142,37 @@ SDD 屬於架構決策,必須人確認。CC 不可以自行在 `docs/3-specs/`
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 第六類:租戶字串來源(Arcrun#108/#105 同族)
|
||||
|
||||
### 6.1 靜態租戶字串不得用於資料面過濾
|
||||
**知識資料面的 `owner_id`(三元組/entries/records/藏書地圖/工作流 KV)必須與寫入端同源。**
|
||||
寫入端只有一個真相源=使用者 `~/.arcrun/config.yaml` 的 `api_key`(=實例 namespace,
|
||||
CLI push/小幫手上傳/MCP 都用它)。讀取端拿另一份手抄的環境變數預設值 → 全被過濾掉。
|
||||
|
||||
實害:`portalTenant(env) = env.CONSOLE_TENANT || "leo"` 讓 leo 的 **1854 條三元組被過濾成 0 個庫**
|
||||
(#108);前一天 `ownerNamespace(env) = env.MCP_OWNER_NAMESPACE || "leo"` 是同一句話(#105)。
|
||||
|
||||
**規則**:
|
||||
1. `cypher-executor/src/lib/tenant.ts` 是租戶字串的**唯一產地**。
|
||||
`CONSOLE_TENANT` / `ARCRUN_NAMESPACE` 只能在該檔被讀取。
|
||||
2. 知識資料面用 `knowledgeOwner(env)`(回 `TenantId`),過濾一律經
|
||||
`ownerQuery()` / `ownerField()`——它們只吃 `TenantId`,`tsc` 就擋掉「隨手一個 string」。
|
||||
3. 帳號層用 `accountTenant(env)`(回 `string`,**刻意不是 TenantId**):帳號子 namespace
|
||||
`{tenant}::portal` 與 cypher 自己寫的設定用它,型別上不可能流進知識資料面。
|
||||
4. 身分解析路徑上**不准有字面預設值**。解析不到 → 丟 `TenantUnresolvedError`,
|
||||
誠實回「讀不到」(不是「你沒有」,#100 同一條)。
|
||||
|
||||
**機械強制**(規則存在但沒機制驗證=它會再犯第三次):
|
||||
- 出貨閘:`scripts/build-worker-artifacts.mjs` 編 tier2 成品前先掃,違規 → **編不出成品**。
|
||||
- 本機自查:`cd cypher-executor && npm run check:tenant`(`npm test` 也會先跑它)。
|
||||
- 規則本體:`cypher-executor/scripts/tenant-source-rules.mjs`(純函式);
|
||||
閘自己的測試:`cypher-executor/tests/tenant-gate.test.ts`(壞例子會擋+合法寫法零誤攔)。
|
||||
|
||||
> 尚未接上 PreToolUse hook(`.claude/hooks/` 為受保護檔案,需人類加入)。
|
||||
> 要加的話:檢查器已備妥 `--stdin <相對路徑>` 模式,可在寫入前擋。
|
||||
|
||||
## Hook Block 訊息格式
|
||||
|
||||
當 hook 擋住一個操作時,訊息格式統一為:
|
||||
|
||||
@@ -99,6 +99,38 @@ CLI / MCP / Python lib / JS lib 全是薄殼:只做「介面轉換 + 暴露」
|
||||
|
||||
---
|
||||
|
||||
## 3.6 自舉例外:能力該「只實作一次」,但不一定要是 HTTP API(2026-08-12 立)
|
||||
|
||||
> 立這條的原因:`Arcrun#97`(更新把使用者的工作流與登入弄不見)的修法一開始寫在
|
||||
> `cli/src/lib/resource-resolver.ts` ——**能力住在介面層,違反 §0**。
|
||||
> 後果不是理論:**安裝器(arcrun-rag)拿不到它,於是同一個 bug 只修了一半**,
|
||||
> 走 `acr` 的人有保護、走 `install.arcrun.dev` 的人沒有——**而所有真實用戶走後者**。
|
||||
> leo 2026-08-12:「**根本就不應該在 CLI,我要的是一個大家都可以用到的規則。**」
|
||||
|
||||
修法(PR #111)把它搬到 **`shared/resource-rule/`:一份零依賴 ESM**,
|
||||
`acr` 與安裝器共用。**它刻意不是 cypher 的 API 端點**,三個理由:
|
||||
|
||||
| 為什麼不放 API | 說明 |
|
||||
|---|---|
|
||||
| **自舉** | 這條規則要在「決定怎麼裝」的當下用得到,而安裝器的工作正是把 cypher 生出來。放進 cypher = 要先有雞才能有蛋。 |
|
||||
| **輸入是使用者自己的帳號狀態** | 判斷依據是使用者 CF 帳號上的綁定。送去平台託管的 worker 換答案 ⇒ ①「能不能安裝」綁在平台是否活著 ②使用者的帳號拓撲交給第三方。 |
|
||||
| **它根本不需要是服務** | 這是**純函式**,唯一的 IO 由呼叫端注入。**§0 要求「能力只實作一次」,不是「能力一定要是 HTTP」。** |
|
||||
|
||||
🔴 **所以本檔 §0 的正確讀法是**:能力**只准有一份**,且**不准住在任何單一介面裡**。
|
||||
「放 API」是達成它的**常見手段**,不是唯一手段。
|
||||
**判準仍然是那句口訣**:「這段邏輯換一個介面要不要重寫?」要 → 它是能力。
|
||||
|
||||
📌 **給下一個人**:看到 `shared/` 底下的純函式**不要「修正」成 API 端點**——
|
||||
先讀 `shared/resource-rule/README.md §2`,那裡記著評估過並否決的其他形態
|
||||
(共用 npm 套件=自舉問題換位置;做成零件=要用 TinyGo 重寫一次,那才是第二份實作)。
|
||||
|
||||
📌 **打包例外**:`acr` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案 ⇒
|
||||
`cli/` 下必須有一份**逐位元組副本**。那不是第二份實作——
|
||||
`scripts/sync-resource-rule.mjs --check` 一有漂移就 exit 1,且 `build`/`test` 都會先跑它
|
||||
(同 `cli/harness/` 的既有慣例)。**手改副本 = build 紅 = publish 擋下。**
|
||||
|
||||
---
|
||||
|
||||
## 4. 統一帳號來源(薄殼共用同一身份)
|
||||
|
||||
所有薄殼讀**同一份**身份設定:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -52,6 +52,14 @@ scripts/__pycache__/
|
||||
# D1 備份/匯出(wrangler d1 export 產物,含整庫全量資料=機敏,絕不 commit)
|
||||
*.sql
|
||||
backup-*.sql
|
||||
# 🔴 但 migration 不是備份,它是**要出貨的程式碼**(2026-08-12 實撞):
|
||||
# 上面那條 `*.sql` 的用意是擋 D1 匯出(整庫全量資料=機敏),卻連 migration 一起吃掉。
|
||||
# 後果:0001-0004 因為在該規則之前就 commit 所以還在,**0005/0006 從此沒進過版控**
|
||||
# ⇒ 更新指令從 Gitea 抓 main,那兩個檔根本不在那裡 ⇒ 每個用戶都會收到
|
||||
# 「✗ D1 migration: 部署物缺 kbdb/migrations/0005…」——**不是誰忘了推,是規則吃掉的**。
|
||||
# ⇒ 與 `.component-builds/**/component.wasm` 同慣例(見 rules/05-deploy-convention.md
|
||||
# 「WASM 來源」段),用否定規則放行。備份檔仍由 `backup-*.sql` 與目錄位置擋住。
|
||||
!kbdb/migrations/*.sql
|
||||
|
||||
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
|
||||
.github-public/
|
||||
|
||||
@@ -9341,9 +9341,11 @@ function authStoreStatus(env) {
|
||||
var healthRouter = new Hono2();
|
||||
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 內容
|
||||
@@ -12555,6 +12557,45 @@ init_kbdb_proxy();
|
||||
|
||||
// cypher-executor/src/routes/console-auth.ts
|
||||
init_dist();
|
||||
|
||||
// cypher-executor/src/lib/tenant.ts
|
||||
var TenantUnresolvedError = class extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "TenantUnresolvedError";
|
||||
}
|
||||
};
|
||||
function knowledgeOwner(env) {
|
||||
const injected = (env.ARCRUN_NAMESPACE ?? "").trim();
|
||||
if (injected) return injected;
|
||||
const legacy = (env.CONSOLE_TENANT ?? "").trim();
|
||||
if (legacy) return legacy;
|
||||
throw new TenantUnresolvedError(
|
||||
"\u9019\u500B\u90E8\u7F72\u6C92\u6709\u77E5\u8B58\u547D\u540D\u7A7A\u9593\uFF08ARCRUN_NAMESPACE / CONSOLE_TENANT \u90FD\u6C92\u8A2D\uFF09\u2014\u2014\u4E0D\u77E5\u9053\u8981\u53BB\u54EA\u4E00\u683C\u627E\u8CC7\u6599\u3002\u8ACB\u8DD1 `acr update` \u8B93\u5B83\u5F9E\u4F60\u7684 ~/.arcrun/config.yaml \u6CE8\u5165\u3002"
|
||||
);
|
||||
}
|
||||
function tenantFromApiKey(apiKey) {
|
||||
const key = (apiKey ?? "").trim();
|
||||
if (!key) throw new TenantUnresolvedError("\u7F3A\u5C11 X-Arcrun-API-Key\uFF0C\u7121\u6CD5\u6C7A\u5B9A\u67E5\u8A62\u7BC4\u570D");
|
||||
return key;
|
||||
}
|
||||
function accountTenant(env) {
|
||||
return env.CONSOLE_TENANT || "leo";
|
||||
}
|
||||
function ownerQuery(tenant2) {
|
||||
return `owner_id=${encodeURIComponent(tenant2)}`;
|
||||
}
|
||||
function ownerField(tenant2) {
|
||||
return tenant2;
|
||||
}
|
||||
function censusQueryAllTenants() {
|
||||
return "owner_id=";
|
||||
}
|
||||
function isOwnedBy(value, tenant2) {
|
||||
return typeof value === "string" && value === tenant2;
|
||||
}
|
||||
|
||||
// cypher-executor/src/routes/console-auth.ts
|
||||
var consoleAuthRouter = new Hono2();
|
||||
var CREDS_KEY = "console:credentials";
|
||||
var SESSION_PREFIX = "console_sess:";
|
||||
@@ -12581,7 +12622,7 @@ async function hashPassword(password, salt) {
|
||||
return h;
|
||||
}
|
||||
function tenantOf(c) {
|
||||
return c.env.CONSOLE_TENANT || "leo";
|
||||
return knowledgeOwner(c.env);
|
||||
}
|
||||
async function loadCredentials(env) {
|
||||
let fromStore = readAuthStore(env).console;
|
||||
@@ -12852,10 +12893,10 @@ var DEFAULT_SESSION_TTL = 604800;
|
||||
var USER_TEMPLATE = "portal_user";
|
||||
var LIBRARY_TEMPLATE = "portal_library";
|
||||
function portalTenant(env) {
|
||||
return env.CONSOLE_TENANT || "leo";
|
||||
return accountTenant(env);
|
||||
}
|
||||
function portalNamespace(env) {
|
||||
return `${portalTenant(env)}::portal`;
|
||||
return `${accountTenant(env)}::portal`;
|
||||
}
|
||||
function sessionTtl(env) {
|
||||
const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? "", 10);
|
||||
@@ -12884,6 +12925,9 @@ async function run(c, fn) {
|
||||
if (e instanceof AuthStoreWriteError) {
|
||||
return c.json({ error: `\u8A8D\u8B49\u5132\u5B58\u5BEB\u5165\u5931\u6557\uFF1A${e.message}`, code: "auth_store_not_writable" }, 502);
|
||||
}
|
||||
if (e instanceof TenantUnresolvedError) {
|
||||
return c.json({ error: e.message, code: "tenant_unresolved" }, 500);
|
||||
}
|
||||
if (e instanceof KbdbError) return c.json({ error: `KBDB \u4E0D\u53EF\u9054\u6216\u56DE\u932F\uFF1A${e.message}` }, 502);
|
||||
throw e;
|
||||
}
|
||||
@@ -13285,7 +13329,12 @@ portalRouter.post(
|
||||
session_token: token,
|
||||
display_name: rec.values.display_name ?? "",
|
||||
role: rec.values.role ?? "user",
|
||||
libraries: parseLibraries(rec.values.libraries)
|
||||
libraries: parseLibraries(rec.values.libraries),
|
||||
// session 還能活多久(秒)。**非機密**(是這台實例的 TTL 設定,不是任何人的憑據),
|
||||
// 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
|
||||
// access_token 的 TTL 夾到 min(自己的 TTL, 這個值):否則 MCP token 活 30 天、
|
||||
// 底下的 portal session 7 天就死,使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
|
||||
session_expires_in: sessionTtl(c.env)
|
||||
// 絕不回租戶字串(design §3.3:portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*)
|
||||
});
|
||||
})
|
||||
@@ -13761,10 +13810,9 @@ portalRouter.post(
|
||||
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
|
||||
}
|
||||
await clearLoginFail(c.env, email);
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const daemonCfg = {
|
||||
cypher_url: new URL(c.req.url).origin,
|
||||
namespace: tenant2,
|
||||
namespace: knowledgeOwner(c.env),
|
||||
library: "kb",
|
||||
email,
|
||||
instance_name: String(rec.values.display_name ?? "")
|
||||
@@ -13780,7 +13828,7 @@ portalRouter.post(
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const key = String(body?.key ?? "").trim();
|
||||
if (!key) return c.json({ error: "\u8ACB\u8CBC\u4E0A\u4F60\u7684 Google AI \u91D1\u9470" }, 400);
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const kvKey2 = `${tenant2}:wf:rag_chat`;
|
||||
const raw2 = await c.env.WEBHOOKS.get(kvKey2, "text");
|
||||
if (!raw2) return c.json({ error: "\u9019\u500B\u5BE6\u4F8B\u6C92\u6709\u5B89\u88DD AI \u554F\u7B54\u5DE5\u4F5C\u6D41" }, 404);
|
||||
@@ -13832,8 +13880,8 @@ portalRouter.get(
|
||||
});
|
||||
const known = new Set(out.map((l) => l.name));
|
||||
try {
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const ownerParam = `owner_id=${encodeURIComponent(tenant2)}`;
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const ownerParam = ownerQuery(tenant2);
|
||||
const [autoRes, cardRes, tripletRes] = await Promise.all([
|
||||
kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
|
||||
kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
|
||||
@@ -13982,8 +14030,8 @@ portalRouter.get(
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalAdmin(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const ownerId = portalTenant(c.env);
|
||||
const res = await kbdbFetch(c.env, `/execution-log/retention?owner_id=${encodeURIComponent(ownerId)}`);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /execution-log/retention \u2192 ${res.status}`);
|
||||
const data = await res.json();
|
||||
return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
|
||||
@@ -13999,10 +14047,10 @@ portalRouter.put(
|
||||
if (days !== null && days !== void 0 && (typeof days !== "number" || !Number.isFinite(days) || days <= 0)) {
|
||||
return c.json({ error: "retention_days \u5FC5\u9808\u662F\u6B63\u6574\u6578\uFF0C\u6216 null\uFF08\u4EE3\u8868\u4E0D\u522A\u9664\uFF09" }, 400);
|
||||
}
|
||||
const ownerId = portalTenant(c.env);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, "/execution-log/retention", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ owner_id: ownerId, retention_days: days === void 0 ? null : days })
|
||||
body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === void 0 ? null : days })
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`PUT /execution-log/retention \u2192 ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -14019,10 +14067,10 @@ portalRouter.delete(
|
||||
const confirm = String(body?.confirm ?? "").trim();
|
||||
if (!confirm) return c.json({ error: 'body \u9808\u5E36 { confirm: "<\u5EAB\u540D>" } \u624D\u57F7\u884C\uFF08\u79FB\u9664\u6703\u5F71\u97FF\u8CC7\u6599\u53EF\u641C\u6027\uFF09' }, 400);
|
||||
if (confirm !== name) return c.json({ error: `confirm \u503C\u300C${confirm}\u300D\u8207\u5EAB\u540D\u300C${name}\u300D\u4E0D\u7B26` }, 400);
|
||||
const ownerId = portalTenant(c.env);
|
||||
const ownerId = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, "/entries/deprecate-by-library", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ owner_id: ownerId, library: name })
|
||||
body: JSON.stringify({ owner_id: ownerField(ownerId), library: name })
|
||||
});
|
||||
if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library \u2192 ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -14078,8 +14126,8 @@ async function buildDiagnostics(env, tenant2) {
|
||||
let embedding = { checked: false };
|
||||
try {
|
||||
const [statusRes, selftestRes] = await Promise.all([
|
||||
kbdbFetch(env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant2 }).toString()}`),
|
||||
kbdbFetch(env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant2 }).toString()}`)
|
||||
kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant2)}`),
|
||||
kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant2)}`)
|
||||
]);
|
||||
const statusBody = await statusRes.json().catch(() => null);
|
||||
const selftestBody = await selftestRes.json().catch(() => null);
|
||||
@@ -14101,7 +14149,7 @@ async function buildDiagnostics(env, tenant2) {
|
||||
}
|
||||
let library_count = 0;
|
||||
let triplet_count = 0;
|
||||
const ownerParam = new URLSearchParams({ owner_id: tenant2 }).toString();
|
||||
const ownerParam = ownerQuery(tenant2);
|
||||
try {
|
||||
const [registeredLibs, autoRes, tripletRes] = await Promise.all([
|
||||
listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
|
||||
@@ -14125,7 +14173,7 @@ async function buildDiagnostics(env, tenant2) {
|
||||
let library_scope_check = { ran: false };
|
||||
if (library_count === 0 && triplet_count === 0) {
|
||||
try {
|
||||
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: tenant2, limit: "1" }).toString()}`);
|
||||
const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant2), limit: "1" }).toString()}`);
|
||||
const probeBody = await probeRes.json().catch(() => null);
|
||||
const total = probeBody?.total ?? 0;
|
||||
library_scope_check = {
|
||||
@@ -14148,7 +14196,7 @@ portalRouter.get(
|
||||
(c) => run(c, async () => {
|
||||
const apiKey = (c.req.header("X-Arcrun-API-Key") ?? "").trim();
|
||||
if (!apiKey) return c.json({ error: "\u7F3A\u5C11 X-Arcrun-API-Key header" }, 401);
|
||||
const core = await buildDiagnostics(c.env, apiKey);
|
||||
const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
|
||||
return c.json({
|
||||
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||||
instance_url: new URL(c.req.url).origin,
|
||||
@@ -14703,7 +14751,7 @@ async function cachedGiteaSprint(env, nowMs, waitUntil, fetcher = fetchGiteaSpri
|
||||
return { ...fresh, cache: "miss" };
|
||||
}
|
||||
consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const now2 = Date.now();
|
||||
const { base: kbdbUrl, headers: kbdbHeaders } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
@@ -14855,7 +14903,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
});
|
||||
});
|
||||
consoleDashboardRouter.get("/console/kb-scale-data", async (c) => {
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const now2 = Date.now();
|
||||
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
|
||||
@@ -14890,7 +14938,7 @@ consoleDashboardRouter.get("/console/settings-data", (c) => {
|
||||
consoleDashboardRouter.get("/console/triage-data", async (c) => {
|
||||
const ok = await validateConsoleSession(c.env, c.req.header("authorization"));
|
||||
if (!ok) return c.json({ error: "\u9700\u8981\u767B\u5165\uFF08console session\uFF09" }, 401);
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const [todoEntries, inboxEntries] = await Promise.all([
|
||||
fetchEntries(c.env, tenant2, "todo", 500),
|
||||
fetchEntries(c.env, tenant2, "inbox", 200)
|
||||
@@ -14905,7 +14953,7 @@ consoleDashboardRouter.post("/console/triage-check", async (c) => {
|
||||
const entryId = typeof body?.entry_id === "string" ? body.entry_id.trim() : "";
|
||||
if (!entryId) return c.json({ error: "entry_id \u5FC5\u586B" }, 400);
|
||||
const action = body?.action === "restore" ? "restore" : "check";
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const got = await fetchJson(
|
||||
`${base}/entries/${encodeURIComponent(entryId)}`,
|
||||
@@ -14933,7 +14981,7 @@ init_kbdb_proxy();
|
||||
init_webhook_handlers();
|
||||
var portalDataRouter = new Hono2();
|
||||
async function getTenantWorkflowGraph(env, name) {
|
||||
const raw2 = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, "text");
|
||||
const raw2 = await env.WEBHOOKS.get(`${knowledgeOwner(env)}:wf:${name}`, "text");
|
||||
if (!raw2) return null;
|
||||
try {
|
||||
const rec = JSON.parse(raw2);
|
||||
@@ -15026,7 +15074,7 @@ function findBestNodeMatch(searchTerm, nodeNames) {
|
||||
}
|
||||
async function tripletCount(env, owner) {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
|
||||
const res = await kbdbFetch(env, `/records/triplet-stats?${owner === null ? censusQueryAllTenants() : ownerQuery(owner)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.stats)) return null;
|
||||
@@ -15043,11 +15091,11 @@ async function tripletCount(env, owner) {
|
||||
async function tripletCensus(env, tenant2) {
|
||||
const owned = await tripletCount(env, tenant2);
|
||||
if (owned !== 0) return { owned, any: null };
|
||||
return { owned, any: await tripletCount(env, "") };
|
||||
return { owned, any: await tripletCount(env, null) };
|
||||
}
|
||||
async function fuzzyFindNode(env, tenant2, searchTerm) {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?${ownerQuery(tenant2)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.records)) return null;
|
||||
@@ -15075,7 +15123,7 @@ portalDataRouter.get(
|
||||
if (libraries.length === 0) {
|
||||
return c.json({ success: true, entries: [], count: 0, mode: "keyword", note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002" });
|
||||
}
|
||||
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
|
||||
const params = new URLSearchParams({ q, owner_id: ownerField(knowledgeOwner(c.env)) });
|
||||
if (!libraries.includes("*")) params.set("library", libraries.join(","));
|
||||
if (c.req.query("mode") === "semantic") {
|
||||
params.set("mode", "semantic");
|
||||
@@ -15111,7 +15159,7 @@ portalDataRouter.get(
|
||||
const body = await res.json();
|
||||
const entry = body.entry;
|
||||
if (!entry) return notFound(c);
|
||||
if ((entry.owner_id ?? "") !== portalTenant(c.env)) return notFound(c);
|
||||
if (!isOwnedBy(entry.owner_id, knowledgeOwner(c.env))) return notFound(c);
|
||||
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
|
||||
return c.json({ success: true, entry });
|
||||
})
|
||||
@@ -15126,7 +15174,7 @@ portalDataRouter.get(
|
||||
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
|
||||
}
|
||||
const nodeName = normalizeCjkQuery(c.req.param("name"));
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const wfGraph = await getTenantWorkflowGraph(c.env, "graph_neighbors");
|
||||
if (wfGraph) {
|
||||
const depthRaw = c.req.query("depth") ?? "";
|
||||
@@ -15180,9 +15228,9 @@ portalDataRouter.get(
|
||||
if (!await hasGraphAccess(c.env, libraries)) {
|
||||
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
|
||||
}
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const [res, census] = await Promise.all([
|
||||
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}&limit=500`),
|
||||
kbdbFetch(c.env, `/records/by-template/triplet?${ownerQuery(tenant2)}&limit=500`),
|
||||
tripletCensus(c.env, tenant2)
|
||||
]);
|
||||
const tripletsTotal = census.owned;
|
||||
@@ -15253,7 +15301,7 @@ portalDataRouter.get(
|
||||
wfGraph,
|
||||
{ question },
|
||||
"rag_chat",
|
||||
portalTenant(c.env),
|
||||
knowledgeOwner(c.env),
|
||||
c.executionCtx
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -15329,7 +15377,7 @@ portalDataRouter.get(
|
||||
if (!workflowsVisible(c.env, auth.user.values.role ?? "user")) {
|
||||
return c.json({ error: "\u9700\u8981 admin \u6B0A\u9650" }, 403);
|
||||
}
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const prefix = `${tenant2}:wf:`;
|
||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||
const workflows = await Promise.all(
|
||||
@@ -15351,7 +15399,7 @@ portalDataRouter.get(
|
||||
let last_execution = null;
|
||||
const execRes = await kbdbFetch(
|
||||
c.env,
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant2 }).toString()}`
|
||||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: ownerField(tenant2) }).toString()}`
|
||||
);
|
||||
const execBody = await execRes.json().catch(() => null);
|
||||
if (execRes.ok && execBody?.success && execBody.execution) {
|
||||
@@ -15363,12 +15411,207 @@ portalDataRouter.get(
|
||||
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
||||
})
|
||||
);
|
||||
function recordLibrary(values) {
|
||||
const lib = values?.library;
|
||||
return typeof lib === "string" && lib.trim() ? lib.trim() : null;
|
||||
}
|
||||
function canReadRecord(rec, tenant2, libraries) {
|
||||
if (!isOwnedBy(rec.owner_id, tenant2)) return false;
|
||||
const lib = recordLibrary(rec.values);
|
||||
return lib === null || canReadLibrary(libraries, lib);
|
||||
}
|
||||
portalDataRouter.get(
|
||||
"/portal/data/map",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: true,
|
||||
empty_reason: "no_library_grant",
|
||||
note: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u3002"
|
||||
});
|
||||
}
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(c.env, `/map?${ownerQuery(tenant2)}`);
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.libraries)) {
|
||||
return c.json({ error: "\u85CF\u66F8\u5730\u5716\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 libraries \u6E05\u55AE" }, 502);
|
||||
}
|
||||
const allowed = body.libraries.filter(
|
||||
(l) => typeof l?.library === "string" && canReadLibrary(libraries, l.library)
|
||||
);
|
||||
if (allowed.length > 0) {
|
||||
return c.json({ success: true, libraries: allowed, count: allowed.length, empty_confirmed: false, empty_reason: null });
|
||||
}
|
||||
if (body.libraries.length > 0) {
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: true,
|
||||
empty_reason: "filtered_out",
|
||||
note: "\u9019\u500B\u5E33\u865F\u76EE\u524D\u6C92\u6709\u4EFB\u4F55\u77E5\u8B58\u5EAB\u7684\u6AA2\u8996\u6B0A\u9650\uFF0C\u8ACB\u806F\u7D61\u7BA1\u7406\u54E1\u958B\u901A\u3002"
|
||||
});
|
||||
}
|
||||
const census = await tripletCensus(c.env, tenant2);
|
||||
if (census.owned === null || census.owned === 0 && census.any === null) {
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: false,
|
||||
empty_reason: "unreadable",
|
||||
note: "\u8B80\u4E0D\u5230\u77E5\u8B58\u5EAB\u7684\u7D71\u8A08\uFF0C\u7121\u6CD5\u78BA\u8A8D\u5EAB\u88E1\u6709\u6C92\u6709\u6771\u897F\u2014\u2014\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u662F\u9019\u6B21\u8B80\u53D6\u5931\u6557\u3002\u8ACB\u7A0D\u5F8C\u91CD\u6574\u6216\u901A\u77E5\u7BA1\u7406\u54E1\u3002"
|
||||
});
|
||||
}
|
||||
if (census.owned === 0 && (census.any ?? 0) > 0) {
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: false,
|
||||
empty_reason: "scope_mismatch",
|
||||
instance_triplet_count: census.any,
|
||||
note: `\u8B80\u4E0D\u5230\u4F60\u9019\u500B\u5E33\u865F\u7BC4\u570D\u5167\u7684\u85CF\u66F8\u2014\u2014\u4F46\u9019\u53F0\u5BE6\u4F8B\u88E1\u6709 ${census.any} \u689D\u77E5\u8B58\u95DC\u806F\u3002\u9019\u4E0D\u662F\u300C\u9084\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u4E0D\u7528\u53BB\u91CD\u65B0\u4E0A\u50B3\uFF1B\u6BD4\u8F03\u50CF\u77E5\u8B58\u7684\u6B78\u5C6C\u547D\u540D\u7A7A\u9593\u5C0D\u4E0D\u4E0A\u3002\u8ACB\u901A\u77E5\u7BA1\u7406\u54E1\u8DD1\u4E00\u6B21 \`acr update\`\uFF08\u6703\u628A\u4F60\u5B89\u88DD\u6642\u7684\u547D\u540D\u7A7A\u9593\u540C\u6B65\u7D66\u96F2\u7AEF\uFF09\uFF0C\u6216\u6AA2\u67E5 ARCRUN_NAMESPACE \u8A2D\u5B9A\u3002`
|
||||
});
|
||||
}
|
||||
return c.json({
|
||||
success: true,
|
||||
libraries: [],
|
||||
count: 0,
|
||||
empty_confirmed: true,
|
||||
empty_reason: "confirmed_empty",
|
||||
note: "\u77E5\u8B58\u5EAB\u9084\u6C92\u6709\u4EFB\u4F55\u5167\u5BB9\u2014\u2014\u4E0A\u50B3\u6587\u4EF6\u5F8C\u5C31\u6703\u51FA\u73FE\u5728\u9019\u88E1\u3002"
|
||||
});
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
"/portal/data/map/:library",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
const library = c.req.param("library");
|
||||
if (!canReadLibrary(libraries, library)) return notFound(c);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/map/${encodeURIComponent(library)}?${ownerQuery(knowledgeOwner(c.env))}`
|
||||
);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
return new Response(res.body, { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
"/portal/data/templates",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const res = await kbdbFetch(c.env, "/templates");
|
||||
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
return new Response(res.body, { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
})
|
||||
);
|
||||
portalDataRouter.post(
|
||||
"/portal/data/templates",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || typeof body.name !== "string" || !body.name.trim() || !Array.isArray(body.slots)) {
|
||||
return c.json({ error: "name \u8207 slots[] \u5FC5\u586B" }, 400);
|
||||
}
|
||||
const res = await kbdbFetch(c.env, "/templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: body.name,
|
||||
slots: body.slots,
|
||||
description: typeof body.description === "string" ? body.description : void 0,
|
||||
created_by: knowledgeOwner(c.env)
|
||||
})
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
"/portal/data/records/by-template/:template",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return c.json({ success: true, records: [], count: 0 });
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const res = await kbdbFetch(
|
||||
c.env,
|
||||
`/records/by-template/${encodeURIComponent(c.req.param("template"))}?${ownerQuery(tenant2)}`
|
||||
);
|
||||
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.records)) {
|
||||
return c.json({ error: "record \u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
|
||||
}
|
||||
const records = body.records.filter((r) => canReadRecord(r, tenant2, libraries));
|
||||
return c.json({ success: true, records, count: records.length });
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
"/portal/data/records/:recordId",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return notFound(c);
|
||||
const res = await kbdbFetch(c.env, `/records/${encodeURIComponent(c.req.param("recordId"))}`);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB \u56DE\u932F\uFF08HTTP ${res.status}\uFF09` }, 502);
|
||||
const body = await res.json().catch(() => null);
|
||||
const record = body?.record;
|
||||
if (!record) return notFound(c);
|
||||
if (!canReadRecord(record, knowledgeOwner(c.env), libraries)) return notFound(c);
|
||||
return c.json({ success: true, record });
|
||||
})
|
||||
);
|
||||
portalDataRouter.post(
|
||||
"/portal/data/records",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
return c.json({ error: "\u6B64\u5E33\u865F\u5C1A\u672A\u88AB\u6388\u6B0A\u4EFB\u4F55\u77E5\u8B58\u5EAB\uFF0C\u7121\u6CD5\u5BEB\u5165" }, 403);
|
||||
}
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body || typeof body.template !== "string" || !body.template.trim() || !body.values || typeof body.values !== "object") {
|
||||
return c.json({ error: "template \u8207 values \u5FC5\u586B" }, 400);
|
||||
}
|
||||
const values = body.values;
|
||||
const targetLib = recordLibrary(values);
|
||||
if (targetLib !== null && !canReadLibrary(libraries, targetLib)) {
|
||||
return c.json({ error: `\u7121\u300C${targetLib}\u300D\u5EAB\u7684\u6B0A\u9650\uFF0C\u4E0D\u80FD\u5BEB\u5165\u8A72\u5EAB` }, 403);
|
||||
}
|
||||
const res = await kbdbFetch(c.env, "/records", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template: body.template, values, owner_id: ownerField(knowledgeOwner(c.env)) })
|
||||
});
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
"/portal/data/diagnostics",
|
||||
(c) => run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const tenant2 = knowledgeOwner(c.env);
|
||||
const core = await buildDiagnostics(c.env, tenant2);
|
||||
return c.json({
|
||||
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
||||
|
||||
@@ -3281,7 +3281,7 @@ async function createRecord(db, input) {
|
||||
});
|
||||
await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("ev"), recordId, tpl.id, slot, entry.id).run();
|
||||
}
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values };
|
||||
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
|
||||
}
|
||||
async function updateRecord(db, recordId, values) {
|
||||
const evRes = await db.prepare(
|
||||
@@ -3312,7 +3312,7 @@ async function updateRecord(db, recordId, values) {
|
||||
}
|
||||
async function getRecord(db, recordId) {
|
||||
const res = await db.prepare(
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||||
`SELECT ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id = ?`
|
||||
).bind(recordId).all();
|
||||
@@ -3320,7 +3320,8 @@ async function getRecord(db, recordId) {
|
||||
if (rows.length === 0) return null;
|
||||
const values = {};
|
||||
for (const r of rows) values[r.slot] = r.content;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values };
|
||||
const owner_id = rows.find((r) => r.owner_id != null)?.owner_id ?? null;
|
||||
return { record_id: recordId, template_id: rows[0].template_id, values, owner_id };
|
||||
}
|
||||
async function searchByTemplate(db, template, owner_id, limit = 100) {
|
||||
const tpl = await getTemplate(db, template);
|
||||
@@ -3339,17 +3340,18 @@ async function searchByTemplate(db, template, owner_id, limit = 100) {
|
||||
const chunk = ids.slice(i, i + 90);
|
||||
const placeholders = chunk.map(() => "?").join(",");
|
||||
const evRes = await db.prepare(
|
||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id
|
||||
`SELECT ev.record_id as record_id, ev.slot_name as slot, e.content as content, ev.template_id as template_id, e.owner_id as owner_id
|
||||
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||
WHERE ev.record_id IN (${placeholders})`
|
||||
).bind(...chunk).all();
|
||||
for (const r of evRes.results ?? []) {
|
||||
let rec = byId.get(r.record_id);
|
||||
if (!rec) {
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {} };
|
||||
rec = { record_id: r.record_id, template_id: r.template_id, values: {}, owner_id: null };
|
||||
byId.set(r.record_id, rec);
|
||||
}
|
||||
rec.values[r.slot] = r.content;
|
||||
if (rec.owner_id == null && r.owner_id != null) rec.owner_id = r.owner_id;
|
||||
}
|
||||
}
|
||||
return ids.map((id) => byId.get(id)).filter((r) => !!r);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"built_for": "arcrun-tier2-worker-artifacts",
|
||||
"generated_at": "2026-08-12T07:29:58.626Z",
|
||||
"repo_head": "1791ffa4972b4135dacd4208e805f67b747479c4",
|
||||
"generated_at": "2026-08-12T16:25:19.210Z",
|
||||
"repo_head": "2fcae722e7d7eab3c8b39744b87ae1344d23ba2a",
|
||||
"repo_dirty": false,
|
||||
"workers": [
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"source_dir": "cypher-executor",
|
||||
"source_commit": "f1370e2275eea62b64a88821a096f2c2cfe76fb0",
|
||||
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 577374,
|
||||
"content_sha256": "8411ed59b7ad9e1a74ac0d8e3b620d7166e7d0178ac5939e6cc736f2e8d1d2be",
|
||||
"js_bytes": 588587,
|
||||
"content_sha256": "c43728d21251f7835497d7dc40a3e702526a70b33406b617ed64126ffb16e1e0",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -58,11 +58,11 @@
|
||||
{
|
||||
"name": "arcrun-kbdb",
|
||||
"source_dir": "kbdb",
|
||||
"source_commit": "c497ec418eba6cd94b1d5872671c51fd5812c11c",
|
||||
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-kbdb/worker.mjs",
|
||||
"js_bytes": 149533,
|
||||
"content_sha256": "ffb8d43467d0cefbd7545fdc0d347f2b965e3c3de20b3315eed7613f20266891",
|
||||
"js_bytes": 149797,
|
||||
"content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
@@ -148,11 +148,11 @@
|
||||
{
|
||||
"name": "arcrun-mcp",
|
||||
"source_dir": "mcp",
|
||||
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
|
||||
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-mcp/worker.mjs",
|
||||
"js_bytes": 1165388,
|
||||
"content_sha256": "c5ff10f9b9d5a77217be343af12d2be3ee8f9792d3e1e091e48e5e6c8d24ca9d",
|
||||
"js_bytes": 1179487,
|
||||
"content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6",
|
||||
"modules": [],
|
||||
"compat_date": "2024-11-27",
|
||||
"compat_flags": [
|
||||
|
||||
+3
-2
@@ -8,11 +8,12 @@
|
||||
"main": "./dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:harness && npm run check:harness && tsc",
|
||||
"build": "npm run build:harness && npm run check:harness && npm run check:rule && tsc",
|
||||
"build:harness": "node scripts/build-harness-skill.mjs",
|
||||
"check:harness": "node scripts/check-harness-generation.mjs",
|
||||
"check:rule": "node ../scripts/sync-resource-rule.mjs --check",
|
||||
"dev": "tsc --watch",
|
||||
"test": "node --test \"tests/**/*.test.ts\"",
|
||||
"test": "npm run check:rule && node --experimental-transform-types --import ./tests/register-ts-hooks.mjs --test \"tests/**/*.test.ts\"",
|
||||
"prepublishOnly": "npm run build && chmod +x dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
+49
-137
@@ -3,7 +3,8 @@
|
||||
* 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI
|
||||
*/
|
||||
|
||||
import type { LiveBinding, ResourceApi, ScriptBindings } from './resource-resolver.js';
|
||||
import { createCloudflareResourceApi } from './resource-rule/cf-resource-api.mjs';
|
||||
import type { ResourceApi, ScriptBindings } from './resource-resolver.js';
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
@@ -86,170 +87,81 @@ export class CfKvClient {
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
|
||||
*/
|
||||
export class CfAccountClient implements ResourceApi {
|
||||
private accountBase: string;
|
||||
private headers: Record<string, string>;
|
||||
/**
|
||||
* `ResourceApi` 的七個方法**全部委派**給共用規則附的那支 client
|
||||
* (`shared/resource-rule/cf-resource-api.mjs`)。
|
||||
*
|
||||
* 🔴 為什麼不是在這裡自己實作一份:判斷一致還不夠,**看到的東西**也要一致。
|
||||
* 兩條路各自寫一份 CF client,只要有一邊把 404 當錯誤、漏了 per_page、少認一種
|
||||
* 欄位名,那一邊就會「看不到既有綁定」——而看不到既有綁定的下一步,依規則就是新建。
|
||||
* Arcrun#97 不需要規則寫錯,眼睛不一樣就足以重演。
|
||||
*/
|
||||
private readonly rule: ReturnType<typeof createCloudflareResourceApi>;
|
||||
|
||||
constructor(accountId: string, apiToken: string) {
|
||||
this.accountBase = `${CF_API_BASE}/accounts/${accountId}`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
this.rule = createCloudflareResourceApi({ accountId, apiToken });
|
||||
}
|
||||
|
||||
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const { ok, status, result, error } = await this.cfRaw<T>(path, init);
|
||||
const { ok, status, result, error } = await this.rule.cfRaw(path, init);
|
||||
if (!ok) throw new Error(`CF API ${path} 失敗:${error ?? `HTTP ${status}`}`);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
/** 同 cf(),但把 HTTP status 交回呼叫端自己判斷(要區分「404 不存在」和「其他錯誤」時用)。 */
|
||||
private async cfRaw<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<{ ok: boolean; status: number; result?: T; error?: string }> {
|
||||
const res = await fetch(`${this.accountBase}${path}`, {
|
||||
...init,
|
||||
headers: { ...this.headers, ...(init?.headers ?? {}) },
|
||||
});
|
||||
const data = await res.json().catch(() => null) as
|
||||
| { success: boolean; result: T; errors?: Array<{ message: string }> }
|
||||
| null;
|
||||
if (!res.ok || !data?.success) {
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
error: data?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, status: res.status, result: data.result };
|
||||
}
|
||||
|
||||
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
|
||||
async verifyAccess(): Promise<void> {
|
||||
// GET /accounts/{id} 能通 = token 有此 account 的基本讀權限
|
||||
await this.cf<{ id: string; name: string }>('');
|
||||
}
|
||||
|
||||
/** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/
|
||||
async listKvNamespaces(): Promise<Map<string, string>> {
|
||||
const result = await this.cf<Array<{ id: string; title: string }>>(
|
||||
'/storage/kv/namespaces?per_page=100',
|
||||
);
|
||||
const map = new Map<string, string>();
|
||||
for (const ns of result) map.set(ns.title, ns.id);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 無條件新建一顆 KV namespace。
|
||||
*
|
||||
* 🔴 Arcrun#97:這裡**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
|
||||
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路
|
||||
* (安裝器取的名字跟我們的 binding 名不一樣,永遠對不上 ⇒ 每次更新都新建)。
|
||||
* 要不要建,一律先經過 resource-resolver 的 planResources 判斷;那裡只有在
|
||||
* 「確定沒有任何已部署的 worker 綁過這個 binding」時才會排進 create。
|
||||
*/
|
||||
async createKvNamespace(title: string): Promise<string> {
|
||||
const result = await this.cf<{ id: string; title: string }>(
|
||||
'/storage/kv/namespaces',
|
||||
{ method: 'POST', body: JSON.stringify({ title }) },
|
||||
);
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀一顆已部署 worker 現在綁著哪些資源——**使用者那側的事實**(Arcrun#97 的唯一真相源)。
|
||||
* CF:`GET /accounts/{id}/workers/scripts/{script}/settings` → `result.bindings[]`。
|
||||
*
|
||||
* - script 不存在(404)→ `{ deployed: false }`,這是「還沒部署」,不是錯誤。
|
||||
* - 其他任何失敗 → throw。呼叫端必須把它當「我不知道」而**不是**「它沒有」——
|
||||
* 把查不到當成不存在,就是 #97 的根因。
|
||||
*/
|
||||
async getScriptBindings(script: string): Promise<ScriptBindings> {
|
||||
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: [] };
|
||||
throw new Error(`讀 ${script} 綁定失敗:${res.error}`);
|
||||
}
|
||||
return { deployed: true, bindings: normalizeBindings(res.result?.bindings ?? []) };
|
||||
}
|
||||
|
||||
/** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
|
||||
async getWorkersSubdomain(): Promise<string> {
|
||||
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
|
||||
return result.subdomain;
|
||||
}
|
||||
|
||||
// D1 (KBDB Base). Free on Workers Free plan, no credit card (kbdb-base Q4 verified).
|
||||
async listD1Databases(): Promise<Map<string, string>> {
|
||||
const result = await this.cf<Array<{ uuid: string; name: string }>>('/d1/database?per_page=100');
|
||||
const map = new Map<string, string>();
|
||||
for (const db of result) map.set(db.name, db.uuid);
|
||||
return map;
|
||||
// ── 以下七支=`ResourceApi`,一律委派共用規則,**這個檔案不得自己實作** ────────────
|
||||
// (`shared/resource-rule/cf-resource-api.mjs`;委派而非複製的理由見本 class 開頭)
|
||||
|
||||
/** 讀一顆已部署 worker 現在綁著哪些資源——使用者那側的事實(Arcrun#97 的唯一真相源)。 */
|
||||
getScriptBindings(script: string): Promise<ScriptBindings> {
|
||||
return this.rule.getScriptBindings(script);
|
||||
}
|
||||
|
||||
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。 */
|
||||
async createD1Database(name: string): Promise<string> {
|
||||
const result = await this.cf<{ uuid: string; name: string }>(
|
||||
'/d1/database',
|
||||
{ method: 'POST', body: JSON.stringify({ name }) },
|
||||
);
|
||||
return result.uuid;
|
||||
/** 帳號上現有的 KV namespace(title → id)。判斷「綁著的那顆還在不在」用。 */
|
||||
listKvNamespaces(): Promise<Map<string, string>> {
|
||||
return this.rule.listKvNamespaces();
|
||||
}
|
||||
|
||||
/** 帳號上現有的 Vectorize index 名單(判斷「綁著的那顆還在不在」用)。 */
|
||||
async listVectorizeIndexes(): Promise<string[]> {
|
||||
const result = await this.cf<Array<{ name: string }>>('/vectorize/v2/indexes');
|
||||
return (result ?? []).map(i => i.name);
|
||||
/** 帳號上現有的 D1(name → uuid)。 */
|
||||
listD1Databases(): Promise<Map<string, string>> {
|
||||
return this.rule.listD1Databases();
|
||||
}
|
||||
|
||||
/** 帳號上現有的 Vectorize index 名單。 */
|
||||
listVectorizeIndexes(): Promise<string[]> {
|
||||
return this.rule.listVectorizeIndexes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建 KBDB embed 用的 Vectorize index(**bge-m3 = 1024 維 / cosine**,見 deploy.ts 常數說明)。
|
||||
* 已存在(409 / already exists)視為成功——並行或重跑不該炸。沒有 ensure 版本:
|
||||
* 「要不要建」由 planResources 判斷,這裡只負責建(Arcrun#97)。
|
||||
* 無條件新建一顆 KV namespace。
|
||||
*
|
||||
* 🔴 Arcrun#97:**故意沒有**「找不到同名就順手建一顆」的 ensure 版本。
|
||||
* 「照名字找 → 找不到 → 新建 → 綁上去」正是把使用者實例洗成空的那條路。
|
||||
* 要不要建,一律先經過 planResources;那裡只有在「確定沒有任何已部署的 worker
|
||||
* 綁過這個 binding」時才會排進 create。
|
||||
*/
|
||||
async createVectorizeIndex(name: string): Promise<string> {
|
||||
const res = await this.cfRaw<{ name: string }>('/vectorize/v2/indexes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
config: { dimensions: 1024, metric: 'cosine' },
|
||||
description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)',
|
||||
}),
|
||||
});
|
||||
if (res.ok) return name;
|
||||
const detail = (res.error ?? '').toLowerCase();
|
||||
if (res.status === 409 || /already exists|duplicate|conflict/.test(detail)) return name;
|
||||
throw new Error(`建 Vectorize index ${name} 失敗:${res.error}`);
|
||||
createKvNamespace(title: string): Promise<string> {
|
||||
return this.rule.createKvNamespace(title);
|
||||
}
|
||||
|
||||
/** 無條件新建 D1。沒有 ensure 版本,理由同 createKvNamespace(Arcrun#97)。 */
|
||||
createD1Database(name: string): Promise<string> {
|
||||
return this.rule.createD1Database(name);
|
||||
}
|
||||
|
||||
/** 新建 KBDB embed 用的 Vectorize index。沒有 ensure 版本,理由同上(Arcrun#97)。 */
|
||||
createVectorizeIndex(name: string): Promise<string> {
|
||||
return this.rule.createVectorizeIndex(name);
|
||||
}
|
||||
}
|
||||
|
||||
/** CF `/settings` 回的 binding 原始形狀(同一種資源在不同 API 版本欄位名不一,故全都收)。 */
|
||||
interface RawWorkerBinding {
|
||||
type?: string;
|
||||
name?: string;
|
||||
namespace_id?: string;
|
||||
id?: string;
|
||||
database_id?: string;
|
||||
index_name?: string;
|
||||
}
|
||||
|
||||
/** 把 CF 的 binding 陣列收斂成 resolver 認得的三種資源。不認得的型別直接略過。 */
|
||||
function normalizeBindings(raw: RawWorkerBinding[]): LiveBinding[] {
|
||||
const out: LiveBinding[] = [];
|
||||
for (const b of raw) {
|
||||
if (!b?.name) continue;
|
||||
if (b.type === 'kv_namespace') {
|
||||
const value = b.namespace_id ?? b.id;
|
||||
if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
|
||||
} else if (b.type === 'd1' || b.type === 'd1_database') {
|
||||
const value = b.id ?? b.database_id;
|
||||
if (value) out.push({ kind: 'd1', binding: b.name, value });
|
||||
} else if (b.type === 'vectorize') {
|
||||
if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+311
-5
@@ -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+<commit7>` 這個
|
||||
* 舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」——
|
||||
* 那正是我們想要的:**寧可說不準,也不要假裝已是最新**。
|
||||
*/
|
||||
export async function resolveBundleStamp(
|
||||
ref: string,
|
||||
commit?: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<BundleStamp> {
|
||||
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<string | undefined> {
|
||||
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`。
|
||||
@@ -185,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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,9 +404,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 +464,7 @@ export async function downloadAndDeploy(
|
||||
// 所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
|
||||
const requirements: BindingRequirement[] = [];
|
||||
const tomlPreviews = new Map<string, string>(); // dir → 注入前的原文
|
||||
const dirScript = new Map<string, string>(); // dir → worker script 名(#106:var 沿用要逐顆對號)
|
||||
for (const dir of allDirs) {
|
||||
const tomlPath = join(dir, 'wrangler.toml');
|
||||
if (!existsSync(tomlPath)) continue;
|
||||
@@ -318,12 +473,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<string, ResolvedResource>();
|
||||
let liveVars = new Map<string, Record<string, string>>();
|
||||
if (requirements.length > 0) {
|
||||
process.stdout.write(chalk.gray(' → 對照你帳號上已部署的 worker,確認每個綁定該用哪顆資源...'));
|
||||
let plan;
|
||||
@@ -369,6 +526,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 +565,63 @@ 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<string, Record<string, string>>();
|
||||
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<string, string> = { ...keep };
|
||||
if (stamp && script === VERSION_STAMP_WORKER) {
|
||||
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) {
|
||||
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 +637,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 +814,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<string> {
|
||||
async function downloadRepoTarball(ref: string, fromRef?: string): Promise<string> {
|
||||
// 唯一 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 +918,91 @@ function injectWranglerConfig(
|
||||
ctx: DeployContext,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
original?: string,
|
||||
extraVars: Record<string, string> = {},
|
||||
): 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<string, string> | undefined,
|
||||
toml: string,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!live) return out;
|
||||
const managed = new Set<string>(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, string>): 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 +1012,15 @@ function injectWranglerConfig(
|
||||
* 「除了資源 id 以外都已經定案」的 toml,資源解析就是照這份預覽去數需求的
|
||||
* ⇒ 解析階段看到的 binding 清單,與最後真的寫進檔案的,保證一致(Arcrun#97 的教訓:
|
||||
* 兩段程式對同一份檔案有不同想像,就會出現「以為沒有、其實有」)。
|
||||
*
|
||||
* `extraVars`(Arcrun#106):這顆 worker 要**沿用的既有 var** + 這趟要**重烙的版本標籤**。
|
||||
* 預覽時不傳(vars 不影響資源需求解析,傳不傳都是同一份需求清單)。
|
||||
*/
|
||||
export function renderWranglerToml(
|
||||
toml: string,
|
||||
ctx: DeployContext,
|
||||
resolved: Map<string, ResolvedResource>,
|
||||
extraVars: Record<string, string> = {},
|
||||
): string {
|
||||
// cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain
|
||||
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
|
||||
@@ -770,6 +1071,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);
|
||||
|
||||
@@ -1,408 +1,42 @@
|
||||
/**
|
||||
* resource-resolver.ts — 資源解析:「已部署的 worker 現在綁著什麼,那就是事實」
|
||||
* resource-resolver.ts — **這裡沒有邏輯**,只是把共用規則接到 CLI 的既有 import 路徑上。
|
||||
*
|
||||
* 🔴 Arcrun#97(2026-08-12 實害,leo 的實例中了):
|
||||
* 舊做法叫「照名字 ensure」——`acr update` 拿 **binding 名**(`WEBHOOKS`)當成 Cloudflare 上的
|
||||
* **資源標題**去找,找不到就**新建一顆空的、然後綁到 worker 上**。
|
||||
* 安裝器建的資源不叫那個名字(它叫 `arcrun-rag-<instance>-kv-webhooks`)⇒ 一次例行更新
|
||||
* 新建了 9 顆 KV、1 顆 D1,使用者的工作流/登入狀態/子庫**在畫面上全部消失**。
|
||||
* 資料沒有被刪,但 worker 被綁去空的那幾顆——從使用者的角度,他的東西就是不見了。
|
||||
* 「這個實例該用哪些資源」的規則住在 `shared/resource-rule/`(repo 根目錄),
|
||||
* 那是**唯一一份人手維護的實作**;`./resource-rule/` 是該目錄的逐位元組鏡射
|
||||
* (`scripts/sync-resource-rule.mjs` 產生,`npm run build` / `npm test` 會跑 `--check` 擋漂移)。
|
||||
* 之所以要有這份鏡射:`arcrun` 是獨立 npm 套件,`npm pack` 打不進套件目錄外的檔案。
|
||||
*
|
||||
* 根因不是「KV 那段寫錯」,是**「用名字猜使用者的資源」這個做法本身**:
|
||||
* 名字是**使用者那側的事實**(安裝器要怎麼取名由它決定,而且它有權改),
|
||||
* 我們不能拿自己的命名慣例去對號入座,更不能在對不上的時候自作主張生一顆新的。
|
||||
* ——所以修法不是「多比對幾種名字」,是**不再用名字當識別**。
|
||||
* 為什麼規則不在 CLI(leo 2026-08-12):
|
||||
* 「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」
|
||||
* ——`acr` 有這條規則、安裝器沒有,結果就是 Arcrun#97:
|
||||
* 安裝器照名字找、找不到就建一顆空的綁上去,使用者的工作流與登入狀態整片消失。
|
||||
* 規則搬到共用層之後,安裝器直接 import 同一份原稿,**不再有第二種答案**。
|
||||
*
|
||||
* ── 新規則(三句話)────────────────────────────────────────────────
|
||||
* 1. **已部署的 worker 上綁著什麼,那就是事實** → 原封不動沿用,不管那顆資源叫什麼名字。
|
||||
* 2. **只有「確定沒有任何人綁過它」才准新建**(新版本新增的 binding、或真的全新帳號)。
|
||||
* 3. **只要有一點說不準就整趟停手**(讀不到綁定/綁著的資源不見了/同一個 binding 指向兩顆/
|
||||
* 該更新的 worker 一顆都不在),**什麼都不建、什麼都不部署**,把話說清楚讓人來判斷。
|
||||
*
|
||||
* ── 為什麼拆成 plan / apply 兩段 ─────────────────────────────────────
|
||||
* `planResources()` **完全不寫入**,只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。
|
||||
* `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。
|
||||
* ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**,
|
||||
* 不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。
|
||||
* 🔴 不要把任何判斷寫回這個檔案。要改規則 → 改 `shared/resource-rule/rule.mjs`。
|
||||
*/
|
||||
|
||||
/** 這支負責的資源種類。要加新種類(R2/Queue/Hyperdrive…)就加在這裡,
|
||||
* 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。 */
|
||||
export type ResourceKind = 'kv_namespace' | 'd1' | 'vectorize';
|
||||
export {
|
||||
planResources,
|
||||
applyResourcePlan,
|
||||
parseWranglerRequirements,
|
||||
normalizeLiveBindings,
|
||||
normalizeLiveVars,
|
||||
bindingKey,
|
||||
ResourcePlanBlocked,
|
||||
KIND_LABEL,
|
||||
TABLE_KIND,
|
||||
} from './resource-rule/rule.mjs';
|
||||
|
||||
/** 從已部署 worker 上讀回來的一條綁定。`value`:KV/D1 是資源 id,Vectorize 是 index 名。 */
|
||||
export interface LiveBinding {
|
||||
kind: ResourceKind;
|
||||
binding: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ScriptBindings {
|
||||
/** false = 這顆 worker 在帳號上還不存在(全新部署),不是「讀取失敗」。讀取失敗要 throw。 */
|
||||
deployed: boolean;
|
||||
bindings: LiveBinding[];
|
||||
}
|
||||
|
||||
/** resolver 需要的 CF 能力(收窄成介面,方便離線測試餵假帳號)。 */
|
||||
export interface ResourceApi {
|
||||
getScriptBindings(script: string): Promise<ScriptBindings>;
|
||||
/** title → id */
|
||||
listKvNamespaces(): Promise<Map<string, string>>;
|
||||
/** name → uuid */
|
||||
listD1Databases(): Promise<Map<string, string>>;
|
||||
listVectorizeIndexes(): Promise<string[]>;
|
||||
createKvNamespace(title: string): Promise<string>;
|
||||
createD1Database(name: string): Promise<string>;
|
||||
createVectorizeIndex(name: string): Promise<string>;
|
||||
}
|
||||
|
||||
/** 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。 */
|
||||
export interface BindingRequirement {
|
||||
kind: ResourceKind;
|
||||
binding: string;
|
||||
/** 需要它的 worker script 名(= wrangler.toml 的 `name`)。 */
|
||||
worker: string;
|
||||
createName: string;
|
||||
}
|
||||
|
||||
export interface PlannedAdopt {
|
||||
kind: ResourceKind;
|
||||
binding: string;
|
||||
value: string;
|
||||
/** 從哪顆已部署的 worker 上讀到的 */
|
||||
from: string;
|
||||
}
|
||||
|
||||
export interface PlannedCreate {
|
||||
kind: ResourceKind;
|
||||
binding: string;
|
||||
createName: string;
|
||||
wantedBy: string[];
|
||||
/** 其他也指向同一顆資源的 binding(見 shareSameResource)。建一顆,大家共用。 */
|
||||
alsoBind: string[];
|
||||
}
|
||||
|
||||
export interface ResourcePlan {
|
||||
adopt: PlannedAdopt[];
|
||||
create: PlannedCreate[];
|
||||
/** 非空 = 整趟停手。applyResourcePlan 會拒絕執行。 */
|
||||
blockers: string[];
|
||||
}
|
||||
|
||||
export interface ResolvedResource {
|
||||
kind: ResourceKind;
|
||||
binding: string;
|
||||
value: string;
|
||||
origin: 'adopted' | 'created';
|
||||
from?: string;
|
||||
}
|
||||
|
||||
/** plan 被擋下時丟這個,讓呼叫端能把每一條原因原文轉給使用者。 */
|
||||
export class ResourcePlanBlocked extends Error {
|
||||
constructor(readonly blockers: string[]) {
|
||||
super(`資源解析被擋下(${blockers.length} 項)`);
|
||||
this.name = 'ResourcePlanBlocked';
|
||||
}
|
||||
}
|
||||
|
||||
export function bindingKey(kind: ResourceKind, binding: string): string {
|
||||
return `${kind}:${binding}`;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<ResourceKind, string> = {
|
||||
kv_namespace: 'KV namespace',
|
||||
d1: 'D1 資料庫',
|
||||
vectorize: 'Vectorize index',
|
||||
};
|
||||
|
||||
function msg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* 決定每個 binding 要沿用哪顆資源/要不要新建,**不寫入任何東西**。
|
||||
*
|
||||
* @param mode 'update' = 這台照定義已經裝過了(見下方「一顆都不在」規則);'init' = 全新安裝,允許從零建。
|
||||
*/
|
||||
export async function planResources(
|
||||
api: ResourceApi,
|
||||
requirements: readonly BindingRequirement[],
|
||||
mode: 'update' | 'init',
|
||||
): Promise<ResourcePlan> {
|
||||
const blockers: string[] = [];
|
||||
const adopt: PlannedAdopt[] = [];
|
||||
const create: PlannedCreate[] = [];
|
||||
|
||||
// ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
|
||||
// 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
|
||||
const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
|
||||
const live = new Map<string, LiveBinding[]>();
|
||||
let readFailed = false;
|
||||
for (const script of scripts) {
|
||||
try {
|
||||
const res = await api.getScriptBindings(script);
|
||||
if (res.deployed) live.set(script, res.bindings);
|
||||
} catch (e) {
|
||||
readFailed = true;
|
||||
blockers.push(
|
||||
`讀不到已部署的 worker「${script}」目前綁著哪些資源(${msg(e)})。` +
|
||||
`不確定它現在用的是哪一顆,就不能重新綁——整趟更新停手,沒有動任何東西。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 「這台照定義已經裝過了,卻一顆 worker 都找不到」= 我對不上它的實例(名字不同/token 看不到)。
|
||||
// 這種時候繼續走下去,等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀,只是換一道門進來。
|
||||
if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
|
||||
blockers.push(
|
||||
`在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker(找過:${scripts.join('、')})。` +
|
||||
`acr update 的前提是「這台已經裝好了」——對不上就不猜:` +
|
||||
`可能是 API token 看得到的帳號不對,或這台實例的 worker 用了別的名字。` +
|
||||
`已停手,沒有新建任何資源。`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. 逐個 binding 決定:沿用 / 新建 / 停手 ─────────────────────────
|
||||
const byKey = new Map<string, BindingRequirement[]>();
|
||||
for (const req of requirements) {
|
||||
const key = bindingKey(req.kind, req.binding);
|
||||
const list = byKey.get(key);
|
||||
if (list) list.push(req);
|
||||
else byKey.set(key, [req]);
|
||||
}
|
||||
|
||||
const existingCache = new Map<ResourceKind, Set<string>>();
|
||||
const listExisting = async (kind: ResourceKind): Promise<Set<string>> => {
|
||||
const hit = existingCache.get(kind);
|
||||
if (hit) return hit;
|
||||
let set: Set<string>;
|
||||
if (kind === 'kv_namespace') set = new Set((await api.listKvNamespaces()).values());
|
||||
else if (kind === 'd1') set = new Set((await api.listD1Databases()).values());
|
||||
else set = new Set(await api.listVectorizeIndexes());
|
||||
existingCache.set(kind, set);
|
||||
return set;
|
||||
};
|
||||
|
||||
for (const [, reqs] of byKey) {
|
||||
const { kind, binding } = reqs[0];
|
||||
|
||||
const found: Array<{ value: string; script: string }> = [];
|
||||
for (const [script, bindings] of live) {
|
||||
const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
|
||||
if (hit) found.push({ value: hit.value, script });
|
||||
}
|
||||
const distinct = [...new Set(found.map((f) => f.value))];
|
||||
|
||||
// 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
|
||||
// 自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
|
||||
if (distinct.length > 1) {
|
||||
blockers.push(
|
||||
`綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
|
||||
`(${found.map((f) => `${f.script} → ${f.value}`).join('、')})。` +
|
||||
`分不出哪一顆才是你在用的,不猜——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
|
||||
if (distinct.length === 1) {
|
||||
const value = distinct[0];
|
||||
let existing: Set<string>;
|
||||
try {
|
||||
existing = await listExisting(kind);
|
||||
} catch (e) {
|
||||
blockers.push(
|
||||
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
|
||||
`(${msg(e)})。不確定就不動——停手。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!existing.has(value)) {
|
||||
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
|
||||
blockers.push(
|
||||
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value},` +
|
||||
`但這顆在你的 Cloudflare 帳號上找不到了。` +
|
||||
`這裡**不會**幫你新建一顆空的頂上去(Arcrun#97 的災情就是那樣來的)——` +
|
||||
`請先確認那顆資源是被刪掉了,還是這把 API token 看不到它。`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
adopt.push({ kind, binding, value, from: found[0].script });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號。
|
||||
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
|
||||
create.push({
|
||||
kind,
|
||||
binding,
|
||||
createName: reqs[0].createName,
|
||||
wantedBy: [...new Set(reqs.map((r) => r.worker))],
|
||||
alsoBind: [],
|
||||
});
|
||||
}
|
||||
|
||||
return { adopt, create: shareSameResource(adopt, create, byKey), blockers };
|
||||
}
|
||||
|
||||
/**
|
||||
* 收斂「不同 binding 其實是同一顆資源」的情況。
|
||||
*
|
||||
* 判準是 **toml 自己宣告的名字**(`database_name` / `index_name`),不是使用者那側的資源名——
|
||||
* cypher 的 `CREDENTIALS_DB` 與 kbdb 的 `DB` 都寫 `database_name = "arcrun-kbdb"`,
|
||||
* 那是**我們**在宣告「這兩個綁定指向同一顆庫」,跟 #97 那種「拿名字去猜使用者的資源」是兩回事。
|
||||
*
|
||||
* 沒有這一步會出兩種錯:
|
||||
* ① 全新安裝時建出兩顆同名 D1,KBDB 的資料與 credential 目錄從此分家。
|
||||
* ② 一邊已部署(沿用既有)、另一邊沒有(新建一顆空的)→ 半套資料,比全壞更難查。
|
||||
*/
|
||||
function shareSameResource(
|
||||
adopt: PlannedAdopt[],
|
||||
create: PlannedCreate[],
|
||||
byKey: Map<string, BindingRequirement[]>,
|
||||
): PlannedCreate[] {
|
||||
const declaredName = (kind: ResourceKind, binding: string): string | undefined =>
|
||||
byKey.get(bindingKey(kind, binding))?.[0]?.createName;
|
||||
|
||||
const out: PlannedCreate[] = [];
|
||||
const groups = new Map<string, PlannedCreate>();
|
||||
|
||||
for (const c of create) {
|
||||
const groupKey = `${c.kind} | ||||