mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-26 21:17:40 +09:00
## 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
331 lines
9.1 KiB
TypeScript
331 lines
9.1 KiB
TypeScript
import { createSign } from "node:crypto";
|
|
import type {
|
|
IssueReportInput,
|
|
IssueReportResult,
|
|
} from "@webstudio-is/protocol";
|
|
import env from "~/env/env.server";
|
|
|
|
type GitHubIssue = {
|
|
number: number;
|
|
html_url: string;
|
|
};
|
|
|
|
type GitHubSearchResponse = {
|
|
items?: GitHubIssue[];
|
|
};
|
|
|
|
const githubApiVersion = "2022-11-28";
|
|
const reportRepository = "webstudio-is/webstudio";
|
|
|
|
const getReportMarker = (deduplicationKey: string) =>
|
|
`<!-- webstudio-issue-report:${deduplicationKey} -->`;
|
|
|
|
const formatItems = (items: readonly string[]) =>
|
|
items.map((item, index) => `${index + 1}. ${item}`).join("\n");
|
|
|
|
const formatRuntime = (runtime: IssueReportInput["runtime"]) =>
|
|
[
|
|
"## Technical runtime",
|
|
"",
|
|
`- CLI: ${runtime?.cliVersion ?? "unknown"}`,
|
|
`- Node.js: ${runtime?.nodeVersion ?? "unknown"}`,
|
|
...(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
|
|
? []
|
|
: [
|
|
`- Operating system: ${runtime.os} ${runtime.osVersion} (${runtime.architecture})`,
|
|
`- Execution mode: ${runtime.executionMode}`,
|
|
`- API contract: ${runtime.apiContractVersion}`,
|
|
...(runtime.serverApiContractVersion === undefined
|
|
? []
|
|
: [`- Server API contract: ${runtime.serverApiContractVersion}`]),
|
|
`- Bundle contract: ${runtime.bundleVersion ?? "unknown"}`,
|
|
]),
|
|
...(runtime?.session === undefined
|
|
? []
|
|
: [
|
|
`- Session namespace issues: stale=${runtime.session.staleNamespaces.join(",") || "none"}; missing=${runtime.session.missingNamespaces.join(",") || "none"}; invalidated=${runtime.session.invalidatedNamespaces.join(",") || "none"}`,
|
|
]),
|
|
...(runtime?.preview === undefined
|
|
? []
|
|
: [
|
|
`- Preview: stale=${runtime.preview.stale}; rendered=${runtime.preview.hasRenderedVersion}; version matches session=${runtime.preview.renderedVersionMatchesSession ?? "unknown"}`,
|
|
]),
|
|
].join("\n");
|
|
|
|
const formatFailureDiagnostics = (runtime: IssueReportInput["runtime"]) => {
|
|
const failure = runtime?.recentFailure;
|
|
if (failure === undefined) {
|
|
return "";
|
|
}
|
|
const issues = failure.issues ?? [];
|
|
return [
|
|
"## Captured failure diagnostics",
|
|
"",
|
|
`- Tool: \`${failure.tool}\``,
|
|
`- Error code: \`${failure.code}\``,
|
|
...(failure.errorType === undefined
|
|
? []
|
|
: [`- Error type: ${failure.errorType}`]),
|
|
...(failure.requestId === undefined
|
|
? []
|
|
: [`- Request ID: \`${failure.requestId}\``]),
|
|
...(failure.httpStatus === undefined
|
|
? []
|
|
: [`- HTTP status: \`${failure.httpStatus}\``]),
|
|
...(failure.elapsedMs === undefined
|
|
? []
|
|
: [`- Duration: \`${failure.elapsedMs}ms\``]),
|
|
...(failure.entityIds === undefined
|
|
? []
|
|
: failure.entityIds.map(({ field, id }) => `- ${field}: \`${id}\``)),
|
|
...(failure.response === undefined
|
|
? []
|
|
: [
|
|
`- Response: ${failure.response.format}; envelope=${failure.response.envelope}${failure.response.batchSize === undefined ? "" : `; batch size=${failure.response.batchSize}`}`,
|
|
]),
|
|
...(failure.browser === undefined
|
|
? []
|
|
: [
|
|
...(failure.browser.exitSignal !== undefined
|
|
? [`- Browser exit: ${failure.browser.exitSignal}`]
|
|
: failure.browser.exitCode === undefined
|
|
? []
|
|
: [`- Browser exit: code ${failure.browser.exitCode}`]),
|
|
...(failure.browser.attempts === undefined
|
|
? []
|
|
: [
|
|
`- Browser attempts: ${failure.browser.attempts.map((attempt) => `${attempt.browser} (${attempt.source})`).join(", ")}`,
|
|
]),
|
|
]),
|
|
...(issues.length === 0
|
|
? []
|
|
: [
|
|
"- Invalid fields:",
|
|
...issues.map(
|
|
(issue) =>
|
|
` - \`${issue.path.join(".") || "input"}\`: \`${issue.code}\` (${issue.constraint})`
|
|
),
|
|
]),
|
|
"",
|
|
].join("\n");
|
|
};
|
|
|
|
export const formatIssueReport = ({
|
|
agent,
|
|
category,
|
|
deduplicationKey,
|
|
report,
|
|
runtime,
|
|
trigger,
|
|
}: IssueReportInput) => `${getReportMarker(deduplicationKey)}
|
|
|
|
## User story
|
|
|
|
${report.userStory}
|
|
|
|
## Summary
|
|
|
|
${report.summary}
|
|
|
|
## What the agent tried
|
|
|
|
${formatItems(report.attemptedWorkflow)}
|
|
|
|
## Expected behavior
|
|
|
|
${report.expectedBehavior}
|
|
|
|
## Actual result
|
|
|
|
${report.actualResult}
|
|
|
|
## Recovery attempts
|
|
|
|
${formatItems(report.recoveryAttempts)}
|
|
|
|
## User impact
|
|
|
|
${report.userImpact}
|
|
|
|
## Technical context
|
|
|
|
${report.technicalContext}
|
|
|
|
## Agent environment
|
|
|
|
- Client: ${agent.client}${agent.clientVersion === undefined ? "" : ` ${agent.clientVersion}`}
|
|
- Provider: ${agent.provider ?? "unknown"}
|
|
- Model: ${agent.model}
|
|
- Reasoning effort: ${agent.reasoningEffort}
|
|
- Trigger: ${trigger}
|
|
- Category: ${category}
|
|
|
|
${formatRuntime(runtime)}
|
|
|
|
${formatFailureDiagnostics(runtime)}
|
|
|
|
## Acceptance criteria
|
|
|
|
${formatItems(report.acceptanceCriteria)}
|
|
`;
|
|
|
|
const requestGitHub = async <Result>({
|
|
body,
|
|
method,
|
|
path,
|
|
request,
|
|
token,
|
|
}: {
|
|
body?: object;
|
|
method?: "GET" | "POST";
|
|
path: string;
|
|
request: typeof fetch;
|
|
token: string;
|
|
}) => {
|
|
const response = await request(new URL(path, "https://api.github.com"), {
|
|
method,
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${token}`,
|
|
"X-GitHub-Api-Version": githubApiVersion,
|
|
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
},
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
});
|
|
if (response.ok === false) {
|
|
throw new Error(
|
|
`GitHub issue reporting failed with HTTP ${response.status}.`
|
|
);
|
|
}
|
|
return (await response.json()) as Result;
|
|
};
|
|
|
|
const encodeJwtPart = (value: object) =>
|
|
Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
|
|
export const createGitHubAppJwt = ({
|
|
appId,
|
|
privateKey,
|
|
now = Date.now(),
|
|
}: {
|
|
appId: string;
|
|
privateKey: string;
|
|
now?: number;
|
|
}) => {
|
|
const issuedAt = Math.floor(now / 1000) - 60;
|
|
const unsignedToken = [
|
|
encodeJwtPart({ alg: "RS256", typ: "JWT" }),
|
|
encodeJwtPart({ iss: appId, iat: issuedAt, exp: issuedAt + 10 * 60 }),
|
|
].join(".");
|
|
const signer = createSign("RSA-SHA256");
|
|
signer.update(unsignedToken);
|
|
signer.end();
|
|
const signature = signer
|
|
.sign(privateKey.split("\\n").join("\n"))
|
|
.toString("base64url");
|
|
return `${unsignedToken}.${signature}`;
|
|
};
|
|
|
|
export const createGitHubInstallationToken = async ({
|
|
appId,
|
|
createJwt = createGitHubAppJwt,
|
|
privateKey,
|
|
request = fetch,
|
|
}: {
|
|
appId: string;
|
|
createJwt?: typeof createGitHubAppJwt;
|
|
privateKey: string;
|
|
request?: typeof fetch;
|
|
}) => {
|
|
const appJwt = createJwt({ appId, privateKey });
|
|
const installation = await requestGitHub<{ id?: unknown }>({
|
|
path: `/repos/${reportRepository}/installation`,
|
|
request,
|
|
token: appJwt,
|
|
});
|
|
if (typeof installation.id !== "number") {
|
|
throw new Error("GitHub App installation was not found.");
|
|
}
|
|
const result = await requestGitHub<{ token?: unknown }>({
|
|
method: "POST",
|
|
path: `/app/installations/${installation.id}/access_tokens`,
|
|
request,
|
|
token: appJwt,
|
|
});
|
|
if (typeof result.token !== "string" || result.token.length === 0) {
|
|
throw new Error("GitHub App did not return an installation token.");
|
|
}
|
|
return result.token;
|
|
};
|
|
|
|
export const publishIssueReport = async (
|
|
input: IssueReportInput,
|
|
{
|
|
getInstallationToken,
|
|
request = fetch,
|
|
}: {
|
|
getInstallationToken: () => Promise<string>;
|
|
request?: typeof fetch;
|
|
}
|
|
): Promise<IssueReportResult> => {
|
|
const token = await getInstallationToken();
|
|
const searchUrl = new URL("https://api.github.com/search/issues");
|
|
searchUrl.searchParams.set(
|
|
"q",
|
|
`repo:${reportRepository} is:issue in:body "${getReportMarker(input.deduplicationKey)}"`
|
|
);
|
|
const search = await requestGitHub<GitHubSearchResponse>({
|
|
path: `${searchUrl.pathname}${searchUrl.search}`,
|
|
request,
|
|
token,
|
|
});
|
|
const existing = search.items?.[0];
|
|
if (existing !== undefined) {
|
|
return {
|
|
status: "existing",
|
|
issueNumber: existing.number,
|
|
issueUrl: existing.html_url,
|
|
};
|
|
}
|
|
|
|
const created = await requestGitHub<GitHubIssue>({
|
|
method: "POST",
|
|
path: `/repos/${reportRepository}/issues`,
|
|
request,
|
|
token,
|
|
body: {
|
|
title: input.title,
|
|
body: formatIssueReport(input),
|
|
},
|
|
});
|
|
return {
|
|
status: "created",
|
|
issueNumber: created.number,
|
|
issueUrl: created.html_url,
|
|
};
|
|
};
|
|
|
|
export const publishConfiguredIssueReport = async (input: IssueReportInput) => {
|
|
const appId = env.GITHUB_ISSUE_REPORT_APP_ID;
|
|
const privateKey = env.GITHUB_ISSUE_REPORT_PRIVATE_KEY;
|
|
if (appId === undefined || privateKey === undefined) {
|
|
throw new Error("GitHub issue reporting is not configured.");
|
|
}
|
|
return await publishIssueReport(input, {
|
|
getInstallationToken: async () =>
|
|
await createGitHubInstallationToken({
|
|
appId,
|
|
privateKey,
|
|
}),
|
|
});
|
|
};
|