Files
webstudio/apps/builder/app/shared/sync/project-queue.ts
Oleg Isonen dde30ffb54 feat: Improve MCP authoring, compact output, and agent onboarding (#5883)
## Summary

This improves how agents discover, author, inspect, and verify Webstudio
projects while reducing unnecessary MCP context usage.

## What changed

- Adds an **Agents** section to Project Settings with ready-to-run setup
commands and client configuration for Claude Code, Codex, Cursor, and VS
Code.
- Makes large list and schema responses compact and paginated by
default, with **verbose** available when complete records or diagnostics
are needed.
- Applies consistent compact output to assets, instances, styles, design
tokens, CLI schemas, and project-session metadata.
- Returns actionable expression warnings for props, resources, and
Collection bindings, including unknown variables and risky render-time
mutation resources.
- Improves Collection expression guidance so agents can bind array items
with the correct scoped variables.
- Reports rendered audit outcomes explicitly as complete, partial,
confirmation-required, or failed.
- Expands runtime output schemas so MCP clients can validate responses
reliably.
- Strengthens the packaged CLI smoke workflow with a realistic
seven-page stdio MCP scenario.

## Architecture

- Consolidates `project-build` from 65 file-oriented entrypoints to 11
architecture-owned surfaces.
- Separates the core model, contracts, runtime authoring, transfer,
state, project sessions, MCP, visual tooling, persistence codecs, and
authenticated server services.
- Isolates Node-only JSX evaluation from browser-safe transfer utilities
and authenticated server modules.
- Keeps persistence codecs independent from authenticated database
services, preventing unrelated module initialization.
- Defines shared instance-path types once and reuses them across runtime
modules.
- Preserves Immer initialization through explicit package side-effect
metadata.
- Adds package tests that prevent a return to source-file entrypoints or
generic exposure of Node-only surfaces.

## Validation

- Full repository checks, tests, typechecks, generated-file checks, and
fixtures
- All 163 Builder test files and 2,141 tests
- All 71 project-build test files and 1,794 tests
- Lint and package-boundary validation
- Full packed CLI release smoke test, including install, preview, agent
setup, seven-page stdio MCP workflow, rendered audit, and structured
commands
2026-07-15 18:11:28 +02:00

412 lines
12 KiB
TypeScript

import { useEffect } from "react";
import { atom } from "nanostores";
import type { Project } from "@webstudio-is/project";
import type { Build } from "@webstudio-is/project-build";
import type { BuilderPatchChange } from "@webstudio-is/project-build/contracts";
import type { AuthPermit } from "@webstudio-is/trpc-interface/index.server";
import { toast } from "@webstudio-is/design-system";
import {
$hasUnsavedSyncChanges,
$syncStatus,
createBackoff,
createTransactionCompletionStore,
stripRevisePatchesFromTransaction,
type SyncStatus,
} from "@webstudio-is/sync-client";
import { publicStaticEnv } from "~/env/env.static";
import { createNativeClient, nativeClient } from "~/shared/trpc/trpc-client";
import * as commandQueue from "./command-queue";
import type { SyncStorage } from "~/shared/sync-client";
import type { Transaction } from "@webstudio-is/sync-client";
import type { ServerSyncState } from "./sync-stores";
export { commandQueue };
// Periodic check for new entries to group them into one job/call in sync queue.
const NEW_ENTRIES_INTERVAL = 1000;
// First attempts we will simply retry without changing the state or notifying anyone.
const INTERVAL_RECOVERY = 2000;
// After this amount of retries without success, we consider connection status error.
const MAX_RETRY_RECOVERY = 5;
// We are assuming that error is fatal (unrecoverable) after this amount of attempts with API error.
const MAX_ALLOWED_API_ERRORS = 5;
const pause = (timeout: number) => {
return new Promise((resolve) => setTimeout(resolve, timeout));
};
/** Last server-confirmed build version. Updated after each successful PATCH. */
export const $committedVersion = atom<number>(0);
const transactionCompletion = createTransactionCompletionStore();
export const $lastTransactionId = transactionCompletion.$lastTransactionId;
export const onTransactionComplete =
transactionCompletion.onTransactionComplete;
export const onNextTransactionComplete =
transactionCompletion.onNextTransactionComplete;
// polling is important to queue new transactions independently
// from async iterator and batch them into single job
const pollCommands = async function* () {
while (true) {
const commands = commandQueue.dequeueAll();
if (commands.length > 0) {
$syncStatus.set({ status: "syncing" });
yield* commands;
await pause(NEW_ENTRIES_INTERVAL);
// Do not switch on idle state until there is possibility that queue is not empty
continue;
}
$syncStatus.set({ status: "idle" });
await pause(NEW_ENTRIES_INTERVAL);
}
};
const retry = async function* () {
const backoff = createBackoff();
while (true) {
yield;
if (backoff.attempts() < MAX_RETRY_RECOVERY) {
backoff.next();
$syncStatus.set({ status: "recovering" });
await pause(INTERVAL_RECOVERY);
} else {
$syncStatus.set({ status: "failed" });
const delay = backoff.next();
toast.error(
`Builder is offline. Retry in ${Math.round(delay / 1000)} seconds.`
);
await pause(delay);
}
}
};
let isPolling = false;
let pollingAbortController: AbortController | undefined;
/**
* Start the queue polling loop.
* Called automatically by enqueueProjectDetails.
*/
const startPolling = () => {
if (pollingAbortController) {
return; // Already running
}
pollingAbortController = new AbortController();
pollQueue(pollingAbortController.signal);
};
/**
* Stop the queue polling loop.
* Call this to stop transaction synchronization (e.g., when closing a dialog).
*/
export const stopPolling = () => {
if (pollingAbortController) {
pollingAbortController.abort();
pollingAbortController = undefined;
}
isPolling = false;
};
/**
* Enqueue project details for synchronization.
* Called automatically by initializeClientSync after loading builder data.
*/
export const enqueueProjectDetails = ({
projectId,
buildId,
version,
authPermit,
authToken,
}: {
buildId: Build["id"];
projectId: Project["id"];
authToken: string | undefined;
version: number;
authPermit: AuthPermit;
}) => {
if (authPermit === "view") {
return;
}
commandQueue.enqueue({
type: "setDetails",
projectId,
buildId,
version,
authToken,
});
// Start polling after enqueuing to ensure command is in queue
startPolling();
};
const pollQueue = async (signal: AbortSignal) => {
if (isPolling) {
return;
}
isPolling = true;
const detailsMap = new Map<
Project["id"],
{
version: number;
buildId: Build["id"];
authToken: string | undefined;
}
>();
polling: for await (const command of pollCommands()) {
// Check if aborted
if (signal.aborted) {
isPolling = false;
break polling;
}
if (command.type === "setDetails") {
// At this moment we can be sure that all transactions for the command.projectId is already synchronized
// There are 2 options
// - Project opened again before transactions synchronized,
// in that case project is outdated and we need to ask user to reload it.
// - Project opened after sync, everything is ok.
if (command.version < (detailsMap.get(command.projectId)?.version ?? 0)) {
const error =
"The project is outdated. Synchronization was incomplete when the project was opened. " +
"Please reload the page to get the latest version.";
const shouldReload = confirm(error);
if (shouldReload) {
location.reload();
}
// stop synchronization and wait til user reload
$syncStatus.set({ status: "fatal", error });
if (shouldReload === false) {
toast.error(
"Synchronization has been paused. Please reload to continue.",
{ id: "outdated-error", duration: Number.POSITIVE_INFINITY }
);
}
break polling;
}
detailsMap.set(command.projectId, {
version: command.version,
buildId: command.buildId,
authToken: command.authToken,
});
continue;
}
const { projectId, transactions } = command;
const details = detailsMap.get(projectId);
if (details === undefined) {
const error =
"Project details not found. Synchronization has been paused. Please reload to continue.";
toast.error(error, {
id: "details-error",
duration: Number.POSITIVE_INFINITY,
});
$syncStatus.set({ status: "fatal", error });
return;
}
// We don't know how to handle api errors. Like parsing errors, etc.
// Let
let apiErrorCount = 0;
for await (const _ of retry()) {
// in case of any error continue retrying
try {
const optimizedTransactions = transactions.map(
stripRevisePatchesFromTransaction
);
const patchClient =
details.authToken === undefined
? nativeClient
: createNativeClient({ "x-auth-token": details.authToken });
const result = await patchClient.build.patch.mutate({
source: "browser",
appVersion: publicStaticEnv.VERSION,
authToken: details.authToken,
entries: optimizedTransactions.map((transaction) => ({
transaction,
})),
buildId: details.buildId,
projectId,
// provide latest stored version to server
version: details.version,
});
if (result) {
if (result.status === "ok") {
details.version = result.version ?? details.version + 1;
$committedVersion.set(details.version);
for (const transaction of transactions) {
transactionCompletion.completeTransaction(transaction.id, true);
}
// stop retrying and wait next transactions
continue polling;
}
// when versions mismatched ask user to reload
// user may cancel to copy own state before reloading
if (
result.status === "version_mismatched" ||
result.status === "authorization_error" ||
result.status === "partial"
) {
const error =
("errors" in result ? result.errors : undefined) ??
("entries" in result
? result.entries
.filter((entry) => entry.status !== "accepted")
.map((entry) => entry.errors)
.join("\n")
: undefined) ??
"Unknown version mismatch. Please reload.";
const shouldReload = confirm(error);
if (shouldReload) {
location.reload();
}
$syncStatus.set({ status: "fatal", error });
if (shouldReload === false) {
toast.error(
"Synchronization has been paused. Please reload to continue.",
{ duration: Number.POSITIVE_INFINITY }
);
}
// stop synchronization and wait til user reload
break polling;
}
if (apiErrorCount >= MAX_ALLOWED_API_ERRORS) {
const error = `Fatal error. ${
result.errors ?? JSON.stringify(result.errors)
} Synchronization has been paused.`;
// Api error we don't know how to handle, as retries will not help probably
// We should show error and break synchronization
$syncStatus.set({ status: "fatal", error });
toast.error(error, {
id: "fatal-error",
duration: Number.POSITIVE_INFINITY,
});
break polling;
}
apiErrorCount += 1;
}
} catch (error) {
if (navigator.onLine) {
// ERR_CONNECTION_REFUSED or like, probably restorable with retries
// anyway lets's log it
console.info(
error instanceof Error ? error.message : JSON.stringify(error)
);
}
}
}
}
};
export class ServerSyncStorage implements SyncStorage {
name = "server";
private projectId: string;
private initialState: ServerSyncState;
constructor(projectId: string, initialState: ServerSyncState) {
this.projectId = projectId;
this.initialState = initialState;
}
sendTransaction(transaction: Transaction<BuilderPatchChange[]>) {
if (transaction.object === "server") {
$lastTransactionId.set(transaction.id);
$syncStatus.set({ status: "syncing" });
commandQueue.enqueue({
type: "transactions",
transactions: [transaction],
projectId: this.projectId,
});
}
}
subscribe(setState: (state: unknown) => void, signal: AbortSignal) {
if (signal.aborted) {
return;
}
setState(new Map([["server", this.initialState]]));
}
}
/**
* Promisify idle state of the queue for a one-off notification when everything is saved.
*/
export const isSyncIdle = () => {
return new Promise<SyncStatus>((resolve, reject) => {
const handle = (status: SyncStatus) => {
if (status.status === "idle") {
resolve(status);
return true;
}
if (status.status === "fatal") {
reject(
new Error(
"Synchronization is in fatal state. Please reload the page or check your internet connection."
)
);
return true;
}
return false;
};
const status = $syncStatus.get();
if (handle(status) === false) {
const unsubscribe = $syncStatus.subscribe((status) => {
if (handle(status)) {
unsubscribe();
}
});
}
});
};
export const usePreventUnload = () => {
useEffect(() => {
const handler = (event: BeforeUnloadEvent) => {
if ($hasUnsavedSyncChanges.get() === false) {
return;
}
event.preventDefault();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, []);
};
export const __testing__ = {
pollCommands,
retry,
transactionCallbacks: transactionCompletion.callbacks,
};