feat: 收口 Rust Web 3000 主链
This commit is contained in:
@@ -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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user