feat(control-plane): add libSQL Turso backend
This commit is contained in:
@@ -5,51 +5,20 @@ const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
|
||||
const {
|
||||
setupWorkspaceAccess,
|
||||
getAiRuntimeRun,
|
||||
countAiRuntimeEvents,
|
||||
} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed"));
|
||||
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
|
||||
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
|
||||
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_LIVE_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task558-reasonix-live-"));
|
||||
const ACTOR = "mnote-e2e";
|
||||
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
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 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)}, 'Task558 Reasonix Live', '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);
|
||||
`);
|
||||
}
|
||||
|
||||
async function signIn(context) {
|
||||
const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
|
||||
method: "POST",
|
||||
@@ -124,36 +93,13 @@ async function waitUntil(label, predicate, timeoutMs = UI_TIMEOUT_MS) {
|
||||
throw new Error(`${label}_timeout: ${String(last && last.message || last || '')}`);
|
||||
}
|
||||
|
||||
function sessionInfoForRuns(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}) AND event_type = 'session.info.updated'
|
||||
ORDER BY created_at ASC, id ASC;
|
||||
`).map((row) => ({
|
||||
runId: row.runId,
|
||||
eventType: row.eventType,
|
||||
payload: JSON.parse(row.payloadJson || "{}"),
|
||||
}));
|
||||
}
|
||||
|
||||
function runtimeRunsForSession(sessionId) {
|
||||
return sqliteJson(`
|
||||
SELECT run_id AS runId, status
|
||||
FROM ai_runtime_runs
|
||||
WHERE session_id=${sqlQuote(sessionId)} AND run_id LIKE 'run_%'
|
||||
ORDER BY created_at ASC;
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const suffix = Date.now().toString(36);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task558-reasonix-live-"));
|
||||
const rootUri = fileUrl(root);
|
||||
const rootUri = `file://${root}`;
|
||||
const relativePath = `Task558-${suffix}.md`;
|
||||
const documentId = localMdDocumentId(relativePath);
|
||||
const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
|
||||
const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
@@ -175,7 +121,24 @@ async function main() {
|
||||
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), `# Task558 Reasonix Live\n\n${suffix}\n`, "utf8");
|
||||
grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task558_${suffix}` });
|
||||
// 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite
|
||||
await setupWorkspaceAccess(context.request, BASE_URL, {
|
||||
actorId,
|
||||
email: `${actorId}@example.com`,
|
||||
username: actorId,
|
||||
displayName: actorId,
|
||||
role: "user",
|
||||
workspaceId,
|
||||
workspaceName: "Task558 Reasonix Live",
|
||||
rootUri,
|
||||
rootPath: root,
|
||||
sourceKind: "local_folder",
|
||||
permission: "write",
|
||||
capabilities: ["ai"],
|
||||
grantSource: "smoke",
|
||||
grantCreatedBy: actorId,
|
||||
timeoutMs: UI_TIMEOUT_MS,
|
||||
});
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", rootUri);
|
||||
@@ -188,7 +151,7 @@ async function main() {
|
||||
const secondMarker = `TASK558_SECOND_${suffix}`.toUpperCase();
|
||||
const beforeAssistantCount = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count();
|
||||
await page.locator("[data-page-ai-input]").fill(
|
||||
`Reasonix 上下文连续性测试:请记住如果下一轮用户只说“可以”,你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`,
|
||||
`Reasonix 上下文连续性测试:请记住如果下一轮用户只说"可以",你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
@@ -229,14 +192,24 @@ async function main() {
|
||||
assert(capturedRuns.every((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"), "两次 run 都应走 Reasonix ACP");
|
||||
assert(capturedRuns[1].acpSessionId, "第二轮请求应携带上一轮 acpSessionId");
|
||||
|
||||
const runtimeRuns = runtimeRunsForSession(capturedRuns[0].sessionId);
|
||||
assert(runtimeRuns.length >= 2, `应有至少两条 runtime run: ${JSON.stringify(runtimeRuns)}`);
|
||||
const first = { runId: runtimeRuns[0].runId, text: firstMarker };
|
||||
const second = { runId: runtimeRuns[1].runId, text: secondMarker };
|
||||
const infos = sessionInfoForRuns([first.runId, second.runId]);
|
||||
assert.equal(infos.length, 2, `应有两条 session.info.updated,实际 ${infos.length}: ${JSON.stringify(infos)}`);
|
||||
const acpSessionIds = infos.map((info) => String(info.payload.acpSessionId || "")).filter(Boolean);
|
||||
assert.equal(new Set(acpSessionIds).size, 1, `两轮 Reasonix 应复用同一 acpSessionId: ${JSON.stringify(infos)}`);
|
||||
// 通过 API helper + capturedRuns 验证 DB 持久化和 acpSessionId 连续性,不再直读 SQLite
|
||||
const firstRunId = capturedRuns[0].runId;
|
||||
const secondRunId = capturedRuns[1].runId;
|
||||
const first = { runId: firstRunId, text: firstMarker };
|
||||
const second = { runId: secondRunId, text: secondMarker };
|
||||
|
||||
const firstRun = await getAiRuntimeRun(context.request, BASE_URL, { userId: actorId, runId: firstRunId });
|
||||
const secondRun = await getAiRuntimeRun(context.request, BASE_URL, { userId: actorId, runId: secondRunId });
|
||||
assert(firstRun, `first run ${firstRunId} 应 persist`);
|
||||
assert(secondRun, `second run ${secondRunId} 应 persist`);
|
||||
|
||||
const infoCount1 = await countAiRuntimeEvents(context.request, BASE_URL, { userId: actorId, runId: firstRunId, eventType: "session.info.updated" });
|
||||
const infoCount2 = await countAiRuntimeEvents(context.request, BASE_URL, { userId: actorId, runId: secondRunId, 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, `两轮 Reasonix 应复用同一 acpSessionId: ${JSON.stringify(acpSessionIds)}`);
|
||||
assert.equal(capturedRuns[1].acpSessionId, acpSessionIds[0], "第二轮请求 acpSessionId 应等于 live session id");
|
||||
|
||||
const screenshotPath = path.join(OUT_DIR, "task558-reasonix-live.png");
|
||||
@@ -254,7 +227,6 @@ async function main() {
|
||||
queuedPreview,
|
||||
acpSessionId: acpSessionIds[0],
|
||||
capturedRuns: capturedRuns.map((body) => ({ message: body.message, acpRuntime: body.acpRuntime, profile: body.profile, acpSessionId: body.acpSessionId || "" })),
|
||||
sessionInfo: infos,
|
||||
screenshotPath,
|
||||
};
|
||||
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
|
||||
Reference in New Issue
Block a user