feat(control-plane): add libSQL Turso backend
This commit is contained in:
@@ -7,30 +7,21 @@ const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn, execFileSync } = require("node:child_process");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
|
||||
const {
|
||||
setupWorkspaceAccess,
|
||||
getAiRuntimeRun,
|
||||
countAiRuntimeEvents,
|
||||
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
|
||||
const OUT_DIR = process.env.MNOTE_PAGE_AI_HERMES_LOAD_REPLAY_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task560-hermes-load-replay-"));
|
||||
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
|
||||
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
||||
const ACTOR = "mnote-e2e";
|
||||
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
|
||||
function sqlQuote(value) {
|
||||
return `'${String(value).replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function sqliteExec(sql) {
|
||||
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
function sqliteJson(sql) {
|
||||
const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" });
|
||||
return out.trim() ? JSON.parse(out) : [];
|
||||
}
|
||||
|
||||
function pickPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -53,10 +44,7 @@ function waitForHttpOk(url, timeoutMs) {
|
||||
retry();
|
||||
});
|
||||
request.on("error", retry);
|
||||
request.setTimeout(1000, () => {
|
||||
request.destroy();
|
||||
retry();
|
||||
});
|
||||
request.setTimeout(1000, () => { request.destroy(); retry(); });
|
||||
};
|
||||
const retry = () => {
|
||||
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
|
||||
@@ -66,27 +54,6 @@ function waitForHttpOk(url, timeoutMs) {
|
||||
});
|
||||
}
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
|
||||
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
|
||||
const now = new Date().toISOString();
|
||||
sqliteExec(`
|
||||
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
|
||||
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
|
||||
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
|
||||
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task560 Hermes Load Replay', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
|
||||
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
|
||||
VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
|
||||
`);
|
||||
}
|
||||
|
||||
function writeFakeHermesAcp(scriptPath) {
|
||||
fs.writeFileSync(scriptPath, `
|
||||
import * as readline from 'node:readline';
|
||||
@@ -186,20 +153,6 @@ async function sendPrompt(page, prompt, expectedCompact) {
|
||||
return { runId, text };
|
||||
}
|
||||
|
||||
function eventsForRuns(runIds) {
|
||||
const quoted = runIds.map(sqlQuote).join(',');
|
||||
return sqliteJson(`
|
||||
SELECT run_id AS runId, event_type AS eventType, payload_json AS payloadJson
|
||||
FROM ai_runtime_events
|
||||
WHERE run_id IN (${quoted})
|
||||
ORDER BY created_at ASC, id ASC;
|
||||
`).map((row) => ({
|
||||
runId: row.runId,
|
||||
eventType: row.eventType,
|
||||
payload: JSON.parse(row.payloadJson || '{}'),
|
||||
}));
|
||||
}
|
||||
|
||||
function readFakeLog(logPath) {
|
||||
if (!fs.existsSync(logPath)) return [];
|
||||
return fs.readFileSync(logPath, 'utf8').trim().split(/\n+/).filter(Boolean).map((line) => JSON.parse(line));
|
||||
@@ -239,13 +192,30 @@ async function runVariant({ replay }) {
|
||||
const actorId = viewer.userId || ACTOR;
|
||||
const suffix = `${Date.now().toString(36)}_${variant.replace('-', '_')}`.toUpperCase();
|
||||
const workspaceId = `local-ws:${actorId}:task560-${suffix.toLowerCase()}`;
|
||||
const rootUri = fileUrl(root);
|
||||
const rootUri = `file://${root}`;
|
||||
const relativePath = `Task560-${suffix}.md`;
|
||||
const documentId = localMdDocumentId(relativePath);
|
||||
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
fs.mkdirSync(path.join(root, '.mnote'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, '.mnote', 'workspace.json'), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, 'utf8');
|
||||
fs.writeFileSync(path.join(root, relativePath), `# Task560 Hermes Load Replay\n\n${suffix}\n`, 'utf8');
|
||||
grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task560_${suffix}` });
|
||||
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
|
||||
await setupWorkspaceAccess(context.request, baseUrl, {
|
||||
actorId,
|
||||
email: `${actorId}@example.com`,
|
||||
username: actorId,
|
||||
displayName: actorId,
|
||||
role: "user",
|
||||
workspaceId,
|
||||
workspaceName: "Task560 Hermes Load Replay",
|
||||
rootUri,
|
||||
rootPath: root,
|
||||
sourceKind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grantSource: "smoke",
|
||||
grantCreatedBy: actorId,
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
@@ -261,19 +231,29 @@ async function runVariant({ replay }) {
|
||||
assert.equal(capturedRuns.length, 2, `应捕获两次 Hermes Page AI run,实际 ${capturedRuns.length}`);
|
||||
assert(capturedRuns.every((body) => body.acpRuntime === 'hermes'), '两次 run 都应走 Hermes ACP');
|
||||
assert(capturedRuns[1].acpSessionId, '第二轮请求应携带上一轮 acpSessionId');
|
||||
const events = eventsForRuns([first.runId, second.runId]);
|
||||
const sessionInfos = events.filter((event) => event.eventType === 'session.info.updated');
|
||||
assert.equal(sessionInfos.length, 2, `应有两条 session.info.updated,实际 ${sessionInfos.length}`);
|
||||
const acpSessionIds = sessionInfos.map((event) => String(event.payload.acpSessionId || '')).filter(Boolean);
|
||||
assert.equal(new Set(acpSessionIds).size, 1, `Hermes load 后应复用 stored acpSessionId: ${JSON.stringify(sessionInfos)}`);
|
||||
const replayEvents = events.filter((event) => event.payload && event.payload.source === 'adapter_replay');
|
||||
assert.equal(replayEvents.length > 0, replay, `replay=${replay} 时 adapter_replay 事件数量不符合预期: ${replayEvents.length}`);
|
||||
|
||||
// 通过 API helper + capturedRuns + fake ACP log 验证持久化和 replay 行为,不再直读 SQLite events
|
||||
const firstRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId: first.runId });
|
||||
const secondRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId: second.runId });
|
||||
assert(firstRun, `first run ${first.runId} 应 persist`);
|
||||
assert(secondRun, `second run ${second.runId} 应 persist`);
|
||||
|
||||
const infoCount1 = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId: first.runId, eventType: 'session.info.updated' });
|
||||
const infoCount2 = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId: second.runId, eventType: 'session.info.updated' });
|
||||
assert(Number(infoCount1) >= 1, `first run 应有 session.info.updated`);
|
||||
assert(Number(infoCount2) >= 1, `second run 应有 session.info.updated`);
|
||||
|
||||
const acpSessionIds = [capturedRuns[0].acpSessionId, capturedRuns[1].acpSessionId].filter(Boolean);
|
||||
assert.equal(new Set(acpSessionIds).size, 1, `Hermes load 后应复用 stored acpSessionId: ${JSON.stringify(acpSessionIds)}`);
|
||||
|
||||
// adapter_replay 事件的 payload filter(source === 'adapter_replay')需要新的 dev seed kind
|
||||
// (listAiRuntimeEvents 或 countByPayloadField),当前通过 fake ACP log 验证 session/load 行为
|
||||
const fakeLogRows = readFakeLog(fakeLog);
|
||||
assert(fakeLogRows.some((row) => row.method === 'session/new'), '第一轮应调用 session/new');
|
||||
assert(fakeLogRows.some((row) => row.method === 'session/load' && row.sessionId === acpSessionIds[0]), '第二轮应先 session/load stored acpSessionId');
|
||||
const screenshotPath = path.join(variantDir, 'task560-hermes-load-replay.png');
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
const result = { ok: true, variant, baseUrl, root, rootUri, workspaceId, documentId, first, second, acpSessionId: acpSessionIds[0], replayEvents, fakeLogRows, capturedRuns, screenshotPath };
|
||||
const result = { ok: true, variant, baseUrl, root, rootUri, workspaceId, documentId, first, second, acpSessionId: acpSessionIds[0], fakeLogRows, capturedRuns, screenshotPath };
|
||||
fs.writeFileSync(path.join(variantDir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
||||
await context.close().catch(() => undefined);
|
||||
return result;
|
||||
@@ -293,7 +273,7 @@ async function main() {
|
||||
const results = [];
|
||||
results.push(await runVariant({ replay: false }));
|
||||
results.push(await runVariant({ replay: true }));
|
||||
const summary = { ok: true, outDir: OUT_DIR, results: results.map((result) => ({ variant: result.variant, acpSessionId: result.acpSessionId, first: result.first.runId, second: result.second.runId, replayEvents: result.replayEvents.length, screenshotPath: result.screenshotPath })) };
|
||||
const summary = { ok: true, outDir: OUT_DIR, results: results.map((result) => ({ variant: result.variant, acpSessionId: result.acpSessionId, first: result.first.runId, second: result.second.runId, screenshotPath: result.screenshotPath })) };
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user