mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
fix: Stabilize Preview and MCP issue reports (#6572)
## Problem A fresh iterative Preview can lose its rendered content on the first client navigation with a React `useContext` error. MCP issue reports can also miss the page or instance ID needed to locate a problem, and concurrent tool calls can overwrite newer report context with an older snapshot. ## Fix - Have Vite scan generated routes before the first navigation. This applies to the base React Router template and the Netlify and Cloudflare overlays. It changes local iterative Preview, not saved project data or production routing. - Include up to three recent, validated tool targets in MCP issue reports. A project-relative screenshot path is matched to its page ID; the CLI does not store the path, full URL, token, or page content. It cannot read the Builder's current UI selection, but includes an instance ID when a tool call names one. - Treat structured MCP error results as failures and serialize report-state writes within a CLI session, so a concurrent older write cannot replace a newer result. The reporting work from #6574 is now included here. #6574 is superseded. ## Verification - Reproduced #6569 twice with CLI 0.306.0 and a cold optimizer cache. With the Vite change, a cold `/` → `/blog` navigation and direct article load rendered without browser errors. - The config assertion failed without the Vite entry and passed with it. A proposed optimizer test also passed without the fix, so it was removed; the real cold-navigation check remains the behavioral evidence. - The new concurrent-write test passed with serialization and failed when the queue was temporarily removed. All 106 focused CLI tests, CLI typecheck, root lint, formatting, and generated API check passed on the combined branch. - Earlier checks of the same Preview and reporting code passed: 133 prebuild tests, 144 project-build MCP tests, protocol and Builder reporting tests, affected typechecks, CLI build, generated docs, and CI on the separate PRs. - The fixture build passed without changing tracked files. The full local fixture pipeline stopped at sync because the development server returned bundle version `bundle-17o6sch` while this checkout expects `bundle-1sh9iaz`. The combined commit passed Linux and Windows fixture checks and all other required CI jobs. - Full Luna/low agent evaluations were run earlier on the combined branch: the latest run passed 8 of 9 fixture tasks, while the managed-form assertion failed on an unrelated task. Saved baselines were missing or incompatible, so the command failed overall. No baselines changed. Evaluations were not rerun for this final concurrent-write change. ## Checklist - [x] Reproduce and fix the first-navigation Preview failure - [x] Cover the base, Netlify, and Cloudflare templates and generated fixtures - [x] Include safe recent page and instance IDs in MCP issue reports - [x] Treat structured MCP errors as failures - [x] Serialize concurrent report-state writes and verify the regression test fails without the queue - [x] Review documentation impact: Preview usage and user guidance are unchanged; no docs change - [x] Run focused local checks and attempt the fixture pipeline - [x] Confirm required CI passes on the final combined commit Closes #6569 Ref #6571
This commit is contained in:
1 parent
13eb7527ef
commit
bc7e58af84
19 files changed
+573
-33
No files matched your search
@@ -48,6 +48,13 @@ const report: IssueReportInput = {
|
||||
},
|
||||
],
|
||||
},
|
||||
recentTargets: [
|
||||
{ tool: "screenshot", entityIds: [{ field: "pageId", id: "page-456" }] },
|
||||
{
|
||||
tool: "inspect-instance",
|
||||
entityIds: [{ field: "instanceId", id: "instance-456" }],
|
||||
},
|
||||
],
|
||||
session: {
|
||||
staleNamespaces: ["styles"],
|
||||
missingNamespaces: ["instances"],
|
||||
@@ -84,6 +91,10 @@ describe("GitHub issue reports", () => {
|
||||
expect(body).toContain("- Client: Codex 1.2.3");
|
||||
expect(body).toContain("- CLI: 1.2.3");
|
||||
expect(body).toContain("- Project ID: `project-123`");
|
||||
expect(body).toContain("- Recent targeted tool: `screenshot`");
|
||||
expect(body).toContain(" - pageId: `page-456`");
|
||||
expect(body).toContain("- Recent targeted tool: `inspect-instance`");
|
||||
expect(body).toContain(" - instanceId: `instance-456`");
|
||||
expect(body).toContain("- pageId: `page-123`");
|
||||
expect(body).toContain("- instanceId: `instance-123`");
|
||||
expect(body).toContain("- Node.js: 22.14.0");
|
||||
|
||||
@@ -32,6 +32,12 @@ const formatRuntime = (runtime: IssueReportInput["runtime"]) =>
|
||||
...(runtime?.projectId === undefined
|
||||
? []
|
||||
: [`- Project ID: \`${runtime.projectId}\``]),
|
||||
...(runtime?.recentTargets === undefined
|
||||
? []
|
||||
: runtime.recentTargets.flatMap(({ tool, entityIds }) => [
|
||||
`- Recent targeted tool: \`${tool}\``,
|
||||
...entityIds.map(({ field, id }) => ` - ${field}: \`${id}\``),
|
||||
])),
|
||||
...(runtime === undefined
|
||||
? []
|
||||
: [
|
||||
|
||||
@@ -3,6 +3,9 @@ import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
|
||||
export default defineConfig({
|
||||
optimizeDeps: {
|
||||
entries: ["app/root.tsx", "app/routes/**/*.{ts,tsx}"],
|
||||
},
|
||||
plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }), reactRouter()],
|
||||
resolve: {
|
||||
conditions: ["browser", "development|production"],
|
||||
|
||||
@@ -11,6 +11,11 @@ const templateNodeModules = new URL("./node_modules", import.meta.url);
|
||||
export default defineConfig({
|
||||
// Local previews share node_modules, but must not share optimizer state.
|
||||
cacheDir: ".react-router/vite",
|
||||
// Scan generated routes before first navigation to avoid replacing React
|
||||
// dependencies while the browser hydrates an initial page.
|
||||
optimizeDeps: {
|
||||
entries: ["app/root.tsx", "app/routes/**/*.{ts,tsx}"],
|
||||
},
|
||||
plugins: [reactRouter()],
|
||||
server: {
|
||||
hmr: process.env.WEBSTUDIO_PREVIEW_HMR === "disabled" ? false : undefined,
|
||||
|
||||
@@ -3,6 +3,9 @@ import { defineConfig } from "vite";
|
||||
import netlifyPlugin from "@netlify/vite-plugin-react-router";
|
||||
|
||||
export default defineConfig({
|
||||
optimizeDeps: {
|
||||
entries: ["app/root.tsx", "app/routes/**/*.{ts,tsx}"],
|
||||
},
|
||||
plugins: [reactRouter(), netlifyPlugin()],
|
||||
resolve: {
|
||||
conditions: ["browser", "development|production"],
|
||||
|
||||
@@ -11,6 +11,11 @@ const templateNodeModules = new URL("./node_modules", import.meta.url);
|
||||
export default defineConfig({
|
||||
// Local previews share node_modules, but must not share optimizer state.
|
||||
cacheDir: ".react-router/vite",
|
||||
// Scan generated routes before first navigation to avoid replacing React
|
||||
// dependencies while the browser hydrates an initial page.
|
||||
optimizeDeps: {
|
||||
entries: ["app/root.tsx", "app/routes/**/*.{ts,tsx}"],
|
||||
},
|
||||
plugins: [reactRouter()],
|
||||
server: {
|
||||
hmr: process.env.WEBSTUDIO_PREVIEW_HMR === "disabled" ? false : undefined,
|
||||
|
||||
@@ -24,11 +24,12 @@ import {
|
||||
} from "@webstudio-is/project-build/state";
|
||||
import { publicApiOperations } from "@webstudio-is/protocol";
|
||||
import { contentEngineLimits } from "@webstudio-is/content-engine/limits";
|
||||
import { ROOT_INSTANCE_ID } from "@webstudio-is/sdk";
|
||||
import { ROOT_INSTANCE_ID, type Pages } from "@webstudio-is/sdk";
|
||||
import { updatePersistedMcpCheckpoint } from "./mcp-checkpoint";
|
||||
import {
|
||||
createCliProjectSessionStorage,
|
||||
createIssueReportFailureTracker,
|
||||
createIssueReportTargetTracker,
|
||||
} from "../project-session";
|
||||
import {
|
||||
__testing__,
|
||||
@@ -38,8 +39,10 @@ import {
|
||||
} from "./mcp";
|
||||
|
||||
const {
|
||||
createIssueReportStateWriter,
|
||||
getMcpDownloadAsset,
|
||||
getIssueReportFailureFile,
|
||||
getIssueReportPageId,
|
||||
persistIssueReportFailure,
|
||||
restoreIssueReportFailure,
|
||||
assertSingleOpCallToolSupported,
|
||||
@@ -511,6 +514,111 @@ test("persists sanitized issue-report failure diagnostics for the next CLI proce
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the latest issue-report state when writes overlap", async () => {
|
||||
const projectRoot = await mkdtemp(
|
||||
path.join(tmpdir(), "webstudio-mcp-issue-report-writes-")
|
||||
);
|
||||
tempDirs.push(projectRoot);
|
||||
const filePath = getIssueReportFailureFile(projectRoot, "project-1");
|
||||
const tracker = createIssueReportFailureTracker();
|
||||
tracker.record("update-text", new Error("failed"), 1, {
|
||||
instanceId: "instance-1",
|
||||
});
|
||||
let releaseFirstWrite: () => void = () => undefined;
|
||||
const firstWriteBlocked = new Promise<void>((resolve) => {
|
||||
releaseFirstWrite = resolve;
|
||||
});
|
||||
const writes: string[] = [];
|
||||
const writer = createIssueReportStateWriter(
|
||||
filePath,
|
||||
async (path, snapshot, targets) => {
|
||||
if (snapshot !== undefined) {
|
||||
await firstWriteBlocked;
|
||||
}
|
||||
writes.push(snapshot === undefined ? "clear" : "failure");
|
||||
await persistIssueReportFailure(path, snapshot, targets);
|
||||
}
|
||||
);
|
||||
|
||||
const firstWrite = writer(tracker.snapshot());
|
||||
const clear = writer(undefined);
|
||||
await Promise.resolve();
|
||||
expect(writes).toEqual([]);
|
||||
releaseFirstWrite();
|
||||
await Promise.all([firstWrite, clear]);
|
||||
expect(writes).toEqual(["failure", "clear"]);
|
||||
await expect(readFile(filePath, "utf8")).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves a screenshot route to a page ID without retaining its URL", () => {
|
||||
const pages = {
|
||||
homePageId: "home",
|
||||
rootFolderId: "root",
|
||||
pages: new Map([
|
||||
["home", { id: "home", path: "" }],
|
||||
["blog", { id: "blog", path: "/blog/:slug" }],
|
||||
]),
|
||||
folders: new Map(),
|
||||
} as unknown as Pages;
|
||||
expect(
|
||||
getIssueReportPageId(
|
||||
"screenshot",
|
||||
{ path: "/blog/private-post?authToken=secret" },
|
||||
pages
|
||||
)
|
||||
).toBe("blog");
|
||||
expect(
|
||||
getIssueReportPageId(
|
||||
"screenshot",
|
||||
{ path: "https://private.example/" },
|
||||
pages
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getIssueReportPageId(
|
||||
"screenshot",
|
||||
{ path: "/blog/private-post", url: "https://private.example/" },
|
||||
pages
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getIssueReportPageId("list-pages", { path: "/blog/private-post" }, pages)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("restores a successful tool target across CLI processes without private input", async () => {
|
||||
const projectRoot = await mkdtemp(
|
||||
path.join(tmpdir(), "webstudio-mcp-target-report-")
|
||||
);
|
||||
tempDirs.push(projectRoot);
|
||||
const filePath = getIssueReportFailureFile(projectRoot, "project-1");
|
||||
const firstTarget = createIssueReportTargetTracker();
|
||||
firstTarget.record(
|
||||
"screenshot",
|
||||
{ path: "/blog/private-post?authToken=secret" },
|
||||
"page-1"
|
||||
);
|
||||
firstTarget.record("inspect-instance", { instanceId: "instance-1" });
|
||||
await persistIssueReportFailure(filePath, undefined, firstTarget.snapshot());
|
||||
const restoredTarget = createIssueReportTargetTracker();
|
||||
await restoreIssueReportFailure(
|
||||
filePath,
|
||||
createIssueReportFailureTracker(),
|
||||
restoredTarget
|
||||
);
|
||||
expect(restoredTarget.getAll()).toEqual([
|
||||
{
|
||||
tool: "inspect-instance",
|
||||
entityIds: [{ field: "instanceId", id: "instance-1" }],
|
||||
},
|
||||
{ tool: "screenshot", entityIds: [{ field: "pageId", id: "page-1" }] },
|
||||
]);
|
||||
expect(await readFile(filePath, "utf8")).not.toContain("private-post");
|
||||
expect(await readFile(filePath, "utf8")).not.toContain("authToken");
|
||||
});
|
||||
|
||||
test("preserves a destructive confirmation failure reason without the token or plan", async () => {
|
||||
const projectRoot = await mkdtemp(
|
||||
path.join(tmpdir(), "webstudio-mcp-confirmation-report-")
|
||||
|
||||
@@ -30,7 +30,9 @@ import {
|
||||
publicApiOperationRequiresServerSupport,
|
||||
publicApiOperations,
|
||||
issueReportRecentFailureSchema,
|
||||
issueReportRecentTargetSchema,
|
||||
type IssueReportRecentFailure,
|
||||
type IssueReportRecentTarget,
|
||||
} from "@webstudio-is/protocol";
|
||||
import * as httpClient from "@webstudio-is/http-client";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
@@ -52,7 +54,13 @@ import {
|
||||
} from "@webstudio-is/content-engine/mdx";
|
||||
import { contentEngineLimits } from "@webstudio-is/content-engine/limits";
|
||||
import { assetQueryRequest } from "@webstudio-is/content-engine";
|
||||
import { assetType, getFileExtension } from "@webstudio-is/sdk";
|
||||
import {
|
||||
assetType,
|
||||
getAllPages,
|
||||
getFileExtension,
|
||||
getPagePath,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { matchPathnameRoutes } from "@webstudio-is/wsauth";
|
||||
import { fontMetaUpdate } from "@webstudio-is/fonts";
|
||||
import { resolveApiConnection } from "../api-connection";
|
||||
import {
|
||||
@@ -69,6 +77,7 @@ import {
|
||||
createCliProjectRestorePointStorage,
|
||||
createCliProjectSession,
|
||||
createIssueReportFailureTracker,
|
||||
createIssueReportTargetTracker,
|
||||
createIssueReportRuntime,
|
||||
getCliProjectRestorePointsFile,
|
||||
getCliServerApiContract,
|
||||
@@ -1858,44 +1867,83 @@ const getIssueReportFailureFile = (projectRoot: string, projectId?: string) =>
|
||||
|
||||
const persistIssueReportFailure = async (
|
||||
filePath: string,
|
||||
snapshot: { failure: IssueReportRecentFailure; at: number } | undefined
|
||||
snapshot: { failure: IssueReportRecentFailure; at: number } | undefined,
|
||||
targetSnapshot?: { target: IssueReportRecentTarget; at: number }[]
|
||||
) => {
|
||||
if (snapshot === undefined) {
|
||||
if (snapshot === undefined && targetSnapshot === undefined) {
|
||||
await rm(filePath, { force: true }).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(temporaryPath, JSON.stringify(snapshot), "utf8");
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
JSON.stringify({
|
||||
...snapshot,
|
||||
...(targetSnapshot === undefined ? {} : { targets: targetSnapshot }),
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
await rename(temporaryPath, filePath);
|
||||
} catch {
|
||||
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const createIssueReportStateWriter = (
|
||||
filePath: string,
|
||||
write = persistIssueReportFailure
|
||||
) => {
|
||||
let pending = Promise.resolve();
|
||||
return (
|
||||
snapshot: { failure: IssueReportRecentFailure; at: number } | undefined,
|
||||
targetSnapshot?: { target: IssueReportRecentTarget; at: number }[]
|
||||
) => {
|
||||
const persist = () => write(filePath, snapshot, targetSnapshot);
|
||||
pending = pending.then(persist, persist);
|
||||
return pending;
|
||||
};
|
||||
};
|
||||
|
||||
const restoreIssueReportFailure = async (
|
||||
filePath: string,
|
||||
failureTracker: ReturnType<typeof createIssueReportFailureTracker>
|
||||
failureTracker: ReturnType<typeof createIssueReportFailureTracker>,
|
||||
targetTracker?: ReturnType<typeof createIssueReportTargetTracker>
|
||||
) => {
|
||||
try {
|
||||
const value: unknown = JSON.parse(await readFile(filePath, "utf8"));
|
||||
if (!isPlainRecord(value)) {
|
||||
await rm(filePath, { force: true }).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const failure = issueReportRecentFailureSchema.safeParse(value.failure);
|
||||
if (
|
||||
isPlainRecord(value) &&
|
||||
failure.success &&
|
||||
typeof value.at === "number" &&
|
||||
Number.isSafeInteger(value.at)
|
||||
) {
|
||||
const failure = issueReportRecentFailureSchema.safeParse(value.failure);
|
||||
if (failure.success) {
|
||||
failureTracker.restore(failure.data, value.at);
|
||||
}
|
||||
if (
|
||||
failure.success === false ||
|
||||
failureTracker.snapshot() === undefined
|
||||
) {
|
||||
await rm(filePath, { force: true }).catch(() => undefined);
|
||||
if (Array.isArray(value.targets)) {
|
||||
for (const entry of value.targets.slice(0, 3)) {
|
||||
if (!isPlainRecord(entry)) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const target = issueReportRecentTargetSchema.safeParse(entry.target);
|
||||
if (
|
||||
target.success &&
|
||||
typeof entry.at === "number" &&
|
||||
Number.isSafeInteger(entry.at)
|
||||
) {
|
||||
targetTracker?.restore(target.data, entry.at);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
failureTracker.snapshot() === undefined &&
|
||||
targetTracker?.snapshot() === undefined
|
||||
) {
|
||||
await rm(filePath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
@@ -1905,6 +1953,46 @@ const restoreIssueReportFailure = async (
|
||||
}
|
||||
};
|
||||
|
||||
const getIssueReportPageId = (
|
||||
tool: string,
|
||||
input: unknown,
|
||||
pages: NonNullable<ProjectSessionSnapshot["state"]["pages"]> | undefined
|
||||
) => {
|
||||
if (
|
||||
pages === undefined ||
|
||||
![
|
||||
"screenshot",
|
||||
"screenshot.responsive",
|
||||
"verify-page-responsive",
|
||||
"get-page-by-path",
|
||||
].includes(tool) ||
|
||||
!isPlainRecord(input) ||
|
||||
((tool === "screenshot" || tool === "screenshot.responsive") &&
|
||||
(input.url !== undefined || input.baseUrl !== undefined)) ||
|
||||
typeof input.path !== "string" ||
|
||||
!input.path.startsWith("/") ||
|
||||
input.path.startsWith("//")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pathUrl = new URL(input.path, "https://webstudio.invalid");
|
||||
if (pathUrl.origin !== "https://webstudio.invalid") {
|
||||
return;
|
||||
}
|
||||
return matchPathnameRoutes(
|
||||
getAllPages(pages).map((page) => ({
|
||||
pattern: getPagePath(page.id, pages),
|
||||
value: page.id,
|
||||
})),
|
||||
pathUrl.pathname
|
||||
)?.value;
|
||||
} catch {
|
||||
// Report context is best effort and must not turn a tool call into an error.
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const createCliMcpHost = async ({
|
||||
projectRoot = cwd(),
|
||||
projectId,
|
||||
@@ -1936,11 +2024,19 @@ const createCliMcpHost = async ({
|
||||
const apiContract = await getCliServerApiContract(apiConnection);
|
||||
const operations = getSupportedPublicApiOperations(apiContract);
|
||||
const failureTracker = createIssueReportFailureTracker();
|
||||
const targetTracker = createIssueReportTargetTracker();
|
||||
const issueReportFailureFile = getIssueReportFailureFile(
|
||||
projectRoot,
|
||||
connection.projectId
|
||||
);
|
||||
await restoreIssueReportFailure(issueReportFailureFile, failureTracker);
|
||||
const persistIssueReportState = createIssueReportStateWriter(
|
||||
issueReportFailureFile
|
||||
);
|
||||
await restoreIssueReportFailure(
|
||||
issueReportFailureFile,
|
||||
failureTracker,
|
||||
targetTracker
|
||||
);
|
||||
const session = createCliProjectSession({
|
||||
connection: apiConnection,
|
||||
projectRoot,
|
||||
@@ -1956,6 +2052,7 @@ const createCliMcpHost = async ({
|
||||
getBuilderStateNamespacesByStatus(snapshot.freshness, status);
|
||||
return createIssueReportRuntime(failureTracker.get(), {
|
||||
projectId: connection.projectId,
|
||||
recentTargets: targetTracker.getAll(),
|
||||
...(namespacesByStatus === undefined
|
||||
? {}
|
||||
: {
|
||||
@@ -2284,16 +2381,42 @@ const createCliMcpHost = async ({
|
||||
) {
|
||||
if (canonicalTool !== "report-issue") {
|
||||
failureTracker.record(canonicalTool, error, elapsedMs, input);
|
||||
await persistIssueReportFailure(
|
||||
issueReportFailureFile,
|
||||
failureTracker.snapshot()
|
||||
targetTracker.record(
|
||||
canonicalTool,
|
||||
input,
|
||||
getIssueReportPageId(
|
||||
canonicalTool,
|
||||
input,
|
||||
session.snapshot?.state.pages
|
||||
)
|
||||
);
|
||||
await persistIssueReportState(
|
||||
failureTracker.snapshot(),
|
||||
targetTracker.snapshot()
|
||||
);
|
||||
}
|
||||
},
|
||||
async recordToolSuccess(canonicalTool: string) {
|
||||
async recordToolSuccess(canonicalTool: string, input: unknown) {
|
||||
failureTracker.succeed(canonicalTool);
|
||||
if (canonicalTool === "report-issue") {
|
||||
await persistIssueReportFailure(issueReportFailureFile, undefined);
|
||||
targetTracker.clear();
|
||||
await persistIssueReportState(undefined);
|
||||
} else {
|
||||
const hasTarget = targetTracker.record(
|
||||
canonicalTool,
|
||||
input,
|
||||
getIssueReportPageId(
|
||||
canonicalTool,
|
||||
input,
|
||||
session.snapshot?.state.pages
|
||||
)
|
||||
);
|
||||
if (hasTarget) {
|
||||
await persistIssueReportState(
|
||||
failureTracker.snapshot(),
|
||||
targetTracker.snapshot()
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
reportLog(message: string) {
|
||||
@@ -2495,7 +2618,7 @@ export const mcpSingleOpCall = async (options: McpSingleOpCallOptions) => {
|
||||
printJson(result.structuredContent);
|
||||
throw new HandledCliError();
|
||||
}
|
||||
await recordToolSuccess(tool);
|
||||
await recordToolSuccess(tool, input);
|
||||
if (
|
||||
tool === "checkpoint.ack" &&
|
||||
persistedCheckpoint?.nextCommand !== undefined &&
|
||||
@@ -2631,7 +2754,7 @@ const runMcpProjectsBatch = async ({
|
||||
throw error;
|
||||
}
|
||||
await callSucceeded(index + 1);
|
||||
await recordToolSuccess(call.tool);
|
||||
await recordToolSuccess(call.tool, call.input);
|
||||
const nextTool = project.calls[index + 1]?.tool;
|
||||
if (
|
||||
checkpoint !== undefined &&
|
||||
@@ -2722,7 +2845,9 @@ export const mcpRun = async (options: McpRunOptions) => {
|
||||
input: unknown
|
||||
) => Promise<void>)
|
||||
| undefined;
|
||||
let recordToolSuccess: ((tool: string) => Promise<void>) | undefined;
|
||||
let recordToolSuccess:
|
||||
| ((tool: string, input: unknown) => Promise<void>)
|
||||
| undefined;
|
||||
let disposeHost: () => Promise<void> = async () => undefined;
|
||||
try {
|
||||
const mcpHost = await createCliMcpHost({
|
||||
@@ -2772,7 +2897,7 @@ export const mcpRun = async (options: McpRunOptions) => {
|
||||
scope,
|
||||
});
|
||||
const session = result.structuredContent.meta.session;
|
||||
await recordToolSuccess?.(call.tool);
|
||||
await recordToolSuccess?.(call.tool, call.input);
|
||||
const committed =
|
||||
session === undefined ? "" : `; committed=${session.committed}`;
|
||||
stderr.write(
|
||||
@@ -2980,6 +3105,8 @@ export const mcp = async (
|
||||
};
|
||||
|
||||
export const __testing__ = {
|
||||
createIssueReportStateWriter,
|
||||
getIssueReportPageId,
|
||||
getMcpDownloadAsset,
|
||||
getIssueReportFailureFile,
|
||||
persistIssueReportFailure,
|
||||
|
||||
@@ -2520,6 +2520,10 @@ sitemap.map((page) => page.path);`
|
||||
);
|
||||
expect(loadedConfig?.config.resolve?.conditions).toContain("import");
|
||||
expect(loadedConfig?.config.ssr?.resolve?.conditions).toContain("import");
|
||||
expect(loadedConfig?.config.optimizeDeps?.entries).toEqual([
|
||||
"app/root.tsx",
|
||||
"app/routes/**/*.{ts,tsx}",
|
||||
]);
|
||||
await runGeneratedCommand("react-router", ["build"]);
|
||||
|
||||
await expect(
|
||||
@@ -2527,6 +2531,17 @@ sitemap.map((page) => page.path);`
|
||||
).resolves.not.toHaveLength(0);
|
||||
}, 30_000);
|
||||
|
||||
test.each(["react-router-netlify", "react-router-cloudflare"])(
|
||||
"pre-scans generated routes with the %s template overlay",
|
||||
async (overlay) => {
|
||||
await prebuild({ assets: false, template: ["react-router", overlay] });
|
||||
const config = await readFile("vite.config.ts", "utf8");
|
||||
expect(config).toContain(
|
||||
'entries: ["app/root.tsx", "app/routes/**/*.{ts,tsx}"]'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test("preserves an authored catch-all page when redirects are configured", async () => {
|
||||
await writeSiteData(
|
||||
createSiteData({
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
createCliProjectSessionTransport,
|
||||
createIssueReportFailure,
|
||||
createIssueReportFailureTracker,
|
||||
createIssueReportTarget,
|
||||
createIssueReportTargetTracker,
|
||||
addIssueReportRuntime,
|
||||
assertCliEditingContractVersion,
|
||||
getCliServerApiContract,
|
||||
@@ -272,6 +274,44 @@ test("keeps a failed mutation available after a successful diagnostic read", ()
|
||||
expect(restored.get()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("keeps validated IDs from recent targeted tools without private input", () => {
|
||||
let now = 1_000;
|
||||
const tracker = createIssueReportTargetTracker(() => now);
|
||||
tracker.record("inspect-instance", {
|
||||
instanceId: "instance-1",
|
||||
url: "https://private.example/?authToken=secret",
|
||||
});
|
||||
expect(tracker.record("list-pages", {})).toBe(false);
|
||||
expect(tracker.getAll()?.[0]).toEqual({
|
||||
tool: "inspect-instance",
|
||||
entityIds: [{ field: "instanceId", id: "instance-1" }],
|
||||
});
|
||||
expect(
|
||||
tracker.record(
|
||||
"screenshot",
|
||||
{ path: "/blog/private-slug?authToken=secret" },
|
||||
"page-1"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(tracker.getAll()).toEqual([
|
||||
{ tool: "screenshot", entityIds: [{ field: "pageId", id: "page-1" }] },
|
||||
{
|
||||
tool: "inspect-instance",
|
||||
entityIds: [{ field: "instanceId", id: "instance-1" }],
|
||||
},
|
||||
]);
|
||||
tracker.record("screenshot", { path: "/blog/another-post" }, "page-1");
|
||||
expect(tracker.getAll()).toHaveLength(2);
|
||||
expect(JSON.stringify(tracker.snapshot())).not.toContain("private");
|
||||
expect(
|
||||
createIssueReportTarget("inspect-instance", {
|
||||
instanceId: "https://secret",
|
||||
})
|
||||
).toBeUndefined();
|
||||
now += 10 * 60_000 + 1;
|
||||
expect(tracker.getAll()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reports only the shape of an untransformable server response", () => {
|
||||
const error = Object.assign(
|
||||
new Error("Unable to transform response from server"),
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
issueReportEntityId,
|
||||
issueReportErrorType,
|
||||
issueReportRequestId,
|
||||
issueReportRecentTargetSchema,
|
||||
getPublicBuildIncludes,
|
||||
publicApiEntityIdFields,
|
||||
publicApiContractVersion,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
publicApiOperations,
|
||||
type IssueReportRuntime,
|
||||
type IssueReportRecentFailure,
|
||||
type IssueReportRecentTarget,
|
||||
type PublicApiCommand,
|
||||
type PublishedProjectBundle,
|
||||
} from "@webstudio-is/protocol";
|
||||
@@ -327,7 +329,10 @@ const publicOperationById = new Map(
|
||||
|
||||
export const createIssueReportRuntime = (
|
||||
recentFailure?: IssueReportRecentFailure,
|
||||
diagnostics?: Pick<IssueReportRuntime, "projectId" | "session" | "preview">
|
||||
diagnostics?: Pick<
|
||||
IssueReportRuntime,
|
||||
"projectId" | "recentTargets" | "session" | "preview"
|
||||
>
|
||||
): IssueReportRuntime => ({
|
||||
cliVersion: packageJson.version,
|
||||
nodeVersion: process.versions.node,
|
||||
@@ -389,6 +394,68 @@ const getIssueReportEntityIds = (input: unknown) => {
|
||||
return ids;
|
||||
};
|
||||
|
||||
export const createIssueReportTarget = (
|
||||
tool: string,
|
||||
input: unknown,
|
||||
pageId?: string
|
||||
): IssueReportRecentTarget | undefined => {
|
||||
const entityIds = getIssueReportEntityIds(input);
|
||||
if (
|
||||
pageId !== undefined &&
|
||||
!entityIds.some(({ field }) => field === "pageId")
|
||||
) {
|
||||
const parsed = issueReportEntityId.safeParse({
|
||||
field: "pageId",
|
||||
id: pageId,
|
||||
});
|
||||
if (parsed.success) {
|
||||
entityIds.unshift(parsed.data);
|
||||
}
|
||||
}
|
||||
const target = issueReportRecentTargetSchema.safeParse({
|
||||
tool,
|
||||
entityIds: entityIds.slice(0, 20),
|
||||
});
|
||||
return target.success ? target.data : undefined;
|
||||
};
|
||||
|
||||
export const createIssueReportTargetTracker = (now = Date.now) => {
|
||||
let recent: { target: IssueReportRecentTarget; at: number }[] = [];
|
||||
const snapshot = () => {
|
||||
const valid = recent.filter(({ at }) => {
|
||||
const age = now() - at;
|
||||
return age >= 0 && age <= 10 * 60_000;
|
||||
});
|
||||
return valid.length === 0 ? undefined : valid;
|
||||
};
|
||||
return {
|
||||
record(tool: string, input: unknown, pageId?: string) {
|
||||
const target = createIssueReportTarget(tool, input, pageId);
|
||||
if (target === undefined) {
|
||||
return false;
|
||||
}
|
||||
recent = [
|
||||
{ target, at: now() },
|
||||
...(snapshot() ?? []).filter(({ target: old }) => old.tool !== tool),
|
||||
].slice(0, 3);
|
||||
return true;
|
||||
},
|
||||
getAll: () => snapshot()?.map(({ target }) => target),
|
||||
restore(target: IssueReportRecentTarget, at: number) {
|
||||
const age = now() - at;
|
||||
if (age >= 0 && age <= 10 * 60_000) {
|
||||
recent.push({ target, at });
|
||||
recent.sort((left, right) => right.at - left.at);
|
||||
recent = recent.slice(0, 3);
|
||||
}
|
||||
},
|
||||
snapshot,
|
||||
clear() {
|
||||
recent = [];
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const createIssueReportFailure = (
|
||||
canonicalTool: string,
|
||||
error: unknown,
|
||||
|
||||
@@ -3,6 +3,9 @@ import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
|
||||
export default defineConfig({
|
||||
optimizeDeps: {
|
||||
entries: ["app/root.tsx", "app/routes/**/*.{ts,tsx}"],
|
||||
},
|
||||
plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }), reactRouter()],
|
||||
resolve: {
|
||||
conditions: ["browser", "development|production"],
|
||||
|
||||
@@ -3,6 +3,9 @@ import { defineConfig } from "vite";
|
||||
import netlifyPlugin from "@netlify/vite-plugin-react-router";
|
||||
|
||||
export default defineConfig({
|
||||
optimizeDeps: {
|
||||
entries: ["app/root.tsx", "app/routes/**/*.{ts,tsx}"],
|
||||
},
|
||||
plugins: [reactRouter(), netlifyPlugin()],
|
||||
resolve: {
|
||||
conditions: ["browser", "development|production"],
|
||||
|
||||
@@ -11,6 +11,11 @@ const templateNodeModules = new URL("./node_modules", import.meta.url);
|
||||
export default defineConfig({
|
||||
// Local previews share node_modules, but must not share optimizer state.
|
||||
cacheDir: ".react-router/vite",
|
||||
// Scan generated routes before first navigation to avoid replacing React
|
||||
// dependencies while the browser hydrates an initial page.
|
||||
optimizeDeps: {
|
||||
entries: ["app/root.tsx", "app/routes/**/*.{ts,tsx}"],
|
||||
},
|
||||
plugins: [reactRouter()],
|
||||
server: {
|
||||
hmr: process.env.WEBSTUDIO_PREVIEW_HMR === "disabled" ? false : undefined,
|
||||
|
||||
@@ -9390,7 +9390,9 @@ describe("project session mcp adapter", () => {
|
||||
},
|
||||
])
|
||||
);
|
||||
expect(onToolSuccess).toHaveBeenCalledWith("list-pages");
|
||||
expect(onToolSuccess).toHaveBeenCalledWith("list-pages", {
|
||||
includeFolders: true,
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
@@ -9398,6 +9400,8 @@ describe("project session mcp adapter", () => {
|
||||
});
|
||||
|
||||
test("returns schema-valid rendered failures and stores their artifact manifest through the server", async () => {
|
||||
const onToolSuccess = vi.fn();
|
||||
const onToolFailure = vi.fn();
|
||||
const storeRenderedAuditArtifacts = vi.fn(async () => "/manifest.json");
|
||||
const executeOperation = createExecuteOperation(async ({ command }) => {
|
||||
if (command === "list-pages") {
|
||||
@@ -9450,6 +9454,8 @@ describe("project session mcp adapter", () => {
|
||||
stopPreview: vi.fn(),
|
||||
captureScreenshot: vi.fn(),
|
||||
storeRenderedAuditArtifacts,
|
||||
onToolSuccess,
|
||||
onToolFailure,
|
||||
});
|
||||
const { client, close } = await createConnectedClient(server);
|
||||
|
||||
@@ -9459,6 +9465,17 @@ describe("project session mcp adapter", () => {
|
||||
arguments: { rendered: true, verbose: true },
|
||||
});
|
||||
|
||||
expect(onToolSuccess).not.toHaveBeenCalledWith(
|
||||
"audit",
|
||||
expect.anything()
|
||||
);
|
||||
expect(onToolFailure).toHaveBeenCalledWith(
|
||||
"audit",
|
||||
expect.objectContaining({ code: "RENDERED_AUDIT_FAILED" }),
|
||||
expect.any(Number),
|
||||
{ rendered: true, verbose: true }
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: {
|
||||
|
||||
@@ -9978,7 +9978,10 @@ export const createProjectSessionMcpServer = async <
|
||||
elapsedMs: number,
|
||||
input: unknown
|
||||
) => void | Promise<void>;
|
||||
onToolSuccess?: (canonicalTool: string) => void | Promise<void>;
|
||||
onToolSuccess?: (
|
||||
canonicalTool: string,
|
||||
input: unknown
|
||||
) => void | Promise<void>;
|
||||
}) => {
|
||||
const server = new Server(
|
||||
{ name: "webstudio", version: "0.0.0" },
|
||||
@@ -10132,8 +10135,18 @@ export const createProjectSessionMcpServer = async <
|
||||
signal: extra.signal,
|
||||
});
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
await onToolSuccess?.(canonicalName ?? "unknown");
|
||||
if (result.structuredContent.ok === false) {
|
||||
await onToolFailure?.(
|
||||
canonicalName ?? "unknown",
|
||||
result.structuredContent.error,
|
||||
elapsedMs,
|
||||
input
|
||||
);
|
||||
sendLog("error", `tool ${name} failed in ${elapsedMs}ms`);
|
||||
} else {
|
||||
await onToolSuccess?.(canonicalName ?? "unknown", input);
|
||||
sendLog("info", `tool ${name} succeeded in ${elapsedMs}ms`);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
|
||||
+73
-2
@@ -448,6 +448,77 @@ export const serverOnlyRouterOperationMetadata = {
|
||||
required: ["tool", "code"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
recentTargets: {
|
||||
maxItems: 3,
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tool: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
maxLength: 160,
|
||||
},
|
||||
entityIds: {
|
||||
minItems: 1,
|
||||
maxItems: 20,
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
field: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"formId",
|
||||
"requestId",
|
||||
"jobId",
|
||||
"domainId",
|
||||
"blockInstanceId",
|
||||
"assetId",
|
||||
"pageId",
|
||||
"parentFolderId",
|
||||
"socialImageAssetId",
|
||||
"thumbnailAssetId",
|
||||
"faviconAssetId",
|
||||
"breakpointId",
|
||||
"templateId",
|
||||
"sourceTemplateId",
|
||||
"targetTemplateId",
|
||||
"folderId",
|
||||
"targetFolderId",
|
||||
"scopeInstanceId",
|
||||
"resourceId",
|
||||
"instanceId",
|
||||
"styleSourceId",
|
||||
"childId",
|
||||
"rootInstanceId",
|
||||
"parentInstanceId",
|
||||
"sourceSlotId",
|
||||
"sourceInstanceId",
|
||||
"targetParentInstanceId",
|
||||
"designTokenId",
|
||||
"dataSourceId",
|
||||
"parentId",
|
||||
"fromAssetId",
|
||||
"toAssetId",
|
||||
],
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
maxLength: 160,
|
||||
pattern: "^[A-Za-z0-9_-]+$",
|
||||
},
|
||||
},
|
||||
required: ["field", "id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["tool", "entityIds"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
session: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -511,7 +582,7 @@ export const serverOnlyRouterOperationMetadata = {
|
||||
],
|
||||
additionalProperties: false,
|
||||
description:
|
||||
"CLI-collected runtime metadata with the project ID and bounded entity IDs from the failed tool input; no raw arguments, URLs, credentials, or customer content.",
|
||||
"CLI-collected runtime metadata with the project ID and bounded entity IDs from recent tool inputs; no raw arguments, URLs, credentials, or customer content.",
|
||||
},
|
||||
report: {
|
||||
type: "object",
|
||||
@@ -601,7 +672,7 @@ export const serverOnlyRouterOperationMetadata = {
|
||||
],
|
||||
additionalProperties: false,
|
||||
description:
|
||||
"LLM-authored technical report. The CLI attaches the project ID and recognized IDs from the failed tool input. Generalize all other context; exclude names, URLs, credentials, customer content, and raw tool data.",
|
||||
"LLM-authored technical report. The CLI attaches the project ID and recognized IDs from recent tool inputs. Generalize all other context; exclude names, URLs, credentials, customer content, and raw tool data.",
|
||||
},
|
||||
},
|
||||
"build.get": {
|
||||
|
||||
@@ -115,6 +115,33 @@ describe("issue report contract", () => {
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("accepts only safe IDs for recent targeted tools", () => {
|
||||
const recentTarget = {
|
||||
tool: "screenshot",
|
||||
entityIds: [{ field: "pageId", id: "page-123" }],
|
||||
};
|
||||
expect(
|
||||
issueReportInput.parse({
|
||||
...report,
|
||||
runtime: { ...report.runtime, recentTargets: [recentTarget] },
|
||||
}).runtime?.recentTargets
|
||||
).toEqual([recentTarget]);
|
||||
expect(() =>
|
||||
issueReportInput.parse({
|
||||
...report,
|
||||
runtime: {
|
||||
...report.runtime,
|
||||
recentTargets: [
|
||||
{
|
||||
...recentTarget,
|
||||
path: "/private-page?authToken=secret",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
test("accepts a complete anonymous LLM-authored report", () => {
|
||||
expect(issueReportInput.parse(report)).toEqual(report);
|
||||
});
|
||||
|
||||
@@ -100,6 +100,13 @@ export const issueReportErrorType = z
|
||||
|
||||
export const issueReportRequestId = z.string().regex(/^[A-Za-z0-9_-]{1,100}$/);
|
||||
|
||||
export const issueReportRecentTargetSchema = z
|
||||
.object({
|
||||
tool: z.string().trim().min(1).max(160),
|
||||
entityIds: z.array(issueReportEntityId).min(1).max(20),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const issueReportRecentFailureSchema = z
|
||||
.object({
|
||||
tool: z.string().trim().min(1).max(160),
|
||||
@@ -158,12 +165,13 @@ const issueReportRuntime = z
|
||||
bundleVersion: z.string().trim().min(1).max(100).optional(),
|
||||
projectId: issueReportId.optional(),
|
||||
recentFailure: issueReportRecentFailureSchema.optional(),
|
||||
recentTargets: z.array(issueReportRecentTargetSchema).max(3).optional(),
|
||||
session: issueReportSession.optional(),
|
||||
preview: issueReportPreview.optional(),
|
||||
})
|
||||
.strict()
|
||||
.describe(
|
||||
"CLI-collected runtime metadata with the project ID and bounded entity IDs from the failed tool input; no raw arguments, URLs, credentials, or customer content."
|
||||
"CLI-collected runtime metadata with the project ID and bounded entity IDs from recent tool inputs; no raw arguments, URLs, credentials, or customer content."
|
||||
);
|
||||
|
||||
const issueReportContent = z
|
||||
@@ -200,7 +208,7 @@ export const issueReportInput = z
|
||||
})
|
||||
.strict()
|
||||
.describe(
|
||||
"LLM-authored technical report. The CLI attaches the project ID and recognized IDs from the failed tool input. Generalize all other context; exclude names, URLs, credentials, customer content, and raw tool data."
|
||||
"LLM-authored technical report. The CLI attaches the project ID and recognized IDs from recent tool inputs. Generalize all other context; exclude names, URLs, credentials, customer content, and raw tool data."
|
||||
);
|
||||
|
||||
export const issueReportResult = z
|
||||
@@ -217,3 +225,6 @@ export type IssueReportRuntime = z.infer<typeof issueReportRuntime>;
|
||||
export type IssueReportRecentFailure = z.infer<
|
||||
typeof issueReportRecentFailureSchema
|
||||
>;
|
||||
export type IssueReportRecentTarget = z.infer<
|
||||
typeof issueReportRecentTargetSchema
|
||||
>;
|
||||
Reference in new issue
Block a user