Compare commits

..

1 Commits

Author SHA1 Message Date
uncle6me-web 179dd60571 fix(kbdb): 三元組版標庫批次補存量+對外通道(Arcrun#87 二次收尾)
藏書地圖(library-map.ts)讀的是三元組 record 自己的 'library' slot(entry_values),
不是 entries.metadata_json.library——Arcrun#85 建的 POST /entries/backfill-library
補的是後者,補了地圖依然是 0。既有能補三元組 library 值的通道只有單筆
PATCH /kbdb/records/:id(b6ef0f0,已在 main),母體上千筆時逐筆呼叫不現實,也沒有
節流/冪等保護。

新增:
- kbdb/src/actions/library-backfill.ts:backfillTripletLibraryTags +
  tripletLibraryBackfillStatus,安全原則與既有 entries 版逐條對齊——呼叫端決定
  library 值+source_prefix 篩選(base 不猜語意)、冪等(NOT EXISTS 找缺 library 的
  那半)、與 entries 版/embed reconcile 共用同一顆 D69 每日 D1 寫入額度、owner_id 必填、
  寫入沿用既有 updateRecord(不手刻第二套 entry_values UPSERT SQL)。
- kbdb/src/routes/records.ts:POST /records/backfill-library + GET .../status。
- cypher-executor/src/routes/kbdb-proxy.ts:對外通道,owner_id 強制用租戶身份(同本檔
  既有 POST /kbdb/records 慣例,不信任 caller 自帶 owner_id)。

測試:kbdb 新增 5 案(含跨租戶隔離/冪等/D69 共用額度截斷/status 統計),全套
218/218 綠燈;cypher-executor 新增 5 案(租戶閘/參數驗證/轉發/owner_id 覆蓋),
兩份既有失敗(auth.test.ts ExecutionContext 型別缺陷、portal-data/portal-admin/
console-library-map-page/executor.test.ts 訊息措辭)複驗與本次改動無關(stash 前後
一致,pre-existing)。

紅線:未併 main、未部署、未對 leo21c 執行任何實際 backfill(缺安全的寫入通道——
MCP 工具集無 update/patch record 能力,CLI 全域設定指著 leo21c 不可裸跑)。
批次執行是下一步,需總管/leo 決定執行方式與時機。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:12:56 +08:00
140 changed files with 1607 additions and 51237 deletions
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+116 -130
View File
@@ -3243,20 +3243,15 @@ var init_kbdb_proxy = __esm({
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
const body = await c.req.json().catch(() => null);
if (!body || !body.template || !body.values && !body.entry_ids) {
return c.json({ error: "template \u5FC5\u586B\uFF0Cvalues \u8207 entry_ids \u81F3\u5C11\u8981\u6709\u4E00\u500B" }, 400);
if (!body || !body.template || !body.values) {
return c.json({ error: "template \u8207 values \u5FC5\u586B" }, 400);
}
const { base, headers } = kbdbBase(c.env);
const res = await fetch(`${base}/records`, {
method: "POST",
headers,
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
body: JSON.stringify({
template: body.template,
...body.values ? { values: body.values } : {},
...body.entry_ids ? { entry_ids: body.entry_ids } : {},
owner_id: owner
})
body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner })
});
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
});
@@ -3405,9 +3400,8 @@ function validateName(name) {
function validSensitivity(s) {
return s === "standard" || s === "high";
}
async function putWorkerSecret(env, secretRef, value, tokenOverride) {
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
if (!token || !env.CF_ACCOUNT_ID) {
async function putWorkerSecret(env, secretRef, value) {
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
throw new Error(
"\u6B64 worker \u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID \u8A2D\u5B9A\uFF0C\u5BEB\u5165\u8DEF\u5F91\u672A\u5C31\u7DD2\uFF08\u898B credential-store-migration.md T3\uFF1Aacr init/update \u61C9\u78BA\u4FDD\u9019\u5169\u9805\u5C31\u7DD2\uFF09"
);
@@ -3416,7 +3410,7 @@ async function putWorkerSecret(env, secretRef, value, tokenOverride) {
const res = await fetch(url, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ name: secretRef, text: value, type: "secret_text" })
@@ -3427,15 +3421,14 @@ async function putWorkerSecret(env, secretRef, value, tokenOverride) {
throw new Error(`CF Workers Secrets \u5BEB\u5165\u5931\u6557\uFF1A${detail}`);
}
}
async function deleteWorkerSecret(env, secretRef, tokenOverride) {
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
if (!token || !env.CF_ACCOUNT_ID) {
async function deleteWorkerSecret(env, secretRef) {
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
throw new Error("\u6B64 worker \u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID \u8A2D\u5B9A\uFF0C\u522A\u9664\u8DEF\u5F91\u672A\u5C31\u7DD2");
}
const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`;
const res = await fetch(url, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` }
headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` }
});
if (res.status === 404) return;
const body = await res.json().catch(() => null);
@@ -3796,9 +3789,6 @@ async function resolveCredentialRefs(data, env, apiKey) {
if (names.size === 0) return data;
const nameList = [...names];
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
if (nameList.every((n) => Object.prototype.hasOwnProperty.call(resolvedSecrets, n))) {
return replaceCredentialRefs(data, resolvedSecrets);
}
const url = wasmWorkerUrl("auth_static_key", env.WORKER_SUBDOMAIN);
const res = await fetch(url, {
method: "POST",
@@ -9208,8 +9198,11 @@ function shardIndex(name) {
function shardNameOf(index) {
return index === 0 ? AUTH_STORE_PREFIX : `${AUTH_STORE_PREFIX}_${index}`;
}
function authStoreWritable(env, tokenOverride) {
return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID);
function authStorePresent(env) {
return shardNames(env).length > 0 || overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS;
}
function authStoreWritable(env) {
return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID);
}
function readAuthStore(env) {
if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
@@ -9245,10 +9238,15 @@ function findAuthUserById(env, id) {
function isAuthStoreId(recordId) {
return recordId.startsWith(AUTH_ID_PREFIX);
}
async function writeAuthStore(env, data, tokenOverride) {
if (!authStoreWritable(env, tokenOverride)) {
function newAuthUserId() {
const arr = new Uint8Array(12);
crypto.getRandomValues(arr);
return AUTH_ID_PREFIX + Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function writeAuthStore(env, data) {
if (!authStoreWritable(env)) {
throw new AuthStoreWriteError(
"\u9019\u53F0\u5BE6\u4F8B\u76EE\u524D\u5BEB\u4E0D\u9032\u8A8D\u8B49\u5132\u5B58\uFF08\u7F3A\u53EF\u7528\u7684 Cloudflare \u5BEB\u5165\u6191\u8B49\uFF1ACF_SECRETS_API_TOKEN\uFF09\u3002\u9019\u662F\u5E73\u53F0\u7AEF\u7684\u5DF2\u77E5\u9650\u5236\uFF0C\u4E0D\u662F\u4F60\u64CD\u4F5C\u932F\u8AA4\u2014\u2014\u76EE\u524D\u6C92\u6709\u4F60\u81EA\u5DF1\u5728\u756B\u9762\u4E0A\u80FD\u505A\u7684\u4E0B\u4E00\u6B65\uFF0C\u8ACB\u628A\u9019\u5247\u8A0A\u606F\u5B8C\u6574\u622A\u5716\uFF0F\u8907\u88FD\u7D66\u652F\u63F4\uFF0C\u4E26\u8A3B\u660E\u4F60\u525B\u624D\u5728\u505A\u4EC0\u9EBC\uFF08\u4F8B\u5982\uFF1A\u5B89\u88DD\u7CBE\u9748\u88E1\u5EFA\u7ACB\u7B2C\u4E00\u500B\u5E33\u865F\u3001\u4E8B\u5F8C\u65B0\u589E\u4F7F\u7528\u8005\u3001\u6216\u4FEE\u6539\u5BC6\u78BC\uFF09\uFF0C\u6703\u9700\u8981\u4EBA\u5DE5\u5354\u52A9\u6392\u9664\u3002"
"\u9019\u53F0\u5BE6\u4F8B\u9084\u4E0D\u80FD\u5BEB\u5165\u8A8D\u8B49\u5132\u5B58\uFF08\u7F3A CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID\uFF09\u3002\u8A8D\u8B49\u5206\u96E2\u9700\u8981\u9019\u5169\u9805\u624D\u5BEB\u5F97\u9032 Workers Secrets\u2014\u2014\u8ACB\u91CD\u65B0\u57F7\u884C\u5B89\u88DD\uFF0F\u66F4\u65B0\u8B93\u5B83\u5C31\u7DD2\u3002"
);
}
const shards = [];
@@ -9271,10 +9269,10 @@ async function writeAuthStore(env, data, tokenOverride) {
}
const existing = shardNames(env);
for (let i = 0; i < shards.length; i++) {
await putWorkerSecret(env, shardNameOf(i), shards[i], tokenOverride);
await putWorkerSecret(env, shardNameOf(i), shards[i]);
}
for (const name of existing) {
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name, tokenOverride);
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
}
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
overlayAt = Date.now();
@@ -9321,23 +9319,26 @@ function unionStores(a, b) {
}
return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
}
async function mutateAuthStore(env, fn, tokenOverride) {
async function mutateAuthStore(env, fn) {
await hydrateFromAccelerator(env);
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
await fn(next);
await writeAuthStore(env, next, tokenOverride);
await writeAuthStore(env, next);
return next;
}
function authStoreStatus(env) {
const data = readAuthStore(env);
return {
present: authStorePresent(env),
writable: authStoreWritable(env),
users: data.users.length,
console_configured: Boolean(data.console),
shards: shardNames(env).length
};
}
// cypher-executor/src/routes/health.ts
var healthRouter = new Hono2();
function authStoreStatus(env) {
const legacy = readAuthStore(env);
return {
console: { home: "sessions-kv", writable: true, legacy_secrets_present: legacy.console !== null },
portal_users: { home: "kbdb", writable: true, legacy_secrets_present: legacy.users.length > 0 }
};
}
healthRouter.get("/health", (c) => {
const bundleVersion = c.env.ARCRUN_BUNDLE_VERSION;
const bundleCommit = c.env.ARCRUN_BUNDLE_COMMIT;
@@ -12624,35 +12625,35 @@ function tenantOf(c) {
return knowledgeOwner(c.env);
}
async function loadCredentials(env) {
const raw2 = await env.SESSIONS_KV.get(CREDS_KEY);
if (raw2) {
try {
return { creds: JSON.parse(raw2), source: "kv" };
} catch {
}
let fromStore = readAuthStore(env).console;
if (!fromStore && await hydrateFromAccelerator(env)) {
fromStore = readAuthStore(env).console;
}
const legacy = readAuthStore(env).console;
if (!legacy) return { creds: null, source: "none" };
if (fromStore) return { creds: fromStore, source: "secrets" };
const raw2 = await env.SESSIONS_KV.get(CREDS_KEY);
if (!raw2) return { creds: null, source: "none" };
let legacy = null;
try {
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
legacy = JSON.parse(raw2);
} catch {
return { creds: null, source: "none" };
}
try {
await mutateAuthStore(env, (data) => {
if (!data.console) data.console = legacy;
});
} catch {
}
return { creds: legacy, source: "legacy-secrets" };
return { creds: legacy, source: "legacy-kv" };
}
async function saveCredentials(env, record) {
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
}
function consoleAuthStoreStatus(env) {
return {
home: "sessions-kv",
writable: true,
// binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
legacy_secrets_present: readAuthStore(env).console !== null
};
await mutateAuthStore(env, (data) => {
data.console = record;
});
}
consoleAuthRouter.get("/console/auth-status", async (c) => {
const { creds, source } = await loadCredentials(c.env);
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(c.env) });
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
});
consoleAuthRouter.post("/console/setup", async (c) => {
const { creds: existing } = await loadCredentials(c.env);
@@ -12678,7 +12679,8 @@ consoleAuthRouter.post("/console/setup", async (c) => {
try {
await saveCredentials(c.env, record);
} catch (e) {
return c.json({ error: `\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${e instanceof Error ? e.message : String(e)}`, code: "auth_store_not_writable" }, 502);
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
return c.json({ error: `\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${msg}`, code: "auth_store_not_writable" }, 502);
}
const token = randomHex(32);
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
@@ -12703,7 +12705,8 @@ consoleAuthRouter.post("/console/setup/reset", async (c) => {
try {
await saveCredentials(c.env, record);
} catch (e) {
return c.json({ error: `\u65B0\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${e instanceof Error ? e.message : String(e)}`, code: "auth_store_not_writable" }, 502);
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
return c.json({ error: `\u65B0\u5E33\u5BC6\u6C92\u6709\u5B58\u8D77\u4F86\uFF1A${msg}`, code: "auth_store_not_writable" }, 502);
}
return c.json({ success: true });
});
@@ -12714,7 +12717,7 @@ consoleAuthRouter.post("/console/login", async (c) => {
{
error: "\u9019\u53F0\u5BE6\u4F8B\u9084\u6C92\u6709\u7BA1\u7406\u54E1\u5E33\u5BC6\uFF08\u6216\u8B80\u4E0D\u5230\uFF09\u2014\u2014\u4E0D\u662F\u5BC6\u78BC\u932F\u3002\u8ACB\u5148\u5B8C\u6210\u9996\u6B21\u8A2D\u5B9A\u3002",
code: "auth_store_empty",
auth_store: consoleAuthStoreStatus(c.env)
auth_store: authStoreStatus(c.env)
},
400
);
@@ -12723,8 +12726,18 @@ consoleAuthRouter.post("/console/login", async (c) => {
const email = (body?.email ?? "").trim().toLowerCase();
const password = body?.password ?? "";
if (!email || !password) return c.json({ error: "email \u8207 password \u5FC5\u586B" }, 400);
const hash = await hashPassword(password, existing.salt);
if (email !== existing.email || hash !== existing.hash) {
let creds = existing;
let hash = await hashPassword(password, creds.salt);
if (email !== creds.email || hash !== creds.hash) {
if (await hydrateFromAccelerator(c.env)) {
const again = (await loadCredentials(c.env)).creds;
if (again) {
creds = again;
hash = await hashPassword(password, creds.salt);
}
}
}
if (email !== creds.email || hash !== creds.hash) {
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
}
const token = randomHex(32);
@@ -12995,31 +13008,24 @@ function recordValuesToAuthUser(id, v) {
updated_at: v.updated_at ?? (/* @__PURE__ */ new Date()).toISOString()
};
}
async function promoteToKbdb(env, rec) {
async function promoteLegacyUser(env, rec) {
try {
const email = (rec.values.email ?? "").toLowerCase();
if (!email) return null;
const already = await findKbdbUserRecordId(env, email);
if (already) return already;
return await createKbdbUserRecord(env, email, {
display_name: rec.values.display_name ?? "",
status: rec.values.status ?? "active",
role: rec.values.role ?? "user",
password_hash: rec.values.password_hash ?? "",
libraries: rec.values.libraries ?? "[]",
created_at: rec.values.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
updated_at: rec.values.updated_at ?? (/* @__PURE__ */ new Date()).toISOString()
if (!email) return;
if (findAuthUserByEmail(env, email)) return;
await mutateAuthStore(env, (data) => {
if (data.users.some((u) => u.email === email)) return;
data.users.push(recordValuesToAuthUser(newAuthUserId(), rec.values));
});
} catch {
return null;
}
}
async function findUserRecordId(env, email) {
const inKbdb = await findKbdbUserRecordId(env, email);
if (inKbdb) return inKbdb;
return findAuthUserByEmail(env, email)?.id ?? null;
const inStore = findAuthUserByEmail(env, email);
if (inStore) return inStore.id;
return findLegacyUserRecordId(env, email);
}
async function findKbdbUserRecordId(env, email) {
async function findLegacyUserRecordId(env, email) {
const ns = portalNamespace(env);
const params = new URLSearchParams({
page_name: email,
@@ -13088,53 +13094,42 @@ function daemonActiveKey(env) {
}
async function listRecordsByTemplate(env, template) {
if (template === USER_TEMPLATE) {
let fromKbdb = [];
const fromStore = readAuthStore(env).users.map(authUserToRecord);
const seen = new Set(fromStore.map((r) => (r.values.email ?? "").toLowerCase()));
let legacy = [];
try {
fromKbdb = await listKbdbRecordsByTemplate(env, template);
legacy = await listLegacyRecordsByTemplate(env, template);
} catch {
fromKbdb = [];
legacy = [];
}
const seen = new Set(fromKbdb.map((r) => (r.values.email ?? "").toLowerCase()));
const fromLegacy = readAuthStore(env).users.map(authUserToRecord).filter((r) => !seen.has((r.values.email ?? "").toLowerCase()));
return [...fromKbdb, ...fromLegacy];
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? "").toLowerCase()))];
}
return listKbdbRecordsByTemplate(env, template);
return listLegacyRecordsByTemplate(env, template);
}
async function listKbdbRecordsByTemplate(env, template) {
async function listLegacyRecordsByTemplate(env, template) {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} \u2192 ${res.status}`);
const body = await res.json();
return body.records ?? [];
}
async function createKbdbUserRecord(env, email, values) {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, "/records", {
method: "POST",
body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { ...values, email } })
});
if (!res.ok) throw new KbdbError(`POST /records\uFF08portal_user\uFF09\u2192 ${res.status}`);
const body = await res.json();
const recordId = body.record?.record_id;
if (!recordId) throw new KbdbError("POST /records \u56DE\u61C9\u7F3A record_id");
const head = await kbdbFetch(env, "/entries", {
method: "POST",
body: JSON.stringify({ entry_type: USER_TEMPLATE, page_name: email, content: recordId, owner_id: ns })
});
if (!head.ok) throw new KbdbError(`head entry \u5EFA\u7ACB\u5931\u6557\uFF08record ${recordId} \u5DF2\u5EFA\uFF0C\u9700\u4EBA\u5DE5\u6536\u62FE\uFF09\u2192 ${head.status}`);
return recordId;
}
async function createPortalUser(env, input) {
const now2 = (/* @__PURE__ */ new Date()).toISOString();
return createKbdbUserRecord(env, input.email.toLowerCase(), {
display_name: input.display_name,
status: "active",
role: input.role,
password_hash: input.password_hash,
libraries: JSON.stringify(input.libraries),
created_at: now2,
updated_at: now2
const id = newAuthUserId();
await mutateAuthStore(env, (data) => {
data.users.push({
id,
email: input.email.toLowerCase(),
display_name: input.display_name,
status: "active",
role: input.role,
libraries: input.libraries,
password_hash: input.password_hash,
created_at: now2,
updated_at: now2
});
});
return id;
}
function parseLibraries(raw2) {
if (!raw2) return [];
@@ -13271,7 +13266,7 @@ async function clearLoginFail(env, email) {
async function instanceHasNoAuthData(env) {
if (readAuthStore(env).users.length > 0) return false;
try {
return (await listKbdbRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
} catch {
return true;
}
@@ -13308,7 +13303,7 @@ portalRouter.post(
{
error: "\u9019\u53F0\u5BE6\u4F8B\u8B80\u4E0D\u5230\u4EFB\u4F55\u767B\u5165\u8CC7\u6599\u2014\u2014\u4E0D\u662F\u5BC6\u78BC\u932F\u3002\u8A8D\u8B49\u5132\u5B58\u662F\u7A7A\u7684\uFF0C\u8ACB\u91CD\u65B0\u57F7\u884C\u5B89\u88DD\uFF0F\u66F4\u65B0\u4EE5\u91CD\u65B0\u5EFA\u7ACB\u7BA1\u7406\u54E1\u5E33\u865F\u3002",
code: "auth_store_empty",
auth_store: { home: "kbdb", writable: true, users: 0 }
auth_store: authStoreStatus(c.env)
},
503
);
@@ -13323,14 +13318,10 @@ portalRouter.post(
await recordLoginFail(c.env, email);
return c.json({ error: "email \u6216\u5BC6\u78BC\u932F\u8AA4" }, 401);
}
let sessionRecordId = recordId;
if (isAuthStoreId(recordId)) {
const migrated = await promoteToKbdb(c.env, rec);
if (migrated) sessionRecordId = migrated;
}
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
await clearLoginFail(c.env, email);
const token = randomHex2(32);
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX2}${token}`, JSON.stringify({ record_id: sessionRecordId }), {
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX2}${token}`, JSON.stringify({ record_id: recordId }), {
expirationTtl: sessionTtl(c.env)
});
return c.json({
@@ -13713,14 +13704,12 @@ portalRouter.post(
const body = await c.req.json().catch(() => null);
const pageName = String(body?.page_name ?? "").trim();
const srcText = String(body?.text ?? "");
const daemonPrompt = String(body?.prompt ?? "").trim();
if (!daemonPrompt && (!pageName || !srcText.trim()))
return c.json({ error: "page_name \u8207 text \u5FC5\u586B" }, 400);
if (!pageName || !srcText.trim()) return c.json({ error: "page_name \u8207 text \u5FC5\u586B" }, 400);
if (!c.env.AI) {
return c.json({ error: "\u9019\u500B\u90E8\u7F72\u6C92\u6709\u7D81\u5B9A Workers AI\uFF08wrangler.toml \u9700\u6709 [ai] binding\uFF09\uFF0C\u8ACB\u66F4\u65B0\u77E5\u8B58\u5EAB\u7248\u672C" }, 501);
}
const REL = ">".repeat(2);
const prompt = daemonPrompt || `\u628A\u4EE5\u4E0B\u539F\u7A3F\u91CD\u5BEB\u6210\u5B9A\u7A3F\u77E5\u8B58\u5361\uFF08\u6B63\u9AD4\u4E2D\u6587\uFF09\u3002\u76F4\u63A5\u8F38\u51FA\u5361\u7247\u672C\u8EAB\uFF1A\u7B2C\u4E00\u884C\u5FC5\u9808\u662F\u300C# ${pageName}\u300D\uFF0C\u4E0D\u8981\u4EFB\u4F55\u524D\u8A00\u3001\u601D\u8003\u904E\u7A0B\u3001\u82F1\u6587\u8349\u7A3F\u6216\u8AAA\u660E\u3002\u683C\u5F0F\uFF1A
const prompt = `\u628A\u4EE5\u4E0B\u539F\u7A3F\u91CD\u5BEB\u6210\u5B9A\u7A3F\u77E5\u8B58\u5361\uFF08\u6B63\u9AD4\u4E2D\u6587\uFF09\u3002\u76F4\u63A5\u8F38\u51FA\u5361\u7247\u672C\u8EAB\uFF1A\u7B2C\u4E00\u884C\u5FC5\u9808\u662F\u300C# ${pageName}\u300D\uFF0C\u4E0D\u8981\u4EFB\u4F55\u524D\u8A00\u3001\u601D\u8003\u904E\u7A0B\u3001\u82F1\u6587\u8349\u7A3F\u6216\u8AAA\u660E\u3002\u683C\u5F0F\uFF1A
# ${pageName}
## \u4E00\u53E5\u8A71\u5B9A\u7FA9
\uFF08\u4E00\u884C\uFF09
@@ -13736,17 +13725,14 @@ ${srcText}`;
try {
const out = await c.env.AI.run("@cf/meta/llama-4-scout-17b-16e-instruct", {
messages: [{ role: "user", content: prompt }],
max_tokens: daemonPrompt ? 8192 : 2048,
max_tokens: 2048,
temperature: 0.2
});
const raw2 = String(out?.response ?? "").trim();
if (!raw2) return c.json({ error: "Workers AI \u6C92\u6709\u56DE\u50B3\u5167\u5BB9" }, 502);
if (daemonPrompt) {
return c.json({ success: true, output: raw2 });
}
const card = String(out?.response ?? "").trim();
if (!card) return c.json({ error: "Workers AI \u6C92\u6709\u56DE\u50B3\u5167\u5BB9" }, 502);
const marker = `# ${pageName}`;
const idx = raw2.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? raw2.slice(idx) : raw2).trim() + "\n" });
const idx = card.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + "\n" });
} catch (e) {
return c.json({ error: `Workers AI \u57F7\u884C\u5931\u6557\uFF1A${e instanceof Error ? e.message : String(e)}` }, 502);
}
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+109 -319
View File
@@ -1,51 +1,3 @@
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res, err) => function __init() {
if (err) throw err[0];
try {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
} catch (e) {
throw err = [e], e;
}
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
// kbdb/src/actions/relation-orphans.ts
var relation_orphans_exports = {};
__export(relation_orphans_exports, {
scanRelationOrphans: () => scanRelationOrphans
});
async function scanRelationOrphans(db, limit = 200) {
const cap = Math.min(Math.max(limit, 1), 1e3);
const res = await db.prepare(
`SELECT relation_id, role, missing_id FROM (
SELECT r.id AS relation_id, 'src' AS role, r.src_id AS missing_id
FROM entries r LEFT JOIN entries t ON t.id = r.src_id
WHERE r.src_id IS NOT NULL AND t.id IS NULL
UNION ALL
SELECT r.id, 'rel', r.rel_id
FROM entries r LEFT JOIN entries t ON t.id = r.rel_id
WHERE r.rel_id IS NOT NULL AND t.id IS NULL
UNION ALL
SELECT r.id, 'dst', r.dst_id
FROM entries r LEFT JOIN entries t ON t.id = r.dst_id
WHERE r.dst_id IS NOT NULL AND t.id IS NULL
) LIMIT ?`
).bind(cap + 1).all();
const rows = res.results ?? [];
const truncated = rows.length > cap;
const orphans = truncated ? rows.slice(0, cap) : rows;
return { orphans, count: orphans.length, truncated };
}
var init_relation_orphans = __esm({
"kbdb/src/actions/relation-orphans.ts"() {
"use strict";
}
});
// kbdb/node_modules/.pnpm/hono@4.12.23/node_modules/hono/dist/compose.js
var compose = (middleware, onError, onNotFound) => {
return (context, next) => {
@@ -2149,15 +2101,12 @@ async function getEntry(db, id) {
const row = await db.prepare("SELECT * FROM entries WHERE id = ?").bind(id).first();
return row ?? null;
}
var NOT_MACHINERY_PREDICATE = "(src_id IS NULL AND entry_type NOT IN ('record', 'sheet', 'field', 'system'))";
async function listEntries(db, f = {}) {
const conds = [];
const params = [];
if (f.entry_type) {
conds.push("entry_type = ?");
params.push(f.entry_type);
} else {
conds.push(NOT_MACHINERY_PREDICATE);
}
if (f.owner_id) {
conds.push("owner_id = ?");
@@ -2209,10 +2158,7 @@ async function updateEntry(db, id, patch) {
return getEntry(db, id);
}
async function deleteEntry(db, id) {
const ref = await db.prepare("SELECT id FROM entries WHERE dst_id = ? LIMIT 1").bind(id).first();
if (ref) throw new Error(`entry ${id} is still referenced by record relation ${ref.id} \u2014 delete the record (or its slot) first`);
await db.prepare("DELETE FROM entries WHERE id = ?").bind(id).run();
await db.prepare("DELETE FROM entries WHERE src_id = ?").bind(id).run();
}
async function embeddedIdsByLibrary(db, ownerId, library) {
const rows = await db.prepare(
@@ -2446,8 +2392,6 @@ async function searchEntries(db, q, owner_id, entry_type, limit = 50, library, s
if (entry_type) {
conds.push("entry_type = ?");
params.push(entry_type);
} else {
conds.push(NOT_MACHINERY_PREDICATE);
}
if (source) {
conds.push("json_extract(metadata_json, '$.source') = ?");
@@ -3292,38 +3236,9 @@ entryRoutes.delete("/:id", async (c) => {
function uid2(prefix) {
return `${prefix}_${crypto.randomUUID()}`;
}
var SYS_ROOT = "sys_root";
var SYS_BELONGS = "sys_belongs";
var SYS_FIELD_OF = "sys_field_of";
function fieldEntryId(templateId, slot) {
return `fld_${templateId}_${slot}`;
}
async function ensureAnchors(db) {
await db.prepare(
`INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES
('${SYS_ROOT}', 'root', 'system', NULL),
('${SYS_BELONGS}', 'belongs', 'system', NULL),
('${SYS_FIELD_OF}', 'field_of', 'system', NULL)`
).run();
}
async function ensureFieldEntries(db, templateId, slots) {
for (const slot of slots) {
const fid = fieldEntryId(templateId, slot);
await db.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'field')`).bind(fid, slot).run();
await db.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_FIELD_OF}', ?)`
).bind(`relf_${templateId}_${slot}`, fid, templateId).run();
}
}
async function createTemplate(db, input) {
const id = input.id ?? uid2("tpl");
await db.prepare(`INSERT INTO templates (id, name, description, slots_json, created_by) VALUES (?, ?, ?, ?, ?)`).bind(id, input.name, input.description ?? null, JSON.stringify(input.slots), input.created_by ?? null).run();
await ensureAnchors(db);
await db.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'sheet')`).bind(id, input.name).run();
await db.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_BELONGS}', '${SYS_ROOT}')`
).bind(`relb_${id}`, id).run();
await ensureFieldEntries(db, id, input.slots);
const row = await getTemplate(db, id);
if (!row) throw new Error("createTemplate: row not found after insert");
return row;
@@ -3350,187 +3265,104 @@ async function updateTemplate(db, id, patch) {
if (cols.length === 0) return getTemplate(db, id);
cols.push("updated_at = unixepoch()");
await db.prepare(`UPDATE templates SET ${cols.join(", ")} WHERE id = ?`).bind(...params, id).run();
if (patch.slots !== void 0) await ensureFieldEntries(db, id, patch.slots);
return getTemplate(db, id);
}
async function loadReferencedEntries(db, entryIds, recordOwnerId) {
const ids = [...new Set(Object.values(entryIds))];
if (ids.length === 0) return /* @__PURE__ */ new Map();
const rows = [];
for (let i = 0; i < ids.length; i += 90) {
const chunk = ids.slice(i, i + 90);
const res = await db.prepare(`SELECT id, content, owner_id FROM entries WHERE id IN (${chunk.map(() => "?").join(",")})`).bind(...chunk).all();
rows.push(...res.results ?? []);
}
const found = new Map(rows.map((r) => [r.id, r]));
const missing = ids.filter((id) => !found.has(id));
if (missing.length > 0) throw new Error(`entry not found: ${missing.join(", ")}`);
if (recordOwnerId != null) {
const foreign = rows.filter((r) => r.owner_id != null && r.owner_id !== recordOwnerId);
if (foreign.length > 0) {
throw new Error(
`entry owner mismatch: ${foreign.map((r) => `${r.id}(${r.owner_id})`).join(", ")} != ${recordOwnerId}`
);
}
}
return new Map(rows.map((r) => [r.id, r.content]));
}
async function recordBelongs(db, recordId) {
const row = await db.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id = '${SYS_BELONGS}' AND dst_id != '${SYS_ROOT}' LIMIT 1`).bind(recordId).first();
return row ?? null;
}
async function insertCellRelation(db, recordId, templateId, slot, dstEntryId, ownerId) {
await db.prepare(
`INSERT INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, ?, ?)`
).bind(uid2("relv"), ownerId, recordId, fieldEntryId(templateId, slot), dstEntryId).run();
}
async function createRecord(db, input) {
const tpl = await getTemplate(db, input.template);
if (!tpl) throw new Error(`template not found: ${input.template}`);
const slots = JSON.parse(tpl.slots_json);
const recordId = input.record_id ?? uid2("rec");
const values = input.values ?? {};
const entryIds = input.entry_ids ?? {};
const refSlots = Object.keys(entryIds);
const ownerId = input.owner_id ?? null;
const both = refSlots.filter((s) => s in values);
if (both.length > 0) throw new Error(`slot given both value and entry_id: ${both.join(", ")}`);
const unknown = refSlots.filter((s) => !slots.includes(s));
if (unknown.length > 0) throw new Error(`slot not in template: ${unknown.join(", ")}`);
const referenced = await loadReferencedEntries(db, entryIds, ownerId);
await db.prepare(`INSERT OR IGNORE INTO entries (id, entry_type, owner_id) VALUES (?, 'record', ?)`).bind(recordId, ownerId).run();
await db.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, '${SYS_BELONGS}', ?)`
).bind(`relb_${recordId}_${tpl.id}`, ownerId, recordId, tpl.id).run();
const writtenSlots = slots.filter((s) => s in entryIds || s in values);
await ensureFieldEntries(db, tpl.id, writtenSlots);
for (const slot of writtenSlots) {
if (slot in entryIds) {
await insertCellRelation(db, recordId, tpl.id, slot, entryIds[slot], ownerId);
continue;
}
for (const slot of slots) {
if (!(slot in input.values)) continue;
const entry = await createEntry(db, {
content: values[slot],
content: input.values[slot],
entry_type: "value",
owner_id: ownerId
owner_id: input.owner_id ?? null
});
await insertCellRelation(db, recordId, tpl.id, slot, entry.id, ownerId);
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();
}
const out = { ...values };
for (const [slot, entryId] of Object.entries(entryIds)) out[slot] = referenced.get(entryId) ?? "";
return { record_id: recordId, template_id: tpl.id, values: out, owner_id: ownerId };
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
}
async function updateRecord(db, recordId, values) {
const belongs = await recordBelongs(db, recordId);
if (!belongs) return null;
const templateId = belongs.dst_id;
const cellRes = await db.prepare(
`SELECT f.content AS slot_name, r.dst_id AS entry_id
FROM entries r JOIN entries f ON r.rel_id = f.id
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`
const evRes = await db.prepare(
`SELECT ev.slot_name AS slot_name, ev.entry_id AS entry_id, 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();
const cells = cellRes.results ?? [];
const slotToEntries = /* @__PURE__ */ new Map();
for (const c of cells) {
const list = slotToEntries.get(c.slot_name) ?? [];
list.push(c.entry_id);
slotToEntries.set(c.slot_name, list);
}
const identity = await db.prepare("SELECT owner_id FROM entries WHERE id = ?").bind(recordId).first();
const recordOwnerId = identity?.owner_id ?? null;
const evRows = evRes.results ?? [];
if (evRows.length === 0) return null;
const templateId = evRows[0].template_id;
const recordOwnerId = evRows.find((r) => r.owner_id != null)?.owner_id ?? null;
const slotToEntry = new Map(evRows.map((r) => [r.slot_name, r.entry_id]));
const tpl = await getTemplate(db, templateId);
const allowed = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntries.keys()];
const allowed = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntry.keys()];
for (const [slot, content] of Object.entries(values)) {
if (!allowed.includes(slot)) {
throw new Error(`slot not in template: ${slot}`);
}
const entryIds = slotToEntries.get(slot);
if (entryIds && entryIds.length > 0) {
for (const entryId of entryIds) {
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
}
const entryId = slotToEntry.get(slot);
if (entryId) {
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
} else {
await ensureFieldEntries(db, templateId, [slot]);
const entry = await createEntry(db, { content, entry_type: "value", owner_id: recordOwnerId });
await insertCellRelation(db, recordId, templateId, slot, entry.id, recordOwnerId);
await db.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`).bind(uid2("ev"), recordId, templateId, slot, entry.id).run();
}
}
return getRecord(db, recordId);
}
async function getRecord(db, recordId) {
const belongs = await recordBelongs(db, recordId);
if (!belongs) return null;
const res = await db.prepare(
`SELECT f.content AS slot, v.content AS content
FROM entries r
JOIN entries f ON r.rel_id = f.id
JOIN entries v ON r.dst_id = v.id
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`
`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();
const rows = res.results ?? [];
if (rows.length === 0) return null;
const values = {};
for (const r of res.results ?? []) values[r.slot] = r.content;
const identity = await db.prepare("SELECT owner_id FROM entries WHERE id = ?").bind(recordId).first();
return { record_id: recordId, template_id: belongs.dst_id, values, owner_id: identity?.owner_id ?? null };
for (const r of rows) values[r.slot] = r.content;
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);
if (!tpl) return [];
const cap = Math.min(limit, 500);
const res = owner_id ? await db.prepare(
`SELECT src_id AS record_id FROM entries
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ? AND owner_id = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`
).bind(tpl.id, owner_id, cap).all() : await db.prepare(
`SELECT src_id AS record_id FROM entries
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`
).bind(tpl.id, cap).all();
`SELECT DISTINCT ev.record_id as record_id FROM entry_values ev
JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ? AND e.owner_id = ?
ORDER BY ev.created_at DESC LIMIT ?`
).bind(tpl.id, owner_id, cap).all() : await db.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`).bind(tpl.id, cap).all();
const ids = (res.results ?? []).map((r) => r.record_id);
if (ids.length === 0) return [];
const byId = /* @__PURE__ */ new Map();
for (const id of ids) byId.set(id, { record_id: id, template_id: tpl.id, values: {}, owner_id: null });
for (let i = 0; i < ids.length; i += 90) {
const chunk = ids.slice(i, i + 90);
const placeholders = chunk.map(() => "?").join(",");
const [cellRes, identRes] = await Promise.all([
db.prepare(
`SELECT r.src_id AS record_id, f.content AS slot, v.content AS content
FROM entries r
JOIN entries f ON r.rel_id = f.id
JOIN entries v ON r.dst_id = v.id
WHERE r.src_id IN (${placeholders}) AND r.rel_id != '${SYS_BELONGS}'`
).bind(...chunk).all(),
db.prepare(`SELECT id, owner_id FROM entries WHERE id IN (${placeholders})`).bind(...chunk).all()
]);
for (const r of cellRes.results ?? []) {
const rec = byId.get(r.record_id);
if (rec) rec.values[r.slot] = r.content;
}
for (const r of identRes.results ?? []) {
const rec = byId.get(r.id);
if (rec) rec.owner_id = r.owner_id;
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, 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: {}, 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);
}
async function deleteRecord(db, recordId) {
const belongs = await recordBelongs(db, recordId);
if (!belongs) return false;
const cellRes = await db.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id != '${SYS_BELONGS}'`).bind(recordId).all();
const dsts = (cellRes.results ?? []).map((r) => r.dst_id);
await db.prepare(`DELETE FROM entries WHERE src_id = ?`).bind(recordId).run();
await db.prepare(
`DELETE FROM entries WHERE id = ?1 AND entry_type = 'record'
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)`
).bind(recordId).run();
for (const dst of dsts) {
await db.prepare(
`DELETE FROM entries WHERE id = ?1
AND entry_type NOT IN ('sheet', 'field', 'system')
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)
AND NOT EXISTS (SELECT 1 FROM entries WHERE src_id = ?1)
AND NOT EXISTS (SELECT 1 FROM entries WHERE rel_id = ?1)`
).bind(dst).run();
const evRes = await db.prepare("SELECT entry_id FROM entry_values WHERE record_id = ?").bind(recordId).all();
const rows = evRes.results ?? [];
if (rows.length === 0) return false;
await db.prepare("DELETE FROM entry_values WHERE record_id = ?").bind(recordId).run();
for (const { entry_id } of rows) {
await db.prepare("DELETE FROM entries WHERE id = ?").bind(entry_id).run();
}
return true;
}
@@ -3563,17 +3395,10 @@ templateRoutes.patch("/:id", async (c) => {
// kbdb/src/routes/records.ts
var recordRoutes = new Hono2();
var isStringMap = (v) => !!v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
recordRoutes.post("/", async (c) => {
const body = await c.req.json().catch(() => null);
if (!body || !body.template || !body.values && !body.entry_ids) {
return c.json({ success: false, error: "template and values (or entry_ids) required" }, 400);
}
if (body.values !== void 0 && !isStringMap(body.values)) {
return c.json({ success: false, error: "values must be an object of {slot: string}" }, 400);
}
if (body.entry_ids !== void 0 && !isStringMap(body.entry_ids)) {
return c.json({ success: false, error: "entry_ids must be an object of {slot: entry_id}" }, 400);
if (!body || !body.template || !body.values) {
return c.json({ success: false, error: "template and values required" }, 400);
}
try {
const rec = await createRecord(c.env.DB, body);
@@ -3586,14 +3411,20 @@ recordRoutes.get("/triplet-stats", async (c) => {
const owner = c.req.query("owner_id") || "";
const rows = await c.env.DB.prepare(
`SELECT
COALESCE(NULLIF(lib_v.content, ''), 'general') AS library,
COALESCE(NULLIF(lib_e.content, ''), 'general') AS library,
COUNT(*) AS triplet_count
FROM entries b
JOIN templates t ON b.dst_id = t.id AND t.name = 'triplet'
LEFT JOIN entries lr ON lr.src_id = b.src_id AND lr.rel_id = ('fld_' || b.dst_id || '_library')
LEFT JOIN entries lib_v ON lib_v.id = lr.dst_id
WHERE b.rel_id = 'sys_belongs' AND (?1 = '' OR b.owner_id = ?1)
GROUP BY COALESCE(NULLIF(lib_v.content, ''), 'general')
FROM (
SELECT DISTINCT ev.record_id
FROM entry_values ev
JOIN templates t ON ev.template_id = t.id
JOIN entries e ON ev.entry_id = e.id
WHERE t.name = 'triplet'
AND (?1 = '' OR e.owner_id = ?1)
) AS tr
LEFT JOIN entry_values lev
ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')
ORDER BY library`
).bind(owner).all();
const stats = (rows.results ?? []).map((r) => ({ library: r.library, triplet_count: r.triplet_count }));
@@ -3773,35 +3604,31 @@ async function ensureTripletLibrarySlot(db, tripletTemplate) {
return true;
}
function tripletPivotSql(ownerFiltered) {
return `SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_subject' THEN v.content END) AS subject,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_object' THEN v.content END) AS object,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_predicate' THEN v.content END) AS predicate,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_source_uri' THEN v.content END) AS source_uri
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? " AND b.owner_id = ?" : ""}
GROUP BY b.src_id`;
return `SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'subject' THEN e.content END) AS subject,
MAX(CASE WHEN ev.slot_name = 'object' THEN e.content END) AS object,
MAX(CASE WHEN ev.slot_name = 'predicate' THEN e.content END) AS predicate,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
MAX(CASE WHEN ev.slot_name = 'source_uri' THEN e.content END) AS source_uri
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${ownerFiltered ? " AND e.owner_id = ?" : ""}
GROUP BY ev.record_id`;
}
function mapPivotSql(ownerFiltered) {
return `SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_narrative' THEN v.content END) AS narrative,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_top_entities' THEN v.content END) AS top_entities,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_relation_profile' THEN v.content END) AS relation_profile,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_bridges' THEN v.content END) AS bridges,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_triplet_count' THEN v.content END) AS triplet_count,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_commit_hash' THEN v.content END) AS commit_hash,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(r.created_at) AS ts
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? " AND b.owner_id = ?" : ""}
GROUP BY b.src_id`;
return `SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
MAX(CASE WHEN ev.slot_name = 'narrative' THEN e.content END) AS narrative,
MAX(CASE WHEN ev.slot_name = 'top_entities' THEN e.content END) AS top_entities,
MAX(CASE WHEN ev.slot_name = 'relation_profile' THEN e.content END) AS relation_profile,
MAX(CASE WHEN ev.slot_name = 'bridges' THEN e.content END) AS bridges,
MAX(CASE WHEN ev.slot_name = 'triplet_count' THEN e.content END) AS triplet_count,
MAX(CASE WHEN ev.slot_name = 'commit_hash' THEN e.content END) AS commit_hash,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
MAX(ev.created_at) AS ts
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${ownerFiltered ? " AND e.owner_id = ?" : ""}
GROUP BY ev.record_id`;
}
function parseJsonArray(raw2) {
if (!raw2) return [];
@@ -3909,7 +3736,6 @@ async function recomputeLibraryMap(db, input) {
await updateRecord(db, row.rid, { status: "superseded" });
superseded.push(row.rid);
}
const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0;
return {
map: {
record_id: blockEntry.id,
@@ -3920,7 +3746,6 @@ async function recomputeLibraryMap(db, input) {
relation_profile: relationProfile,
bridges,
triplet_count: tripletCount,
entry_count: entryCount,
commit_hash: input.commit_hash ?? null,
status: "active",
updated_at: blockEntry.created_at
@@ -3934,36 +3759,18 @@ async function liveTripletCountsByLibrary(db, tripletTemplateId, owner_id) {
const params = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
const res = await db.prepare(
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
`SELECT COALESCE(NULLIF(tr.library, ''), 'general') AS library, COUNT(*) AS n
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
FROM (
SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? " AND b.owner_id = ?" : ""}
GROUP BY b.src_id
SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""}
GROUP BY ev.record_id
) AS tr
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
WHERE COALESCE(tr.status, 'active') = 'active'
GROUP BY COALESCE(NULLIF(tr.library, ''), 'general')`
).bind(...params).all();
const m = /* @__PURE__ */ new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
return m;
}
async function liveEntryCountsByLibrary(db, owner_id) {
const params = owner_id ? [owner_id] : [];
const res = await db.prepare(
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆)
`SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
COUNT(*) AS n
FROM entries
WHERE ${owner_id ? "owner_id = ? AND " : ""}entry_type != 'value'
AND src_id IS NULL
AND entry_type NOT IN ('record', 'sheet', 'field', 'system')
AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map')
GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')`
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`
).bind(...params).all();
const m = /* @__PURE__ */ new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
@@ -3981,12 +3788,10 @@ async function knownLibraryNames(db, owner_id) {
if (libTpl) {
const libParams = owner_id ? [libTpl.id, owner_id] : [libTpl.id];
const libRows = await db.prepare(
`SELECT MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_name' THEN v.content END) AS name
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? " AND b.owner_id = ?" : ""}
GROUP BY b.src_id`
`SELECT MAX(CASE WHEN ev.slot_name = 'name' THEN e.content END) AS name
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""}
GROUP BY ev.record_id`
).bind(...libParams).all();
for (const r of libRows.results ?? []) if (r.name) names.add(r.name);
}
@@ -4020,15 +3825,11 @@ async function listLibraryMaps(db, owner_id) {
const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
if (!tpl) return [];
const params = owner_id ? [tpl.id, owner_id] : [tpl.id];
const [res, entryCounts] = await Promise.all([
db.prepare(
// kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`
).bind(...params).all(),
liveEntryCountsByLibrary(db, owner_id)
]);
const res = await db.prepare(
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`
).bind(...params).all();
const byLib = /* @__PURE__ */ new Map();
for (const r of res.results ?? []) {
if (!r.library || byLib.has(r.library)) continue;
@@ -4037,7 +3838,6 @@ async function listLibraryMaps(db, owner_id) {
narrative: r.narrative || null,
top_entities: parseJsonArray(r.top_entities).slice(0, 3).map((t) => t.name),
triplet_count: Number(r.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(r.library) ?? 0,
updated_at: r.ts
});
}
@@ -4053,10 +3853,7 @@ async function getLibraryMapDetail(db, library, owner_id) {
ORDER BY m.ts DESC LIMIT 1`
).bind(...params).first();
if (!row) return null;
const [blockEntry, entryCounts] = await Promise.all([
getEntry(db, row.rid),
liveEntryCountsByLibrary(db, owner_id)
]);
const blockEntry = await getEntry(db, row.rid);
return {
record_id: row.rid,
library,
@@ -4066,7 +3863,6 @@ async function getLibraryMapDetail(db, library, owner_id) {
relation_profile: parseJsonArray(row.relation_profile),
bridges: parseJsonArray(row.bridges),
triplet_count: Number(row.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(library) ?? 0,
commit_hash: row.commit_hash || null,
status: row.status ?? "active",
updated_at: row.ts
@@ -4354,12 +4150,6 @@ app.use("*", async (c, next) => {
});
app.get("/", (c) => c.json({ service: "arcrun-kbdb", tier: "base", status: "ok" }));
app.get("/health", (c) => c.json({ ok: true }));
app.get("/maintenance/relation-orphans", async (c) => {
const { scanRelationOrphans: scanRelationOrphans2 } = await Promise.resolve().then(() => (init_relation_orphans(), relation_orphans_exports));
const limit = Number(c.req.query("limit") ?? "200");
const report = await scanRelationOrphans2(c.env.DB, Number.isFinite(limit) ? limit : 200);
return c.json({ success: true, ...report });
});
app.route("/entries", entryRoutes);
app.route("/templates", templateRoutes);
app.route("/records", recordRoutes);
+112 -209
View File
@@ -31262,109 +31262,16 @@ function registerAllWorkflowCrudTools(server, env) {
registerRunWorkflow(server, env);
}
// mcp/src/lib/portal-client.ts
async function portalFetch(env, session, path, opts = {}) {
if (!env.CYPHER_EXECUTOR) {
throw new Error("CYPHER_EXECUTOR service binding not configured");
}
const url = new URL(`https://cypher${path}`);
for (const [k, v] of Object.entries(opts.query ?? {})) {
if (v !== void 0 && v !== "") url.searchParams.set(k, String(v));
}
return env.CYPHER_EXECUTOR.fetch(url.toString(), {
method: opts.method ?? "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session}`
},
body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
});
}
function resolveKnowledgeIdentity(authPath, portal) {
if (authPath !== "oauth") return { kind: "service" };
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
}
function staleIdentityError() {
return errorResponse(
"identity_missing",
"\u9019\u689D MCP \u9023\u7DDA\u662F\u820A\u7248\u7C3D\u767C\u7684 token\uFF0C\u88E1\u9762\u6C92\u6709\u767B\u5165\u8005\u8EAB\u5206\uFF0C\u56E0\u6B64\u67E5\u4E0D\u5230\u4EFB\u4F55\u77E5\u8B58\u5167\u5BB9\u3002\u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u5728 claude.ai \u7684 connector \u8A2D\u5B9A\u88E1\u91CD\u65B0\u6388\u6B0A\u3001\u8F38\u5165\u4F60\u7684 Portal \u5E33\u5BC6\uFF09\u5373\u53EF\u2014\u2014\u4E0D\u9700\u8981\u53E6\u5916\u627E\u4EFB\u4F55 credential \u6216\u91D1\u9470\u3002",
[
"\u5230 claude.ai \u2192 Settings \u2192 Connectors\uFF0C\u628A\u9019\u500B connector \u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u6703\u8DF3\u51FA\u8F38\u5165 Portal \u5E33\u5BC6\u7684\u9801\u9762\uFF09",
"\u91CD\u9023\u5F8C kbdb_* \u5168\u90E8\u5DE5\u5177\u90FD\u6703\u7528\u4F60\u9019\u500B\u5E33\u865F\u7684\u6B0A\u9650\u67E5\u8A62"
]
);
}
async function portalError(res, what) {
const detail = await res.text().catch(() => "");
if (res.status === 401) {
return errorResponse(
"session_expired",
`${what}\u5931\u6557\uFF1A\u767B\u5165\u968E\u6BB5\u5DF2\u904E\u671F\uFF08portal session \u5230\u671F\u6216\u5DF2\u767B\u51FA\uFF09\u3002`,
[
"\u5230 claude.ai \u2192 Settings \u2192 Connectors \u91CD\u65B0\u9023\u7DDA\u9019\u500B connector\uFF08\u91CD\u65B0\u8F38\u5165 Portal \u5E33\u5BC6\uFF09",
"\u91CD\u9023\u5F8C\u6B0A\u9650\u8207\u4F60\u5728 portal \u7DB2\u9801\u4E0A\u770B\u5230\u7684\u4E00\u81F4"
],
detail
);
}
if (res.status === 403) {
return errorResponse(
"forbidden",
`${what}\u5931\u6557\uFF1A\u9019\u500B\u5E33\u865F\u6C92\u6709\u9019\u9805\u6B0A\u9650\uFF08\u5E33\u865F\u53EF\u80FD\u5DF2\u505C\u7528\uFF0C\u6216\u6C92\u6709\u88AB\u6388\u6B0A\u8A72\u77E5\u8B58\u5EAB\uFF09\u3002`,
["\u8ACB\u77E5\u8B58\u5EAB\u7BA1\u7406\u54E1\u5728 portal \u7684\u5E33\u865F\u7BA1\u7406\u88E1\u78BA\u8A8D\u4F60\u7684\u72C0\u614B\u8207\u53EF\u7528\u77E5\u8B58\u5EAB"],
detail
);
}
return errorResponse(`portal_${res.status}`, `${what}\u5931\u6557\uFF08HTTP ${res.status}\uFF09`, ["\u7A0D\u5F8C\u91CD\u8A66"], detail);
}
// mcp/src/tools/arcrun_skills_examples.ts
var KbdbAccessError = class extends Error {
constructor(status, what, detail) {
super(`KBDB ${what} HTTP ${status}`);
this.status = status;
this.what = what;
this.detail = detail;
}
status;
what;
detail;
};
function kbdbFailure(e, searchHint, identity) {
const portalNote = identity.kind === "portal" ? "\u4F60\u9019\u689D\u662F\u5E33\u5BC6\u767B\u5165\u7684\u9023\u7DDA\uFF0C\u4F46 skill\uFF0Fexample \u9019\u6279\u5DE5\u5177\u76EE\u524D\u4ECD\u8D70**\u670D\u52D9\u5167\u90E8\u6191\u64DA**\uFF08\u9084\u6C92\u63A5\u4E0A\u767B\u5165\u8EAB\u5206\uFF09\u2014\u2014\u6240\u4EE5\u5B83\u8B80\u4E0D\u5230\uFF0C\u4E0D\u4EE3\u8868\u4F60\u7684\u5E33\u865F\u8B80\u4E0D\u5230\u3002" : "";
if (e instanceof KbdbAccessError) {
const unauthorized = e.status === 401 || e.status === 403;
return errorResponse(
unauthorized ? "kbdb_unauthorized" : "kbdb_unreachable",
(unauthorized ? `\u8B80\u4E0D\u5230 KBDB\uFF08HTTP ${e.status}\uFF1A\u9019\u689D\u9023\u7DDA\u7684\u6191\u64DA\u88AB\u62D2\u6216\u6839\u672C\u6C92\u5E36\uFF09\u3002` : `\u8B80\u4E0D\u5230 KBDB\uFF08HTTP ${e.status}\uFF09\u3002`) + "\u{1F534} **\u9019\u662F\u300C\u8B80\u4E0D\u5230\u300D\uFF0C\u4E0D\u662F\u300C\u4E0D\u5B58\u5728\u300D**\u2014\u2014\u5167\u5BB9\u9084\u5728\u5EAB\u88E1\uFF0C\u53EA\u662F\u9019\u689D\u8DEF\u88AB\u64CB\u4F4F\u4E86\u3002" + (portalNote ? ` ${portalNote}` : ""),
[
searchHint,
"kbdb_get_map() \u770B\u9019\u53F0\u5BE6\u4F8B\u6709\u54EA\u4E9B\u5EAB\uFF08\u90A3\u689D\u8D70\u5F97\u901A\u5C31\u66F4\u78BA\u5B9A\u662F\u9019\u6279\u5DE5\u5177\u7684\u8DEF\u58DE\u4E86\uFF0C\u4E0D\u662F\u5EAB\u7A7A\u4E86\uFF09",
"\u{1F534} \u4E0D\u51C6\u628A\u9019\u500B\u932F\u8AA4\u56DE\u5831\u6210\u300C\u627E\u4E0D\u5230\uFF0F\u6C92\u6709\u9019\u500B skill\u300D\u2014\u2014\u8ACB\u7167\u5BE6\u8AAA\u300CKBDB \u9019\u689D\u8DEF\u8B80\u4E0D\u5230\u300D",
"\u6301\u7E8C\u5931\u6557\uFF1A\u544A\u8A34 leo \u9019\u53F0\u5BE6\u4F8B\u7684 arcrun-mcp \u5C11\u4E86 secret KBDB_INTERNAL_TOKEN"
],
e.detail
);
}
return errorResponse(
"kbdb_unreachable",
`\u8B80\u4E0D\u5230 KBDB\uFF1A${e instanceof Error ? e.message : String(e)}\u3002\u{1F534} **\u9019\u662F\u300C\u8B80\u4E0D\u5230\u300D\uFF0C\u4E0D\u662F\u300C\u4E0D\u5B58\u5728\u300D**\u3002` + (portalNote ? ` ${portalNote}` : ""),
[searchHint, "\u7A0D\u5F8C\u91CD\u8A66", "\u{1F534} \u4E0D\u51C6\u628A\u5B83\u56DE\u5831\u6210\u300C\u6C92\u6709\u9019\u500B skill\uFF0Fexample\u300D"]
);
}
async function kbdbList(env, entryType, limit = 100) {
const resp = await kbdbFetch(env, `/entries?entry_type=${encodeURIComponent(entryType)}&limit=${limit}`);
if (!resp.ok) {
throw new KbdbAccessError(resp.status, `list entry_type=${entryType}`, await resp.text().catch(() => ""));
}
if (!resp.ok) throw new Error(`KBDB list entry_type=${entryType} HTTP ${resp.status}`);
const data = await resp.json();
return data.entries ?? [];
}
async function kbdbGetByPageName(env, pageName) {
const resp = await kbdbFetch(env, `/entries?page_name=${encodeURIComponent(pageName)}&limit=1`);
if (!resp.ok) {
throw new KbdbAccessError(resp.status, `get page_name=${pageName}`, await resp.text().catch(() => ""));
}
if (!resp.ok) return null;
const data = await resp.json();
return data.entries?.[0] ?? null;
}
@@ -31377,7 +31284,7 @@ function parseTags(tagsJson) {
return [];
}
}
function registerListSkills(server, env, identity) {
function registerListSkills(server, env) {
server.tool(
toolName("list_skills"),
"\u5217\u6240\u6709 agent-skill blocks\uFF08\u5F9E arcrun/registry/skills/ \u540C\u6B65\u9032 KBDB\uFF09\u3002\u6BCF\u500B skill \u662F\u500B markdown playbook\uFF0C\u63CF\u8FF0 AI \u9762\u5C0D X \u554F\u984C\u8A72\u600E\u9EBC\u60F3 + \u8A72\u7528\u54EA\u500B example\u3002\u56DE [{slug, title, tags}]\u3002call get_skill(slug) \u62FF\u5B8C\u6574\u5167\u6587\u3002",
@@ -31385,7 +31292,6 @@ function registerListSkills(server, env, identity) {
tag: external_exports.string().optional().describe("optional \u6A19\u7C64\u904E\u6FFE\u3002\u5982 'rag' / 'watcher' / 'debug'")
},
async ({ tag }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const blocks = await kbdbList(env, "agent-skill", 100);
const skills = blocks.map((b) => {
@@ -31407,19 +31313,20 @@ function registerListSkills(server, env, identity) {
return successResponse(
{ count: skills.length, skills },
[
skills.length === 0 ? "\u6C92\u6709 skill \u547D\u4E2D\u3002\u8A66 list_skills() \u4E0D\u5E36 tag \u770B\u5168\u90E8" : "call arcrun_get_skill(slug) \u62FF\u55AE\u500B skill \u5B8C\u6574 markdown",
// 誠實:這裡回的是**這台實例被 seed 進去的那幾支**,不是「全世界的 skill 目錄」。
// 上面清單沒有的名字(例如 'INDEX')就是這台沒有——別照舊教材去猜一個 slug。
"\u{1F534} \u53EA\u7528\u4E0A\u9762\u6E05\u55AE\u88E1\u771F\u7684\u6709\u7684 slug\uFF1B\u6E05\u55AE\u6C92\u6709\uFF1D\u9019\u53F0\u5BE6\u4F8B\u6C92 seed \u9032\u53BB\uFF0C\u4E0D\u8981\u786C\u731C\u540D\u5B57"
skills.length === 0 ? "\u6C92\u6709 skill \u547D\u4E2D\u3002\u8A66 list_skills() \u4E0D\u5E36 tag \u770B\u5168\u90E8" : "call arcrun_get_skill(slug) \u62FF\u55AE\u500B skill \u5B8C\u6574 markdown"
]
);
} catch (e) {
return kbdbFailure(e, "\u6539\u7528 kbdb_search({ q: 'skill' }) \u76F4\u63A5\u5728\u77E5\u8B58\u5EAB\u88E1\u627E skill \u5361\u7247\uFF08\u90A3\u689D\u8DEF\u8D70\u7684\u662F\u53E6\u4E00\u7D44\u6191\u64DA\uFF09", identity);
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["\u7A0D\u5F8C\u91CD\u8A66", "\u82E5\u6301\u7E8C\u5931\u6557\uFF0C\u544A\u8A34 leo"]
);
}
}
);
}
function registerGetSkill(server, env, identity) {
function registerGetSkill(server, env) {
server.tool(
toolName("get_skill"),
"\u62FF\u55AE\u4E00 agent-skill \u5B8C\u6574 markdown playbook\u3002slug \u5F9E list_skills \u53D6\u5F97\u3002",
@@ -31427,17 +31334,15 @@ function registerGetSkill(server, env, identity) {
slug: external_exports.string().describe("skill slug\uFF0C\u4F8B\u5982 'build_watcher_workflow' / 'rag_with_arcrun'")
},
async ({ slug }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`;
const block = await kbdbGetByPageName(env, pageName);
if (!block) {
return errorResponse(
"not_found",
`KBDB \u6B63\u5E38\u56DE\u61C9\uFF0C\u4F46\u6C92\u6709 page_name="${pageName}" \u9019\u5F35\u5361\u2014\u2014\u9019\u53F0\u5BE6\u4F8B\u6C92\u6709 seed \u9019\u652F skill\u3002\uFF08\u4E0D\u540C\u5BE6\u4F8B seed \u7684 skill \u4E0D\u4E00\u6A23\uFF0C\u5225\u7167\u820A\u6559\u6750\u5047\u8A2D\u67D0\u500B\u540D\u5B57\u4E00\u5B9A\u5728\u3002\uFF09`,
`skill "${slug}" \u4E0D\u5B58\u5728`,
[
"call arcrun_list_skills() \u770B**\u9019\u53F0\u5BE6\u4F8B\u771F\u7684\u6709**\u54EA\u5E7E\u652F",
`kbdb_search({ q: '${slug}' }) \u770B\u5167\u5BB9\u662F\u4E0D\u662F\u88AB\u5B58\u6210\u5225\u7684\u540D\u5B57`,
"call arcrun_list_skills() \u770B\u53EF\u7528 slug",
"\u78BA\u8A8D\u62FC\u5B57\u6B63\u78BA\uFF08\u4E0D\u9700\u8981 'skill-' prefix\uFF09"
]
);
@@ -31449,16 +31354,16 @@ function registerGetSkill(server, env, identity) {
tags: parseTags(block.tags_json)
});
} catch (e) {
return kbdbFailure(
e,
`\u6539\u7528 kbdb_search({ q: 'skill-${slug}' }) \u6488\u540C\u4E00\u5F35\u5361\u7247\uFF08skill \u5C31\u4F4F\u5728\u77E5\u8B58\u5EAB\u7684 entries \u88E1\uFF0C\u90A3\u689D\u8DEF\u8D70\u53E6\u4E00\u7D44\u6191\u64DA\uFF09`,
identity
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["\u7A0D\u5F8C\u91CD\u8A66"]
);
}
}
);
}
function registerListExamples(server, env, identity) {
function registerListExamples(server, env) {
server.tool(
toolName("list_examples"),
"\u5217\u6240\u6709 workflow-example blocks\uFF08\u5F9E arcrun/registry/examples/ \u540C\u6B65\u9032 KBDB\uFF09\u3002\u6BCF\u500B example \u662F\u53EF\u76F4\u63A5 push \u7684 workflow YAML \u7BC4\u672C + description\u3002\u56DE [{slug, tags}]\u3002call get_example / search_examples \u62FF\u7D30\u7BC0\u3002",
@@ -31466,7 +31371,6 @@ function registerListExamples(server, env, identity) {
tag: external_exports.string().optional().describe("optional \u6A19\u7C64\u904E\u6FFE\u3002\u5982 'rag' / 'cron' / 'llm' / 'webhook'")
},
async ({ tag }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const blocks = await kbdbList(env, "workflow-example", 200);
const examples = blocks.map((b) => {
@@ -31485,12 +31389,16 @@ function registerListExamples(server, env, identity) {
]
);
} catch (e) {
return kbdbFailure(e, "\u6539\u7528 kbdb_search({ q: 'example' }) \u76F4\u63A5\u5728\u77E5\u8B58\u5EAB\u88E1\u627E example \u5361\u7247\uFF08\u90A3\u689D\u8DEF\u8D70\u53E6\u4E00\u7D44\u6191\u64DA\uFF09", identity);
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["\u7A0D\u5F8C\u91CD\u8A66"]
);
}
}
);
}
function registerGetExample(server, env, identity) {
function registerGetExample(server, env) {
server.tool(
toolName("get_example"),
"\u62FF\u55AE\u4E00 workflow-example \u5B8C\u6574 YAML + description\u3002slug \u5F9E list_examples / search_examples \u53D6\u5F97\u3002\u53EF\u76F4\u63A5\u62FF YAML \u6539\u6210\u4F60\u81EA\u5DF1\u7684 \u2192 push\u3002",
@@ -31498,18 +31406,16 @@ function registerGetExample(server, env, identity) {
slug: external_exports.string().describe("example slug\uFF0C\u4F8B\u5982 'rag-search-answer' / 'cron-watcher'")
},
async ({ slug }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const pageName = slug.startsWith("example-") ? slug : `example-${slug}`;
const block = await kbdbGetByPageName(env, pageName);
if (!block) {
return errorResponse(
"not_found",
`KBDB \u6B63\u5E38\u56DE\u61C9\uFF0C\u4F46\u6C92\u6709 page_name="${pageName}" \u9019\u5F35\u5361\u2014\u2014\u9019\u53F0\u5BE6\u4F8B\u6C92 seed \u9019\u500B example\u3002`,
`example "${slug}" \u4E0D\u5B58\u5728`,
[
"call arcrun_list_examples() \u770B**\u9019\u53F0\u5BE6\u4F8B\u771F\u7684\u6709**\u54EA\u4E9B slug",
"\u6216 arcrun_search_examples(use_case) \u7528\u95DC\u9375\u5B57\u627E",
`kbdb_search({ q: '${slug}' }) \u770B\u5167\u5BB9\u662F\u4E0D\u662F\u88AB\u5B58\u6210\u5225\u7684\u540D\u5B57`
"call arcrun_list_examples() \u770B\u53EF\u7528 slug",
"\u6216 arcrun_search_examples(use_case) \u7528\u81EA\u7136\u8A9E\u8A00\u627E"
]
);
}
@@ -31530,16 +31436,16 @@ function registerGetExample(server, env, identity) {
"\u770B description_md \u4E86\u89E3\u8A2D\u8A08\u610F\u5716 / \u6539\u9020\u65B9\u5411"
]);
} catch (e) {
return kbdbFailure(
e,
`\u6539\u7528 kbdb_search({ q: 'example-${slug}' }) \u6488\u540C\u4E00\u5F35\u5361\u7247\uFF08example \u5C31\u4F4F\u5728\u77E5\u8B58\u5EAB\u7684 entries \u88E1\uFF09`,
identity
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["\u7A0D\u5F8C\u91CD\u8A66"]
);
}
}
);
}
function registerSearchExamples(server, env, identity) {
function registerSearchExamples(server, env) {
server.tool(
toolName("search_examples"),
"\u7528 use case \u95DC\u9375\u5B57\u641C workflow examples\uFF0C\u56DE\u6700\u76F8\u95DC N \u500B\u3002\u6CE8\u610F\uFF1A\u57FA\u672C\u76E4\u76EE\u524D\u662F D1 LIKE \u95DC\u9375\u5B57\u641C\u5C0B\uFF08\u975E\u8A9E\u7FA9 embedding\uFF1B\u8A9E\u7FA9\u662F kbdb-base Phase 1 \u7684 embed \u6A21\u7D44\uFF0C\u5C1A\u672A\u4E0A\uFF09\u3002\u2192 \u7528\u5177\u9AD4\u8A5E\uFF08'email'\u3001'cron'\u3001'rag'\uFF09\u6BD4\u6574\u53E5\u81EA\u7136\u8A9E\u8A00\u547D\u4E2D\u7387\u9AD8\u3002\u4E5F\u6703\u6BD4\u5C0D slug/tag\u3002",
@@ -31548,7 +31454,6 @@ function registerSearchExamples(server, env, identity) {
top_k: external_exports.number().int().min(1).max(20).optional().describe("\u56DE\u5E7E\u500B\u7D50\u679C\uFF08\u9810\u8A2D 5\uFF09")
},
async ({ query, top_k }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const k = top_k ?? 5;
const q = query.trim();
@@ -31593,17 +31498,21 @@ function registerSearchExamples(server, env, identity) {
]
);
} catch (e) {
return kbdbFailure(e, `\u6539\u7528 kbdb_search({ q: '${query.trim()}' }) \u76F4\u63A5\u67E5\u77E5\u8B58\u5EAB\uFF08\u90A3\u689D\u8DEF\u8D70\u53E6\u4E00\u7D44\u6191\u64DA\uFF09`, identity);
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["\u91CD\u8A66\u4E00\u6B21"]
);
}
}
);
}
function registerAllSkillExampleTools(server, env, identity) {
registerListSkills(server, env, identity);
registerGetSkill(server, env, identity);
registerListExamples(server, env, identity);
registerGetExample(server, env, identity);
registerSearchExamples(server, env, identity);
function registerAllSkillExampleTools(server, env) {
registerListSkills(server, env);
registerGetSkill(server, env);
registerListExamples(server, env);
registerGetExample(server, env);
registerSearchExamples(server, env);
}
// mcp/src/tools/arcrun_recipe.ts
@@ -31804,6 +31713,62 @@ function registerRecipeDelete(server, env) {
);
}
// mcp/src/lib/portal-client.ts
async function portalFetch(env, session, path, opts = {}) {
if (!env.CYPHER_EXECUTOR) {
throw new Error("CYPHER_EXECUTOR service binding not configured");
}
const url = new URL(`https://cypher${path}`);
for (const [k, v] of Object.entries(opts.query ?? {})) {
if (v !== void 0 && v !== "") url.searchParams.set(k, String(v));
}
return env.CYPHER_EXECUTOR.fetch(url.toString(), {
method: opts.method ?? "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session}`
},
body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
});
}
function resolveKnowledgeIdentity(authPath, portal) {
if (authPath !== "oauth") return { kind: "service" };
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
}
function staleIdentityError() {
return errorResponse(
"identity_missing",
"\u9019\u689D MCP \u9023\u7DDA\u662F\u820A\u7248\u7C3D\u767C\u7684 token\uFF0C\u88E1\u9762\u6C92\u6709\u767B\u5165\u8005\u8EAB\u5206\uFF0C\u56E0\u6B64\u67E5\u4E0D\u5230\u4EFB\u4F55\u77E5\u8B58\u5167\u5BB9\u3002\u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u5728 claude.ai \u7684 connector \u8A2D\u5B9A\u88E1\u91CD\u65B0\u6388\u6B0A\u3001\u8F38\u5165\u4F60\u7684 Portal \u5E33\u5BC6\uFF09\u5373\u53EF\u2014\u2014\u4E0D\u9700\u8981\u53E6\u5916\u627E\u4EFB\u4F55 credential \u6216\u91D1\u9470\u3002",
[
"\u5230 claude.ai \u2192 Settings \u2192 Connectors\uFF0C\u628A\u9019\u500B connector \u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u6703\u8DF3\u51FA\u8F38\u5165 Portal \u5E33\u5BC6\u7684\u9801\u9762\uFF09",
"\u91CD\u9023\u5F8C kbdb_* \u5168\u90E8\u5DE5\u5177\u90FD\u6703\u7528\u4F60\u9019\u500B\u5E33\u865F\u7684\u6B0A\u9650\u67E5\u8A62"
]
);
}
async function portalError(res, what) {
const detail = await res.text().catch(() => "");
if (res.status === 401) {
return errorResponse(
"session_expired",
`${what}\u5931\u6557\uFF1A\u767B\u5165\u968E\u6BB5\u5DF2\u904E\u671F\uFF08portal session \u5230\u671F\u6216\u5DF2\u767B\u51FA\uFF09\u3002`,
[
"\u5230 claude.ai \u2192 Settings \u2192 Connectors \u91CD\u65B0\u9023\u7DDA\u9019\u500B connector\uFF08\u91CD\u65B0\u8F38\u5165 Portal \u5E33\u5BC6\uFF09",
"\u91CD\u9023\u5F8C\u6B0A\u9650\u8207\u4F60\u5728 portal \u7DB2\u9801\u4E0A\u770B\u5230\u7684\u4E00\u81F4"
],
detail
);
}
if (res.status === 403) {
return errorResponse(
"forbidden",
`${what}\u5931\u6557\uFF1A\u9019\u500B\u5E33\u865F\u6C92\u6709\u9019\u9805\u6B0A\u9650\uFF08\u5E33\u865F\u53EF\u80FD\u5DF2\u505C\u7528\uFF0C\u6216\u6C92\u6709\u88AB\u6388\u6B0A\u8A72\u77E5\u8B58\u5EAB\uFF09\u3002`,
["\u8ACB\u77E5\u8B58\u5EAB\u7BA1\u7406\u54E1\u5728 portal \u7684\u5E33\u865F\u7BA1\u7406\u88E1\u78BA\u8A8D\u4F60\u7684\u72C0\u614B\u8207\u53EF\u7528\u77E5\u8B58\u5EAB"],
detail
);
}
return errorResponse(`portal_${res.status}`, `${what}\u5931\u6557\uFF08HTTP ${res.status}\uFF09`, ["\u7A0D\u5F8C\u91CD\u8A66"], detail);
}
// mcp/src/tools/kbdb_data.ts
var OWNER_IGNORED_HINT = "owner_id \u5728\u767B\u5165\u8EAB\u5206\u4E0B\u4E0D\u751F\u6548\uFF1A\u67E5\u8A62\u7BC4\u570D\u7531\u4F60\u7684\u5E33\u865F\u6B0A\u9650\u6C7A\u5B9A\uFF08\u8207\u4F60\u5728 portal \u7DB2\u9801\u770B\u5230\u7684\u4E00\u81F4\uFF09";
function registerAllKbdbDataTools(server, env, identity) {
@@ -32177,10 +32142,8 @@ function renderLibraryMapLines(libraries) {
const narrative = (l.narrative ?? "").trim() || "\uFF08narrative \u5F85\u88DC\uFF09";
const clipped = narrative.length > MAX_NARRATIVE_CHARS ? `${narrative.slice(0, MAX_NARRATIVE_CHARS)}\u2026` : narrative;
const core = entityNames(l.top_entities, 3);
const tripletCount = Number(l.triplet_count ?? 0) || 0;
const entryCount = Number(l.entry_count ?? 0) || 0;
const countPart = tripletCount > 0 ? `${tripletCount} triplets` : entryCount > 0 ? `0 triplets\uFF0F${entryCount} \u7B46\u539F\u59CB\u5167\u5BB9\uFF08\u5C1A\u672A\u8403\u53D6\u95DC\u4FC2\uFF0Ckbdb_search \u67E5\u5F97\u5230\uFF09` : `0 triplets\uFF0F0 \u5167\u5BB9`;
return `- ${l.library}\uFF1A${clipped}\uFF5C\u6838\u5FC3\uFF1A${core.length ? core.join("\u3001") : "\uFF08\u5C1A\u7121\uFF09"}\uFF5C${countPart}`;
const count = Number(l.triplet_count ?? 0) || 0;
return `- ${l.library}\uFF1A${clipped}\uFF5C\u6838\u5FC3\uFF1A${core.length ? core.join("\u3001") : "\uFF08\u5C1A\u7121\uFF09"}\uFF5C${count} triplets`;
});
const omitted = rows.length > MAX_LIBRARY_LINES ? `
\uFF08\u5176\u9918 ${rows.length - MAX_LIBRARY_LINES} \u5EAB\u7565\uFF0Ckbdb_get_map \u53EF\u770B\u5168\u90E8\uFF09` : "";
@@ -32232,13 +32195,6 @@ var RECOMPUTE_HINTS = [
function registerAllKbdbMapTools(server, env, identity) {
registerGetMap(server, env, identity);
}
function entryOnlyHint(tripletCount, entryCount, library) {
if (tripletCount > 0 || entryCount <= 0) return [];
const lib = library ? `\u300C${library}\u300D` : "\u9019\u500B\u5EAB";
return [
`${lib} triplet_count\uFF1D0\uFF0C\u4F46\u6709 ${entryCount} \u7B46\u539F\u59CB\u5167\u5BB9\uFF08entries\uFF09\u2014\u2014\u4E09\u5143\u7D44\u8403\u53D6\u9084\u6C92\u5C0D\u5B83\u8DD1\u904E\uFF0C\u4E0D\u4EE3\u8868\u6C92\u6709\u77E5\u8B58\u3002\u7528 kbdb_search\uFF08\u95DC\u9375\u5B57\u6216\u8A9E\u7FA9\uFF09\u76F4\u63A5\u67E5\u5F97\u5230\u5167\u5BB9\u3002`
];
}
function registerGetMap(server, env, identity) {
server.tool(
"kbdb_get_map",
@@ -32273,8 +32229,7 @@ function registerGetMap(server, env, identity) {
...l,
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
top_entities: entityNames(l.top_entities, 3),
triplet_count: Number(l.triplet_count ?? 0) || 0,
entry_count: Number(l.entry_count ?? 0) || 0
triplet_count: Number(l.triplet_count ?? 0) || 0
}));
if (libraries.length === 0) {
return successResponse({ libraries: [], count: 0 }, [
@@ -32283,15 +32238,9 @@ function registerGetMap(server, env, identity) {
...RECOMPUTE_HINTS
]);
}
const entryOnlyLibs = libraries.filter(
(l) => (l.triplet_count ?? 0) === 0 && (l.entry_count ?? 0) > 0
);
return successResponse({ libraries, count: libraries.length }, [
"\u8981\u770B\u67D0\u5EAB\u7D30\u7BC0\uFF1Akbdb_get_map(library='\u5EAB\u540D')",
"\u9032\u5EAB\u67E5\u5167\u5BB9\uFF1Akbdb_search\uFF08\u95DC\u9375\u5B57/\u8A9E\u7FA9\uFF09\uFF1B\u67E5\u95DC\u4FC2\uFF1Akbdb_graph_neighbors",
...entryOnlyLibs.length > 0 ? [
`${entryOnlyLibs.map((l) => l.library).join("\u3001")} \u9019\u5E7E\u5EAB triplet_count\uFF1D0 \u4F46 entry_count\uFF1E0\uFF1A\u6709\u539F\u59CB\u5167\u5BB9\uFF0C\u53EA\u662F\u9084\u6C92\u8403\u53D6\u51FA\u4E09\u5143\u7D44\u95DC\u4FC2\u2014\u2014\u5225\u628A 0 triplets \u8B80\u6210\u300C\u6C92\u6709\u77E5\u8B58\u300D\uFF0C\u76F4\u63A5 kbdb_search \u9032\u53BB\u67E5\u3002`
] : []
"\u9032\u5EAB\u67E5\u5167\u5BB9\uFF1Akbdb_search\uFF08\u95DC\u9375\u5B57/\u8A9E\u7FA9\uFF09\uFF1B\u67E5\u95DC\u4FC2\uFF1Akbdb_graph_neighbors"
]);
}
const res = await mapFetch(`/map/${encodeURIComponent(library)}${qs}`);
@@ -32319,13 +32268,11 @@ function registerGetMap(server, env, identity) {
top_entities: parseSlotArray(raw2.top_entities),
relation_profile: parseSlotArray(raw2.relation_profile),
bridges: parseSlotArray(raw2.bridges),
triplet_count: Number(raw2.triplet_count ?? 0) || 0,
entry_count: Number(raw2.entry_count ?? 0) || 0
triplet_count: Number(raw2.triplet_count ?? 0) || 0
};
return successResponse({ map: map2 }, [
"bridges\uFF1D\u6B64\u5EAB entity \u540C\u6642\u51FA\u73FE\u5728\u54EA\u4E9B\u5176\u4ED6\u5EAB\uFF08\u53EA\u6709\u5169\u5074\u4E09\u5143\u7D44\u90FD\u6A19\u4E86 library \u503C\u624D\u6293\u5F97\u5230\uFF0C\u820A\u8CC7\u6599\u82E5\u6C92\u6A19\u6703\u504F\u7A00\u758F\uFF0C\u662F\u8AA0\u5BE6\u73FE\u6CC1\u4E0D\u662F bug\uFF09",
"\u6CBF\u6838\u5FC3 entity \u6316\u95DC\u4FC2\uFF1Akbdb_graph_neighbors(subject=entity \u540D)",
...entryOnlyHint(map2.triplet_count, map2.entry_count, library)
"\u6CBF\u6838\u5FC3 entity \u6316\u95DC\u4FC2\uFF1Akbdb_graph_neighbors(subject=entity \u540D)"
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["\u7A0D\u5F8C\u91CD\u8A66"]);
@@ -32393,7 +32340,7 @@ function registerAllTools(server, env, orgNamespace, partnerToken, identity) {
registerReportFeedback(server, env, orgNamespace);
registerAllIntrospectionTools(server, env);
registerAllWorkflowCrudTools(server, env);
registerAllSkillExampleTools(server, env, identity);
registerAllSkillExampleTools(server, env);
registerAllRecipeTools(server, env);
registerAllKbdbDataTools(server, env, identity);
registerAllKbdbGraphTools(server, env, orgNamespace, identity);
@@ -32402,47 +32349,7 @@ function registerAllTools(server, env, orgNamespace, partnerToken, identity) {
}
// mcp/src/mcp-handler.ts
var KNOWLEDGE_FIRST = [
"\u3010\u9019\u689D\u9023\u7DDA\u4E0A\u6709\u4E3B\u4EBA\u7684\u77E5\u8B58\u5EAB\u2014\u2014\u5148\u67E5\u5B83\uFF0C\u518D\u67E5\u5225\u7684\u3011",
"",
"\u9019\u689D MCP \u9023\u7DDA\u5F8C\u9762\u63A5\u8457\u4E00\u500B **KBDB \u77E5\u8B58\u5EAB**\uFF1A\u9019\u53F0\u5BE6\u4F8B\u7684\u4E3B\u4EBA\u9577\u671F\u7D2F\u7A4D\u7684\u7B46\u8A18\u3001\u6C7A\u7B56\u3001",
"\u8E29\u904E\u7684\u5751\u3001\u5C08\u6848\u73FE\u6CC1\u3001skill \u8207\u5DE5\u4F5C\u6D41\u7D00\u9304\uFF0C\u90FD\u5728\u88E1\u9762\u3002**\u4F60\u4E0D\u662F\u5F9E\u96F6\u958B\u59CB\u7684**\u2014\u2014",
"\u4F60\u5C0D\u9019\u4E9B\u5C08\u6848\u7684\u5167\u5EFA\u5370\u8C61\u591A\u534A\u662F\u932F\u7684\u6216\u904E\u6642\u7684\uFF0C\u5EAB\u88E1\u90A3\u4EFD\u624D\u662F\u4E3B\u4EBA\u8A8D\u7684\u7248\u672C\u3002",
"",
"\u{1F534} **\u6709\u4EBA\u554F\u4F60\u300CX \u662F\u4EC0\u9EBC\uFF0F\u70BA\u4EC0\u9EBC\u9019\u6A23\u505A\uFF0F\u4E4B\u524D\u600E\u9EBC\u6C7A\u5B9A\u7684\uFF0F\u73FE\u5728\u505A\u5230\u54EA\u300D\u2014\u2014",
"\u4F60\u7684\u7B2C\u4E00\u500B\u52D5\u4F5C\u662F `kbdb_search`\uFF0C\u4E0D\u662F grep \u539F\u59CB\u78BC\u3001\u4E0D\u662F\u4E0A\u7DB2\u641C\u3001\u4E0D\u662F\u56DE\u7B54\u300C\u6211\u4E0D\u77E5\u9053\u300D\u3002**",
"",
"- `kbdb_search({ q: \"Arcrun \u662F\u4EC0\u9EBC\" })` \u2014 \u95DC\u9375\u5B57\u67E5\uFF08\u9810\u8A2D `mode:'keyword'`\uFF0C\u57FA\u672C\u76E4\u6C38\u9060\u53EF\u7528\uFF09\u3002",
" \u63DB\u5E7E\u7D44\u8B1B\u6CD5\u518D\u653E\u68C4\uFF1B\u60F3\u8981\u8A9E\u7FA9\u76F8\u4F3C\u5EA6\u7528 `mode:'semantic'`\u3002**\u9019\u4E00\u652F\u662F\u4F60\u7684\u7B2C\u4E00\u7AD9\u3002**",
"- `kbdb_get_map()` \u2014 \u4E0D\u77E5\u9053\u8A72\u9032\u54EA\u500B\u5EAB\u6642\u5148\u770B\u85CF\u66F8\u5730\u5716\uFF08\u4E0B\u9762\u82E5\u6709\u3010\u85CF\u66F8\u5730\u5716\u3011\u5C31\u662F\u5B83\u7684\u5FEB\u7167\uFF09\u3002",
'- `kbdb_graph_neighbors({ subject: "Arcrun" })` \u2014 \u67E5\u67D0\u500B\u6771\u897F\u8DDF\u8AB0\u6709\u95DC\u4FC2\uFF08\u4E09\u5143\u7D44\u904D\u6B77\uFF09\u3002',
"- `kbdb_list_templates` / `kbdb_query` \u2014 \u6309 template \u53D6\u6574\u6279\u7D50\u69CB\u5316\u8CC7\u6599\u3002",
"",
"\u{1F534} **\u9019\u4E09\u4EF6\u4E8B\u4E0D\u53EF\u4EE5\u8B1B\u6210\u540C\u4E00\u53E5**\uFF08\u8B1B\u6210\u540C\u4E00\u53E5\u5C31\u662F\u5728\u9A19\u4EBA\uFF09\uFF1A",
"\u2460 \u300C\u77E5\u8B58\u5EAB\u88E1\u6C92\u6709\u300D\u3000\u2461 \u300C\u6211\u6C92\u67E5\u300D\u3000\u2462 \u300C\u5730\u5716\u6C92\u53D6\u5230\uFF0F\u67D0\u5EAB\u986F\u793A 0\u300D\u3002",
"\u67E5\u904E\u771F\u7684\u6C92\u6709 \u2192 \u660E\u8AAA\u300C\u77E5\u8B58\u5EAB\u88E1\u67E5\u4E0D\u5230\uFF0C\u4EE5\u4E0B\u662F\u6211\u5F9E\u539F\u59CB\u78BC\uFF0F\u7DB2\u8DEF\u63A8\u7684\u300D\uFF0C\u518D\u53BB\u8B80 code \u6216\u4E0A\u7DB2\u3002",
"**\u6C92\u67E5\u5C31\u56DE\u7B54\uFF1D\u62FF\u4F60\u7684\u731C\u6E2C\u5192\u5145\u4E3B\u4EBA\u7684\u77E5\u8B58\uFF0C\u90A3\u662F\u9019\u689D\u9023\u7DDA\u4E0A\u6700\u56B4\u91CD\u7684\u932F\u3002**",
"",
"\u{1F534} **\u5730\u5716\u662F\u7D22\u5F15\uFF0C\u4E0D\u662F\u5EAB\u5B58\u6E05\u55AE**\uFF1A\u67D0\u5EAB\u986F\u793A `0 triplets`\u3001\u6216\u4E0B\u9762\u6574\u6BB5\u3010\u85CF\u66F8\u5730\u5716\u3011\u6C92\u51FA\u73FE\uFF0C",
"\u90FD**\u4E0D\u4EE3\u8868**\u6C92\u6709\u77E5\u8B58\uFF08\u53EF\u80FD\u53EA\u662F\u9084\u6C92\u91CD\u7B97\u3001\u6216\u9019\u6B21\u6C92\u6293\u5230\uFF09\u3002\u8981\u77E5\u9053\u6709\u6C92\u6709\uFF0C\u53EA\u6709\u4E00\u500B\u65B9\u6CD5\uFF1A`kbdb_search` \u67E5\u904E\u3002",
"\u540C\u7406\uFF0C\u4EFB\u4F55\u5DE5\u5177\u56DE 401\uFF0F\u9023\u4E0D\u4E0A\uFF0F\u6C92\u6B0A\u9650\uFF0C\u90A3\u662F**\u8B80\u4E0D\u5230**\uFF0C\u4E0D\u662F**\u4E0D\u5B58\u5728**\u2014\u2014\u7167\u5B83\u7D66\u7684 next_actions \u4FEE\uFF0C",
"\u5225\u628A\u5B83\u6539\u53E3\u8B1B\u6210\u300C\u9019\u88E1\u6C92\u6709\u300D\uFF08`arcrun_get_skill` \u66FE\u628A KBDB \u7684 401 \u8B1B\u6210\u300Cskill \u4E0D\u5B58\u5728\u300D\uFF0C\u5C31\u662F\u9019\u500B\u75C5\uFF09\u3002"
].join("\n");
var MAP_UNAVAILABLE_NOTE = [
"\u3010\u85CF\u66F8\u5730\u5716\uFF1A\u9019\u6B21\u6C92\u53D6\u5230\u3011",
"\u5730\u5716\u6293\u53D6\u903E\u6642\uFF0F\u56DE\u932F\uFF0F\u6216\u5B83\u56DE\u5831\u7684\u6E05\u55AE\u662F\u7A7A\u7684\uFF08\u4E5F\u53EF\u80FD\u53EA\u662F\u9084\u6C92\u91CD\u7B97\u904E\uFF09\u3002",
"\u{1F534} **\u9019\u662F\u300C\u5730\u5716\u6C92\u62FF\u5230\u300D\uFF0C\u4E0D\u662F\u300C\u9019\u88E1\u6C92\u6709\u77E5\u8B58\u300D\u3002** \u4E0A\u9762\u90A3\u689D\u898F\u5247\u7167\u820A\uFF1A",
"\u8981\u77E5\u9053\u5EAB\u88E1\u6709\u4EC0\u9EBC\uFF0C\u76F4\u63A5 `kbdb_search`\uFF1B\u60F3\u518D\u6293\u4E00\u6B21\u5730\u5716\u547C\u53EB `kbdb_get_map()`\uFF08\u5B83\u6703\u56DE\u5831\u771F\u6B63\u7684\u539F\u56E0\uFF09\u3002"
].join("\n");
var MAP_STALE_NOTE = [
"\u3010\u85CF\u66F8\u5730\u5716\uFF1A\u62FF\u4E0D\u5230\uFF0C\u56E0\u70BA\u9019\u689D\u9023\u7DDA\u662F\u820A\u7248\u7C3D\u767C\u7684 token\u3011",
"\u9019\u689D\u9023\u7DDA\u7684 token \u6C92\u5E36\u767B\u5165\u8005\u8EAB\u5206\uFF0C`kbdb_*` \u6703\u56DE `identity_missing`\u3002",
"\u{1F534} **\u9019\u4E0D\u4EE3\u8868\u77E5\u8B58\u5EAB\u662F\u7A7A\u7684**\u2014\u2014\u662F\u9019\u689D\u9023\u7DDA\u9084\u6C92\u8A8D\u5F97\u4F60\u3002",
"\u4ECD\u7136\u5148\u547C\u53EB\u4E00\u6B21 `kbdb_search` \u78BA\u8A8D\u932F\u8AA4\u78BC\uFF1B\u82E5\u771F\u7684\u662F `identity_missing`\uFF0C",
"\u8ACB\u4F7F\u7528\u8005\u5230 claude.ai \u2192 Settings \u2192 Connectors \u628A\u9019\u500B connector \u91CD\u65B0\u9023\u7DDA\u4E00\u6B21\uFF08\u91CD\u65B0\u8F38\u5165 Portal \u5E33\u5BC6\uFF09\uFF0C",
"**\u4E0D\u8981\u6539\u53E3\u8AAA\u300C\u67E5\u4E0D\u5230\u8CC7\u6599\u300D\u6216\u81EA\u5DF1\u53BB\u731C\u7B54\u6848\u3002**"
].join("\n");
async function buildServerInstructions(env, identity) {
async function handleMcpRequest(request, env, orgNamespace, partnerToken, identity) {
const mapInstructions = await buildLibraryMapInstructions(env, identity);
const startHere = [
"# Arcrun \u2014 \u4F60\u5DF2\u7D93\u914D\u5099\u4E86\u9019\u5957\u5DE5\u5177\uFF0C\u5225\u4E0A\u7DB2\u627E",
@@ -32456,13 +32363,9 @@ async function buildServerInstructions(env, identity) {
"",
"1. `arcrun_get_skill('write_intent_workflow')` \u2014 **\u5FC5\u8B80\u7B2C\u4E00\u652F**\u3002",
" \u6559\u4F60\u7528 `>>` \u5BEB\u300C\u610F\u5716\u5DE5\u4F5C\u6D41\u300D\u3002\u4F60**\u4E0D\u9700\u8981\u5148\u77E5\u9053\u6709\u54EA\u4E9B\u96F6\u4EF6**\uFF0C\u5148\u5BEB\u610F\u5716\u3002",
" \u26A0\uFE0F \u9019\u652F\u82E5\u56DE\u932F\uFF08401\uFF0F\u9023\u4E0D\u4E0A\uFF0F`kbdb_unreachable`\uFF09\uFF0C\u90A3\u662F**\u9019\u689D\u9023\u7DDA\u8B80\u4E0D\u5230 KBDB**\uFF0C",
" **\u4E0D\u662F skill \u4E0D\u5B58\u5728**\u2014\u2014\u540C\u4E00\u4EFD\u5167\u5BB9\u7528 `kbdb_search({ q: 'skill-write_intent_workflow' })` \u6488\u5F97\u5230\u3002",
"2. `arcrun_whoami()` \u2014 \u78BA\u8A8D\u9023\u5230\u54EA\u500B\u5E33\u865F\uFF08\u52FF\u81EA\u884C curl \u731C\u5E33\u865F URL\uFF09\u3002",
"3. \u628A\u610F\u5716\u4E1F `POST /cypher/search` \u6216 `arcrun_validate_yaml` \u2014 \u7CFB\u7D71\u544A\u8A34\u4F60\u54EA\u4E9B\u96F6\u4EF6\u5B58\u5728\u3002",
"4. \u5361\u4F4F\uFF0F\u4E0D\u77E5\u9053\u8A72\u67E5\u4EC0\u9EBC \u2192 \u5148 `arcrun_list_skills()` **\u770B\u9019\u53F0\u5BE6\u4F8B\u771F\u7684\u6709\u54EA\u5E7E\u652F**\uFF0C\u518D\u6311\u4E00\u652F\u8B80\u3002",
" \uFF082026-08-13 \u5BE6\u6E2C\uFF1A\u4E0D\u540C\u5BE6\u4F8B seed \u7684 skill \u4E0D\u4E00\u6A23\uFF0C\u6709\u7684\u5BE6\u4F8B\u53EA\u6709\u5169\u652F\u3001\u9023 `INDEX` \u90FD\u6C92\u6709\u3002",
" **\u4E0D\u8981\u7167\u6559\u6750\u76F4\u63A5\u6307\u540D\u4E00\u500B slug** \u2014\u2014\u5148\u5217\u6E05\u55AE\uFF0C\u6216 `kbdb_search({ q: 'skill' })` \u76F4\u63A5\u5728\u5EAB\u88E1\u627E\u3002\uFF09",
"4. \u5361\u4F4F\uFF0F\u4E0D\u77E5\u9053\u8A72\u67E5\u4EC0\u9EBC \u2192 `arcrun_get_skill('INDEX')`\uFF08\u5168\u9928\u5C0E\u822A\uFF1A\u4EC0\u9EBC\u554F\u984C\u67E5\u54EA\u88E1\uFF0B\u5DF2\u77E5\u7684\u5751\uFF09\u3002",
"5. \u7F3A\u96F6\u4EF6\u6642\uFF1A\u7F3A API \u2192 \u5BEB recipe\uFF08`arcrun_recipe_push`\uFF09\uFF1B\u7F3A\u80FD\u529B \u2192 \u6295\u7A3F\u96F6\u4EF6 PR\u3002",
" \u{1F534} **\u4E0D\u8981\u56E0\u70BA\u67E5\u4E0D\u5230\u96F6\u4EF6\u5C31\u6539\u5BEB\u6210 `code` \u7BC0\u9EDE**\u2014\u2014\u90A3\u53EB\u300C\u8179\u8A9E\u8853\u300D\uFF08\u8868\u9762\u7528 Arcrun\u3001",
" \u5BE6\u969B\u5168\u5BEB JS\uFF09\u3002`code` \u53EA\u7528\u65BC\u5C40\u90E8\u6574\u5F62\uFF08\u4F8B\uFF1A\u525D\u6389 LLM \u56DE\u61C9\u7684\u96DC\u8A0A\uFF09\u3002",
@@ -32478,11 +32381,11 @@ async function buildServerInstructions(env, identity) {
"\u4E0D\u662F\u5931\u6557\u2014\u2014\u5225\u56E0\u70BA\u300C\u53EA\u6709\u4E00\u689D\u8DEF\u6709\u8F38\u51FA\u300D\u5C31\u4EE5\u70BA\u58DE\u6389\u800C\u6539\u5BEB\u6210 code\uFF082026-08-01 \u5BE6\u649E\uFF09\u3002",
"\u7B2C\u4E00\u500B\u7BC0\u9EDE\u56FA\u5B9A\u662F `input`\u3002"
].join("\n");
const mapSection = mapInstructions ?? (identity.kind === "stale" ? MAP_STALE_NOTE : MAP_UNAVAILABLE_NOTE);
return [KNOWLEDGE_FIRST, startHere, mapSection].join("\n\n---\n\n");
}
async function handleMcpRequest(request, env, orgNamespace, partnerToken, identity) {
const instructions = await buildServerInstructions(env, identity);
const instructions = mapInstructions ? `${startHere}
---
${mapInstructions}` : startHere;
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
const server = new McpServer(
{ name: "arcrun-mcp-server", version: "1.0.0" },
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+31 -570
View File
@@ -1,18 +1,18 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-15T15:53:28.612Z",
"repo_head": "cacaa33f7d4e51d0011096250a33bb5107c67074",
"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": "9fa88065abdc5a795d3847711f04101480621af0",
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 590499,
"content_sha256": "94a21ad012ee0c48d78e9a74752048917ddb4616f1ea089976834acd887a9d14",
"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": "ceb7638d746f0e8847552aef721f6f53ef87ff0b",
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
"main_module": "worker.mjs",
"main_file": "arcrun-kbdb/worker.mjs",
"js_bytes": 160306,
"content_sha256": "47b09d60f8d122f7ec234d146a99bc68b2661c56ad6a98eca146d99ec5de03ea",
"js_bytes": 149797,
"content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -84,308 +84,6 @@
},
"warnings": []
},
{
"name": "arcrun-code",
"source_dir": "registry/components/code",
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
"main_module": "worker.mjs",
"main_file": "arcrun-code/worker.mjs",
"js_bytes": 153758,
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
"modules": [
{
"name": "quickjs.wasm",
"type": "application/wasm",
"file": "arcrun-code/quickjs.wasm",
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "code"
}
},
"warnings": []
},
{
"name": "arcrun-mcp",
"source_dir": "mcp",
"source_commit": "1ccee0055ebdb1f6d74094df3e807f56b849fa6f",
"main_module": "worker.mjs",
"main_file": "arcrun-mcp/worker.mjs",
"js_bytes": 1192612,
"content_sha256": "9bc8dffb34d0f36608a78b2cfeb8ee8b5c793f52b4497f5d4d5f69f70700972e",
"modules": [],
"compat_date": "2024-11-27",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [
"OAUTH_KV"
],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {}
},
"warnings": []
},
{
"name": "arcrun-array-ops",
"source_dir": ".component-builds/array_ops",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-array-ops/worker.mjs",
"js_bytes": 67802,
"content_sha256": "35ad5be6332dde2299efd10be726300c6cace1eb085ab24f08173536ca7fb950",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-array-ops/component.wasm",
"sha256": "74dc9d99f8b9ef43628b55c85c05beb1af8f6f90d78d07cd132ffe4b865147ae"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "array_ops"
}
},
"warnings": []
},
{
"name": "arcrun-auth-oauth2",
"source_dir": ".component-builds/auth_oauth2",
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
"main_module": "worker.mjs",
"main_file": "arcrun-auth-oauth2/worker.mjs",
"js_bytes": 80472,
"content_sha256": "a9b0d45ee6a63e0eebd45554df16d9fa4e6e717e0c31dac13b630e132f1a96db",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-auth-oauth2/component.wasm",
"sha256": "3622c20644e6c8c39487109e7353e2590bad90b1207139e34dfb0cf1c2696664"
}
],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [
"CREDENTIALS_KV",
"RECIPES"
],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "auth_oauth2"
}
},
"warnings": []
},
{
"name": "arcrun-auth-service-account",
"source_dir": ".component-builds/auth_service_account",
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
"main_module": "worker.mjs",
"main_file": "arcrun-auth-service-account/worker.mjs",
"js_bytes": 81693,
"content_sha256": "68675ec76f3f060309492e55301936a47f4ae0a5e9b00865eb861e0e631e40e5",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-auth-service-account/component.wasm",
"sha256": "7d0808b4b9a2df4fbb54977eafe34a5bdb99e0cc941d97462c62e26f3651951e"
}
],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [
"CREDENTIALS_KV",
"RECIPES"
],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "auth_service_account"
}
},
"warnings": []
},
{
"name": "arcrun-auth-static-key",
"source_dir": ".component-builds/auth_static_key",
"source_commit": "20c7610371c401f622ef4e6ea1b9ba812963be95",
"main_module": "worker.mjs",
"main_file": "arcrun-auth-static-key/worker.mjs",
"js_bytes": 81217,
"content_sha256": "140c163d2e8c609eebf281de25d16e521d85004f8437cc842613c249e44aa7ff",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-auth-static-key/component.wasm",
"sha256": "cd0f69a80ced0979749e0ec30b1981795a49eaa56bdb2ba102cd4b64f4670369"
}
],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [
"CREDENTIALS_KV",
"RECIPES"
],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "auth_static_key"
}
},
"warnings": []
},
{
"name": "arcrun-cron",
"source_dir": ".component-builds/cron",
"source_commit": "6e92ca03727bf22143499d6c0df06b40cb65c17b",
"main_module": "worker.mjs",
"main_file": "arcrun-cron/worker.mjs",
"js_bytes": 78911,
"content_sha256": "17068f03f686aee6c0aec9d7d748bbb1998edc8918260033f985697bc718aa76",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-cron/component.wasm",
"sha256": "afb3a14f8c8f00f4f5573f44153cb1dcf6fc715b2fb47c2afb0aaf95b74d3cde"
}
],
"compat_date": "2025-02-19",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "cron"
}
},
"warnings": []
},
{
"name": "arcrun-date-ops",
"source_dir": ".component-builds/date_ops",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-date-ops/worker.mjs",
"js_bytes": 67781,
"content_sha256": "60ebc1d17ab0b9d3bb66f1c71ec63eb5d35bc97ed9f8b4cd1c1c95f4eeb171b7",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-date-ops/component.wasm",
"sha256": "4e2c1329ce4014aa86a9a6449d8699e67c6c616f5c07088dc21216d4036ca41e"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "date_ops"
}
},
"warnings": []
},
{
"name": "arcrun-filter",
"source_dir": ".component-builds/filter",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-filter/worker.mjs",
"js_bytes": 67739,
"content_sha256": "c7fd5070ff7c0f0341e9dd364805e393f9d68056a846f48e6bc61ef6f4e67187",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-filter/component.wasm",
"sha256": "6a0692b386522918b3b5af79cf7877394d1f02c350018dbaef68ef380dad1b9e"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "filter"
}
},
"warnings": []
},
{
"name": "arcrun-foreach-control",
"source_dir": ".component-builds/foreach_control",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-foreach-control/worker.mjs",
"js_bytes": 67928,
"content_sha256": "e52410c9611a93ac7fe38c7b11291cc18711c7da3370492788c3c55bc0fb03d5",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-foreach-control/component.wasm",
"sha256": "8ae161ab37cc78ef6fae65364a47f7accb572378b395c4637daa2ffa57f14632"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "foreach_control"
}
},
"warnings": []
},
{
"name": "arcrun-http-request",
"source_dir": ".component-builds/http_request",
@@ -419,19 +117,19 @@
"warnings": []
},
{
"name": "arcrun-if-control",
"source_dir": ".component-builds/if_control",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"name": "arcrun-code",
"source_dir": "registry/components/code",
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
"main_module": "worker.mjs",
"main_file": "arcrun-if-control/worker.mjs",
"js_bytes": 67823,
"content_sha256": "557ecf4ee955105af07b2227b08365eb9de8c39ad8ffb323b101397704f438bd",
"main_file": "arcrun-code/worker.mjs",
"js_bytes": 153758,
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
"modules": [
{
"name": "component.wasm",
"name": "quickjs.wasm",
"type": "application/wasm",
"file": "arcrun-if-control/component.wasm",
"sha256": "87c81e9cf5e664f60c06a364508dd365d49953e1b7afba6a5753fce5a4c82636"
"file": "arcrun-code/quickjs.wasm",
"sha256": "105c3bed22d457e43e3d1c3c1c6959fda62a8fe06f0fc8a985303c3a2be72232"
}
],
"compat_date": "2025-02-19",
@@ -442,272 +140,35 @@
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "if_control"
"COMPONENT_ID": "code"
}
},
"warnings": []
},
{
"name": "arcrun-merge",
"source_dir": ".component-builds/merge",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"name": "arcrun-mcp",
"source_dir": "mcp",
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
"main_module": "worker.mjs",
"main_file": "arcrun-merge/worker.mjs",
"js_bytes": 67718,
"content_sha256": "e4216249436000bbb4db0318cc9028f936ca6537d3d656228cfdd3cb31df25f8",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-merge/component.wasm",
"sha256": "55a4d143246df172f60b5e15e7b26e55279deda9db436e3d65bccc25c86d7cd9"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "merge"
}
},
"warnings": []
},
{
"name": "arcrun-number-ops",
"source_dir": ".component-builds/number_ops",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-number-ops/worker.mjs",
"js_bytes": 67823,
"content_sha256": "91821a291117e033bfa4f013cd88de09479cd4ac43708e37a39df3f31c72aaa6",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-number-ops/component.wasm",
"sha256": "452d1940f6e2b2b36dc97c145abcb7b5bacdbfa7631c742827c2f6f6db87b4ea"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "number_ops"
}
},
"warnings": []
},
{
"name": "arcrun-set",
"source_dir": ".component-builds/set",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-set/worker.mjs",
"js_bytes": 67676,
"content_sha256": "6cac587802cae7dc1651d6b461232abafe40dcf0fbfe9e0adc5b1d8e35b41cc1",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-set/component.wasm",
"sha256": "f5afcf6e57401d2d83b21fb971ec80ca56d5e8e4bdb74a004f3d118875ed57c4"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "set"
}
},
"warnings": []
},
{
"name": "arcrun-string-ops",
"source_dir": ".component-builds/string_ops",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-string-ops/worker.mjs",
"js_bytes": 67823,
"content_sha256": "c85166e357b76828c7020426d912b591006152ac38f024a095a4d9dcd10dee72",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-string-ops/component.wasm",
"sha256": "41f3243ee18bb9f4fcbaa417bd773347128dec2971132e240eddec19ba6a9da2"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "string_ops"
}
},
"warnings": []
},
{
"name": "arcrun-switch",
"source_dir": ".component-builds/switch",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-switch/worker.mjs",
"js_bytes": 67739,
"content_sha256": "e0e7689dbba735a254556272d6d233396c7fa05cc0fba5835a3e20b0ddf00fca",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-switch/component.wasm",
"sha256": "6d993dc9b1affe16b600aaa7cae5af977108cc08513bb11d9d9db908285fea8b"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "switch"
}
},
"warnings": []
},
{
"name": "arcrun-try-catch",
"source_dir": ".component-builds/try_catch",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-try-catch/worker.mjs",
"js_bytes": 67802,
"content_sha256": "a5fbcbf452ab00269dc857997df3059bee7eb1f2c1b322d249146ab4e5e5bac9",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-try-catch/component.wasm",
"sha256": "10eaddc5ef0d3dc68e3f009970541ac7f5fce30dcdbecec9a333afd5a29e3486"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "try_catch"
}
},
"warnings": []
},
{
"name": "arcrun-validate-json",
"source_dir": ".component-builds/validate_json",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-validate-json/worker.mjs",
"js_bytes": 67886,
"content_sha256": "2d0efa26e9fe38db87eb9585daf72e0f2ba8c859d15d1b65f36798656cc4e83b",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-validate-json/component.wasm",
"sha256": "8142ffd3cc57efbb9128faaf450b646111b0f2e2575cc29a7b562a7b185912ab"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "validate_json"
}
},
"warnings": []
},
{
"name": "arcrun-wait",
"source_dir": ".component-builds/wait",
"source_commit": "922a57fe3458dfda82bcc25d287cef93f8dab838",
"main_module": "worker.mjs",
"main_file": "arcrun-wait/worker.mjs",
"js_bytes": 67697,
"content_sha256": "3d1b46e7b1ff056abb2c1aeed738d23d58eaa7c8f36b674e4ea2ed1602015f9e",
"modules": [
{
"name": "component.wasm",
"type": "application/wasm",
"file": "arcrun-wait/component.wasm",
"sha256": "52ca2d184bf3dcc646bb94f29f7f3e69b5540276363e83a54cb435ff33ca2f3c"
}
],
"compat_date": "2025-02-19",
"compat_flags": [],
"requires": {
"kv": [],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {
"COMPONENT_ID": "wait"
}
},
"warnings": []
},
{
"name": "arcrun-rag-ui",
"source_dir": "console-ui/public",
"source_commit": "614fe448124dbd25d8228e37723473f1704f3a7a",
"main_module": "worker.mjs",
"main_file": "arcrun-rag-ui/worker.mjs",
"js_bytes": 524513,
"content_sha256": "5825f427e4211dd4b0eee969c94c8bfa856f65b8a48fc718e137f3b5d074e1ff",
"main_file": "arcrun-mcp/worker.mjs",
"js_bytes": 1179487,
"content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6",
"modules": [],
"compat_date": "2026-07-01",
"compat_flags": [],
"compat_date": "2024-11-27",
"compat_flags": [
"nodejs_compat"
],
"requires": {
"kv": [],
"kv": [
"OAUTH_KV"
],
"d1": [],
"vectorize": 0,
"ai": false,
"vars": {}
},
"ui_fingerprint": "82bc636990e60172663f7ce90c75d8b434193b8532b21f1d4b57c5fb330cbe11",
"ui_file_count": 9,
"warnings": []
}
],
"excluded": [
{
"name": "arcrun-claude-api",
"dir": ".component-builds/claude_api",
"reason": "component.wasm 不在版控(.gitignore 明文排除=這顆不是可出貨的零件)"
}
],
"notes": []
}
+5 -16
View File
@@ -65,26 +65,15 @@
## 🔴 第一鐵律:wiki 是判準,不准跳過(2026-07-20/21 leo 兩度點破)
**要查任何東西之前,先照索引走:`system-dev/wiki/INDEX.md` → 下層 `00-INDEX.md` → 卡片。**
🔴 **2026-08-15 leo 糾正:本條原本寫的是「用 grep」,那是錯的。**
grep 是平面搜尋,它繞過索引,於是**索引永遠不會被驗證、也永遠不會變準**——
而 AI 又用「索引可能不準」當理由繼續 grep。這是自我實現的。
實測那天:`matrix/arcrun` 的 wiki 有 64 張卡,**只有 1 張走得到索引**
(唯一那張是 leo 手寫的;63 張機器產的卡從沒加入任何索引),
而 AI 從沒發現——**因為 grep 找得到,破損就永遠不會浮現。**
**grep 不是禁令,是異常訊號**:
1. 先照索引走
2. 索引走不到 → **那是索引壞了,先講出來**(哪一層缺、缺什麼)
3. 講完才准 fallback 用 `grep -rin "<關鍵字>" system-dev/wiki/`
⇒ 每一次 grep 都該留下一筆「索引缺陷」,而不是變成習慣。
(全面禁止 grep 也是錯的——索引壞掉那天 AI 會瞎掉,而且沒人知道。)
**要查任何東西之前,先搜尋 wiki——用 grep,不是只讀開頭幾行。**
> leo:「花很多力氣去產生 wiki,最重要的就是要可以查詢,**結果要查的時候就跳過,那就白寫了**。」
> 「重點是你自己的記憶對嗎?而你有按照規定去切實讀 wiki 嗎?」
```bash
grep -rin "<本題關鍵字>" system-dev/wiki/
```
**三條硬規則**
1. **wiki 與程式碼/歷史文件衝突 → 以 wiki 為準**。程式碼反映「還沒清乾淨」,不等於「還在用」。
2. wiki 寫「不可動/待廢除/進行中」→ **讀它的解除條件並逐條核對**。那是當時狀態,不是永久禁令。
-45
View File
@@ -721,22 +721,6 @@ export async function downloadAndDeploy(
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql${execLogMigPath}`);
}
// 3.8 樹狀 record 模型(0007v7 定稿 2026-08-15):record 有身分、關係是唯一機制、
// entry_values 拆表。**必須排在所有 template seed 之後**(它把 templates 表既有列
// 鏡射成池中 sheet/field entry)。逐句套用+容錯 duplicate column:檔內三句
// ADD COLUMN 在 SQLite 沒有 IF NOT EXISTS 形式,重跑(每次部署都會重跑本段)時
// 那三句報 duplicate column = 已套用,其餘語句全部語句級冪等(檔頭有完整說明)。
const treeMigPath = join(root, 'kbdb', 'migrations', '0007_tree_record_model.sql');
if (existsSync(treeMigPath)) {
try {
await applyD1MigrationTolerant(ctx, readFileSync(treeMigPath, 'utf8'));
} catch (e) {
failures.push(`D1 migration 0007_tree_record_model (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
}
} else {
failures.push(`D1 migration: 部署物缺 kbdb/migrations/0007_tree_record_model.sql${treeMigPath}`);
}
}
const cypherExecutorUrl = ctx.workerSubdomain
@@ -767,35 +751,6 @@ export async function downloadAndDeploy(
};
}
/**
* migration duplicate column0007
*
* applyD1Migration /query
* 0007 ADD COLUMN duplicate columnSQLite
* IF NOT EXISTS migration
* duplicate column
* compile-migrations.mjs --
* 0007
*/
async function applyD1MigrationTolerant(ctx: DeployContext, sql: string): Promise<void> {
const statements = sql
.split('\n')
.map((l) => l.replace(/--.*$/, ''))
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const stmt of statements) {
try {
await applyD1Migration(ctx, stmt);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (/duplicate column/i.test(msg)) continue; // ADD COLUMN 重跑=已套用
throw new Error(`${stmt.slice(0, 60)}… → ${msg}`);
}
}
}
/**
* D1 SQL migration CF API `/d1/database/{id}/query` wrangler
* init ctx.apiToken + accountIdquery 0001_base.sql
+6 -93
View File
@@ -24,19 +24,6 @@ import { normalizeLiveBindings, normalizeLiveVars } from './rule.mjs';
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
/**
* 清單端點每頁抓幾筆100 CF 這幾支端點通用的安全上限KV 官方上限就是 100
* 這個數字**不影響正確性**`cfListAll` 會一直翻到底它只決定要打幾次 API
*/
const LIST_PER_PAGE = 100;
/**
* 翻頁的安全上限100 × 100 10,000 遠超 CF 的帳號上限
* KV namespace 每帳號 1,000 正常帳號永遠碰不到
* 碰到了就是 CF 那邊的行為變了這種時候**寧可 throw 也不回一份不完整的清單**
*/
const LIST_MAX_PAGES = 100;
/**
* @typedef {import('./rule.mjs').ResourceApi} ResourceApi
* @typedef {import('./rule.mjs').ScriptBindings} ScriptBindings
@@ -70,10 +57,9 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/**
* HTTP status 交回呼叫端自己判斷要區分404 不存在其他錯誤時用
* `resultInfo` CF 回應裡的 `result_info`不分頁的端點是 `null``cfListAll` 靠它翻頁
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<{ok: boolean, status: number, result?: any, resultInfo?: any, error?: string}>}
* @returns {Promise<{ok: boolean, status: number, result?: any, error?: string}>}
*/
async function cfRaw(path, init) {
const res = await doFetch(`${accountBase}${path}`, {
@@ -90,7 +76,7 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
`HTTP ${res.status}`,
};
}
return { ok: true, status: res.status, result: data.result, resultInfo: data?.result_info ?? null };
return { ok: true, status: res.status, result: data.result };
}
/**
@@ -104,75 +90,6 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
return result;
}
/**
* 把一支列出帳號上有什麼的端點**翻到底**回傳全部項目
*
* 為什麼非翻不可這是 Arcrun#123 的續集不是效能優化
* 三支清單方法原本只打 `?per_page=100`也就是**只看第一頁**同一個截斷
* #123 的修法前後後果**不一樣**
*
* | 被截掉的那顆 | 規則走到哪 | 結果 |
* |---|---|---|
* | #123 修好****worker 綁著它但它落在第二頁 | 2b 綁著的資源不見了 | 產生 blocker**停手**過度保守但安全 |
* | #123 修好****名字落在第二頁 | 2c 這個名字沒被佔走 | **去建 CF title already exists #123 的死路原樣回來** |
*
* 修法把這個洞從叫得太大聲變成**安靜地復發**所以規約是
* **看不完整就不准當作看完了**翻不完或翻出來的數量對不上 CF 自己回報的
* `total_count`一律 throw `planResources` 把它變成 blocker
* README 規則第 3 說不準就整趟停手一顆都不建
*
* 三支端點的分頁行為不一樣這裡刻意不假設它們同款2026-08-14 geek6688 帳號實測
* - `/storage/kv/namespaces`真分頁`result_info` `{page, per_page, count, total_count, total_pages}`
* - `/d1/database`真分頁 `result_info` **沒有 `total_pages`**實測 `{page, per_page, count, total_count}`
* **不准拿 `total_pages` 當終止條件**那個欄位在 D1 上是 `undefined`
* - `/vectorize/v2/indexes`**不分頁**`result_info` `null` `page``per_page` 也被忽略一次回全部
*
* 所以終止條件只用三支都有或三支都沒有的兩件事`result_info` 在不在`total_count` 對不對得上
* 對不分頁的那支這支等於只打一次就回來那兩個被忽略的參數實測無害
* 而萬一 CF 哪天替它補上分頁這支會自己跟著翻不必等下一次災情才想起來改
*
* @param {string} path 不含分頁參數的端點路徑可自帶其他 query
* @param {string} what 出錯訊息裡怎麼稱呼它
* @returns {Promise<any[]>}
*/
async function cfListAll(path, what) {
/** @type {any[]} */
const items = [];
for (let page = 1; page <= LIST_MAX_PAGES; page++) {
const sep = path.includes('?') ? '&' : '?';
const res = await cfRaw(`${path}${sep}per_page=${LIST_PER_PAGE}&page=${page}`);
if (!res.ok) {
throw new Error(`${what} 失敗(第 ${page} 頁):${res.error ?? `HTTP ${res.status}`}`);
}
const batch = Array.isArray(res.result) ? res.result : [];
items.push(...batch);
const info = res.resultInfo;
// 這支端點沒有分頁(Vectorize v2)⇒ 這一趟拿到的就是全部。
if (!info) return items;
const total = Number(info.total_count);
if (Number.isFinite(total)) {
if (items.length >= total) return items;
// CF 說還有,卻一筆都不給 ⇒ 我們看不到全部。**不准安靜地當作看完了。**
if (batch.length === 0) {
throw new Error(
`${what} 只讀到 ${items.length} 筆,但 Cloudflare 說共有 ${total} 筆,第 ${page} 頁卻是空的。` +
`看不到帳號上的全部資源就沒辦法判斷該不該新建——停手。`,
);
}
continue; // total_count 說還有就繼續翻(不看 total_pages:D1 根本沒這個欄位)
}
// 沒有 total_count 可對,只剩「這一頁沒裝滿 ⇒ 沒有下一頁」可用。
if (batch.length < LIST_PER_PAGE) return items;
}
throw new Error(
`${what} 翻超過 ${LIST_MAX_PAGES} 頁還沒到底(已讀 ${items.length} 筆)。` +
`這不正常,寧可停手,也不拿一份不完整的清單去判斷該不該新建資源。`,
);
}
return {
cfRaw,
@@ -205,8 +122,7 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/** @returns {Promise<Map<string, string>>} title → id */
async listKvNamespaces() {
/** @type {Array<{id: string, title: string}>} */
// 翻到底才算數(只看第一頁會讓 Arcrun#123 安靜復發,理由見 cfListAll
const result = await cfListAll('/storage/kv/namespaces', 'KV namespace');
const result = await cf('/storage/kv/namespaces?per_page=100');
const map = new Map();
for (const ns of result) map.set(ns.title, ns.id);
return map;
@@ -215,8 +131,7 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/** @returns {Promise<Map<string, string>>} name → uuid */
async listD1Databases() {
/** @type {Array<{uuid: string, name: string}>} */
// 翻到底才算數。D1 的 result_info **沒有 total_pages**,所以終止條件只認 total_count。
const result = await cfListAll('/d1/database', 'D1 資料庫');
const result = await cf('/d1/database?per_page=100');
const map = new Map();
for (const db of result) map.set(db.name, db.uuid);
return map;
@@ -225,10 +140,8 @@ export function createCloudflareResourceApi({ accountId, apiToken, fetch: fetchI
/** @returns {Promise<string[]>} */
async listVectorizeIndexes() {
/** @type {Array<{name: string}>} */
// 這支端點**目前不分頁**`result_info` 是 null),走 cfListAll 等同只打一次;
// 但 CF 哪天替它補上分頁,這裡會自己跟著翻,不必等下一次災情才想起來改。
const result = await cfListAll('/vectorize/v2/indexes', 'Vectorize index');
return result.map((i) => i.name);
const result = await cf('/vectorize/v2/indexes');
return (result ?? []).map((i) => i.name);
},
/**
+17 -96
View File
@@ -106,19 +106,6 @@
* @property {string} binding
* @property {string} worker 需要它的 worker script = wrangler.toml `name`
* @property {string} createName
* @property {boolean} [createNameIsOurs]
* 呼叫端在此**聲明**`createName` 是我們自己用可重現的方式替**這一台實例**算出來的名字
* 帳號上若已經有一顆**恰好同名**的資源它只可能是我們上一次沒裝完留下的Arcrun#123
*
* 🔴 這個聲明是接管同名資源**唯一**依據預設 falsefail-closed
* 只有在名字**推導得出而且推導的輸入是使用者自己的身分**時才准聲明 true
* 安裝器的 `arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 就是這種
* slug `SHA-256('arcrun-rag:' + email)` 取前 8 同一個 email 每次算出同一組名字
* 別人算不到也不會不小心撞上
*
* **不准**因為名字看起來像我們的就聲明 true`acr` 那條從 wrangler.toml 讀到的
* createName 是裸 binding `WEBHOOKS` toml 宣告的庫名`arcrun-kbdb`
* 那種名字使用者自己也可能拿去用**證明不了是我們的**所以那條路一律不聲明
*/
/**
@@ -126,12 +113,7 @@
* @property {ResourceKind} kind
* @property {string} binding
* @property {string} value
* @property {string} from 從哪顆已部署的 worker 上讀到的`reclaimed` 時為空字串
* **沒有任何 worker 綁著它正是接收它的前提**Arcrun#123不是漏填
* @property {boolean} [reclaimed]
* true = 這顆不是從某顆 worker 的綁定讀出來的而是帳號上已經有一顆我們自己命名的同名資源
* 卻沒有人綁著 上一次沒裝完留下的這次把它接回來用Arcrun#123
* 給呼叫端做診斷統計用**使用者不必知道殘骸這個詞**對外一律講沿用你原本的資源
* @property {string} from 從哪顆已部署的 worker 上讀到的
*/
/**
@@ -162,11 +144,7 @@
* @property {string} binding
* @property {string} value
* @property {'adopted' | 'created'} origin
* 🔴 接回上次沒裝完留下的那顆`reclaimed`**仍然算 `adopted`**不另開第三種值
* 它本來就是沿用既有資源而且呼叫端現有的 `origin === 'adopted' / 'created'` 統計
* 安裝器那句沿用你原本的 N 項資源不會因為多一種值就悄悄漏數
* @property {string} [from]
* @property {boolean} [reclaimed] PlannedAdopt.reclaimedArcrun#123
*/
/**
@@ -273,25 +251,19 @@ export async function planResources(api, requirements, mode) {
else byKey.set(key, [req]);
}
// 帳號上現有的資源,一種只查一次。**名字 → 身分**KV/D1 是 idVectorize 的身分就是名字)。
//
// 為什麼連名字都收下來(本來只留 `.values()`):
// · 2b 要問的是「這顆綁著的資源還在不在」→ 只需要 values(身分)。
// · 2c 要問的是「這個**名字**是不是已經被佔走了」→ 需要 key。
// 同一份 API 回應裡兩個問題都答得出來,不必多打一次。
/** @type {Map<ResourceKind, Map<string, string>>} */
/** @type {Map<ResourceKind, Set<string>>} */
const existingCache = new Map();
/** @param {ResourceKind} kind @returns {Promise<Map<string, string>>} */
const listExistingByName = async (kind) => {
/** @param {ResourceKind} kind @returns {Promise<Set<string>>} */
const listExisting = async (kind) => {
const hit = existingCache.get(kind);
if (hit) return hit;
/** @type {Map<string, string>} */
let map;
if (kind === 'kv_namespace') map = await api.listKvNamespaces();
else if (kind === 'd1') map = await api.listD1Databases();
else map = new Map((await api.listVectorizeIndexes()).map((n) => [n, n]));
existingCache.set(kind, map);
return map;
/** @type {Set<string>} */
let set;
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) {
@@ -319,10 +291,10 @@ export async function planResources(api, requirements, mode) {
// 2b. 有人綁著它 → 這就是事實,沿用。名字長什麼樣完全不看。
if (distinct.length === 1) {
const value = distinct[0];
/** @type {Map<string, string>} */
/** @type {Set<string>} */
let existing;
try {
existing = await listExistingByName(kind);
existing = await listExisting(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${binding}」綁著的 ${value} 還在不在` +
@@ -330,7 +302,7 @@ export async function planResources(api, requirements, mode) {
);
continue;
}
if (![...existing.values()].includes(value)) {
if (!existing.has(value)) {
// 這正是 #97 的入口:舊版在這裡會安靜地新建一顆空的頂上去。
blockers.push(
`worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}` +
@@ -344,62 +316,12 @@ export async function planResources(api, requirements, mode) {
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding全新帳號
// **或者上一次安裝建到一半死掉**(Arcrun#123)。
//
// 🔴 原本這裡直接 `create.push()`,理由寫「本來就沒有東西可丟」。**那句話漏了一種狀態**:
// 資源已經建在帳號上、worker 還沒部署就中斷(逾時/關掉分頁/斷網)。那個當下:
// 名字已存在 ✅ / 有 worker 綁著 ❌ ⇒ 舊邏輯判「可以新建」⇒ CF 回
// `a namespace with this account ID and title already exists` ⇒ **這個帳號從此裝不起來**。
// 封測者 1.4.45 實撞;youlin 拆除時也親眼看到 8 顆「一個 worker 都沒裝出來就被砍」的空殼。
//
// ⚠️ **這不是把 Arcrun#97 刪掉的 `ensureKvNamespace` 搬回來**,兩者差在三個地方:
// ① #97 是「照名字找 → **找不到就新建一顆頂上去**」;這裡是「照名字找 →
// **找到才沿用那一顆,找不到就照舊新建**」。**永遠不會拿新的空資源去頂替既有的**
// ——會弄丟資料的是那個動作,不是這個。
// ② #97 的比對凌駕於「worker 綁著誰」之上;這裡在 2b 之後,**已部署的綁定仍然絕對優先**,
// 只有在「確定沒有任何 worker 綁過它」時才輪得到名字說話。
// ③ #97 無條件相信名字;這裡要呼叫端**先聲明這個名字推導自使用者自己的身分**
// `createNameIsOurs`),沒聲明就停手。
/** @type {Map<string, string>} */
let existingByName;
try {
existingByName = await listExistingByName(kind);
} catch (e) {
blockers.push(
`查不到帳號上的 ${KIND_LABEL[kind]} 清單,無法確認「${reqs[0].createName}」這個名字是不是已經被用掉了` +
`${msg(e)})。不確定就不建——停手。`,
);
continue;
}
const createName = reqs[0].createName;
const sameName = existingByName.get(createName);
if (sameName !== undefined) {
if (!reqs[0].createNameIsOurs) {
// 名字被佔走,而呼叫端證明不了那顆是我們的 ⇒ 接管它可能蓋掉使用者自己的東西。
// #97 的反向災情(安靜地接管一顆別人的)跟正向一樣糟 ⇒ fail-closed。
// 訊息不准叫使用者自己去 Cloudflare 後台動手(#121/D88:機器做得到的事不要丟回給人)。
blockers.push(
`你的 Cloudflare 帳號上已經有一個叫「${createName}」的 ${KIND_LABEL[kind]}` +
`但沒有任何 worker 綁著它,我也無法證明那顆是這次安裝建的。` +
`直接拿來用有可能蓋掉你自己的東西,所以停手了——沒有建立或改動任何資源。` +
`請把這則訊息回報給我們(錯誤碼 RES-NAME-TAKEN/${kind}/${binding}),這需要我們處理。`,
);
continue;
}
// 名字是我們替這台實例算出來的(見 createNameIsOurs 的推導條件)⇒ 這顆只可能是
// 我們上一次沒裝完留下的。沿用它=把上次做到一半的進度接回來,**不會有任何損失**:
// · 它若是空的(最常見)→ 等同於新建一顆,只是省下 CF 那個「名字已存在」的拒絕。
// · 它若有資料(更早裝過、後來 worker 被拆掉)→ 沿用正是把使用者的東西接回來。
adopt.push({ kind, binding, value: sameName, from: '', reclaimed: true });
continue;
}
// 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding,或全新帳號
// 這種情況下新建不會弄丟任何東西(本來就沒有東西可丟)。
create.push({
kind,
binding,
createName,
createName: reqs[0].createName,
wantedBy: [...new Set(reqs.map((r) => r.worker))],
alsoBind: [],
});
@@ -479,7 +401,6 @@ export async function applyResourcePlan(api, plan) {
value: a.value,
origin: 'adopted',
from: a.from,
...(a.reclaimed ? { reclaimed: true } : {}),
});
}
/** @type {string[]} */
-101
View File
@@ -135,13 +135,6 @@ class FakeCloudflare implements ResourceApi {
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
async createKvNamespace(title: string): Promise<string> {
// 🔴 Arcrun#123:真的 Cloudflare **不准同名**——
// `a namespace with this account ID and title already exists`。
// 這個假帳號原本沒有模擬這條限制,於是「上一次裝到一半死掉」的帳號在測試裡
// 看起來只是「多建幾顆孤兒」,實際上是**再也裝不起來**。少了這一行,#123 測不出來。
if (this.kv.has(title)) {
throw new Error('a namespace with this account ID and title already exists');
}
const id = `NEW-kvid-${this.createdKv.length}`;
this.kv.set(title, id);
this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上
@@ -493,100 +486,6 @@ test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某
assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
});
// ═════════════════════════════════════════════════════════════════════════════
// Arcrun#123 —— 「上一次裝到一半死掉」的帳號,再裝一次要能成功
//
// 這一格與 #97 的差別:#97 是「worker 綁著資源,卻被重新綁到新建的空殼」(會弄丟資料);
// #123 是「資源建好了、worker 一顆都還沒部署」——**沒有任何綁定可以當事實**,
// 而帳號上偏偏已經有一批同名資源 ⇒ 舊規則判「可以新建」⇒ CF 拒絕 ⇒ 這個帳號從此裝不起來。
//
// 封測者 1.4.45 實撞。逃過驗證的原因:我們只測乾淨帳號與完整安裝。
// ═════════════════════════════════════════════════════════════════════════════
/**
* `createName` ****
* `arcrun-rag-<slugFromEmail(email)>-…` createNameIsOurs
* collectRequirements() tomlcreateName binding
*/
function installerRequirements(claimOwnership = true): BindingRequirement[] {
const own = claimOwnership ? { createNameIsOurs: true } : {};
const out: BindingRequirement[] = [];
for (const { requirements } of [collectRequirements()]) {
for (const r of requirements) {
if (r.kind === 'kv_namespace') {
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kv-${r.binding.toLowerCase()}`, ...own });
} else if (r.kind === 'd1') {
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kbdb`, ...own });
} else {
out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-embed`, ...own });
}
}
}
return out;
}
test('#123 ①:上次裝到一半死掉的帳號 → 接回上次留下的那批,一顆都不必新建', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true }); // 資源在、worker 一顆都沒有
const kvBefore = cf.kv.size;
const plan = await planResources(cf, installerRequirements(), 'init');
assert.deepEqual(plan.blockers, [], '半殘帳號不該有 blocker——用戶只要再按一次就該裝得起來');
assert.deepEqual(plan.create, [], '一顆都不該新建');
assert.ok(plan.adopt.length > 0 && plan.adopt.every((a) => a.reclaimed === true),
'全部都是「接回上次留下的」');
const resolved = await applyResourcePlan(cf, plan);
assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
assert.equal(cf.kv.size, kvBefore, '帳號上的顆數不變(沒有再留下一批孤兒)');
// 綁到的是上次建的那幾顆本尊
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.value, 'kvid-webhooks');
assert.equal(resolved.get(bindingKey('d1', 'DB'))!.value, 'd1id-kbdb');
assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.origin, 'adopted',
'origin 維持 adopted——呼叫端既有的 adopted/created 統計不會漏數');
});
test('#123 ①對照組:修好之前,同一個帳號會被 CF 用「title already exists」擋死', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
// 不聲明所有權 ⇒ 走的是修好之前的判斷(「沒人綁著就可以新建」)。
// 舊版會直接送 POST → 撞 CF 的同名限制;新版在 plan 階段就停手,兩者都裝不起來,
// 差別在**新版一顆資源都不會被建出來**,而且訊息說得出人話。
const plan = await planResources(cf, installerRequirements(false), 'init');
assert.ok(plan.blockers.length > 0, '證明不了是自己的 → fail-closed 停手');
assert.match(plan.blockers.join('\n'), /RES-NAME-TAKEN/, '要給得出可回報的錯誤碼');
assert.doesNotMatch(plan.blockers.join('\n'), /後台|dashboard/,
'不准叫使用者自己去 Cloudflare 後台處理(#121D88');
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, [], '被擋下時一顆都不能被建出來');
});
test('#123 ②紅線:名字真的不是我們的(走 toml 的裸 binding 名)→ 停手,不接管也不新建', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
await cf.createKvNamespace('WEBHOOKS'); // 使用者自己建的、剛好叫這個名字
cf.createdKv.length = 0;
const { requirements } = collectRequirements(); // createName = 裸 binding 名,不得聲明所有權
const plan = await planResources(cf, requirements, 'init');
assert.ok(plan.blockers.length > 0, '撞到不能證明是我們的同名資源 → 停手');
assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), 'WEBHOOKS 不准被排進「要新建」');
await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
assert.deepEqual(cf.createdKv, [], '一顆都沒建');
});
test('#123 ③:全新帳號照舊建得出整套(D82 第一步不可退化)', async () => {
const cf = new FakeCloudflare({ nothingDeployed: true });
cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0; // 真正的空帳號
const plan = await planResources(cf, installerRequirements(), 'init');
assert.deepEqual(plan.blockers, [], '全新帳號不該有 blocker');
assert.ok(plan.adopt.length === 0, '全新帳號沒有東西可沿用');
const resolved = await applyResourcePlan(cf, plan);
assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length, '9 顆 KV 全部建出來');
assert.equal(cf.createdD1.length, 1, 'D1 建一顆(兩個綁定共用)');
assert.ok([...resolved.values()].every((r) => r.origin === 'created'), '全部都是新建的');
});
test('CfAccountClient.getScriptBindings404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => {
const orig = globalThis.fetch;
try {
+1 -6
View File
@@ -840,12 +840,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
'<div class="kt">' + esc(l.library) + (S.library === l.library ? ' <span class="tag" style="font-size:11.5px;vertical-align:middle">搜尋中</span>' : '') + '</div>' +
'<div class="ks">' + (l.narrative ? esc(l.narrative) : '<span class="dim">(此庫尚無 narrative——recompute 時可帶入)</span>') + '</div>' +
(ents && ents.length ? '<div style="display:flex;gap:6px;flex-wrap:wrap">' + lmEntityLine(ents) + '</div>' : '') +
// Arcrun#87 三次收尾(2026-08-13):triplet_count=0 不等於這庫沒有知識——entries 有標庫、
// 只是三元組萃取沒對它跑過(kb 以外幾乎全庫皆此況)。entry_count>0 時附一句,別讓看板
// 的「0 三元組」被讀成「這庫是空的」(同 MCP 端 kbdb_map.ts 的 entryOnlyHint 同一件事)。
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組' +
(!(Number(l.triplet_count) || 0) && (Number(l.entry_count) || 0) > 0
? '<span class="dim">・' + (Number(l.entry_count) || 0) + ' 筆原始內容(尚未萃取)</span>' : '') + '</span>' +
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組</span>' +
'<span class="dim" style="margin-left:auto;font-size:12.5px">' + stamp + '</span></div></div>';
}).join('');
}
@@ -245,35 +245,6 @@ export async function resolveCredentialRefs(
// 沒取到的 name 缺席 → WASM 的 resolve_credentials 分支 fallback 舊 KVT7)。
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
// ── 全部取到 → 不必打 auth_static_key workerArcrun#1242026-08-14)────────────
//
// 這條路徑上,那顆 worker 唯一比這裡多做的事,是「幫**沒取到**的 name 回頭翻舊 KV」
// T7 雙讀 fallback)。當 nameList 每一個都已經從新家取到明文時,它收到
// `resolved_secrets` 後就是原樣回傳——回填本來就由下面的 `replaceCredentialRefs`
// 在本檔做。**所以那趟往返在「全取到」時不產生任何結果差異。**
//
// 為什麼這不是效能微調,是修 bug`wasmWorkerUrl` 推導出的
// `arcrun-auth-static-key.{WORKER_SUBDOMAIN}.workers.dev` **在網頁安裝器裝出來的實例上不存在**
// (首裝只裝 cypher-executor / kbdb / http_request / code 四顆,auth primitive 不在其中,
// 也沒有「用到才長」的機制)。於是 fetch 撞 CF 邊緣的 404 `error code: 1042`
// ⇒ 下面的 `!res.ok` throw ⇒ **凡是工作流用到 {{credential.X}} 的節點全部 500**。
// 實測:leo21c 與全新安裝的 youlin 兩台,問答與寫入路徑一字不差地同死。
//
// 🔴 給下一個人:**這個 1042 跟 d2048e295a1462 那兩輪的 1042 不是同一個病。**
// 那兩輪是「same-zone fetch 被 CF 擋」(解法=`global_fetch_strictly_public` flag
// 且 service binding 的解法**已被 revert、不要再拿回來**)。本案是「**目標 worker 根本不存在**」
// ——同一個錯誤碼、兩種原因。照舊帳去動 flag 或 binding 對本案完全無效(flag 早就在了)。
//
// 邊界(rule 02 §2.2)不變:本檔仍然不解密、不展開模板、不組 JWT。
// 明文的來源是 `secret_get`host function,實作 = env[ref]),與改動前完全同一條路;
// 這裡只是不再為了「拿一份自己已經拿到的東西」去 fetch 一顆可能不存在的 worker。
//
// 刻意保留 WASM 路徑:只要有任何一個 name 沒從新家取到(舊租戶、遷移過渡期的 KV 存量),
// 就照舊全量交給 WASM 走 T7 雙讀——**不縮減既有能力,只在等價時省掉那一跳**。
if (nameList.every((n) => Object.prototype.hasOwnProperty.call(resolvedSecrets, n))) {
return replaceCredentialRefs(data, resolvedSecrets) as Record<string, unknown>;
}
const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN);
const res = await fetch(url, {
method: 'POST',
+10 -28
View File
@@ -131,16 +131,9 @@ export function authStorePresent(env: Bindings): boolean {
return shardNames(env).length > 0 || (overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS);
}
/**
*
*
* `tokenOverride`2026-08-14arcrun-rag#99`env.CF_SECRETS_API_TOKEN`
* leo
* OAuth token `/console/setup``/portal/admin/bootstrap`
* `routes/credentials.ts putWorkerSecret`
*/
export function authStoreWritable(env: Bindings, tokenOverride?: string): boolean {
return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID);
/** 寫入路徑是否就緒——缺就誠實回報「不能改密碼」,不假綠。 */
export function authStoreWritable(env: Bindings): boolean {
return Boolean(env.CF_SECRETS_API_TOKEN && env.CF_ACCOUNT_ID);
}
/**
@@ -208,21 +201,11 @@ export function newAuthUserId(): string {
* console 0 users
*
*/
export async function writeAuthStore(env: Bindings, data: AuthStoreData, tokenOverride?: string): Promise<void> {
if (!authStoreWritable(env, tokenOverride)) {
// arcrun-rag#992026-08-14leo 傳實測畫面點破):舊文案叫用戶「重新執行安裝/更新」,
// 但在這個訊息會出現的所有情況下(包含安裝精靈本身,若它沒能把 install token 帶到)那句話
// 都不保證成立——安裝/更新從來不會把 CF_SECRETS_API_TOKEN 種成一個常駐的值。
// 對一個做不到的動作下指令=把人導向死路(他會以為自己操作錯誤,反覆重試)。
// 改成誠實描述現況+給得出去的下一步(回報支援),不再承諾一個我們自己都不確定會生效的動作。
// 只列 CF_SECRETS_API_TOKENCF_ACCOUNT_ID 已查證全新實例一定有
//installer/oauth-prototype/worker.js:1354 無條件注入每一顆部署的 worker),
// 列兩項只會讓下一個人以為兩項都要查,白工一次。
export async function writeAuthStore(env: Bindings, data: AuthStoreData): Promise<void> {
if (!authStoreWritable(env)) {
throw new AuthStoreWriteError(
'這台實例目前寫不進認證儲存(缺可用的 Cloudflare 寫入憑證:CF_SECRETS_API_TOKEN)。' +
'這是平台端的已知限制,不是你操作錯誤——目前沒有你自己在畫面上能做的下一步,' +
'請把這則訊息完整截圖/複製給支援,並註明你剛才在做什麼(例如:安裝精靈裡建立第一個帳號、' +
'事後新增使用者、或修改密碼),會需要人工協助排除。',
'這台實例還不能寫入認證儲存(缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID)。' +
'認證分離需要這兩項才寫得進 Workers Secrets——請重新執行安裝/更新讓它就緒。',
);
}
@@ -249,10 +232,10 @@ export async function writeAuthStore(env: Bindings, data: AuthStoreData, tokenOv
const existing = shardNames(env);
for (let i = 0; i < shards.length; i++) {
await putWorkerSecret(env, shardNameOf(i), shards[i], tokenOverride);
await putWorkerSecret(env, shardNameOf(i), shards[i]);
}
for (const name of existing) {
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name, tokenOverride);
if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name);
}
overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
@@ -337,12 +320,11 @@ function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
export async function mutateAuthStore(
env: Bindings,
fn: (data: AuthStoreData) => void | Promise<void>,
tokenOverride?: string,
): Promise<AuthStoreData> {
await hydrateFromAccelerator(env);
const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
await fn(next);
await writeAuthStore(env, next, tokenOverride);
await writeAuthStore(env, next);
return next;
}
+63 -59
View File
@@ -22,18 +22,19 @@
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
// D61 補充(2026-08-14leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 D1/KV」):
// D61 把這組管理員帳密搬去認證儲存(CF Workers Secrets)是為了躲開「重裝時 binding 被安裝器
// 照名字重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
// `CF_SECRETS_API_TOKEN`,而這把 token 從安裝那天起就沒被種過,於是每一台全新實例永遠建不出
// 第一個帳號(arcrun-rag#99)。
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`Arcrun#97)解掉——
// 現在每次安裝/更新都會沿用既有 binding,不會再把 SESSIONS_KV 重指到空資源。既然病根已解,
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債:**帳密改回住 SESSIONS_KV**
// `console:credentials`binding,永不需要外部 CF token)。
// 認證儲存(Workers Secrets)留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
// 零外部憑證需求(只有寫入才要 token)——查到就順手搬回 SESSIONS_KV(見 loadCredentials)。
import { readAuthStore } from '../lib/portal-auth-store';
// D61ADR D61 / Leo/arcrun-rag#55):這組管理員帳密原本住 SESSIONS_KV`console:credentials`
// 而且沒有 TTL)——KV 是靠 binding 指過去的,重裝會被指到**新建的空 KV** ⇒ 帳密憑空消失。
// 這是「KV=暫存、非長期真相源」第三次被違反,而這一次違反的是大門的鎖。
// 現改存進認證儲存(Workers Secrets,不靠 binding);舊 KV 只保留為回退讀路徑,
// 讀到就順手搬過去(見 loadCredentials)。
import {
AuthStoreWriteError,
authStoreStatus,
hydrateFromAccelerator,
mutateAuthStore,
readAuthStore,
type AuthConsoleRecord,
} from '../lib/portal-auth-store';
// Arcrun#108:租戶字串唯一產地。
import { knowledgeOwner } from '../lib/tenant';
@@ -93,59 +94,52 @@ function tenantOf(c: { env: Bindings }): string {
return knowledgeOwner(c.env);
}
// ── 帳密的家(2026-08-14 起:SESSIONS_KV 為主,認證儲存為舊實例回退讀路徑)────────────
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
/**
* console **SESSIONS_KVbinding**退
* D61 CF Workers Secrets
* SESSIONS_KVbest-effort
* console **Workers Secrets**退KV
* best-effort
*/
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'kv' | 'legacy-secrets' | 'none' }> {
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'secrets' | 'legacy-kv' | 'none' }> {
let fromStore = readAuthStore(env).console;
if (!fromStore && (await hydrateFromAccelerator(env))) {
// 剛設定完帳密、secret 的新版本還沒鋪到這顆 isolate(實測有 15 秒以上的窗口)
// → 先問一次加速器,免得「剛設好就說你沒設過」。細節見 lib 的 ACCEL_KEY 註解。
fromStore = readAuthStore(env).console;
}
if (fromStore) return { creds: fromStore, source: 'secrets' };
const raw = await env.SESSIONS_KV.get(CREDS_KEY);
if (raw) {
try {
return { creds: JSON.parse(raw) as StoredCredentials, source: 'kv' };
} catch {
/* KV 這份壞了,當作沒有,往下查舊家 */
}
}
// 舊家(D61 的認證儲存):純讀 env 字串,零網路呼叫、不需要任何外部 CF 憑證。
const legacy = readAuthStore(env).console;
if (!legacy) return { creds: null, source: 'none' };
if (!raw) return { creds: null, source: 'none' };
let legacy: StoredCredentials | null = null;
try {
// best-effort 搬回 SESSIONS_KV——這是 binding put,本來就不需要外部 token,
// 幾乎不會失敗;失敗也不影響本次用這份舊資料繼續(狀態看 /console/auth-status)。
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
legacy = JSON.parse(raw) as StoredCredentials;
} catch {
/* 照舊用這份,下次再試著搬一次 */
return { creds: null, source: 'none' };
}
return { creds: legacy as StoredCredentials, source: 'legacy-secrets' };
try {
await mutateAuthStore(env, (data) => {
if (!data.console) data.console = legacy as AuthConsoleRecord;
});
} catch {
/* 搬不動就照舊用 KV 這份(狀態看 /health 的 auth_store */
}
return { creds: legacy, source: 'legacy-kv' };
}
/**
* console ** SESSIONS_KV**binding CF token
* Workers Secrets
*/
/** 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 */
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
}
/** `/console/auth-status`、`/health` 共用的儲存狀態區塊(不洩漏 email/雜湊,只回統計)。 */
function consoleAuthStoreStatus(env: Bindings): { home: 'sessions-kv'; writable: true; legacy_secrets_present: boolean } {
return {
home: 'sessions-kv',
writable: true, // binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
legacy_secrets_present: readAuthStore(env).console !== null,
};
await mutateAuthStore(env, (data) => {
data.console = record;
});
}
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
consoleAuthRouter.get('/console/auth-status', async (c) => {
const { creds, source } = await loadCredentials(c.env);
// 多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
// D61多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(c.env) });
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
});
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
@@ -177,12 +171,11 @@ consoleAuthRouter.post('/console/setup', async (c) => {
const hash = await hashPassword(password, salt);
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
try {
// 2026-08-14 起:寫 SESSIONS_KVbinding),不再需要安裝精靈遞任何臨時 CF token
// arcrun-rag#99 那個結構性缺口——見檔頭說明——已經隨儲存層搬回 binding 一併解掉)。
await saveCredentials(c.env, record);
} catch (e) {
// 寫不進去就誠實回報(不假綠:binding put 幾乎不會失敗,於是沒人處理過這條路)
return c.json({ error: `帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, code: 'auth_store_not_writable' }, 502);
// 寫不進去就誠實回報(不假綠:舊版寫 KV 幾乎不會失敗,於是沒人處理過這條路)
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
return c.json({ error: `帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
}
const token = randomHex(32);
@@ -213,7 +206,8 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
try {
await saveCredentials(c.env, record);
} catch (e) {
return c.json({ error: `新帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, code: 'auth_store_not_writable' }, 502);
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
return c.json({ error: `新帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
}
return c.json({ success: true });
});
@@ -222,13 +216,12 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
consoleAuthRouter.post('/console/login', async (c) => {
const { creds: existing } = await loadCredentials(c.env);
if (!existing) {
// 明顯失敗(#10「寧可明顯失敗,不要靜默錯置」):這是「這台實例讀不到認證資料」,
// 不是「你帳密打錯」——兩句話混成一句正是 2026-08-09 leo 被誤鎖 15 分鐘的根因。
// D61 明顯失敗:這是「這台實例讀不到認證資料」,不是「你帳密打錯」
return c.json(
{
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
code: 'auth_store_empty',
auth_store: consoleAuthStoreStatus(c.env),
auth_store: authStoreStatus(c.env),
},
400,
);
@@ -239,8 +232,19 @@ consoleAuthRouter.post('/console/login', async (c) => {
const password = body?.password ?? '';
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
const hash = await hashPassword(password, existing.salt);
if (email !== existing.email || hash !== existing.hash) {
let creds = existing;
let hash = await hashPassword(password, creds.salt);
if (email !== creds.email || hash !== creds.hash) {
// D61:剛改完帳密、secret 新版本還沒鋪開的窗口 → 問一次加速器再判失敗
if (await hydrateFromAccelerator(c.env)) {
const again = (await loadCredentials(c.env)).creds;
if (again) {
creds = again;
hash = await hashPassword(password, creds.salt);
}
}
}
if (email !== creds.email || hash !== creds.hash) {
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
+6 -22
View File
@@ -92,21 +92,9 @@ function validSensitivity(s: unknown): s is 'standard' | 'high' {
/**
* CF Workers Scripts secrets API worker per-script secret
* API secret create/update/delete/list D19
*
* `tokenOverride`2026-08-14arcrun-rag#99`writable:false`
* worker `env.CF_SECRETS_API_TOKEN` 07-29
* pending-changes.mdcredential n8n D61
* `/console/setup``/portal/admin/bootstrap`
* **** OAuth token
* `workers-scripts.write` scope credential
* `installer/oauth-prototype/worker.js` `putWorkerSecretDirect`/`seedCredential`
* D36 1 token ****
* cypher
* > worker env
*/
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string, tokenOverride?: string): Promise<void> {
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
if (!token || !env.CF_ACCOUNT_ID) {
export async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise<void> {
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
throw new Error(
'此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' +
'credential-store-migration.md T3acr init/update 應確保這兩項就緒)',
@@ -116,7 +104,7 @@ export async function putWorkerSecret(env: Bindings, secretRef: string, value: s
const res = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: secretRef, text: value, type: 'secret_text' }),
@@ -133,19 +121,15 @@ export async function putWorkerSecret(env: Bindings, secretRef: string, value: s
/**
* CF Workers Scripts secrets API per-script secretT9
* 404
*
* `tokenOverride` `putWorkerSecret` auth store
* token
*/
export async function deleteWorkerSecret(env: Bindings, secretRef: string, tokenOverride?: string): Promise<void> {
const token = tokenOverride || env.CF_SECRETS_API_TOKEN;
if (!token || !env.CF_ACCOUNT_ID) {
export async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) {
throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒');
}
const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`;
const res = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` },
});
if (res.status === 404) return;
const body = (await res.json().catch(() => null)) as
+1 -18
View File
@@ -1,26 +1,9 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { readAuthStore } from '../lib/portal-auth-store';
import { authStoreStatus } from '../lib/portal-auth-store';
export const healthRouter = new Hono<{ Bindings: Bindings }>();
/**
* 2026-08-14 console SESSIONS_KVportal KBDB
* binding-based CF token console-auth.tsportal.ts
* D61 `legacy_*_present` D61 CF Workers
* Secrets email
*/
function authStoreStatus(env: Bindings): {
console: { home: 'sessions-kv'; writable: true; legacy_secrets_present: boolean };
portal_users: { home: 'kbdb'; writable: true; legacy_secrets_present: boolean };
} {
const legacy = readAuthStore(env);
return {
console: { home: 'sessions-kv', writable: true, legacy_secrets_present: legacy.console !== null },
portal_users: { home: 'kbdb', writable: true, legacy_secrets_present: legacy.users.length > 0 },
};
}
// t162leo 07-31 實撞:「小幫手一直顯示知識庫需要更新…重新更新後並不會消失」):
// daemon cloudVersionStale() 讀 /health 的 `bundle_version` 判斷是否過舊——
// 但本端點過去只回 {ok:true}**從沒吐這個欄位** ⇒ daemon 恆讀到空字串
+43 -17
View File
@@ -81,33 +81,20 @@ kbdbProxyRouter.get('/kbdb/templates/:idOrName', async (c) => {
// ── records(以租戶 namespace 為 owner_id 隔離)────────────────────────────────
// POST /kbdb/records — 填一筆 recordtemplate + valuesentry_ids)。owner_id 自動注入。
//
// `entry_ids`Arcrun#128)= slot 指向**既有** entry 的 id,不新建、不複製;與 values 並存
// (給字串照舊新建)。這裡維持純轉發,判斷與擋人全在基本盤 kbdb(薄殼鐵律,見檔頭):
// · 兩者都沒給/型別不對 → base 回 400
// · 指到別人的 entry → base 擋(它比對被參照 entry 的 owner_id 與這裡注入的租戶身份,
// 所以「呼叫端自己指定 entry_id」這條新路徑不會變成跨租戶的門)
// 🔴 為什麼通道要一起開:#129wiki template)與 #130(三元組正規化)的寫入端走這扇門。
// 基本盤補好而通道不開=能力在、沒人打得到——同 PATCH 那次(b6ef0f0)的教訓。
// POST /kbdb/records — 填一筆 recordtemplate + values)。owner_id 自動注入。
kbdbProxyRouter.post('/kbdb/records', async (c) => {
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
const body = await c.req.json().catch(() => null);
if (!body || !body.template || (!body.values && !body.entry_ids)) {
return c.json({ error: 'template 必填,values 與 entry_ids 至少要有一個' }, 400);
if (!body || !body.template || !body.values) {
return c.json({ error: 'template values 必填' }, 400);
}
const { base, headers } = kbdbBase(c.env);
const res = await fetch(`${base}/records`, {
method: 'POST',
headers,
// 強制以租戶身份隔離:忽略 caller 自帶 owner_id,一律用 header 身份(防跨租戶寫入)
body: JSON.stringify({
template: body.template,
...(body.values ? { values: body.values } : {}),
...(body.entry_ids ? { entry_ids: body.entry_ids } : {}),
owner_id: owner,
}),
body: JSON.stringify({ template: body.template, values: body.values, owner_id: owner }),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -124,6 +111,45 @@ kbdbProxyRouter.get('/kbdb/records/by-template/:template', async (c) => {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// POST /kbdb/records/backfill-library — 三元組版標庫補存量對外通道(Arcrun#87 二次收尾,2026-08-13)。
// 基本盤(kbdb/src/routes/records.ts)的 POST /records/backfill-library 缺對外通道——同
// PATCH /kbdb/records/:recordId 那條的破口(能力在 base,插件/工作流打不到)。純轉發,
// owner_id 強制用租戶身份(同本檔 POST /kbdb/records 的既有慣例,不信任 caller 自帶 owner_id)。
// 此路由必須在 '/:recordId' 之前註冊,否則 'backfill-library' 會被當成 recordId 參數。
kbdbProxyRouter.post('/kbdb/records/backfill-library', async (c) => {
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
const body = await c.req.json().catch(() => null);
if (!body || !body.library) return c.json({ error: 'library 必填' }, 400);
const { base, headers } = kbdbBase(c.env);
const res = await fetch(`${base}/records/backfill-library`, {
method: 'POST',
headers,
body: JSON.stringify({
library: body.library,
owner_id: owner,
triplet_template: body.triplet_template,
source_prefix: body.source_prefix,
limit: body.limit,
}),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// GET /kbdb/records/backfill-library/status — 待補標統計(三元組版),owner_id 同樣強制用租戶身份。
kbdbProxyRouter.get('/kbdb/records/backfill-library/status', async (c) => {
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
const { base, headers } = kbdbBase(c.env);
const params = new URLSearchParams({ owner_id: owner });
for (const k of ['triplet_template', 'source_prefix']) {
const v = c.req.query(k);
if (v) params.set(k, v);
}
const res = await fetch(`${base}/records/backfill-library/status?${params.toString()}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// GET /kbdb/records/:recordId — 取單筆 record。
kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
+77 -149
View File
@@ -31,25 +31,19 @@ import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey
// arcrun-rag#10/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑,
// 不在 portal 這層另造第二套儲存(D36:值進 Workers SecretD1 只留 ref)。
import { storeCredential, hasCredential } from './credentials';
// D61 補充(2026-08-14leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 D1/KV」):
// D61 把帳號搬去認證儲存(CF Workers Secrets)是為了躲開「重裝時 binding 被安裝器照名字
// 重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
// `CF_SECRETS_API_TOKEN`,這把 token 從安裝那天起就沒被種過,於是每一台全新實例永遠建不出
// 第一個帳號、也永遠加不了第二個(arcrun-rag#99)。
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`Arcrun#97)解掉——
// 現在每次安裝/更新都會沿用既有 binding,不會再把 KBDB D1 重指到空資源。既然病根已解,
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債:**帳號改回住 KBDB**
// (走 base HTTP APID38 零 SQLbinding,永不需要外部 CF token)。
// 認證儲存(Workers Secrets)留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
// 零外部憑證需求(只有寫入才要 token)——登入成功就順手搬進 KBDB(見 promoteToKbdb)。
// D61Leo/arcrun-rag#55ADR D61):**帳號不再住知識資料庫**。
// 讀寫一律先走 lib/portal-auth-storeCF Workers Secrets,不靠任何 binding),
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
import {
AuthStoreWriteError,
authStoreRecentlyWritten,
authStoreStatus,
findAuthUserByEmail,
findAuthUserById,
hydrateFromAccelerator,
isAuthStoreId,
mutateAuthStore,
newAuthUserId,
readAuthStore,
type AuthUserRecord,
} from '../lib/portal-auth-store';
@@ -120,9 +114,7 @@ export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<
try {
return await fn();
} catch (e) {
// 帳號還住在舊家(D61 認證儲存)時,寫入需要外部 CF_SECRETS_API_TOKEN——
// 新家(KBDB)不需要,但尚未搬遷的既有帳號仍可能撞到這格,要看得出來是這件事
// (不是 KBDB 的錯,也不是密碼的錯),且會在該帳號下次登入時自動搬進 KBDB 而解除。
// D61認證儲存寫不進去要**看得出來是這件事**(不是 KBDB 的錯,也不是密碼的錯)
if (e instanceof AuthStoreWriteError) {
return c.json({ error: `認證儲存寫入失敗:${e.message}`, code: 'auth_store_not_writable' }, 502);
}
@@ -201,7 +193,7 @@ export async function ensurePortalTemplates(
return { created, existing, errors };
}
// ── 認證儲存(D61 舊家)⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)──
// ── D61 認證儲存 ⇄ PortalRecord 轉換(呼叫端一律只認 PortalRecord,不必分辨住哪)─────
function authUserToRecord(u: AuthUserRecord): PortalRecord {
return {
@@ -235,41 +227,33 @@ function recordValuesToAuthUser(id: string, v: Record<string, string>): AuthUser
}
/**
* D61 CF Workers Secrets KBDB
* best-effortKBDB
* 沿 #55****
* record_id null沿 record_id
* session
* KBDB
* best-effort
* #55****
*/
async function promoteToKbdb(env: Bindings, rec: PortalRecord): Promise<string | null> {
async function promoteLegacyUser(env: Bindings, rec: PortalRecord): Promise<void> {
try {
const email = (rec.values.email ?? '').toLowerCase();
if (!email) return null;
const already = await findKbdbUserRecordId(env, email);
if (already) return already; // 更早一次登入已經搬過了,不重複建
return await createKbdbUserRecord(env, email, {
display_name: rec.values.display_name ?? '',
status: rec.values.status ?? 'active',
role: rec.values.role ?? 'user',
password_hash: rec.values.password_hash ?? '',
libraries: rec.values.libraries ?? '[]',
created_at: rec.values.created_at ?? new Date().toISOString(),
updated_at: rec.values.updated_at ?? new Date().toISOString(),
if (!email) return;
if (findAuthUserByEmail(env, email)) return;
await mutateAuthStore(env, (data) => {
if (data.users.some((u) => u.email === email)) return;
data.users.push(recordValuesToAuthUser(newAuthUserId(), rec.values));
});
} catch {
return null; // 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來)
/* 搬遷失敗不擋登入(誠實:狀態可從 /health 的 auth_store 看出來) */
}
}
/** email → user record_id。**KBDB新家優先**;找不到才回退舊家(認證儲存)。 */
/** email → user record_id。**新家優先**;找不到才回退舊家(KBDB),並順手搬過去。 */
async function findUserRecordId(env: Bindings, email: string): Promise<string | null> {
const inKbdb = await findKbdbUserRecordId(env, email);
if (inKbdb) return inKbdb;
return findAuthUserByEmail(env, email)?.id ?? null;
const inStore = findAuthUserByEmail(env, email);
if (inStore) return inStore.id;
return findLegacyUserRecordId(env, email);
}
/** KBDB 的 email → record_iddesign §2.3 head entry O(1) 查找)。 */
async function findKbdbUserRecordId(env: Bindings, email: string): Promise<string | null> {
/** 舊家(KBDB的 email → record_iddesign §2.3 head entry O(1) 查找)。 */
async function findLegacyUserRecordId(env: Bindings, email: string): Promise<string | null> {
const ns = portalNamespace(env);
const params = new URLSearchParams({
page_name: email,
@@ -285,8 +269,7 @@ async function findKbdbUserRecordId(env: Bindings, email: string): Promise<strin
}
async function getRecordById(env: Bindings, recordId: string): Promise<PortalRecord | null> {
// 舊家(D61 認證儲存)的帳號零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到
// 這正是它當初被選為「不可能因重裝而不見」的理由——見 lib/portal-auth-store.ts 檔頭)。
// D61:住新家的帳號零網路呼叫直接讀 env(換 D1/換租戶代號都影響不到
if (isAuthStoreId(recordId)) {
const u = findAuthUserById(env, recordId);
return u ? authUserToRecord(u) : null;
@@ -299,9 +282,7 @@ async function getRecordById(env: Bindings, recordId: string): Promise<PortalRec
}
async function patchRecordValues(env: Bindings, recordId: string, values: Record<string, string>): Promise<PortalRecord> {
// 舊家(D61 認證儲存)的帳號改寫進 Workers Secrets需要 CF_SECRETS_API_TOKEN
// 沒有 token 就誠實拋 AuthStoreWriteError → run() 轉 502。這批帳號會在下次登入時
// 自動搬進 KBDB(見 promoteToKbdb),搬完之後就落進下面的 KBDB PATCH 分支。
// D61:住新家的帳號改寫進 Workers Secrets改密碼/停用/改權限都在這條路上)
if (isAuthStoreId(recordId)) {
let updated: AuthUserRecord | null = null;
await mutateAuthStore(env, (data) => {
@@ -348,25 +329,24 @@ function daemonActiveKey(env: Bindings): string {
}
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
// 帳號清單=KBDB新家為主,認證儲存(舊家)尚未搬走的補在後面(同 email 以新家為準)。
// 家讀不到(KBDB 不可達)不算失敗——舊家仍是完整真相源之一,這裡只是把還沒搬完的人也列出來。
// D61帳號清單=新家為主,舊家KBDB)尚未搬走的補在後面(同 email 以新家為準)。
// 家讀不到不算失敗——認證已經不靠它了,這裡只是把還沒搬完的人也列出來。
if (template === USER_TEMPLATE) {
let fromKbdb: PortalRecord[] = [];
const fromStore = readAuthStore(env).users.map(authUserToRecord);
const seen = new Set(fromStore.map((r) => (r.values.email ?? '').toLowerCase()));
let legacy: PortalRecord[] = [];
try {
fromKbdb = await listKbdbRecordsByTemplate(env, template);
legacy = await listLegacyRecordsByTemplate(env, template);
} catch {
fromKbdb = [];
legacy = [];
}
const seen = new Set(fromKbdb.map((r) => (r.values.email ?? '').toLowerCase()));
const fromLegacy = readAuthStore(env).users.map(authUserToRecord)
.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()));
return [...fromKbdb, ...fromLegacy];
return [...fromStore, ...legacy.filter((r) => !seen.has((r.values.email ?? '').toLowerCase()))];
}
return listKbdbRecordsByTemplate(env, template);
return listLegacyRecordsByTemplate(env, template);
}
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」走這條,那些本來就住知識庫)。 */
async function listKbdbRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
/** KBDB 原生的 by-template 查詢(portal_library 等「資料」走這條,那些本來就住知識庫)。 */
async function listLegacyRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template}${res.status}`);
@@ -383,44 +363,27 @@ interface CreateUserInput {
}
/**
* portal_user record namespace email head entrydesign §2.3
* `createPortalUser` `promoteToKbdb`
*/
async function createKbdbUserRecord(env: Bindings, email: string, values: Record<string, string>): Promise<string> {
const ns = portalNamespace(env);
const res = await kbdbFetch(env, '/records', {
method: 'POST',
body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { ...values, email } }),
});
if (!res.ok) throw new KbdbError(`POST /recordsportal_user)→ ${res.status}`);
const body = (await res.json()) as { record?: { record_id: string } };
const recordId = body.record?.record_id;
if (!recordId) throw new KbdbError('POST /records 回應缺 record_id');
// head entrypage_name=emailindexed)→ content=record_idO(1) 登入查找
const head = await kbdbFetch(env, '/entries', {
method: 'POST',
body: JSON.stringify({ entry_type: USER_TEMPLATE, page_name: email, content: recordId, owner_id: ns }),
});
if (!head.ok) throw new KbdbError(`head entry 建立失敗(record ${recordId} 已建,需人工收拾)→ ${head.status}`);
return recordId;
}
/**
* **2026-08-14 KBDB**binding base HTTP APID38 SQL
* `CF_SECRETS_API_TOKEN`D61
* **D61 Workers Secrets KBDB**
* AuthStoreWriteError 502退
* 退
*/
async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<string> {
const now = new Date().toISOString();
return createKbdbUserRecord(env, input.email.toLowerCase(), {
display_name: input.display_name,
status: 'active',
role: input.role,
password_hash: input.password_hash,
libraries: JSON.stringify(input.libraries),
created_at: now,
updated_at: now,
const id = newAuthUserId();
await mutateAuthStore(env, (data) => {
data.users.push({
id,
email: input.email.toLowerCase(),
display_name: input.display_name,
status: 'active',
role: input.role,
libraries: input.libraries,
password_hash: input.password_hash,
created_at: now,
updated_at: now,
});
});
return id;
}
// ── user 值域 helpers ──────────────────────────────────────────────────────
@@ -633,15 +596,15 @@ async function clearLoginFail(env: Bindings, email: string): Promise<void> {
}
/**
* KBDB /
* #10
* D61/
*
*/
async function instanceHasNoAuthData(env: Bindings): Promise<boolean> {
if (readAuthStore(env).users.length > 0) return false; // 舊家還有尚未搬遷的帳號
if (readAuthStore(env).users.length > 0) return false;
try {
return (await listKbdbRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
return (await listLegacyRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
} catch {
return true; // KBDB 讀不到 家空 = 這台實例確實沒有可用的登入資料
return true; // 舊家讀不到 家空 = 這台實例確實沒有可用的登入資料
}
}
@@ -692,7 +655,7 @@ portalRouter.post('/portal/login', (c) =>
const { recordId, rec, ok } = await findAndVerifyUser(c.env, email, password);
if (!recordId || !rec) {
// 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
// D61 明顯失敗(arcrun-rag#10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
// 「這台實例一個帳號都沒有」跟「你密碼打錯」是兩件事,不准混成同一句話——
// 2026-08-09 leo 就是被這個誤判鎖了 15 分鐘,而他的密碼從頭到尾都是對的。
// ⇒ 回一個**分得出來**的錯,而且**不計入鎖定**。
@@ -703,7 +666,7 @@ portalRouter.post('/portal/login', (c) =>
'這台實例讀不到任何登入資料——不是密碼錯。認證儲存是空的,' +
'請重新執行安裝/更新以重新建立管理員帳號。',
code: 'auth_store_empty',
auth_store: { home: 'kbdb', writable: true, users: 0 },
auth_store: authStoreStatus(c.env),
},
503,
);
@@ -719,20 +682,14 @@ portalRouter.post('/portal/login', (c) =>
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
// 自癒:這次是拿舊家(D61 認證儲存)的帳號登進來的 → 順手搬進 KBDB(新家)
// 且**這次登入發出的 session 就直接指向新 record_id**(搬遷成功的話)——不必等
// 下一次登入,密碼變更/admin 編輯這類寫入這次就已經走得到不需要外部 token 的 KBDB 路。
// 搬不動(KBDB 不可達)就沿用舊 record_id,讀路徑仍然通,只是寫入仍會走舊家那格。
let sessionRecordId = recordId;
if (isAuthStoreId(recordId)) {
const migrated = await promoteToKbdb(c.env, rec);
if (migrated) sessionRecordId = migrated;
}
// D61 自癒:這次是拿舊家(KBDB)的帳號登進來的 → 順手搬進認證儲存
// 下次換庫/換租戶代號就不會再把他鎖在門外。
if (!isAuthStoreId(recordId)) await promoteLegacyUser(c.env, rec);
await clearLoginFail(c.env, email);
const token = randomHex(32);
// session 值只存 record_iddesign §4.3)——權限/狀態每請求回讀 record,不快取進 session
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: sessionRecordId }), {
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: recordId }), {
expirationTtl: sessionTtl(c.env),
});
return c.json({
@@ -1116,8 +1073,6 @@ portalRouter.post('/portal/admin/bootstrap', (c) =>
if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409);
// 2026-08-14 起:createPortalUser 寫 KBDBbinding),不再需要安裝精靈遞任何臨時
// CF tokenarcrun-rag#99 那個結構性缺口已隨儲存層搬回 binding 一併解掉)。
const recordId = await createPortalUser(c.env, {
email,
display_name: displayName,
@@ -1309,8 +1264,7 @@ function toPublicLibrary(rec: PortalRecord) {
// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。
// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。
// POST /portal/daemon/extract — 小幫手把「已轉成純文字的原稿」送上來,雲端用 Workers AI 萃成知識卡。
// body {page_name, text}(舊 daemon)或 {page_name, text, prompt}Arcrun#134 起:
// daemon 自帶提示詞,回應改回 {success, output} 原文)。認證=X-Arcrun-API-Key(見下)。
// body {email, password, page_name, text}。認證同 /portal/daemon/config(帳密)。
//
// 🔴 t181leo 08-04:「daemon 的 AI 改用 workers AI」,列為**最優先**——
// 「這是我的用戶最大障礙,造成首輪測試用戶的好評或惡評」):
@@ -1349,67 +1303,41 @@ portalRouter.post('/portal/daemon/extract', (c) =>
if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
const body = (await c.req.json().catch(() => null)) as
| { page_name?: string; text?: string; prompt?: string }
| { page_name?: string; text?: string }
| null;
const pageName = String(body?.page_name ?? '').trim();
const srcText = String(body?.text ?? '');
// 🔴 Arcrun#1342026-08-15):新版 daemon **自帶完整提示詞**`prompt` 欄位)。
//
// 為什麼:卡片格式契約原本存在兩份——daemon 端 gemmaPromptGo)與本端點(TS),
// 靠註解叮嚀「兩邊要一起改」同步。2026-08-15 daemon 端改成「LLM 只回 JSON 判斷、
// wikishape.go 機械組卡」(InkStoneCo#44 ④),本端點沒跟上 ⇒ 免金鑰預設路
// (=多數用戶實際走的路)繼續產舊格式卡——**叮嚀式同步實證失效,這張票就是證據**。
//
// ⇒ 格式契約唯一真相源改為 daemon(提示詞+JSON 解析+組卡同住 collector/
// 不可能再彼此漂移);本端點在 prompt 模式下只是「用實例自己的 env.AI 跑一段
// 生成」的執行器,**不認得也不需要認得卡的形狀**——與 cypher TS「只做 routing
// +提供能力、不長業務邏輯」的分工一致。
// 安全面不變:認證同上(租戶識別 key)、只生成、不寫資料、用戶自己的 AI 額度;
// `text` 本來就是任意內容,本欄位沒有放大任何攻擊面。
// 相容:舊 daemon 不帶 prompt ⇒ 走下方 legacy 路(行為與回應完全不變)。
const daemonPrompt = String(body?.prompt ?? '').trim();
if (!daemonPrompt && (!pageName || !srcText.trim()))
return c.json({ error: 'page_name 與 text 必填' }, 400);
if (!pageName || !srcText.trim()) return c.json({ error: 'page_name 與 text 必填' }, 400);
if (!c.env.AI) {
// 誠實失敗:不假裝成功,並指名這個部署缺什麼(禁假綠)
return c.json({ error: '這個部署沒有綁定 Workers AIwrangler.toml 需有 [ai] binding),請更新知識庫版本' }, 501);
}
// legacy 提示詞:只服務**還沒更新的舊 daemon**(無 prompt 欄位)。舊 daemon 期待
// 「# <頁名>」開頭的 markdown 卡(收端 lint 新舊雙軌仍接受);新格式不再從這裡出生,
// 所以本段刻意凍結、不再演進——要改卡的形狀,去 daemon 端 collector/ 改那唯一一份。
// 提示詞與 daemon 端 gemmaPrompt 同一份契約(第一行必須是「# <頁名>」),
// 兩邊要一起改;daemon 端在 collector/extract_gemma.go。
// 註:關聯段用的是「知識卡三元組」格式(主詞/謂詞/受詞),與 Arcrun 工作流的邊無關。
const REL = '>'.repeat(2);
const prompt =
daemonPrompt ||
`把以下原稿重寫成定稿知識卡(正體中文)。直接輸出卡片本身:第一行必須是「# ${pageName}」,` +
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
`## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B3-8 行,用上面實體名)\n\n原稿:\n${srcText}`;
`不要任何前言、思考過程、英文草稿或說明。格式:\n# ${pageName}\n## 一句話定義\n(一行)\n` +
`## 要點\n- (3-12 條,具體、含數字條件)\n## 關鍵實體\n- **實體名** — 一句說明\n` +
`## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B3-8 行,用上面實體名)\n\n原稿:\n${srcText}`;
try {
// 模型與 workers_ai_chat recipe 同一支(選型實測見 api-recipe-seeds.ts:140
// llama-4-scout 2373ms/答案最完整;對照 Gemini gemma-4-31b-it 16.87 秒且吐英文草稿)。
// prompt 模式放寬 max_tokens 到 8192:對齊 gemma 路的 generationConfig——
// JSON 契約要裝下「文件總覽+N 個原子概念」,2048 會把 JSON 攔腰截斷(解析必失敗)。
const out = (await c.env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', {
messages: [{ role: 'user', content: prompt }],
max_tokens: daemonPrompt ? 8192 : 2048,
max_tokens: 2048,
temperature: 0.2,
} as never)) as { response?: string } | undefined;
const raw = String(out?.response ?? '').trim();
if (!raw) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
if (daemonPrompt) {
// 原文回傳(`output` 欄位):淨化與解析屬於契約,契約住 daemon ⇒ 這裡一個字都不動。
// daemon 以「回應裡有沒有 output」分辨雲端新舊:舊雲端只會回 `card`(見下),
// 新 daemon 據此 fallback 到 legacy 卡處理——兩個方向的版本歪斜都有路可走。
return c.json({ success: true, output: raw });
}
const card = String(out?.response ?? '').trim();
if (!card) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
// 淨化:模型偶爾在卡片前多帶一段前言 ⇒ 取最後一個「# <頁名>」起(同 daemon cleanGemmaCard
const marker = `# ${pageName}`;
const idx = raw.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? raw.slice(idx) : raw).trim() + '\n' });
const idx = card.lastIndexOf(marker);
return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + '\n' });
} catch (e) {
return c.json({ error: `Workers AI 執行失敗:${e instanceof Error ? e.message : String(e)}` }, 502);
}
@@ -1,21 +1,17 @@
/**
* console-auth.ts D61 SESSIONS_KV
* console-auth.ts D61 SESSIONS_KV
*
* 2026-08-14 D61 leo confirmCconsole SESSIONS_KVbinding
* D61 CF Workers Secrets
* CF_SECRETS_API_TOKEN
* SESSIONS_KVbest-effortbinding put
*
* portal-auth-store.ts
* worker console-auth.test.ts
* SESSIONS_KV
* portal-auth-store.ts per-isolate overlay
* console overlay.console ****
* worker
* tests/console-auth.test.ts
* KV
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { mutateAuthStore } from '../src/lib/portal-auth-store';
import type { Bindings } from '../src/types';
const CF_API = 'https://api.cloudflare.com';
const CREDS_KEY = 'console:credentials';
beforeAll(() => {
fetchMock.activate();
@@ -31,12 +27,18 @@ function json(method: string, path: string, body?: unknown) {
});
}
/** 種一筆進 D61 認證儲存(CF Workers Secrets)——直接呼叫 lib,不經任何 HTTP route。 */
function mockLegacySecretsWrite(): void {
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
const captured: Array<{ name: string; text: string }> = [];
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.reply(200, { success: true });
.reply(200, (opts) => {
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
captured.push(body);
return { success: true };
})
.times(times);
return { puts: () => captured };
}
/** console-auth.ts export sha256(salt+password) 3
@@ -55,45 +57,45 @@ const EMAIL = 'legacy-owner@example.com';
const PASSWORD = 'legacy-owner-pw-1';
const SALT = 'deadbeef00112233';
// 🔴 全部收在**同一個 `it()`** 裡(不拆成多則):`isolatedStorage`vitest-pool-workers 預設開)
// 在每一個 `it()` 前重置 SESSIONS_KV,但 D61 認證儲存的模組級記憶體變數 `overlay` 不受影響
// (見 lib/portal-auth-store.ts 檔頭)。若拆成多則,「搬回 SESSIONS_KV 後再打一次直接命中
// 新家」這一步在下一個 `it()` 會因為 KV 被重置而又落回 legacy-secrets 分支,驗不出「新家優先」
// 這件事——所以要在同一次測試、同一份 KV 狀態內連續打兩次才驗得出來。
describe('舊實例相容:console 帳密只在認證儲存(尚未搬回 SESSIONS_KV', () => {
it('GET /console/auth-status 讀到認證儲存這筆、順手搬回 SESSIONS_KV;再打一次直接命中新家;帳密登得進去', async () => {
describe('D61 舊實例相容:console 帳密只在舊 KV(尚未搬遷)', () => {
it('GET /console/auth-status:讀到舊 KV 這筆、順手搬進認證儲存', async () => {
const hash = await legacyHash(PASSWORD, SALT);
mockLegacySecretsWrite();
await mutateAuthStore(env as unknown as Bindings, (data) => {
data.console = { email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' };
});
await env.SESSIONS_KV.put(
CREDS_KEY,
JSON.stringify({ email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' }),
);
const { puts } = mockAuthStoreWrite();
const res = await json('GET', '/console/auth-status');
expect(res.status).toBe(200);
const data = (await res.json()) as {
configured: boolean;
credentials_source: string;
auth_store: { legacy_secrets_present: boolean };
auth_store: { console_configured: boolean };
};
expect(data.configured).toBe(true);
expect(data.credentials_source).toBe('legacy-secrets'); // 這次是靠回退讀到的
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成
expect(data.auth_store.legacy_secrets_present).toBe(true);
expect(data.credentials_source).toBe('legacy-kv'); // 這次是靠回退讀到的
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成
// 故 authStoreStatus 已經反映搬遷後的狀態
expect(data.auth_store.console_configured).toBe(true);
const kvRaw = await env.SESSIONS_KV.get('console:credentials');
expect(kvRaw).toBeTruthy();
const stored = JSON.parse(kvRaw!) as { email: string; hash: string };
expect(stored.email).toBe(EMAIL);
expect(stored.hash).toBe(hash); // 原樣搬過去,不重新雜湊
const shards = puts();
expect(shards.length).toBe(1);
const shard = JSON.parse(shards[0].text) as { console: { email: string; hash: string } };
expect(shard.console.email).toBe(EMAIL);
expect(shard.console.hash).toBe(hash); // 原樣搬過去,不重新雜湊
});
// 搬回後再打一次:SESSIONS_KV 已經有了,直接命中新家(不用再查認證儲存)
const res2 = await json('GET', '/console/auth-status');
const data2 = (await res2.json()) as { credentials_source: string };
expect(data2.credentials_source).toBe('kv');
it('搬遷後再打一次:新家已經有了,直接命中新家(不用再查舊 KV)', async () => {
const res = await json('GET', '/console/auth-status');
const data = (await res.json()) as { credentials_source: string };
expect(data.credentials_source).toBe('secrets');
});
// 用搬回去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)
const login = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
expect(login.status).toBe(200);
expect((await login.json() as { success: boolean }).success).toBe(true);
it('用搬遷過去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)', async () => {
const res = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(true);
});
});
+82 -63
View File
@@ -1,64 +1,81 @@
/**
* console-auth.ts 2026-08-14 console SESSIONS_KV
* D61 pending-changes.md D1/KVleo confirmC
* console-auth.ts D61console ADR D61 / Leo/arcrun-rag#55
*
* /console/setup/console/login D61ADR D61 / Leo/arcrun-rag#55
* CF Workers Secrets binding
* Workers Secrets `CF_SECRETS_API_TOKEN` token
* arcrun-rag#99
* `shared/resource-rule`Arcrun#97 SESSIONS_KVbinding token
* 退 tests/console-auth-legacy.test.ts
* /console/setup/console/login SESSIONS_KV `console:credentials`
* TTLKV binding KV
* console-auth.ts KV
* D61 CF Workers SecretsSESSIONS_KV 退
*
*
* D61
* 1. auth-status configured:falselogin
* 2. POST /console/setup SESSIONS_KV
* 3. 409
* 2. POST /console/setup CF Workers Secrets KV
* 3. 409D61
*
* 4. 200 401
* 5. /console/setup/reset SESSIONS_KV
* 5. /console/setup/reset
*
* CF API mockSESSIONS_KV bindingwrangler.test.toml mock KV
* `https://api.cloudflare.com/.../secrets`PUT fetchMock host
* portal-auth.test.ts mockAuthStoreWritewrangler.test.toml
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID
*
* 🔴 `@cloudflare/vitest-pool-workers`
* `isolatedStorage`** `it()`** KV/D1 storage bindings
* SESSIONS_KV
* POST /console/setup 沿 `it()`
* D61 `overlay` SESSIONS_KV
* portal-auth-store.ts per-isolate overlay
* /console/setup reset overlay.console ****
* KV/D1 storage
* isolatedStorage
* /console/setup
* KV overlay
* tests/console-auth-legacy.test.ts worker
*/
import { SELF, env } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
function json(method: string, path: string, body?: unknown) {
const CF_API = 'https://api.cloudflare.com';
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
return SELF.fetch(`http://localhost${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
/** 每則測試自建一組帳密(POST /console/setup),回傳供後續斷言使用。 */
async function setupOwner(email: string, password: string): Promise<void> {
const res = await json('POST', '/console/setup', { email, password });
expect(res.status).toBe(200);
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,那邊有完整說明)。 */
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
const captured: Array<{ name: string; text: string }> = [];
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.reply(200, (opts) => {
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
captured.push(body);
return { success: true };
})
.times(times);
return { puts: () => captured };
}
const OWNER_EMAIL = 'owner@example.com';
const OWNER_PW = 'owner-first-pw-1';
// ═══════════════ 1. 全新實例(尚未設定過任何管理員帳密)═══════════════
// ═══════════════ 1. 全新實例(尚未設定過,必須排最前面)═══════════════
describe('全新實例(尚未設定過任何管理員帳密)', () => {
it('GET /console/auth-status → configured:false,不洩漏 email', async () => {
const res = await json('GET', '/console/auth-status');
expect(res.status).toBe(200);
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { home: string } };
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { present: boolean } };
expect(data.configured).toBe(false);
expect(data.credentials_source).toBe('none');
expect(data.auth_store.home).toBe('sessions-kv');
expect(JSON.stringify(data)).not.toContain('@'); // 不洩漏 email
});
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(明顯失敗)', async () => {
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(D61 明顯失敗)', async () => {
const res = await json('POST', '/console/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
expect(res.status).toBe(400);
const data = (await res.json()) as { code: string; error: string };
@@ -74,31 +91,36 @@ describe('全新實例(尚未設定過任何管理員帳密)', () => {
});
});
// ═══════════════ 2. 首次設定:成功寫進 SESSIONS_KV ═══════════════
// ═══════════════ 2. 首次設定:成功寫進認證儲存(D61 起唯一寫入路徑)═══════════════
describe('POST /console/setup — 首次設定', () => {
it('成功:寫進 SESSIONS_KV(binding,不需要任何外部 CF token),回 session_token', async () => {
it('成功:寫進認證儲存(不再寫 SESSIONS_KV),回 session_token', async () => {
const { puts } = mockAuthStoreWrite();
const res = await json('POST', '/console/setup', { email: OWNER_EMAIL.toUpperCase(), password: OWNER_PW });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; session_token: string; tenant: string };
expect(data.success).toBe(true);
expect(typeof data.session_token).toBe('string');
const raw = await env.SESSIONS_KV.get('console:credentials');
expect(raw).toBeTruthy();
expect(raw).not.toContain(OWNER_PW); // 明碼絕不落地
const stored = JSON.parse(raw!) as { email: string; salt: string; hash: string };
expect(stored.email).toBe(OWNER_EMAIL); // 存小寫
expect(typeof stored.salt).toBe('string');
expect(typeof stored.hash).toBe('string');
// 寫入認證儲存:一片、含小寫 email,明碼密碼絕不落地
const shards = puts();
expect(shards.length).toBe(1);
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
expect(shards[0].text).not.toContain(OWNER_PW);
const shard = JSON.parse(shards[0].text) as { console: { email: string; salt: string; hash: string } };
expect(shard.console.email).toBe(OWNER_EMAIL); // 存小寫
expect(typeof shard.console.salt).toBe('string');
expect(typeof shard.console.hash).toBe('string');
// D61:不再寫舊 KV——這是本次變更的核心(舊版寫 SESSIONS_KV,重裝就蒸發)
expect(await env.SESSIONS_KV.get('console:credentials')).toBeNull();
});
});
// ═══════════════ 3. 已設定過 → 409(明顯失敗:說得出「沒有被採用」)═══════════════
// ═══════════════ 3. 已設定過 → 409(D61 明顯失敗:說得出「沒有被採用」)═══════════════
describe('POST /console/setup — 已設定過(重複設定)', () => {
it('409,訊息明講「你剛才輸入的密碼沒有被採用」,不誤導成「設定成功」', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
const res = await json('POST', '/console/setup', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
expect(res.status).toBe(409);
const data = (await res.json()) as {
@@ -108,29 +130,22 @@ describe('POST /console/setup — 已設定過(重複設定)', () => {
expect(data.password_applied).toBe(false);
expect(data.error).toContain('沒有被採用');
expect(data.reset_path).toBe('/console/setup/reset');
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗,原帳密仍有效
const attackerLogin = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
expect(attackerLogin.status).toBe(401);
const ownerLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
expect(ownerLogin.status).toBe(200);
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗(下一個 describe 也會正面驗證原帳密仍有效)
});
it('GET /console/auth-status → configured:truecredentials_source:kv', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
it('GET /console/auth-status → configured:truecredentials_source:secrets(新家優先命中)', async () => {
const res = await json('GET', '/console/auth-status');
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { writable: boolean } };
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { console_configured: boolean } };
expect(data.configured).toBe(true);
expect(data.credentials_source).toBe('kv');
expect(data.auth_store.writable).toBe(true);
expect(data.credentials_source).toBe('secrets');
expect(data.auth_store.console_configured).toBe(true);
});
});
// ═══════════════ 4. 登入對錯 ═══════════════
// ═══════════════ 4. 登入對錯(用第 2 節設定的帳密)═══════════════
describe('POST /console/login', () => {
it('帳密正確 → 200,發 session token', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; session_token: string };
@@ -139,27 +154,30 @@ describe('POST /console/login', () => {
});
it('密碼錯 → 401', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: 'wrong-password-x' });
expect(res.status).toBe(401);
});
it('攻擊者在第 3 節試圖搶注的帳密登不進來(證明真的「沒有被採用」)', async () => {
const res = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
expect(res.status).toBe(401);
});
});
// ═══════════════ 5. /console/setup/reset:換密碼,寫進 SESSIONS_KV ═══════════════
// ═══════════════ 5. /console/setup/reset:換密碼,寫進新家 ═══════════════
describe('POST /console/setup/reset', () => {
const NEW_PW = 'brand-new-owner-pw-1';
it('舊密碼錯 → 401,不寫入', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
const res = await json('POST', '/console/setup/reset', {
current_password: 'still-wrong', email: OWNER_EMAIL, password: NEW_PW,
});
expect(res.status).toBe(401);
});
it('舊密碼對 → 200,新 hash 寫進 SESSIONS_KV;換完後舊密碼立即失效、新密碼生效', async () => {
await setupOwner(OWNER_EMAIL, OWNER_PW);
it('舊密碼對 → 200,新 hash 寫進新家;換完後舊密碼立即失效、新密碼生效', async () => {
const { puts } = mockAuthStoreWrite();
const res = await json('POST', '/console/setup/reset', {
current_password: OWNER_PW, email: OWNER_EMAIL, password: NEW_PW,
});
@@ -167,10 +185,11 @@ describe('POST /console/setup/reset', () => {
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(true);
const raw = await env.SESSIONS_KV.get('console:credentials');
expect(raw).not.toContain(NEW_PW); // 明碼不落地
const stored = JSON.parse(raw!) as { email: string };
expect(stored.email).toBe(OWNER_EMAIL);
const shards = puts();
expect(shards.length).toBe(1);
expect(shards[0].text).not.toContain(NEW_PW); // 明碼不落地
const shard = JSON.parse(shards[0].text) as { console: { email: string } };
expect(shard.console.email).toBe(OWNER_EMAIL);
// 舊密碼立即失效
const oldLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
@@ -0,0 +1,100 @@
/**
* POST /kbdb/records/backfill-library + GET .../status proxy Arcrun#87 2026-08-13
*
* kbdb/src/routes/records.ts
* backfillTripletLibraryTags record 'library' slot
* entries backfill-library metadata_json.library cypher proxy
* kbdb-records-patch-proxy.test.ts base
* /owner_id caller owner_id
* POST /kbdb/records
*
* IO KBDB kbdb-records-patch-proxy.test.ts
* 1. X-Arcrun-API-Key 401 KBDB
* 2. body library 400
* 3. owner_id 使 caller owner_id
* 4. GET statusowner_id query
*
* KBDB fetchMock hostdisableNetConnect
*/
import { SELF, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
describe('POST /kbdb/records/backfill-library — 租戶閘', () => {
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library: 'arcrun' }),
});
expect(res.status).toBe(401);
});
});
describe('POST /kbdb/records/backfill-library — 參數驗證', () => {
it('body 沒有 library → 400,不轉發', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ source_prefix: 'gitea:Leo/Arcrun@' }),
});
expect(res.status).toBe(400);
});
});
describe('POST /kbdb/records/backfill-library — 轉發', () => {
it('owner_id 一律用租戶身份覆蓋,即使 caller 自帶了別的 owner_id', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records/backfill-library',
method: 'POST',
body: JSON.stringify({
library: 'arcrun',
owner_id: 'leo', // 來自 X-Arcrun-API-Key,不是 body 裡的 'someone-else'
triplet_template: undefined,
source_prefix: 'gitea:Leo/Arcrun@',
limit: 200,
}),
})
.reply(200, { success: true, library: 'arcrun', scanned: 208, tagged: 200, remaining: 8, quota_limit: 2000, quota_used_today: 200, quota_exceeded: true });
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ library: 'arcrun', owner_id: 'someone-else', source_prefix: 'gitea:Leo/Arcrun@', limit: 200 }),
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; tagged: number; remaining: number };
expect(data.success).toBe(true);
expect(data.tagged).toBe(200);
expect(data.remaining).toBe(8);
});
});
describe('GET /kbdb/records/backfill-library/status — 租戶閘 + 轉發', () => {
it('無 X-Arcrun-API-Key → 401', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library/status?source_prefix=gitea:Leo/Arcrun@');
expect(res.status).toBe(401);
});
it('owner_id 強制用租戶身份,其餘 query 參數透傳', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/records/backfill-library/status?owner_id=leo&source_prefix=gitea%3ALeo%2FArcrun%40', method: 'GET' })
.reply(200, { success: true, pending: 8 });
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library/status?source_prefix=gitea:Leo/Arcrun@', {
headers: KEY,
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; pending: number };
expect(data.success).toBe(true);
expect(data.pending).toBe(8);
});
});
@@ -1,126 +0,0 @@
/**
* POST /kbdb/records `entry_ids` Arcrun#128
*
* kbdb createRecord `entry_ids`slot **** entry id
* proxy `values` values 400
* X-Arcrun-API-Key #129 wiki #130
* b6ef0f0 PATCH
*
* IO kbdb-records-patch-proxy.test.ts
* 1. X-Arcrun-API-Key 401 KBDB
* 2. entry_ids values 400
* 3. body entry_ids ** owner_id**caller owner_id
* 4. values entry_ids
* 5. 400
* 6. base 400
*
* KBDB fetchMock hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect
*/
import { SELF, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
describe('POST /kbdb/recordsentry_ids)— 租戶閘與參數', () => {
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1' } }),
});
expect(res.status).toBe(401);
});
it('values 與 entry_ids 都沒給 → 400,不轉發', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ template: 'wiki' }),
});
expect(res.status).toBe(400);
});
});
describe('POST /kbdb/recordsentry_ids)— 轉發', () => {
it('只給 entry_ids(沒有 values)→ 轉發,且 owner_id 由租戶身份注入', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records',
method: 'POST',
// 沒有 values 這個 key(不要憑空塞一個空物件給基本盤)
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'leo' }),
})
.reply(200, {
success: true,
record: { record_id: 'rec_1', template_id: 'tpl-wiki', values: { gloss: '既有', points: '既有2' }, owner_id: 'leo' },
});
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
// caller 自帶 owner_id:必須被忽略(防跨租戶寫入,本檔既有慣例)
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'alice' }),
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; record: { values: Record<string, string> } };
expect(data.success).toBe(true);
expect(data.record.values.gloss).toBe('既有');
});
it('values 與 entry_ids 混用 → 兩個都轉過去', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records',
method: 'POST',
body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' }, owner_id: 'leo' }),
})
.reply(200, { success: true, record: { record_id: 'rec_2', template_id: 'tpl-wiki', values: {}, owner_id: 'leo' } });
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' } }),
});
expect(res.status).toBe(200);
});
it('舊呼叫端(只給 values)→ 轉發的 body 不夾帶 entry_ids,行為與過去相同', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records',
method: 'POST',
body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' }, owner_id: 'leo' }),
})
.reply(200, { success: true, record: { record_id: 'rec_3', template_id: 'tpl-triplet', values: {}, owner_id: 'leo' } });
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' } }),
});
expect(res.status).toBe(200);
});
it('base 擋下跨租戶參照(400)→ 原樣透傳,不假裝成功', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/records', method: 'POST' })
.reply(400, { success: false, error: 'entry owner mismatch: e_x(alice) != leo' });
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_x' } }),
});
expect(res.status).toBe(400);
const data = (await res.json()) as { success: boolean; error: string };
expect(data.success).toBe(false);
expect(data.error).toContain('owner mismatch');
});
});
+32 -51
View File
@@ -16,20 +16,20 @@
* KBDB fetchMock hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnectUI worker curl PR
*
* 2026-08-14 D61 leo confirmC KBDBbinding
* pending-changes.md D1/KV fixture
* rec_admin/rec_u1/rec_admin2沿record_id auth: 開頭
* portal.ts isAuthStoreId(recordId) auth: 開頭的 id
* KBDB D61
* ****POST /portal/admin/usersPOST /portal/admin/bootstrap
* createPortalUser KBDBPOST /records + POST /entries head
* `https://api.cloudflare.com/.../secrets`
* D61ADR D61 / Leo/arcrun-rag#55 fixturerec_admin/rec_u1/rec_admin2
* 沿record_id auth: 開頭 portal.ts
* isAuthStoreId(recordId) auth: 開頭的 id KBDB D61
* ****POST /portal/admin/users
* POST /portal/admin/bootstrap createPortalUserCF Workers
* Secrets `https://api.cloudflare.com/.../secrets`PUT mockAuthStoreWrite
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
const KBDB = 'https://kbdb.test';
const CF_API = 'https://api.cloudflare.com';
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
let storedHash: string;
@@ -49,21 +49,19 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
});
}
/** 建帳號的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
function mockCreateUser(recordId: string): { recordBody: () => string } {
let recordBody = '';
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,見那邊檔頭的完整說明)。 */
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
const captured: Array<{ name: string; text: string }> = [];
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.reply(200, (opts) => {
recordBody = String(opts.body);
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
});
fetchMock
.get(KBDB)
.intercept({ path: '/entries', method: 'POST' })
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
return { recordBody: () => recordBody };
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
captured.push(body);
return { success: true };
})
.times(times);
return { puts: () => captured };
}
function mockHeadLookup(email: string, recordId: string | null) {
@@ -204,12 +202,11 @@ describe('last-admin 鎖死保護(PATCH /portal/admin/users/:id', () => {
// ═══════════════ 2. 一次性密碼(新增帳號)═══════════════
describe('POST /portal/admin/users(一次性密碼)', () => {
it('未帶 password → generated_password 回一次(16 碼);KBDB 落的是 hash 非明碼', async () => {
it('未帶 password → generated_password 回一次(16 碼);認證儲存落的是 hash 非明碼D61', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockHeadLookup('new@example.com', null); // email 未占用
const { recordBody } = mockCreateUser('rec_new');
mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀
mockHeadLookup('new@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
const { puts } = mockAuthStoreWrite();
const res = await json(
'POST',
'/portal/admin/users',
@@ -221,21 +218,23 @@ describe('POST /portal/admin/users(一次性密碼)', () => {
expect(typeof data.generated_password).toBe('string');
expect(data.generated_password!.length).toBe(16);
expect('password_hash' in data.user).toBe(false);
expect((data.user as { record_id: string }).record_id).toBe('rec_new'); // 住 KBDB
expect((data.user as { record_id: string }).record_id.startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家
// 一次性密碼不落地:KBDB 收到的 record body 只有 hash、無明碼
expect(recordBody()).not.toContain(data.generated_password!);
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
expect(rec.owner_id).toBe(NS);
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
// 一次性密碼不落地:認證儲存收到的 shard 只有 hash、無明碼
const shards = puts();
expect(shards.length).toBe(1);
expect(shards[0].text).not.toContain(data.generated_password!);
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
const stored = shard.users.find((u) => u.email === 'new@example.com');
expect(stored).toBeDefined();
expect(stored!.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
});
it('自帶 password → 回應**無** generated_password', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockHeadLookup('own@example.com', null);
mockCreateUser('rec_own');
mockGetRecord('rec_own', userValues({ email: 'own@example.com' }));
mockAuthStoreWrite();
const res = await json(
'POST',
'/portal/admin/users',
@@ -748,22 +747,4 @@ describe('POST /portal/daemon/extractt181Workers AI 萃卡,免金鑰)'
expect(raw).not.toContain('gemini_api_key');
expect(raw).not.toContain('credential');
});
// 🔴 Arcrun#134:新 daemon 自帶提示詞(卡片格式契約的唯一真相源在 daemon 端
// collector/——提示詞+JSON 解析+wikishape 組卡同住一處,才不會像 t181 那樣
// 兩份 prompt 靠註解叮嚀同步而漂移)。雲端在 prompt 模式下只是執行器。
//
// 測試環境刻意沒有 [ai] binding ⇒ 走到 AI 分支必回 501。這裡拿 501 當「驗證已通過、
// 已進入 AI 分支」的探針:若有人把 prompt 模式改回要求 page_name/text,這則會看到 400。
it('帶 prompt 時不再要求 page_name/text#134:契約住 daemon 端)', async () => {
const res = await json('POST', '/portal/daemon/extract', { prompt: '任意契約內容' }, KEY);
expect(res.status).toBe(501); // 不是 400 ⇒ prompt 單獨成立
});
// 相容守衛:舊 daemon(無 prompt 欄位)行為凍結——缺欄仍 400(上面那則),
// 齊欄則同樣走到 AI 分支(501 探針)。若這則變 400,代表 legacy 驗證被動到了。
it('legacy(無 prompt)齊欄仍走原路(相容不回退)', async () => {
const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' }, KEY);
expect(res.status).toBe(501);
});
});
+119 -191
View File
@@ -4,29 +4,31 @@
* =tasks.md P2
* 1. KDFpbkdf2-sha256$100000$ CF Workers runtime 100k2026-07-14
* false600k hash
* 2. bootstrap console session 401 admin **KBDB** admin 409
* 2. bootstrap console session 401 admin ****D61
* admin 409
* 3. token**** 401 403 email 401
* 4. 5 429KV TTL
* 5. session record session
* 6. hash 100k slot
* 7. role admin admin 403admin ** password_hash**
*
* 2026-08-14 D61 leo confirmCpending-changes.md
* D1/KVCF Workers SecretsD61 **KBDB**binding base
* HTTP API CF token
* D61ADR D61 / Leo/arcrun-rag#55
* 8.
* 9. D61 KBDB
* ** session record_id**KBDB
* 9. KBDB
*
* KBDB fetchMock hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect namespace email worker
* curl PR owner_id=leo::portal
*
* D61 KBDB CF Workers Secrets
* `https://api.cloudflare.com/.../secrets`PUT fetchMock host
* wrangler.test.toml CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds';
import { AUTH_ID_PREFIX, isAuthStoreId, mutateAuthStore } from '../src/lib/portal-auth-store';
import { AUTH_ID_PREFIX } from '../src/lib/portal-auth-store';
import { portalRouter } from '../src/routes/portal';
import type { Bindings, ExecutionContext } from '../src/types';
@@ -54,13 +56,38 @@ function json(method: string, path: string, body?: unknown, headers: Record<stri
});
}
/**
* D61
* CF Workers Scripts secrets API PUT body
* `puts()` {name, text}[]
*
* seed env`cloudflare:test` `env`
* `vitest` context `SELF.fetch()` worker isolate ****
* mutate `env.XXX` SELF wrangler.test.toml
* ** KBDB fetchMock**
* ****bootstrap/ portal-auth-store
* per-isolate overlay overlay \*\*
* test KV/D1 storage isolatedStorage
* ****
*/
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
const captured: Array<{ name: string; text: string }> = [];
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.reply(200, (opts) => {
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
captured.push(body);
return { success: true };
})
.times(times);
return { puts: () => captured };
}
// ── KBDB mock helpers ──────────────────────────────────────────────────────
/**
* head entry GET /entries?page_name=&entry_type=portal_user&owner_id=ns&limit=1
* `times``findUserRecordId``promoteToKbdb` email
*/
function mockHeadLookup(email: string, recordId: string | null, times = 1) {
/** head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1 */
function mockHeadLookup(email: string, recordId: string | null) {
const needle = new URLSearchParams({ page_name: email }).toString();
fetchMock
.get(KBDB)
@@ -69,61 +96,7 @@ function mockHeadLookup(email: string, recordId: string | null, times = 1) {
p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
method: 'GET',
})
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 })
.times(times);
}
/** 建 portal_user 的 KBDB 寫入路徑(POST /records + POST /entries head entry)。 */
function mockCreateUser(recordId: string): { recordBody: () => string } {
let recordBody = '';
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(200, (opts) => {
recordBody = String(opts.body);
return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
});
fetchMock
.get(KBDB)
.intercept({ path: '/entries', method: 'POST' })
.reply(200, { success: true, entry: { id: `${recordId}_head` } });
return { recordBody: () => recordBody };
}
/** POST /records 建立失敗(模擬 KBDB 拒寫,用於「搬遷失敗不擋登入」的測試)。 */
function mockCreateUserFails(status = 500) {
fetchMock
.get(KBDB)
.intercept({ path: '/records', method: 'POST' })
.reply(status, { success: false, error: 'kbdb write failed (test)' });
}
/**
* ****D61 CF Workers Secrets lib HTTP
* routeconsoleAuthRouter/portalRouter CF Secrets PUT
* wrangler.test.toml CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID
*/
async function seedLegacySecretsUser(overrides: Partial<{
email: string; display_name: string; status: string; role: string; libraries: string[]; password_hash: string;
}> = {}): Promise<void> {
fetchMock
.get(CF_API)
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
.reply(200, { success: true });
const now = new Date().toISOString();
await mutateAuthStore(env as unknown as Bindings, (data) => {
data.users.push({
id: `auth:${crypto.randomUUID().replace(/-/g, '')}`,
email: (overrides.email ?? EMAIL).toLowerCase(),
display_name: overrides.display_name ?? '舊實例同仁',
status: overrides.status ?? 'active',
role: overrides.role ?? 'user',
libraries: overrides.libraries ?? ['general'],
password_hash: overrides.password_hash ?? storedHash,
created_at: now,
updated_at: now,
});
});
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 });
}
function mockGetRecord(recordId: string, values: Record<string, string>) {
@@ -203,20 +176,20 @@ describe('PBKDF2 模組(lib/portal-auth', () => {
// ═══════════════ 1.5 D61:整台實例沒有任何認證資料 ═══════════════
//
// 🔴 這個 describe 必須留在檔案裡「第一個會入認證儲存(舊家)的測試」之前——見
// seedLegacySecretsUser 檔頭註解:portal-auth-store.ts 的 per-isolate overlay 是模組級全域
// 變數,同一支測試檔案跑起來不會在測試之間重置,一旦有測試種過舊家資料,後面的測試都會看到
// 那筆資料,「乾淨無帳號」的前提就不成立了。
describe('整台實例沒有任何認證資料(arcrun-rag#55leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
// 🔴 這個 describe 必須留在檔案裡「第一個會入認證儲存的測試」之前(下面 2. bootstrap
// 的「console session OK」那則)——見 mockAuthStoreWrite 檔頭註解:portal-auth-store.ts
// 的 per-isolate overlay 是模組級全域變數,同一支測試檔案跑起來不會在測試之間重置,
// 一旦有測試寫入過,後面的測試都會看到那筆資料,「乾淨無帳號」的前提就不成立了。
describe('D61整台實例沒有任何認證資料(arcrun-rag#55leo 2026-08-09 被誤鎖 15 分鐘的事故)', () => {
it('登入回「讀不到認證資料」而不是「密碼錯誤」,且不計入失敗鎖定', async () => {
// KBDB(新家)此刻回空——head lookup 查無此人+by-template 列表也空
// 認證儲存(舊家)此刻也還是空的(本測試特意排在任何種子測試之前)——兩邊都沒有帳號,
// 新家(overlay/env bag)此刻還是空的(本測試特意排在任何寫入測試之前)
// 舊家(KBDB)也回空——head lookup 查無此人+by-template 列表也空,兩邊都沒有帳號,
// 才是「這台實例真的沒有認證資料」。
mockHeadLookup('anyone@example.com', null);
mockListByTemplate('portal_user', []);
const res = await json('POST', '/portal/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
expect(res.status).toBe(503);
const data = (await res.json()) as { error: string; code: string; auth_store: { users: number } };
const data = (await res.json()) as { error: string; code: string; auth_store: { present: boolean; users: number } };
expect(data.code).toBe('auth_store_empty');
// 分得出來的錯:這句要誠實講「不是密碼錯」,而且**不能**是密碼錯誤那句通用訊息
// (文案含混是 leo 被鎖 15 分鐘的根因——他的密碼從頭到尾是對的)。
@@ -236,12 +209,12 @@ describe('POST /portal/admin/bootstrap', () => {
expect(res.status).toBe(401);
});
it('console session OK → 建第一個 admin:寫進 KBDB,不需要任何外部 CF token', async () => {
it('console session OK → 建第一個 admin:寫進認證儲存(D61,不再落 KBDB', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
mockTemplatesExist();
mockListByTemplate('portal_user', []); // 尚無 adminKBDB 空,認證儲存舊家也空)
mockHeadLookup('admin@example.com', null); // email 未占用
const { recordBody } = mockCreateUser('rec_admin_new');
mockListByTemplate('portal_user', []); // 尚無 admin新家空,舊家也空)
mockHeadLookup('admin@example.com', null); // email 未占用(新家找不到 → 回退查舊家)
const { puts } = mockAuthStoreWrite();
const res = await json(
'POST',
@@ -252,18 +225,25 @@ describe('POST /portal/admin/bootstrap', () => {
expect(res.status).toBe(200);
const data = (await res.json()) as Record<string, unknown>;
expect(data.success).toBe(true);
expect(data.record_id).toBe('rec_admin_new'); // 住 KBDB(新 record_id 來自 KBDB 回應)
expect(typeof data.record_id).toBe('string');
expect((data.record_id as string).startsWith(AUTH_ID_PREFIX)).toBe(true); // 住新家(D61
expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1
// 一次 POST /records=一筆,body 含 owner_id 子 namespace,明碼絕不落地
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
expect(rec.owner_id).toBe(NS);
expect(rec.values.email).toBe('admin@example.com');
expect(rec.values.role).toBe('admin');
expect(rec.values.status).toBe('active');
expect(JSON.parse(rec.values.libraries)).toEqual(['*']);
expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
expect(recordBody()).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
// D61:一次寫入=一片,落進認證儲存(Workers Secrets),不再有 KBDB record/head entry
const shards = puts();
expect(shards.length).toBe(1);
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
const shard = JSON.parse(shards[0].text) as {
users: Array<{ email: string; role: string; status: string; libraries: string[]; password_hash: string }>;
};
expect(shard.users.length).toBe(1);
const stored = shard.users[0];
expect(stored.email).toBe('admin@example.com');
expect(stored.role).toBe('admin');
expect(stored.status).toBe('active');
expect(stored.libraries).toEqual(['*']);
expect(stored.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
expect(shards[0].text).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
});
it('已有 admin → 409 拒絕重複 bootstrap', async () => {
@@ -283,10 +263,12 @@ describe('POST /portal/admin/bootstrap', () => {
// ═══════════════ 3. 登入對錯 ═══════════════
describe('POST /portal/login', () => {
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組 **KBDB 原生** fixture(非 auth: 開頭 id)——
// 2026-08-14 起 KBDB 是新家,這批帳號本來就住在該住的地方,**不會**觸發任何搬遷嘗試
// isAuthStoreId('rec_1') 為 false)。搬遷本身的驗證另開一組使用**專屬、不共用**email
// 的 describe(見檔案最後「舊實例登入自癒」),避免污染這裡的既有 fixture
// 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組舊家 fixture(原本就是),**故意不**在這裡驗證
// 「登入成功後搬進新家」——promoteLegacyUser 一旦真的寫成功,會把 EMAIL 留進 overlay
// 而 overlay 是模組級全域、同檔案後面的測試都讀得到,會讓後面每一則「查 KBDB 的 EMAIL」
// 全部改成「命中新家」而跳過 KBDB mock,導致假性的 pending-interceptor 骨牌
// 搬遷本身的驗證另開一組使用**專屬、不共用**email 的 describe(見檔案最後
// 「D61:舊實例登入自癒」),避免污染這裡的既有 fixture。
it('成功:發 session token;回 display_name/role/libraries**無任何租戶字串欄位**', async () => {
mockHeadLookup(EMAIL, 'rec_1');
mockGetRecord('rec_1', activeUserValues());
@@ -304,6 +286,11 @@ describe('POST /portal/login', () => {
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
expect(sess).toBeTruthy();
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_1'); // 只存 record_id
// D61promoteLegacyUser 的實際寫入嘗試沒有掛 CF API mockdisableNetConnect 之下
// 該次 fetch 會失敗,但函式本身 best-effort 吞掉(見 portal.ts promoteLegacyUser 的
// try/catch)——這正是要驗的事:搬不動不影響本次登入已經成功這件事實(上面兩個
// expect 已經成立)。afterEach 的 assertNoPendingInterceptors 只檢查「有登記但沒用到」
// 的 mock,一次沒登記過 mock 的失敗呼叫不算數,故這裡不需要(也不能)額外掛 CF API mock。
});
it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => {
@@ -318,10 +305,6 @@ describe('POST /portal/login', () => {
it('未知 email → 401 同樣通用訊息(不洩帳號存在性)', async () => {
mockHeadLookup('ghost@example.com', null);
// instanceHasNoAuthData 在「查無此帳號」時會確認「是不是整台實例都沒帳號」——
// 這裡要證明的是「查無此人」而非「這台實例是空的」,故 by-template 要回非空列表
// (這台實例確實有別的帳號,只是不是 ghost@example.com)。
mockListByTemplate('portal_user', [{ record_id: 'rec_1', values: activeUserValues() }]);
const res = await json('POST', '/portal/login', { email: 'ghost@example.com', password: 'whatever-123' });
expect(res.status).toBe(401);
const data = (await res.json()) as { error: string };
@@ -546,52 +529,57 @@ describe('t130 — triplet template seedPORTAL_TEMPLATE_SEEDS 補 triplete
});
});
// ═══════════════ 舊實例登入自癒(認證儲存舊家 → 搬進 KBDB 新家)═══════════════
// ═══════════════ D61舊實例登入自癒(搬進新家)═══════════════
//
// 🔴 放在檔案最後、用**專屬 email**(不與上面任何一則共用):portal-auth-store.ts 的
// per-isolate overlay 是模組級全域變數,seedLegacySecretsUser 種一次資料就會留在同一支測試
// 檔案的後續測試裡(見該函式檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
// per-isolate overlay 是模組級全域變數,寫入一旦成功就會留在同一支測試檔案的後續測試裡
// (見 mockAuthStoreWrite 檔頭的長註解)。這裡就是要驗證那次「留下」,所以刻意隔離在最後,
// 不會有更後面的測試共用這個 email 而被污染。
describe('舊實例登入自癒(帳號只在認證儲存舊家,登入成功後搬進 KBDB 新家', () => {
describe('D61舊實例登入自癒(帳號只在 KBDB,登入成功後 best-effort 搬進認證儲存)', () => {
const LEGACY_EMAIL = 'legacy-promote@example.com';
it('登入成功;自動把這筆帳號寫進 KBDB(含正確 email/hash),且**這次的 session 就指向新 record_id**', async () => {
await seedLegacySecretsUser({ email: LEGACY_EMAIL });
// KBDB 查無此人:一次給 findUserRecordId(登入查找)、一次給 promoteToKbdb(搬遷前的
// 「已經搬過了嗎」確認)——兩次都要 mock,見 mockHeadLookup 的 times 參數說明。
mockHeadLookup(LEGACY_EMAIL, null, 2);
const { recordBody } = mockCreateUser('rec_legacy_promoted_1');
it('登入成功;promoteLegacyUser 把這筆帳號寫進認證儲存(一片、含正確 email/hash)', async () => {
mockHeadLookup(LEGACY_EMAIL, 'rec_legacy_1');
mockGetRecord('rec_legacy_1', activeUserValues({ email: LEGACY_EMAIL }));
const { puts } = mockAuthStoreWrite();
const res = await json('POST', '/portal/login', { email: LEGACY_EMAIL, password: PASSWORD });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; session_token: string };
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(true);
const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
expect(rec.owner_id).toBe(NS);
expect(rec.values.email).toBe(LEGACY_EMAIL);
expect(rec.values.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
// 這次登入發出的 session 已經指向新 record_id(不必等下一次登入才生效)
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_legacy_promoted_1');
const shards = puts();
expect(shards.length).toBe(1);
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
const shard = JSON.parse(shards[0].text) as { users: Array<{ email: string; password_hash: string }> };
const promoted = shard.users.find((u) => u.email === LEGACY_EMAIL);
expect(promoted).toBeDefined();
expect(promoted!.password_hash).toBe(storedHash); // 原樣搬過去,不重新雜湊
});
it('KBDB 拒寫(搬遷失敗)——照樣登入成功,session 沿用舊家 record_id搬不動不擋門', async () => {
const email = 'legacy-promote-writefail@example.com';
await seedLegacySecretsUser({ email });
mockHeadLookup(email, null, 2); // 登入查找 + 搬遷前確認,KBDB 兩次都查無此人
mockCreateUserFails(500); // 搬遷寫入本身失敗(模擬 KBDB 不可達/拒寫)
const res = await json('POST', '/portal/login', { email, password: PASSWORD });
it('若新家寫入路徑未就緒(缺 CF_SECRETS_API_TOKEN),照樣登入成功——搬不動不擋門', async () => {
// 直接呼叫 router、帶一份缺寫入路徑的 envhealth.test.ts 已有的直呼叫慣例),
// 證明 promoteLegacyUser 的失敗被 best-effort 吞掉,不影響登入本身。
const email = 'legacy-promote-writeless@example.com';
mockHeadLookup(email, 'rec_legacy_2');
mockGetRecord('rec_legacy_2', activeUserValues({ email }));
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
const res = await portalRouter.fetch(
new Request('http://localhost/portal/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: PASSWORD }),
}),
fakeEnv,
{} as ExecutionContext,
);
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; session_token: string };
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(true);
// 搬不動 → session 沿用舊家(認證儲存)的 record_id"auth:" 開頭),讀路徑仍然通
const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
const sessRecordId = (JSON.parse(sess!) as { record_id: string }).record_id;
expect(isAuthStoreId(sessRecordId)).toBe(true);
// 沒掛 CF API mock:若程式碼真的嘗試網呼叫且被 disableNetConnect 擋下,錯誤仍會被
// best-effort 吞掉(不影響上面的 200 斷言);若程式碼正確地在 authStoreWritable() 檢查
// 就提前短路,則根本不會嘗試呼叫——兩種情況這裡都驗不出差異,差異由 afterEach 的
// assertNoPendingInterceptors 間接把關(沒有殘留 mock 代表沒有意外多打的請求)。
});
});
@@ -599,7 +587,7 @@ describe('舊實例登入自癒(帳號只在認證儲存舊家,登入成功
//
// ⚠️ 順序刻意:這兩個 describe 放在檔案最後,而且「D62」在前、「#66」在後。
// 原因=#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**(模擬傳播空窗),
// 而 overlay 是模組級全域變數、不隨 test 重置(見 seedLegacySecretsUser 檔頭長註解)。
// 而 overlay 是模組級全域變數、不隨 test 重置(見 mockAuthStoreWrite 檔頭長註解)。
// 任何需要「認證儲存裡有帳號」的測試都不能排在它後面。
describe('D62:改密碼與忘記密碼是同一個機制(同一支端點、同一條寫入路徑)', () => {
@@ -694,63 +682,3 @@ describe('arcrun-rag#66:傳播空窗期不可以銷毀 session', () => {
expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
});
});
// ═══════════════ arcrun-rag#992026-08-14):全新安裝從沒種過 CF_SECRETS_API_TOKEN ═══════════════
//
// 每一台裝好的新實例過去都會在 bootstrap 這裡卡死(leo 本人+封測者都撞到「裝得起來,
// 但卡在註冊」——`/console/auth-status` 永遠回 `writable:false`)。止血版(安裝精靈遞一把
// 臨時 OAuth token)只解掉「建第一個帳號」這一格;2026-08-14 confirm 走 C 之後,帳號改回
// 住 KBDBbinding),**從根拔掉整個問題**`x-arcrun-install-token` 這條路已經不存在了,
// 因為建帳號這件事本來就不再需要任何 CF API 憑證,無論裝機當下還是裝完之後都一樣。
//
// 下面這則是回歸守衛:**一台連 `CF_SECRETS_API_TOKEN``CF_ACCOUNT_ID` 都沒有的全新實例**
// 從 bootstrap 建第一個帳號、到之後用 /portal/admin/users 加第二個帳號,全程都要成功——
// 若哪天有人把帳號寫入路徑又改回去揹 CF Secrets 依賴,這裡會紅。
describe('arcrun-rag#99:全新實例(連 CF_SECRETS_API_TOKENCF_ACCOUNT_ID 都沒有)不再卡死', () => {
it('bootstrap 建第一個 admin、再用 admin session 加第二個 user,全程零 CF token', async () => {
await env.SESSIONS_KV.put('console_sess:owner-token-fresh', JSON.stringify({ created_at: Date.now() }));
mockTemplatesExist();
mockListByTemplate('portal_user', []); // 尚無 admin
mockHeadLookup('fresh-install-admin@example.com', null);
const { recordBody: bootstrapBody } = mockCreateUser('rec_fresh_admin');
const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
const bootstrapRes = await portalRouter.fetch(
new Request('http://localhost/portal/admin/bootstrap', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner-token-fresh' },
body: JSON.stringify({ email: 'fresh-install-admin@example.com', password: 'bootstrap-pw-3' }),
}),
fakeEnv,
{} as ExecutionContext,
);
expect(bootstrapRes.status).toBe(200);
const bootstrapData = (await bootstrapRes.json()) as Record<string, unknown>;
expect(bootstrapData.success).toBe(true);
expect(bootstrapData.record_id).toBe('rec_fresh_admin');
expect(JSON.parse(bootstrapBody()).values.password_hash.startsWith('pbkdf2-sha256$')).toBe(true);
expect(bootstrapBody()).not.toContain('bootstrap-pw-3'); // 明碼絕不落地
// 安裝精靈離場之後:管理員事後手動加第二個人(POST /portal/admin/users),
// 這條路本來就沒帶過任何臨時 token,過去在 D61 底下永遠 502——現在也要成功。
await env.SESSIONS_KV.put('portal_sess:tok-fresh-admin', JSON.stringify({ record_id: 'rec_fresh_admin' }));
mockGetRecord('rec_fresh_admin', activeUserValues({ email: 'fresh-install-admin@example.com', role: 'admin' }));
mockHeadLookup('second-user@example.com', null);
const { recordBody: secondBody } = mockCreateUser('rec_fresh_second');
mockGetRecord('rec_fresh_second', activeUserValues({ email: 'second-user@example.com' }));
const addUserRes = await portalRouter.fetch(
new Request('http://localhost/portal/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer tok-fresh-admin' },
body: JSON.stringify({ email: 'second-user@example.com', password: 'second-user-pw-1' }),
}),
fakeEnv,
{} as ExecutionContext,
);
expect(addUserRes.status).toBe(200);
const addUserData = (await addUserRes.json()) as { success: boolean };
expect(addUserData.success).toBe(true);
expect(JSON.parse(secondBody()).owner_id).toBe(NS);
});
});
-58
View File
@@ -1,58 +0,0 @@
# 00-INDEXarcrun/docs
> 機器整理的 wiki。**原稿在上一層**,這裡不放原文。有爭議時以原稿為準。
## 子節點
- **user_requirements**`../user_requirements/.wiki/00-INDEX.md`
主題:u6u 四層架構、credential 三層模型|概念:Polaris 意圖層、零件宇宙、AuthBroker…
`#規格 #架構 #credential` 4 篇(含 1 篇指針) 更 2026-08-15
## 文件(本層每一篇一張)
- [[HANDOFF-config-scope-and-vectorize]] — 一份交辦文件,指出 arcrun 設定分層的兩條缺口,萃出兩個概念。
`#文件 #設定` 建 2026-06-15 更 2026-08-15
- [[HANDOFF-matrix-rearrange]] — Matrix 重整交給 arcrun 的待辦清單,用指針式考古不複製素材。
`#文件 #遷移` 建 2026-06-13 更 2026-08-15
- [[HANDOFF-mira-repair]] — 一份修復交辦,把調查做完了:哪些不用改、哪些要改、哪些要降級。
`#文件 #事故` 建 2026-06-03 更 2026-08-15
- [[HANDOFF-self-host-harness]] — 一份把「今天要做的三件事」連同已查證實況整理好的交辦,核心是戰法轉向。
`#文件 #策略` 建 2026-06-01 更 2026-08-15
- [[component-pr-review-standard]] — 零件與 binding 的 PR 審核規範,五段 checklist,任一否就打回。
`#文件 #流程` 建 2026-06-03 更 2026-08-15
## 主題(跨文件聚類產生)
- [[結構性閘]] — 把錯誤路徑做成「根本碰不到」,而不是叮嚀人不要走。
`#方法論 #流程 #架構` 建 2026-06-03 更 2026-08-15
## 本層概念(9
- [[AI 讀不到的隱形分層]] — arcrun 是給 AI 操作的工具,而 harness 說明 41 行對 scope 零字提及。
`#設定 #AI 協作` 建 2026-06-15 更 2026-08-15
- [[Mira workflow 斷鏈]] — Mira 六個 workflow 全斷,但真因不是 arcrun 壞了,是 Mira 當初把東西錯做成假零件。
`#事故 #零件` 建 2026-06-03 更 2026-08-15
- [[cypher 雙份分岔]] — matrix 與 arcrun 各有一份 cypher-executor 且已分岔——整合後只留 arcrun 一份。
`#架構 #遷移` 建 2026-06-13 更 2026-08-15
- [[scope 誤部風險]] — 部署到哪個 CF 帳號,取決於你當下站在哪個資料夾——而這件事沒有任何地方寫著。
`#設定 #風險` 建 2026-06-15 更 2026-08-15
- [[self-hosted 戰法轉向]] — 2026-06-01 從 SaaS 改成 self-hosted 開源——它改的不只是商業模式,是「成功」的定義。
`#策略 #部署` 建 2026-06-01 更 2026-08-15
- [[假零件降級]] — 打固定 endpoint 的東西不是零件是 recipe——33 顆降到 22 顆,代價是所有引用它的人斷鏈。
`#零件 #架構` 建 2026-06-03 更 2026-08-15
- [[成功的定義]] — 舊:在我的帳號上能跑。新:任何人在自己的帳號上跑得通,而且寫錯會被擋住。
`#策略 #驗收` 建 2026-06-01 更 2026-08-15
- [[規則記不住]] — 規定說幾次都沒用——這不是紀律問題,是機制缺席。
`#流程 #AI 協作` 建 2026-06-03 更 2026-08-15
- [[零件 PR 審核閘]] — 靠「AI 記住規則」防架構錯誤不 scale,所以把錯誤擋在 PR 這道結構性閘上。
`#流程 #架構 #審核` 建 2026-06-03 更 2026-08-15
## 說明
> 上面四節是**機器讀的**,只准放連結/路徑;說明寫在這一節,**不要用連結語法**。
> 每一行的摘要/標籤/日期都是從卡的 frontmatter 複製過來的 ⇒ index 可機械產生。
>
> 🔴 **`user_requirements/arcrun-landing-page/` 不在任何清單上,這是對的**——
> 它只有 `.jsx``.html``.css`,**沒有可掃文件 ⇒ 依規範它不是節點**,不建 `.wiki`
>
> 本層只長出一個主題(結構性閘),因為另外幾張卡各自獨立、還湊不出第二個主題。
-42
View File
@@ -1,42 +0,0 @@
---
tags: [設定, AI 協作]
gloss: arcrun 是給 AI 操作的工具,而 harness 說明 41 行對 scope 零字提及。
created: 2026-06-15
updated: 2026-08-15
---
# AI 讀不到的隱形分層
← [[HANDOFF-config-scope-and-vectorize]]
## 摘要
真實事故:mira 被交代遷移時沒看懂 scope 存在,差點誤部官方帳號。病根不在它笨,在它讀的東西裡沒有這一層。
## 重點
- **arcrun 的操作者是 AI 不是人**mindset §2),所以「AI 讀得到什麼」就是「系統有什麼」
- 而 harness block 全文 41 行,**對 scope/帳號歸屬/誤部風險 0 字**
- ⇒ mira 把「搬資料」跟「用哪個帳號」混為一談——**那是兩件獨立的事**
- 這是 [[結構性閘]] 的另一面:規則沒被寫在會被讀到的地方,等於不存在
## 實體
- **harness block**(介面)— AI 讀 arcrun 時看到的說明,`cli/harness/CLAUDE.block.md`
- **遷移 ≠ scope**(判準)— 搬資料與用哪個帳號是兩件獨立的事。
## 關聯
### 內文知識關係
- harness block >> 沒有提及 >> scope
- AI >> 只知道 >> harness block 寫的東西
### 卡片關係
- AI 讀不到的隱形分層 >> 屬於 >> [[HANDOFF-config-scope-and-vectorize]]
- AI 讀不到的隱形分層 >> 屬於 >> [[結構性閘]]
- AI 讀不到的隱形分層 >> 被誤部風險放大 >> [[scope 誤部風險]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> AI 讀不到的隱形分層
@@ -1,34 +0,0 @@
---
tags: [文件, 設定]
gloss: 一份交辦文件,指出 arcrun 設定分層的兩條缺口,萃出兩個概念。
created: 2026-06-15
updated: 2026-08-15
---
# HANDOFF-config-scope-and-vectorize
← [[00-INDEX]]
## 摘要
2026-06-15 頂層總管交辦。由 mira self-hosted dogfood 踩出來的兩條框架缺口,兩條都走 SDD 協議。
## 重點
- **缺口 1** 是功能面:讀分層完整但只能寫全域 ⇒ [[scope 誤部風險]]
- **缺口 2 被作者標為最重要**:AI 讀的說明裡完全沒有這一層 ⇒ [[AI 讀不到的隱形分層]]
- 文件還帶了第三塊(KBDB Vectorize 開關),但那部分在本次萃取中沒有獨立成概念
## 實體
- **HANDOFF-config-scope-and-vectorize**(原稿)— 本卡對應的那一份交辦文件。
## 關聯
### 卡片關係
- HANDOFF-config-scope-and-vectorize >> 涵蓋 >> [[AI 讀不到的隱形分層]]
- HANDOFF-config-scope-and-vectorize >> 涵蓋 >> [[scope 誤部風險]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> HANDOFF-config-scope-and-vectorize
-32
View File
@@ -1,32 +0,0 @@
---
tags: [文件, 遷移]
gloss: Matrix 重整交給 arcrun 的待辦清單,用指針式考古不複製素材。
created: 2026-06-13
updated: 2026-08-15
---
# HANDOFF-matrix-rearrange
← [[00-INDEX]]
## 摘要
頂層重整的產物。第一條就是關掉 matrix 版 cypher-executor。
## 重點
- **主要問題**[[cypher 雙份分岔]],含逐檔的勘查結果
- **指針式考古**:素材真身留在 `_archive/`,這份只給路徑——**不複製進來,避免第二份真相**
## 實體
- **HANDOFF-matrix-rearrange**(原稿)— 本卡對應的那一份交辦文件。
## 關聯
### 卡片關係
- HANDOFF-matrix-rearrange >> 涵蓋 >> [[cypher 雙份分岔]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-matrix-rearrange.md` >> 提及 >> HANDOFF-matrix-rearrange
-34
View File
@@ -1,34 +0,0 @@
---
tags: [文件, 事故]
gloss: 一份修復交辦,把調查做完了:哪些不用改、哪些要改、哪些要降級。
created: 2026-06-03
updated: 2026-08-15
---
# HANDOFF-mira-repair
← [[00-INDEX]]
## 摘要
arcrun 端的 CC 寫給 mira 端的 CC。特色是「調查已做完,照著改即可,不必重跑」。
## 重點
- **事故**[[Mira workflow 斷鏈]]——六個 workflow 全掃過
- **真因**:[[假零件降級]],而 mira 當初錯做了假零件
- **這份文件本身示範了一件事**:交辦時把調查結果一起交出去,接手的人才不會重跑一遍
## 實體
- **HANDOFF-mira-repair**(原稿)— 本卡對應的那一份交辦文件。
## 關聯
### 卡片關係
- HANDOFF-mira-repair >> 涵蓋 >> [[Mira workflow 斷鏈]]
- HANDOFF-mira-repair >> 涵蓋 >> [[假零件降級]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-mira-repair.md` >> 提及 >> HANDOFF-mira-repair
-34
View File
@@ -1,34 +0,0 @@
---
tags: [文件, 策略]
gloss: 一份把「今天要做的三件事」連同已查證實況整理好的交辦,核心是戰法轉向。
created: 2026-06-01
updated: 2026-08-15
---
# HANDOFF-self-host-harness
← [[00-INDEX]]
## 摘要
2026-06-01 撰寫。它最重要的一段不是任務,是背景:戰法已經變了。
## 重點
- **背景**[[self-hosted 戰法轉向]]
- **後果**[[成功的定義]] 被整個改寫
- **它的體例值得學**:先講「戰法已轉變(最重要的背景)」,再講今天做什麼
## 實體
- **HANDOFF-self-host-harness**(原稿)— 本卡對應的那一份交辦文件。
## 關聯
### 卡片關係
- HANDOFF-self-host-harness >> 涵蓋 >> [[self-hosted 戰法轉向]]
- HANDOFF-self-host-harness >> 涵蓋 >> [[成功的定義]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-self-host-harness.md` >> 提及 >> HANDOFF-self-host-harness
-40
View File
@@ -1,40 +0,0 @@
---
tags: [事故, 零件]
gloss: Mira 六個 workflow 全斷,但真因不是 arcrun 壞了,是 Mira 當初把東西錯做成假零件。
created: 2026-06-03
updated: 2026-08-15
---
# Mira workflow 斷鏈
← [[HANDOFF-mira-repair]]
## 摘要
整修後 mira 的 workflow 斷了。調查結論:prod 活著、降級的 recipe 都在 KV,大部分只需小改不必重寫。
## 重點
- **不是 arcrun 壞了**——`cypher.arcrun.dev` 活著,降級後的 recipe 都在 prod KV(已驗)
- **是 Mira 當初自己把一堆東西錯做成假零件** ⇒ [[假零件降級]] 一動它就斷
- **教訓**:引用「不該存在的東西」的人,會在那個東西被修正時付出代價
## 實體
- **六個 workflow**(受害範圍)— mira 全部的 workflow 都掃過了。
- **降級後的 recipe**(現況)— kbdb_gettelegram_sendgmail_send 等都在 prod KV。
## 關聯
### 內文知識關係
- Mira >> 錯做成 >> 假零件
- 假零件降級 >> 導致 >> Mira workflow 斷鏈
### 卡片關係
- Mira workflow 斷鏈 >> 屬於 >> [[HANDOFF-mira-repair]]
- Mira workflow 斷鏈 >> 肇因於 >> [[假零件降級]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-mira-repair.md` >> 提及 >> Mira workflow 斷鏈
@@ -1,34 +0,0 @@
---
tags: [文件, 流程]
gloss: 零件與 binding 的 PR 審核規範,五段 checklist,任一否就打回。
created: 2026-06-03
updated: 2026-08-15
---
# component-pr-review-standard
← [[00-INDEX]]
## 摘要
它的開場白就是整份文件的論點:靠 AI 記住規則不 scale,所以把錯誤擋在結構上。
## 重點
- **論點**[[規則記不住]]
- **手法**:[[零件 PR 審核閘]],五段 A–E 逐條過
- 每一段都附了**真實反例**`km_wiki_card_parse` 被否、drainer 本地綠但撞 1042)——**反例比規則本身有用**
## 實體
- **component-pr-review-standard**(原稿)— 本卡對應的那一份交辦文件。
## 關聯
### 卡片關係
- component-pr-review-standard >> 涵蓋 >> [[規則記不住]]
- component-pr-review-standard >> 涵蓋 >> [[零件 PR 審核閘]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../component-pr-review-standard.md` >> 提及 >> component-pr-review-standard
-37
View File
@@ -1,37 +0,0 @@
---
tags: [架構, 遷移]
gloss: matrix 與 arcrun 各有一份 cypher-executor 且已分岔——整合後只留 arcrun 一份。
created: 2026-06-13
updated: 2026-08-15
---
# cypher 雙份分岔
← [[HANDOFF-matrix-rearrange]]
## 摘要
勘查結果:matrix 版獨有 5 檔(皆非 SaaS 遺留,是 self-hosted 核心)、兩邊都有但內容不同 10 檔要逐檔比對。
## 重點
- **matrix 獨有的 5 檔是要保留的**:版本選擇策略、缺件自動生成、雙模式路由、WASM 執行、proxy
- **10 檔內容分岔**要逐檔比對合併,取較完整的一邊但保留 arcrun 較新的 auth 演進
- **同一個東西有兩份且都在演化 ⇒ 必然分岔**,這是要儘早合併的理由
## 實體
- **diverged 副本**(狀態)— 同源但各自演化後內容不同的兩份。
## 關聯
### 內文知識關係
- 兩份副本 >> 各自演化 >> 內容分岔
### 卡片關係
- cypher 雙份分岔 >> 屬於 >> [[HANDOFF-matrix-rearrange]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-matrix-rearrange.md` >> 提及 >> cypher 雙份分岔
-57
View File
@@ -1,57 +0,0 @@
{
"schema": 3,
"note": "卡→原文寫在卡的『### 出處』;原文→卡寫在這裡。多對多。",
"docs": [
{
"doc_id": "d_000",
"node": "(根)",
"path": "HANDOFF-config-scope-and-vectorize.md",
"提及": [
"HANDOFF-config-scope-and-vectorize",
"AI 讀不到的隱形分層",
"結構性閘",
"scope 誤部風險"
]
},
{
"doc_id": "d_001",
"node": "(根)",
"path": "HANDOFF-matrix-rearrange.md",
"提及": [
"HANDOFF-matrix-rearrange",
"cypher 雙份分岔"
]
},
{
"doc_id": "d_002",
"node": "(根)",
"path": "HANDOFF-mira-repair.md",
"提及": [
"假零件降級",
"Mira workflow 斷鏈",
"HANDOFF-mira-repair"
]
},
{
"doc_id": "d_003",
"node": "(根)",
"path": "HANDOFF-self-host-harness.md",
"提及": [
"成功的定義",
"self-hosted 戰法轉向",
"HANDOFF-self-host-harness"
]
},
{
"doc_id": "d_004",
"node": "(根)",
"path": "component-pr-review-standard.md",
"提及": [
"零件 PR 審核閘",
"規則記不住",
"結構性閘",
"component-pr-review-standard"
]
}
]
}
-41
View File
@@ -1,41 +0,0 @@
---
tags: [設定, 風險]
gloss: 部署到哪個 CF 帳號,取決於你當下站在哪個資料夾——而這件事沒有任何地方寫著。
created: 2026-06-15
updated: 2026-08-15
---
# scope 誤部風險
← [[HANDOFF-config-scope-and-vectorize]]
## 摘要
arcrun 的設定讀取分三層(env > 專案 > 全域),但寫入只能寫全域,而「我現在會部到哪」沒有任何提示。
## 重點
- **讀分層完整、寫只能全域**`saveConfig()` 寫死 `~/.arcrun/config.yaml``acr init` 四處呼叫全寫全域
- ⇒ 想用專案層的人被迫污染全域,**怕誤打官方帳號**
- **最陰險的是 fallback**:任一層都沒 `mode` 就靜默當 `local`,不報錯——你會看到「成功」但雲端沒有 trace
- 真正的病灶不是缺功能,是 [[AI 讀不到的隱形分層]]
## 實體
- **scope 分層**(機制)— env > 專案 `.arcrun.yaml` > 全域 `~/.arcrun/config.yaml`
- **靜默 fallback**(陷阱)— 沒設 mode 就當 local,不報錯。
## 關聯
### 內文知識關係
- scope 分層 >> 決定 >> 部署到哪個帳號
- 靜默 fallback >> 造成 >> 看到成功但沒有雲端 trace
### 卡片關係
- scope 誤部風險 >> 屬於 >> [[HANDOFF-config-scope-and-vectorize]]
- scope 誤部風險 >> 根因是 >> [[AI 讀不到的隱形分層]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> scope 誤部風險
-39
View File
@@ -1,39 +0,0 @@
---
tags: [策略, 部署]
gloss: 2026-06-01 從 SaaS 改成 self-hosted 開源——它改的不只是商業模式,是「成功」的定義。
created: 2026-06-01
updated: 2026-08-15
---
# self-hosted 戰法轉向
← [[HANDOFF-self-host-harness]]
## 摘要
richblack 的決定。連帶把 self-hosted 一鍵起得來從「重要但非阻擋項」升為第一優先。
## 重點
- **改的是驗收標準**,見 [[成功的定義]]
- ⇒ 優先序跟著翻轉:self-host 起得來變成當日第一優先
- **戰法一變,原本排在後面的東西可能立刻變成阻擋項**——這是排序時要先問「戰法變了嗎」的理由
## 實體
- **self-hosted 開源策略**(戰法)— 使用者在自己的 CF 帳號上跑,而非平台託管。
## 關聯
### 內文知識關係
- 戰法轉向 >> 改變 >> 優先序
- self-hosted >> 取代 >> SaaS
### 卡片關係
- self-hosted 戰法轉向 >> 屬於 >> [[HANDOFF-self-host-harness]]
- self-hosted 戰法轉向 >> 改寫了 >> [[成功的定義]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-self-host-harness.md` >> 提及 >> self-hosted 戰法轉向
-41
View File
@@ -1,41 +0,0 @@
---
tags: [零件, 架構]
gloss: 打固定 endpoint 的東西不是零件是 recipe——33 顆降到 22 顆,代價是所有引用它的人斷鏈。
created: 2026-06-03
updated: 2026-08-15
---
# 假零件降級
← [[HANDOFF-mira-repair]]
## 摘要
arcrun 大整修:把「打固定 API 卻被做成零件」的假零件降級成 recipe 並刪掉零件目錄。判準是 DECISIONS §1:零件=endpoint 薄殼。
## 重點
- **判準**:零件是可複用的原語;**打固定 API 的是 recipe**
- **33 → 22 顆**,被降級的解析方式變了 ⇒ 造成 [[Mira workflow 斷鏈]]
- 這正是 [[零件 PR 審核閘]] 要在源頭擋掉的東西——**降級是補救,審核才是預防**
## 實體
- **假零件**(反模式)— 打固定 API 卻被做成零件的東西。
- **recipe**(正解)— 具名封裝的單一 API 呼叫。
## 關聯
### 內文知識關係
- 假零件 >> 應降級為 >> recipe
- 降級 >> 改變了 >> 零件名的解析方式
### 卡片關係
- 假零件降級 >> 屬於 >> [[HANDOFF-mira-repair]]
- 假零件降級 >> 被擋於 >> [[零件 PR 審核閘]]
- 假零件降級 >> 觸發了 >> [[Mira workflow 斷鏈]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-mira-repair.md` >> 提及 >> 假零件降級
-40
View File
@@ -1,40 +0,0 @@
---
tags: [策略, 驗收]
gloss: 舊:在我的帳號上能跑。新:任何人在自己的帳號上跑得通,而且寫錯會被擋住。
created: 2026-06-01
updated: 2026-08-15
---
# 成功的定義
← [[HANDOFF-self-host-harness]]
## 摘要
戰法轉向後,harness 的成功定義被整個改寫。新定義多了一個常被忽略的下半句。
## 重點
- **舊**:在 richblack 的 prod 帳號(`cypher.arcrun.dev`)上能跑
- **新**:任何 CC 在自己的 CF 帳號 `acr init --self-hosted` 後跑通一個含 recipe 的 workflow
- 🔴 **而且「寫錯時會被程式擋住」是定義的一部分**——不是加分項
- ⇒ 這句話直接推導出 [[零件 PR 審核閘]] 這類 [[結構性閘]] 的必要性
## 實體
- **新成功定義**(驗收)— 任何人在自己帳號跑通,且寫錯會被擋。
## 關聯
### 內文知識關係
- 新定義 >> 包含 >> 寫錯會被擋住
- 舊定義 >> 只涵蓋 >> 作者自己的帳號
### 卡片關係
- 成功的定義 >> 屬於 >> [[HANDOFF-self-host-harness]]
- 成功的定義 >> 被改寫於 >> [[self-hosted 戰法轉向]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../HANDOFF-self-host-harness.md` >> 提及 >> 成功的定義
-37
View File
@@ -1,37 +0,0 @@
---
tags: [方法論, 流程, 架構]
gloss: 把錯誤路徑做成「根本碰不到」,而不是叮嚀人不要走。
created: 2026-06-03
updated: 2026-08-15
---
# 結構性閘
← [[00-INDEX]]
## 摘要
這個主題把三張卡收在一起,因為它們是同一個手法的三個場合:不靠自覺,靠結構。
## 重點
- 把 [[零件 PR 審核閘]] 收進來,是因為它明講了理由——**零件本來就要走 PR,所以把檢查放在那裡,錯誤路徑就碰不到**
- 而 [[規則記不住]] 是這個手法的前提:**規定說幾次都沒用,這不是紀律問題是機制缺席**
- [[AI 讀不到的隱形分層]] 則是同一件事的反面——**規則沒被放在會被讀到的地方,等於不存在**
- ⇒ 三張卡合起來給出一條可複用的判準:**看到「說過很多次還是會犯」,就去找那個缺席的閘**
## 實體
- **結構性閘**(手法)— 讓錯誤路徑在結構上碰不到,而非靠遵守。
## 關聯
### 卡片關係
- 結構性閘 >> 收攏 >> [[AI 讀不到的隱形分層]]
- 結構性閘 >> 收攏 >> [[規則記不住]]
- 結構性閘 >> 收攏 >> [[零件 PR 審核閘]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../component-pr-review-standard.md` >> 提及 >> 結構性閘
- `../HANDOFF-config-scope-and-vectorize.md` >> 提及 >> 結構性閘
-40
View File
@@ -1,40 +0,0 @@
---
tags: [流程, AI 協作]
gloss: 規定說幾次都沒用——這不是紀律問題,是機制缺席。
created: 2026-06-03
updated: 2026-08-15
---
# 規則記不住
← [[component-pr-review-standard]]
## 摘要
審核規範開宗明義的一句:「靠 AI 記住規則防架構錯誤不 scale」。它是整份文件存在的理由。
## 重點
- **規定寫得再清楚,沒有機制驗證就等於沒有**
- ⇒ 對策不是寫更多規定,是**把錯誤路徑做成碰不到**
- 這條可以推廣到任何「說過很多次還是會犯」的問題——**去找那個缺席的閘**
## 實體
- **不 scale**(性質)— 規則數量增加時,靠記憶遵守的成功率下降。
## 關聯
### 內文知識關係
- 靠記憶遵守規則 >> 不 scale
- 缺席的機制 >> 才是 >> 真正的病灶
### 卡片關係
- 規則記不住 >> 屬於 >> [[component-pr-review-standard]]
- 規則記不住 >> 屬於 >> [[結構性閘]]
- 規則記不住 >> 治的是 >> [[零件 PR 審核閘]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../component-pr-review-standard.md` >> 提及 >> 規則記不住
-46
View File
@@ -1,46 +0,0 @@
---
tags: [流程, 架構, 審核]
gloss: 靠「AI 記住規則」防架構錯誤不 scale,所以把錯誤擋在 PR 這道結構性閘上。
created: 2026-06-03
updated: 2026-08-15
---
# 零件 PR 審核閘
← [[component-pr-review-standard]]
## 摘要
零件與 service binding 本來就要走 PR,所以把檢查放在那裡,讓錯誤路徑「根本碰不到」。五段 checklist,任一否就打回。
## 重點
- **A 反過度工程**:判準是「三個月後會有第二個 workflow 用它嗎」,不會就不是零件
- **B binding 層級**(最常踩):跨-worker 編排一律走 cypher bindingservice binding 只准組 wasm
- **D 驗證誠實**:要打**部署後的真端點**,本地 miniflare 會假綠——實例是 drainer 本地綠、production 撞 1042
- **E 部署鐵律**wrangler 直推,不用 `acr update`(會假綠蓋掉改動)
- 它治的是 [[規則記不住]],手法屬於 [[結構性閘]]
## 實體
- **PR 審核閘**(機制)— 零件與 binding 變更的強制檢查點。
- **假綠**(反模式)— 本地測試通過但生產環境會失敗。
- **三個月判準**(判準)— 未來三個月會不會有第二個使用者。
## 關聯
### 內文知識關係
- PR 審核 >> 擋下 >> 架構錯誤
- 本地 miniflare >> 產生 >> 假綠
- 三個月判準 >> 決定 >> 是不是零件
### 卡片關係
- 零件 PR 審核閘 >> 實作 >> [[規則記不住]]
- 零件 PR 審核閘 >> 屬於 >> [[component-pr-review-standard]]
- 零件 PR 審核閘 >> 屬於 >> [[結構性閘]]
- 零件 PR 審核閘 >> 擋的正是 >> [[假零件降級]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../component-pr-review-standard.md` >> 提及 >> 零件 PR 審核閘
-55
View File
@@ -1,55 +0,0 @@
# 00-INDEXuser_requirements
`../../.wiki/00-INDEX.md`
## 子節點
- **ADR-lib-and-landingPage**`../ADR-lib-and-landingPage/.wiki/00-INDEX.md` — 3 篇(未萃)
- **simplify_mvp**`../simplify_mvp/.wiki/00-INDEX.md` — 1 篇(未萃)
- **u6u-long-term**`../u6u-long-term/.wiki/00-INDEX.md` — 1 篇+子節點 SourceDocs 3 篇(未萃)
## 文件(本層每一篇一張)
- [[credential_parts]] — 29KB 的 credential 規格,本次只萃了 §0–§2,其餘尚未分段。
`#文件 #credential` 建 2026-05-25 更 2026-08-15
- [[u6u_design]] — 一份兩千字的設計筆記,密度很高,萃出五個概念。
`#文件 #設計` 建 2026-05-20 更 2026-08-15
- `../wishlist.md`**空**:指針檔,全文都在說「已移至頂層」。真身在 `InkStoneCo/docs/1-vision/product-wishlist.md`
## 主題(跨文件聚類產生)
- [[讓生態自己長]] — u6u 對「零件從哪來、誰把關、爛的怎麼淘汰」的整套答案:三件事互相咬合。
`#生態 #零件 #方法論` 建 2026-05-20 更 2026-08-15
## 本層概念(7
- [[AI 的 marketplace]] — marketplace 給 AI 不給人:強制用完回評價,被評差的零件其他 AI 自動避開。
`#生態 #零件` 建 2026-05-20 更 2026-08-15
- [[n8n 的每服務一零件]] — n8n 為每個服務寫一個 credential 零件——這份文件開宗明義說那是錯的。
`#credential #反模式` 建 2026-05-25 更 2026-08-15
- [[四個 primitive 不是每服務一個零件]] — 做四個 TinyGo/WASM primitive,每個服務只要一份 YAML recipe 用戶自己的 secret。
`#credential #架構` 建 2026-05-25 更 2026-08-15
- [[自動審核取代人工]] — call API 的只要 call 通就過,功能性的只要通過它自己設的 Gherkin 就過。
`#流程 #零件` 建 2026-05-20 更 2026-08-15
- [[視覺優先開發]] — 用戶拉一個按鈕,後面自然綁上 webhook——不用解釋 webhook 是什麼。
`#產品 #介面` 建 2026-05-20 更 2026-08-15
- [[解釋 webhook 的困難]] — 要跟一般用戶解釋什麼是 webhook 很難——這是低門檻產品真正的門檻。
`#產品 #介面` 建 2026-05-20 更 2026-08-15
- [[零件長尾誰來補]] — 沒有人會為了 delete table 去寫完整的 Google Sheets 零件——但缺的那個端點會被下一個需要的人補上。
`#生態 #零件` 建 2026-05-20 更 2026-08-15
## 說明
> 上面四節是機器讀的;說明寫這裡,不要用連結語法。
>
> 🔴 **`arcrun-landing-page/` 沒有出現在子節點清單裡,這是對的**——
> 它只有 `.jsx``.html``.css`,**沒有可掃文件 ⇒ 依規範不是節點**。
>
> 🔴 **三個子節點標「未萃」是誠實的殘缺**:本次真實測試只做了 `docs/` 與本層兩個節點。
> 標出來,使用者才分得出「沒東西」和「還沒做」。
>
> 🔴 **`wishlist.md` 是「空」的第三種**:不是沒東西(待辦)、不是紀錄(報價單),是**指針**。
> 依規範標 `no_concept` **不產卡**——我第一版給它產了卡,`wiki-lint` 當場報孤島卡抓到。
>
> 🔴 **`credential_parts.md` 是 29KB、12 個 H2**,超過 20KB 分段門檻。
> 本次只萃了 §0–§2,其餘八段**如實標明未做**——一張卡蓋不住 29KB。
@@ -1,42 +0,0 @@
---
tags: [生態, 零件]
gloss: marketplace 給 AI 不給人:強制用完回評價,被評差的零件其他 AI 自動避開。
created: 2026-05-20
updated: 2026-08-15
---
# AI 的 marketplace
← [[u6u_design]]
## 摘要
u6u 的零件市集設計。它跟一般 marketplace 的差別在使用者是 AI,所以回饋可以被強制。
## 重點
- **強制回評價**——AI 不像人會忘記或懶得評
- **被評差幾次的零件,其他 AI 自動避開** ⇒ 淘汰不需要人介入
- 它配的是 [[自動審核取代人工]],兩者合起來讓 [[零件長尾誰來補]] 有解
## 實體
- **AI marketplace**(機制)— 使用者是 AI 的零件市集,回饋可強制。
- **自然淘汰**(效果)— 差評零件被自動避開。
## 關聯
### 內文知識關係
- AI >> 被強制 >> 回覆評價
- 低評零件 >> 被 >> 其他 AI 避開
### 卡片關係
- AI 的 marketplace >> 屬於 >> [[u6u_design]]
- AI 的 marketplace >> 屬於 >> [[讓生態自己長]]
- AI 的 marketplace >> 機制是 >> [[自動審核取代人工]]
- AI 的 marketplace >> 解的是 >> [[零件長尾誰來補]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> AI 的 marketplace
@@ -1,34 +0,0 @@
---
tags: [文件, credential]
gloss: 29KB 的 credential 規格,本次只萃了 §0–§2,其餘尚未分段。
created: 2026-05-25
updated: 2026-08-15
---
# credential_parts
← [[00-INDEX]]
## 摘要
這份文件超過本規範的 20KB 分段門檻,共 12 個 H2 段落。本次示範「只萃前段、其餘誠實標明未做」。
## 重點
- 已萃:[[四個 primitive 不是每服務一個零件]]、[[n8n 的每服務一零件]](來自 §0 TL;DR 與 §12
- 🔴 **未萃**:§3 四個 primitive 詳細規格/§4 Recipe Schema/§5 繼承/§6 WASM 介面/§7 AuthBroker API/§8 Storage/§9 授權 UI/§10 TODO/§11 風險
- **這正是「大檔要按 H2 分段」那個洞的實例**——一張卡蓋不住 29KB
## 實體
- **credential_parts**(原稿)— 本卡對應的那一份文件。
## 關聯
### 卡片關係
- credential_parts >> 涵蓋 >> [[n8n 的每服務一零件]]
- credential_parts >> 涵蓋 >> [[四個 primitive 不是每服務一個零件]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../credential_parts.md` >> 提及 >> credential_parts
@@ -1,37 +0,0 @@
---
tags: [credential, 反模式]
gloss: n8n 為每個服務寫一個 credential 零件——這份文件開宗明義說那是錯的。
created: 2026-05-25
updated: 2026-08-15
---
# n8n 的每服務一零件
← [[credential_parts]]
## 摘要
被點名的反模式。它的問題是零件數量隨服務數量線性成長,而認證方式其實只有幾種。
## 重點
- **零件數 ∝ 服務數**,但認證方式其實只有四種
- ⇒ 正解是 [[四個 primitive 不是每服務一個零件]]
## 實體
- **每服務一零件**(反模式)— n8n 的 credential 做法。
## 關聯
### 內文知識關係
- 零件數 >> 隨服務數 >> 線性成長
### 卡片關係
- n8n 的每服務一零件 >> 否定 >> [[四個 primitive 不是每服務一個零件]]
- n8n 的每服務一零件 >> 屬於 >> [[credential_parts]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../credential_parts.md` >> 提及 >> n8n 的每服務一零件
@@ -1,37 +0,0 @@
---
tags: [文件, 設計]
gloss: 一份兩千字的設計筆記,密度很高,萃出五個概念。
created: 2026-05-20
updated: 2026-08-15
---
# u6u_design
← [[00-INDEX]]
## 摘要
開場一句「u6u 是一個 AI Friendly 的 n8n」,後面是條列的設計主張,最後一段是觀念想法。
## 重點
- **生態那三條**被收成 [[讓生態自己長]]
- **介面策略**是 [[視覺優先開發]],它迴避 [[解釋 webhook 的困難]]
- ⚠️ 文件最後一句「考慮讓他自己 OWN,企業版資料獨立」**沒有展開,本次未萃成卡**
## 實體
- **u6u_design**(原稿)— 本卡對應的那一份文件。
## 關聯
### 卡片關係
- u6u_design >> 涵蓋 >> [[AI 的 marketplace]]
- u6u_design >> 涵蓋 >> [[自動審核取代人工]]
- u6u_design >> 涵蓋 >> [[視覺優先開發]]
- u6u_design >> 涵蓋 >> [[解釋 webhook 的困難]]
- u6u_design >> 涵蓋 >> [[零件長尾誰來補]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> u6u_design
@@ -1,44 +0,0 @@
---
tags: [credential, 架構]
gloss: 做四個 TinyGo/WASM primitive,每個服務只要一份 YAML recipe 用戶自己的 secret。
created: 2026-05-25
updated: 2026-08-15
---
# 四個 primitive 不是每服務一個零件
← [[credential_parts]]
## 摘要
credential 設計的核心主張。它是三層模型:primitive(程式)/recipe(公共設定)/secret(私有)。
## 重點
- **四個 primitive** 涵蓋所有認證方式,不隨服務數量成長
- **recipe 存平台 KV(公共)、secret 存 tenant KV(私有)**runtime 由 AuthBroker 組裝
- 它明確反對 [[n8n 的每服務一零件]]
- ⚠️ 本卡只涵蓋這份 29KB 文件的 §0–§2;`§3 四個 primitive 詳細規格``§4 Recipe Schema` 等**尚未分段萃取**
## 實體
- **primitive**(零件)— TinyGo/WASM 寫的認證原語,共四個。
- **recipe**(設定)— 每服務一份 YAML,存公共 KV。
- **AuthBroker**(組裝者)— runtime 把 recipe 與 secret 組成可用的 HTTP client。
## 關聯
### 內文知識關係
- primitive >> 加上 >> recipe 與 secret
- AuthBroker >> 組裝 >> 可用的 HTTP client
- recipe >> 存於 >> 公共 KV
- secret >> 存於 >> tenant KV
### 卡片關係
- 四個 primitive 不是每服務一個零件 >> 反的是 >> [[n8n 的每服務一零件]]
- 四個 primitive 不是每服務一個零件 >> 屬於 >> [[credential_parts]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../credential_parts.md` >> 提及 >> 四個 primitive 不是每服務一個零件
@@ -1,41 +0,0 @@
---
tags: [流程, 零件]
gloss: call API 的只要 call 通就過,功能性的只要通過它自己設的 Gherkin 就過。
created: 2026-05-20
updated: 2026-08-15
---
# 自動審核取代人工
← [[u6u_design]]
## 摘要
把「零件能不能上架」的判斷交給可執行的檢查,省掉人工審核。
## 重點
- **打 API 的**:成功 call 通就算過
- **功能性的**:通過投稿者自己寫的 Gherkin 就算過
- ⚠️ 這條跟後來的 `component-pr-review-standard` 有張力——那份明講 **Gherkin 全綠 ≠ 零件安全**(投稿者可寫避重就輕的 Gherkin)
- ⇒ **兩份文件寫於不同時期,後者修正了前者**。真正框死破壞力的是純 WASI 沙箱,不是 Gherkin
## 實體
- **Gherkin 自審**(機制)— 投稿者自寫驗收條件,通過即上架。
## 關聯
### 內文知識關係
- call API 成功 >> 即通過 >> 審核
- Gherkin 全綠 >> 不等於 >> 零件安全
### 卡片關係
- 自動審核取代人工 >> 屬於 >> [[u6u_design]]
- 自動審核取代人工 >> 屬於 >> [[讓生態自己長]]
- 自動審核取代人工 >> 支撐 >> [[AI 的 marketplace]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> 自動審核取代人工
@@ -1,41 +0,0 @@
---
tags: [產品, 介面]
gloss: 用戶拉一個按鈕,後面自然綁上 webhook——不用解釋 webhook 是什麼。
created: 2026-05-20
updated: 2026-08-15
---
# 視覺優先開發
← [[u6u_design]]
## 摘要
u6u 的低門檻策略:讓抽象概念從具體操作裡浮現,而不是先教會使用者術語。
## 重點
- **先做前端按鈕、輸入框,後端邏輯跟著綁上去**
- **點下去就看到工作流** ⇒ 概念從操作裡長出來
- 它迴避的是 [[解釋 webhook 的困難]]
- 延伸:cypher 放大就是 graph,**每個功能可以摺疊成一點,也可以 zoom in 展開調整** ⇒ 同一個碎型結構
## 實體
- **視覺優先**(策略)— 先給看得見的操作,再讓概念浮現。
- **摺疊與展開**(互動)— 功能可收成一點也可展開調整。
## 關聯
### 內文知識關係
- 前端按鈕 >> 綁定 >> webhook
- cypher 放大 >> 就是 >> graph
### 卡片關係
- 視覺優先開發 >> 屬於 >> [[u6u_design]]
- 視覺優先開發 >> 迴避了 >> [[解釋 webhook 的困難]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> 視覺優先開發
@@ -1,37 +0,0 @@
---
tags: [產品, 介面]
gloss: 要跟一般用戶解釋什麼是 webhook 很難——這是低門檻產品真正的門檻。
created: 2026-05-20
updated: 2026-08-15
---
# 解釋 webhook 的困難
← [[u6u_design]]
## 摘要
一句被順帶寫下、但其實是整個介面策略起點的觀察。
## 重點
- **門檻不在功能,在術語**
- ⇒ 對策不是寫更好的說明文件,是 [[視覺優先開發]]——讓他不必知道那個詞
## 實體
- **術語門檻**(障礙)— 使用者卡在概念名稱而非功能本身。
## 關聯
### 內文知識關係
- 術語 >> 構成 >> 使用門檻
### 卡片關係
- 解釋 webhook 的困難 >> 屬於 >> [[u6u_design]]
- 解釋 webhook 的困難 >> 被迴避於 >> [[視覺優先開發]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> 解釋 webhook 的困難
@@ -1,36 +0,0 @@
---
tags: [生態, 零件, 方法論]
gloss: u6u 對「零件從哪來、誰把關、爛的怎麼淘汰」的整套答案:三件事互相咬合。
created: 2026-05-20
updated: 2026-08-15
---
# 讓生態自己長
← [[00-INDEX]]
## 摘要
這個主題把三張卡收在一起,因為它們是同一個迴圈的三段:怎麼補、怎麼把關、怎麼淘汰。
## 重點
- 起點是 [[零件長尾誰來補]]——**不預先做完整,缺的時候由需要的人補**
- 把關交給 [[自動審核取代人工]],因為人工審核會變成瓶頸
- 淘汰交給 [[AI 的 marketplace]]**而它能成立的唯一原因是使用者是 AI**(回饋可以被強制)
- ⇒ **三段缺一段這個迴圈就停**:沒人補、沒人審、或爛的不會消失,生態都長不起來
## 實體
- **生態迴圈**(結構)— 補件、把關、淘汰三段互相咬合。
## 關聯
### 卡片關係
- 讓生態自己長 >> 收攏 >> [[AI 的 marketplace]]
- 讓生態自己長 >> 收攏 >> [[自動審核取代人工]]
- 讓生態自己長 >> 收攏 >> [[零件長尾誰來補]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> 讓生態自己長
@@ -1,41 +0,0 @@
---
tags: [生態, 零件]
gloss: 沒有人會為了 delete table 去寫完整的 Google Sheets 零件——但缺的那個端點會被下一個需要的人補上。
created: 2026-05-20
updated: 2026-08-15
---
# 零件長尾誰來補
← [[u6u_design]]
## 摘要
u6u 對「零件覆蓋率」的答案:不追求完整,追求需要時補得上,而搜尋會把碎片整合起來。
## 重點
- **每個 API call 獨立,但搜尋會整合**——有人做了 create table,下一個人要 delete 時發現沒有就直接做一個
- **下次搜 google sheets 就同時看到兩個端點** ⇒ 覆蓋率由需求長出來,不由規劃決定
- 這正是 [[AI 的 marketplace]] 要服務的形狀
## 實體
- **長尾覆蓋**(問題)— 沒人會預先寫完整的 API 包裝。
- **搜尋整合**(機制)— 碎片端點在搜尋時被聚合成一組。
## 關聯
### 內文知識關係
- 需求 >> 決定 >> 覆蓋率
- 搜尋 >> 整合 >> 獨立的端點
### 卡片關係
- 零件長尾誰來補 >> 對策是 >> [[AI 的 marketplace]]
- 零件長尾誰來補 >> 屬於 >> [[u6u_design]]
- 零件長尾誰來補 >> 屬於 >> [[讓生態自己長]]
### 出處(原文 >> 提及 >> 本卡,可多筆)
- `../u6u_design.md` >> 提及 >> 零件長尾誰來補
-138
View File
@@ -1,138 +0,0 @@
-- 0007 — record 有身分,關係是唯一機制(樹狀 record 模型第一刀)
-- 規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm
--
-- 模型一句話(leo 定案):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」
-- · record = 池中一顆有身分的 entry(沿用原 record_id 字串當 id,既有引用不失效)
-- · 欄位是關係、歸屬也是關係——同一種機制:一列關係 = src/rel/dst 三個型別化指標欄
-- (「池上型別化指標欄」,parent_id 是這族欄位的既有先例。紅線:不准用關係實作關係、
-- 不准把指標塞回 content/JSON——那是 D91 的位置換個門進來)
-- · 舊 entry_values 四欄的下場:record_id → record entry 自己的 identry_id → 指標終點(dst
-- slot_name → 指標謂詞(rel field entry)/template_id → 一條屬於關係(rel sys_belongs
-- · 那張表該死的理由:它是「關係」的第二套實作——同一件事兩個實作必然漂移(D92)
--
-- 資料遷移形狀(v7 §3):既有 value entries 一列都不動(id 穩定、向量索引不失效)、
-- 全部純 INSERT、零 UPDATE。以舊儲存格列 id 衍生關係列 id ⇒ INSERT OR IGNORE 天然冪等。
--
-- 冪等設計(三條套用路徑:官方 wrangler migrationscli deploy.ts 每次重跑/安裝器逐句重放):
-- · 除了三句 ADD COLUMN 之外,每一句都是語句級冪等(IF NOT EXISTSINSERT OR IGNORE
-- · SQLite 沒有「ADD COLUMN IF NOT EXISTS」——重跑時那三句會報 duplicate column
-- 套用端必須把「duplicate column」視為已套用(cli deploy.ts 的 applyD1MigrationTolerant、
-- 安裝器逐句 try/catch 本就容錯)。官方路徑走 wrangler migrations 追蹤表,只跑一次。
-- · 不走「重建整張 entries」的做法:那雖然能純語句冪等,但 deploy.ts 每次部署都重跑
-- migration ⇒ 每次部署全表複製一輪,直接吃掉 D1 每日列寫入額度。
-- · entry_values 拆表用 0006 的「墊表、搬、拆」手法:重跑時先墊一份空殼,搬 0 筆,再拆,無害。
--
-- 施工窗口注意:本檔跑完 entry_values 就不存在了,必須與讀寫端改版(kbdb/src 同一批)一起部署。
-- 舊 worker 碰新庫會炸 entry_values 不存在——這是刻意的(leo:「不做長期雙讀相容層,一次翻」)。
-- ============================================================
-- 1. 關係的物理載體:池上型別化指標欄 + 一對方向索引
-- ============================================================
ALTER TABLE entries ADD COLUMN src_id TEXT; -- kbdb-sql-ok: 牆內 migration 本體(v7 定案載體「池上型別化指標欄」),重跑由套用端容錯 duplicate column
ALTER TABLE entries ADD COLUMN rel_id TEXT; -- kbdb-sql-ok: 同上
ALTER TABLE entries ADD COLUMN dst_id TEXT; -- kbdb-sql-ok: 同上
-- 一對方向索引(v7 §2-1):src 端服務「r1 的所有欄位」,(dst,rel) 端服務
-- 「某張 sheet 的所有 record」(rel=屬於, dst=sheet)與「誰指到 e1」(dst=e1)。
-- partial index:內容 entry(指標欄全 NULL)不進索引,索引大小 ≈ 關係列數。
CREATE INDEX IF NOT EXISTS idx_entries_rel_src ON entries(src_id) WHERE src_id IS NOT NULL; -- kbdb-sql-ok: 牆內 migration 本體,普通欄位索引(0001 十條索引的同族)
CREATE INDEX IF NOT EXISTS idx_entries_rel_dst ON entries(dst_id, rel_id) WHERE dst_id IS NOT NULL; -- kbdb-sql-ok: 同上
-- ============================================================
-- 2. 啟動常數(v7 §2-3:一組、極小、只讀)
-- ============================================================
-- 保留根:屬於鏈的終點。某顆 entry ─屬於→ sys_root 它是一張 sheet。
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_root', 'root', 'system', NULL);
-- 「屬於」謂詞:record ─屬於→ sheet(歸屬關係的 rel)。
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_belongs', 'belongs', 'system', NULL);
-- 「欄位屬於表」謂詞:field ─field_of→ sheetsheet 的欄位名冊,與 record 歸屬分開,
-- 免得「某張 sheet 的所有 record」把欄位也撈進來)。
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES ('sys_field_of', 'field_of', 'system', NULL);
-- ============================================================
-- 3. 墊表(0006 手法):本檔尾端會拆掉 entry_values,重跑時先墊空殼
-- 讓下面的搬遷語句永遠合法(搬 0 筆),最後再拆一次。
-- ============================================================
CREATE TABLE IF NOT EXISTS entry_values ( -- kbdb-sql-ok: 表退場施工步驟①保底存在(0006 同款),非資料存取違規
id TEXT PRIMARY KEY,
record_id TEXT NOT NULL,
template_id TEXT NOT NULL,
slot_name TEXT NOT NULL,
entry_id TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch()),
UNIQUE(record_id, slot_name)
);
-- ============================================================
-- 4. sheetfield 落池(templates 表遷移期雙軌:欄位定義的真相源暫仍在 templates 表,
-- sheet/field entry 是它在池中的身分——第二刀把 templates 表整個退役)
-- ============================================================
-- 每個 template 一顆 sheet entryid 沿用 template id,既有引用不失效)
INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id, created_at, updated_at)
SELECT t.id, t.name, 'sheet', NULL, t.created_at, t.updated_at FROM templates t;
-- sheet ─屬於→ 保留根(「什麼讓一顆 entry 成為 sheet」的答案:這條關係)
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
SELECT 'relb_' || t.id, 'relation', t.id, 'sys_belongs', 'sys_root' FROM templates t;
-- 每個 slot 一顆 field entryid 決定性衍生:fld_<template>_<slot>,欄名=關係的謂詞)
INSERT OR IGNORE INTO entries (id, content, entry_type)
SELECT 'fld_' || t.id || '_' || j.value, j.value, 'field'
FROM templates t, json_each(t.slots_json) j;
-- field ─field_of→ sheet(欄位名冊)
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
SELECT 'relf_' || t.id || '_' || j.value, 'relation', 'fld_' || t.id || '_' || j.value, 'sys_field_of', t.id
FROM templates t, json_each(t.slots_json) j;
-- 保險網:entry_values 裡實際用過、但 slots_json 沒宣告的 slot(宣告與實作漂移的實證),
-- 一樣補出 field entry 與名冊,資料一筆都不能掉。
INSERT OR IGNORE INTO entries (id, content, entry_type)
SELECT DISTINCT 'fld_' || ev.template_id || '_' || ev.slot_name, ev.slot_name, 'field' FROM entry_values ev;
INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id)
SELECT DISTINCT 'relf_' || ev.template_id || '_' || ev.slot_name, 'relation',
'fld_' || ev.template_id || '_' || ev.slot_name, 'sys_field_of', ev.template_id
FROM entry_values ev;
-- ============================================================
-- 5. record 有身分:每筆 record 一顆池中 entryid 原 record_id 字串)
-- 歸屬 owner 沿用「第一個非 NULL 的 slot entry owner」——與舊讀端的推導逐字同義。
-- INSERT OR IGNORE 的另一層意義:record_id 已經是池中既有 entrylibrary_map 的
-- block 即 record 慣例)時,那顆 entry 本人就是身分,不另建。
-- ============================================================
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, created_at, updated_at)
SELECT ev.record_id, 'record',
(SELECT e2.owner_id FROM entry_values ev2 JOIN entries e2 ON ev2.entry_id = e2.id
WHERE ev2.record_id = ev.record_id AND e2.owner_id IS NOT NULL LIMIT 1),
MIN(ev.created_at), MIN(ev.created_at)
FROM entry_values ev GROUP BY ev.record_id;
-- record ─屬於→ sheet(舊 template_id 欄的下場:變成一條關係)
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id, created_at)
SELECT 'relb_' || ev.record_id || '_' || ev.template_id, 'relation',
(SELECT e2.owner_id FROM entry_values ev2 JOIN entries e2 ON ev2.entry_id = e2.id
WHERE ev2.record_id = ev.record_id AND e2.owner_id IS NOT NULL LIMIT 1),
ev.record_id, 'sys_belongs', ev.template_id, MIN(ev.created_at)
FROM entry_values ev GROUP BY ev.record_id, ev.template_id;
-- ============================================================
-- 6. 每個儲存格 → 一條關係列(src=record、rel=欄位謂詞、dst=原 value entry
-- 關係列 id 以舊儲存格列 id 衍生(relv_<ev.id>)⇒ 重跑天然冪等。
-- ============================================================
INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id, created_at)
SELECT 'relv_' || ev.id, 'relation', e.owner_id,
ev.record_id, 'fld_' || ev.template_id || '_' || ev.slot_name, ev.entry_id, ev.created_at
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id;
-- ============================================================
-- 7. 拆表(本提案的完成證明:關係的第二套實作不再有位置)
-- ============================================================
DROP TABLE IF EXISTS entry_values; -- kbdb-sql-ok: 表退場施工(0006 同款),v7 定案「兩套機制合成一套」的落地
-20
View File
@@ -73,20 +73,10 @@ export interface ListEntriesResult {
// 當 count 回傳,容易被誤讀成「總共只有這幾筆」。total 才是真總數,count 仍保留=本頁筆數。
}
// ── 機制列隔離(0007 樹狀 record 模型)───────────────────────────────
// 關係列/裸身分/sheet/field/啟動常數是模型的機械零件,不是使用者的「一筆知識」。
// caller 沒指定 entry_type 時預設排除,免得 owner-scoped 列表被 content=NULL 的關係列灌爆;
// caller 明白指定 entry_type(含指定成機制型別)→ 尊重他要的,不攔。
// 判「是不是關係列」認指標欄(src_id IS NOT NULL),不認 entry_type 標記——
// entry_type 在 v7 §7 白名單終局會整欄消失,這條謂詞到時只剩前半。
const NOT_MACHINERY_PREDICATE =
"(src_id IS NULL AND entry_type NOT IN ('record', 'sheet', 'field', 'system'))";
export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Promise<ListEntriesResult> {
const conds: string[] = [];
const params: unknown[] = [];
if (f.entry_type) { conds.push('entry_type = ?'); params.push(f.entry_type); }
else { conds.push(NOT_MACHINERY_PREDICATE); }
if (f.owner_id) { conds.push('owner_id = ?'); params.push(f.owner_id); }
if (f.parent_id) { conds.push('parent_id = ?'); params.push(f.parent_id); }
if (f.page_name) { conds.push('page_name = ?'); params.push(f.page_name); }
@@ -141,14 +131,7 @@ export async function updateEntry(db: D1Database, id: string, patch: UpdateEntry
}
export async function deleteEntry(db: D1Database, id: string): Promise<void> {
// 舊世界靠 FKentry_values.entry_id REFERENCES entries)擋「刪掉還被 record 指著的
// entry」;新模型(0007)關係列的 dst_id 沒有 FK → 這條不變量改由牆自己保,
// 否則會產出指向不存在 id 的孤兒關係列(孤兒巡檢見 relation-orphans.ts)。
const ref = await db.prepare('SELECT id FROM entries WHERE dst_id = ? LIMIT 1').bind(id).first<{ id: string }>();
if (ref) throw new Error(`entry ${id} is still referenced by record relation ${ref.id} — delete the record (or its slot) first`);
await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run();
// 這顆 entry 自己發出的關係列(它是 record 身分時的格子與歸屬)失去意義,一併拆
await db.prepare('DELETE FROM entries WHERE src_id = ?').bind(id).run();
}
/**
@@ -600,9 +583,6 @@ export async function searchEntries(
const params: unknown[] = [...plan.scoreParams];
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
// 機制列隔離(0007):沒指定 entry_type 時,sheet/field 這類有 content 的機制節點
// 不進關鍵字搜尋(搜 "status" 不該撈回一顆欄位謂詞 entry);關係列 content=NULL 本就 0 分。
else { conds.push(NOT_MACHINERY_PREDICATE); }
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
+129
View File
@@ -29,6 +29,8 @@
// 這件事不只管向量化,也要管補標,否則做標庫時就會把補算的閘繞過去」)。
import type { Bindings } from '../types';
import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota';
import { updateRecord } from './record-crud';
import { ensureTripletLibrarySlot, DEFAULT_TRIPLET_TEMPLATE } from './library-map';
// IN 清單長度上限(避開 D1/SQLite bound-parameter 上限;一次點名這麼多張卡已經很夠用,
// 呼叫端清單更長就自然分批呼叫,跟 limit 分頁是同一種節奏)。
@@ -166,3 +168,130 @@ export async function libraryBackfillStatus(
.first<{ c: number }>();
return { pending: row?.c ?? 0 };
}
// ── 三元組(triplet)版:藏書地圖真正讀的那一半(Arcrun#87 二次收尾,2026-08-13)──────────
//
// 上面 backfillEntryLibraryTags 補的是 entries.metadata_json.$.library(卡片/搜尋/embed 層)。
// 藏書地圖(library-map.ts 的 recomputeLibraryMapliveTripletCountsByLibrary)讀的是**三元組
// record 自己的 'library' slot**entry_values,經 record-crud 的 updateRecord 寫入)——兩者是
// 兩個互不相干的存放處(票上 2026-08-11 14:21 comment「庫值有兩個互不相干的存放處」段已釐清),
// 補了前者地圖依然是 0。
//
// 既有通道只有「單筆 PATCH /kbdb/records/:id」(b6ef0f02026-08-11)——沒有批次版本。
// 補標母體上千筆時逐筆 PATCH 不現實(也不安全:呼叫端要自己刻節流/冪等,容易漏做)。
// 本函式是三元組版的批次 backfill,安全原則與上面 entries 版逐條對齊,不重新發明:
// - 呼叫端決定 library 值+篩選條件(base 對內容語意無知,不猜哪個 source_uri 該歸哪個庫)
// - 冪等:只選「目前沒有 library slot 值」的候選(NOT EXISTS 找缺 library 的那半,同 entries
// 版用「library 為空」而非覆蓋已標記過的)
// - D69 節流:與 entries 版、embed reconcile 共用同一顆每日 D1 寫入額度計數器(不共用會被繞過)
// - owner_id 必填(2026-08-11 leo 直令:批次改一大片既有資料不准無租戶範圍地掃)
// - 寫入沿用既有 updateRecordrecord-crud.ts)——不手刻第二套 entry_values UPSERT SQL
// 這條寫入路徑已經被 triplet-library-backfill.test.ts 驗證過語意正確(源頭順序/存量補標/
// 冪等三案),本函式只是把它包成「呼叫端給 library+source_prefix,一次處理一批」的批次版。
export interface TripletLibraryBackfillCriteria {
owner_id?: string;
triplet_template?: string; // 預設 DEFAULT_TRIPLET_TEMPLATE'triplet'
source_prefix?: string; // source_uri LIKE prefix%(本票的規則:^gitea:Leo/<repo>@ → 各庫;^kb:// → kb
}
function tripletCriteriaSql(c: TripletLibraryBackfillCriteria & { owner_id: string }): { where: string; params: unknown[] } {
// 候選:這個 template 底下、有 source_uri 值、owner 符合、source_uri 符合前綴、
// 且目前這個 record 沒有任何 'library' slot 值的 record_idNOT EXISTS 保冪等)。
const conds = [
'ev.slot_name = ?',
'e.content LIKE ? || \'%\'',
'e.owner_id = ?',
`NOT EXISTS (SELECT 1 FROM entry_values lev WHERE lev.record_id = ev.record_id AND lev.slot_name = 'library')`,
];
const params: unknown[] = ['source_uri', c.source_prefix ?? '', c.owner_id];
return { where: conds.join(' AND '), params };
}
/**
* library record target library
* + limit + budget entries /embed reconcile D1
* daemon#87 source_uri library
* recordbase backfillEntryLibraryTags
*/
export async function backfillTripletLibraryTags(
db: D1Database,
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
opts: { library: string; owner_id: string; limit?: number } & TripletLibraryBackfillCriteria,
): Promise<LibraryBackfillResult> {
const library = (opts.library ?? '').trim();
if (!library) throw new Error('library required');
const ownerId = (opts.owner_id ?? '').trim();
if (!ownerId) throw new Error('owner_id required(標庫是跨大量既有資料的批次寫入,不准無租戶範圍地掃全庫——2026-08-11 leo 直令)');
const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);
const tripletTemplateName = opts.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE;
// 冪等地確保 template 有 library slot(同 recomputeLibraryMap 的既有慣例,不動表)。
await ensureTripletLibrarySlot(db, tripletTemplateName);
const tpl = await db.prepare(`SELECT id FROM templates WHERE name = ?`).bind(tripletTemplateName).first<{ id: string }>();
if (!tpl) throw new Error(`triplet template not found: ${tripletTemplateName}`);
const sel = tripletCriteriaSql({ ...opts, owner_id: ownerId });
const res = await db
.prepare(
`SELECT ev.record_id AS id FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ? AND ${sel.where} ORDER BY ev.record_id ASC LIMIT ?`,
)
.bind(tpl.id, ...sel.params, limit)
.all<{ id: string }>();
const scannedIds = (res.results ?? []).map((r) => r.id);
const scanned = scannedIds.length;
// D69:額度截斷——每個候選最多 1 次 D1 writeupdateRecord 對「缺 slot」的 grow 路徑正是 1 次
// INSERT),與 entries 版/reconcile 共用同一顆計數器。
const budget = await maintenanceBudgetToday(env, db);
const ids = scannedIds.slice(0, budget.remaining);
const quotaExceeded = scanned > ids.length;
let tagged = 0;
for (const id of ids) {
const updated = await updateRecord(db, id, { library });
if (updated) tagged += 1;
}
try {
await addMaintenanceUsage(db, tagged);
} catch {
// fail-open:額度計數寫入失敗不影響已經完成的標庫寫入(精神同 embed.ts 的做法)。
}
const remRow = await db
.prepare(
`SELECT COUNT(*) as c FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ? AND ${sel.where}`,
)
.bind(tpl.id, ...sel.params)
.first<{ c: number }>();
return {
library,
scanned,
tagged,
remaining: remRow?.c ?? 0,
quota_limit: budget.limit,
quota_used_today: budget.used + tagged,
quota_exceeded: quotaExceeded,
};
}
/** 待補標統計(三元組版,回報用):符合條件、目前未標記 library 的三元組筆數。 */
export async function tripletLibraryBackfillStatus(
db: D1Database,
opts: TripletLibraryBackfillCriteria & { owner_id: string },
): Promise<{ pending: number }> {
const tripletTemplateName = opts.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE;
const tpl = await db.prepare(`SELECT id FROM templates WHERE name = ?`).bind(tripletTemplateName).first<{ id: string }>();
if (!tpl) return { pending: 0 };
const sel = tripletCriteriaSql(opts);
const row = await db
.prepare(
`SELECT COUNT(*) as c FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ? AND ${sel.where}`,
)
.bind(tpl.id, ...sel.params)
.first<{ c: number }>();
return { pending: row?.c ?? 0 };
}
+50 -118
View File
@@ -38,13 +38,6 @@ export interface LibraryMapRow {
narrative: string | null;
top_entities: string[]; // 全館視圖只回 top 3 名字(數百 token 內,design §4 MCP instructions 用)
triplet_count: number;
// Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於「這庫沒有知識」——
// entriesingest 進來的原始卡片/skill 內容)與 triplet(從 entries 萃取出的三元組)是兩件事,
// 一個庫可能有大量 entries、但三元組萃取從沒對它跑過(實測:kb 以外 7 庫皆此況,entries 存在
// 且 library 標記正確,triplet 一筆都沒有)。entry_count 讓讀端(AIMCP 渲染層)分得清
// 「真的沒東西」與「有東西但還沒被萃取成三元組」,不再把後者誤讀成前者、誤判庫是空的而放棄查詢
// (見本檔 liveEntryCountsByLibrary 的計數口徑)。
entry_count: number;
updated_at: number;
}
@@ -57,7 +50,6 @@ export interface LibraryMapDetail {
relation_profile: RelationStat[];
bridges: Bridge[];
triplet_count: number;
entry_count: number; // 同 LibraryMapRow.entry_count(見該欄位註解)
commit_hash: string | null;
status: string;
updated_at: number;
@@ -113,45 +105,36 @@ export async function ensureTripletLibrarySlot(db: D1Database, tripletTemplate:
// ---- 聚合 SQLM2 recompute ----
// record → 一列一 triplet 的 pivot0007 樹狀 record 模型:格子=關係列)。
// b=歸屬關係列(rel=sys_belongs, dst=template 的 sheet entry)=record 成員名單;
// r=該 record 的格子關係列;v=格子指到的內容 entry。欄位謂詞 id 決定性衍生
// fld_<template>_<slot>,與 0007 遷移、record-crud fieldEntryId 同一條規則)⇒
// 直接用字串串接比對,不必 JOIN field entry。MAX(CASE …) 縱轉橫慣用法照舊;
// owner filter 下在歸屬關係列的 owner_idcreateRecord 寫入時同值,0007 遷移同一推導)。
// 參數簽名與舊版逐字相同:[template_id, owner?]。
// recordentry_values 縱表)→ 一列一 triplet 的 pivot。MAX(CASE …) 是 SQLite 縱轉橫慣用法;
// owner filter 直接下在 pivot 前(record 的所有 slot entries 同 ownercreateRecord 寫入時同值)。
function tripletPivotSql(ownerFiltered: boolean): string {
return `SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_subject' THEN v.content END) AS subject,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_object' THEN v.content END) AS object,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_predicate' THEN v.content END) AS predicate,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_source_uri' THEN v.content END) AS source_uri
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? ' AND b.owner_id = ?' : ''}
GROUP BY b.src_id`;
return `SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'subject' THEN e.content END) AS subject,
MAX(CASE WHEN ev.slot_name = 'object' THEN e.content END) AS object,
MAX(CASE WHEN ev.slot_name = 'predicate' THEN e.content END) AS predicate,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
MAX(CASE WHEN ev.slot_name = 'source_uri' THEN e.content END) AS source_uri
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${ownerFiltered ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id`;
}
// library_map 自身 record 的 pivot(讀端+supersede 查找共用;同上 0007 形狀)。
// library_map 自身 record 的 pivot(讀端+supersede 查找共用)。
function mapPivotSql(ownerFiltered: boolean): string {
return `SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_narrative' THEN v.content END) AS narrative,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_top_entities' THEN v.content END) AS top_entities,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_relation_profile' THEN v.content END) AS relation_profile,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_bridges' THEN v.content END) AS bridges,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_triplet_count' THEN v.content END) AS triplet_count,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_commit_hash' THEN v.content END) AS commit_hash,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(r.created_at) AS ts
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerFiltered ? ' AND b.owner_id = ?' : ''}
GROUP BY b.src_id`;
return `SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'library' THEN e.content END) AS library,
MAX(CASE WHEN ev.slot_name = 'narrative' THEN e.content END) AS narrative,
MAX(CASE WHEN ev.slot_name = 'top_entities' THEN e.content END) AS top_entities,
MAX(CASE WHEN ev.slot_name = 'relation_profile' THEN e.content END) AS relation_profile,
MAX(CASE WHEN ev.slot_name = 'bridges' THEN e.content END) AS bridges,
MAX(CASE WHEN ev.slot_name = 'triplet_count' THEN e.content END) AS triplet_count,
MAX(CASE WHEN ev.slot_name = 'commit_hash' THEN e.content END) AS commit_hash,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status,
MAX(ev.created_at) AS ts
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${ownerFiltered ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id`;
}
function parseJsonArray<T>(raw: string | null | undefined): T[] {
@@ -302,10 +285,6 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
superseded.push(row.rid);
}
// entry_countArcrun#87 三次收尾):recompute 只重算三元組那一半,entry_count 是即時算的
// 另一半,兩者同樣的道理一起回傳(見 LibraryMapDetail.entry_count 欄位註解)。
const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0;
return {
map: {
record_id: blockEntry.id,
@@ -316,7 +295,6 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
relation_profile: relationProfile,
bridges,
triplet_count: tripletCount,
entry_count: entryCount,
commit_hash: input.commit_hash ?? null,
status: 'active',
updated_at: blockEntry.created_at,
@@ -369,49 +347,18 @@ async function liveTripletCountsByLibrary(
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
const res = await db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree matrix/arcrun/.worktree-fix-87/(避免打斷另一 session 佔用中的 matrix/arcrun 主 checkout),hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆
`SELECT COALESCE(NULLIF(tr.library, ''), 'general') AS library, COUNT(*) AS n
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
FROM (
SELECT b.src_id AS rid,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_status' THEN v.content END) AS status,
MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_library' THEN v.content END) AS library
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? ' AND b.owner_id = ?' : ''}
GROUP BY b.src_id
SELECT ev.record_id AS rid,
MAX(CASE WHEN ev.slot_name = 'status' THEN e.content END) AS status
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id
) AS tr
LEFT JOIN entry_values lev ON lev.record_id = tr.rid AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
WHERE COALESCE(tr.status, 'active') = 'active'
GROUP BY COALESCE(NULLIF(tr.library, ''), 'general')`,
)
.bind(...params)
.all<{ library: string; n: number }>();
const m: LibraryCountMap = new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
return m;
}
// 這個 owner 底下、依 entries 自己的 metadata_json.$.library 分組的即時「原始內容」數
//Arcrun#87 三次收尾,2026-08-13)——與 liveTripletCountsByLibrary 算的是兩張完全不同的帳:
// 那個算「萃取出幾條三元組」,這個算「ingest 進來幾筆原始卡片/skill/block」。兩者天生會不一樣
// (萃取是下游、有延遲甚至從沒對某些庫跑過),刻意分開算、分開回傳,不是同一數字的兩種寫法。
//
// 排除口徑(都是結構性排除,不是內容語意判斷——沒有違反「base 對內容語意無知」):
// - entry_type='value'record-crud.ts 的通用機制,任何 template(含 triplet 本身)建 record
// 時每個 slot 值都會落一筆這種 entry,是儲存實作細節,不是使用者看得到的「一筆知識」。
// - metadata.kind='library_map' 的 block:地圖自己每次 recompute 產出的摘要 block(含歷史
// superseded 的),算進去會自我膨脹、且無限接近雞生蛋——地圖不該把自己算進地圖裡。
async function liveEntryCountsByLibrary(db: D1Database, owner_id?: string): Promise<LibraryCountMap> {
const params: unknown[] = owner_id ? [owner_id] : [];
const res = await db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆)
`SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
COUNT(*) AS n
FROM entries
WHERE ${owner_id ? 'owner_id = ? AND ' : ''}entry_type != 'value'
AND src_id IS NULL
AND entry_type NOT IN ('record', 'sheet', 'field', 'system')
AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map')
GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')`,
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
)
.bind(...params)
.all<{ library: string; n: number }>();
@@ -441,12 +388,10 @@ async function knownLibraryNames(db: D1Database, owner_id?: string): Promise<Lib
const libParams: unknown[] = owner_id ? [libTpl.id, owner_id] : [libTpl.id];
const libRows = await db
.prepare(
`SELECT MAX(CASE WHEN r.rel_id = 'fld_' || b.dst_id || '_name' THEN v.content END) AS name
FROM entries b
LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs'
LEFT JOIN entries v ON v.id = r.dst_id
WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${owner_id ? ' AND b.owner_id = ?' : ''}
GROUP BY b.src_id`,
`SELECT MAX(CASE WHEN ev.slot_name = 'name' THEN e.content END) AS name
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
GROUP BY ev.record_id`,
)
.bind(...libParams)
.all<{ name: string | null }>();
@@ -508,26 +453,20 @@ interface MapPivotRow {
ts: number;
}
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_countentry_count),MCP
// instructions 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→
// 誠實回空清單。entry_count 與 triplet_count 並排回傳(Arcrun#87 三次收尾):一個查「原始內容
// 有沒有」、一個查「萃取出幾條三元組」,兩者不同源、允許不一致(見 liveEntryCountsByLibrary
// 註解),讀端不該把其中一個的 0 當成另一個的答案。
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_count),MCP instructions
// 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→ 誠實回空清單。
export async function listLibraryMaps(db: D1Database, owner_id?: string): Promise<LibraryMapRow[]> {
const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
if (!tpl) return [];
const params: unknown[] = owner_id ? [tpl.id, owner_id] : [tpl.id];
const [res, entryCounts] = await Promise.all([
db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`,
)
.bind(...params)
.all<MapPivotRow>(),
liveEntryCountsByLibrary(db, owner_id),
]);
const res = await db
.prepare(
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`,
)
.bind(...params)
.all<MapPivotRow>();
// 每庫只留最新 activesupersede 失敗殘留多個 active 時,讀端自癒取最新——順序安全的另一半)。
const byLib = new Map<string, LibraryMapRow>();
for (const r of res.results ?? []) {
@@ -537,7 +476,6 @@ export async function listLibraryMaps(db: D1Database, owner_id?: string): Promis
narrative: r.narrative || null,
top_entities: parseJsonArray<TopEntity>(r.top_entities).slice(0, 3).map((t) => t.name),
triplet_count: Number(r.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(r.library) ?? 0,
updated_at: r.ts,
});
}
@@ -563,12 +501,7 @@ export async function getLibraryMapDetail(
.first<MapPivotRow>();
if (!row) return null;
// record_idmap block entry idrecompute 寫入時綁定);entry 若被外力刪除,content 誠實回 null。
// entry_count 與全館視圖(listLibraryMaps)同一套計法(liveEntryCountsByLibrary),單庫詳圖
// 只取自己那一庫的數字——Arcrun#87 三次收尾,理由見 LibraryMapDetail.entry_count 欄位註解。
const [blockEntry, entryCounts] = await Promise.all([
getEntry(db, row.rid),
liveEntryCountsByLibrary(db, owner_id),
]);
const blockEntry = await getEntry(db, row.rid);
return {
record_id: row.rid,
library,
@@ -578,7 +511,6 @@ export async function getLibraryMapDetail(
relation_profile: parseJsonArray<RelationStat>(row.relation_profile),
bridges: parseJsonArray<Bridge>(row.bridges),
triplet_count: Number(row.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(library) ?? 0,
commit_hash: row.commit_hash || null,
status: row.status ?? 'active',
updated_at: row.ts,
+99 -314
View File
@@ -1,15 +1,5 @@
// Template + Record CRUD — 樹狀 record 模型(v7 定稿,2026-08-15 confirm)。
//
// 模型(leo 定案):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」
// · record 池中一顆有身分的 entryrecord_id 就是它的 id
// · 一格 一條關係列(src=record、rel=field entry、dst=value entry)——池上型別化指標欄
// · 歸屬 一條關係列(src=record、rel=sys_belongs、dst=sheet
// · 欄位是關係、歸屬也是關係——同一種機制。entry_values 表已拆(0007),
// 它是「關係」的第二套實作(D92:同一件事兩個實作必然漂移)。
//
// 遷移期雙軌(第二刀收):templates 表仍是「欄位定義」的真相源(slots_jsondescription),
// sheetfield entry 是它們在池中的身分;createTemplate/updateTemplate 同步維護兩邊,
// 維護語句全部 INSERT OR IGNORE(決定性 id)⇒ 冪等、可自癒。
// Template + Record CRUD. A "record" = multiple entries composed via a template's slots.
// Base, D1 only. (Ported clean from KBDB; no vectorize/triplet imports.)
import type { Template } from '../types';
import { createEntry } from './entry-crud';
@@ -17,46 +7,7 @@ function uid(prefix: string): string {
return `${prefix}_${crypto.randomUUID()}`;
}
// ── 啟動常數(0007 seed;一組、極小、只讀)─────────────────────────────
export const SYS_ROOT = 'sys_root'; // 屬於鏈的終點:entry ─屬於→ sys_root 它是 sheet
export const SYS_BELONGS = 'sys_belongs'; // 歸屬謂詞:record ─屬於→ sheet
export const SYS_FIELD_OF = 'sys_field_of';// 欄位名冊謂詞:field ─field_of→ sheet
/** field entry 的決定性 id(0007 遷移與執行期寫入共用同一條衍生規則,兩邊永遠對得上)。 */
export function fieldEntryId(templateId: string, slot: string): string {
return `fld_${templateId}_${slot}`;
}
/** 啟動常數自癒(冪等;空庫或部分遷移的實例第一次寫入時補齊)。 */
async function ensureAnchors(db: D1Database): Promise<void> {
await db
.prepare(
`INSERT OR IGNORE INTO entries (id, content, entry_type, owner_id) VALUES
('${SYS_ROOT}', 'root', 'system', NULL),
('${SYS_BELONGS}', 'belongs', 'system', NULL),
('${SYS_FIELD_OF}', 'field_of', 'system', NULL)`,
)
.run();
}
/** 這批 slot 的 field entry 名冊關係(field ─field_of→ sheet)自癒建立(冪等)。 */
async function ensureFieldEntries(db: D1Database, templateId: string, slots: string[]): Promise<void> {
for (const slot of slots) {
const fid = fieldEntryId(templateId, slot);
await db
.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'field')`)
.bind(fid, slot)
.run();
await db
.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_FIELD_OF}', ?)`,
)
.bind(`relf_${templateId}_${slot}`, fid, templateId)
.run();
}
}
// ---- Templates(遷移期雙軌:表=欄位定義真相源,池中 sheet/field entry=身分)----
// ---- Templates ----
export interface CreateTemplateInput {
name: string;
@@ -72,19 +23,6 @@ export async function createTemplate(db: D1Database, input: CreateTemplateInput)
.prepare(`INSERT INTO templates (id, name, description, slots_json, created_by) VALUES (?, ?, ?, ?, ?)`)
.bind(id, input.name, input.description ?? null, JSON.stringify(input.slots), input.created_by ?? null)
.run();
// 池中身分:sheet entryid 沿用 template id)+ sheet ─屬於→ 保留根 + 欄位名冊
await ensureAnchors(db);
await db
.prepare(`INSERT OR IGNORE INTO entries (id, content, entry_type) VALUES (?, ?, 'sheet')`)
.bind(id, input.name)
.run();
await db
.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, '${SYS_BELONGS}', '${SYS_ROOT}')`,
)
.bind(`relb_${id}`, id)
.run();
await ensureFieldEntries(db, id, input.slots);
const row = await getTemplate(db, id);
if (!row) throw new Error('createTemplate: row not found after insert');
return row;
@@ -111,341 +49,188 @@ export async function updateTemplate(db: D1Database, id: string, patch: { descri
if (cols.length === 0) return getTemplate(db, id);
cols.push('updated_at = unixepoch()');
await db.prepare(`UPDATE templates SET ${cols.join(', ')} WHERE id = ?`).bind(...params, id).run();
// 新增的 slot 要有 field entry(欄名=關係的謂詞),否則後續寫格子會指到不存在的謂詞
if (patch.slots !== undefined) await ensureFieldEntries(db, id, patch.slots);
return getTemplate(db, id);
}
// ---- Records(關係列組成;entry_values 已拆)----
// ---- Records (entry_values composed by template) ----
export interface CreateRecordInput {
template: string; // template id or name
values?: Record<string, string>; // slot_name -> content**新建**一筆 entry 當這個 slot 的值)
/**
* slot_name -> **** entry identries entry
* Arcrun#128
* value entry id entry****
*/
entry_ids?: Record<string, string>;
values: Record<string, string>; // slot_name -> content
owner_id?: string | null;
record_id?: string;
}
/** 被參照 entry 的 id -> 它現在的 content(回傳值要帶真內容,不是空殼)。 */
type ReferencedContent = Map<string, string | null>;
/**
* entry****
* 1. id FK
* 2. entry_id
* 3. entry
* INSERT
* 90 id D1 100沿
*/
async function loadReferencedEntries(
db: D1Database,
entryIds: Record<string, string>,
recordOwnerId: string | null,
): Promise<ReferencedContent> {
const ids = [...new Set(Object.values(entryIds))];
if (ids.length === 0) return new Map();
const rows: { id: string; content: string | null; owner_id: string | null }[] = [];
for (let i = 0; i < ids.length; i += 90) {
const chunk = ids.slice(i, i + 90);
const res = await db
.prepare(`SELECT id, content, owner_id FROM entries WHERE id IN (${chunk.map(() => '?').join(',')})`)
.bind(...chunk)
.all<{ id: string; content: string | null; owner_id: string | null }>();
rows.push(...(res.results ?? []));
}
const found = new Map(rows.map((r) => [r.id, r]));
const missing = ids.filter((id) => !found.has(id));
if (missing.length > 0) throw new Error(`entry not found: ${missing.join(', ')}`);
// 歸屬不同 → 擋。owner_id 為 null 的 entry 視為無主/共用(既有資料多半如此),放行。
if (recordOwnerId != null) {
const foreign = rows.filter((r) => r.owner_id != null && r.owner_id !== recordOwnerId);
if (foreign.length > 0) {
throw new Error(
`entry owner mismatch: ${foreign.map((r) => `${r.id}(${r.owner_id})`).join(', ')} != ${recordOwnerId}`,
);
}
}
return new Map(rows.map((r) => [r.id, r.content]));
}
export interface RecordResult {
record_id: string;
template_id: string;
values: Record<string, string>;
/** record 的歸屬。新模型直接存在 record 身分 entry 上(不再從 slot entries 推導)。 */
/**
* record slot entries owner_idcreateRecord
* 2026-08-12 `GET /records/:id` ****
* id cypher portal /AI
* 404 null
*/
owner_id: string | null;
}
/** record 的歸屬關係(record ─屬於→ sheet,排除 sheet 自己的 ─屬於→ 保留根)。 */
async function recordBelongs(db: D1Database, recordId: string): Promise<{ dst_id: string } | null> {
const row = await db
.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id = '${SYS_BELONGS}' AND dst_id != '${SYS_ROOT}' LIMIT 1`)
.bind(recordId)
.first<{ dst_id: string }>();
return row ?? null;
}
async function insertCellRelation(
db: D1Database,
recordId: string,
templateId: string,
slot: string,
dstEntryId: string,
ownerId: string | null,
): Promise<void> {
await db
.prepare(
`INSERT INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, ?, ?)`,
)
.bind(uid('relv'), ownerId, recordId, fieldEntryId(templateId, slot), dstEntryId)
.run();
}
export async function createRecord(db: D1Database, input: CreateRecordInput): Promise<RecordResult> {
const tpl = await getTemplate(db, input.template);
if (!tpl) throw new Error(`template not found: ${input.template}`);
const slots: string[] = JSON.parse(tpl.slots_json);
const recordId = input.record_id ?? uid('rec');
const values = input.values ?? {};
const entryIds = input.entry_ids ?? {};
const refSlots = Object.keys(entryIds);
const ownerId = input.owner_id ?? null;
// 同一個 slot 不准同時給字串又給 id:兩者的意思相反(複製一份 vs 指向既有),不猜。
const both = refSlots.filter((s) => s in values);
if (both.length > 0) throw new Error(`slot given both value and entry_id: ${both.join(', ')}`);
// entry_ids 指到 template 沒有的 slot → 報錯(不學 values 那條「靜默略過」——
// 指標設了卻無聲消失是最難查的失敗)。
const unknown = refSlots.filter((s) => !slots.includes(s));
if (unknown.length > 0) throw new Error(`slot not in template: ${unknown.join(', ')}`);
// 全部檢查(存在/歸屬)先跑完再寫,失敗=一列都沒寫。
const referenced = await loadReferencedEntries(db, entryIds, ownerId);
// record 身分:池中一顆 entry。record_id 已是池中既有 entryblock 即 record 慣例,
// 如 library_map 的 map block)→ 那顆 entry 本人就是身分,不另建、不覆蓋。
await db
.prepare(`INSERT OR IGNORE INTO entries (id, entry_type, owner_id) VALUES (?, 'record', ?)`)
.bind(recordId, ownerId)
.run();
// 歸屬=一條關係(舊 template_id 欄的下場)
await db
.prepare(
`INSERT OR IGNORE INTO entries (id, entry_type, owner_id, src_id, rel_id, dst_id) VALUES (?, 'relation', ?, ?, '${SYS_BELONGS}', ?)`,
)
.bind(`relb_${recordId}_${tpl.id}`, ownerId, recordId, tpl.id)
.run();
// 欄位謂詞自癒(決定性 id,冪等)——只補這次真的要寫的 slot
const writtenSlots = slots.filter((s) => s in entryIds || s in values);
await ensureFieldEntries(db, tpl.id, writtenSlots);
for (const slot of writtenSlots) {
if (slot in entryIds) {
// 指向既有 entry:只插一條關係列,不碰內容(這就是指標)。
await insertCellRelation(db, recordId, tpl.id, slot, entryIds[slot], ownerId);
continue;
}
for (const slot of slots) {
if (!(slot in input.values)) continue;
const entry = await createEntry(db, {
content: values[slot],
content: input.values[slot],
entry_type: 'value',
owner_id: ownerId,
owner_id: input.owner_id ?? null,
});
await insertCellRelation(db, recordId, tpl.id, slot, entry.id, ownerId);
await db
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
.bind(uid('ev'), recordId, tpl.id, slot, entry.id)
.run();
}
// 回傳值:舊路徑照舊原樣回 input.values,指標來的 slot 補上那條既有 entry 的現有內容。
const out: Record<string, string> = { ...values };
for (const [slot, entryId] of Object.entries(entryIds)) out[slot] = referenced.get(entryId) ?? '';
return { record_id: recordId, template_id: tpl.id, values: out, owner_id: ownerId };
return { record_id: recordId, template_id: tpl.id, values: input.values, owner_id: input.owner_id ?? null };
}
// Update an existing record's slot values(行為契約與 entry_values 時代一字不變):
// - slot 已有格子 → UPDATE 指到的 entries.content(**只有指標才連動**:所有指著同一顆的都看到新值)
// - slot 在 template 裡但還沒有格子 → 新建 entry + 一條關係列(grow
// - slot 不在 template → reject
// 回 null = record 不存在(沒有歸屬關係)。
// Update an existing record's slot values (mira-dissolve T2.1, issue #6).
// "Deprecate by flipping a slot value" — base append-only is NOT broken: we change the
// underlying entries.content of the slot's entry, we do not alter table structure / add columns / delete rows.
// - slot already on the record → UPDATE the linked entries.content.
// - slot valid for the record's template but not yet present → create entry + entry_value (idempotent grow).
// - slot not in the template's slots_json → reject (records must stay template-shaped).
// Returns null if the record does not exist.
export async function updateRecord(
db: D1Database,
recordId: string,
values: Record<string, string>,
): Promise<RecordResult | null> {
const belongs = await recordBelongs(db, recordId);
if (!belongs) return null; // record does not exist
const templateId = belongs.dst_id;
// 既有格子:slot(謂詞 entry 的 content)→ 指到的 entry id(重複 slot 允許 → 全部收)
const cellRes = await db
// Existing slot → entry_id + template_id for this record.
// JOIN entries 帶回 owner_idgrow 路徑建新 entry 時要沿用 record 既有 owner_id
//portal-auth design §2.2 附帶修復——原本漏帶 → 孤兒 entryowner_id=NULL),
// owner-scoped 查詢(searchByTemplate / searchEntries)看不到該 slot 值)。
const evRes = await db
.prepare(
`SELECT f.content AS slot_name, r.dst_id AS entry_id
FROM entries r JOIN entries f ON r.rel_id = f.id
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`,
`SELECT ev.slot_name AS slot_name, ev.entry_id AS entry_id, 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<{ slot_name: string; entry_id: string }>();
const cells = cellRes.results ?? [];
const slotToEntries = new Map<string, string[]>();
for (const c of cells) {
const list = slotToEntries.get(c.slot_name) ?? [];
list.push(c.entry_id);
slotToEntries.set(c.slot_name, list);
}
.all<{ slot_name: string; entry_id: string; template_id: string; owner_id: string | null }>();
const evRows = evRes.results ?? [];
if (evRows.length === 0) return null; // record does not exist
// record 的歸屬=身分 entry 的 ownergrow 建新 entry 時沿用,防孤兒 entry
const identity = await db.prepare('SELECT owner_id FROM entries WHERE id = ?').bind(recordId).first<{ owner_id: string | null }>();
const recordOwnerId = identity?.owner_id ?? null;
const templateId = evRows[0].template_id;
// record 的歸屬=其既有 slot entries 的 owner_idcreateRecord 寫入時同一值)。
const recordOwnerId = evRows.find((r) => r.owner_id != null)?.owner_id ?? null;
const slotToEntry = new Map(evRows.map((r) => [r.slot_name, r.entry_id]));
const tpl = await getTemplate(db, templateId);
const allowed: string[] = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntries.keys()];
const allowed: string[] = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntry.keys()];
for (const [slot, content] of Object.entries(values)) {
if (!allowed.includes(slot)) {
throw new Error(`slot not in template: ${slot}`);
}
const entryIds = slotToEntries.get(slot);
if (entryIds && entryIds.length > 0) {
// flip the slot value: update the linked entry's content(指標連動語意)
for (const entryId of entryIds) {
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
}
const entryId = slotToEntry.get(slot);
if (entryId) {
// flip the slot value: update the linked entry's content (table structure untouched)
await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run();
} else {
// valid template slot not yet on this record → growentry 關係列)
await ensureFieldEntries(db, templateId, [slot]);
// valid template slot not yet on this record → grow it (create entry + link)
// owner_id 帶 record 既有歸屬(design §2.2 附帶修復,防孤兒 entry)
const entry = await createEntry(db, { content, entry_type: 'value', owner_id: recordOwnerId });
await insertCellRelation(db, recordId, templateId, slot, entry.id, recordOwnerId);
await db
.prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`)
.bind(uid('ev'), recordId, templateId, slot, entry.id)
.run();
}
}
return getRecord(db, recordId);
}
export async function getRecord(db: D1Database, recordId: string): Promise<RecordResult | null> {
const belongs = await recordBelongs(db, recordId);
if (!belongs) return null;
const res = await db
.prepare(
`SELECT f.content AS slot, v.content AS content
FROM entries r
JOIN entries f ON r.rel_id = f.id
JOIN entries v ON r.dst_id = v.id
WHERE r.src_id = ? AND r.rel_id != '${SYS_BELONGS}'`,
`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<{ slot: string; content: string }>();
.all<{ slot: string; content: string; template_id: string; owner_id: string | null }>();
const rows = res.results ?? [];
if (rows.length === 0) return null;
const values: Record<string, string> = {};
for (const r of res.results ?? []) values[r.slot] = r.content;
const identity = await db.prepare('SELECT owner_id FROM entries WHERE id = ?').bind(recordId).first<{ owner_id: string | null }>();
return { record_id: recordId, template_id: belongs.dst_id, values, owner_id: identity?.owner_id ?? null };
for (const r of rows) values[r.slot] = r.content;
// 歸屬取第一個非 null 的 slot entry owner(同一 record 的 slot entries 同歸屬)
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 };
}
export async function searchByTemplate(db: D1Database, template: string, owner_id?: string, limit = 100): Promise<RecordResult[]> {
const tpl = await getTemplate(db, template);
if (!tpl) return [];
// owner_id 過濾在 SQL 做:record 的歸屬存在底層 entries.owner_idcreateRecord 寫入時帶)。
// 給了 owner_id → JOIN entries 限定該 owner(租戶隔離,cypher proxy 強制注入);
// 沒給 → 不限(內部/全域查詢)。先前 `|| true` 是 stub,會洩漏跨租戶資料(2026-06-14 修)。
const cap = Math.min(limit, 500);
// record ids:歸屬關係(rel=屬於, dst=sheet)就是成員名單——(dst_id, rel_id) 索引直達。
// owner 過濾下在歸屬關係列的 owner_idcreateRecord 寫入時同值,0007 遷移同一推導)。
const res = owner_id
? await db
.prepare(
`SELECT src_id AS record_id FROM entries
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ? AND owner_id = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`,
`SELECT DISTINCT ev.record_id as record_id FROM entry_values ev
JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ? AND e.owner_id = ?
ORDER BY ev.created_at DESC LIMIT ?`,
)
.bind(tpl.id, owner_id, cap)
.all<{ record_id: string }>()
: await db
.prepare(
`SELECT src_id AS record_id FROM entries
WHERE rel_id = '${SYS_BELONGS}' AND dst_id = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`,
)
.prepare(`SELECT DISTINCT record_id FROM entry_values WHERE template_id = ? ORDER BY created_at DESC LIMIT ?`)
.bind(tpl.id, cap)
.all<{ record_id: string }>();
// 批次撈齊所有 record 的 slot 值(2026-07-18 修 N+1:原本逐筆 getRecord=每筆 1 次 D1
// 往返,100 筆 triplet ≈ 19 秒——graph/總圖/rag_chat 的「查詢 20-30 秒」病根就是這裡)。
// D1 綁定參數上限 100 → id 以 90 一組分批 IN 查詢;輸出保持原排序(created_at DESC)。
const ids = (res.results ?? []).map((r) => r.record_id);
if (ids.length === 0) return [];
// 批次撈齊格子與身分(N+1 教訓照舊:D1 綁定參數上限 100 → 90 一組)。
const byId = new Map<string, RecordResult>();
for (const id of ids) byId.set(id, { record_id: id, template_id: tpl.id, values: {}, owner_id: null });
for (let i = 0; i < ids.length; i += 90) {
const chunk = ids.slice(i, i + 90);
const placeholders = chunk.map(() => '?').join(',');
const [cellRes, identRes] = await Promise.all([
db
.prepare(
`SELECT r.src_id AS record_id, f.content AS slot, v.content AS content
FROM entries r
JOIN entries f ON r.rel_id = f.id
JOIN entries v ON r.dst_id = v.id
WHERE r.src_id IN (${placeholders}) AND r.rel_id != '${SYS_BELONGS}'`,
)
.bind(...chunk)
.all<{ record_id: string; slot: string; content: string }>(),
db
.prepare(`SELECT id, owner_id FROM entries WHERE id IN (${placeholders})`)
.bind(...chunk)
.all<{ id: string; owner_id: string | null }>(),
]);
for (const r of cellRes.results ?? []) {
const rec = byId.get(r.record_id);
if (rec) rec.values[r.slot] = r.content;
}
for (const r of identRes.results ?? []) {
const rec = byId.get(r.id);
if (rec) rec.owner_id = r.owner_id;
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, 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<{ record_id: string; slot: string; content: string; template_id: string; owner_id: string | null }>();
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: {}, 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 is RecordResult => !!r);
}
/**
* record entry entry
* false record
*
* 🔴 ** entry **Arcrun#128
* entry record
* 🔴 **block record **record_id blocklibrary_map
* entry entry_type 'record' block
*/
/** 刪除一筆 record:先刪 entry_valuesFK),再刪底層 entries。回 false 表示 record 不存在。 */
export async function deleteRecord(db: D1Database, recordId: string): Promise<boolean> {
const belongs = await recordBelongs(db, recordId);
if (!belongs) return false;
const cellRes = await db
.prepare(`SELECT dst_id FROM entries WHERE src_id = ? AND rel_id != '${SYS_BELONGS}'`)
const evRes = await db
.prepare('SELECT entry_id FROM entry_values WHERE record_id = ?')
.bind(recordId)
.all<{ dst_id: string }>();
const dsts = (cellRes.results ?? []).map((r) => r.dst_id);
// 這筆 record 發出的所有關係列(格子+歸屬)一次拆掉
await db.prepare(`DELETE FROM entries WHERE src_id = ?`).bind(recordId).run();
// 裸身分 entryentry_type='record')才刪;被別的關係指著就留(變回池中普通 entry)
await db
.prepare(
`DELETE FROM entries WHERE id = ?1 AND entry_type = 'record'
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)`,
)
.bind(recordId)
.run();
// 指到的內容 entry:沒有任何關係指著、自己也不發出關係、且不是機制節點 → 才刪
for (const dst of dsts) {
await db
.prepare(
`DELETE FROM entries WHERE id = ?1
AND entry_type NOT IN ('sheet', 'field', 'system')
AND NOT EXISTS (SELECT 1 FROM entries WHERE dst_id = ?1)
AND NOT EXISTS (SELECT 1 FROM entries WHERE src_id = ?1)
AND NOT EXISTS (SELECT 1 FROM entries WHERE rel_id = ?1)`,
)
.bind(dst)
.run();
.all<{ entry_id: string }>();
const rows = evRes.results ?? [];
if (rows.length === 0) return false;
await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run();
for (const { entry_id } of rows) {
await db.prepare('DELETE FROM entries WHERE id = ?').bind(entry_id).run();
}
return true;
}
-54
View File
@@ -1,54 +0,0 @@
// 關係列孤兒巡檢(0007 樹狀 record 模型的配套,v7 §5 明訂「要寫成明確的巡檢項,不能默認」)。
//
// 為什麼要有這支(不是順手加的):舊模型的孤兒偵測靠 entry_values 的外鍵形狀——
// 2026-06-24 那次 11 萬筆誤寫的清理就是拿 FK LEFT JOIN 找斷鏈
// system-dev/docs/5-records/2026-06-24-official-kbdb-cleanup-leo-misdelete.md)。
// 0007 拆掉 entry_values 後,關係列的 src/rel/dst 指標**沒有 FK**(同一張表指自己,
// SQLite 自參照 FK 會把插入順序綁死,且 D1 逐句執行無交易可 defer)⇒ 斷鏈不再被
// 資料庫擋下,改由本巡檢主動找:**孤兒=指標指向不存在 id 的關係列**。
// 掃法與舊 FK 形狀同款(LEFT JOIN 找斷鏈),v7 押的「形狀可承接」在這裡兌現。
//
// 什麼情況會產生孤兒(誠實列):
// 1. DELETE /entries/:id 的舊資料時代殘骸(新 deleteEntry 已擋 dst 被指著的刪除)
// 2. 遷移時 template 已被刪但 entry_values 還留著格子(0007 保險網補 field entry
// 但 dst=template 的名冊關係可能指到不存在的 sheet)
// 3. 未來任何繞過牆的直接寫入(本巡檢就是抓它們的網)
import type { D1Database } from '@cloudflare/workers-types';
export interface RelationOrphan {
relation_id: string;
role: 'src' | 'rel' | 'dst';
missing_id: string;
}
export interface RelationOrphanReport {
orphans: RelationOrphan[];
count: number; // 本次回報筆數(受 limit 截斷)
truncated: boolean; // true = 還有更多,加大 limit 或先清這批再掃
}
export async function scanRelationOrphans(db: D1Database, limit = 200): Promise<RelationOrphanReport> {
const cap = Math.min(Math.max(limit, 1), 1000);
const res = await db
.prepare(
`SELECT relation_id, role, missing_id FROM (
SELECT r.id AS relation_id, 'src' AS role, r.src_id AS missing_id
FROM entries r LEFT JOIN entries t ON t.id = r.src_id
WHERE r.src_id IS NOT NULL AND t.id IS NULL
UNION ALL
SELECT r.id, 'rel', r.rel_id
FROM entries r LEFT JOIN entries t ON t.id = r.rel_id
WHERE r.rel_id IS NOT NULL AND t.id IS NULL
UNION ALL
SELECT r.id, 'dst', r.dst_id
FROM entries r LEFT JOIN entries t ON t.id = r.dst_id
WHERE r.dst_id IS NOT NULL AND t.id IS NULL
) LIMIT ?`,
)
.bind(cap + 1)
.all<RelationOrphan>();
const rows = res.results ?? [];
const truncated = rows.length > cap;
const orphans = truncated ? rows.slice(0, cap) : rows;
return { orphans, count: orphans.length, truncated };
}
-10
View File
@@ -39,16 +39,6 @@ app.use('*', async (c, next) => {
app.get('/', (c) => c.json({ service: 'arcrun-kbdb', tier: 'base', status: 'ok' }));
app.get('/health', (c) => c.json({ ok: true }));
// 關係列孤兒巡檢(0007 配套;v7 §5「新模型的孤兒=指標指向不存在 id 的關係列」,
// 舊 entry_values FK 形狀的承接——2026-06-24 清理事故用的就是同款 LEFT JOIN 斷鏈掃描)。
// 唯讀,不自動清:清哪些要人裁(同 embed 孤兒清理的慣例,發現與處置分開)。
app.get('/maintenance/relation-orphans', async (c) => {
const { scanRelationOrphans } = await import('./actions/relation-orphans');
const limit = Number(c.req.query('limit') ?? '200');
const report = await scanRelationOrphans(c.env.DB, Number.isFinite(limit) ? limit : 200);
return c.json({ success: true, ...report });
});
app.route('/entries', entryRoutes);
app.route('/templates', templateRoutes);
app.route('/records', recordRoutes);
+68 -27
View File
@@ -2,28 +2,15 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
import { backfillTripletLibraryTags, tripletLibraryBackfillStatus } from '../actions/library-backfill';
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
// POST /records — { template, values:{slot:content}?, entry_ids:{slot:既有entry_id}?, owner_id? }
//
// values = 給字串 → **新建**一筆 entry 當這個 slot 的值(原本就有的路,行為不變)
// entry_ids = 給既有 entry 的 id → **指向**水池裡那條 entry,不複製(Arcrun#128 的外鍵)
// 兩者可混用;至少要有一個。**只給 entry_ids 是合法的**——例如一則 wiki 的五段在 KBDB 裡
// 本來就已經是五筆既有 entry,建 record 只是替它們取名字,不該再生出任何新 entry。
const isStringMap = (v: unknown): boolean =>
!!v && typeof v === 'object' && !Array.isArray(v) && Object.values(v as object).every((x) => typeof x === 'string');
// POST /records — { template, values:{slot:content}, owner_id? }
recordRoutes.post('/', async (c) => {
const body = await c.req.json().catch(() => null);
if (!body || !body.template || (!body.values && !body.entry_ids)) {
return c.json({ success: false, error: 'template and values (or entry_ids) required' }, 400);
}
if (body.values !== undefined && !isStringMap(body.values)) {
return c.json({ success: false, error: 'values must be an object of {slot: string}' }, 400);
}
if (body.entry_ids !== undefined && !isStringMap(body.entry_ids)) {
return c.json({ success: false, error: 'entry_ids must be an object of {slot: entry_id}' }, 400);
if (!body || !body.template || !body.values) {
return c.json({ success: false, error: 'template and values required' }, 400);
}
try {
const rec = await createRecord(c.env.DB, body);
@@ -36,21 +23,27 @@ recordRoutes.post('/', async (c) => {
// GET /records/triplet-stats?owner_id=... — 每個庫的三元組(關聯)數。
// t1422026-07-29):政府驗收——顯示每個庫整理出幾條知識關聯。
// 計法:依 triplet 型 record 的 'library' slot 值分組計數。無 library slot 的舊三元組歸 general。
// 0007 之後:record 成員名單=歸屬關係列(rel=sys_belongs, dst=triplet sheet
// library 格子=rel 為決定性欄位謂詞 idfld_<template>_library)的關係列——
// 一次 SQL 完成(無 N+1),owner 過濾下在歸屬關係列的 owner_id。
// 使用子查詢先取 distinct triplet record IDs(針對 owner),再 LEFT JOIN library slot
// 避免 N+1(全部一次 SQL 完成,不逐筆 getRecord)。
recordRoutes.get('/triplet-stats', async (c) => {
const owner = c.req.query('owner_id') || '';
// 子查詢:找到屬於這個 owner 的所有 triplet recordsLEFT JOIN library slot 取庫名
const rows = await c.env.DB.prepare(
`SELECT
COALESCE(NULLIF(lib_v.content, ''), 'general') AS library,
COALESCE(NULLIF(lib_e.content, ''), 'general') AS library,
COUNT(*) AS triplet_count
FROM entries b
JOIN templates t ON b.dst_id = t.id AND t.name = 'triplet'
LEFT JOIN entries lr ON lr.src_id = b.src_id AND lr.rel_id = ('fld_' || b.dst_id || '_library')
LEFT JOIN entries lib_v ON lib_v.id = lr.dst_id
WHERE b.rel_id = 'sys_belongs' AND (?1 = '' OR b.owner_id = ?1)
GROUP BY COALESCE(NULLIF(lib_v.content, ''), 'general')
FROM (
SELECT DISTINCT ev.record_id
FROM entry_values ev
JOIN templates t ON ev.template_id = t.id
JOIN entries e ON ev.entry_id = e.id
WHERE t.name = 'triplet'
AND (?1 = '' OR e.owner_id = ?1)
) AS tr
LEFT JOIN entry_values lev
ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')
ORDER BY library`,
)
.bind(owner)
@@ -65,6 +58,54 @@ recordRoutes.get('/by-template/:template', async (c) => {
return c.json({ success: true, records, count: records.length });
});
// POST /records/backfill-library — 三元組版標庫補存量(Arcrun#87 二次收尾,2026-08-13)。
// entries 版(POST /entries/backfill-libraryArcrun#85)補的是卡片層 metadata_json.library
// 藏書地圖讀的是三元組 record 自己的 'library' slot,兩者是不同存放處——本端點補後者,
// 地圖(GET /map)才會真的從 0 變成有意義的數字。
// body(必填 library + owner_id):{ library, owner_id, triplet_template?(預設 'triplet',
// source_prefix?(必要篩選:本票的規則=gitea:Leo/<repo>@ 前綴 → 對應庫;kb:// → 'kb',
// limit?(1-500,預設100) }。
// 冪等:只選「目前沒有 library slot 值」的三元組;分批:單次 limit 上限,remaining>0 → 重複呼叫直到 0。
// budget:與 /entries/backfill-library、/embed/reconcile 共用同一顆每日 D1 寫入額度(D69)。
// 此路由必須在 '/:recordId' 之前註冊,否則 'backfill-library' 會被當成 recordId 參數。
recordRoutes.post('/backfill-library', async (c) => {
const body = (await c.req.json().catch(() => ({}))) as {
library?: string;
owner_id?: string;
triplet_template?: string;
source_prefix?: string;
limit?: number | string;
};
const library = String(body.library ?? '').trim();
const ownerId = String(body.owner_id ?? '').trim();
if (!library || !ownerId) return c.json({ success: false, error: 'library 與 owner_id 必填' }, 400);
try {
const result = await backfillTripletLibraryTags(c.env.DB, c.env, {
library,
owner_id: ownerId,
triplet_template: body.triplet_template || undefined,
source_prefix: body.source_prefix || undefined,
limit: body.limit !== undefined ? Number(body.limit) : undefined,
});
return c.json({ success: true, ...result });
} catch (e) {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
}
});
// GET /records/backfill-library/status?owner_id=&triplet_template=&source_prefix=
// — 符合條件、目前未標記 library 的三元組筆數(backfill 前後都能查,判斷還剩多少)。
recordRoutes.get('/backfill-library/status', async (c) => {
const ownerId = c.req.query('owner_id') || '';
if (!ownerId) return c.json({ success: false, error: 'owner_id 必填' }, 400);
const status = await tripletLibraryBackfillStatus(c.env.DB, {
owner_id: ownerId,
triplet_template: c.req.query('triplet_template') || undefined,
source_prefix: c.req.query('source_prefix') || undefined,
});
return c.json({ success: true, ...status });
});
// GET /records/:recordId
recordRoutes.get('/:recordId', async (c) => {
const rec = await getRecord(c.env.DB, c.req.param('recordId'));
+1 -15
View File
@@ -49,16 +49,7 @@ export type EntryType =
| 'execution_log'
| 'execution_log_usage'
| 'embed_backfill_usage'
| 'kbdb_maintenance_usage'
// 樹狀 record 模型(0007v7 定稿 2026-08-15):機制節點與關係列。
// 誠實註記:entry_type 在 v7 §7 的白名單終局裡會整欄消失(型別只能由關係推導),
// 這裡先沿用它做遷移期的機制列標記——程式邏輯一律以指標欄(src_id IS NOT NULL)判斷
// 「是不是關係列」,不依賴這個標記。
| 'relation' // 一列關係:src ─rel→ dst(池上型別化指標欄)
| 'record' // record 的裸身分 entryblock 即 record 時身分是那顆 block,不是這個型別)
| 'sheet' // 一張表(template 在池中的身分,id 沿用 template id
| 'field' // 一個欄位(欄名=關係的謂詞)
| 'system'; // 啟動常數(sys_root / sys_belongs / sys_field_of
| 'kbdb_maintenance_usage';
export interface Entry {
id: string;
@@ -74,11 +65,6 @@ export interface Entry {
is_embedded: number;
confidence: number | null;
metadata_json: string | null;
// 關係的物理載體(0007):池上型別化指標欄。內容 entry 三欄全 NULL
// 關係列三欄全非 NULL(src ─rel→ dst)。紅線:指標永不塞回 content/JSOND91)。
src_id: string | null;
rel_id: string | null;
dst_id: string | null;
created_at: number;
updated_at: number;
}
@@ -29,7 +29,6 @@ function makeSqliteD1(): D1Database {
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔,比照 execution-log.test.ts
raw.exec(readFileSync(new URL('../migrations/0002_credentials.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0005_credential_template.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
@@ -90,9 +89,8 @@ describe('credential-legacy-migration — 反向驗證:重現 2026-08-07 youli
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) // kbdb-sql-ok: 測試查詢
.all<{ name: string }>();
const names = (tables.results ?? []).map((t) => t.name).sort();
// entries/templates 兩張核心表(entry_values 已由 0007 拆掉——關係的第二套實作,D92)
// + credentials(舊表,尚未清理)——沒有第四張表。
expect(names).toEqual(['credentials', 'entries', 'templates']);
// entries/templates/entry_values 三張核心表 + credentials(舊表,尚未清理)——沒有第五張表。
expect(names).toEqual(['credentials', 'entries', 'entry_values', 'templates']);
});
});
-1
View File
@@ -31,7 +31,6 @@ const CURRENT_MODEL = '@cf/baai/bge-m3'; // embed.ts DEFAULT_EMBED_MODEL(未 e
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
+1 -1
View File
@@ -12,7 +12,7 @@ function mkEntry(id: string, content: string, ownerId = 'leo', is_embedded = 1):
return {
id, content, entry_type: 'block', owner_id: ownerId, parent_id: null, page_name: null,
refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded,
confidence: null, metadata_json: JSON.stringify({ embed: true }), src_id: null, rel_id: null, dst_id: null, created_at: 1, updated_at: 1,
confidence: null, metadata_json: JSON.stringify({ embed: true }), created_at: 1, updated_at: 1,
};
}
+2 -7
View File
@@ -36,7 +36,6 @@ function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
@@ -71,11 +70,7 @@ describe('execution-log — schema 零異動(證明沒建表)', () => {
expect(sql).toContain('INSERT OR IGNORE INTO templates');
});
// 0007(樹狀 record 模型,v7 定稿 2026-08-15 confirm)後的正解表清單:entry_values 已拆
// ——它是「關係」的第二套實作(D92),關係列改住 entries 的指標欄。這條斷言在改版前
// 釘的是「三張核心表」,規格層變更(pending-changes.md「record 要有身分」)把答案改掉,
// 不是實作去配合測試。
it('template 存在(tpl-execution-log),核心表只剩 entries/templatesentry_values 已拆,0007', async () => {
it('template 存在(tpl-execution-log),entries/templates/entry_values 三表結構不變', async () => {
const db = makeSqliteD1();
const tpl = await db.prepare('SELECT * FROM templates WHERE name = ?').bind('execution_log').first<{ id: string }>();
expect(tpl?.id).toBe('tpl-execution-log');
@@ -84,7 +79,7 @@ describe('execution-log — schema 零異動(證明沒建表)', () => {
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
).all<{ name: string }>();
const names = (tables.results ?? []).map((t) => t.name).sort();
expect(names).toEqual(['entries', 'templates']);
expect(names).toEqual(['entries', 'entry_values', 'templates']);
});
});
-1
View File
@@ -23,7 +23,6 @@ import type { Bindings, Entry, EntryType } from '../src/types';
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },

Some files were not shown because too many files have changed in this diff Show More