Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5e4caf5cb | |||
| e69d6bbc03 | |||
| b302c03ea8 | |||
| c497ec418e | |||
| 6985bf4850 | |||
| 8e10f1d83e | |||
| 3eb8b31f2b | |||
| 8cee9c9f76 | |||
| 7e3ca4c1a1 | |||
| af3edff856 | |||
| c5d696556e | |||
| 5919c6b90f | |||
| 525faaf5d0 |
@@ -1,7 +1,12 @@
|
|||||||
var __defProp = Object.defineProperty;
|
var __defProp = Object.defineProperty;
|
||||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
var __esm = (fn, res) => function __init() {
|
var __esm = (fn, res, err2) => function __init() {
|
||||||
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
if (err2) throw err2[0];
|
||||||
|
try {
|
||||||
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
||||||
|
} catch (e) {
|
||||||
|
throw err2 = [e], e;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
var __export = (target, all) => {
|
var __export = (target, all) => {
|
||||||
for (var name in all)
|
for (var name in all)
|
||||||
@@ -102,7 +107,7 @@ function applyBaseRuntimeOptions(runtime, options) {
|
|||||||
function applyModuleEvalRuntimeOptions(runtime, options) {
|
function applyModuleEvalRuntimeOptions(runtime, options) {
|
||||||
options.moduleLoader && runtime.setModuleLoader(options.moduleLoader), options.shouldInterrupt && runtime.setInterruptHandler(options.shouldInterrupt), options.memoryLimitBytes !== void 0 && runtime.setMemoryLimit(options.memoryLimitBytes), options.maxStackSizeBytes !== void 0 && runtime.setMaxStackSize(options.maxStackSizeBytes);
|
options.moduleLoader && runtime.setModuleLoader(options.moduleLoader), options.shouldInterrupt && runtime.setInterruptHandler(options.shouldInterrupt), options.memoryLimitBytes !== void 0 && runtime.setMemoryLimit(options.memoryLimitBytes), options.maxStackSizeBytes !== void 0 && runtime.setMaxStackSize(options.maxStackSizeBytes);
|
||||||
}
|
}
|
||||||
var __defProp2, __export2, QTS_DEBUG, errors_exports, QuickJSUnwrapError, QuickJSWrongOwner, QuickJSUseAfterFree, QuickJSNotImplemented, QuickJSAsyncifyError, QuickJSAsyncifySuspended, QuickJSMemoryLeakDetected, QuickJSEmscriptenModuleError, QuickJSUnknownIntrinsic, QuickJSPromisePending, QuickJSEmptyGetOwnPropertyNames, AwaitYield, UsingDisposable, SymbolDispose, prototypeAsAny, Lifetime, StaticLifetime, WeakLifetime, Scope, AbstractDisposableResult, DisposableSuccess, DisposableFail, DisposableResult, QuickJSDeferredPromise, ModuleMemory, UnstableSymbol, DefaultIntrinsics, QuickJSIterator, ContextMemory, QuickJSContext, QuickJSRuntime, QuickJSEmscriptenModuleCallbacks, QuickJSModuleCallbacks, QuickJSWASMModule;
|
var __defProp2, __export2, QTS_DEBUG, errors_exports, QuickJSUnwrapError, QuickJSWrongOwner, QuickJSUseAfterFree, QuickJSNotImplemented, QuickJSAsyncifyError, QuickJSAsyncifySuspended, QuickJSMemoryLeakDetected, QuickJSEmscriptenModuleError, QuickJSUnknownIntrinsic, QuickJSPromisePending, QuickJSEmptyGetOwnPropertyNames, AwaitYield, UsingDisposable, SymbolDispose, prototypeAsAny, Lifetime, StaticLifetime, WeakLifetime, Scope, AbstractDisposableResult, DisposableSuccess, DisposableFail, DisposableResult, QuickJSDeferredPromise, ModuleMemory, DefaultIntrinsics, QuickJSIterator, ContextMemory, QuickJSContext, QuickJSRuntime, QuickJSEmscriptenModuleCallbacks, QuickJSModuleCallbacks, QuickJSWASMModule;
|
||||||
var init_chunk_JTKJZQYV = __esm({
|
var init_chunk_JTKJZQYV = __esm({
|
||||||
"registry/components/code/node_modules/quickjs-emscripten-core/dist/chunk-JTKJZQYV.mjs"() {
|
"registry/components/code/node_modules/quickjs-emscripten-core/dist/chunk-JTKJZQYV.mjs"() {
|
||||||
init_dist();
|
init_dist();
|
||||||
@@ -190,7 +195,7 @@ var init_chunk_JTKJZQYV = __esm({
|
|||||||
return this.dispose();
|
return this.dispose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
SymbolDispose = Symbol.dispose ?? Symbol.for("Symbol.dispose");
|
SymbolDispose = Symbol.dispose ?? /* @__PURE__ */ Symbol.for("Symbol.dispose");
|
||||||
prototypeAsAny = UsingDisposable.prototype;
|
prototypeAsAny = UsingDisposable.prototype;
|
||||||
prototypeAsAny[SymbolDispose] || (prototypeAsAny[SymbolDispose] = function() {
|
prototypeAsAny[SymbolDispose] || (prototypeAsAny[SymbolDispose] = function() {
|
||||||
return this.dispose();
|
return this.dispose();
|
||||||
@@ -409,7 +414,6 @@ Lifetime used`) : new QuickJSUseAfterFree("Lifetime not alive");
|
|||||||
return this.module._free(ptr), str;
|
return this.module._free(ptr), str;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
UnstableSymbol = Symbol("Unstable");
|
|
||||||
DefaultIntrinsics = Object.freeze({ BaseObjects: true, Date: true, Eval: true, StringNormalize: true, RegExp: true, JSON: true, Proxy: true, MapSet: true, TypedArrays: true, Promise: true });
|
DefaultIntrinsics = Object.freeze({ BaseObjects: true, Date: true, Eval: true, StringNormalize: true, RegExp: true, JSON: true, Proxy: true, MapSet: true, TypedArrays: true, Promise: true });
|
||||||
QuickJSIterator = class extends UsingDisposable {
|
QuickJSIterator = class extends UsingDisposable {
|
||||||
constructor(handle, context) {
|
constructor(handle, context) {
|
||||||
@@ -773,7 +777,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception;
|
|||||||
}
|
}
|
||||||
return result.value;
|
return result.value;
|
||||||
}
|
}
|
||||||
[Symbol.for("nodejs.util.inspect.custom")]() {
|
[/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
|
||||||
return this.alive ? `${this.constructor.name} { ctx: ${this.ctx.value} rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`;
|
return this.alive ? `${this.constructor.name} { ctx: ${this.ctx.value} rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`;
|
||||||
}
|
}
|
||||||
getFunction(fn_id) {
|
getFunction(fn_id) {
|
||||||
@@ -909,7 +913,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception;
|
|||||||
debugLog(...msg) {
|
debugLog(...msg) {
|
||||||
this._debugMode && console.log("quickjs-emscripten:", ...msg);
|
this._debugMode && console.log("quickjs-emscripten:", ...msg);
|
||||||
}
|
}
|
||||||
[Symbol.for("nodejs.util.inspect.custom")]() {
|
[/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
|
||||||
return this.alive ? `${this.constructor.name} { rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`;
|
return this.alive ? `${this.constructor.name} { rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`;
|
||||||
}
|
}
|
||||||
getSystemContext() {
|
getSystemContext() {
|
||||||
@@ -1339,10 +1343,10 @@ async function QuickJSRaw(moduleArg = {}) {
|
|||||||
x ? (0 === h && (h = ra()), g[m] = x(e[m])) : g[m] = e[m];
|
x ? (0 === h && (h = ra()), g[m] = x(e[m])) : g[m] = e[m];
|
||||||
}
|
}
|
||||||
b = a(...g);
|
b = a(...g);
|
||||||
return b = function(k) {
|
return b = (function(k) {
|
||||||
0 !== h && sa(h);
|
0 !== h && sa(h);
|
||||||
return "string" === d ? R(k) : "boolean" === d ? !!k : k;
|
return "string" === d ? R(k) : "boolean" === d ? !!k : k;
|
||||||
}(b);
|
})(b);
|
||||||
};
|
};
|
||||||
c.wasmMemory ? r = c.wasmMemory : r = new WebAssembly.Memory({ initial: (c.INITIAL_MEMORY || 16777216) / 65536, maximum: 32768 });
|
c.wasmMemory ? r = c.wasmMemory : r = new WebAssembly.Memory({ initial: (c.INITIAL_MEMORY || 16777216) / 65536, maximum: 32768 });
|
||||||
K();
|
K();
|
||||||
@@ -1464,7 +1468,7 @@ async function QuickJSRaw(moduleArg = {}) {
|
|||||||
}, t: function(a, d) {
|
}, t: function(a, d) {
|
||||||
c.callbacks.freeHostRef(void 0, a, d);
|
c.callbacks.freeHostRef(void 0, a, d);
|
||||||
} }, Z;
|
} }, Z;
|
||||||
Z = await async function() {
|
Z = await (async function() {
|
||||||
function a(b) {
|
function a(b) {
|
||||||
b = Z = b.exports;
|
b = Z = b.exports;
|
||||||
c._malloc = b.v;
|
c._malloc = b.v;
|
||||||
@@ -1551,7 +1555,7 @@ async function QuickJSRaw(moduleArg = {}) {
|
|||||||
});
|
});
|
||||||
M ??= c.locateFile ? c.locateFile ? c.locateFile("emscripten-module.wasm", u) : u + "emscripten-module.wasm" : new URL("emscripten-module.wasm", import.meta.url).href;
|
M ??= c.locateFile ? c.locateFile ? c.locateFile("emscripten-module.wasm", u) : u + "emscripten-module.wasm" : new URL("emscripten-module.wasm", import.meta.url).href;
|
||||||
return a((await ea(d)).instance);
|
return a((await ea(d)).instance);
|
||||||
}();
|
})();
|
||||||
(function() {
|
(function() {
|
||||||
function a() {
|
function a() {
|
||||||
c.calledRun = true;
|
c.calledRun = true;
|
||||||
@@ -3034,7 +3038,7 @@ var Hono = class _Hono {
|
|||||||
var emptyParam = [];
|
var emptyParam = [];
|
||||||
function match(method, path) {
|
function match(method, path) {
|
||||||
const matchers = this.buildAllMatchers();
|
const matchers = this.buildAllMatchers();
|
||||||
const match2 = (method2, path2) => {
|
const match2 = ((method2, path2) => {
|
||||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||||
const staticMatch = matcher[2][path2];
|
const staticMatch = matcher[2][path2];
|
||||||
if (staticMatch) {
|
if (staticMatch) {
|
||||||
@@ -3046,7 +3050,7 @@ function match(method, path) {
|
|||||||
}
|
}
|
||||||
const index = match3.indexOf("", 1);
|
const index = match3.indexOf("", 1);
|
||||||
return [matcher[1][index], match3];
|
return [matcher[1][index], match3];
|
||||||
};
|
});
|
||||||
this.match = match2;
|
this.match = match2;
|
||||||
return match2(method, path);
|
return match2(method, path);
|
||||||
}
|
}
|
||||||
@@ -4008,7 +4012,7 @@ app.post("/", async (c) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
var code_default = app;
|
var index_default = app;
|
||||||
export {
|
export {
|
||||||
code_default as default
|
index_default as default
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
var __defProp = Object.defineProperty;
|
var __defProp = Object.defineProperty;
|
||||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||||
var __esm = (fn, res) => function __init() {
|
var __esm = (fn, res, err) => function __init() {
|
||||||
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
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) => {
|
var __export = (target, all) => {
|
||||||
for (var name in all)
|
for (var name in all)
|
||||||
@@ -1501,7 +1506,7 @@ var init_hono_base = __esm({
|
|||||||
// cypher-executor/node_modules/.pnpm/hono@4.12.10/node_modules/hono/dist/router/reg-exp-router/matcher.js
|
// cypher-executor/node_modules/.pnpm/hono@4.12.10/node_modules/hono/dist/router/reg-exp-router/matcher.js
|
||||||
function match(method, path) {
|
function match(method, path) {
|
||||||
const matchers = this.buildAllMatchers();
|
const matchers = this.buildAllMatchers();
|
||||||
const match2 = (method2, path2) => {
|
const match2 = ((method2, path2) => {
|
||||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||||
const staticMatch = matcher[2][path2];
|
const staticMatch = matcher[2][path2];
|
||||||
if (staticMatch) {
|
if (staticMatch) {
|
||||||
@@ -1513,7 +1518,7 @@ function match(method, path) {
|
|||||||
}
|
}
|
||||||
const index = match3.indexOf("", 1);
|
const index = match3.indexOf("", 1);
|
||||||
return [matcher[1][index], match3];
|
return [matcher[1][index], match3];
|
||||||
};
|
});
|
||||||
this.match = match2;
|
this.match = match2;
|
||||||
return match2(method, path);
|
return match2(method, path);
|
||||||
}
|
}
|
||||||
@@ -3022,7 +3027,7 @@ async function readBodyOnce(res) {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var WASM_HTTP_RUNNER_IDS, LOGIC_BINDING_MAP;
|
var WASM_HTTP_RUNNER_IDS, LOGIC_BINDING_MAP, RUNTIME_NATIVE_COMPONENT_IDS;
|
||||||
var init_component_loader = __esm({
|
var init_component_loader = __esm({
|
||||||
"cypher-executor/src/lib/component-loader.ts"() {
|
"cypher-executor/src/lib/component-loader.ts"() {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -3065,6 +3070,12 @@ var init_component_loader = __esm({
|
|||||||
// ai_transform_compile / ai_transform_run 已刪除(2026-05-29):
|
// ai_transform_compile / ai_transform_run 已刪除(2026-05-29):
|
||||||
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
|
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
|
||||||
};
|
};
|
||||||
|
RUNTIME_NATIVE_COMPONENT_IDS = /* @__PURE__ */ new Set([
|
||||||
|
"trigger_workflow",
|
||||||
|
...BUILTIN_COMPONENTS.keys(),
|
||||||
|
...Object.keys(LOGIC_BINDING_MAP),
|
||||||
|
...WASM_HTTP_RUNNER_IDS
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3201,6 +3212,20 @@ var init_kbdb_proxy = __esm({
|
|||||||
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param("recordId"))}`, { headers });
|
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param("recordId"))}`, { headers });
|
||||||
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||||
});
|
});
|
||||||
|
kbdbProxyRouter.patch("/kbdb/records/:recordId", async (c) => {
|
||||||
|
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
if (!body || typeof body.values !== "object" || body.values === null) {
|
||||||
|
return c.json({ error: "values \u5FC5\u586B\uFF08{slot\u540D: \u5167\u5BB9}\uFF09" }, 400);
|
||||||
|
}
|
||||||
|
const { base, headers } = kbdbBase(c.env);
|
||||||
|
const res = await fetch(`${base}/records/${encodeURIComponent(c.req.param("recordId"))}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ values: body.values })
|
||||||
|
});
|
||||||
|
return new Response(res.body, { status: res.status, headers: { "Content-Type": "application/json" } });
|
||||||
|
});
|
||||||
kbdbProxyRouter.get("/kbdb/search", async (c) => {
|
kbdbProxyRouter.get("/kbdb/search", async (c) => {
|
||||||
const owner = tenant(c);
|
const owner = tenant(c);
|
||||||
if (!owner) return c.json(NEED_KEY, 401);
|
if (!owner) return c.json(NEED_KEY, 401);
|
||||||
@@ -7355,7 +7380,7 @@ var init_lib = __esm({
|
|||||||
...processCreateParams(params)
|
...processCreateParams(params)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
BRAND = Symbol("zod_brand");
|
BRAND = /* @__PURE__ */ Symbol("zod_brand");
|
||||||
ZodBranded = class extends ZodType {
|
ZodBranded = class extends ZodType {
|
||||||
_parse(input) {
|
_parse(input) {
|
||||||
const { ctx } = this._processInputParams(input);
|
const { ctx } = this._processInputParams(input);
|
||||||
@@ -7529,14 +7554,14 @@ var init_lib = __esm({
|
|||||||
onumber = () => numberType().optional();
|
onumber = () => numberType().optional();
|
||||||
oboolean = () => booleanType().optional();
|
oboolean = () => booleanType().optional();
|
||||||
coerce = {
|
coerce = {
|
||||||
string: (arg) => ZodString.create({ ...arg, coerce: true }),
|
string: ((arg) => ZodString.create({ ...arg, coerce: true })),
|
||||||
number: (arg) => ZodNumber.create({ ...arg, coerce: true }),
|
number: ((arg) => ZodNumber.create({ ...arg, coerce: true })),
|
||||||
boolean: (arg) => ZodBoolean.create({
|
boolean: ((arg) => ZodBoolean.create({
|
||||||
...arg,
|
...arg,
|
||||||
coerce: true
|
coerce: true
|
||||||
}),
|
})),
|
||||||
bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }),
|
bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })),
|
||||||
date: (arg) => ZodDate.create({ ...arg, coerce: true })
|
date: ((arg) => ZodDate.create({ ...arg, coerce: true }))
|
||||||
};
|
};
|
||||||
NEVER = INVALID;
|
NEVER = INVALID;
|
||||||
z = /* @__PURE__ */ Object.freeze({
|
z = /* @__PURE__ */ Object.freeze({
|
||||||
@@ -7757,6 +7782,7 @@ var init_recipe_loader = __esm({
|
|||||||
super(message);
|
super(message);
|
||||||
this.recipe = recipe;
|
this.recipe = recipe;
|
||||||
}
|
}
|
||||||
|
recipe;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -8763,7 +8789,7 @@ function recordRecipeStats(env, recipeKeys, ok, at, ctx) {
|
|||||||
)
|
)
|
||||||
).then(() => void 0);
|
).then(() => void 0);
|
||||||
if (ctx?.waitUntil) ctx.waitUntil(promise);
|
if (ctx?.waitUntil) ctx.waitUntil(promise);
|
||||||
else ;
|
else void promise;
|
||||||
}
|
}
|
||||||
function generateToken() {
|
function generateToken() {
|
||||||
const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
|
const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
@@ -8805,7 +8831,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct
|
|||||||
result.trace
|
result.trace
|
||||||
);
|
);
|
||||||
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
|
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
|
||||||
else ;
|
else void statsPromise;
|
||||||
}
|
}
|
||||||
return { success: true, data: result.data, duration_ms };
|
return { success: true, data: result.data, duration_ms };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -8829,7 +8855,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct
|
|||||||
err.trace
|
err.trace
|
||||||
);
|
);
|
||||||
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
|
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
|
||||||
else ;
|
else void statsPromise;
|
||||||
}
|
}
|
||||||
if (err instanceof ExecutionError) {
|
if (err instanceof ExecutionError) {
|
||||||
const traceFormatted = err.trace.map((s) => ({
|
const traceFormatted = err.trace.map((s) => ({
|
||||||
@@ -9294,6 +9320,7 @@ function extractTarget(input) {
|
|||||||
return typeof raw2 === "string" ? raw2 : JSON.stringify(raw2);
|
return typeof raw2 === "string" ? raw2 : JSON.stringify(raw2);
|
||||||
}
|
}
|
||||||
async function writeExecutionVerdict(env, workflowId, nodes, verdict, durationMs, message, input, apiKey) {
|
async function writeExecutionVerdict(env, workflowId, nodes, verdict, durationMs, message, input, apiKey) {
|
||||||
|
void nodes;
|
||||||
try {
|
try {
|
||||||
const { base, headers } = kbdbBase(env);
|
const { base, headers } = kbdbBase(env);
|
||||||
await fetch(`${base}/execution-log/record`, {
|
await fetch(`${base}/execution-log/record`, {
|
||||||
@@ -9485,6 +9512,16 @@ async function searchNodes(parsed, config, env, mode = "discover", target) {
|
|||||||
nodeResults[nodeName] = { status: "found", componentId, type: role };
|
nodeResults[nodeName] = { status: "found", componentId, type: role };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
|
||||||
|
nodeResults[nodeName] = {
|
||||||
|
status: "found",
|
||||||
|
componentId,
|
||||||
|
type: role,
|
||||||
|
source: "builtin",
|
||||||
|
branch_hint: branchHintFor(componentId)
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (catalog.status === "unreachable") {
|
if (catalog.status === "unreachable") {
|
||||||
nodeResults[nodeName] = { status: "unknown", componentId, type: role };
|
nodeResults[nodeName] = { status: "unknown", componentId, type: role };
|
||||||
continue;
|
continue;
|
||||||
@@ -15257,10 +15294,10 @@ app.route("/", consoleAuthRouter);
|
|||||||
app.route("/", consoleDashboardRouter);
|
app.route("/", consoleDashboardRouter);
|
||||||
app.route("/", portalRouter);
|
app.route("/", portalRouter);
|
||||||
app.route("/", portalDataRouter);
|
app.route("/", portalDataRouter);
|
||||||
var src_default = {
|
var index_default = {
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
scheduled: handleScheduled
|
scheduled: handleScheduled
|
||||||
};
|
};
|
||||||
export {
|
export {
|
||||||
src_default as default
|
index_default as default
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1427,7 +1427,7 @@ var Hono = class _Hono {
|
|||||||
var emptyParam = [];
|
var emptyParam = [];
|
||||||
function match(method, path) {
|
function match(method, path) {
|
||||||
const matchers = this.buildAllMatchers();
|
const matchers = this.buildAllMatchers();
|
||||||
const match2 = (method2, path2) => {
|
const match2 = ((method2, path2) => {
|
||||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||||
const staticMatch = matcher[2][path2];
|
const staticMatch = matcher[2][path2];
|
||||||
if (staticMatch) {
|
if (staticMatch) {
|
||||||
@@ -1439,7 +1439,7 @@ function match(method, path) {
|
|||||||
}
|
}
|
||||||
const index = match3.indexOf("", 1);
|
const index = match3.indexOf("", 1);
|
||||||
return [matcher[1][index], match3];
|
return [matcher[1][index], match3];
|
||||||
};
|
});
|
||||||
this.match = match2;
|
this.match = match2;
|
||||||
return match2(method, path);
|
return match2(method, path);
|
||||||
}
|
}
|
||||||
@@ -2522,7 +2522,7 @@ app.post("/", async (c) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
var src_default = app;
|
var index_default = app;
|
||||||
async function runWasm(input) {
|
async function runWasm(input) {
|
||||||
const hostFunctions = {
|
const hostFunctions = {
|
||||||
http_request: async (url, method, headersJson, body) => {
|
http_request: async (url, method, headersJson, body) => {
|
||||||
@@ -2568,5 +2568,5 @@ async function runWasm(input) {
|
|||||||
return JSON.parse(stdout);
|
return JSON.parse(stdout);
|
||||||
}
|
}
|
||||||
export {
|
export {
|
||||||
src_default as default
|
index_default as default
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1444,7 +1444,7 @@ var Hono = class _Hono {
|
|||||||
var emptyParam = [];
|
var emptyParam = [];
|
||||||
function match(method, path) {
|
function match(method, path) {
|
||||||
const matchers = this.buildAllMatchers();
|
const matchers = this.buildAllMatchers();
|
||||||
const match2 = (method2, path2) => {
|
const match2 = ((method2, path2) => {
|
||||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||||
const staticMatch = matcher[2][path2];
|
const staticMatch = matcher[2][path2];
|
||||||
if (staticMatch) {
|
if (staticMatch) {
|
||||||
@@ -1456,7 +1456,7 @@ function match(method, path) {
|
|||||||
}
|
}
|
||||||
const index = match3.indexOf("", 1);
|
const index = match3.indexOf("", 1);
|
||||||
return [matcher[1][index], match3];
|
return [matcher[1][index], match3];
|
||||||
};
|
});
|
||||||
this.match = match2;
|
this.match = match2;
|
||||||
return match2(method, path);
|
return match2(method, path);
|
||||||
}
|
}
|
||||||
@@ -2189,11 +2189,18 @@ async function deprecateEntriesByLibrary(db, ownerId, library) {
|
|||||||
var MAX_LIKE_Q_BYTES = 48;
|
var MAX_LIKE_Q_BYTES = 48;
|
||||||
var MAX_LIKE_TERMS = 6;
|
var MAX_LIKE_TERMS = 6;
|
||||||
var utf8Len = (s) => new TextEncoder().encode(s).length;
|
var utf8Len = (s) => new TextEncoder().encode(s).length;
|
||||||
|
var LIKE_ESCAPE = "\\";
|
||||||
|
var CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`;
|
||||||
|
function escapeLikeLiteral(s) {
|
||||||
|
return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch);
|
||||||
|
}
|
||||||
|
var likeBytes = (s) => utf8Len(escapeLikeLiteral(s));
|
||||||
|
var likePattern = (s) => `%${escapeLikeLiteral(s)}%`;
|
||||||
function chunkByBytes(s, maxBytes) {
|
function chunkByBytes(s, maxBytes) {
|
||||||
const out = [];
|
const out = [];
|
||||||
let cur = "";
|
let cur = "";
|
||||||
for (const ch of s) {
|
for (const ch of s) {
|
||||||
if (utf8Len(cur + ch) > maxBytes) {
|
if (likeBytes(cur + ch) > maxBytes) {
|
||||||
if (cur) out.push(cur);
|
if (cur) out.push(cur);
|
||||||
cur = ch;
|
cur = ch;
|
||||||
} else {
|
} else {
|
||||||
@@ -2204,8 +2211,8 @@ function chunkByBytes(s, maxBytes) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
function buildContentLike(q) {
|
function buildContentLike(q) {
|
||||||
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
|
if (likeBytes(q) <= MAX_LIKE_Q_BYTES) {
|
||||||
return { conds: ["content LIKE ?"], params: [`%${q}%`], split: false };
|
return { conds: [CONTENT_LIKE], params: [likePattern(q)], split: false };
|
||||||
}
|
}
|
||||||
const terms = [];
|
const terms = [];
|
||||||
for (const word of q.split(/\s+/).filter(Boolean)) {
|
for (const word of q.split(/\s+/).filter(Boolean)) {
|
||||||
@@ -2217,8 +2224,8 @@ function buildContentLike(q) {
|
|||||||
}
|
}
|
||||||
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? "");
|
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? "");
|
||||||
return {
|
return {
|
||||||
conds: terms.map(() => "content LIKE ?"),
|
conds: terms.map(() => CONTENT_LIKE),
|
||||||
params: terms.map((t) => `%${t}%`),
|
params: terms.map(likePattern),
|
||||||
split: true
|
split: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2335,7 +2342,7 @@ function buildSearchScore(q) {
|
|||||||
if (terms.length === 0) {
|
if (terms.length === 0) {
|
||||||
const m = buildContentLike(trimmed);
|
const m = buildContentLike(trimmed);
|
||||||
return {
|
return {
|
||||||
scoreExpr: m.conds.map(() => "CASE WHEN content LIKE ? THEN 1 ELSE 0 END").join(" + "),
|
scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(" + "),
|
||||||
scoreParams: m.params,
|
scoreParams: m.params,
|
||||||
terms: [],
|
terms: [],
|
||||||
legacyShape: true
|
legacyShape: true
|
||||||
@@ -2344,14 +2351,14 @@ function buildSearchScore(q) {
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
for (const { term, weight } of terms) {
|
for (const { term, weight } of terms) {
|
||||||
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
|
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${weight} ELSE 0 END`);
|
||||||
params.push(`%${term}%`);
|
params.push(likePattern(term));
|
||||||
}
|
}
|
||||||
const single = terms.length === 1 && terms[0].term === trimmed;
|
const single = terms.length === 1 && terms[0].term === trimmed;
|
||||||
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
|
if (!single && likeBytes(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||||
const bonus = terms.reduce((s, t) => s + t.weight, 0);
|
const bonus = terms.reduce((s, t) => s + t.weight, 0);
|
||||||
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
|
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
|
||||||
params.push(`%${trimmed}%`);
|
params.push(likePattern(trimmed));
|
||||||
}
|
}
|
||||||
return { scoreExpr: parts.join(" + "), scoreParams: params, terms, legacyShape: single };
|
return { scoreExpr: parts.join(" + "), scoreParams: params, terms, legacyShape: single };
|
||||||
}
|
}
|
||||||
@@ -2408,6 +2415,57 @@ async function searchEntries(db, q, owner_id, entry_type, limit = 50, library, s
|
|||||||
return applyRelativeCut(res.results ?? []);
|
return applyRelativeCut(res.results ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kbdb/src/actions/maintenance-quota.ts
|
||||||
|
var DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT = 2e4;
|
||||||
|
function maintenanceDailyLimit(env) {
|
||||||
|
const raw2 = env.KBDB_MAINTENANCE_DAILY_WRITE_LIMIT;
|
||||||
|
const n = raw2 ? parseInt(raw2, 10) : NaN;
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAINTENANCE_DAILY_WRITE_LIMIT;
|
||||||
|
}
|
||||||
|
function utcDay() {
|
||||||
|
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
function maintenanceUsageId() {
|
||||||
|
return `kbdb-maintenance-usage:${utcDay()}`;
|
||||||
|
}
|
||||||
|
async function getMaintenanceUsageToday(db) {
|
||||||
|
const row = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(maintenanceUsageId()).first();
|
||||||
|
if (!row) return 0;
|
||||||
|
try {
|
||||||
|
const parsed = row.metadata_json ? JSON.parse(row.metadata_json) : {};
|
||||||
|
return Number(parsed.writes) || 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function addMaintenanceUsage(db, by) {
|
||||||
|
if (by <= 0) return;
|
||||||
|
const id = maintenanceUsageId();
|
||||||
|
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
|
||||||
|
let prev = 0;
|
||||||
|
if (existing) {
|
||||||
|
try {
|
||||||
|
const parsed = existing.metadata_json ? JSON.parse(existing.metadata_json) : {};
|
||||||
|
prev = Number(parsed.writes) || 0;
|
||||||
|
} catch {
|
||||||
|
prev = 0;
|
||||||
|
}
|
||||||
|
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay(), writes: prev + by }), id).run();
|
||||||
|
} else {
|
||||||
|
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'kbdb_maintenance_usage', ?)`).bind(id, JSON.stringify({ day: utcDay(), writes: by })).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function maintenanceBudgetToday(env, db) {
|
||||||
|
const limit = maintenanceDailyLimit(env);
|
||||||
|
let used = 0;
|
||||||
|
try {
|
||||||
|
used = await getMaintenanceUsageToday(db);
|
||||||
|
} catch {
|
||||||
|
used = 0;
|
||||||
|
}
|
||||||
|
return { limit, used, remaining: Math.max(0, limit - used) };
|
||||||
|
}
|
||||||
|
|
||||||
// kbdb/src/embed.ts
|
// kbdb/src/embed.ts
|
||||||
var DEFAULT_EMBED_MODEL = "@cf/baai/bge-m3";
|
var DEFAULT_EMBED_MODEL = "@cf/baai/bge-m3";
|
||||||
var MIN_SCORE_ABS_FLOOR = 0.45;
|
var MIN_SCORE_ABS_FLOOR = 0.45;
|
||||||
@@ -2449,7 +2507,7 @@ async function embedOnWrite(env, entry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
await env.DB.prepare("UPDATE entries SET is_embedded = 1 WHERE id = ?").bind(entry.id).run();
|
await env.DB.prepare("UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id = ?").bind(embedModel(env), entry.id).run();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function isEmbeddable(entry) {
|
function isEmbeddable(entry) {
|
||||||
@@ -2476,12 +2534,47 @@ function parseMeta(json) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var BACKFILL_PREDICATE = "is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";
|
var BACKFILL_PREDICATE = "is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";
|
||||||
async function backfillEmbeddings(env, opts = {}) {
|
var DEFAULT_BACKFILL_DAILY_LIMIT = 1800;
|
||||||
if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 };
|
function backfillDailyLimit(env) {
|
||||||
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
|
const raw2 = env.EMBED_BACKFILL_DAILY_LIMIT;
|
||||||
const offset = Math.max(opts.offset ?? 0, 0);
|
const n = raw2 ? parseInt(raw2, 10) : NaN;
|
||||||
const basePredicate = opts.reindex ? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1" : BACKFILL_PREDICATE;
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_BACKFILL_DAILY_LIMIT;
|
||||||
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"];
|
}
|
||||||
|
function utcDay2() {
|
||||||
|
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
function backfillUsageId() {
|
||||||
|
return `embed-backfill-usage:${utcDay2()}`;
|
||||||
|
}
|
||||||
|
async function getBackfillUsageToday(db) {
|
||||||
|
const row = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(backfillUsageId()).first();
|
||||||
|
if (!row) return 0;
|
||||||
|
try {
|
||||||
|
const parsed = row.metadata_json ? JSON.parse(row.metadata_json) : {};
|
||||||
|
return Number(parsed.embedded) || 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function addBackfillUsage(db, by) {
|
||||||
|
if (by <= 0) return;
|
||||||
|
const id = backfillUsageId();
|
||||||
|
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
|
||||||
|
let prev = 0;
|
||||||
|
if (existing) {
|
||||||
|
try {
|
||||||
|
const parsed = existing.metadata_json ? JSON.parse(existing.metadata_json) : {};
|
||||||
|
prev = Number(parsed.embedded) || 0;
|
||||||
|
} catch {
|
||||||
|
prev = 0;
|
||||||
|
}
|
||||||
|
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay2(), embedded: prev + by }), id).run();
|
||||||
|
} else {
|
||||||
|
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'embed_backfill_usage', ?)`).bind(id, JSON.stringify({ day: utcDay2(), embedded: by })).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function selectionCriteriaPredicate(opts) {
|
||||||
|
const conds = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
if (opts.owner_id) {
|
if (opts.owner_id) {
|
||||||
conds.push("owner_id = ?");
|
conds.push("owner_id = ?");
|
||||||
@@ -2491,12 +2584,55 @@ async function backfillEmbeddings(env, opts = {}) {
|
|||||||
conds.push("json_extract(metadata_json, '$.source') = ?");
|
conds.push("json_extract(metadata_json, '$.source') = ?");
|
||||||
params.push(opts.source);
|
params.push(opts.source);
|
||||||
}
|
}
|
||||||
|
if (opts.library) {
|
||||||
|
conds.push("COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ?");
|
||||||
|
params.push(opts.library);
|
||||||
|
}
|
||||||
|
if (typeof opts.since === "number") {
|
||||||
|
conds.push("created_at >= ?");
|
||||||
|
params.push(opts.since);
|
||||||
|
}
|
||||||
|
if (typeof opts.until === "number") {
|
||||||
|
conds.push("created_at < ?");
|
||||||
|
params.push(opts.until);
|
||||||
|
}
|
||||||
|
return { conds, params };
|
||||||
|
}
|
||||||
|
async function backfillEmbeddings(env, opts = {}) {
|
||||||
|
if (!embedEnabled(env)) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
processed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
remaining: 0,
|
||||||
|
scanned: 0,
|
||||||
|
quota_limit: 0,
|
||||||
|
quota_used_today: 0,
|
||||||
|
quota_exceeded: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
|
||||||
|
const offset = Math.max(opts.offset ?? 0, 0);
|
||||||
|
const basePredicate = opts.reindex ? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1" : BACKFILL_PREDICATE;
|
||||||
|
const sel = selectionCriteriaPredicate(opts);
|
||||||
|
const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", ...sel.conds];
|
||||||
|
const params = [...sel.params];
|
||||||
const where = conds.join(" AND ");
|
const where = conds.join(" AND ");
|
||||||
const res = await env.DB.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all();
|
const res = await env.DB.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all();
|
||||||
const rows = res.results ?? [];
|
const rows = res.results ?? [];
|
||||||
const scanned = rows.length;
|
const scanned = rows.length;
|
||||||
|
const dailyCap = backfillDailyLimit(env);
|
||||||
|
let usedToday = 0;
|
||||||
|
try {
|
||||||
|
usedToday = await getBackfillUsageToday(env.DB);
|
||||||
|
} catch {
|
||||||
|
usedToday = 0;
|
||||||
|
}
|
||||||
|
const remainingQuota = Math.max(0, dailyCap - usedToday);
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
const embeddable = rows.filter((e) => (e.content ?? "").trim().length > 0);
|
const candidates = rows.filter((e) => (e.content ?? "").trim().length > 0);
|
||||||
|
const embeddable = candidates.slice(0, remainingQuota);
|
||||||
|
const quotaExceeded = candidates.length > embeddable.length;
|
||||||
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
|
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
|
||||||
const texts = embeddable.map((e) => (e.content ?? "").trim());
|
const texts = embeddable.map((e) => (e.content ?? "").trim());
|
||||||
const out = await env.AI.run(embedModel(env), { text: texts });
|
const out = await env.AI.run(embedModel(env), { text: texts });
|
||||||
@@ -2516,14 +2652,27 @@ async function backfillEmbeddings(env, opts = {}) {
|
|||||||
await env.VECTORIZE.upsert(vectors);
|
await env.VECTORIZE.upsert(vectors);
|
||||||
const ids = vectors.map((v) => v.id);
|
const ids = vectors.map((v) => v.id);
|
||||||
const placeholders = ids.map(() => "?").join(",");
|
const placeholders = ids.map(() => "?").join(",");
|
||||||
await env.DB.prepare(`UPDATE entries SET is_embedded = 1 WHERE id IN (${placeholders})`).bind(...ids).run();
|
await env.DB.prepare(`UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id IN (${placeholders})`).bind(embedModel(env), ...ids).run();
|
||||||
processed = vectors.length;
|
processed = vectors.length;
|
||||||
|
try {
|
||||||
|
await addBackfillUsage(env.DB, processed);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const remRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...params).first();
|
const remRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...params).first();
|
||||||
const totalMatching = remRow?.c ?? 0;
|
const totalMatching = remRow?.c ?? 0;
|
||||||
const remaining = opts.reindex ? Math.max(0, totalMatching - (offset + scanned)) : totalMatching;
|
const remaining = opts.reindex ? Math.max(0, totalMatching - (offset + scanned)) : totalMatching;
|
||||||
return { enabled: true, processed, skipped: scanned - processed, remaining, scanned };
|
return {
|
||||||
|
enabled: true,
|
||||||
|
processed,
|
||||||
|
skipped: scanned - processed,
|
||||||
|
remaining,
|
||||||
|
scanned,
|
||||||
|
quota_limit: dailyCap,
|
||||||
|
quota_used_today: usedToday + processed,
|
||||||
|
quota_exceeded: quotaExceeded
|
||||||
|
};
|
||||||
}
|
}
|
||||||
async function backfillStatus(env, opts = {}) {
|
async function backfillStatus(env, opts = {}) {
|
||||||
const conds = [];
|
const conds = [];
|
||||||
@@ -2541,6 +2690,74 @@ async function backfillStatus(env, opts = {}) {
|
|||||||
const embeddedRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE is_embedded = 1 AND json_extract(metadata_json, '$.embed') = 1${extra}`).bind(...params).first();
|
const embeddedRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE is_embedded = 1 AND json_extract(metadata_json, '$.embed') = 1${extra}`).bind(...params).first();
|
||||||
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
|
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
|
||||||
}
|
}
|
||||||
|
async function reconcileEmbedGeneration(env, opts = {}) {
|
||||||
|
if (!embedEnabled(env)) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
checked: 0,
|
||||||
|
confirmed_current: 0,
|
||||||
|
reset_to_pending: 0,
|
||||||
|
remaining: 0,
|
||||||
|
scanned: 0,
|
||||||
|
quota_limit: 0,
|
||||||
|
quota_used_today: 0,
|
||||||
|
quota_exceeded: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
||||||
|
const currentModel = embedModel(env);
|
||||||
|
const sel = selectionCriteriaPredicate(opts);
|
||||||
|
const conds = [
|
||||||
|
"is_embedded = 1",
|
||||||
|
"(content_hash IS NULL OR content_hash != ?)",
|
||||||
|
"COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'",
|
||||||
|
...sel.conds
|
||||||
|
];
|
||||||
|
const params = [currentModel, ...sel.params];
|
||||||
|
const where = conds.join(" AND ");
|
||||||
|
const res = await env.DB.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ?`).bind(...params, limit).all();
|
||||||
|
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||||
|
const scanned = scannedIds.length;
|
||||||
|
const budget = await maintenanceBudgetToday(env, env.DB);
|
||||||
|
const ids = scannedIds.slice(0, budget.remaining);
|
||||||
|
const quotaExceeded = scanned > ids.length;
|
||||||
|
const checked = ids.length;
|
||||||
|
let confirmed_current = 0;
|
||||||
|
let reset_to_pending = 0;
|
||||||
|
if (ids.length > 0 && env.VECTORIZE) {
|
||||||
|
const found = await env.VECTORIZE.getByIds(ids);
|
||||||
|
const foundIds = new Set(found.map((v) => v.id));
|
||||||
|
const presentIds = ids.filter((id) => foundIds.has(id));
|
||||||
|
const missingIds = ids.filter((id) => !foundIds.has(id));
|
||||||
|
if (presentIds.length > 0) {
|
||||||
|
const ph = presentIds.map(() => "?").join(",");
|
||||||
|
await env.DB.prepare(`UPDATE entries SET content_hash = ? WHERE id IN (${ph})`).bind(currentModel, ...presentIds).run();
|
||||||
|
confirmed_current = presentIds.length;
|
||||||
|
}
|
||||||
|
if (missingIds.length > 0) {
|
||||||
|
const ph = missingIds.map(() => "?").join(",");
|
||||||
|
await env.DB.prepare(`UPDATE entries SET is_embedded = 0, content_hash = NULL WHERE id IN (${ph})`).bind(...missingIds).run();
|
||||||
|
reset_to_pending = missingIds.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const remRow = await env.DB.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...params).first();
|
||||||
|
const written = confirmed_current + reset_to_pending;
|
||||||
|
try {
|
||||||
|
await addMaintenanceUsage(env.DB, written);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
checked,
|
||||||
|
confirmed_current,
|
||||||
|
reset_to_pending,
|
||||||
|
remaining: remRow?.c ?? 0,
|
||||||
|
scanned,
|
||||||
|
quota_limit: budget.limit,
|
||||||
|
quota_used_today: budget.used + written,
|
||||||
|
quota_exceeded: quotaExceeded
|
||||||
|
};
|
||||||
|
}
|
||||||
async function embedSelfTest(env, opts = {}) {
|
async function embedSelfTest(env, opts = {}) {
|
||||||
if (!embedEnabled(env)) {
|
if (!embedEnabled(env)) {
|
||||||
return { enabled: false, tested: false, passed: null, note: "embed \u6A21\u7D44\u672A\u958B\uFF08\u7F3A Vectorize/AI binding\uFF09\uFF0C\u8A9E\u7FA9\u641C\u5C0B\u9019\u689D\u8DEF\u76EE\u524D\u4E0D\u5B58\u5728" };
|
return { enabled: false, tested: false, passed: null, note: "embed \u6A21\u7D44\u672A\u958B\uFF08\u7F3A Vectorize/AI binding\uFF09\uFF0C\u8A9E\u7FA9\u641C\u5C0B\u9019\u689D\u8DEF\u76EE\u524D\u4E0D\u5B58\u5728" };
|
||||||
@@ -2647,6 +2864,90 @@ async function migrateLegacyCredentialsForOwner(db, ownerId) {
|
|||||||
return (after?.n ?? 0) - (before?.n ?? 0);
|
return (after?.n ?? 0) - (before?.n ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kbdb/src/actions/library-backfill.ts
|
||||||
|
var MAX_PAGE_NAMES = 300;
|
||||||
|
var HARD_LIMIT_CAP = 500;
|
||||||
|
function criteriaPredicate(c) {
|
||||||
|
const conds = [
|
||||||
|
"(json_extract(metadata_json, '$.library') IS NULL OR json_extract(metadata_json, '$.library') = '')"
|
||||||
|
];
|
||||||
|
const params = [];
|
||||||
|
if (c.owner_id) {
|
||||||
|
conds.push("owner_id = ?");
|
||||||
|
params.push(c.owner_id);
|
||||||
|
}
|
||||||
|
if (c.entry_type) {
|
||||||
|
conds.push("entry_type = ?");
|
||||||
|
params.push(c.entry_type);
|
||||||
|
}
|
||||||
|
if (c.page_names && c.page_names.length > 0) {
|
||||||
|
const names = c.page_names.slice(0, MAX_PAGE_NAMES);
|
||||||
|
conds.push(`page_name IN (${names.map(() => "?").join(",")})`);
|
||||||
|
params.push(...names);
|
||||||
|
}
|
||||||
|
if (c.source_prefix) {
|
||||||
|
conds.push("json_extract(metadata_json, '$.source') LIKE ? || '%'");
|
||||||
|
params.push(c.source_prefix);
|
||||||
|
}
|
||||||
|
if (c.page_name_prefix) {
|
||||||
|
conds.push("page_name LIKE ? || '%'");
|
||||||
|
params.push(c.page_name_prefix);
|
||||||
|
}
|
||||||
|
if (typeof c.since === "number") {
|
||||||
|
conds.push("created_at >= ?");
|
||||||
|
params.push(c.since);
|
||||||
|
}
|
||||||
|
if (typeof c.until === "number") {
|
||||||
|
conds.push("created_at < ?");
|
||||||
|
params.push(c.until);
|
||||||
|
}
|
||||||
|
return { conds, params };
|
||||||
|
}
|
||||||
|
async function backfillEntryLibraryTags(db, env, opts) {
|
||||||
|
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\uFF08\u6A19\u5EAB\u662F\u8DE8\u5927\u91CF\u65E2\u6709\u8CC7\u6599\u7684\u6279\u6B21\u5BEB\u5165\uFF0C\u4E0D\u51C6\u7121\u79DF\u6236\u7BC4\u570D\u5730\u6383\u5168\u5EAB\u2014\u20142026-08-11 leo \u76F4\u4EE4\uFF09");
|
||||||
|
const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);
|
||||||
|
const sel = criteriaPredicate({ ...opts, owner_id: ownerId });
|
||||||
|
const where = sel.conds.join(" AND ");
|
||||||
|
const params = sel.params;
|
||||||
|
const res = await db.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ?`).bind(...params, limit).all();
|
||||||
|
const scannedIds = (res.results ?? []).map((r) => r.id);
|
||||||
|
const scanned = scannedIds.length;
|
||||||
|
const budget = await maintenanceBudgetToday(env, db);
|
||||||
|
const ids = scannedIds.slice(0, budget.remaining);
|
||||||
|
const quotaExceeded = scanned > ids.length;
|
||||||
|
let tagged = 0;
|
||||||
|
if (ids.length > 0) {
|
||||||
|
const ph = ids.map(() => "?").join(",");
|
||||||
|
await db.prepare(
|
||||||
|
`UPDATE entries SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.library', ?), updated_at = unixepoch() WHERE id IN (${ph})`
|
||||||
|
).bind(library, ...ids).run();
|
||||||
|
tagged = ids.length;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await addMaintenanceUsage(db, tagged);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
const remRow = await db.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...params).first();
|
||||||
|
return {
|
||||||
|
library,
|
||||||
|
scanned,
|
||||||
|
tagged,
|
||||||
|
remaining: remRow?.c ?? 0,
|
||||||
|
quota_limit: budget.limit,
|
||||||
|
quota_used_today: budget.used + tagged,
|
||||||
|
quota_exceeded: quotaExceeded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function libraryBackfillStatus(db, opts = {}) {
|
||||||
|
const sel = criteriaPredicate(opts);
|
||||||
|
const where = sel.conds.join(" AND ");
|
||||||
|
const row = await db.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`).bind(...sel.params).first();
|
||||||
|
return { pending: row?.c ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
// kbdb/src/routes/entries.ts
|
// kbdb/src/routes/entries.ts
|
||||||
var entryRoutes = new Hono2();
|
var entryRoutes = new Hono2();
|
||||||
function fireAndForget(c, p) {
|
function fireAndForget(c, p) {
|
||||||
@@ -2873,6 +3174,39 @@ entryRoutes.patch("/deprecate-by-library", async (c) => {
|
|||||||
}
|
}
|
||||||
return c.json({ success: true, deprecated_count: count, vectors_deleted });
|
return c.json({ success: true, deprecated_count: count, vectors_deleted });
|
||||||
});
|
});
|
||||||
|
entryRoutes.post("/backfill-library", async (c) => {
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const library = String(body.library ?? "").trim();
|
||||||
|
const ownerId = String(body.owner_id ?? "").trim();
|
||||||
|
if (!library || !ownerId) return c.json({ success: false, error: "library \u8207 owner_id \u5FC5\u586B" }, 400);
|
||||||
|
try {
|
||||||
|
const result = await backfillEntryLibraryTags(c.env.DB, c.env, {
|
||||||
|
library,
|
||||||
|
owner_id: ownerId,
|
||||||
|
entry_type: body.entry_type || void 0,
|
||||||
|
page_names: Array.isArray(body.page_names) && body.page_names.length > 0 ? body.page_names : void 0,
|
||||||
|
source_prefix: body.source_prefix || void 0,
|
||||||
|
page_name_prefix: body.page_name_prefix || void 0,
|
||||||
|
since: body.since !== void 0 ? Number(body.since) : void 0,
|
||||||
|
until: body.until !== void 0 ? Number(body.until) : void 0,
|
||||||
|
limit: body.limit !== void 0 ? Number(body.limit) : void 0
|
||||||
|
});
|
||||||
|
return c.json({ success: true, ...result });
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
entryRoutes.get("/backfill-library/status", async (c) => {
|
||||||
|
const status = await libraryBackfillStatus(c.env.DB, {
|
||||||
|
owner_id: c.req.query("owner_id") || void 0,
|
||||||
|
entry_type: c.req.query("entry_type") || void 0,
|
||||||
|
source_prefix: c.req.query("source_prefix") || void 0,
|
||||||
|
page_name_prefix: c.req.query("page_name_prefix") || void 0,
|
||||||
|
since: c.req.query("since") ? Number(c.req.query("since")) : void 0,
|
||||||
|
until: c.req.query("until") ? Number(c.req.query("until")) : void 0
|
||||||
|
});
|
||||||
|
return c.json({ success: true, ...status });
|
||||||
|
});
|
||||||
entryRoutes.patch("/:id", async (c) => {
|
entryRoutes.patch("/:id", async (c) => {
|
||||||
const body = await c.req.json().catch(() => ({}));
|
const body = await c.req.json().catch(() => ({}));
|
||||||
const entry = await updateEntry(c.env.DB, c.req.param("id"), body);
|
const entry = await updateEntry(c.env.DB, c.req.param("id"), body);
|
||||||
@@ -3192,6 +3526,9 @@ embedRoutes.post("/backfill", async (c) => {
|
|||||||
limit: body.limit !== void 0 ? Number(body.limit) : void 0,
|
limit: body.limit !== void 0 ? Number(body.limit) : void 0,
|
||||||
owner_id: body.owner_id || void 0,
|
owner_id: body.owner_id || void 0,
|
||||||
source: body.source || void 0,
|
source: body.source || void 0,
|
||||||
|
library: body.library || void 0,
|
||||||
|
since: body.since !== void 0 ? Number(body.since) : void 0,
|
||||||
|
until: body.until !== void 0 ? Number(body.until) : void 0,
|
||||||
// reindex(Arcrun#11):重推既有向量讓事後建立的 Vectorize metadata index 收錄(見 embed.ts)。
|
// reindex(Arcrun#11):重推既有向量讓事後建立的 Vectorize metadata index 收錄(見 embed.ts)。
|
||||||
reindex: body.reindex === true,
|
reindex: body.reindex === true,
|
||||||
offset: body.offset !== void 0 ? Number(body.offset) : void 0
|
offset: body.offset !== void 0 ? Number(body.offset) : void 0
|
||||||
@@ -3205,6 +3542,23 @@ embedRoutes.get("/backfill/status", async (c) => {
|
|||||||
});
|
});
|
||||||
return c.json({ success: true, ...status });
|
return c.json({ success: true, ...status });
|
||||||
});
|
});
|
||||||
|
embedRoutes.post("/reconcile", async (c) => {
|
||||||
|
if (!embedEnabled(c.env)) {
|
||||||
|
return c.json(
|
||||||
|
{ success: false, error: "embed module not enabled (need VECTORIZE + AI bindings)", capability_hint: OFF_HINT },
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const result = await reconcileEmbedGeneration(c.env, {
|
||||||
|
limit: body.limit !== void 0 ? Number(body.limit) : void 0,
|
||||||
|
owner_id: body.owner_id || void 0,
|
||||||
|
library: body.library || void 0,
|
||||||
|
since: body.since !== void 0 ? Number(body.since) : void 0,
|
||||||
|
until: body.until !== void 0 ? Number(body.until) : void 0
|
||||||
|
});
|
||||||
|
return c.json({ success: true, ...result });
|
||||||
|
});
|
||||||
embedRoutes.get("/selftest", async (c) => {
|
embedRoutes.get("/selftest", async (c) => {
|
||||||
const result = await embedSelfTest(c.env, { owner_id: c.req.query("owner_id") || void 0 });
|
const result = await embedSelfTest(c.env, { owner_id: c.req.query("owner_id") || void 0 });
|
||||||
return c.json({ success: true, ...result });
|
return c.json({ success: true, ...result });
|
||||||
@@ -3402,14 +3756,18 @@ async function recomputeLibraryMap(db, input) {
|
|||||||
async function liveTripletCountsByLibrary(db, tripletTemplateId, owner_id) {
|
async function liveTripletCountsByLibrary(db, tripletTemplateId, owner_id) {
|
||||||
const params = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
|
const params = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
|
||||||
const res = await db.prepare(
|
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(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
||||||
FROM (
|
FROM (
|
||||||
SELECT DISTINCT ev.record_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
|
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||||
WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""}
|
WHERE ev.template_id = ?${owner_id ? " AND e.owner_id = ?" : ""}
|
||||||
|
GROUP BY ev.record_id
|
||||||
) AS tr
|
) AS tr
|
||||||
LEFT JOIN entry_values lev ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
|
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
|
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||||
|
WHERE COALESCE(tr.status, 'active') = 'active'
|
||||||
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`
|
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`
|
||||||
).bind(...params).all();
|
).bind(...params).all();
|
||||||
const m = /* @__PURE__ */ new Map();
|
const m = /* @__PURE__ */ new Map();
|
||||||
@@ -3558,7 +3916,7 @@ function dailyLimit(env) {
|
|||||||
const n = raw2 ? parseInt(raw2, 10) : NaN;
|
const n = raw2 ? parseInt(raw2, 10) : NaN;
|
||||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_DAILY_LIMIT;
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_DAILY_LIMIT;
|
||||||
}
|
}
|
||||||
function utcDay() {
|
function utcDay3() {
|
||||||
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
function truncate(s, max) {
|
function truncate(s, max) {
|
||||||
@@ -3566,7 +3924,7 @@ function truncate(s, max) {
|
|||||||
return s.slice(0, Math.max(0, max - 1)) + "\u2026";
|
return s.slice(0, Math.max(0, max - 1)) + "\u2026";
|
||||||
}
|
}
|
||||||
async function checkUsage(db, limit) {
|
async function checkUsage(db, limit) {
|
||||||
const id = `exlog-usage:${utcDay()}`;
|
const id = `exlog-usage:${utcDay3()}`;
|
||||||
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
|
const existing = await db.prepare("SELECT metadata_json FROM entries WHERE id = ?").bind(id).first();
|
||||||
let count;
|
let count;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -3578,10 +3936,10 @@ async function checkUsage(db, limit) {
|
|||||||
prevWrites = 0;
|
prevWrites = 0;
|
||||||
}
|
}
|
||||||
count = prevWrites + 1;
|
count = prevWrites + 1;
|
||||||
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay(), writes: count }), id).run();
|
await db.prepare("UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?").bind(JSON.stringify({ day: utcDay3(), writes: count }), id).run();
|
||||||
} else {
|
} else {
|
||||||
count = 1;
|
count = 1;
|
||||||
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`).bind(id, JSON.stringify({ day: utcDay(), writes: count })).run();
|
await db.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'execution_log_usage', ?)`).bind(id, JSON.stringify({ day: utcDay3(), writes: count })).run();
|
||||||
}
|
}
|
||||||
if (count > limit) return "skip";
|
if (count > limit) return "skip";
|
||||||
if (count > limit * DEGRADE_RATIO) return "log_failure_only";
|
if (count > limit * DEGRADE_RATIO) return "log_failure_only";
|
||||||
@@ -3797,7 +4155,7 @@ app.route("/recipe-stats", recipeStatRoutes);
|
|||||||
app.route("/execution-log", executionLogRoutes);
|
app.route("/execution-log", executionLogRoutes);
|
||||||
app.route("/embed", embedRoutes);
|
app.route("/embed", embedRoutes);
|
||||||
app.route("/map", mapRoutes);
|
app.route("/map", mapRoutes);
|
||||||
var src_default = app;
|
var index_default = app;
|
||||||
export {
|
export {
|
||||||
src_default as default
|
index_default as default
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
|
|||||||
var __getProtoOf = Object.getPrototypeOf;
|
var __getProtoOf = Object.getPrototypeOf;
|
||||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||||
var __commonJS = (cb, mod) => function __require() {
|
var __commonJS = (cb, mod) => function __require() {
|
||||||
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
try {
|
||||||
|
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
||||||
|
} catch (e) {
|
||||||
|
throw mod = 0, e;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
var __export = (target, all) => {
|
var __export = (target, all) => {
|
||||||
for (var name in all)
|
for (var name in all)
|
||||||
@@ -8223,7 +8227,7 @@ var Hono = class _Hono {
|
|||||||
var emptyParam = [];
|
var emptyParam = [];
|
||||||
function match(method, path) {
|
function match(method, path) {
|
||||||
const matchers = this.buildAllMatchers();
|
const matchers = this.buildAllMatchers();
|
||||||
const match2 = (method2, path2) => {
|
const match2 = ((method2, path2) => {
|
||||||
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
|
||||||
const staticMatch = matcher[2][path2];
|
const staticMatch = matcher[2][path2];
|
||||||
if (staticMatch) {
|
if (staticMatch) {
|
||||||
@@ -8235,7 +8239,7 @@ function match(method, path) {
|
|||||||
}
|
}
|
||||||
const index = match3.indexOf("", 1);
|
const index = match3.indexOf("", 1);
|
||||||
return [matcher[1][index], match3];
|
return [matcher[1][index], match3];
|
||||||
};
|
});
|
||||||
this.match = match2;
|
this.match = match2;
|
||||||
return match2(method, path);
|
return match2(method, path);
|
||||||
}
|
}
|
||||||
@@ -12954,7 +12958,7 @@ ZodNaN.create = (params) => {
|
|||||||
...processCreateParams(params)
|
...processCreateParams(params)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
var BRAND = Symbol("zod_brand");
|
var BRAND = /* @__PURE__ */ Symbol("zod_brand");
|
||||||
var ZodBranded = class extends ZodType {
|
var ZodBranded = class extends ZodType {
|
||||||
_parse(input) {
|
_parse(input) {
|
||||||
const { ctx } = this._processInputParams(input);
|
const { ctx } = this._processInputParams(input);
|
||||||
@@ -13156,14 +13160,14 @@ var ostring = () => stringType().optional();
|
|||||||
var onumber = () => numberType().optional();
|
var onumber = () => numberType().optional();
|
||||||
var oboolean = () => booleanType().optional();
|
var oboolean = () => booleanType().optional();
|
||||||
var coerce = {
|
var coerce = {
|
||||||
string: (arg) => ZodString.create({ ...arg, coerce: true }),
|
string: ((arg) => ZodString.create({ ...arg, coerce: true })),
|
||||||
number: (arg) => ZodNumber.create({ ...arg, coerce: true }),
|
number: ((arg) => ZodNumber.create({ ...arg, coerce: true })),
|
||||||
boolean: (arg) => ZodBoolean.create({
|
boolean: ((arg) => ZodBoolean.create({
|
||||||
...arg,
|
...arg,
|
||||||
coerce: true
|
coerce: true
|
||||||
}),
|
})),
|
||||||
bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }),
|
bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })),
|
||||||
date: (arg) => ZodDate.create({ ...arg, coerce: true })
|
date: ((arg) => ZodDate.create({ ...arg, coerce: true }))
|
||||||
};
|
};
|
||||||
var NEVER = INVALID;
|
var NEVER = INVALID;
|
||||||
|
|
||||||
@@ -13214,7 +13218,6 @@ function $constructor(name, initializer3, params) {
|
|||||||
Object.defineProperty(_, "name", { value: name });
|
Object.defineProperty(_, "name", { value: name });
|
||||||
return _;
|
return _;
|
||||||
}
|
}
|
||||||
var $brand = Symbol("zod_brand");
|
|
||||||
var $ZodAsyncError = class extends Error {
|
var $ZodAsyncError = class extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`);
|
super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`);
|
||||||
@@ -15718,8 +15721,6 @@ function en_default2() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mcp/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v4/core/registries.js
|
// mcp/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v4/core/registries.js
|
||||||
var $output = Symbol("ZodOutput");
|
|
||||||
var $input = Symbol("ZodInput");
|
|
||||||
var $ZodRegistry = class {
|
var $ZodRegistry = class {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._map = /* @__PURE__ */ new Map();
|
this._map = /* @__PURE__ */ new Map();
|
||||||
@@ -16997,10 +16998,10 @@ var ZodMiniType = /* @__PURE__ */ $constructor("ZodMiniType", (inst, def) => {
|
|||||||
};
|
};
|
||||||
inst.clone = (_def, params) => clone(inst, _def, params);
|
inst.clone = (_def, params) => clone(inst, _def, params);
|
||||||
inst.brand = () => inst;
|
inst.brand = () => inst;
|
||||||
inst.register = (reg, meta) => {
|
inst.register = ((reg, meta) => {
|
||||||
reg.add(inst, meta);
|
reg.add(inst, meta);
|
||||||
return inst;
|
return inst;
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
var ZodMiniObject = /* @__PURE__ */ $constructor("ZodMiniObject", (inst, def) => {
|
var ZodMiniObject = /* @__PURE__ */ $constructor("ZodMiniObject", (inst, def) => {
|
||||||
$ZodObject.init(inst, def);
|
$ZodObject.init(inst, def);
|
||||||
@@ -17263,10 +17264,10 @@ var ZodType2 = /* @__PURE__ */ $constructor("ZodType", (inst, def) => {
|
|||||||
};
|
};
|
||||||
inst.clone = (def2, params) => clone(inst, def2, params);
|
inst.clone = (def2, params) => clone(inst, def2, params);
|
||||||
inst.brand = () => inst;
|
inst.brand = () => inst;
|
||||||
inst.register = (reg, meta) => {
|
inst.register = ((reg, meta) => {
|
||||||
reg.add(inst, meta);
|
reg.add(inst, meta);
|
||||||
return inst;
|
return inst;
|
||||||
};
|
});
|
||||||
inst.parse = (data, params) => parse2(inst, data, params, { callee: inst.parse });
|
inst.parse = (data, params) => parse2(inst, data, params, { callee: inst.parse });
|
||||||
inst.safeParse = (data, params) => safeParse3(inst, data, params);
|
inst.safeParse = (data, params) => safeParse3(inst, data, params);
|
||||||
inst.parseAsync = async (data, params) => parseAsync2(inst, data, params, { callee: inst.parseAsync });
|
inst.parseAsync = async (data, params) => parseAsync2(inst, data, params, { callee: inst.parseAsync });
|
||||||
@@ -19238,11 +19239,13 @@ function assertCompleteRequestPrompt(request) {
|
|||||||
if (request.params.ref.type !== "ref/prompt") {
|
if (request.params.ref.type !== "ref/prompt") {
|
||||||
throw new TypeError(`Expected CompleteRequestPrompt, but got ${request.params.ref.type}`);
|
throw new TypeError(`Expected CompleteRequestPrompt, but got ${request.params.ref.type}`);
|
||||||
}
|
}
|
||||||
|
void request;
|
||||||
}
|
}
|
||||||
function assertCompleteRequestResourceTemplate(request) {
|
function assertCompleteRequestResourceTemplate(request) {
|
||||||
if (request.params.ref.type !== "ref/resource") {
|
if (request.params.ref.type !== "ref/resource") {
|
||||||
throw new TypeError(`Expected CompleteRequestResourceTemplate, but got ${request.params.ref.type}`);
|
throw new TypeError(`Expected CompleteRequestResourceTemplate, but got ${request.params.ref.type}`);
|
||||||
}
|
}
|
||||||
|
void request;
|
||||||
}
|
}
|
||||||
var CompleteResultSchema = ResultSchema.extend({
|
var CompleteResultSchema = ResultSchema.extend({
|
||||||
completion: looseObject({
|
completion: looseObject({
|
||||||
@@ -19395,7 +19398,7 @@ function isTerminal(status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mcp/node_modules/.pnpm/zod-to-json-schema@3.25.2_zod@3.25.76/node_modules/zod-to-json-schema/dist/esm/Options.js
|
// mcp/node_modules/.pnpm/zod-to-json-schema@3.25.2_zod@3.25.76/node_modules/zod-to-json-schema/dist/esm/Options.js
|
||||||
var ignoreOverride = Symbol("Let zodToJsonSchema decide on which parser to use");
|
var ignoreOverride = /* @__PURE__ */ Symbol("Let zodToJsonSchema decide on which parser to use");
|
||||||
var defaultOptions = {
|
var defaultOptions = {
|
||||||
name: void 0,
|
name: void 0,
|
||||||
$refStrategy: "root",
|
$refStrategy: "root",
|
||||||
@@ -22371,7 +22374,7 @@ var Server = class extends Protocol {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// mcp/node_modules/.pnpm/@modelcontextprotocol+sdk@1.29.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/completable.js
|
// mcp/node_modules/.pnpm/@modelcontextprotocol+sdk@1.29.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/completable.js
|
||||||
var COMPLETABLE_SYMBOL = Symbol.for("mcp.completable");
|
var COMPLETABLE_SYMBOL = /* @__PURE__ */ Symbol.for("mcp.completable");
|
||||||
function isCompletable(schema4) {
|
function isCompletable(schema4) {
|
||||||
return !!schema4 && typeof schema4 === "object" && COMPLETABLE_SYMBOL in schema4;
|
return !!schema4 && typeof schema4 === "object" && COMPLETABLE_SYMBOL in schema4;
|
||||||
}
|
}
|
||||||
@@ -24686,13 +24689,13 @@ function registerAllIntrospectionTools(server, env) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/nodes/identity.js
|
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/nodes/identity.js
|
||||||
var ALIAS = Symbol.for("yaml.alias");
|
var ALIAS = /* @__PURE__ */ Symbol.for("yaml.alias");
|
||||||
var DOC = Symbol.for("yaml.document");
|
var DOC = /* @__PURE__ */ Symbol.for("yaml.document");
|
||||||
var MAP = Symbol.for("yaml.map");
|
var MAP = /* @__PURE__ */ Symbol.for("yaml.map");
|
||||||
var PAIR = Symbol.for("yaml.pair");
|
var PAIR = /* @__PURE__ */ Symbol.for("yaml.pair");
|
||||||
var SCALAR = Symbol.for("yaml.scalar");
|
var SCALAR = /* @__PURE__ */ Symbol.for("yaml.scalar");
|
||||||
var SEQ = Symbol.for("yaml.seq");
|
var SEQ = /* @__PURE__ */ Symbol.for("yaml.seq");
|
||||||
var NODE_TYPE = Symbol.for("yaml.node.type");
|
var NODE_TYPE = /* @__PURE__ */ Symbol.for("yaml.node.type");
|
||||||
var isAlias = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === ALIAS;
|
var isAlias = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === ALIAS;
|
||||||
var isDocument = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === DOC;
|
var isDocument = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === DOC;
|
||||||
var isMap = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === MAP;
|
var isMap = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === MAP;
|
||||||
@@ -24722,9 +24725,9 @@ function isNode(node) {
|
|||||||
var hasAnchor = (node) => (isScalar(node) || isCollection(node)) && !!node.anchor;
|
var hasAnchor = (node) => (isScalar(node) || isCollection(node)) && !!node.anchor;
|
||||||
|
|
||||||
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/visit.js
|
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/visit.js
|
||||||
var BREAK = Symbol("break visit");
|
var BREAK = /* @__PURE__ */ Symbol("break visit");
|
||||||
var SKIP = Symbol("skip children");
|
var SKIP = /* @__PURE__ */ Symbol("skip children");
|
||||||
var REMOVE = Symbol("remove node");
|
var REMOVE = /* @__PURE__ */ Symbol("remove node");
|
||||||
function visit(node, visitor) {
|
function visit(node, visitor) {
|
||||||
const visitor_ = initVisitor(visitor);
|
const visitor_ = initVisitor(visitor);
|
||||||
if (isDocument(node)) {
|
if (isDocument(node)) {
|
||||||
@@ -29317,9 +29320,9 @@ ${end.comment}` : end.comment;
|
|||||||
};
|
};
|
||||||
|
|
||||||
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/parse/cst-visit.js
|
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/parse/cst-visit.js
|
||||||
var BREAK2 = Symbol("break visit");
|
var BREAK2 = /* @__PURE__ */ Symbol("break visit");
|
||||||
var SKIP2 = Symbol("skip children");
|
var SKIP2 = /* @__PURE__ */ Symbol("skip children");
|
||||||
var REMOVE2 = Symbol("remove item");
|
var REMOVE2 = /* @__PURE__ */ Symbol("remove item");
|
||||||
function visit2(cst, visitor) {
|
function visit2(cst, visitor) {
|
||||||
if ("type" in cst && cst.type === "document")
|
if ("type" in cst && cst.type === "document")
|
||||||
cst = { start: cst.start, value: cst.value };
|
cst = { start: cst.start, value: cst.value };
|
||||||
@@ -33389,7 +33392,7 @@ app.post("/", partnerAuthMiddleware, async (c) => {
|
|||||||
const partnerToken = c.get("partner_token");
|
const partnerToken = c.get("partner_token");
|
||||||
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken);
|
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken);
|
||||||
});
|
});
|
||||||
var src_default = _app;
|
var index_default = _app;
|
||||||
export {
|
export {
|
||||||
src_default as default
|
index_default as default
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"built_for": "arcrun-tier2-worker-artifacts",
|
"built_for": "arcrun-tier2-worker-artifacts",
|
||||||
"generated_at": "2026-08-11T05:33:37.988Z",
|
"generated_at": "2026-08-12T04:21:48.581Z",
|
||||||
"repo_head": "d8bbf2241bd6b117d76fb27d9e386ecfb0ffe8f7",
|
"repo_head": "b302c03ea8076bcfe82bbcebb1523dcec2d1e830",
|
||||||
"repo_dirty": false,
|
"repo_dirty": false,
|
||||||
"workers": [
|
"workers": [
|
||||||
{
|
{
|
||||||
"name": "arcrun-cypher-executor",
|
"name": "arcrun-cypher-executor",
|
||||||
"source_dir": "cypher-executor",
|
"source_dir": "cypher-executor",
|
||||||
"source_commit": "797e7f751cc42cb1f5d9e2e187f18cf51eb981a1",
|
"source_commit": "525faaf5d01e156a9b8f90808607bead92f40165",
|
||||||
"main_module": "worker.mjs",
|
"main_module": "worker.mjs",
|
||||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||||
"js_bytes": 568855,
|
"js_bytes": 570290,
|
||||||
"content_sha256": "66e2a6341854e8b2de0567a46282b94669e73b95d152b05b17b0f8b58e257fec",
|
"content_sha256": "49d59597c01b5264875e0295c86c7bb2212bf54d3c5858cd4a167752370fa716",
|
||||||
"modules": [],
|
"modules": [],
|
||||||
"compat_date": "2025-02-19",
|
"compat_date": "2025-02-19",
|
||||||
"compat_flags": [
|
"compat_flags": [
|
||||||
@@ -58,11 +58,11 @@
|
|||||||
{
|
{
|
||||||
"name": "arcrun-kbdb",
|
"name": "arcrun-kbdb",
|
||||||
"source_dir": "kbdb",
|
"source_dir": "kbdb",
|
||||||
"source_commit": "a7e23badf2a771be779a861e69e7efa6e8141dfe",
|
"source_commit": "c497ec418eba6cd94b1d5872671c51fd5812c11c",
|
||||||
"main_module": "worker.mjs",
|
"main_module": "worker.mjs",
|
||||||
"main_file": "arcrun-kbdb/worker.mjs",
|
"main_file": "arcrun-kbdb/worker.mjs",
|
||||||
"js_bytes": 135910,
|
"js_bytes": 149533,
|
||||||
"content_sha256": "5e5a7a030f4fd1f5549ace6791c3827b6497b0bfdd9add46af041af47c472905",
|
"content_sha256": "ffb8d43467d0cefbd7545fdc0d347f2b965e3c3de20b3315eed7613f20266891",
|
||||||
"modules": [],
|
"modules": [],
|
||||||
"compat_date": "2025-02-19",
|
"compat_date": "2025-02-19",
|
||||||
"compat_flags": [
|
"compat_flags": [
|
||||||
@@ -90,8 +90,8 @@
|
|||||||
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
|
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
|
||||||
"main_module": "worker.mjs",
|
"main_module": "worker.mjs",
|
||||||
"main_file": "arcrun-http-request/worker.mjs",
|
"main_file": "arcrun-http-request/worker.mjs",
|
||||||
"js_bytes": 80073,
|
"js_bytes": 80079,
|
||||||
"content_sha256": "9a9dcb71879a7bdfd9fec1bd94eb9742e12cb63733d822ce63eeb1be30008d15",
|
"content_sha256": "cdd97364f277587cbade69e09bb40812c68f26a1e8bc9aa632c65b1b962b0b85",
|
||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
"name": "component.wasm",
|
"name": "component.wasm",
|
||||||
@@ -122,8 +122,8 @@
|
|||||||
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
|
||||||
"main_module": "worker.mjs",
|
"main_module": "worker.mjs",
|
||||||
"main_file": "arcrun-code/worker.mjs",
|
"main_file": "arcrun-code/worker.mjs",
|
||||||
"js_bytes": 153671,
|
"js_bytes": 153758,
|
||||||
"content_sha256": "285a7406ec694ae47dccfaf48517f712c74d207a1689dffa15c39f1555b45be5",
|
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
|
||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
"name": "quickjs.wasm",
|
"name": "quickjs.wasm",
|
||||||
@@ -151,8 +151,8 @@
|
|||||||
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
|
"source_commit": "035e8b255b0dcbd4238707f7d2ac8ccf9ee1ba72",
|
||||||
"main_module": "worker.mjs",
|
"main_module": "worker.mjs",
|
||||||
"main_file": "arcrun-mcp/worker.mjs",
|
"main_file": "arcrun-mcp/worker.mjs",
|
||||||
"js_bytes": 1165130,
|
"js_bytes": 1165388,
|
||||||
"content_sha256": "be15033f32e605f03f69bd10cd87782dafa34dbafeee2ce367bd7361a062a291",
|
"content_sha256": "c5ff10f9b9d5a77217be343af12d2be3ee8f9792d3e1e091e48e5e6c8d24ca9d",
|
||||||
"modules": [],
|
"modules": [],
|
||||||
"compat_date": "2024-11-27",
|
"compat_date": "2024-11-27",
|
||||||
"compat_flags": [
|
"compat_flags": [
|
||||||
|
|||||||
@@ -277,9 +277,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
} else {
|
} else {
|
||||||
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
|
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
|
||||||
}
|
}
|
||||||
rows.push(sys.graph && sys.graph.ok
|
// Arcrun#100:「服務活著嗎」與「庫裡有幾條」拆兩列。混一列時,圖服務打不通會把
|
||||||
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
|
// 「其實有 1854 條」整個吞掉,畫面看起來就像知識庫是空的。數字讀不到寫「讀不到」,不寫 0。
|
||||||
: sysRow('知識圖譜', '● 打不通', 'bad'));
|
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' : ''));
|
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
|
||||||
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
|
// 精耕層 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')
|
fetch(API_BASE + '/console/kb-scale-data')
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
if (!d) return;
|
// #100:讀不到就明說讀不到(原本靜默 return,會把上一輪的舊數字留在畫面上)
|
||||||
var n = function (v) { return v == null ? '?' : v.toLocaleString(); };
|
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 parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)];
|
||||||
var latest = d.wiki_card_latest_ago_minutes;
|
var latest = d.wiki_card_latest_ago_minutes;
|
||||||
$('se-scale').textContent = '精耕層 ' + parts.join('・') +
|
$('se-scale').textContent = '精耕層 ' + parts.join('・') +
|
||||||
(latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');
|
(latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');
|
||||||
})
|
})
|
||||||
.catch(function () { /* 規模感拿不到不擋搜尋 */ });
|
.catch(function () { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; });
|
||||||
}
|
}
|
||||||
$('se-sem').addEventListener('click', function () {
|
$('se-sem').addEventListener('click', function () {
|
||||||
S.semantic = !S.semantic;
|
S.semantic = !S.semantic;
|
||||||
@@ -1504,7 +1505,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
|||||||
]).then(function (rs) {
|
]).then(function (rs) {
|
||||||
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
|
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
|
||||||
var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;
|
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 = '';
|
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="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>';
|
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;
|
$('se-q').value = name;
|
||||||
doGraphSearch(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() {
|
function loadMap() {
|
||||||
|
$('map-meta').textContent = '';
|
||||||
$('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">載入總圖中…</div>';
|
$('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">載入總圖中…</div>';
|
||||||
$('map-md-link').innerHTML = SOURCE_WEB_BASE
|
$('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>'
|
? ':<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 (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||||
.then(function (x) {
|
.then(function (x) {
|
||||||
if (guard401(x.status)) return;
|
if (guard401(x.status)) return;
|
||||||
if (!x.ok) { $('map-box').innerHTML = '<div class="err">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')')) + '</div>'; return; }
|
if (!x.ok) { mapUnavailable(esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')'))); return; }
|
||||||
var nodes = x.d.nodes || [];
|
// Arcrun#100:「0」只准在後端確認過真的是 0 的時候出現。
|
||||||
var edges = x.d.edges || [];
|
// 形狀不對 → 讀不到(不是空庫);nodes 為空但 empty_confirmed 不成立 → 讀不到。
|
||||||
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : '');
|
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; }
|
if (!nodes.length) { $('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。</div>'; return; }
|
||||||
renderMap(nodes, edges);
|
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) {
|
function renderMap(nodes, edges) {
|
||||||
var N = nodes.length;
|
var N = nodes.length;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ParsedTriplets, NodeRole } from './triplet-parser';
|
import type { ParsedTriplets, NodeRole } from './triplet-parser';
|
||||||
import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
|
import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
|
||||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
import { wasmWorkerUrl, RUNTIME_NATIVE_COMPONENT_IDS } from '../lib/component-loader';
|
||||||
import { resolveRecipe } from '../routes/recipes';
|
import { resolveRecipe } from '../routes/recipes';
|
||||||
import type { RecipeDefinition } from '../routes/recipes';
|
import type { RecipeDefinition } from '../routes/recipes';
|
||||||
import { branchHintFor } from '../lib/branch-hints';
|
import { branchHintFor } from '../lib/branch-hints';
|
||||||
@@ -44,8 +44,12 @@ export type NodeInfo = {
|
|||||||
status: NodeStatus;
|
status: NodeStatus;
|
||||||
componentId?: string;
|
componentId?: string;
|
||||||
type: NodeRole;
|
type: NodeRole;
|
||||||
/** found 時標來源庫:零件 registry(component)或 recipe 庫(recipe)。 */
|
/**
|
||||||
source?: 'component' | 'recipe';
|
* found 時標來源庫:零件 registry(component)、recipe 庫(recipe),
|
||||||
|
* 或 cypher-executor 自帶、無須查 registry 即保證解析得動的執行期原生零件(builtin,
|
||||||
|
* Arcrun#88——component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS)。
|
||||||
|
*/
|
||||||
|
source?: 'component' | 'recipe' | 'builtin';
|
||||||
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
|
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
|
||||||
input_schema?: unknown;
|
input_schema?: unknown;
|
||||||
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
|
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
|
||||||
@@ -212,6 +216,25 @@ export async function searchNodes(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 執行期原生零件(Arcrun#88):查 registry 之前先比對 ──────────────────
|
||||||
|
// component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS=trigger_workflow/
|
||||||
|
// BUILTIN_COMPONENTS/LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS 的聯集——
|
||||||
|
// 這些零件 cypher-executor 自己就能 resolve,從不查 registry,執行期保證解析得動。
|
||||||
|
// 病史:registry 是空的/未部署新版 `/catalog` 端點時,這批零件(if_control/
|
||||||
|
// http_request/switch…)會被下面「兩庫都查過沒有」誤判成 not_found——
|
||||||
|
// 而 leo 08-11 實測探測工作流證明它們跑得動。命中即 found,不受 registry 健康狀態影響。
|
||||||
|
// target=recipe(使用者明確只要查 recipe 庫)不適用——這些從來不是 recipe。
|
||||||
|
if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
|
||||||
|
nodeResults[nodeName] = {
|
||||||
|
status: 'found',
|
||||||
|
componentId,
|
||||||
|
type: role,
|
||||||
|
source: 'builtin',
|
||||||
|
branch_hint: branchHintFor(componentId),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。
|
// registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。
|
||||||
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
|
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
|
||||||
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
|
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
|
||||||
|
|||||||
@@ -88,6 +88,33 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
|
|||||||
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
|
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「查得到 vs 真的有」的單一真相源(Arcrun#88,2026-08-11)。
|
||||||
|
*
|
||||||
|
* 病因:`/cypher/search`(`search-nodes.ts`)只查 component registry(`SUBMISSIONS_KV`,
|
||||||
|
* 經 `submitComponent`/`index-only` 才會有記錄);而本檔 0/1/5/7 四步驟能直接解析、
|
||||||
|
* **完全不查 registry** 的一整類零件(trigger_workflow、BUILTIN_COMPONENTS、
|
||||||
|
* LOGIC_BINDING_MAP、WASM_HTTP_RUNNER_IDS)從未被 submit 過(也不需要——它們是
|
||||||
|
* cypher-executor 自帶的,不是投稿存量)。實測 leo21c 實例:`/components/catalog`
|
||||||
|
* 404(registry 是舊版沒這端點/索引空),search 因此對 `if_control`/`http_request`
|
||||||
|
* 誠實地回「兩庫都查過沒有」——但這兩顆其實跑得動(leo 08-11 探測工作流已證)。
|
||||||
|
*
|
||||||
|
* 修法:把「執行期真的解析得動」的這份清單匯出給 search-nodes.ts,在查 registry
|
||||||
|
* **之前**先比對——讓「查得到」不受 registry 是否可達/是否已 backfill 影響。
|
||||||
|
*
|
||||||
|
* 刻意不做的事:不去掃 `registry/components/*` 目錄當清單來源——那是零件原始碼
|
||||||
|
* 存放處,含已標記待刪的死碼(`km_writer`/`kbdb_upsert_block`,見
|
||||||
|
* `system-dev/docs/3-specs/arcrun-usable/cleanup-dead-code.md`);07-30 曾把這類死碼
|
||||||
|
* 誤灌進 registry(leo 點名的錯)。這裡改用**執行期真正拿去 resolve 的白名單本身**
|
||||||
|
* (本檔 1/5/7 步驟既有的三份清單)——精確等於「解析得動」,不會多一顆、不會少一顆。
|
||||||
|
*/
|
||||||
|
export const RUNTIME_NATIVE_COMPONENT_IDS: ReadonlySet<string> = new Set([
|
||||||
|
'trigger_workflow',
|
||||||
|
...BUILTIN_COMPONENTS.keys(),
|
||||||
|
...Object.keys(LOGIC_BINDING_MAP),
|
||||||
|
...WASM_HTTP_RUNNER_IDS,
|
||||||
|
]);
|
||||||
|
|
||||||
export function createComponentLoader(env: Bindings) {
|
export function createComponentLoader(env: Bindings) {
|
||||||
return async (componentId: string): Promise<ComponentRunner> => {
|
return async (componentId: string): Promise<ComponentRunner> => {
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
*/
|
*/
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
import { kbdbBase, graphBase } from './kbdb-proxy';
|
import { kbdbBase, graphBase, graphHeaders } from './kbdb-proxy';
|
||||||
import { validateConsoleSession } from './console-auth';
|
import { validateConsoleSession } from './console-auth';
|
||||||
import {
|
import {
|
||||||
type KbdbEntry,
|
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 = 讀不到。 */
|
/** KBDB entries 符合條件的總數(limit=1 只拿 total 欄,不搬資料)。null = 讀不到。 */
|
||||||
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
|
async function fetchEntryTotal(env: Bindings, filters: Record<string, string>): Promise<number | null> {
|
||||||
const { base, headers } = kbdbBase(env);
|
const { base, headers } = kbdbBase(env);
|
||||||
@@ -254,6 +279,7 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
|||||||
kbdbHealth,
|
kbdbHealth,
|
||||||
embedStatus,
|
embedStatus,
|
||||||
graphStats,
|
graphStats,
|
||||||
|
tripletTotal,
|
||||||
entriesTotal,
|
entriesTotal,
|
||||||
wikiCardTotal,
|
wikiCardTotal,
|
||||||
workflowTotal,
|
workflowTotal,
|
||||||
@@ -265,7 +291,13 @@ consoleDashboardRouter.get('/console/dashboard-data', async (c) => {
|
|||||||
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
|
cachedGiteaSprint(c.env, now, (p) => c.executionCtx.waitUntil(p)),
|
||||||
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
|
fetchJson<{ ok?: boolean }>(`${kbdbUrl}/health`, kbdbHeaders),
|
||||||
fetchJson<{ enabled?: boolean; pending?: number; embedded?: number }>(`${kbdbUrl}/embed/backfill/status`, 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)
|
// owner_id 一律鎖本租戶:原本不帶 owner 會混到別租戶(實測 459,137 vs leo 的 458,732)
|
||||||
fetchEntryTotal(c.env, { owner_id: tenant }),
|
fetchEntryTotal(c.env, { owner_id: tenant }),
|
||||||
fetchEntryTotal(c.env, { entry_type: 'wiki_card', 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
|
embed: embedStatus
|
||||||
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
|
? { enabled: embedStatus.enabled === true, embedded: embedStatus.embedded ?? null, pending: embedStatus.pending ?? null }
|
||||||
: 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,
|
workflow_total: workflowTotal,
|
||||||
},
|
},
|
||||||
kb: {
|
kb: {
|
||||||
entries_total: entriesTotal,
|
entries_total: entriesTotal,
|
||||||
wiki_card_total: wikiCardTotal,
|
wiki_card_total: wikiCardTotal,
|
||||||
triplets_total: graphStats?.total ?? null,
|
triplets_total: tripletTotal,
|
||||||
},
|
},
|
||||||
generated_at: new Date(now).toISOString(),
|
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) => {
|
consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
||||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||||
const { base, headers } = kbdbBase(c.env);
|
const { base, headers } = kbdbBase(c.env);
|
||||||
const graphUrl = graphBase(c.env);
|
|
||||||
const now = Date.now();
|
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)=「最近寫入時間」
|
// limit=1 順手拿最新一筆 created_at(list 為 created_at DESC)=「最近寫入時間」
|
||||||
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
|
fetchJson<{ total?: number; entries?: { created_at?: string | number }[] }>(
|
||||||
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
|
`${base}/entries?${new URLSearchParams({ owner_id: tenant, entry_type: 'wiki_card', limit: '1' }).toString()}`,
|
||||||
headers,
|
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),
|
fetchJson<{ enabled?: boolean; embedded?: number; pending?: number }>(`${base}/embed/backfill/status`, headers),
|
||||||
]);
|
]);
|
||||||
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
|
const latestMs = parseCreatedAtMs(wikiCards?.entries?.[0]?.created_at ?? null);
|
||||||
@@ -436,7 +469,7 @@ consoleDashboardRouter.get('/console/kb-scale-data', async (c) => {
|
|||||||
return c.json({
|
return c.json({
|
||||||
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
|
wiki_card_total: typeof wikiCards?.total === 'number' ? wikiCards.total : null,
|
||||||
wiki_card_latest_ago_minutes: latestMs === null ? -1 : agoMinutes(now, latestMs),
|
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,
|
embedded: embedStatus?.embedded ?? null,
|
||||||
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
|
embed_enabled: embedStatus ? embedStatus.enabled === true : null,
|
||||||
generated_at: new Date(now).toISOString(),
|
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`;
|
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/卡片名)的鄰居 + 邊。
|
// GET /kbdb/graph/neighbors/:name — 查某節點(entity/卡片名)的鄰居 + 邊。
|
||||||
// 查無 triplet 資料時 plugin 回空陣列——前端據此顯示「尚無關聯資料」(誠實,不編造關聯)。
|
// 查無 triplet 資料時 plugin 回空陣列——前端據此顯示「尚無關聯資料」(誠實,不編造關聯)。
|
||||||
kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => {
|
kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => {
|
||||||
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
if (!tenant(c)) return c.json(NEED_KEY, 401);
|
||||||
const base = graphBase(c.env);
|
const base = graphBase(c.env);
|
||||||
const headers: Record<string, string> = {};
|
const headers = graphHeaders(c.env);
|
||||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
|
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' } });
|
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 { Context } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
import type { Bindings } from '../types';
|
||||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
|
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';
|
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||||
|
|
||||||
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
|
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);
|
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 用)。 */
|
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
|
||||||
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
|
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
@@ -343,8 +382,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
|||||||
|
|
||||||
// ② plugin fallback(Mira/leo21c 相容)
|
// ② plugin fallback(Mira/leo21c 相容)
|
||||||
const base = graphBase(c.env);
|
const base = graphBase(c.env);
|
||||||
const headers: Record<string, string> = {};
|
const headers = graphHeaders(c.env);
|
||||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
|
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -383,14 +421,23 @@ portalDataRouter.get('/portal/data/graph/overview', (c) =>
|
|||||||
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
||||||
}
|
}
|
||||||
const tenant = portalTenant(c.env);
|
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) {
|
if (!res.ok) {
|
||||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
}
|
}
|
||||||
const body = (await res.json().catch(() => null)) as
|
const body = (await res.json().catch(() => null)) as
|
||||||
| { records?: { values?: Record<string, unknown> }[] }
|
| { records?: { values?: Record<string, unknown> }[] }
|
||||||
| null;
|
| 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 EDGE_CAP = 500;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const edges: { subject: string; predicate: string; object: 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);
|
degree.set(o, (degree.get(o) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
|
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('截圖');
|
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,116 @@
|
|||||||
|
/**
|
||||||
|
* Arcrun#88:「零件目錄會說『這顆零件不存在』,但同一台實例上那顆零件跑得動」
|
||||||
|
*
|
||||||
|
* 病史(leo21c 實例實測,2026-08-11):
|
||||||
|
* `/cypher/search` 對 `if_control`/`http_request` 回 `not_found`,
|
||||||
|
* 但兩者其實由 component-loader.ts 直接解析(LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS),
|
||||||
|
* 從不查 registry;registry catalog 端點在該實例回 404(`GET /components/catalog` →
|
||||||
|
* `{"success":false,"error":"零件 catalog 不存在"}`),search 因此誤判成「兩庫都查過沒有」。
|
||||||
|
*
|
||||||
|
* 本測試複現病史的環境條件(wrangler.test.toml 未設 WORKER_SUBDOMAIN → registryBase
|
||||||
|
* undefined → catalog.status='unreachable',等價於「registry 完全連不到」),
|
||||||
|
* 驗證修法:RUNTIME_NATIVE_COMPONENT_IDS 的成員必須在 registry 查詢**之前**就短路成 found,
|
||||||
|
* 不受 registry 健康狀態影響——因為它們的存在性從不依賴 registry。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { parseTriplets } from '../src/actions/triplet-parser';
|
||||||
|
import { searchNodes } from '../src/actions/search-nodes';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const IF_CONTROL_TRIPLETS = [
|
||||||
|
'input >> ON_SUCCESS >> if_control',
|
||||||
|
];
|
||||||
|
|
||||||
|
const HTTP_REQUEST_TRIPLETS = [
|
||||||
|
'input >> ON_SUCCESS >> http_request',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MULTI_BUILTIN_TRIPLETS = [
|
||||||
|
'input >> ON_SUCCESS >> switch',
|
||||||
|
'input >> ON_SUCCESS >> filter',
|
||||||
|
'input >> ON_SUCCESS >> code',
|
||||||
|
];
|
||||||
|
|
||||||
|
const FAKE_COMPONENT_TRIPLETS = [
|
||||||
|
'input >> ON_SUCCESS >> totally_made_up_component_xyz',
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Arcrun#88:執行期原生零件不受 registry 健康狀態影響', () => {
|
||||||
|
it('if_control(LOGIC_BINDING_MAP 成員)在 registry 不可達時仍回 found', async () => {
|
||||||
|
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
|
||||||
|
// 無 WORKER_SUBDOMAIN/REGISTRY_BASE_URL → registryBase undefined → catalog unreachable
|
||||||
|
});
|
||||||
|
expect(nodeResults.if_control.status).toBe('found');
|
||||||
|
expect(nodeResults.if_control.source).toBe('builtin');
|
||||||
|
// if_control 會分岔,branch_hint 應隨 found 一併附上(不必逐顆再查一次)
|
||||||
|
expect(nodeResults.if_control.branch_hint?.edge_types).toEqual(['ON_TRUE', 'ON_FALSE']);
|
||||||
|
expect(missingNodes).not.toContain('if_control');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('http_request(WASM_HTTP_RUNNER_IDS 成員)在 registry 不可達時仍回 found', async () => {
|
||||||
|
const parsed = parseTriplets(HTTP_REQUEST_TRIPLETS);
|
||||||
|
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {});
|
||||||
|
expect(nodeResults.http_request.status).toBe('found');
|
||||||
|
expect(nodeResults.http_request.source).toBe('builtin');
|
||||||
|
expect(missingNodes).not.toContain('http_request');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switch/filter/code(同一批白名單的其他成員)也回 found,不逐一漏網', async () => {
|
||||||
|
const parsed = parseTriplets(MULTI_BUILTIN_TRIPLETS);
|
||||||
|
const { nodeResults } = await searchNodes(parsed!, undefined, {});
|
||||||
|
expect(nodeResults.switch.status).toBe('found');
|
||||||
|
expect(nodeResults.filter.status).toBe('found');
|
||||||
|
expect(nodeResults.code.status).toBe('found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registry 完全連不到時,真正不存在的名字誠實回 unknown(不敢空口說沒有——既有行為,修法沒有動它)', async () => {
|
||||||
|
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
|
||||||
|
const { nodeResults } = await searchNodes(parsed!, undefined, {});
|
||||||
|
expect(nodeResults.totally_made_up_component_xyz.status).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registry 查得到但目錄是空的(複現 leo21c 實例 catalog 404 的真實症狀):真正不存在的名字回 not_found', async () => {
|
||||||
|
// 複現生產實測:GET /components/catalog → HTTP 200 空陣列(本測試模擬「registry 活著但沒東西」,
|
||||||
|
// 與 leo21c 實例的「404 零件 catalog 不存在」殊途同歸——都會落到「查得到、目錄無此零件」)。
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
const parsed = parseTriplets(FAKE_COMPONENT_TRIPLETS);
|
||||||
|
const { nodeResults, missingNodes } = await searchNodes(parsed!, undefined, {
|
||||||
|
WORKER_SUBDOMAIN: 'test-sub',
|
||||||
|
});
|
||||||
|
expect(nodeResults.totally_made_up_component_xyz.status).toBe('not_found');
|
||||||
|
expect(missingNodes).toContain('totally_made_up_component_xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registry 目錄是空的(catalog 通但無資料)時,執行期原生零件依然 found——這才是 Arcrun#88 的核心場景', async () => {
|
||||||
|
// 這就是 leo21c 實例的真實狀態:registry 活著、目錄卻沒有任何一顆執行期原生零件的記錄
|
||||||
|
// (SUBMISSIONS_KV 從未收到 if_control/http_request 的 submit)。若沒有本次修法,
|
||||||
|
// 這裡會落到「兩庫都查過沒有」→ not_found,正是 Arcrun#88 回報的病徵。
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ success: true, data: { components: [], count: 0 } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
|
||||||
|
const { nodeResults } = await searchNodes(parsed!, undefined, { WORKER_SUBDOMAIN: 'test-sub' });
|
||||||
|
expect(nodeResults.if_control.status).toBe('found');
|
||||||
|
expect(nodeResults.if_control.source).toBe('builtin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('target=recipe 明確只查 recipe 庫時,執行期原生零件不搶答 found(尊重使用者明確限庫)', async () => {
|
||||||
|
const parsed = parseTriplets(IF_CONTROL_TRIPLETS);
|
||||||
|
const { nodeResults } = await searchNodes(parsed!, undefined, {}, 'discover', 'recipe');
|
||||||
|
// if_control 從來不是 recipe,target=recipe 下不該被 builtin 短路成 found
|
||||||
|
expect(nodeResults.if_control.status).not.toBe('found');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,6 +49,10 @@ KBDB_BASE_URL = "https://kbdb.test"
|
|||||||
CONSOLE_TENANT = "leo"
|
CONSOLE_TENANT = "leo"
|
||||||
# portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
|
# portal-auth P3:graph 粗閘放行後的轉發目標也指假 host(fetchMock 攔截,絕不外連)
|
||||||
KBDB_GRAPH_URL = "https://graph.test"
|
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
|
# D61(ADR D61 / Leo/arcrun-rag#55):認證儲存(lib/portal-auth-store.ts)走 CF Workers
|
||||||
# Scripts secrets 管理 API(https://api.cloudflare.com/...),authStoreWritable() 只看這兩項
|
# Scripts secrets 管理 API(https://api.cloudflare.com/...),authStoreWritable() 只看這兩項
|
||||||
# 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的
|
# 存不存在。測試環境預設就緒(比照真實已裝妥的實例),值是明顯的假字串、非真實金鑰;實際的
|
||||||
|
|||||||
@@ -216,12 +216,63 @@ const MAX_LIKE_TERMS = 6;
|
|||||||
|
|
||||||
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
|
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
|
||||||
|
|
||||||
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */
|
// ── 使用者打的 `%` 與 `_` 是「要找的字」,不是萬用字元(Arcrun#94)────────────────
|
||||||
|
//
|
||||||
|
// 病徵:搜尋框打 `100%` 或 `owner_id`,回來一堆跟那些字無關的東西。
|
||||||
|
// SQLite LIKE 只有兩個萬用字元——`%`(任意長度)與 `_`(任意一個字元),而且
|
||||||
|
// **沒有預設跳脫字元**(不寫 ESCAPE 就沒有任何辦法表示「字面上的 %」)。
|
||||||
|
// 我們把使用者輸入直接內插成 `'%' + q + '%'` ⇒ 他打的符號被當成 pattern 語法:
|
||||||
|
// `100%` → `%100%%` → 「100」開頭後面接什麼都算 ⇒ 撈回一堆不相干的
|
||||||
|
// `owner_id` → `%owner_id%` → `_` 匹配任一字元 ⇒ `ownerXid`、`owner-id` 也中
|
||||||
|
// `%`/`_` 單打 → `%%%`/`%_%` → **整個庫都回來**(`_` 只要有一個字元就中)
|
||||||
|
//
|
||||||
|
// 這是舊病,不是 08-10 斷詞(47c6aae→本檔上一段)引進的:pattern 一直都是這樣拼的。
|
||||||
|
// 之前關鍵字搜尋幾乎恆為 0 命中(整串比對),這個洞被那個洞蓋住,看不出來;
|
||||||
|
// 斷詞讓搜尋真的會回東西之後它才浮出來。**斷詞那段一個字都沒動。**
|
||||||
|
//
|
||||||
|
// 修法:三個字元都跳脫,並在每個 LIKE 後面掛 `ESCAPE '\'`。
|
||||||
|
//
|
||||||
|
// 為什麼**跳脫字元本身(`\`)也要跳脫**(邊界問題的答案,不是順手多做):
|
||||||
|
// 一旦宣告了 ESCAPE,`\` 在 pattern 裡就變成有意義的字元,於是「使用者打的 `\`」
|
||||||
|
// 同樣會被誤讀——而且是更糟的一種,因為它會**把後面那個字吃掉**:
|
||||||
|
// 使用者打 `100\%` → 不跳脫 `\` ⇒ pattern `%100\%%` ⇒ `\%`=字面 %
|
||||||
|
// ⇒ 實際找的是 `100%`,**跟他打的字不一樣**
|
||||||
|
// 使用者打 `C:\` → pattern `%C:\%` ⇒ 尾巴 `\%`=字面 %
|
||||||
|
// ⇒ 找的是 `C:%`,而真正的 `C:\` 反而找不到
|
||||||
|
// ⇒ 三個字元是一組的:宣告 ESCAPE 卻不跳脫 `\` 等於用新的漏洞換掉舊的。
|
||||||
|
// (SQLite 對「`\` 後面接其他字元」是寬容的——照字面匹配下一個字、不報錯——
|
||||||
|
// 所以不跳脫不會炸,只會靜靜地找錯東西,正是最難發現的那種。)
|
||||||
|
//
|
||||||
|
// 為什麼**只有這三個**:SQLite 的 LIKE 萬用字元就只有 `%` 和 `_`(`[...]`、`?`、`*`
|
||||||
|
// 是別的方言/GLOB 的東西,LIKE 不吃),加上自己宣告的跳脫字元 `\`,就這三個。
|
||||||
|
// 不多跳脫其他字元——跳脫沒有語法意義的字元只會白白吃掉 pattern 的 byte 預算。
|
||||||
|
//
|
||||||
|
// 🔴 與 50 bytes 上限的交互作用(不能只加跳脫就收工):跳脫會**變長**(`%`→`\%`),
|
||||||
|
// 所以所有 byte 預算改用「跳脫後」的長度算(likeBytes),否則使用者打一串 `%`
|
||||||
|
// 會讓 pattern 膨脹回 50 bytes 以上 ⇒ 退回 2026-08-03 那個 500。
|
||||||
|
// 不含這三個字元的查詢,likeBytes ≡ utf8Len ⇒ **既有查詢的行為逐字不變**。
|
||||||
|
const LIKE_ESCAPE = '\\';
|
||||||
|
/** 每個 `content LIKE ?` 都要帶著它的 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面。 */
|
||||||
|
const CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`;
|
||||||
|
|
||||||
|
/** 把使用者輸入當「字面字串」送進 LIKE(純函式,單測用 export)。 */
|
||||||
|
export function escapeLikeLiteral(s: string): string {
|
||||||
|
// 一次掃描、每個字元各自替換 ⇒ 不會發生「先換 % 再換 \ 把剛加的跳脫又跳脫一次」。
|
||||||
|
return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 這段文字**跳脫後**佔的 byte 數(=它在 LIKE pattern 裡真正佔的長度)。 */
|
||||||
|
const likeBytes = (s: string): number => utf8Len(escapeLikeLiteral(s));
|
||||||
|
|
||||||
|
/** 子字串比對用的 pattern:只有頭尾那兩個 `%` 是萬用字元,中間全是字面。 */
|
||||||
|
const likePattern = (s: string): string => `%${escapeLikeLiteral(s)}%`;
|
||||||
|
|
||||||
|
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。上限算的是**跳脫後**的長度。 */
|
||||||
function chunkByBytes(s: string, maxBytes: number): string[] {
|
function chunkByBytes(s: string, maxBytes: number): string[] {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
let cur = '';
|
let cur = '';
|
||||||
for (const ch of s) {
|
for (const ch of s) {
|
||||||
if (utf8Len(cur + ch) > maxBytes) {
|
if (likeBytes(cur + ch) > maxBytes) {
|
||||||
if (cur) out.push(cur);
|
if (cur) out.push(cur);
|
||||||
cur = ch;
|
cur = ch;
|
||||||
} else {
|
} else {
|
||||||
@@ -237,8 +288,8 @@ function chunkByBytes(s: string, maxBytes: number): string[] {
|
|||||||
* 回 `split=false` 代表走的是與舊版逐字相同的單一 LIKE。
|
* 回 `split=false` 代表走的是與舊版逐字相同的單一 LIKE。
|
||||||
*/
|
*/
|
||||||
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
|
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
|
||||||
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
|
if (likeBytes(q) <= MAX_LIKE_Q_BYTES) {
|
||||||
return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false };
|
return { conds: [CONTENT_LIKE], params: [likePattern(q)], split: false };
|
||||||
}
|
}
|
||||||
const terms: string[] = [];
|
const terms: string[] = [];
|
||||||
for (const word of q.split(/\s+/).filter(Boolean)) {
|
for (const word of q.split(/\s+/).filter(Boolean)) {
|
||||||
@@ -251,8 +302,8 @@ export function buildContentLike(q: string): { conds: string[]; params: string[]
|
|||||||
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
|
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
|
||||||
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
|
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
|
||||||
return {
|
return {
|
||||||
conds: terms.map(() => 'content LIKE ?'),
|
conds: terms.map(() => CONTENT_LIKE),
|
||||||
params: terms.map((t) => `%${t}%`),
|
params: terms.map(likePattern),
|
||||||
split: true,
|
split: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -428,7 +479,7 @@ export function buildSearchScore(q: string): SearchScorePlan {
|
|||||||
if (terms.length === 0) {
|
if (terms.length === 0) {
|
||||||
const m = buildContentLike(trimmed);
|
const m = buildContentLike(trimmed);
|
||||||
return {
|
return {
|
||||||
scoreExpr: m.conds.map(() => 'CASE WHEN content LIKE ? THEN 1 ELSE 0 END').join(' + '),
|
scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(' + '),
|
||||||
scoreParams: m.params,
|
scoreParams: m.params,
|
||||||
terms: [],
|
terms: [],
|
||||||
legacyShape: true,
|
legacyShape: true,
|
||||||
@@ -438,16 +489,16 @@ export function buildSearchScore(q: string): SearchScorePlan {
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
const params: string[] = [];
|
const params: string[] = [];
|
||||||
for (const { term, weight } of terms) {
|
for (const { term, weight } of terms) {
|
||||||
parts.push(`CASE WHEN content LIKE ? THEN ${weight} ELSE 0 END`);
|
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${weight} ELSE 0 END`);
|
||||||
params.push(`%${term}%`);
|
params.push(likePattern(term));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
|
// 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。
|
||||||
const single = terms.length === 1 && terms[0].term === trimmed;
|
const single = terms.length === 1 && terms[0].term === trimmed;
|
||||||
if (!single && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) {
|
if (!single && likeBytes(trimmed) <= MAX_LIKE_Q_BYTES) {
|
||||||
const bonus = terms.reduce((s, t) => s + t.weight, 0);
|
const bonus = terms.reduce((s, t) => s + t.weight, 0);
|
||||||
parts.push(`CASE WHEN content LIKE ? THEN ${bonus} ELSE 0 END`);
|
parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`);
|
||||||
params.push(`%${trimmed}%`);
|
params.push(likePattern(trimmed));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
|
return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single };
|
||||||
@@ -512,7 +563,9 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo
|
|||||||
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
// includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
|
||||||
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
|
||||||
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
// 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。
|
||||||
// 2026-08-10(本次):q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
|
// 2026-08-12(Arcrun#94):q 裡的 `%` `_` `\` 一律當字面字元(escapeLikeLiteral + ESCAPE 宣告)
|
||||||
|
// ——使用者打什麼字就照那些字找。舊病,見上面 LIKE_ESCAPE 那段。
|
||||||
|
// 2026-08-10:q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。
|
||||||
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
|
// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例;
|
||||||
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
|
// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。
|
||||||
export async function searchEntries(
|
export async function searchEntries(
|
||||||
|
|||||||
@@ -330,6 +330,15 @@ type LibraryNameSet = Set<string>;
|
|||||||
|
|
||||||
// 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊
|
// 這個 owner 底下、依 triplet 自身 'library' slot 分組的即時三元組數(缺 library slot 值的舊
|
||||||
// triplet 歸 'general')——與 GET /records/triplet-stats(t142)同一套分組語意,兩處數字對得上。
|
// triplet 歸 'general')——與 GET /records/triplet-stats(t142)同一套分組語意,兩處數字對得上。
|
||||||
|
//
|
||||||
|
// 2026-08-11 修根因(Arcrun#87,動工前量測 comment 第四節):這裡原本完全不過濾 status,
|
||||||
|
// 而 recomputeLibraryMap(上方 withLib)只算 COALESCE(status,'active')='active'。兩邊判準不
|
||||||
|
// 一致,只要有一筆 superseded triplet,這裡的即時計數就會跟重算後的快取對不上,
|
||||||
|
// ensureFreshLibraryMaps 判定 stale,每次讀地圖都觸發重算,每次都新建一筆 library_map
|
||||||
|
// record(superseded 舊的),無止盡寫 D1,且加劇 recomputeLibraryMap 本身非原子 supersede
|
||||||
|
// 的競態(另一個已知病,wiki 08-10 條目)。實測:間隔數秒連讀兩次地圖、中間無任何寫入動作,
|
||||||
|
// updated_at 仍前進。修法:這裡的 status 判準改成與 recomputeLibraryMap 逐字一致,兩邊算出
|
||||||
|
// 的計數才會在資料未變動時相等,stale 判定回歸「真的有資料變動才 stale」。
|
||||||
async function liveTripletCountsByLibrary(
|
async function liveTripletCountsByLibrary(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
tripletTemplateId: string,
|
tripletTemplateId: string,
|
||||||
@@ -337,15 +346,18 @@ async function liveTripletCountsByLibrary(
|
|||||||
): Promise<LibraryCountMap> {
|
): Promise<LibraryCountMap> {
|
||||||
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
|
const params: unknown[] = owner_id ? [tripletTemplateId, owner_id] : [tripletTemplateId];
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare(
|
.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(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
`SELECT COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, COUNT(*) AS n
|
||||||
FROM (
|
FROM (
|
||||||
SELECT DISTINCT ev.record_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
|
FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
|
||||||
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
|
WHERE ev.template_id = ?${owner_id ? ' AND e.owner_id = ?' : ''}
|
||||||
|
GROUP BY ev.record_id
|
||||||
) AS tr
|
) AS tr
|
||||||
LEFT JOIN entry_values lev ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
|
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
|
LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
|
||||||
|
WHERE COALESCE(tr.status, 'active') = 'active'
|
||||||
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
|
GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')`,
|
||||||
)
|
)
|
||||||
.bind(...params)
|
.bind(...params)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
ensureFreshLibraryMaps,
|
ensureFreshLibraryMaps,
|
||||||
LIBRARY_MAP_SLOTS,
|
LIBRARY_MAP_SLOTS,
|
||||||
} from '../src/actions/library-map';
|
} from '../src/actions/library-map';
|
||||||
import { createTemplate, createRecord, getRecord, getTemplate } from '../src/actions/record-crud';
|
import { createTemplate, createRecord, getRecord, getTemplate, searchByTemplate } from '../src/actions/record-crud';
|
||||||
import { createEntry } from '../src/actions/entry-crud';
|
import { createEntry } from '../src/actions/entry-crud';
|
||||||
import type { Bindings } from '../src/types';
|
import type { Bindings } from '../src/types';
|
||||||
|
|
||||||
@@ -292,6 +292,39 @@ describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動
|
|||||||
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(2);
|
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次觸發重算(不再無止盡寫入)', async () => {
|
||||||
|
// 重現票上的根因:liveTripletCountsByLibrary 原本不濾 status,recomputeLibraryMap 只算
|
||||||
|
// active——只要庫裡混了 superseded triplet,兩邊算出來的數字永遠對不上,
|
||||||
|
// ensureFreshLibraryMaps 就永遠判定 stale,每次讀地圖都重算、每次都新建一筆 record。
|
||||||
|
const db = makeSqliteD1();
|
||||||
|
await seedTripletTemplate(db);
|
||||||
|
await ensureTripletLibrarySlot(db, 'triplet');
|
||||||
|
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' }); // active
|
||||||
|
await seedTriplet(db, { s: 'A', p: '連結至', o: 'C', library: 'kb', status: 'superseded' }); // 已淘汰
|
||||||
|
|
||||||
|
const { app, env } = makeApp(db);
|
||||||
|
|
||||||
|
// 第一次讀:資料是新的(從沒 recompute 過),觸發一次重算是正常的。
|
||||||
|
const first = await app.request('/map', {}, env);
|
||||||
|
const firstBody = (await first.json()) as { libraries: { library: string; triplet_count: number }[] };
|
||||||
|
expect(firstBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1); // 只算 active 那筆
|
||||||
|
|
||||||
|
const countAfterFirst = (await searchByTemplate(db, 'library_map')).length;
|
||||||
|
|
||||||
|
// 第二次讀:中間沒有任何寫入動作。修好之前,這裡會再次判定 stale 並多新建一筆 record。
|
||||||
|
const second = await app.request('/map', {}, env);
|
||||||
|
const secondBody = (await second.json()) as { libraries: { library: string; triplet_count: number }[] };
|
||||||
|
expect(secondBody.libraries.find((l) => l.library === 'kb')!.triplet_count).toBe(1);
|
||||||
|
|
||||||
|
const countAfterSecond = (await searchByTemplate(db, 'library_map')).length;
|
||||||
|
expect(countAfterSecond).toBe(countAfterFirst); // 沒有新增任何 library_map record
|
||||||
|
|
||||||
|
// 第三次也一樣,多讀幾次確認不是巧合。
|
||||||
|
await app.request('/map', {}, env);
|
||||||
|
const countAfterThird = (await searchByTemplate(db, 'library_map')).length;
|
||||||
|
expect(countAfterThird).toBe(countAfterFirst);
|
||||||
|
});
|
||||||
|
|
||||||
it('narrative 不會被自動重算靜默洗掉:先人工帶 narrative,之後的自動重算要保留它', async () => {
|
it('narrative 不會被自動重算靜默洗掉:先人工帶 narrative,之後的自動重算要保留它', async () => {
|
||||||
const db = makeSqliteD1();
|
const db = makeSqliteD1();
|
||||||
await seedTripletTemplate(db);
|
await seedTripletTemplate(db);
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// 搜尋框裡的 `%` 與 `_` 是「要找的字」,不是萬用字元 —— Arcrun#94(2026-08-12)
|
||||||
|
//
|
||||||
|
// 病徵(leo 回報):搜尋框打 `%` 或 `_`,搜出來一堆跟他打的字**無關**的東西。
|
||||||
|
// 根因:pattern 一直是 `'%' + 使用者輸入 + '%'` 直接內插,而 SQLite 的 LIKE 有兩個
|
||||||
|
// 萬用字元 `%`/`_` 且**沒有預設跳脫字元** ⇒ 使用者打的符號被當成 pattern 語法。
|
||||||
|
//
|
||||||
|
// 舊病,不是 08-10 斷詞(search-tokenize.test.ts)引進的:pattern 從來就是這樣拼的。
|
||||||
|
// 之前關鍵字搜尋幾乎恆為 0 命中,這個洞被那個洞蓋住;斷詞讓搜尋真的會回東西之後才浮出來。
|
||||||
|
//
|
||||||
|
// 測試策略:**用真 SQLite 跑真的 SQL**(node:sqlite,與 library-map/embed-backfill 同款 adapter)。
|
||||||
|
// 只驗 SQL 形狀不算數——「% 被當成萬用字元」這件事,只有真的跑一次 LIKE 才看得見。
|
||||||
|
// 每組驗收都同時跑「舊寫法」與「現行寫法」,讓前後對照直接長在測試裡(legacyPattern)。
|
||||||
|
//
|
||||||
|
// 註:直接對 SQLite 治具下 SQL 的行集中在下面的 helper(測試治具本身,非牆外業務邏輯繞過
|
||||||
|
// API),每行標 kbdb-sql-ok 留痕——與 embed-backfill/library-backfill 等既有測試同慣例。
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import {
|
||||||
|
escapeLikeLiteral,
|
||||||
|
buildContentLike,
|
||||||
|
buildSearchScore,
|
||||||
|
searchEntries,
|
||||||
|
createEntry,
|
||||||
|
} from '../src/actions/entry-crud';
|
||||||
|
|
||||||
|
// ── node:sqlite → D1 最小 adapter ────────────────────────────────────────────
|
||||||
|
function makeSqliteD1(): D1Database {
|
||||||
|
const raw = new DatabaseSync(':memory:'); // kbdb-sql-ok:記憶體測試替身,非真 KBDB D1
|
||||||
|
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 migration 原檔
|
||||||
|
function stmt(sql: string, params: unknown[]) {
|
||||||
|
return {
|
||||||
|
bind(...args: unknown[]) { return stmt(sql, args); },
|
||||||
|
async all<T>() { return { results: raw.prepare(sql).all(...(params as never[])) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||||
|
async first<T>() { return (raw.prepare(sql).get(...(params as never[])) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||||
|
async run() { raw.prepare(sql).run(...(params as never[])); return { success: true }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 舊寫法(本次修掉的那個):使用者輸入直接內插、LIKE 不帶 ESCAPE。前後對照用。 */
|
||||||
|
const legacyPattern = (q: string) => `%${q}%`;
|
||||||
|
|
||||||
|
/** 對真 SQLite 跑一次 `content LIKE ?`,回命中的 content(要不要帶 ESCAPE 可選)。 */
|
||||||
|
async function likeHits(db: D1Database, pattern: string, escape: boolean): Promise<string[]> {
|
||||||
|
const pred = escape ? "content LIKE ? ESCAPE '\\'" : 'content LIKE ?';
|
||||||
|
const res = await db
|
||||||
|
.prepare(`SELECT content FROM entries WHERE ${pred} ORDER BY id`) // kbdb-sql-ok:測試治具讀回斷言用
|
||||||
|
.bind(pattern)
|
||||||
|
.all<{ content: string }>();
|
||||||
|
return (res.results ?? []).map((x) => x.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 searchEntries 送出的 SQL 側錄下來(不改行為,只是中間插一層)。 */
|
||||||
|
function sqlSpy(db: D1Database): { spy: D1Database; sqls: string[] } {
|
||||||
|
const sqls: string[] = [];
|
||||||
|
const spy = {
|
||||||
|
prepare: (sql: string) => { sqls.push(sql); return db.prepare(sql); }, // kbdb-sql-ok:測試治具側錄,轉呼叫同一顆治具 DB
|
||||||
|
} as unknown as D1Database;
|
||||||
|
return { spy, sqls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
||||||
|
const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限(承 2026-08-03 的 500 修復)
|
||||||
|
|
||||||
|
// 一組刻意設計的語料:每一筆都用來分辨「字面命中」與「萬用字元誤中」。
|
||||||
|
const CORPUS = [
|
||||||
|
'毛利率 100% 達成', // 含字面 %
|
||||||
|
'共有 100 個待辦項目', // 含 100 但不含 %,`%100%%` 會誤中它
|
||||||
|
'owner_id 是租戶隔離的欄位', // 含字面 _
|
||||||
|
'ownerXid 是打錯的欄位名', // `_` 當萬用字元才會中
|
||||||
|
'路徑 C:\\_temp 底下', // 含字面「反斜線+底線」(跳脫字元本身 + 萬用字元)
|
||||||
|
'路徑 C:\\Xtemp 底下', // 反斜線後接任一字元——`_` 漏成萬用字元才會中
|
||||||
|
'完全無關的一筆內容', // 對照組:什麼都不該中
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seeded(): Promise<D1Database> {
|
||||||
|
const db = makeSqliteD1();
|
||||||
|
for (const [i, content] of CORPUS.entries()) {
|
||||||
|
await createEntry(db, { id: `e${i}`, content, entry_type: 'block', owner_id: 'leo' });
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('① 前後對照:使用者打什麼字,就照那些字找', () => {
|
||||||
|
it('`100%`:舊寫法把 % 當萬用字元、連「100 個待辦」都撈回來;現行只回真的含 100% 的', async () => {
|
||||||
|
const db = await seeded();
|
||||||
|
const before = await likeHits(db, legacyPattern('100%'), false);
|
||||||
|
const after = await likeHits(db, buildContentLike('100%').params[0], true);
|
||||||
|
|
||||||
|
expect(before).toEqual(['毛利率 100% 達成', '共有 100 個待辦項目']); // ← 病徵:多了不相干的
|
||||||
|
expect(after).toEqual(['毛利率 100% 達成']); // ← 只有真的含「100%」的
|
||||||
|
});
|
||||||
|
|
||||||
|
it('`owner_id`:舊寫法 _ 匹配任一字元、把 ownerXid 也撈回來;現行只回字面相符的', async () => {
|
||||||
|
const db = await seeded();
|
||||||
|
expect(await likeHits(db, legacyPattern('owner_id'), false)).toEqual([
|
||||||
|
'owner_id 是租戶隔離的欄位',
|
||||||
|
'ownerXid 是打錯的欄位名', // ← 病徵
|
||||||
|
]);
|
||||||
|
expect(await likeHits(db, buildContentLike('owner_id').params[0], true)).toEqual([
|
||||||
|
'owner_id 是租戶隔離的欄位',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('只打一個 `%` 或 `_`:舊寫法把**整個庫**倒回來(leo 回報的那個畫面)', async () => {
|
||||||
|
const db = await seeded();
|
||||||
|
// `%%%` 匹配任何字串;`%_%` 只要有一個字元就中 ⇒ 兩者都等於「全庫」
|
||||||
|
expect(await likeHits(db, legacyPattern('%'), false)).toHaveLength(CORPUS.length);
|
||||||
|
expect(await likeHits(db, legacyPattern('_'), false)).toHaveLength(CORPUS.length);
|
||||||
|
|
||||||
|
// 現行:只回真的含那個字元的(`_` 有兩筆——欄位名那筆與路徑那筆,兩筆都是字面命中)
|
||||||
|
expect(await likeHits(db, buildContentLike('%').params[0], true)).toEqual(['毛利率 100% 達成']);
|
||||||
|
expect(await likeHits(db, buildContentLike('_').params[0], true)).toEqual([
|
||||||
|
'owner_id 是租戶隔離的欄位',
|
||||||
|
'路徑 C:\\_temp 底下',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('走完整搜尋路徑(searchEntries,含斷詞與算分)結果一致——不是只有底層函式對', async () => {
|
||||||
|
const db = await seeded();
|
||||||
|
expect((await searchEntries(db, '100%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
|
||||||
|
expect((await searchEntries(db, 'owner_id', 'leo')).map((e) => e.content)).toEqual([
|
||||||
|
'owner_id 是租戶隔離的欄位',
|
||||||
|
]);
|
||||||
|
// 單打一個符號:以前是全庫,現在是「真的含那個字的那一筆」
|
||||||
|
expect((await searchEntries(db, '%', 'leo')).map((e) => e.content)).toEqual(['毛利率 100% 達成']);
|
||||||
|
expect((await searchEntries(db, '_', 'leo')).map((e) => e.content).sort()).toEqual(
|
||||||
|
['owner_id 是租戶隔離的欄位', '路徑 C:\\_temp 底下'].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('② 邊界:跳脫字元本身(`\\`)也必須跳脫', () => {
|
||||||
|
// 為什麼這組必須存在:宣告了 ESCAPE 之後,`\` 就變成 pattern 裡有意義的字元。
|
||||||
|
// 只跳脫 % 和 _、不跳脫 `\`,等於用新的漏洞換掉舊的——而且更難發現,因為它
|
||||||
|
// **不會報錯**,只會靜靜地把後面那個字吃掉、去找一個使用者沒打過的字串。
|
||||||
|
const halfDone = (q: string) => `%${q.replace(/[%_]/g, (c) => '\\' + c)}%`; // 只跳脫 %/_ 的假想修法
|
||||||
|
|
||||||
|
it('打 `C:\\`:不跳脫反斜線的話尾巴變成「字面 %」,反而找不到任何真正含 `C:\\` 的內容', async () => {
|
||||||
|
const db = await seeded();
|
||||||
|
// pattern `%C:\%` ⇒ 尾巴的 `\%` 被讀成「字面的 %」⇒ 實際去找 `C:%`,庫裡沒有 ⇒ 全漏
|
||||||
|
expect(await likeHits(db, halfDone('C:\\'), true)).toEqual([]);
|
||||||
|
expect(await likeHits(db, buildContentLike('C:\\').params[0], true)).toEqual([
|
||||||
|
'路徑 C:\\_temp 底下',
|
||||||
|
'路徑 C:\\Xtemp 底下',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('打 `C:\\_temp`:反斜線沒跳脫 ⇒ 它把 `_` 的跳脫吃掉,萬用字元漏回來、撈到不相干的', async () => {
|
||||||
|
const db = await seeded();
|
||||||
|
// `%C:\\_temp%`:`\\` 先被讀成「字面 \」,後面那個 `_` 就變回萬用字元 ⇒ C:\Xtemp 也中
|
||||||
|
expect(await likeHits(db, halfDone('C:\\_temp'), true)).toEqual([
|
||||||
|
'路徑 C:\\_temp 底下',
|
||||||
|
'路徑 C:\\Xtemp 底下', // ← 使用者沒打過這個字
|
||||||
|
]);
|
||||||
|
expect(await likeHits(db, buildContentLike('C:\\_temp').params[0], true)).toEqual([
|
||||||
|
'路徑 C:\\_temp 底下',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('打 `100\\%`:三個都不跳脫 ⇒ `\\%` 被讀成「字面 %」⇒ 去找 `100%`,跟他打的不一樣', async () => {
|
||||||
|
const db = await seeded();
|
||||||
|
// 原始寫法(一個都不跳脫)= pattern `%100\%%`:`\%`=字面 %、尾巴那個 `%`=萬用字元
|
||||||
|
expect(await likeHits(db, legacyPattern('100\\%'), true)).toEqual(['毛利率 100% 達成']); // ← 找錯東西
|
||||||
|
// 正解:庫裡沒有字面的 `100\%` ⇒ 就該零命中,而不是拿別的東西充數
|
||||||
|
expect(await likeHits(db, buildContentLike('100\\%').params[0], true)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapeLikeLiteral 只碰這三個字元,且不會把自己剛加的跳脫再跳脫一次', () => {
|
||||||
|
expect(escapeLikeLiteral('100%')).toBe('100\\%');
|
||||||
|
expect(escapeLikeLiteral('owner_id')).toBe('owner\\_id');
|
||||||
|
expect(escapeLikeLiteral('C:\\')).toBe('C:\\\\');
|
||||||
|
expect(escapeLikeLiteral('%_\\')).toBe('\\%\\_\\\\'); // 三個各自跳脫一次,不是兩次
|
||||||
|
// LIKE 沒有 [] ? * 這些萬用字元(那是 GLOB/別的方言)⇒ 不該白白吃掉 byte 預算
|
||||||
|
expect(escapeLikeLiteral('a[b]?c*d 中文')).toBe('a[b]?c*d 中文');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('③ 每個 LIKE 都要帶 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面', () => {
|
||||||
|
it('buildContentLike/buildSearchScore 產生的謂詞都含 ESCAPE', () => {
|
||||||
|
for (const c of buildContentLike('100%').conds) expect(c).toContain("ESCAPE '\\'");
|
||||||
|
for (const c of buildContentLike('a'.repeat(200)).conds) expect(c).toContain("ESCAPE '\\'");
|
||||||
|
expect(buildSearchScore('Gemini 逃生口').scoreExpr).toContain("ESCAPE '\\'");
|
||||||
|
expect(buildSearchScore('。。。').scoreExpr).toContain("ESCAPE '\\'"); // 一個詞都拆不出來的退路
|
||||||
|
});
|
||||||
|
|
||||||
|
it('沒有任何 `content LIKE ?` 是裸的(漏掉一個就等於那條路沒修)', async () => {
|
||||||
|
const { spy, sqls } = sqlSpy(await seeded());
|
||||||
|
for (const q of ['100%', 'Gemini 逃生口', '。。。', 'a'.repeat(200)]) await searchEntries(spy, q, 'leo');
|
||||||
|
expect(sqls.length).toBeGreaterThan(0);
|
||||||
|
for (const sql of sqls) expect(sql.match(/content LIKE \?(?! ESCAPE)/g) ?? []).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ESCAPE 宣告本身是合法 SQL(D1=SQLite;真的跑得起來,不是形狀對而已)', async () => {
|
||||||
|
const db = await seeded();
|
||||||
|
await expect(likeHits(db, '%100\\%%', true)).resolves.toEqual(['毛利率 100% 達成']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('④ 不退化:不含 % _ \\ 的查詢,行為與修改前逐字相同', () => {
|
||||||
|
it('pattern 一個字都沒變(跳脫對這些字串是恆等變換)', () => {
|
||||||
|
for (const q of ['語意檢索', 'arcrun', 'Gemini 逃生口', '為什麼今天額度用完']) {
|
||||||
|
expect(escapeLikeLiteral(q)).toBe(q);
|
||||||
|
}
|
||||||
|
expect(buildContentLike('語意檢索').params).toEqual(['%語意檢索%']);
|
||||||
|
expect(buildSearchScore('arcrun').scoreParams).toEqual(['%arcrun%']);
|
||||||
|
expect(buildSearchScore('語意檢索').legacyShape).toBe(true); // 最熱路徑仍是單一 LIKE
|
||||||
|
});
|
||||||
|
|
||||||
|
it('斷詞(08-10,Arcrun#84 已判定留下)沒被動到:問句照樣拆得開', () => {
|
||||||
|
expect(buildSearchScore('Gemini 逃生口').scoreParams).toEqual(
|
||||||
|
expect.arrayContaining(['%Gemini%', '%逃生口%']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('⑤ 跳脫會變長 ⇒ byte 預算要用「跳脫後」的長度算,否則退回 2026-08-03 那個 500', () => {
|
||||||
|
it('滿是 % 的長查詢,每個 pattern 仍在 D1 的 50 bytes 上限內', () => {
|
||||||
|
const qs = [
|
||||||
|
'%'.repeat(200), // 每個字元跳脫後變 2 bytes
|
||||||
|
'_'.repeat(60),
|
||||||
|
'\\'.repeat(60),
|
||||||
|
`${'%'.repeat(30)}中文${'_'.repeat(30)}`,
|
||||||
|
'a'.repeat(24) + '%'.repeat(24), // 卡在舊上限附近的混合
|
||||||
|
];
|
||||||
|
for (const q of qs) {
|
||||||
|
for (const p of buildContentLike(q).params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||||
|
const plan = buildSearchScore(q);
|
||||||
|
expect(plan.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉)
|
||||||
|
for (const p of plan.scoreParams) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('48 個 `%`(跳脫前剛好在舊上限內)不會產生 98 bytes 的 pattern', () => {
|
||||||
|
const q = '%'.repeat(48);
|
||||||
|
expect(bytes(q)).toBe(48); // 用舊的算法看,它「在上限內」
|
||||||
|
const m = buildContentLike(q);
|
||||||
|
expect(m.split).toBe(true); // 用跳脫後的長度看,它必須被拆開
|
||||||
|
for (const p of m.params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拆片段仍切在字元邊界上,不會把跳脫序列切成半個', async () => {
|
||||||
|
const db = await seeded();
|
||||||
|
for (const p of buildContentLike(`${'%'.repeat(40)}中文${'_'.repeat(40)}`).params) {
|
||||||
|
expect(p).not.toContain('\uFFFD');
|
||||||
|
// 切壞的跳脫序列(尾巴是落單的 `\`)會讓 SQLite 把後面的 `%` 讀成字面 ⇒ 語意錯掉
|
||||||
|
expect(/(^|[^\\])(\\\\)*\\%$/.test(p)).toBe(false);
|
||||||
|
await expect(likeHits(db, p, true)).resolves.toBeDefined(); // 真的送得進 SQLite
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,12 +16,16 @@ import { buildContentLike, searchEntries } from '../src/actions/entry-crud';
|
|||||||
|
|
||||||
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
const bytes = (s: string) => new TextEncoder().encode(s).length;
|
||||||
const MAX_PATTERN = 50; // D1 上限
|
const MAX_PATTERN = 50; // D1 上限
|
||||||
|
// 謂詞字串在 Arcrun#94 多了 ESCAPE 宣告(`content LIKE ? ESCAPE '\'`)。這裡跟著改的是
|
||||||
|
// **比對用的常數**,不是放寬檢查——底下仍然逐字相等比對,只是比的是現在正確的那個字串。
|
||||||
|
// pattern 本身('%語意檢索%')一個字都沒變:那句話裡沒有 % _ \,跳脫後與原文相同。
|
||||||
|
const LIKE_PRED = "content LIKE ? ESCAPE '\\'";
|
||||||
|
|
||||||
describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () => {
|
describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () => {
|
||||||
it('短查詢(≤48 bytes)=與舊版逐字相同的單一 LIKE', () => {
|
it('短查詢(≤48 bytes)=與舊版逐字相同的單一 LIKE', () => {
|
||||||
const m = buildContentLike('語意檢索');
|
const m = buildContentLike('語意檢索');
|
||||||
expect(m.split).toBe(false);
|
expect(m.split).toBe(false);
|
||||||
expect(m.conds).toEqual(['content LIKE ?']);
|
expect(m.conds).toEqual([LIKE_PRED]);
|
||||||
expect(m.params).toEqual(['%語意檢索%']);
|
expect(m.params).toEqual(['%語意檢索%']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,7 +47,7 @@ describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () =
|
|||||||
const m = buildContentLike('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫');
|
const m = buildContentLike('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫');
|
||||||
expect(m.split).toBe(true);
|
expect(m.split).toBe(true);
|
||||||
expect(m.conds.length).toBeGreaterThan(1);
|
expect(m.conds.length).toBeGreaterThan(1);
|
||||||
expect(m.conds.every((c) => c === 'content LIKE ?')).toBe(true);
|
expect(m.conds.every((c) => c === LIKE_PRED)).toBe(true);
|
||||||
expect(m.params).toContain('%語意檢索%');
|
expect(m.params).toContain('%語意檢索%');
|
||||||
expect(m.conds.length).toBeLessThanOrEqual(6); // 詞數上限
|
expect(m.conds.length).toBeLessThanOrEqual(6); // 詞數上限
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 卡在人類閘前的產物(`Arcrun#89` / `#90` / `#91`)
|
||||||
|
|
||||||
|
> **為什麼這個資料夾存在**:這三樣東西都做完並實測過了,但落地的最後一步是
|
||||||
|
> **終端機裡等人親手打字的互動閘**,AI 打不進去。
|
||||||
|
> 2026-08-11 它們原本只存在於某個 session 的暫存目錄——**那種目錄一關就沒了**。
|
||||||
|
> 先搶進版控,等人有空時再落地。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、兩份 recipe(`#89`/`#90`)
|
||||||
|
|
||||||
|
`recipes/gitea_put_file.yaml` — 把檔案寫回 Gitea repo。**出貨線有 7 站等它。**
|
||||||
|
`recipes/cf_worker_deploy_simple.yaml` — 部署單檔 Worker(classic 格式)。
|
||||||
|
|
||||||
|
**落地指令**(一份跑一次):
|
||||||
|
|
||||||
|
```
|
||||||
|
acr recipe push pending-human-gate/recipes/gitea_put_file.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
跑的時候會停下來要你**親手輸入資源名確認**——那是「把資源變成可被外部呼叫」的暴露同意閘,
|
||||||
|
不是卡住,是設計如此。
|
||||||
|
|
||||||
|
⚠️ **`cf_worker_deploy_simple.yaml` 先別急著推**:`#90` 查出一件結構性的事——
|
||||||
|
recipe 引擎的 body 一律 JSON,而 Cloudflare 上傳 Worker 的 API 要的是原始 JS 或 multipart。
|
||||||
|
⇒ **classic 版只適用於沒有 bindings 的簡單情形**。而實查安裝器那站有 9 把 KV + 一顆 D1,
|
||||||
|
**classic 版幫不上它**。詳見 `Leo/Arcrun#90`。
|
||||||
|
|
||||||
|
### 金鑰(D36)
|
||||||
|
|
||||||
|
兩份 recipe 都只寫名字(`gitea_token`/`cf_api_token`),真身由 credential 中心在執行前回填。
|
||||||
|
對應的 auth-recipe **已經註冊在 leo21c 上**,可以直接查證:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -s https://arcrun-cypher-executor.leo21c.workers.dev/auth-recipes/gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、`hash` 零件(`#91`)
|
||||||
|
|
||||||
|
`hash-component/` — sha256/sha1/md5,hex/base64。出貨線的版本號機制與成品指紋核對都要它。
|
||||||
|
|
||||||
|
**已實測**(tinygo 編出來、wasmtime 真跑,三種演算法都跟系統原生指令**逐位元一致**)。
|
||||||
|
`.wasm` 是 1.3 MB 編譯產物,**沒有進版控**——要驗自己重編:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd pending-human-gate/hash-component && tinygo build -target=wasi -o /tmp/hash.wasm main.go
|
||||||
|
echo '{"algorithm":"sha256","input":"hello"}' | wasmtime /tmp/hash.wasm
|
||||||
|
printf 'hello' | shasum -a 256 # 兩者應該一致
|
||||||
|
```
|
||||||
|
|
||||||
|
**落地要走零件投稿流程**(D27/D28):`docs/component-pr-review-standard.md` 的 checklist
|
||||||
|
+ 人在終端機互動跑 `scripts/component-arm.sh`。
|
||||||
|
🔴 `registry/components/` 底下有機械閘(`component-guard.sh`)擋著 AI 直接寫入——**那是刻意的**,
|
||||||
|
所以這份放在 `pending-human-gate/`,不是放在它最終該去的位置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 落地之後
|
||||||
|
|
||||||
|
三樣都上去之後,`Arcrun#89`/`#91` 才能從 **◐ 半通** 變 **✅**——
|
||||||
|
而判準是**貼一次真實的執行輸出**(recipe 對某個測試檔案回 2xx、零件在真端點上跑出正確雜湊),
|
||||||
|
不是「推上去了」。
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
canonical_id: "hash"
|
||||||
|
display_name: "計算雜湊"
|
||||||
|
category: "logic"
|
||||||
|
version: "v1"
|
||||||
|
wasi_target: "preview1"
|
||||||
|
stability: "floating"
|
||||||
|
runtime_compat:
|
||||||
|
- "cf-workers"
|
||||||
|
- "workerd"
|
||||||
|
- "wazero"
|
||||||
|
constraints:
|
||||||
|
max_size_kb: 2048
|
||||||
|
max_cold_start_ms: 50
|
||||||
|
no_network_syscall: true
|
||||||
|
no_filesystem_syscall: true
|
||||||
|
io_model: "stdin_stdout_json"
|
||||||
|
input_schema:
|
||||||
|
type: object
|
||||||
|
required: [input]
|
||||||
|
properties:
|
||||||
|
algorithm:
|
||||||
|
type: string
|
||||||
|
enum: [sha256, sha1, md5]
|
||||||
|
description: 雜湊演算法,預設 sha256
|
||||||
|
input:
|
||||||
|
type: string
|
||||||
|
description: 要算雜湊的內容
|
||||||
|
encoding:
|
||||||
|
type: string
|
||||||
|
enum: [hex, base64]
|
||||||
|
description: 輸出編碼,預設 hex
|
||||||
|
output_schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
result:
|
||||||
|
type: string
|
||||||
|
algorithm:
|
||||||
|
type: string
|
||||||
|
encoding:
|
||||||
|
type: string
|
||||||
|
gherkin_tests:
|
||||||
|
- scenario: "sha256 hex(預設)"
|
||||||
|
given: '{"algorithm":"sha256","input":"hello"}'
|
||||||
|
then_contains: '"result":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"'
|
||||||
|
- scenario: "sha1"
|
||||||
|
given: '{"algorithm":"sha1","input":"hello"}'
|
||||||
|
then_contains: '"result":"aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"'
|
||||||
|
- scenario: "md5"
|
||||||
|
given: '{"algorithm":"md5","input":"hello"}'
|
||||||
|
then_contains: '"result":"5d41402abc4b2a76b9719d911017c592"'
|
||||||
|
- scenario: "base64 編碼"
|
||||||
|
given: '{"algorithm":"sha256","input":"hello","encoding":"base64"}'
|
||||||
|
then_contains: '"result":"LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="'
|
||||||
|
- scenario: "預設 algorithm=sha256"
|
||||||
|
given: '{"input":"hello"}'
|
||||||
|
then_contains: '"algorithm":"sha256"'
|
||||||
|
- scenario: "不支援的 algorithm"
|
||||||
|
given: '{"algorithm":"crc32","input":"hello"}'
|
||||||
|
then_contains: '{"success":false'
|
||||||
|
tags: [builtin, logic, hash, checksum, versioning]
|
||||||
|
description: >-
|
||||||
|
計算內容雜湊(sha256/sha1/md5,輸出 hex 或 base64)。純計算,無網路/檔案 syscall。
|
||||||
|
用途:出貨線版本號機制(Leo/Arcrun#91)——內容一變雜湊必變,是「改了東西版本沒動」在結構上
|
||||||
|
不可能發生的機制來源;build 站核對官方成品指紋也用它。
|
||||||
|
config_example: |
|
||||||
|
compute_hash: # 節點名稱(可自訂)
|
||||||
|
algorithm: "sha256" # 演算法(選填,預設 sha256),可選值:sha256/sha1/md5
|
||||||
|
input: "{{ctx.bundle_content}}" # 要算雜湊的內容(必填)
|
||||||
|
encoding: "hex" # 輸出編碼(選填,預設 hex),可選值:hex/base64
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// hash — 計算內容雜湊(純計算,無網路/檔案 syscall)
|
||||||
|
// 支援: sha256, sha1, md5;輸出編碼: hex(預設), base64
|
||||||
|
// 用途:出貨線版本號機制(Leo/Arcrun#91)——內容一變雜湊必變,
|
||||||
|
// 是「改了東西版本沒動」在結構上不可能發生的機制來源。
|
||||||
|
//
|
||||||
|
//go:build tinygo
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Input struct {
|
||||||
|
Algorithm string `json:"algorithm"` // sha256(預設)| sha1 | md5
|
||||||
|
Input string `json:"input"`
|
||||||
|
Encoding string `json:"encoding"` // hex(預設)| base64
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
raw, err := io.ReadAll(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
writeError("failed to read stdin: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in Input
|
||||||
|
if err := json.Unmarshal(raw, &in); err != nil {
|
||||||
|
writeError("invalid input JSON: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
algorithm := in.Algorithm
|
||||||
|
if algorithm == "" {
|
||||||
|
algorithm = "sha256"
|
||||||
|
}
|
||||||
|
encoding := in.Encoding
|
||||||
|
if encoding == "" {
|
||||||
|
encoding = "hex"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sum []byte
|
||||||
|
switch algorithm {
|
||||||
|
case "sha256":
|
||||||
|
h := sha256.Sum256([]byte(in.Input))
|
||||||
|
sum = h[:]
|
||||||
|
case "sha1":
|
||||||
|
h := sha1.Sum([]byte(in.Input))
|
||||||
|
sum = h[:]
|
||||||
|
case "md5":
|
||||||
|
h := md5.Sum([]byte(in.Input))
|
||||||
|
sum = h[:]
|
||||||
|
default:
|
||||||
|
writeError("不支援的 algorithm: " + algorithm + "(支援 sha256/sha1/md5)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result string
|
||||||
|
switch encoding {
|
||||||
|
case "hex":
|
||||||
|
result = hex.EncodeToString(sum)
|
||||||
|
case "base64":
|
||||||
|
result = base64.StdEncoding.EncodeToString(sum)
|
||||||
|
default:
|
||||||
|
writeError("不支援的 encoding: " + encoding + "(支援 hex/base64)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"result": result,
|
||||||
|
"algorithm": algorithm,
|
||||||
|
"encoding": encoding,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
os.Stdout.Write(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(msg string) {
|
||||||
|
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||||
|
os.Stdout.Write(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name = "arcrun-hash"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-02-19"
|
||||||
|
workers_dev = true
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
COMPONENT_ID = "hash"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
pattern = "hash.arcrun.dev/*"
|
||||||
|
zone_name = "arcrun.dev"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
canonical_id: cf_worker_deploy_simple
|
||||||
|
display_name: Cloudflare Worker Deploy (single-file, classic format)
|
||||||
|
description: >-
|
||||||
|
PUT /accounts/{account_id}/workers/scripts/{script_name} 部署單檔 Worker(CF 「classic Service
|
||||||
|
Worker」格式,非 ES module)。_path 帶 /{account_id}/workers/scripts/{script_name}。
|
||||||
|
auth: cloudflare_workers static_key(Bearer token)。
|
||||||
|
⚠️ 已知限制(誠實記錄,非隱藏債):這個 recipe 走 arcrun 的「recipe body 一律 JSON.stringify」
|
||||||
|
引擎行為(cypher-executor/src/lib/component-loader.ts makeRecipeRunner),CF 這支 API 卻要求
|
||||||
|
body 是「原始 JS 原始碼」或(現代 ES module + bindings 情境)multipart/form-data——兩者都不是
|
||||||
|
JSON。純 recipe 模型在這支 API 上天生對不上,這不是可以在 recipe schema 裡修的事。
|
||||||
|
正解=07-thin-shell §3.5 自力救濟階梯「第三方 API 缺能力→ workflow/code-node 補丁」:
|
||||||
|
用 http_request 零件直接打(body 走它的原生 string 模式,不透過本 recipe wrapper),
|
||||||
|
header 用 {{credential.cf_api_token}} 直接內插(D36 credential 模板,不必經過 recipe/auth_service
|
||||||
|
間接層);若目標 Worker 需要 bindings/compatibility_flags(現代 ES module 格式常態),
|
||||||
|
上游加一個 code 節點組出 multipart/form-data body(純資料編碼,非業務邏輯,合法局部整形)。
|
||||||
|
本 recipe 保留給「目標帳號仍接受 classic 格式」的簡單場景;不保證覆蓋所有部署情境。
|
||||||
|
endpoint: https://api.cloudflare.com/client/v4/accounts{{_path}}
|
||||||
|
method: PUT
|
||||||
|
auth_service: cloudflare_workers
|
||||||
|
headers:
|
||||||
|
Content-Type: application/javascript
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
canonical_id: gitea_put_file
|
||||||
|
display_name: Gitea Put File (Create/Update)
|
||||||
|
description: >-
|
||||||
|
Gitea PUT /repos/{owner}/{repo}/contents/{filepath} 建立或更新檔案並產生 commit。
|
||||||
|
_path 帶完整路徑(例 /Leo/arcrun-rag-bundles/contents/manifest.json,filepath 各段需 URL-encode)。
|
||||||
|
body 帶 {message, content(base64), branch, sha(更新既有檔案時必填,取自前一次 GET 的 content.sha;
|
||||||
|
新建檔案時不帶)}。auth: gitea static_key,header Authorization: token <TOKEN>(D36:定義只留
|
||||||
|
{{credential.*}} 名字,真身由 credential 中心於執行前回填,非本 recipe 職責)。
|
||||||
|
endpoint: https://git.uncle6.me/api/v1/repos{{_path}}
|
||||||
|
method: PUT
|
||||||
|
auth_service: gitea
|
||||||
@@ -44,3 +44,34 @@ leo 否決②——「**藏書地圖就是 arcrun 的最重要功能,讓 AI
|
|||||||
不報錯。`mcp/tests/unit/tools/kbdb-map.test.ts` 新增 1 案釘住舊謊言不再出現(18/18 全綠)。
|
不報錯。`mcp/tests/unit/tools/kbdb-map.test.ts` 新增 1 案釘住舊謊言不再出現(18/18 全綠)。
|
||||||
tsc 兩包乾淨。實測:`yuga3bse` 租戶(從未 backfill 過、真實 triplet 資料橫跨 5 個庫)改前
|
tsc 兩包乾淨。實測:`yuga3bse` 租戶(從未 backfill 過、真實 triplet 資料橫跨 5 個庫)改前
|
||||||
`kbdb_get_map` 回 `{libraries:[],count:0}`——改動待部署後需重新實測驗證非空。
|
`kbdb_get_map` 回 `{libraries:[],count:0}`——改動待部署後需重新實測驗證非空。
|
||||||
|
|
||||||
|
### M3 止血(2026-08-11,Arcrun#87,總管交辦「動工前的量測」comment 第四節)
|
||||||
|
|
||||||
|
**08-08 那次改法本身留了一個判準缺口,這次補上**:`ensureFreshLibraryMaps` 比對
|
||||||
|
「即時三元組數」(`liveTripletCountsByLibrary`)與「快取的地圖數」(`recomputeLibraryMap`
|
||||||
|
算出來寫進去的),但兩邊的 status 過濾不一致——`recomputeLibraryMap` 只算
|
||||||
|
`COALESCE(status,'active')='active'`,`liveTripletCountsByLibrary` 完全不濾 status。
|
||||||
|
只要一個庫裡混了任何一筆 superseded/deprecated triplet,兩邊數字就永遠對不上,
|
||||||
|
`ensureFreshLibraryMaps` 就永遠判定 stale ⇒ **每次讀地圖都觸發重算,每次都新建一筆
|
||||||
|
library_map record(superseded 舊的),無止盡寫 D1**——且加劇 `recomputeLibraryMap`
|
||||||
|
本身非原子 supersede 的既有競態(更高重算頻率 = 更高並發重算機率),是 `kb` 庫
|
||||||
|
全部 44 筆被標 superseded、`notes` 庫兩筆同時 active(`arcrun-rag#50`)這兩個症狀的
|
||||||
|
共同根因之一。
|
||||||
|
|
||||||
|
**修法**:`liveTripletCountsByLibrary`(`kbdb/src/actions/library-map.ts`)的 SQL 改成
|
||||||
|
先 pivot 出每筆 triplet record 的 status,再套用與 `recomputeLibraryMap` 逐字一致的
|
||||||
|
`COALESCE(status,'active')='active'` 過濾,兩邊判準對齊後,資料未變動時兩個計數必然相等,
|
||||||
|
stale 判定回歸「真的有資料變動才 stale」。
|
||||||
|
|
||||||
|
**驗證**:新增迴歸案「Arcrun#87 迴歸:superseded triplet 存在時,連讀兩次地圖不會再次
|
||||||
|
觸發重算」(`kbdb/tests/library-map.test.ts`,19/19 全綠);反向驗證過——把同一顆測試跑在
|
||||||
|
修前的舊 SQL 上會失敗(`library_map` record 數 2 vs 期望 1),證明測試真的釘住這個 bug、
|
||||||
|
不是空氣測試。另外用 leo21c MCP 連線(`bfezv28v`)連讀兩次 `kbdb_get_map()`(無中間寫入)
|
||||||
|
獨立重現修前症狀:`general` 庫 `updated_at` 從 `1786457080` 前進到 `1786457114`。
|
||||||
|
|
||||||
|
**尚待**:改動只在分支 `fix/library-map-recompute-loop-87-v3`(未 push、未部署 leo21c);
|
||||||
|
既有 100 筆 library_map 殘骸(`kb` 44 筆 superseded/`general` 41/`notes` 2)未清——
|
||||||
|
清除需要一個目前不存在的 DELETE 通道(cypher-executor 的 `/kbdb/records/:id` proxy 只有
|
||||||
|
GET/POST/PATCH,無 DELETE;kbdb base 自己雖有 `DELETE /records/:recordId` 但走 leo21c
|
||||||
|
需要 `KBDB_INTERNAL_TOKEN`,非 CC 可持有的機密)——待總管部署本修法+視情況補一支
|
||||||
|
DELETE proxy 後再清。
|
||||||
|
|||||||
Reference in New Issue
Block a user