chore: checkpoint turso and ai runtime work

This commit is contained in:
Agent Board
2026-07-03 23:20:16 +08:00
parent a75b3d11f9
commit 36d027a4a1
67 changed files with 4224 additions and 914 deletions
+92 -5
View File
@@ -11,6 +11,7 @@
* - SKIP_OPENCODE:设为 "1" or "true" 可强制跳过 opencode
* - ENABLE_OPENHUB:设为 "1" or "true" 时启用 OpenHub FastAPI
* - OPENHUB_CMD / OPENHUB_BACKEND_CMD:覆盖 OpenHub FastAPI 启动命令;设置后即视为显式启用 OpenHub
* - OPENHUB_HOST / OPENHUB_BIND_HOSTOpenHub FastAPI 监听地址,默认 0.0.0.0 便于局域网访问
* - OPENHUB_PORT / OPENHUB_BACKEND_PORTOpenHub FastAPI 端口,默认 18080
* - SKIP_OPENHUB:设为 "1" or "true" 可强制跳过 OpenHub
* - OPENHUB_REDIS_URL / OPENHUB_REDIS_DB:记录 OpenHub Redis 位置;OPENHUB_REDIS_HEALTH_URL 可选做 HTTP health 检查
@@ -18,6 +19,8 @@
* - OPENHUB_OPENCODE_BASE_URLOpenHub 侧 opencode serve base URL;默认复用 MNOTE_OPENCODE_BASE_URL
* - SKIP_OPENHUB_HEALTH:设为 "1" or "true" 可跳过 OpenHub/Redis/opencode health 预检
* - MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE:默认 "1",禁用 OpenHub Git snapshot/restore/revert 写链
* - MNOTE_CONTROL_PLANE_BACKEND:控制面后端,默认 libsql-local;可设 turso-remote / turso-local-replica / turso-synced
* - MNOTE_TURSO_LOCAL_PATHlibsql-local 本地库路径,默认 /mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db
* - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python"
*/
@@ -59,6 +62,7 @@ const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
const opencodePortFromEnv = Number(process.env.OPENCODE_PORT || 4096);
const openhubPortFromEnv = Number(process.env.OPENHUB_BACKEND_PORT || process.env.OPENHUB_PORT || 18080);
const defaultControlPlaneDir = "/mnt/Data1T/Mnote_data/control-plane";
function hasCommand(command) {
try {
@@ -83,12 +87,51 @@ function buildDefaultBackendCommand(port) {
}
function buildDefaultOpencodeCommand(port) {
return `while true; do script -qfec "opencode serve --hostname=127.0.0.1 --port ${port} --print-logs" /dev/null; sleep 1; done`;
const opencodeXdgRoot = process.env.OPENHUB_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/openhub/opencode-runtime";
const opencodeHome = process.env.OPENHUB_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/openhub/opencode-home";
const modelEnvNames = [
"OPENCODE_API_KEY",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"GOOGLE_GENERATIVE_AI_API_KEY",
"DEEPSEEK_API_KEY",
"GEMINI_API_KEY",
"MISTRAL_API_KEY",
"OPENROUTER_API_KEY",
"GROQ_API_KEY",
"XAI_API_KEY",
"AZURE_OPENAI_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
];
const opencodeEnv = [
"env",
...modelEnvNames.map((name) => `-u ${name}`),
`HOME=${JSON.stringify(opencodeHome)}`,
`XDG_CONFIG_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "config"))}`,
`XDG_DATA_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "data"))}`,
`XDG_STATE_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "state"))}`,
`XDG_CACHE_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "cache"))}`,
].join(" ");
return [
`mkdir -p ${JSON.stringify(opencodeXdgRoot)} ${JSON.stringify(opencodeHome)}`,
`while true; do script -qfec ${JSON.stringify(`${opencodeEnv} opencode serve --hostname=127.0.0.1 --port ${port} --print-logs`)} /dev/null; sleep 1; done`,
].join(" && ");
}
function buildDefaultOpenHubCommand(port) {
const openhubBackendDir = process.env.OPENHUB_BACKEND_DIR || "/tmp/mnote-openhub-research/OpenHub/smart-query-backend";
return `cd ${JSON.stringify(openhubBackendDir)} && uvicorn app.main:app --host 127.0.0.1 --port ${port}`;
const openhubBackendDir = process.env.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend";
const openhubHost = process.env.OPENHUB_HOST || process.env.OPENHUB_BIND_HOST || "0.0.0.0";
const uvicornBin = fs.existsSync(path.join(openhubBackendDir, ".venv", "bin", "uvicorn"))
? path.join(openhubBackendDir, ".venv", "bin", "uvicorn")
: "uvicorn";
const opencodeBaseUrl = process.env.OPENHUB_OPENCODE_BASE_URL || process.env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`;
return [
`cd ${JSON.stringify(openhubBackendDir)}`,
`OPENCODE_BASE_URL=${JSON.stringify(opencodeBaseUrl)} MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE=${JSON.stringify(process.env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1")} exec ${JSON.stringify(uvicornBin)} app.main:app --host ${JSON.stringify(openhubHost)} --port ${port}`,
].join(" && ");
}
function isEnabledEnv(value) {
@@ -132,7 +175,7 @@ function resolveOpenHubHealthPlan(env = process.env) {
enabled: shouldCheckOpenHubHealth(env),
openhub: {
label: "OpenHub FastAPI",
url: env.OPENHUB_HEALTH_URL || `${baseUrl}/health`,
url: env.OPENHUB_HEALTH_URL || `${baseUrl}/api/health`,
required: requireHealth,
},
redis: {
@@ -160,8 +203,25 @@ function resolveRuntimePlan(env = process.env) {
const frontendPort = Number(env.FRONTEND_PORT || 3000);
const skipGateway = false;
const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000;
const controlPlaneBackend = String(env.MNOTE_CONTROL_PLANE_BACKEND || "libsql-local").trim() || "libsql-local";
if (controlPlaneBackend === "sqlite") {
throw new Error("desktop:hot 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-local-replica/turso-remote/turso-synced");
}
const controlPlaneEnv = {
MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend,
};
if (controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso") {
controlPlaneEnv.MNOTE_TURSO_LOCAL_PATH =
env.MNOTE_TURSO_LOCAL_PATH || path.join(defaultControlPlaneDir, "control-plane-libsql.db");
} else if (controlPlaneBackend === "turso-local-replica" || controlPlaneBackend === "turso-remote-replica") {
controlPlaneEnv.MNOTE_TURSO_LOCAL_REPLICA_PATH =
env.MNOTE_TURSO_LOCAL_REPLICA_PATH || path.join(defaultControlPlaneDir, "control-plane-replica.db");
} else if (controlPlaneBackend === "turso-synced") {
controlPlaneEnv.MNOTE_TURSO_SYNCED_PATH =
env.MNOTE_TURSO_SYNCED_PATH || env.MNOTE_TURSO_LOCAL_REPLICA_PATH || path.join(defaultControlPlaneDir, "control-plane-synced.db");
}
return {
const plan = {
skipGateway,
publicPort,
publicUrl: `http://localhost:${publicPort}`,
@@ -169,11 +229,14 @@ function resolveRuntimePlan(env = process.env) {
mnoteWebEnv: {
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
MNOTE_KNOWLEDGE_PROVIDER: env.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy",
MNOTE_OPENCODE_BASE_URL: env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`,
MNOTE_OPENHUB_BASE_URL: env.MNOTE_OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPortFromEnv}`,
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1",
...controlPlaneEnv,
},
};
return plan;
}
const runtimePlan = resolveRuntimePlan(process.env);
@@ -466,6 +529,23 @@ async function checkHttpHealth(url, label, required) {
}
}
async function isHttpHealthy(url) {
if (!url) return false;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1200);
const response = await fetch(url, {
method: "GET",
headers: { accept: "application/json,text/plain,*/*" },
signal: controller.signal,
});
clearTimeout(timer);
return response.ok;
} catch {
return false;
}
}
async function checkOpenHubHealth(plan = resolveOpenHubHealthPlan(process.env)) {
if (!plan.enabled) {
return true;
@@ -641,6 +721,11 @@ async function main() {
}
if (shouldStartOpenHub(process.env) && !process.env.OPENHUB_BACKEND_CMD && !process.env.OPENHUB_CMD) {
const openhubPortOk = await ensurePortFree(openhubPortFromEnv, "openhub");
if (!openhubPortOk) {
console.error(`OpenHub 端口 ${openhubPortFromEnv} 无法释放,已中止启动。`);
process.exit(1);
}
const openhubTask = findTask("openhub");
if (!openhubTask) {
throw new Error("缺少 OpenHub 任务配置");
@@ -674,11 +759,13 @@ if (require.main === module) {
}
module.exports = {
buildDefaultOpencodeCommand,
buildDefaultOpenHubCommand,
checkOpenHubHealth,
ensurePortFree,
getListeningPidsByPort,
getProcessNameByPid,
isHttpHealthy,
isPortFree,
resolveOpenHubHealthPlan,
resolveRuntimePlan,
+72 -3
View File
@@ -3,9 +3,11 @@ const { spawn } = require("node:child_process");
const net = require("node:net");
const { test } = require("node:test");
const {
buildDefaultOpencodeCommand,
buildDefaultOpenHubCommand,
resolveBackendExecutable,
ensurePortFree,
isHttpHealthy,
isPortFree,
resolveOpenHubHealthPlan,
resolveRuntimePlan,
@@ -129,12 +131,48 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
assert.deepEqual(plan.mnoteWebEnv, {
MNOTE_WEB_BIND: "0.0.0.0:3000",
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
MNOTE_KNOWLEDGE_PROVIDER: "lightrag_legacy",
MNOTE_OPENCODE_BASE_URL: "http://127.0.0.1:4096",
MNOTE_OPENHUB_BASE_URL: "http://127.0.0.1:18080",
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: "1",
MNOTE_CONTROL_PLANE_BACKEND: "libsql-local",
MNOTE_TURSO_LOCAL_PATH: "/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db",
});
});
test("热启动计划支持 libSQL local 控制面后端", () => {
const plan = resolveRuntimePlan({
FRONTEND_PORT: "3000",
MNOTE_CONTROL_PLANE_BACKEND: "libsql-local",
});
assert.equal(plan.mnoteWebEnv.MNOTE_CONTROL_PLANE_BACKEND, "libsql-local");
assert.equal(
plan.mnoteWebEnv.MNOTE_TURSO_LOCAL_PATH,
"/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db",
);
assert.equal(plan.mnoteWebEnv.MNOTE_CONTROL_PLANE_DB_PATH, undefined);
});
test("热启动计划拒绝 SQLite runtime fallback", () => {
assert.throws(
() => resolveRuntimePlan({
FRONTEND_PORT: "3000",
MNOTE_CONTROL_PLANE_BACKEND: "sqlite",
}),
/不再支持 SQLite control-plane fallback/,
);
});
test("显式配置知识库 provider 时透传给 MNote host", () => {
const plan = resolveRuntimePlan({
FRONTEND_PORT: "3000",
MNOTE_KNOWLEDGE_PROVIDER: "weknora",
});
assert.equal(plan.mnoteWebEnv.MNOTE_KNOWLEDGE_PROVIDER, "weknora");
});
test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => {
assert.equal(shouldStartBackend({}), false);
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1" }), true);
@@ -161,7 +199,7 @@ test("OpenHub health plan 包含 FastAPI、Redis、opencode 和默认关闭 Git
});
assert.equal(plan.enabled, true);
assert.equal(plan.openhub.url, "http://127.0.0.1:18081/health");
assert.equal(plan.openhub.url, "http://127.0.0.1:18081/api/health");
assert.equal(plan.openhub.required, true);
assert.equal(plan.redis.url, "http://127.0.0.1:6379/health");
assert.equal(plan.redis.skipped, false);
@@ -175,7 +213,38 @@ test("OpenHub health plan 包含 FastAPI、Redis、opencode 和默认关闭 Git
test("OpenHub 默认命令只启动 FastAPI,不包含 snapshot/restore 写操作", () => {
const command = buildDefaultOpenHubCommand(18082);
assert.match(command, /uvicorn app\.main:app/);
assert.match(command, /app\.main:app/);
assert.match(command, /--host "?0\.0\.0\.0"?/);
assert.match(command, /--port 18082/);
assert.doesNotMatch(command, /snapshot|restore|revert|kill-port/i);
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/OpenHub\/smart-query-backend/);
assert.match(command, /MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE="1"/);
assert.doesNotMatch(command, /git\s+(snapshot|restore|revert)|kill-port/i);
});
test("opencode 默认命令使用 OpenHub 专用运行目录", () => {
const command = buildDefaultOpencodeCommand(18085);
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/opencode-runtime/);
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/opencode-home/);
assert.match(command, /HOME=/);
assert.match(command, /XDG_CONFIG_HOME=/);
assert.doesNotMatch(command, /已检测到现有 opencode/);
assert.match(command, /opencode serve --hostname=127\.0\.0\.1 --port 18085 --print-logs/);
});
test("isHttpHealthy 只把 2xx HTTP health 视为可复用服务", async () => {
const server = net.createServer((socket) => {
socket.once("data", () => {
socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK");
});
});
await new Promise((resolve, reject) => {
server.listen(0, "127.0.0.1", resolve);
server.once("error", reject);
});
const { port } = server.address();
try {
assert.equal(await isHttpHealthy(`http://127.0.0.1:${port}/health`), true);
} finally {
server.close();
}
});
+43 -1
View File
@@ -58,12 +58,54 @@ function devHotBindAddr(env = process.env) {
}
function buildDevHotEnv(baseEnv = process.env) {
return {
const opencodePort = String(baseEnv.OPENCODE_PORT || "4096").trim();
const openhubPort = String(baseEnv.OPENHUB_BACKEND_PORT || baseEnv.OPENHUB_PORT || "18080").trim();
const opencodeBaseUrl = String(baseEnv.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePort}`).trim();
const openhubBaseUrl = String(baseEnv.MNOTE_OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPort}`).trim();
const openhubBackendDir = String(
baseEnv.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend"
).trim();
const openhubOpencodeXdgRoot = String(
baseEnv.OPENHUB_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/openhub/opencode-runtime"
).trim();
const openhubOpencodeHome = String(
baseEnv.OPENHUB_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/openhub/opencode-home"
).trim();
const skipOpenHub = String(baseEnv.SKIP_OPENHUB || "").trim();
const controlPlaneBackend = String(baseEnv.MNOTE_CONTROL_PLANE_BACKEND || "libsql-local").trim() || "libsql-local";
if (controlPlaneBackend === "sqlite") {
throw new Error("dev:hot 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-local-replica/turso-remote/turso-synced");
}
const defaultControlPlaneDir = "/mnt/Data1T/Mnote_data/control-plane";
const env = {
...baseEnv,
MNOTE_WEB_DEV_HOT_RELOAD: "1",
MNOTE_WEB_BIND: devHotBindAddr(baseEnv),
MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv),
MNOTE_KNOWLEDGE_PROVIDER: String(baseEnv.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy").trim(),
ENABLE_OPENHUB: skipOpenHub ? String(baseEnv.ENABLE_OPENHUB || "") : "1",
CHECK_OPENHUB_HEALTH: String(baseEnv.CHECK_OPENHUB_HEALTH || "1"),
MNOTE_OPENCODE_BASE_URL: opencodeBaseUrl,
OPENHUB_OPENCODE_BASE_URL: String(baseEnv.OPENHUB_OPENCODE_BASE_URL || opencodeBaseUrl).trim(),
OPENHUB_BACKEND_DIR: openhubBackendDir,
OPENHUB_OPENCODE_XDG_ROOT: openhubOpencodeXdgRoot,
OPENHUB_OPENCODE_HOME: openhubOpencodeHome,
MNOTE_OPENHUB_BASE_URL: openhubBaseUrl,
OPENHUB_HEALTH_URL: String(baseEnv.OPENHUB_HEALTH_URL || `${openhubBaseUrl.replace(/\/+$/, "")}/api/health`).trim(),
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: String(baseEnv.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1"),
MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend,
};
if (controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso") {
env.MNOTE_TURSO_LOCAL_PATH =
baseEnv.MNOTE_TURSO_LOCAL_PATH || path.join(defaultControlPlaneDir, "control-plane-dev-hot-libsql.db");
} else if (controlPlaneBackend === "turso-local-replica" || controlPlaneBackend === "turso-remote-replica") {
env.MNOTE_TURSO_LOCAL_REPLICA_PATH =
baseEnv.MNOTE_TURSO_LOCAL_REPLICA_PATH || path.join(defaultControlPlaneDir, "control-plane-dev-hot-replica.db");
} else if (controlPlaneBackend === "turso-synced") {
env.MNOTE_TURSO_SYNCED_PATH =
baseEnv.MNOTE_TURSO_SYNCED_PATH || baseEnv.MNOTE_TURSO_LOCAL_REPLICA_PATH || path.join(defaultControlPlaneDir, "control-plane-dev-hot-synced.db");
}
return env;
}
function main() {
+14 -3
View File
@@ -24,7 +24,8 @@ const BUILD_CACHE_DIR = path.join(ROOT, "dist", "prod-build-cache", "mnote-web")
const RUN_DIR = path.join(ROOT, "dist", "run", "mnote-web-prod");
const RUN_STATE_PATH = path.join(RUN_DIR, "process.json");
const DEFAULT_PORT = 3003;
const DEFAULT_CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const DEFAULT_TURSO_LOCAL_PATH = "/mnt/Data1T/Mnote_data/control-plane/control-plane-prod-libsql.db";
const DEFAULT_CONTROL_PLANE_BACKEND = "libsql-local";
const ENV_ALL_PATH = path.join(ROOT, ".env.all");
const RELEASE_BUILD_SCHEMA = "mnote-web-prod-runtime-assets-v2";
const SUPPLEMENTAL_RUNTIME_ASSETS = [
@@ -529,6 +530,11 @@ async function startRelease(release) {
const logFd = fs.openSync(logPath, "a");
const bind = process.env.MNOTE_WEB_BIND || `0.0.0.0:${port}`;
const publicBind = process.env.MNOTE_WEB_PUBLIC_BIND || defaultPublicBind(port);
const controlPlaneBackend =
process.env.MNOTE_CONTROL_PLANE_BACKEND || DEFAULT_CONTROL_PLANE_BACKEND;
if (controlPlaneBackend === "sqlite") {
throw new Error("prod runtime 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-remote/turso-local-replica/turso-synced");
}
const child = spawn(binaryPath, [], {
cwd: release.metadata?.sourceDir || release.releaseDir,
@@ -538,8 +544,13 @@ async function startRelease(release) {
...process.env,
MNOTE_WEB_BIND: bind,
MNOTE_WEB_PUBLIC_BIND: publicBind,
MNOTE_CONTROL_PLANE_DB_PATH:
process.env.MNOTE_CONTROL_PLANE_DB_PATH || DEFAULT_CONTROL_PLANE_DB,
MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend,
...(controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso"
? {
MNOTE_TURSO_LOCAL_PATH:
process.env.MNOTE_TURSO_LOCAL_PATH || DEFAULT_TURSO_LOCAL_PATH,
}
: {}),
},
});
+31
View File
@@ -16,6 +16,25 @@ assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/src/);
assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/browser/);
assert.equal(env.FRONTEND_PORT, "3200");
assert.equal(env.MNOTE_WEB_BIND, "0.0.0.0:3200");
assert.equal(env.MNOTE_KNOWLEDGE_PROVIDER, "lightrag_legacy");
assert.equal(env.ENABLE_OPENHUB, "1");
assert.equal(env.CHECK_OPENHUB_HEALTH, "1");
assert.equal(env.MNOTE_OPENCODE_BASE_URL, "http://127.0.0.1:4096");
assert.equal(env.OPENHUB_OPENCODE_BASE_URL, "http://127.0.0.1:4096");
assert.equal(env.OPENHUB_BACKEND_DIR, "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend");
assert.equal(env.OPENHUB_OPENCODE_XDG_ROOT, "/mnt/Data1T/Mnote_data/openhub/opencode-runtime");
assert.equal(env.OPENHUB_OPENCODE_HOME, "/mnt/Data1T/Mnote_data/openhub/opencode-home");
assert.equal(env.MNOTE_OPENHUB_BASE_URL, "http://127.0.0.1:18080");
assert.equal(env.OPENHUB_HEALTH_URL, "http://127.0.0.1:18080/api/health");
assert.equal(env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE, "1");
assert.equal(env.MNOTE_CONTROL_PLANE_BACKEND, "libsql-local");
assert.equal(env.MNOTE_TURSO_LOCAL_PATH, "/mnt/Data1T/Mnote_data/control-plane/control-plane-dev-hot-libsql.db");
assert.equal(env.MNOTE_CONTROL_PLANE_DB_PATH, undefined);
const explicitKnowledgeProviderEnv = buildDevHotEnv({
MNOTE_KNOWLEDGE_PROVIDER: "weknora",
});
assert.equal(explicitKnowledgeProviderEnv.MNOTE_KNOWLEDGE_PROVIDER, "weknora");
const loopbackEnv = buildDevHotEnv({
MNOTE_WEB_BIND: "127.0.0.1:3300",
@@ -24,4 +43,16 @@ const loopbackEnv = buildDevHotEnv({
assert.equal(loopbackEnv.MNOTE_WEB_BIND, "0.0.0.0:3300");
assert.equal(loopbackEnv.MNOTE_WEB_CMD, "custom");
const skipEnv = buildDevHotEnv({
SKIP_OPENHUB: "1",
});
assert.equal(skipEnv.ENABLE_OPENHUB, "");
assert.throws(
() => buildDevHotEnv({
MNOTE_CONTROL_PLANE_BACKEND: "sqlite",
}),
/不再支持 SQLite control-plane fallback/,
);
console.log(JSON.stringify({ ok: true, command: env.MNOTE_WEB_CMD }, null, 2));
@@ -0,0 +1,4 @@
#!/usr/bin/env node
"use strict";
require("./task436-local-markdown-open-document-external-change-smoke.js");
@@ -0,0 +1,4 @@
#!/usr/bin/env node
"use strict";
require("./task436-local-markdown-open-document-external-change-smoke.js");
@@ -0,0 +1,4 @@
#!/usr/bin/env node
"use strict";
require("./task779-openhub-file-edit-document-pane-refresh-smoke.js");
+1 -1
View File
@@ -70,7 +70,7 @@ async function validateAuthEntry(baseUrl) {
assert.match(authText, /name="account"[^>]*type="text"|type="text"[^>]*name="account"/);
assert.match(authText, /data-auth-field="username" hidden/);
assert.match(authText, /name="password"[^>]*type="password"|type="password"[^>]*name="password"/);
assert.match(authText, /data-auth-mode="sqlite-session"/);
assert.match(authText, /data-auth-mode="control-plane-session"/);
assert.match(authText, /没有账号?注册/);
assert.match(authText, /测试账号快速登录/);
assert.doesNotMatch(authText, /隐私政策|使用\s*Google|使用\s*GitHub|第三方快捷/iu);
@@ -122,9 +122,17 @@ async function main() {
window.EventSource = FakeEventSource;
window.__mnoteTask540EventSources = sources;
window.__mnoteTask540CompatEvents = [];
window.__mnoteTask540FileChangeBatches = [];
window.__mnoteTask540FileChangeReactions = [];
window.addEventListener("tree:local-folder-watch-batch", (event) => {
window.__mnoteTask540CompatEvents.push(event.detail || {});
});
window.addEventListener("mnote:file-change-batch", (event) => {
window.__mnoteTask540FileChangeBatches.push(event.detail || {});
});
window.addEventListener("mnote:file-change-reaction", (event) => {
window.__mnoteTask540FileChangeReactions.push(event.detail || {});
});
});
const page = await context.newPage();
@@ -203,7 +211,7 @@ async function main() {
window.__mnoteTask540FileProjectionRequests = [];
window.__mnoteTask540SidebarProjectionRequests = [];
});
await page.evaluate(({ rootUriValue, pagePath, assetPath }) => {
await page.evaluate(({ rootUriValue, workspaceIdValue, pagePath, assetPath }) => {
const detail = {
source: "synthetic_page_ai_receipt",
reason: "agent_run_receipt",
@@ -214,15 +222,37 @@ async function main() {
rootUri: rootUriValue,
revision: "task540-revision",
changedPaths: [
{ relativePath: pagePath, changeType: "modified" },
{
relativePath: pagePath,
documentId: `local-md:${pagePath.replaceAll("/", "~2F")}`,
changeType: "modified",
eventKind: "Modify(Data(Content))",
observedFileVersion: "sha256:task540-observed",
bufferFileVersion: "sha256:task540-buffer",
selfWriteEcho: false,
},
{ relativePath: assetPath, changeType: "modified" },
],
affectedParents: [{ relativePath: "", reason: "task540" }],
},
};
window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch(detail);
window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch(detail);
}, { rootUriValue: rootUri, pagePath: relativePath, assetPath: resourcePath });
window.__mnoteLocalFolderEventBus.emitChangedFiles({
source: detail.source,
reason: detail.reason,
rootUri: rootUriValue,
workspaceId: workspaceIdValue,
changedFiles: detail.payload.changedPaths,
affectedParents: detail.payload.affectedParents,
});
window.__mnoteLocalFolderEventBus.emitChangedFiles({
source: detail.source,
reason: detail.reason,
rootUri: rootUriValue,
workspaceId: workspaceIdValue,
changedFiles: detail.payload.changedPaths,
affectedParents: detail.payload.affectedParents,
});
}, { rootUriValue: rootUri, workspaceIdValue: workspaceId, pagePath: relativePath, assetPath: resourcePath });
await page.waitForFunction(() => {
const events = window.__mnoteTask540CompatEvents || [];
@@ -251,6 +281,14 @@ async function main() {
lastReason: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-reason") || "",
compatEventCount: compatEvents.length,
lastCompatViaEventBus: Boolean(compatEvents.at(-1)?.viaEventBus),
fileChangeService: document.documentElement.getAttribute("data-mnote-file-change-service") || "",
fileChangeSchema: document.documentElement.getAttribute("data-mnote-file-change-service-last-schema") || "",
fileChangeCount: document.documentElement.getAttribute("data-mnote-file-change-service-last-count") || "",
fileChangeDroppedCount: document.documentElement.getAttribute("data-mnote-file-change-service-dropped-count") || "",
fileChangeLastReaction: document.documentElement.getAttribute("data-mnote-file-change-service-last-reaction") || "",
fileChangeBatchCount: (window.__mnoteTask540FileChangeBatches || []).length,
fileChangeReactionTypes: (window.__mnoteTask540FileChangeReactions || []).map((event) => event.type),
lastFileChangeBatch: (window.__mnoteTask540FileChangeBatches || []).at(-1) || null,
hasPageAiSyntheticCompatEvent: compatEvents.some((event) => (
event?.source === "synthetic_page_ai_receipt"
&& event?.reason === "agent_run_receipt"
@@ -268,6 +306,30 @@ async function main() {
assert.equal(result.connectionCount, "1", "event bus diagnostics connectionCount 应为 1");
assert.equal(result.hasPageAiSyntheticCompatEvent, true, "Page AI synthetic 事件应保留 source/reason 并通过 bus 派发兼容事件");
assert.equal(result.lastCompatViaEventBus, true, "兼容 tree:local-folder-watch-batch 必须标记 viaEventBus");
assert.equal(result.fileChangeService, "ready", "FileChangeService diagnostics 未 ready");
assert.equal(typeof result.lastFileChangeBatch, "object", "应暴露标准 FileChange batch");
assert.equal(result.fileChangeSchema, "mnote.file_change_batch.v1", "应生成标准 FileChange batch");
assert.equal(result.fileChangeCount, "2", "同 tick 两个 changed path 应进入同一个 FileChange batch");
assert.equal(result.fileChangeDroppedCount, "0", "rootUri 内 changed path 不应被丢弃");
assert.equal(result.fileChangeBatchCount, 1, "同 tick 两个 receipt 应合并成一次标准 FileChange batch");
assert(
result.fileChangeReactionTypes.includes("refresh_current_document"),
`当前 Markdown 应生成 refresh_current_document reaction: ${JSON.stringify(result.fileChangeReactionTypes)}`,
);
assert(
result.fileChangeReactionTypes.includes("refresh_resource_tab"),
`资源文件应生成 refresh_resource_tab reaction: ${JSON.stringify(result.fileChangeReactionTypes)}`,
);
assert.equal(
result.lastFileChangeBatch?.changes?.[0]?.observedFileVersion,
"sha256:task540-observed",
"FileChange batch 应保留 observedFileVersion",
);
assert.equal(
result.lastFileChangeBatch?.changes?.[0]?.selfWriteEcho,
false,
"FileChange batch 应保留 selfWriteEcho",
);
assert.equal(result.resourceWatchReady, "event-bus", "resource tab watch 应通过 event bus 准备好");
assert(
result.browserStatRequests.some((url) => String(url).includes(encodeURIComponent(resourcePath)) || String(url).includes(resourcePath)),
@@ -17,7 +17,7 @@ const checks = [
['mnote auth truth copy', route.includes('"authTruth": "mnote_session"') && runtime.includes('拒绝 OpenHub JWT/localStorage')],
['openhub user key derived', route.includes('"openhubUserKey"') && route.includes('stable_hash("openhub_user"')],
['workspace session tool scope', route.includes('"openhubSessionScope"') && route.includes('"skillScope"') && route.includes('"mcpScope"') && route.includes('"toolPermissionScope"')],
['proxy injects mnote scope headers', route.includes('add_mnote_scope_headers') && route.includes('x-mnote-user-key') && route.includes('x-mnote-workspace-key') && route.includes('x-mnote-session-scope') && route.includes('x-mnote-tool-permission-scope') && route.includes('x-mnote-weknora-tool-scope')],
['proxy injects mnote scope headers', route.includes('add_mnote_scope_headers') && route.includes('x-mnote-user-key') && route.includes('x-mnote-user-id') && route.includes('x-mnote-display-name') && route.includes('x-mnote-workspace-key') && route.includes('x-mnote-session-scope') && route.includes('x-mnote-tool-permission-scope') && route.includes('x-mnote-weknora-tool-scope')],
['proxy carries full mnote scope internally', route.includes('mnoteScope') && route.includes('mnote_scope_from_query') && route.includes('proxy_query_without_internal_scope')],
['snake case bootstrap scope aliases', route.includes('"openhub_user_key"') && route.includes('"workspace_key"') && route.includes('"session_scope"') && route.includes('"tool_permission_scope"')],
['weknora tool scope', route.includes('"weknoraToolScope"') && route.includes('"weknora_tool_scope"') && runtime.includes('weknora_tool_scope')],
@@ -4,7 +4,7 @@ const fs = require('fs');
const path = require('path');
const openHubRoot =
process.env.OPENHUB_RESEARCH_ROOT || '/tmp/mnote-openhub-research/OpenHub';
process.env.OPENHUB_RESEARCH_ROOT || '/mnt/Data1T/Mnote_data/openhub/OpenHub';
const backendRoot = path.join(openHubRoot, 'smart-query-backend');
const disableEnv = 'MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE';
const legacyDisableEnv = 'OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE';
@@ -4,7 +4,7 @@ const fs = require('fs');
const path = require('path');
const openHubRoot =
process.env.OPENHUB_RESEARCH_ROOT || '/tmp/mnote-openhub-research/OpenHub';
process.env.OPENHUB_RESEARCH_ROOT || '/mnt/Data1T/Mnote_data/openhub/OpenHub';
const backendRoot = path.join(openHubRoot, 'smart-query-backend');
function read(relativePath) {
@@ -43,21 +43,28 @@ assertCheck(
'MNOTE_HOST_TRUTH = "mnote_controlled_headers"',
'def derive_mnote_user',
'def resolve_user_workspace',
'def resolve_user_asset_workspace',
'def get_mnote_scope_metadata',
'X-MNote-Display-Name',
...requiredHeaders,
])
);
assertCheck(
'derived user is stable and not a shared singleton',
'derived user is stable per MNote user and not tied to workspace scope',
includesAll(mnoteScope, [
'def _stable_openhub_user_id',
'def _stable_openhub_user_id(user_key: str)',
'mnote_openhub_user_id',
'user_key',
'workspace_key',
'display_name',
'get_user_by_username',
'"openhub_user_id"',
'"openhub_username"',
]) && !mnoteScope.includes('mnote_shared_user')
]) &&
!mnoteScope.includes('def _stable_openhub_user_id(user_key: str, workspace_key: str)') &&
!mnoteScope.includes('_stable_openhub_user_id(user_key, workspace_key)') &&
!mnoteScope.includes('mnote_shared_user')
);
assertCheck(
@@ -78,6 +85,7 @@ assertCheck(
includesAll(mnoteScope, [
'tool_permission_scope',
'weknora_tool_scope',
'provisioned_workspace_path',
'_parse_scope_header',
'"mnote_scope"',
'"source_headers"',
@@ -101,7 +109,7 @@ assertCheck(
'query/session entries consume derived workspace and scope',
query.includes('resolve_user_workspace(current_user)') &&
query.includes('not current_user.get("mnote_host_mode")') &&
query.includes('mnote_scope=get_mnote_scope_metadata(current_user)') &&
query.includes('mnote_scope = get_mnote_scope_metadata(current_user)') &&
session.includes('resolve_user_workspace(current_user)') &&
session.includes('mnote_scope=get_mnote_scope_metadata(current_user)')
);
@@ -5,7 +5,7 @@ const fs = require("node:fs");
const path = require("node:path");
const repoRoot = path.resolve(__dirname, "..");
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/tmp/mnote-openhub-research/OpenHub";
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const backendSessionPath = path.join(openHubRoot, "smart-query-backend/app/api/session.py");
const frontendApiPath = path.join(openHubRoot, "smart-query-frontend/src/services/api.js");
const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx");
@@ -82,6 +82,7 @@ async function readDocumentPaneState(page) {
openhubRefreshMarker: document.documentElement.getAttribute("data-mnote-page-ai-openhub-document-pane-refresh") || "",
eventBusSource: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-source") || "",
eventBusReason: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-reason") || "",
fileChangeSources: window.__mnoteTask779FileChangeSources || [],
documentSessionDebug: window.__mnoteDebugDocumentSessions?.snapshot?.() || null,
};
});
@@ -166,6 +167,12 @@ async function run() {
await waitForEditorText(page, initialText);
await waitForPageAiRuntime(page);
await installOpenHubChangedFileBridge(page);
await page.evaluate(() => {
window.__mnoteTask779FileChangeSources = [];
window.addEventListener("mnote:file-change-batch", (event) => {
window.__mnoteTask779FileChangeSources.push(String(event?.detail?.source || ""));
});
});
debug.before = await readDocumentPaneState(page);
fs.writeFileSync(
@@ -182,7 +189,10 @@ async function run() {
assert(debug.after.editorText.includes(changedText), `document pane 应显示磁盘新内容: ${debug.after.editorText}`);
assert(!debug.after.editorText.includes(initialText), `document pane 不应保留旧正文: ${debug.after.editorText}`);
assert.equal(debug.after.openhubRefreshMarker, relativePath, "应记录 OpenHub document pane refresh marker");
assert.equal(debug.after.eventBusSource, "openhub_changed_file_bridge", "应复用 local-folder event bus synthetic watch batch");
assert(
debug.after.fileChangeSources.some((source) => source.includes("openhub_changed_file_bridge")),
`应通过 FileChangeService 消费 OpenHub changedFiles adapter: ${JSON.stringify(debug.after.fileChangeSources)}`,
);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
const result = {
@@ -7,7 +7,7 @@ const vm = require("node:vm");
const { TextDecoder } = require("node:util");
const repoRoot = path.resolve(__dirname, "..");
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/tmp/mnote-openhub-research/OpenHub";
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const embedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js");
const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx");
const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx");
@@ -6,7 +6,7 @@ const path = require("node:path");
const vm = require("node:vm");
const repoRoot = path.resolve(__dirname, "..");
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/tmp/mnote-openhub-research/OpenHub";
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const backendBridgePath = path.join(openHubRoot, "smart-query-backend/app/services/mnote_weknora.py");
const backendApiPath = path.join(openHubRoot, "smart-query-backend/app/api/mnote_tools.py");
const backendStreamPath = path.join(openHubRoot, "smart-query-backend/app/services/stream.py");
@@ -224,7 +224,10 @@ async function verifyInBrowser(fixture, name) {
url.searchParams.set("treeView", "filetree");
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
const knowledgeEntry = page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]');
assert.equal(await knowledgeEntry.getAttribute("href"), "/knowledge", "主知识库入口应打开 WeKnora 原生 host");
assert.equal(await knowledgeEntry.getAttribute("data-mnote-action"), "open-knowledge-host", "主知识库入口不应再打开旧设置壳");
await page.goto(`${BASE_URL}/debug/knowledge-rag`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]');
await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.equal(await panel.getAttribute("data-knowledge-rag-default-provider"), "weknora", "设置面板应声明 WeKnora provider");
@@ -251,7 +254,7 @@ async function verifyInBrowser(fixture, name) {
const firstSourceInput = panel.locator('[data-knowledge-rag-source-input]').first();
await firstSourceInput.fill(fixture.folderPath);
await panel.locator('[data-knowledge-rag-action="ingest"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').first().click({ timeout: UI_TIMEOUT_MS });
const sourceRows = await Promise.all(fixture.sourcePaths.map(async (sourcePath) => {
const row = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${sourcePath}"]`).first();
await row.waitFor({ state: "visible", timeout: POLL_TIMEOUT_MS });
@@ -5,7 +5,7 @@ const fs = require("node:fs");
const path = require("node:path");
const repoRoot = path.resolve(__dirname, "..");
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/tmp/mnote-openhub-research/OpenHub";
const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const files = {
chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"),
embed: path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js"),
@@ -6,7 +6,7 @@ const path = require("node:path");
const repoRoot = path.resolve(__dirname, "..");
const openHubRoot =
process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/tmp/mnote-openhub-research/OpenHub";
process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub";
const files = {
chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"),
@@ -48,7 +48,7 @@ function check(failures, name, passed, detail = "") {
const chatInput = read(files.chatInput);
const smartQueryPage = read(files.smartQueryPage);
const api = read(files.api);
const controlRow = extractBetween(chatInput, "<Segmented", "<TextArea");
const controlRow = extractBetween(chatInput, "<AgentModeToggle", "{currentTodos");
const handleMNoteSend = extractBetween(chatInput, "const handleSendWithMNoteContext", "useEffect(() =>");
const handleSend = extractBetween(smartQueryPage, "const handleSend = async", "const handleKeyPress");
@@ -91,13 +91,13 @@ check(
check(
failures,
"MNote context buttons are in the controls row beside agent/model controls",
controlRow.includes("<Segmented") &&
controlRow.includes("<AgentModeToggle") &&
controlRow.includes("<ModelSelect") &&
controlRow.includes("data-mnote-openhub-current-tab-toggle") &&
controlRow.includes("data-mnote-openhub-current-folder-toggle") &&
controlRow.indexOf("<Segmented") < controlRow.indexOf("data-mnote-openhub-current-tab-toggle") &&
controlRow.indexOf("<AgentModeToggle") < controlRow.indexOf("data-mnote-openhub-current-tab-toggle") &&
controlRow.indexOf("<ModelSelect") < controlRow.indexOf("data-mnote-openhub-current-folder-toggle"),
"native buttons must be before TextArea and in the same row as agent segmented/model select"
"native buttons must stay in the same compact controls row as agent toggle/model select"
);
check(
@@ -4,7 +4,7 @@
const http = require("node:http");
const { spawn } = require("node:child_process");
const OPENHUB_BACKEND_DIR = process.env.OPENHUB_BACKEND_DIR || "/tmp/mnote-openhub-research/OpenHub/smart-query-backend";
const OPENHUB_BACKEND_DIR = process.env.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend";
const OPENCODE_PORT = Number(process.env.TASK793_OPENCODE_PORT || 19096);
const OPENHUB_PORT = Number(process.env.TASK793_OPENHUB_PORT || 18181);
const SESSION_ID = "ses_mnote_context_fullchain";