feat: 收口 Rust Web 3000 主链

This commit is contained in:
lix-2026
2026-05-11 13:16:34 +08:00
parent 7f3f7d4e2f
commit 17c003976b
61 changed files with 2090 additions and 2256 deletions
@@ -219,18 +219,19 @@ describe("/api/ai-agent/run route", () => {
},
);
it("provider=codex 应进入 Codex host 并返回 codex_session", async () => {
it.each(["codex", "hermes", "claudecode"] as const)(
"provider=%s 已退场,必须明确失败且不能静默进入 mnote-cli",
async (provider) => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "#chat 继续检查" }],
messages: [{ role: "user", content: "继续检查" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "codex",
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2",
provider,
},
},
});
@@ -241,104 +242,18 @@ describe("/api/ai-agent/run route", () => {
userId: "user-1",
},
});
mockStartCodexJsonRun.mockImplementation(({ onJsonLine }) => {
onJsonLine?.({ type: "thread.started", thread_id: "codex-thread-1" });
return {
done: Promise.resolve({ ok: true, threadId: "codex-thread-1", text: "Codex 已回复" }),
kill: vi.fn(),
};
});
const { POST } = await import("./route");
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
const text = await response.text();
expect(response.status).toBe(200);
expect(response.status).toBe(410);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
expect(mockStartCodexJsonRun).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2",
}),
);
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe("codex");
expect(text).toContain("event: codex_session");
expect(text).toContain("codex-thread-1");
expect(text).toContain("Codex 已回复");
});
it("provider=hermes 应进入 Hermes API bridge", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "hermes",
sessionId: "hermes-session-1",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartHermesRun.mockResolvedValue({ runId: "hermes-run-1" });
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({ event: "message.delta", delta: "Hermes " });
await onEvent({ event: "run.completed", output: "Hermes 已回复" });
});
const { POST } = await import("./route");
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
session_id: "hermes-session-1",
}),
);
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe("hermes");
expect(text).toContain("Hermes 已回复");
});
it("provider=claudecode 未接桥时必须明确报错,不能静默进入 mnote-cli", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "ping" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "claudecode",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
const { POST } = await import("./route");
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
const text = await response.text();
expect(response.status).toBe(501);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
expect(text).toContain("ClaudeCode");
});
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe(`${provider}-retired`);
expect(mockStartCodexJsonRun).not.toHaveBeenCalled();
expect(mockStartHermesRun).not.toHaveBeenCalled();
expect(text).toContain(provider);
},
);
it("未登录时不应启动 mnote-cli host", async () => {
mockSafeGetJsonBody.mockResolvedValue({
@@ -1,6 +1,4 @@
import { errorResponses, safeGetJsonBody, validateRequestBody } from "@/lib/api-utils";
import { codexMessagesToPrompt, findWorkspaceRoot, startCodexJsonRun } from "@/lib/ai/codex/codexExec";
import { startHermesRun, streamHermesRunEvents } from "@/lib/ai-agent/hermes/bridge";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
@@ -11,120 +9,6 @@ import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
const toSseFrame = (event: string, data: unknown) => {
const json = JSON.stringify(data ?? null);
return `event: ${event}\ndata: ${json}\n\n`;
};
const lastUserMessage = (payload: MnoteCliAgentRunPayload) => {
const found = [...payload.messages].reverse().find((message) => message.role === "user");
return String(found?.content ?? "");
};
const codexSandboxForPayload = (payload: MnoteCliAgentRunPayload) =>
/^\s*#dev\b/i.test(lastUserMessage(payload)) ? "workspace-write" : "read-only";
async function startCodexAgentRun(payload: MnoteCliAgentRunPayload): Promise<Response> {
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(toSseFrame(event, data)));
send("ready", { ok: true, bridgeOwner: "codex" });
try {
const cwd = await findWorkspaceRoot(process.cwd());
const prompt = codexMessagesToPrompt(payload.messages);
const run = startCodexJsonRun({
cwd,
sandbox: codexSandboxForPayload(payload),
prompt,
model: payload.options?.ai?.model,
sessionId: payload.options?.ai?.sessionId,
onJsonLine: (line) => {
if (line.type === "thread.started" && typeof line.thread_id === "string" && line.thread_id.trim()) {
send("codex_session", { sessionId: line.thread_id.trim() });
}
},
});
const result = await run.done;
if (!result.ok) {
send("error", { ok: false, message: result.error || "Codex 执行失败" });
return;
}
const sessionId = result.threadId.trim();
if (sessionId) send("codex_session", { sessionId });
send("assistant_message", { text: result.text || "(无输出)" });
send("completion", { ok: true, text: result.text || "(无输出)", steps: 1 });
} catch (error) {
send("error", { ok: false, message: error instanceof Error ? error.message : String(error) });
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
"x-mnote-ai-execution-owner": "codex",
},
});
}
async function startHermesAgentRun(payload: MnoteCliAgentRunPayload): Promise<Response> {
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(toSseFrame(event, data)));
send("ready", { ok: true, bridgeOwner: "hermes" });
try {
const started = await startHermesRun({
input: payload.messages.map((message) => ({
role: message.role,
content: message.content,
})),
conversation_history: payload.messages.slice(0, -1).map((message) => ({
role: message.role,
content: message.content,
})),
session_id: payload.options?.ai?.sessionId,
});
let assistantText = "";
await streamHermesRunEvents(started.runId, (event) => {
if (event.event === "message.delta" && typeof event.delta === "string") {
assistantText += event.delta;
send("assistant_delta", { text: event.delta });
}
if (event.event === "run.completed") {
const output = typeof event.output === "string" && event.output.trim() ? event.output.trim() : assistantText.trim();
send("assistant_message", { text: output || "(无输出)" });
send("completion", { ok: true, text: output || "(无输出)", steps: 1 });
}
if (event.event === "run.failed") {
send("error", { ok: false, message: event.error || "Hermes 执行失败" });
}
});
} catch (error) {
send("error", { ok: false, message: error instanceof Error ? error.message : String(error) });
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
"x-mnote-ai-execution-owner": "hermes",
},
});
}
export async function POST(request: Request) {
const payload = await safeGetJsonBody<MnoteCliAgentRunPayload>(request);
if (!payload) {
@@ -150,19 +34,15 @@ export async function POST(request: Request) {
}
const provider = String(payload.options?.ai?.provider ?? "").trim().toLowerCase();
if (provider === "codex") {
return startCodexAgentRun(payload);
}
if (provider === "hermes") {
return startHermesAgentRun(payload);
}
if (provider === "claudecode") {
if (provider === "codex" || provider === "hermes" || provider === "claudecode") {
return NextResponse.json(
{ error: "ClaudeCode bridge 尚未接入,不能静默降级到 mnote-cli。" },
{
status: 501,
error: `${provider} 已退出默认系统组件,当前只保留 mnote-cli host 主执行入口。`,
},
{
status: 410,
headers: {
"x-mnote-ai-execution-owner": "claudecode-unavailable",
"x-mnote-ai-execution-owner": `${provider}-retired`,
},
},
);
@@ -1,266 +1,18 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: vi.fn(() => true),
}));
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: vi.fn(async () => ({
userId: "user_1",
})),
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(async () => ({
auth: { userId: "user_1" },
client: {
query: vi.fn(async () => ({
id: "doc_1",
workspace_id: "ws_1",
title: "Next fallback 页面",
updated_at: null,
can_edit: true,
})),
},
})),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeQueryPlan: vi.fn(async () => ({
kind: "query",
queryName: "documents.content.get",
functionName: "documents:getContent",
workspaceId: "ws_1",
requestId: "req_next",
traceId: "trace_next",
actorId: "user_1",
payloadJson: "{}",
argsJson: { id: "doc_1" },
})),
executeRustBridgeQueryTransport: vi.fn(async () => ({
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 1,
conflict_detection_key: "doc_1:1",
pageSubtree: null,
})),
}));
vi.mock("@/lib/mnote-web/internal-url", () => ({
resolveMnoteWebInternalUrl: vi.fn(async () => "http://127.0.0.1:3104"),
}));
import { describe, expect, it } from "vitest";
import { GET } from "@/app/api/documents/page/route";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
function rustPageAggregateSnapshot() {
return {
schema: "mnote.page_aggregate.v1" as const,
projectionVersion: 1,
source: "KernelProjection",
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: {
title: "Rust 聚合页面",
updatedAt: null,
permissions: {
readOnly: false,
disableDownload: false,
disableCopy: false,
},
},
layout: { pageOptions: { wideLayout: false } },
body: { content: [], revision: 7, conflictDetectionKey: "doc_1:7" },
tree: { pageSubtree: null },
stats: { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 },
};
}
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe("documents/page route", () => {
it("优先返回 Rust page aggregate snapshot,而不是重新组装 meta + content", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
schema: "mnote.page_aggregate.v1",
result: rustPageAggregateSnapshot(),
requestId: "req_rust",
traceId: "trace_rust",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
it("compat 读链应明确返回 410,并指向 page aggregate 正式路由", async () => {
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"authorization": "Bearer route-token",
"cookie": "convex-auth=test-cookie",
"x-request-id": "req_route",
"x-trace-id": "trace_route",
"x-session-id": "sess_route",
"x-source-channel": "next-route",
"x-source-client": "vitest",
},
}),
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),
);
const payload = await response.json() as {
page: { schema?: string; identity: { documentId: string }; head: { title: string } };
meta: { requestId: string; traceId: string; queryName: string };
error: string;
redirectTo: string;
};
expect(response.status).toBe(200);
expect(resolveMnoteWebInternalUrl).toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/page-aggregate/doc_1?workspaceId=ws_1",
expect.objectContaining({
method: "GET",
cache: "no-store",
signal: expect.any(AbortSignal),
headers: expect.any(Headers),
}),
);
const fetchHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("accept")).toBe("application/json");
expect(fetchHeaders.get("authorization")).toBe("Bearer route-token");
expect(fetchHeaders.get("cookie")).toBe("convex-auth=test-cookie");
expect(fetchHeaders.get("x-request-id")).toBe("req_route");
expect(fetchHeaders.get("x-mnote-request-id")).toBe("req_route");
expect(fetchHeaders.get("x-trace-id")).toBe("trace_route");
expect(fetchHeaders.get("x-mnote-trace-id")).toBe("trace_route");
expect(fetchHeaders.get("x-session-id")).toBe("sess_route");
expect(fetchHeaders.get("x-mnote-session-id")).toBe("sess_route");
expect(fetchHeaders.get("x-source-channel")).toBe("next-route");
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next-route");
expect(fetchHeaders.get("x-source-client")).toBe("vitest");
expect(fetchHeaders.get("x-mnote-source-client")).toBe("vitest");
expect(getAuthedConvexClient).not.toHaveBeenCalled();
expect(payload.page.schema).toBe("mnote.page_aggregate.v1");
expect(payload.page.identity.documentId).toBe("doc_1");
expect(payload.page.head.title).toBe("Rust 聚合页面");
expect(payload.meta).toEqual({
requestId: "req_rust",
traceId: "trace_rust",
queryName: "documents.page.get",
});
});
it("Rust snapshot 返回畸形 projection 时保留 TS builder fallback", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
schema: "mnote.page_aggregate.v1",
result: {
schema: "mnote.page_aggregate.v1",
projectionVersion: 1,
source: "KernelProjection",
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: { title: "错误页面", updatedAt: null },
layout: {},
body: { content: [], revision: "bad_revision", conflictDetectionKey: 7 },
tree: { pageSubtree: null },
stats: null,
},
requestId: "req_bad",
traceId: "trace_bad",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"x-request-id": "req_next",
"x-trace-id": "trace_next",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; head: { title: string }; body: { revision: number | null } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(getAuthedConvexClient).toHaveBeenCalled();
expect(payload.page.schema).toBeUndefined();
expect(payload.page.head.title).toBe("Next fallback 页面");
expect(payload.page.body.revision).toBe(1);
expect(payload.meta).toEqual({
requestId: "req_next",
traceId: "trace_next",
queryName: "documents.page.get",
});
});
it("Rust internal base 不可信时直接 fallback,且不会外发凭据", async () => {
vi.mocked(resolveMnoteWebInternalUrl).mockResolvedValueOnce("https://example.com");
const fetchMock = vi.spyOn(globalThis, "fetch");
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"authorization": "Bearer route-token",
"cookie": "convex-auth=test-cookie",
"x-request-id": "req_untrusted",
"x-trace-id": "trace_untrusted",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; head: { title: string } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(fetchMock).not.toHaveBeenCalled();
expect(getAuthedConvexClient).toHaveBeenCalled();
expect(payload.page.schema).toBeUndefined();
expect(payload.page.head.title).toBe("Next fallback 页面");
expect(payload.meta).toEqual({
requestId: "req_untrusted",
traceId: "trace_untrusted",
queryName: "documents.page.get",
});
});
it("Rust snapshot fetch 抛错时保留 TS builder fallback", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"x-request-id": "req_timeout",
"x-trace-id": "trace_timeout",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; head: { title: string } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(getAuthedConvexClient).toHaveBeenCalled();
expect(payload.page.schema).toBeUndefined();
expect(payload.page.head.title).toBe("Next fallback 页面");
expect(payload.meta).toEqual({
requestId: "req_timeout",
traceId: "trace_timeout",
queryName: "documents.page.get",
});
expect(response.status).toBe(410);
expect(payload.error).toContain("/api/page-aggregate/:documentId");
expect(payload.redirectTo).toBe("/api/page-aggregate/doc_1?workspaceId=ws_1");
});
});
@@ -1,48 +1,24 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(request: Request) {
if (isConvexEnabled()) {
try {
const url = new URL(request.url);
const documentId = assertDocumentId(url.searchParams.get("documentId"));
const workspaceId = url.searchParams.get("workspaceId")?.trim() || null;
const loaded = await loadPageAggregate({
request,
documentId,
workspaceId,
});
if (!loaded) {
return NextResponse.json(
{
error: "页面不存在",
meta: {
requestId: "unknown",
traceId: "unknown",
queryName: "documents.page.get",
},
},
{ status: 404 },
);
}
return NextResponse.json({
page: loaded.page,
meta: loaded.bridge,
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
const url = new URL(request.url);
const documentId = url.searchParams.get("documentId")?.trim() || "";
const workspaceId = url.searchParams.get("workspaceId")?.trim() || "";
const redirectTo = new URL(
`/api/page-aggregate/${encodeURIComponent(documentId || ":documentId")}`,
request.url,
);
if (workspaceId) {
redirectTo.searchParams.set("workspaceId", workspaceId);
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
return NextResponse.json(
{
error: "Next /api/documents/page compat 读链已退场,请直接使用 /api/page-aggregate/:documentId。",
redirectTo: redirectTo.pathname + redirectTo.search,
},
{ status: 410 },
);
}
@@ -43,46 +43,18 @@ vi.mock("@/lib/documents/page-write-command-adapter", () => ({
})),
}));
vi.mock("@/lib/documents/page-aggregate-loader", () => ({
loadPageAggregate: vi.fn(async () => ({
page: {
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: {
title: "页面标题",
updatedAt: null,
permissions: {
readOnly: false,
disableDownload: false,
disableCopy: false,
},
},
layout: { pageOptions: { wideLayout: false } },
body: { content: null, revision: 0, conflictDetectionKey: "doc_1:0" },
tree: { pageSubtree: null },
stats: null,
},
bridge: {
requestId: "req_1",
traceId: "trace_1",
queryName: "documents.page.get",
},
})),
}));
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
import { POST as postTemplate } from "@/app/api/documents/template/route";
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
import { POST as postTitle } from "@/app/api/documents/title/route";
import { POST as postOptions } from "@/app/api/documents/options/route";
import { POST as postSave } from "@/app/api/documents/save/route";
import { GET as getPage } from "@/app/api/documents/page/route";
import {
executeDocumentCreateChildBridgeCommand,
executeDocumentTemplateBridgeCommand,
executeDocumentEmptyTrashBridgeCommand,
} from "@/lib/documents/page-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
afterEach(() => {
vi.restoreAllMocks();
@@ -90,62 +62,26 @@ afterEach(() => {
});
describe("documents route adapters", () => {
it("title route 在树重命名兼容请求下委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_rename_1",
traceId: "trace_tree_rename_1",
result: {
action: "rename",
workspaceId: "ws_1",
documentId: "doc_1",
title: "新标题",
updatedAt: "2026-04-23T00:00:00Z",
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
it("title route 在缺少 page head commandName 时返回校验错误", async () => {
const response = await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST",
headers: {
"authorization": "Bearer test-token",
"content-type": "application/json",
"cookie": "convex-auth=test-cookie",
},
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
}));
const payload = await response.json() as {
ok: boolean;
meta: { commandName: string };
})) as {
message: string;
status: number;
details?: { code?: string; details?: Array<{ field: string; reason: string }> };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "rename",
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
}),
}),
);
const renameCall = fetchMock.mock.calls.find(([, init]) => {
if (!init || typeof init.body !== "string") {
return false;
}
return init.body.includes('"action":"rename"');
expect(response.status).toBe(400);
expect(response.message).toBe("标题保存仅支持 page.head.updateTitle");
expect(response.details).toEqual({
code: "VALIDATION_ERROR",
details: [{ field: "commandName", reason: "expected page.head.updateTitle" }],
});
const forwardedHeaders = renameCall?.[1]?.headers as Headers;
expect(forwardedHeaders.get("authorization")).toBe("Bearer test-token");
expect(forwardedHeaders.get("cookie")).toBe("convex-auth=test-cookie");
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.rename");
expect(executePageWriteBridgeCommand).not.toHaveBeenCalled();
});
it("creates child route delegates to unified adapter", async () => {
@@ -172,21 +108,6 @@ describe("documents route adapters", () => {
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
});
it("page route delegates to unified aggregate loader", async () => {
const response = await getPage(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),
);
const payload = await response.json() as {
page: { identity: { documentId: string } };
meta: { queryName: string };
};
expect(loadPageAggregate).toHaveBeenCalled();
expect(response.status).toBe(200);
expect(payload.page.identity.documentId).toBe("doc_1");
expect(payload.meta.queryName).toBe("documents.page.get");
});
it("title route 在 page head 请求下仍委托 unified page write adapter", async () => {
await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST",
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
DocumentBridgeError,
assertDocumentId,
assertTitle,
buildDocumentBridgeContext,
@@ -20,10 +21,16 @@ interface RenamePayload {
commandName?: string | null;
}
type TreeRenameResponse = {
requestId?: string;
traceId?: string;
};
function assertPageHeadCommandName(commandName: string | null | undefined) {
if (commandName !== PAGE_COMMAND_NAMES.updateTitle) {
throw new DocumentBridgeError("标题保存仅支持 page.head.updateTitle", 400, "VALIDATION_ERROR", [
{
field: "commandName",
reason: `expected ${PAGE_COMMAND_NAMES.updateTitle}`,
},
]);
}
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
@@ -32,53 +39,7 @@ export async function POST(request: Request) {
const normalizedDocumentId = assertDocumentId(documentId);
const normalizedTitle = assertTitle(title);
const normalizedWorkspaceId = workspaceId?.trim() || null;
if (commandName !== PAGE_COMMAND_NAMES.updateTitle) {
const upstreamUrl = new URL("/api/tree/commands", request.url);
const upstreamHeaders = new Headers({
"content-type": "application/json",
});
const authorization = request.headers.get("authorization");
const cookie = request.headers.get("cookie");
if (authorization) {
upstreamHeaders.set("authorization", authorization);
}
if (cookie) {
upstreamHeaders.set("cookie", cookie);
}
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: upstreamHeaders,
body: JSON.stringify({
action: "rename",
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
title: normalizedTitle,
}),
});
const payload = (await response.json().catch(() => null)) as TreeRenameResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "重命名失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.rename",
},
});
}
assertPageHeadCommandName(commandName);
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({
@@ -119,7 +119,7 @@ describe("/api/mnote-web/stream route", () => {
mockStreamTreeFrames.mockReset().mockReturnValue(makeFrames());
});
it("应在 3000 route 内直接生成 SSE,不再代理 mnote-web:3104", async () => {
it("应返回 410,明确要求改用 /api/tree/events,不再代理 mnote-web:3104", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const { GET } = await import("./route");
@@ -130,27 +130,21 @@ describe("/api/mnote-web/stream route", () => {
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("x-mnote-web-owner")).toBe("mnote-web");
expect(response.headers.get("x-mnote-tree-stream-owner")).toBe("rust-web");
expect(response.headers.get("x-mnote-compat-boundary")).toBe("mnote-web-stream-alias");
expect(await response.text()).toContain("event: snapshot");
expect(response.status).toBe(410);
expect(response.headers.get("x-mnote-compat-boundary")).toBe(
"mnote-web-stream-alias-retired",
);
expect(await response.json()).toMatchObject({
error: "Next /api/mnote-web/stream compat alias 已退场,请直接使用 /api/tree/events。",
redirectTo:
"http://127.0.0.1:3000/api/tree/events?workspaceId=ws_1&cursor=evt_9&rootNodeId=page_root&pollMs=500&maxPolls=0",
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockExecuteRustBridgeQuery).not.toHaveBeenCalled();
expect(mockStreamTreeFrames).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "ws_1",
rootNodeId: "page_root",
initialCursor: "evt_9",
pollMs: 500,
maxPolls: 0,
}),
);
expect(mockStreamTreeFrames).not.toHaveBeenCalled();
});
it("Convex 未启用时返回 501,而不是探测 3104", async () => {
it("Convex 未启用时返回 retired alias,不探测 3104", async () => {
mockIsConvexEnabled.mockReturnValue(false);
const fetchSpy = vi.spyOn(globalThis, "fetch");
@@ -161,8 +155,11 @@ describe("/api/mnote-web/stream route", () => {
}),
);
expect(response.status).toBe(501);
expect(response.status).toBe(410);
expect(fetchSpy).not.toHaveBeenCalled();
expect(await response.json()).toEqual({ error: "当前仅支持 Convex 模式" });
expect(await response.json()).toMatchObject({
error: "Next /api/mnote-web/stream compat alias 已退场,请直接使用 /api/tree/events。",
redirectTo: "http://127.0.0.1:3000/api/tree/events?workspaceId=ws_1",
});
});
});
@@ -1,165 +1,21 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContextWithActor,
buildDocumentQueryEnvelope,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
import { attachKernelFileTreeProjection, resolveKernelFileTreeProjection } from "@/lib/server/kernel-file-tree";
import {
streamTreeFrames,
type TreeStreamOverview,
type TreeStreamSnapshotPayload,
} from "@/lib/tree-stream/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function readNumberParam(url: URL, name: string): number | null {
const raw = url.searchParams.get(name);
if (!raw?.trim()) {
return null;
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
function encodeSseFrame(event: string, payload: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
}
export async function GET(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
const requestUrl = new URL(request.url);
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { auth, client } = await getAuthedConvexClient();
const actor = {
actorType: "user",
actorId: auth.userId,
sessionId: null,
};
const context = buildDocumentBridgeContextWithActor({
request,
actor,
workspaceId,
source: {
channel: "next_mnote_web_stream",
client: "wolai-frontend",
const directUrl = new URL("/api/tree/events", request.url);
directUrl.search = new URL(request.url).search;
return NextResponse.json(
{
error: "Next /api/mnote-web/stream compat alias 已退场,请直接使用 /api/tree/events。",
redirectTo: directUrl.toString(),
},
});
const loadOverview = async (): Promise<TreeStreamOverview> => {
const envelope = buildDocumentQueryEnvelope({
name: "bridge.workspace.overview",
payload: {
workspaceId,
limit: 50,
cursor: null,
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
{
status: 410,
headers: {
"x-mnote-compat-boundary": "mnote-web-stream-alias-retired",
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
return executeRustBridgeQueryTransport<TreeStreamOverview>({
client,
plan,
});
};
const loadSnapshot = async (): Promise<TreeStreamSnapshotPayload> => {
const envelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
const dataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
client,
plan,
});
const datasetWithFileTree = attachKernelFileTreeProjection({
dataset,
projection: await resolveKernelFileTreeProjection({
client,
request,
workspaceId,
actor,
dataset,
rootNodeId: requestUrl.searchParams.get("rootNodeId")?.trim() || null,
depth: readNumberParam(requestUrl, "depth"),
}),
});
return {
requestId: context.requestId,
traceId: context.traceId,
data: datasetWithFileTree,
snapshot: {
dataset: datasetWithFileTree,
},
};
};
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const frame of streamTreeFrames({
workspaceId,
rootNodeId: requestUrl.searchParams.get("rootNodeId"),
initialCursor: requestUrl.searchParams.get("cursor"),
pollMs: readNumberParam(requestUrl, "pollMs") ?? undefined,
maxPolls: readNumberParam(requestUrl, "maxPolls"),
loadOverview,
loadSnapshot,
})) {
if (request.signal.aborted) {
break;
}
controller.enqueue(encoder.encode(encodeSseFrame(frame.event, frame.payload)));
}
controller.close();
} catch (error) {
controller.error(error);
}
},
cancel() {
return undefined;
},
});
return new NextResponse(stream, {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
connection: "keep-alive",
"x-upstream": "next-tree-stream-compat",
"x-mnote-web-owner": "mnote-web",
"x-mnote-tree-stream-owner": "rust-web",
"x-mnote-compat-boundary": "mnote-web-stream-alias",
},
});
);
}
@@ -208,7 +208,8 @@ describe("/api/tree/commands route", () => {
expect.arrayContaining([
expect.objectContaining({
id: "tree.commands",
role: "next-thin-proxy",
role: "rust-owned",
owner: "rust-web-gateway",
}),
]),
);