Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1370e2275 | |||
| 793a94ecb5 | |||
| cbeddf7535 | |||
| d7c6bd0680 | |||
| a5e4caf5cb |
@@ -3145,6 +3145,11 @@ function graphBase(env) {
|
||||
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, "");
|
||||
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
|
||||
}
|
||||
function graphHeaders(env) {
|
||||
const headers = {};
|
||||
if (env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
return headers;
|
||||
}
|
||||
var kbdbProxyRouter, NEED_KEY;
|
||||
var init_kbdb_proxy = __esm({
|
||||
"cypher-executor/src/routes/kbdb-proxy.ts"() {
|
||||
@@ -3278,8 +3283,7 @@ var init_kbdb_proxy = __esm({
|
||||
kbdbProxyRouter.get("/kbdb/graph/neighbors/:name", async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const base = graphBase(c.env);
|
||||
const headers = {};
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
const headers = graphHeaders(c.env);
|
||||
try {
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param("name"))}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
@@ -14537,6 +14541,20 @@ async function fetchJson(url, headers) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function fetchTripletTotal(env, tenant2) {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
const data = await fetchJson(
|
||||
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant2)}`,
|
||||
headers
|
||||
);
|
||||
if (!data || !Array.isArray(data.stats)) return null;
|
||||
let total = 0;
|
||||
for (const row of data.stats) {
|
||||
if (typeof row?.triplet_count !== "number") return null;
|
||||
total += row.triplet_count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
async function fetchEntryTotal(env, filters) {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
const params = new URLSearchParams({ ...filters, limit: "1" });
|
||||
@@ -14644,6 +14662,7 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
kbdbHealth,
|
||||
embedStatus,
|
||||
graphStats,
|
||||
tripletTotal,
|
||||
entriesTotal,
|
||||
wikiCardTotal,
|
||||
workflowTotal
|
||||
@@ -14655,7 +14674,13 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
cachedGiteaSprint(c.env, now2, (p) => c.executionCtx.waitUntil(p)),
|
||||
fetchJson(`${kbdbUrl}/health`, kbdbHeaders),
|
||||
fetchJson(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
|
||||
fetchJson(`${graphUrl}/triplets/stats`),
|
||||
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
|
||||
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
|
||||
fetchJson(
|
||||
`${graphUrl}/triplets/stats`,
|
||||
graphHeaders(c.env)
|
||||
),
|
||||
fetchTripletTotal(c.env, tenant2),
|
||||
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732)
|
||||
fetchEntryTotal(c.env, { owner_id: tenant2 }),
|
||||
fetchEntryTotal(c.env, { entry_type: "wiki_card", owner_id: tenant2 }),
|
||||
@@ -14763,13 +14788,14 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
system: {
|
||||
kbdb_ok: kbdbHealth ? kbdbHealth.ok === true : false,
|
||||
embed: embedStatus ? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null } : null,
|
||||
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
|
||||
// ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關)
|
||||
graph: { ok: graphStats !== null, triplets: tripletTotal },
|
||||
workflow_total: workflowTotal
|
||||
},
|
||||
kb: {
|
||||
entries_total: entriesTotal,
|
||||
wiki_card_total: wikiCardTotal,
|
||||
triplets_total: graphStats?.total ?? null
|
||||
triplets_total: tripletTotal
|
||||
},
|
||||
generated_at: new Date(now2).toISOString()
|
||||
});
|
||||
@@ -14777,22 +14803,22 @@ consoleDashboardRouter.get("/console/dashboard-data", async (c) => {
|
||||
consoleDashboardRouter.get("/console/kb-scale-data", async (c) => {
|
||||
const tenant2 = c.env.CONSOLE_TENANT || "leo";
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
const now2 = Date.now();
|
||||
const [wikiCards, graphStats, embedStatus] = await Promise.all([
|
||||
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
|
||||
// limit=1 順手拿最新一筆 created_at(list 為 created_at DESC)=「最近寫入時間」
|
||||
fetchJson(
|
||||
`${base}/entries?${new URLSearchParams({ owner_id: tenant2, entry_type: "wiki_card", limit: "1" }).toString()}`,
|
||||
headers
|
||||
),
|
||||
fetchJson(`${graphUrl}/triplets/stats`),
|
||||
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
|
||||
fetchTripletTotal(c.env, tenant2),
|
||||
fetchJson(`${base}/embed/backfill/status`, headers)
|
||||
]);
|
||||
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
|
||||
return c.json({
|
||||
wiki_card_total: typeof wikiCards?.total === "number" ? wikiCards.total : null,
|
||||
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now2, latestMs),
|
||||
triplets_total: typeof graphStats?.total === "number" ? graphStats.total : null,
|
||||
triplets_total: tripletTotal,
|
||||
embedded: embedStatus?.embedded ?? null,
|
||||
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
|
||||
generated_at: new Date(now2).toISOString()
|
||||
@@ -14944,6 +14970,27 @@ function findBestNodeMatch(searchTerm, nodeNames) {
|
||||
if (hits.length === 0) return null;
|
||||
return hits.reduce((a, b) => a.length <= b.length ? a : b);
|
||||
}
|
||||
async function tripletCount(env, owner) {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || !Array.isArray(body.stats)) return null;
|
||||
let total = 0;
|
||||
for (const row of body.stats) {
|
||||
if (typeof row?.triplet_count !== "number") return null;
|
||||
total += row.triplet_count;
|
||||
}
|
||||
return total;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function tripletCensus(env, tenant2) {
|
||||
const owned = await tripletCount(env, tenant2);
|
||||
if (owned !== 0) return { owned, any: null };
|
||||
return { owned, any: await tripletCount(env, "") };
|
||||
}
|
||||
async function fuzzyFindNode(env, tenant2, searchTerm) {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
|
||||
@@ -15045,8 +15092,7 @@ portalDataRouter.get(
|
||||
return c.json(mapGraphWorkflowOutput(result.data));
|
||||
}
|
||||
const base = graphBase(c.env);
|
||||
const headers = {};
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers["Authorization"] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
const headers = graphHeaders(c.env);
|
||||
try {
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
|
||||
if (!res.ok) {
|
||||
@@ -15081,12 +15127,19 @@ portalDataRouter.get(
|
||||
return c.json({ error: "\u7121\u77E5\u8B58\u5716\u8B5C\u6AA2\u8996\u6B0A\u9650" }, 403);
|
||||
}
|
||||
const tenant2 = portalTenant(c.env);
|
||||
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}`);
|
||||
const [res, census] = await Promise.all([
|
||||
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant2)}&limit=500`),
|
||||
tripletCensus(c.env, tenant2)
|
||||
]);
|
||||
const tripletsTotal = census.owned;
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
const records = body && Array.isArray(body.records) ? body.records : [];
|
||||
if (!body || !Array.isArray(body.records)) {
|
||||
return c.json({ error: "\u4E09\u5143\u7D44\u8B80\u53D6\u5931\u6557\uFF1AKBDB \u56DE\u61C9\u4E0D\u662F\u9810\u671F\u7684 records \u6E05\u55AE" }, 502);
|
||||
}
|
||||
const records = body.records;
|
||||
const EDGE_CAP = 500;
|
||||
const seen = /* @__PURE__ */ new Set();
|
||||
const edges = [];
|
||||
@@ -15112,7 +15165,24 @@ portalDataRouter.get(
|
||||
degree.set(o, (degree.get(o) ?? 0) + 1);
|
||||
}
|
||||
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
|
||||
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
|
||||
let emptyReason = null;
|
||||
if (nodes.length === 0) {
|
||||
if (census.owned === null) emptyReason = "unreadable";
|
||||
else if (census.owned > 0) emptyReason = "scope_mismatch";
|
||||
else if (census.any === null) emptyReason = "unreadable";
|
||||
else emptyReason = census.any > 0 ? "scope_mismatch" : "confirmed_empty";
|
||||
}
|
||||
return c.json({
|
||||
nodes,
|
||||
edges,
|
||||
node_count: nodes.length,
|
||||
edge_count: edges.length,
|
||||
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
|
||||
truncated: truncated || records.length >= 500,
|
||||
triplets_total: tripletsTotal,
|
||||
empty_confirmed: nodes.length > 0 || emptyReason === "confirmed_empty",
|
||||
empty_reason: emptyReason
|
||||
});
|
||||
})
|
||||
);
|
||||
portalDataRouter.get(
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"built_for": "arcrun-tier2-worker-artifacts",
|
||||
"generated_at": "2026-08-12T04:21:48.581Z",
|
||||
"repo_head": "b302c03ea8076bcfe82bbcebb1523dcec2d1e830",
|
||||
"generated_at": "2026-08-12T05:36:26.216Z",
|
||||
"repo_head": "cbeddf753537fbf836e20b7efce5b47b50f30d06",
|
||||
"repo_dirty": false,
|
||||
"workers": [
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"source_dir": "cypher-executor",
|
||||
"source_commit": "525faaf5d01e156a9b8f90808607bead92f40165",
|
||||
"source_commit": "a5e4caf5cb38c376f892708d8a46ad96a9cc23cc",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 570290,
|
||||
"content_sha256": "49d59597c01b5264875e0295c86c7bb2212bf54d3c5858cd4a167752370fa716",
|
||||
"js_bytes": 572842,
|
||||
"content_sha256": "d1765930ce157de07400dcff21d620a3b5549e0a66f0d6428cc6901631ba73b2",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
|
||||
@@ -277,9 +277,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
} else {
|
||||
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
|
||||
}
|
||||
rows.push(sys.graph && sys.graph.ok
|
||||
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
|
||||
: sysRow('知識圖譜', '● 打不通', 'bad'));
|
||||
// Arcrun#100:「服務活著嗎」與「庫裡有幾條」拆兩列。混一列時,圖服務打不通會把
|
||||
// 「其實有 1854 條」整個吞掉,畫面看起來就像知識庫是空的。數字讀不到寫「讀不到」,不寫 0。
|
||||
var gOk = !!(sys.graph && sys.graph.ok);
|
||||
var tri = sys.graph && sys.graph.triplets != null ? sys.graph.triplets : null;
|
||||
rows.push(sysRow('知識圖譜服務', gOk ? '● 正常' : '● 打不通', gOk ? 'ok' : 'bad'));
|
||||
rows.push(sysRow('三元組(關聯)', tri == null ? '讀不到' : tri.toLocaleString() + ' 條', tri == null ? 'off' : ''));
|
||||
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
|
||||
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
|
||||
// 三元組/已嵌入 已各有一列)
|
||||
|
||||
@@ -934,14 +934,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
fetch(API_BASE + '/console/kb-scale-data')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
var n = function (v) { return v == null ? '?' : v.toLocaleString(); };
|
||||
// #100:讀不到就明說讀不到(原本靜默 return,會把上一輪的舊數字留在畫面上)
|
||||
if (!d) { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; return; }
|
||||
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
|
||||
var parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)];
|
||||
var latest = d.wiki_card_latest_ago_minutes;
|
||||
$('se-scale').textContent = '精耕層 ' + parts.join('・') +
|
||||
(latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');
|
||||
})
|
||||
.catch(function () { /* 規模感拿不到不擋搜尋 */ });
|
||||
.catch(function () { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; });
|
||||
}
|
||||
$('se-sem').addEventListener('click', function () {
|
||||
S.semantic = !S.semantic;
|
||||
@@ -1504,7 +1505,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
]).then(function (rs) {
|
||||
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
|
||||
var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;
|
||||
var n = function (v) { return v == null ? '?' : v.toLocaleString(); };
|
||||
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
|
||||
var rows = '';
|
||||
rows += '<div class="kvline"><span class="muted">服務</span><span class="mono" style="font-size:14px">' + esc(svc.service || 'arcrun-cypher-executor') + '</span></div>';
|
||||
rows += '<div class="kvline"><span class="muted">版本</span><span class="mono" style="color:var(--amber)">' + esc(svc.version || '—') + '</span></div>';
|
||||
|
||||
@@ -1486,7 +1486,14 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
$('se-q').value = name;
|
||||
doGraphSearch(name);
|
||||
}
|
||||
// 讀不到就明說「讀不到」——標題列**絕不**留著 0 或舊數字(Arcrun#100:leo 看到
|
||||
// 「0 個實體・0 條關聯」以為要去上傳文件,其實庫裡有 1854 條,只是這支讀失敗了)。
|
||||
function mapUnavailable(html) {
|
||||
$('map-meta').textContent = '讀不到';
|
||||
$('map-box').innerHTML = '<div class="err" style="padding:30px 10px">' + html + '</div>';
|
||||
}
|
||||
function loadMap() {
|
||||
$('map-meta').textContent = '';
|
||||
$('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">載入總圖中…</div>';
|
||||
$('map-md-link').innerHTML = SOURCE_WEB_BASE
|
||||
? ':<a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
|
||||
@@ -1495,14 +1502,31 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { $('map-box').innerHTML = '<div class="err">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')')) + '</div>'; return; }
|
||||
var nodes = x.d.nodes || [];
|
||||
var edges = x.d.edges || [];
|
||||
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : '');
|
||||
if (!x.ok) { mapUnavailable(esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')'))); return; }
|
||||
// Arcrun#100:「0」只准在後端確認過真的是 0 的時候出現。
|
||||
// 形狀不對 → 讀不到(不是空庫);nodes 為空但 empty_confirmed 不成立 → 讀不到。
|
||||
if (!Array.isArray(x.d.nodes) || !Array.isArray(x.d.edges)) {
|
||||
mapUnavailable('總圖回應格式不對——沒有拿到關聯資料。這不代表知識庫是空的。');
|
||||
return;
|
||||
}
|
||||
var nodes = x.d.nodes, edges = x.d.edges;
|
||||
var total = typeof x.d.triplets_total === 'number' ? x.d.triplets_total : null;
|
||||
if (!nodes.length && x.d.empty_confirmed !== true) {
|
||||
mapUnavailable(x.d.empty_reason === 'scope_mismatch'
|
||||
? '讀不到你這個帳號的關聯資料——知識庫裡有三元組'
|
||||
+ (total ? '(本帳號範圍算到 ' + total.toLocaleString() + ' 條)' : '')
|
||||
+ ',但這張圖一條都抽不出來。<br>'
|
||||
+ '<b>這不是「還沒有關聯」,不用去上傳文件</b>;比較像資料的歸屬範圍對不上,請通知管理員。'
|
||||
: '讀不到知識庫的關聯資料,無法確認庫裡有沒有關聯。<br>'
|
||||
+ '<b>這不是「還沒有關聯」,不用去上傳文件</b>——是這次讀取失敗,請稍後重整或通知管理員。');
|
||||
return;
|
||||
}
|
||||
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯'
|
||||
+ (total !== null && x.d.truncated ? '(全庫共 ' + total.toLocaleString() + ' 條,已達單次上限)' : x.d.truncated ? '・已達上限截斷' : '');
|
||||
if (!nodes.length) { $('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。</div>'; return; }
|
||||
renderMap(nodes, edges);
|
||||
})
|
||||
.catch(function (e) { $('map-box').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
|
||||
.catch(function (e) { mapUnavailable('請求失敗:' + esc(friendlyErr(e))); });
|
||||
}
|
||||
function renderMap(nodes, edges) {
|
||||
var N = nodes.length;
|
||||
|
||||
@@ -77,7 +77,12 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
|
||||
filter: 'SVC_FILTER',
|
||||
merge: 'SVC_MERGE',
|
||||
try_catch: 'SVC_TRY_CATCH',
|
||||
wait: 'SVC_WAIT',
|
||||
// wait 已於 Arcrun#101(2026-08-12)移進 BUILTIN_COMPONENTS(step 1)——
|
||||
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
|
||||
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
|
||||
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
|
||||
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.1:13 個既有 binding 保留不新增),
|
||||
// 拆綁定要重新部署、與本票無關。
|
||||
set: 'SVC_SET',
|
||||
array_ops: 'SVC_ARRAY_OPS',
|
||||
string_ops: 'SVC_STRING_OPS',
|
||||
|
||||
@@ -47,6 +47,13 @@ export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
|
||||
'SUBFLOW': 'CALLS_SUBFLOW',
|
||||
};
|
||||
|
||||
/**
|
||||
* wait 零件的等待上限(毫秒)。與 registry/components/wait/component.contract.yaml
|
||||
* 逐字相同 —— 超過此值截斷、不報錯。**不可為了閃避資源上限調小**(Arcrun#101 紅線):
|
||||
* 「等外部系統跟上」是這顆零件存在的理由,把上限砍掉等於把能力換掉。
|
||||
*/
|
||||
export const WAIT_MAX_MS = 30000;
|
||||
|
||||
/**
|
||||
* 內建零件表(靜態函數)
|
||||
* WASM 零件 = 各自獨立 Worker,cypher-executor 走 HTTP URL 呼叫(不從 R2 讀)
|
||||
@@ -61,6 +68,61 @@ export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
|
||||
const c = ctx as Record<string, unknown>;
|
||||
return { ...c, count: (Number(c.count) || 0) + 1 };
|
||||
}],
|
||||
|
||||
// ── wait:等待 N 毫秒後繼續(Arcrun#101,2026-08-12)────────────────────────
|
||||
//
|
||||
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
|
||||
//
|
||||
// 舊實作是 registry/components/wait/main.go(TinyGo → WASM),用 time.Sleep。
|
||||
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
|
||||
// poll_oneoff 實作成 ENOSYS(`.component-builds/*/src/index.ts`:`poll_oneoff: () => 76`)
|
||||
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
|
||||
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
|
||||
//
|
||||
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
|
||||
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
|
||||
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
|
||||
// ⇒ 時鐘並沒有全程凍結。
|
||||
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
|
||||
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
|
||||
//
|
||||
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
|
||||
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
|
||||
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
|
||||
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
|
||||
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
|
||||
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
|
||||
//
|
||||
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
|
||||
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
|
||||
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
|
||||
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
|
||||
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
|
||||
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
|
||||
//
|
||||
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
|
||||
// 吃 ms(必填 > 0)+可選 context;ms > WAIT_MAX_MS 截斷;
|
||||
// 回 { success: true, data: { ...context, waited_ms } };ms <= 0 回 success:false。
|
||||
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
|
||||
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
|
||||
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
|
||||
['wait', async (ctx) => {
|
||||
const c = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||||
|
||||
const requested = typeof c.ms === 'number' ? c.ms : Number(c.ms);
|
||||
if (!Number.isFinite(requested) || requested <= 0) {
|
||||
return { success: false, error: 'ms 必須大於 0' };
|
||||
}
|
||||
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
|
||||
|
||||
// 這一行就是整張票:await timer ⇒ 只走 wall-clock,不佔請求執行緒、不記 CPU。
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const passthrough = (c.context && typeof c.context === 'object' && !Array.isArray(c.context))
|
||||
? c.context as Record<string, unknown>
|
||||
: {};
|
||||
return { success: true, data: { ...passthrough, waited_ms: ms } };
|
||||
}],
|
||||
]);
|
||||
|
||||
export const SCORE_THRESHOLD = 0.5;
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbBase, graphBase } from './kbdb-proxy';
|
||||
import { kbdbBase, graphBase, graphHeaders } from './kbdb-proxy';
|
||||
import { validateConsoleSession } from './console-auth';
|
||||
import {
|
||||
type KbdbEntry,
|
||||
@@ -104,6 +104,31 @@ async function fetchJson<T>(url: string, headers?: Record<string, string>): Prom
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本租戶三元組的**真實總數**(null = 讀不到,畫面要顯示「讀不到」而非 0)。
|
||||
*
|
||||
* 🔴 Arcrun#100:不可以拿 graph-plugin `/triplets/stats` 的 `total` 當數量。
|
||||
* 那支的 `total` 是**分頁長度**不是 COUNT——它走 `/records/by-template/triplet`(KBDB 端
|
||||
* `searchByTemplate` 預設 limit=100、硬上限 500)且不帶 owner 過濾,所以 1854 條的庫
|
||||
* 只會回 100。修好 401 之後若還讀它,畫面會從「0」變成「100」——一樣是假的。
|
||||
* 真相源=KBDB `/records/triplet-stats`(真 SQL COUNT(*)、依 owner_id 過濾、無上限),
|
||||
* 回 `{ success, stats: [{ library, triplet_count }] }`,加總即全庫條數。
|
||||
*/
|
||||
async function fetchTripletTotal(env: Bindings, tenant: string): Promise<number | null> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
const data = await fetchJson<{ stats?: { triplet_count?: unknown }[] }>(
|
||||
`${base}/records/triplet-stats?owner_id=${encodeURIComponent(tenant)}`,
|
||||
headers,
|
||||
);
|
||||
if (!data || !Array.isArray(data.stats)) return null;
|
||||
let total = 0;
|
||||
for (const row of data.stats) {
|
||||
if (typeof row?.triplet_count !== 'number') return null; // 形狀不對 → 誠實回讀不到,不半信半疑加總
|
||||
total += row.triplet_count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
|
||||
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
@@ -254,6 +279,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
kbdbHealth,
|
||||
embedStatus,
|
||||
graphStats,
|
||||
tripletTotal,
|
||||
entriesTotal,
|
||||
wikiCardTotal,
|
||||
workflowTotal,
|
||||
@@ -265,7 +291,13 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
|
||||
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
|
||||
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, kbdbHeaders),
|
||||
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(`${graphUrl}/triplets/stats`),
|
||||
// graph-plugin 只拿來判「圖服務活著沒」(燈號)——數字不從這裡拿,見 fetchTripletTotal。
|
||||
// headers 一定要帶:plugin 的 /triplets 前綴掛 Bearer 閘,漏帶=永遠 401=永遠假紅燈(#100)。
|
||||
fetchJson<{ total?: number; recent?: { today?: number; this_week?: number } }>(
|
||||
`${graphUrl}/triplets/stats`,
|
||||
graphHeaders(c.env),
|
||||
),
|
||||
fetchTripletTotal(c.env, tenant),
|
||||
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732)
|
||||
fetchEntryTotal(c.env, { owner_id: tenant }),
|
||||
fetchEntryTotal(c.env, { entry_type: 'wiki_card', owner_id: tenant }),
|
||||
@@ -400,13 +432,14 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
embed: embedStatus
|
||||
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
|
||||
: null,
|
||||
graph: graphStats ? { ok: true, triplets: graphStats.total ?? null } : { ok: false, triplets: null },
|
||||
// ok = plugin 通不通(graphStats 讀得到就是通);triplets = KBDB 真 COUNT(與 plugin 分頁長度無關)
|
||||
graph: { ok: graphStats !== null, triplets: tripletTotal },
|
||||
workflow_total: workflowTotal,
|
||||
},
|
||||
kb: {
|
||||
entries_total: entriesTotal,
|
||||
wiki_card_total: wikiCardTotal,
|
||||
triplets_total: graphStats?.total ?? null,
|
||||
triplets_total: tripletTotal,
|
||||
},
|
||||
generated_at: new Date(now).toISOString(),
|
||||
});
|
||||
@@ -420,15 +453,15 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
||||
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const graphUrl = graphBase(c.env);
|
||||
const now = Date.now();
|
||||
const [wikiCards, graphStats, embedStatus] = await Promise.all([
|
||||
const [wikiCards, tripletTotal, embedStatus] = await Promise.all([
|
||||
// limit=1 順手拿最新一筆 created_at(list 為 created_at DESC)=「最近寫入時間」
|
||||
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
|
||||
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
|
||||
headers,
|
||||
),
|
||||
fetchJson<{ total?: number }>(`${graphUrl}/triplets/stats`),
|
||||
// #100:三元組數改讀 KBDB 真 COUNT,不再讀 graph-plugin 的分頁長度(見 fetchTripletTotal 註)
|
||||
fetchTripletTotal(c.env, tenant),
|
||||
fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
|
||||
]);
|
||||
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
|
||||
@@ -436,7 +469,7 @@ consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
||||
return c.json({
|
||||
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
|
||||
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
|
||||
triplets_total: typeof graphStats?.total === 'number' ? graphStats.total : null,
|
||||
triplets_total: tripletTotal,
|
||||
embedded: embedStatus?.embedded ?? null,
|
||||
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
|
||||
generated_at: new Date(now).toISOString(),
|
||||
|
||||
@@ -223,13 +223,27 @@ export function graphBase(env: Bindings): string {
|
||||
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
|
||||
}
|
||||
|
||||
/**
|
||||
* kbdb-graph-plugin 的 internal headers。**打 plugin 一律用這支,不要各自手拼**(Arcrun#100)。
|
||||
*
|
||||
* plugin 端(kbdb-graph-plugin/src/index.ts)對 `/triplets` `/graph` `/search` `/entities`
|
||||
* 四個前綴掛了 Bearer 閘:設了 KBDB_INTERNAL_TOKEN 就必須帶,否則一律 401。
|
||||
* 原本三處手拼(本檔 neighbors、portal-data neighbors、console-dashboard 兩支 stats),
|
||||
* 前兩處帶了、後兩處漏了 → `/triplets/stats` 永遠 401 → 前端「三元組 0」。
|
||||
* 收斂成一支函式=新的呼叫點不可能再漏(漂移的根,不是那兩行本身)。
|
||||
*/
|
||||
export function graphHeaders(env: Bindings): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// GET /kbdb/graph/neighbors/:name — 查某節點(entity/卡片名)的鄰居 + 邊。
|
||||
// 查無 triplet 資料時 plugin 回空陣列——前端據此顯示「尚無關聯資料」(誠實,不編造關聯)。
|
||||
kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => {
|
||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||
const base = graphBase(c.env);
|
||||
const headers: Record<string, string> = {};
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
const headers = graphHeaders(c.env);
|
||||
try {
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
@@ -24,7 +24,7 @@ import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
|
||||
import { graphBase } from './kbdb-proxy';
|
||||
import { graphBase, graphHeaders } from './kbdb-proxy';
|
||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
|
||||
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
|
||||
@@ -186,6 +186,45 @@ export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): stri
|
||||
return hits.reduce((a, b) => a.length <= b.length ? a : b);
|
||||
}
|
||||
|
||||
/**
|
||||
* 三元組條數(KBDB `/records/triplet-stats` 真 SQL COUNT)。owner 傳 '' =不限租戶(KBDB 端
|
||||
* `?1 = '' OR e.owner_id = ?1`)。null=讀不到——caller 據此不敢宣稱 0。
|
||||
*/
|
||||
async function tripletCount(env: Bindings, owner: string): Promise<number | null> {
|
||||
try {
|
||||
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
|
||||
if (!body || !Array.isArray(body.stats)) return null;
|
||||
let total = 0;
|
||||
for (const row of body.stats) {
|
||||
if (typeof row?.triplet_count !== 'number') return null;
|
||||
total += row.triplet_count;
|
||||
}
|
||||
return total;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 三元組普查(Arcrun#100)——回答總圖那句「知識庫還沒有任何關聯」到底能不能講。
|
||||
*
|
||||
* leo 的原則(已寫在 portal.ts §②.5 daemon diagnostics):**「不要讓『查不到』和『沒有』
|
||||
* 長得一樣」**。t161 前科:手補的 record owner_id 存成 None ⇒ 全量查得到、按 owner_id 過濾
|
||||
* 的畫面永遠空——比真的沒資料更難查。所以本租戶數為 0 時**再花一次查詢換一條路徑**
|
||||
* (同一支端點但不帶 owner),問「這個庫到底有沒有三元組」:
|
||||
* owned>0 → 有資料
|
||||
* owned=0 且 any=0 → 真的空(此時、也只有此時,畫面才准印 0)
|
||||
* owned=0 但 any>0 → owner_id / 範圍對不上,不是空庫 → 畫面說讀不到
|
||||
* owned=null → 讀不到 → 畫面說讀不到
|
||||
*/
|
||||
async function tripletCensus(env: Bindings, tenant: string): Promise<{ owned: number | null; any: number | null }> {
|
||||
const owned = await tripletCount(env, tenant);
|
||||
if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次
|
||||
return { owned, any: await tripletCount(env, '') };
|
||||
}
|
||||
|
||||
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
|
||||
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
|
||||
try {
|
||||
@@ -343,8 +382,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
|
||||
// ② plugin fallback(Mira/leo21c 相容)
|
||||
const base = graphBase(c.env);
|
||||
const headers: Record<string, string> = {};
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
const headers = graphHeaders(c.env);
|
||||
try {
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
|
||||
if (!res.ok) {
|
||||
@@ -383,14 +421,23 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
|
||||
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
||||
}
|
||||
const tenant = portalTenant(c.env);
|
||||
const res = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
|
||||
const [res, census] = await Promise.all([
|
||||
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}&limit=500`),
|
||||
tripletCensus(c.env, tenant),
|
||||
]);
|
||||
const tripletsTotal = census.owned;
|
||||
if (!res.ok) {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { records?: { values?: Record<string, unknown> }[] }
|
||||
| null;
|
||||
const records = body && Array.isArray(body.records) ? body.records : [];
|
||||
// #100:形狀不對 ≠ 沒有資料。原本 `: []` 會把「讀不出來」變成一張空圖,
|
||||
// 前端照著印「0 個實體・0 條關聯」——那是畫面在說謊。讀不出來就誠實 502。
|
||||
if (!body || !Array.isArray(body.records)) {
|
||||
return c.json({ error: '三元組讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
|
||||
}
|
||||
const records = body.records;
|
||||
const EDGE_CAP = 500;
|
||||
const seen = new Set<string>();
|
||||
const edges: { subject: string; predicate: string; object: string }[] = [];
|
||||
@@ -413,7 +460,28 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
|
||||
degree.set(o, (degree.get(o) ?? 0) + 1);
|
||||
}
|
||||
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
|
||||
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
|
||||
// #100:一張空圖有三種成因,前端必須分得出來(判準留在 server,不留給前端猜)——
|
||||
// confirmed_empty :本租戶真的一條都沒有,全庫也沒有 → 才准印「0 個實體・0 條關聯」
|
||||
// scope_mismatch :全庫有、本租戶查不到 → owner_id/範圍對不上,不是空庫(t161 前科)
|
||||
// unreadable :連條數都讀不到 → 只能說讀不到
|
||||
let emptyReason: 'confirmed_empty' | 'scope_mismatch' | 'unreadable' | null = null;
|
||||
if (nodes.length === 0) {
|
||||
if (census.owned === null) emptyReason = 'unreadable';
|
||||
else if (census.owned > 0) emptyReason = 'scope_mismatch'; // 有條數卻抽不出邊
|
||||
else if (census.any === null) emptyReason = 'unreadable';
|
||||
else emptyReason = census.any > 0 ? 'scope_mismatch' : 'confirmed_empty';
|
||||
}
|
||||
return c.json({
|
||||
nodes,
|
||||
edges,
|
||||
node_count: nodes.length,
|
||||
edge_count: edges.length,
|
||||
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
|
||||
truncated: truncated || records.length >= 500,
|
||||
triplets_total: tripletsTotal,
|
||||
empty_confirmed: nodes.length > 0 || emptyReason === 'confirmed_empty',
|
||||
empty_reason: emptyReason,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Arcrun#100 — 「畫面上的 0,只准在真的是 0 的時候出現」
|
||||
*
|
||||
* 病灶(leo 實遇):總圖頁寫「0 個實體・0 條關聯/知識庫還沒有任何關聯——上傳文件後 AI 會
|
||||
* 自動織網」,而他庫裡有 1854 條三元組。那句話會叫他去做一件不需要做的事。
|
||||
*
|
||||
* 本檔釘住三件事:
|
||||
* ① kbdb-graph-plugin 的 `/triplets` 前綴掛 Bearer 閘,cypher 打它**一定要帶 token**
|
||||
* (console-dashboard 兩支 stats 原本漏帶 → 永遠 401)。
|
||||
* ② 三元組數量的真相源=KBDB `/records/triplet-stats`(真 SQL COUNT、依 owner 過濾),
|
||||
* **不是** plugin `/triplets/stats` 的 `total`——那是分頁長度(KBDB 端上限 100/500),
|
||||
* 1854 條的庫只會回 100。只修 401 不換來源=把「0」換成「100」,一樣是假的。
|
||||
* ③ 讀不到一律 null / 502 / empty_confirmed=false,**絕不退化成 0**。
|
||||
*
|
||||
* KBDB/graph-plugin 都打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test、
|
||||
* KBDB_GRAPH_URL=https://graph.test)+disableNetConnect——絕不外連。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { graphHeaders, graphBase } from '../src/routes/kbdb-proxy';
|
||||
import type { Bindings } from '../src/types';
|
||||
|
||||
const KBDB = 'https://kbdb.test';
|
||||
const GRAPH = 'https://graph.test';
|
||||
const TENANT = 'leo'; // wrangler.test.toml CONSOLE_TENANT
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
/** KBDB `/records/triplet-stats` — 真 COUNT 的形狀:{ success, stats: [{library, triplet_count}] } */
|
||||
function mockTripletStats(rows: { library: string; triplet_count: number }[] | null, status = 200) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(status, rows === null ? { success: false, error: 'boom' } : { success: true, stats: rows });
|
||||
}
|
||||
|
||||
// ═══════════════ 1. graphHeaders:打 plugin 的 header 只有一份 ═══════════════
|
||||
|
||||
describe('graphHeaders(#100 漂移的根:三處手拼 → 一支函式)', () => {
|
||||
it('有 KBDB_INTERNAL_TOKEN → 帶 Bearer(plugin 的 /triplets /graph /search /entities 全靠它)', () => {
|
||||
expect(graphHeaders({ KBDB_INTERNAL_TOKEN: 'tok-abc' } as unknown as Bindings)).toEqual({
|
||||
Authorization: 'Bearer tok-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('沒設 token → 空 headers(plugin 未設 secret 時本來就開放,不硬塞空 Bearer)', () => {
|
||||
expect(graphHeaders({} as unknown as Bindings)).toEqual({});
|
||||
});
|
||||
|
||||
it('graphBase 仍照舊(KBDB_GRAPH_URL 優先、去尾斜線)', () => {
|
||||
expect(graphBase({ KBDB_GRAPH_URL: 'https://graph.test/' } as unknown as Bindings)).toBe('https://graph.test');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 2. /console/kb-scale-data:數字對得上庫裡真正的數量 ═══════════════
|
||||
|
||||
describe('GET /console/kb-scale-data — 三元組數=KBDB 真 COUNT', () => {
|
||||
it('庫裡 1854 條(跨三個庫)→ triplets_total 回 1854,不是 plugin 的分頁長度 100', async () => {
|
||||
mockTripletStats([
|
||||
{ library: 'general', triplet_count: 1200 },
|
||||
{ library: 'finance', triplet_count: 600 },
|
||||
{ library: 'ops', triplet_count: 54 },
|
||||
]);
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBe(1854);
|
||||
});
|
||||
|
||||
it('反向:triplet-stats 讀不到(500)→ triplets_total = null,**不是 0**', async () => {
|
||||
mockTripletStats(null, 500);
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBeNull();
|
||||
expect(d.triplets_total).not.toBe(0); // 這一行就是 #100 的整個重點
|
||||
});
|
||||
|
||||
it('反向:回應形狀不對(stats 不是陣列)→ null,不半信半疑當 0', async () => {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
|
||||
.reply(200, { success: true, stats: 'oops' });
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBeNull();
|
||||
});
|
||||
|
||||
it('真的是 0(庫存在但沒有任何三元組)→ 誠實回 0(0 只在這種時候出現)', async () => {
|
||||
mockTripletStats([]);
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBe(0);
|
||||
});
|
||||
|
||||
it('kb-scale-data 不再打 graph-plugin(沒有 plugin interceptor 也能拿到數字)', async () => {
|
||||
mockTripletStats([{ library: 'general', triplet_count: 7 }]);
|
||||
const res = await SELF.fetch('http://localhost/console/kb-scale-data');
|
||||
const d = (await res.json()) as { triplets_total: number | null };
|
||||
expect(d.triplets_total).toBe(7); // 打 GRAPH 的話 disableNetConnect 會讓它變 null
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3. /console/dashboard-data:燈號問 plugin、數字問 KBDB ═══════════════
|
||||
|
||||
describe('GET /console/dashboard-data — 圖服務健康 vs 三元組數量是兩件事', () => {
|
||||
it('打 plugin /triplets/stats **有帶 Bearer** → graph.ok=true;數量仍取 KBDB 真 COUNT', async () => {
|
||||
// headers matcher:漏帶 Authorization 就配不到這個 interceptor → 請求失敗 → graph.ok=false
|
||||
fetchMock
|
||||
.get(GRAPH)
|
||||
.intercept({
|
||||
path: (p: string) => p.startsWith('/triplets/stats'),
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${env.KBDB_INTERNAL_TOKEN}` },
|
||||
})
|
||||
.reply(200, { total: 100 }); // plugin 的分頁長度,故意與真值不同
|
||||
mockTripletStats([{ library: 'general', triplet_count: 1854 }]);
|
||||
const res = await SELF.fetch('http://localhost/console/dashboard-data');
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as {
|
||||
system: { graph: { ok: boolean; triplets: number | null } };
|
||||
kb: { triplets_total: number | null };
|
||||
};
|
||||
expect(d.system.graph.ok).toBe(true); // 帶了 token 才會是 true(#100 迴歸閘)
|
||||
expect(d.system.graph.triplets).toBe(1854); // 不是 plugin 的 100
|
||||
expect(d.kb.triplets_total).toBe(1854);
|
||||
});
|
||||
|
||||
it('反向:plugin 打不通 → graph.ok=false,但三元組數照樣是真的(不被服務狀態吞掉)', async () => {
|
||||
mockTripletStats([{ library: 'general', triplet_count: 1854 }]);
|
||||
const res = await SELF.fetch('http://localhost/console/dashboard-data');
|
||||
const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } };
|
||||
expect(d.system.graph.ok).toBe(false);
|
||||
expect(d.system.graph.triplets).toBe(1854);
|
||||
});
|
||||
|
||||
it('反向:兩邊都讀不到 → ok=false + triplets=null(不是 0)', async () => {
|
||||
const res = await SELF.fetch('http://localhost/console/dashboard-data');
|
||||
const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } };
|
||||
expect(d.system.graph.ok).toBe(false);
|
||||
expect(d.system.graph.triplets).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -942,3 +942,91 @@ describe('GET /portal/daemon/diagnostics(t213 daemon 版)', () => {
|
||||
expect(JSON.stringify(body.notes)).not.toContain('截圖');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══ Arcrun#100: 總圖的「0」只准在真的是 0 的時候出現 ═══
|
||||
|
||||
describe('GET /portal/data/graph/overview(#100 空圖三態)', () => {
|
||||
/** KBDB `/records/triplet-stats`:帶 owner 與不帶 owner 是兩條不同路徑,分別攔。 */
|
||||
function mockCount(scoped: number | null, global?: number | null) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith(`/records/triplet-stats?owner_id=${TENANT}`), method: 'GET' })
|
||||
.reply(scoped === null ? 500 : 200, scoped === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: scoped }] });
|
||||
if (global !== undefined) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p === '/records/triplet-stats?owner_id=', method: 'GET' })
|
||||
.reply(global === null ? 500 : 200, global === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: global }] });
|
||||
}
|
||||
}
|
||||
function mockTriplets(body: object, status = 200) {
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
|
||||
.reply(status, body);
|
||||
}
|
||||
async function overview(token: string) {
|
||||
await seedSession(token, `rec_${token}`);
|
||||
mockGetRecord(`rec_${token}`, userValues({ libraries: '["*"]', role: 'admin' }));
|
||||
return get('/portal/data/graph/overview', { Authorization: `Bearer ${token}` });
|
||||
}
|
||||
|
||||
it('有資料 → 照常回圖,並附上全庫真實條數', async () => {
|
||||
mockTriplets({ success: true, records: [{ values: { subject: 'A', predicate: '連到', object: 'B' } }] });
|
||||
mockCount(1854);
|
||||
const res = await overview('tok-ov1');
|
||||
expect(res.status).toBe(200);
|
||||
const d = (await res.json()) as { node_count: number; triplets_total: number; empty_confirmed: boolean };
|
||||
expect(d.node_count).toBe(2);
|
||||
expect(d.triplets_total).toBe(1854);
|
||||
expect(d.empty_confirmed).toBe(true);
|
||||
});
|
||||
|
||||
it('真的空(本租戶 0、全庫也 0)→ empty_confirmed=true,畫面才准印 0', async () => {
|
||||
mockTriplets({ success: true, records: [] });
|
||||
mockCount(0, 0);
|
||||
const res = await overview('tok-ov2');
|
||||
const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
|
||||
expect(d.node_count).toBe(0);
|
||||
expect(d.empty_confirmed).toBe(true);
|
||||
expect(d.empty_reason).toBe('confirmed_empty');
|
||||
});
|
||||
|
||||
it('🔴 反向:本租戶查到 0、全庫卻有 1854(t161 owner_id 對不上)→ 不准說空,回 scope_mismatch', async () => {
|
||||
mockTriplets({ success: true, records: [] });
|
||||
mockCount(0, 1854);
|
||||
const res = await overview('tok-ov3');
|
||||
const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string };
|
||||
expect(d.empty_confirmed).toBe(false);
|
||||
expect(d.empty_reason).toBe('scope_mismatch');
|
||||
});
|
||||
|
||||
it('🔴 反向:條數讀不到 → unreadable(不是 confirmed_empty,畫面顯示「讀不到」)', async () => {
|
||||
mockTriplets({ success: true, records: [] });
|
||||
mockCount(null);
|
||||
const res = await overview('tok-ov4');
|
||||
const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string; triplets_total: number | null };
|
||||
expect(d.empty_confirmed).toBe(false);
|
||||
expect(d.empty_reason).toBe('unreadable');
|
||||
expect(d.triplets_total).toBeNull();
|
||||
});
|
||||
|
||||
it('🔴 反向:有條數卻一條邊都抽不出來 → scope_mismatch,不是空庫', async () => {
|
||||
mockTriplets({ success: true, records: [{ values: { subject: '', object: '' } }] });
|
||||
mockCount(1854);
|
||||
const res = await overview('tok-ov5');
|
||||
const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
|
||||
expect(d.node_count).toBe(0);
|
||||
expect(d.empty_reason).toBe('scope_mismatch');
|
||||
expect(d.empty_confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it('🔴 反向:KBDB 回應形狀不對(沒有 records 陣列)→ 502,不再回一張空圖', async () => {
|
||||
mockTriplets({ success: true, items: [] }); // 欄位名不對=讀不出來
|
||||
mockCount(1854);
|
||||
const res = await overview('tok-ov6');
|
||||
expect(res.status).toBe(502);
|
||||
const d = (await res.json()) as { error: string };
|
||||
expect(d.error).toContain('三元組讀取失敗');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* wait:等待不該吃運算額度(Arcrun#101)
|
||||
*
|
||||
* 病灶(leo 2026-08-12 於 youlin stage 實測,只有 input >> wait 兩個節點):
|
||||
* ms=3000 → 38.9s 後 503(1102) / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
|
||||
* 四個值同一種死法、與 ms 完全無關。若「等 N 秒=燒 N 秒 CPU」,ms=3000 只會花 3 秒
|
||||
* 就結束、根本不該死 —— 所以真正的病不是「等待很貴」,是「等待永遠不會結束」。
|
||||
*
|
||||
* 機制:wait 是 TinyGo WASM,time.Sleep 走 WASI poll_oneoff;component worker 的
|
||||
* WASI shim 把 poll_oneoff 實作成 ENOSYS ⇒ TinyGo 排程器退化成迴圈重讀 clock_time_get
|
||||
* 自旋;而 Workers 的時鐘在無 I/O 的同步執行期間凍結 ⇒ 迴圈的結束條件永遠不成立。
|
||||
*
|
||||
* 本檔驗四件事:
|
||||
* A. 反向驗證(機制):在真的 workerd 裡,輪詢時鐘的同步自旋迴圈確實永不前進。
|
||||
* B. 修法本體:wait 走引擎的 timer ⇒ 真的讓出執行緒(不佔請求執行緒)。
|
||||
* C. 契約沒變:既有 workflow 的 wait 節點定義不用改就能照樣跑。
|
||||
* D. 路由:wait 由 step 1 內建命中,不再打 arcrun-wait worker(不發任何 fetch)。
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { env } from 'cloudflare:test';
|
||||
import { BUILTIN_COMPONENTS, WAIT_MAX_MS } from '../src/lib/constants';
|
||||
import { createComponentLoader } from '../src/lib/component-loader';
|
||||
import type { Bindings, ComponentRunner } from '../src/types';
|
||||
|
||||
const wait = BUILTIN_COMPONENTS.get('wait') as ComponentRunner;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── A. 反向驗證:舊路徑為什麼不可能便宜地等 ──────────────────────────────────
|
||||
//
|
||||
// 直接跑那顆 component.wasm 沒辦法寫成安全的測試 —— 它會把 isolate 卡到 CPU 上限,
|
||||
// 測試無從中止(那正是 bug 本身)。所以這裡驗的是「**沙箱裡根本沒有睡覺這個手段**」。
|
||||
//
|
||||
// 🔴 這裡本來有一條斷言「Workers 的時鐘在同步執行期間凍結,所以自旋迴圈的結束條件
|
||||
// 永遠不成立」。**實跑打臉了**:在 vitest-pool-workers 的 workerd 裡,2553 圈之後
|
||||
// Date.now() 就前進了。⇒ 那條斷言被刪掉,不是改鬆——它從一開始就不是證據。
|
||||
//
|
||||
// 保留下來的是**查證得動的那一半**:WASI shim 把 poll_oneoff 實作成 ENOSYS(76),
|
||||
// TinyGo 的 time.Sleep 只有這一條路可走 ⇒ 拿不到「睡到某個時刻」的手段,
|
||||
// 只能退化成自旋。至於「自旋為什麼會拖到 35 秒才死」的完整機制**仍是推測**,
|
||||
// 證據是 leo 在 youlin stage 的四次實測(見檔頭),不是本檔任何一條斷言。
|
||||
//
|
||||
// ⇒ 而修法不依賴那個推測:純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)
|
||||
// 本來就沒有「不花 CPU 地等」這種東西,會等的只有宿主。無論卡死的細節是什麼,
|
||||
// 等待都該搬回引擎。
|
||||
// 「poll_oneoff 是 ENOSYS」這件事查原始碼即可(`wasi-shim.ts:319` 的
|
||||
// `poll_oneoff: () => WASI_ENOSYS`,以及 13 個 `.component-builds/*/src/index.ts`
|
||||
// 的 `poll_oneoff: () => 76`)。**沒有為它硬寫一條測試**——寫得出來的只會是
|
||||
// 「把字串抓出來比對」,那驗的是抓字串,不是行為。事實放註解,斷言留給真的驗行為的 B/C/D。
|
||||
describe('A. 反向驗證:WASI 沙箱裡沒有「睡覺」這個手段', () => {
|
||||
it('對照組:await 一個 timer 之後時鐘才會前進(=為什麼修法必須在引擎側 await)', async () => {
|
||||
const t0 = Date.now();
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
expect(Date.now()).toBeGreaterThan(t0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── B. 修法本體:等待是 timer,不是佔用執行緒 ────────────────────────────────
|
||||
describe('B. 引擎側的 wait 真的讓出執行緒(等 30 秒與等 3 秒同價)', () => {
|
||||
it('5 個 300ms 的 wait 併發跑完 ≈ 300ms 而非 1500ms(會 blocking 的實作做不到這件事)', async () => {
|
||||
const started = Date.now();
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, () => wait({ ms: 300 })),
|
||||
);
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
for (const r of results) {
|
||||
expect(r).toEqual({ success: true, data: { waited_ms: 300 } });
|
||||
}
|
||||
// 序列化(blocking)會是 ~1500ms;讓出執行緒則 5 個計時器同時走完 ≈ 300ms。
|
||||
// 抓 900ms 當門檻:離 300 夠鬆、離 1500 夠遠。
|
||||
expect(elapsed).toBeLessThan(900);
|
||||
expect(elapsed).toBeGreaterThanOrEqual(300);
|
||||
});
|
||||
|
||||
it('等待期間 event loop 沒被佔住:同時排的 timer 照樣先到', async () => {
|
||||
const order: string[] = [];
|
||||
const waited = Promise.resolve(wait({ ms: 400 })).then(() => { order.push('wait-400'); });
|
||||
const ticked = new Promise<void>((r) => setTimeout(r, 50)).then(() => { order.push('tick-50'); });
|
||||
|
||||
await Promise.all([waited, ticked]);
|
||||
expect(order).toEqual(['tick-50', 'wait-400']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── C. 契約沒變:既有 wait 節點定義不用改 ────────────────────────────────────
|
||||
//
|
||||
// 逐條對 registry/components/wait/component.contract.yaml 的 gherkin_tests。
|
||||
describe('C. I/O 契約與 WASM 版一致(既有 workflow 不必改定義)', () => {
|
||||
it('contract gherkin:等待 100ms → waited_ms:100', async () => {
|
||||
expect(await wait({ ms: 100 })).toEqual({ success: true, data: { waited_ms: 100 } });
|
||||
});
|
||||
|
||||
it('contract gherkin:ms 為 0 時失敗(不是靜靜跳過)', async () => {
|
||||
expect(await wait({ ms: 0 })).toEqual({ success: false, error: 'ms 必須大於 0' });
|
||||
});
|
||||
|
||||
it('ms 缺漏 / 負數 / 非數字,一律誠實回 success:false,不假裝等過', async () => {
|
||||
for (const bad of [undefined, null, -1, 'abc', {}, []]) {
|
||||
expect(await wait({ ms: bad })).toEqual({ success: false, error: 'ms 必須大於 0' });
|
||||
}
|
||||
});
|
||||
|
||||
it('contract gherkin:ms=99999 截斷為上限 30000(不是報錯、也不是真的等 99 秒)', async () => {
|
||||
// 不真的等 30 秒:換掉 setTimeout,攔下引擎「要求等多久」再立刻放行。
|
||||
const asked: number[] = [];
|
||||
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
|
||||
asked.push(Number(delay));
|
||||
fn();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as unknown as typeof setTimeout);
|
||||
|
||||
expect(await wait({ ms: 99999 })).toEqual({ success: true, data: { waited_ms: WAIT_MAX_MS } });
|
||||
expect(asked).toEqual([WAIT_MAX_MS]);
|
||||
expect(WAIT_MAX_MS).toBe(30000); // 紅線:上限不准為了閃避資源限制被調小
|
||||
});
|
||||
|
||||
it('ms=30000 一路走到底也只是「排一個 30 秒的 timer」,沒有任何同步佔用', async () => {
|
||||
const asked: number[] = [];
|
||||
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
|
||||
asked.push(Number(delay));
|
||||
fn();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as unknown as typeof setTimeout);
|
||||
|
||||
expect(await wait({ ms: 30000 })).toEqual({ success: true, data: { waited_ms: 30000 } });
|
||||
expect(asked).toEqual([30000]);
|
||||
});
|
||||
|
||||
it('context 照契約透傳,並補上 waited_ms', async () => {
|
||||
const r = await wait({ ms: 5, context: { order_id: 'A-1', payload: { n: 2 } } });
|
||||
expect(r).toEqual({
|
||||
success: true,
|
||||
data: { order_id: 'A-1', payload: { n: 2 }, waited_ms: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('node.data 經 interpolateData 後 ms 會是字串 —— 收得下(WASM 版在這裡直接 unmarshal 失敗)', async () => {
|
||||
expect(await wait({ ms: '250' })).toEqual({ success: true, data: { waited_ms: 250 } });
|
||||
});
|
||||
});
|
||||
|
||||
// ── D. 路由:不再打 arcrun-wait worker ───────────────────────────────────────
|
||||
describe('D. component-loader 把 wait 解到內建 runner(step 1),不發任何 fetch', () => {
|
||||
it('loader("wait") 跑起來不會對外送出任何請求', async () => {
|
||||
const fakeEnv = { ...env, WORKER_SUBDOMAIN: 'test-sub' } as unknown as Bindings;
|
||||
const fetchSpy = vi.fn(async () => new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const runner = await createComponentLoader(fakeEnv)('wait');
|
||||
const r = await runner({ ms: 10 });
|
||||
|
||||
expect(r).toEqual({ success: true, data: { waited_ms: 10 } });
|
||||
// 修法前這裡會打 arcrun-wait.test-sub.workers.dev(SVC_WAIT 未綁時的 fallback),
|
||||
// 那顆 worker 就是會燒到 1102 的那顆。
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wait 仍在「執行期真的解析得動」的清單裡(/cypher/search 查得到)', async () => {
|
||||
const { RUNTIME_NATIVE_COMPONENT_IDS } = await import('../src/lib/component-loader');
|
||||
expect(RUNTIME_NATIVE_COMPONENT_IDS.has('wait')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,10 @@ KBDB_BASE_URL = "https://kbdb.test"
|
||||
CONSOLE_TENANT = "leo"
|
||||
# portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
|
||||
KBDB_GRAPH_URL = "https://graph.test"
|
||||
# Arcrun#100:kbdb-graph-plugin 對 /triplets /graph /search /entities 掛 Bearer 閘。測試環境要有
|
||||
# 這把(明顯的假字串、非真實金鑰)才驗得出「cypher 打 plugin 有沒有帶 token」——原本兩支
|
||||
# /triplets/stats 漏帶 → 永遠 401 → 前端「三元組 0」。真實部署仍走 wrangler secret put。
|
||||
KBDB_INTERNAL_TOKEN = "test-fake-not-a-real-token" # credential-ok:測試假值,同上方 CF_SECRETS_API_TOKEN 慣例
|
||||
# D61(ADR D61 / Leo/arcrun-rag#55):認證儲存(lib/portal-auth-store.ts)走 CF Workers
|
||||
# Scripts secrets 管理 API(https://api.cloudflare.com/...),authStoreWritable() 只看這兩項
|
||||
# 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
// wait — 等待指定毫秒數後繼續(最多 30 秒)
|
||||
// 注意:TinyGo/WASM 環境中 time.Sleep 可能不可用,改用 busy-wait 模擬
|
||||
//
|
||||
// ⚠️ 已由引擎接手,這份 WASM 在 Cloudflare Workers 上跑不動(Arcrun#101,2026-08-12)。
|
||||
// 現行實作在 cypher-executor/src/lib/constants.ts 的 BUILTIN_COMPONENTS['wait'],
|
||||
// component-loader step 1 先命中,這顆 wasm 不會再被工作流呼叫到。
|
||||
//
|
||||
// 為什麼跑不動(不是「比較慢」,是「永遠不會結束」):
|
||||
// 下面的 time.Sleep 在 TinyGo 走 WASI poll_oneoff,而 component worker 的 WASI shim
|
||||
// 把 poll_oneoff 實作成 ENOSYS ⇒ TinyGo 排程器退化成迴圈重讀 clock_time_get 自旋;
|
||||
// Workers 的時鐘在無 I/O 的同步執行期間是凍結的 ⇒ 結束條件永遠不成立 ⇒ 一路燒到
|
||||
// CPU 上限被砍(error 1102)。leo 實測 ms=3000/20000/30000 全在 ~35 秒後 503,
|
||||
// 死法與 ms 無關 —— 這正是「迴圈沒結束」而非「等待很貴」的證據。
|
||||
//
|
||||
// 原本的舊註解寫「改用 busy-wait 模擬」是錯的:這個檔從來沒有 busy-wait,
|
||||
// 一直是 time.Sleep。那句話誤導了後來每一個讀這個檔的人。
|
||||
//
|
||||
// 本次刻意不改行為、只改註解:手邊沒有 TinyGo 工具鏈,改了 main.go 卻沒重編,
|
||||
// 會讓 repo 內已 commit 的 .component-builds/wait/component.wasm 與原始碼漂移
|
||||
// (rule 05「WASM 來源」:那份 wasm 是 self-host 用戶的部署來源)。
|
||||
// 要退役這顆零件(刪目錄/下架 wait.arcrun.dev)是另一個決定,需人拍板。
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
Reference in New Issue
Block a user