fix(studio): move AI generation behind short-lived job requests

This commit is contained in:
Clintchiz committed 2026-10-19 20:11:49 +05:30
1 parent e8c0f06d8f
commit 58687d8679
7 files changed
+248 -8

No files matched your search

+1
View File
@@ -142,6 +142,7 @@ to APIMart when you request generation. The engine validates proposals and locks
before offering them for review. Failed requests leave the draft unchanged. before offering them for review. Failed requests leave the draft unchanged.
Requests allow two concurrent generations and ten starts per minute per server Requests allow two concurrent generations and ten starts per minute per server
process, with two total upstream requests (including repairs and temporary-failure retries), 8192 output tokens per attempt and a 60-second total deadline. process, with two total upstream requests (including repairs and temporary-failure retries), 8192 output tokens per attempt and a 60-second total deadline.
The Studio starts a background generation job and polls with short HTTP requests so the deployment gateway does not need to hold a connection open while APIMart responds. Cancel aborts the job. Results expire after five minutes; a server restart asks the user to generate again without changing their saved draft. Legacy API clients can still use synchronous requests.
Without APIMART_API_KEY, generation uses local rules and reports that in the UI; Without APIMART_API_KEY, generation uses local rules and reports that in the UI;
local rules do not interpret arbitrary product descriptions. Drafts remain in local rules do not interpret arbitrary product descriptions. Drafts remain in
browser storage; export before clearing browser data or moving to another origin. browser storage; export before clearing browser data or moving to another origin.
+129 -1
View File
@@ -166,7 +166,7 @@ async function readBounded(source: Request | Response, limit: number) {
} }
} }
export function studioAI(apiKey?: string, transport: typeof fetch = fetch) { function synchronousStudioAI(apiKey?: string, transport: typeof fetch = fetch) {
let active = 0, let active = 0,
windowStart = Date.now(), windowStart = Date.now(),
count = 0; count = 0;
@@ -200,6 +200,7 @@ export function studioAI(apiKey?: string, transport: typeof fetch = fetch) {
provider: "APIMart", provider: "APIMart",
model: APIMART_MODEL, model: APIMART_MODEL,
templateVersion: 1, templateVersion: 1,
delivery: "polling",
maxAttempts: 2, maxAttempts: 2,
maxOutputTokens: AI_OUTPUT_TOKENS, maxOutputTokens: AI_OUTPUT_TOKENS,
timeoutMs: AI_TIMEOUT_MS, timeoutMs: AI_TIMEOUT_MS,
@@ -348,3 +349,130 @@ export function studioAI(apiKey?: string, transport: typeof fetch = fetch) {
} }
}; };
} }
/** Short HTTP requests keep the deployment proxy out of the provider wait. */
export function studioAI(apiKey?: string, transport: typeof fetch = fetch) {
const run = synchronousStudioAI(apiKey, transport);
type Job = {
status: "pending" | "complete" | "failed" | "cancelled";
controller: AbortController;
expires: number;
result?: unknown;
error?: string;
};
const jobs = new Map<string, Job>();
const json = (value: unknown, status = 200) =>
Response.json(value, { status, headers: { "cache-control": "no-store" } });
return async (request: Request): Promise<Response | undefined> => {
const url = new URL(request.url);
const match =
/^\/design-studio\/api\/generate\/jobs\/([a-f0-9-]{36})$/.exec(
url.pathname,
);
const enqueue =
url.pathname === "/design-studio/api/generate" &&
request.method === "POST" &&
request.headers.get("prefer")?.includes("respond-async");
if (!apiKey || (!match && !enqueue)) return run(request);
const scheme = request.headers.get("x-forwarded-proto");
if (scheme === "http" || scheme === "https") url.protocol = scheme + ":";
if (
request.headers.get("origin") &&
request.headers.get("origin") !== url.origin
)
return json({ error: "Same-origin requests only." }, 403);
for (const [id, job] of jobs)
if (job.expires < Date.now()) {
job.controller.abort();
jobs.delete(id);
}
if (match) {
if (!["GET", "DELETE"].includes(request.method))
return json({ error: "Method not allowed." }, 405);
const job = jobs.get(match[1]!);
if (!job)
return json(
{
error:
"This generation job expired or the server restarted. Your draft is preserved; generate again.",
},
410,
);
if (request.method === "DELETE" && job.status === "pending") {
job.status = "cancelled";
job.error = "Generation cancelled. Your draft is unchanged.";
job.controller.abort();
}
return json({
status: job.status,
...(job.result ? { result: job.result } : {}),
...(job.error ? { error: job.error } : {}),
});
}
if (
!(request.headers.get("content-type") ?? "").startsWith(
"application/json",
)
)
return json({ error: "JSON required." }, 415);
if (
[...jobs.values()].filter((job) => job.status === "pending").length >= 2
)
return json({ error: "AI generation is busy. Try again shortly." }, 429);
let body: string;
try {
body = await readBounded(request, 2_000_000);
JSON.parse(body);
} catch {
return json({ error: "Invalid or oversized generation request." }, 400);
}
// Recheck after reading the body so concurrent uploads cannot bypass the limit.
if (
[...jobs.values()].filter((job) => job.status === "pending").length >= 2
)
return json({ error: "AI generation is busy. Try again shortly." }, 429);
if (jobs.size >= 20) {
const old = [...jobs].find(([, job]) => job.status !== "pending");
if (old) jobs.delete(old[0]);
}
const id = crypto.randomUUID();
const job: Job = {
status: "pending",
controller: new AbortController(),
expires: Date.now() + 5 * 60_000,
};
jobs.set(id, job);
const headers = new Headers(request.headers);
headers.delete("prefer");
const task = new Request(request.url, {
method: "POST",
headers,
body,
signal: job.controller.signal,
});
void run(task)
.then(async (response) => {
if (job.status !== "pending") return;
if (!response) throw Error("Generation is not configured.");
const result = await response.json();
if (response.ok) {
job.status = "complete";
job.result = result;
} else {
job.status = "failed";
job.error =
typeof result.error === "string"
? result.error
: "Generation did not complete.";
}
})
.catch(() => {
if (job.status === "pending") {
job.status = "failed";
job.error =
"Generation could not complete. Your draft is preserved; try again.";
}
});
return json({ job: { id, status: "pending" } }, 202);
};
}
+2 -2
View File
@@ -7,7 +7,7 @@
"dependencies": { "dependencies": {
"@wrnexus/core": "0.8.25", "@wrnexus/core": "0.8.25",
"@wrnexus/csr": "0.8.75", "@wrnexus/csr": "0.8.75",
"@wrnexus/design-studio": "0.1.6", "@wrnexus/design-studio": "0.1.7",
"@wrnexus/design-system": "0.1.2", "@wrnexus/design-system": "0.1.2",
"@wrnexus/dev-server": "0.8.84", "@wrnexus/dev-server": "0.8.84",
"@wrnexus/helpers": "0.8.10", "@wrnexus/helpers": "0.8.10",
@@ -165,7 +165,7 @@
"@wrnexus/db": ["@wrnexus/db@0.8.24", "https://registry.workroot.in/repository/npm/@wrnexus/db/-/db-0.8.24.tgz", {}, "sha512-zLT7e+C1nWhYs3BnqEjEO0QSli0JNl5sSnSHObjSX0C+wty30ASWD1WyBnfu9xFjOFn1eucZGthzo1xdGGQ1TQ=="], "@wrnexus/db": ["@wrnexus/db@0.8.24", "https://registry.workroot.in/repository/npm/@wrnexus/db/-/db-0.8.24.tgz", {}, "sha512-zLT7e+C1nWhYs3BnqEjEO0QSli0JNl5sSnSHObjSX0C+wty30ASWD1WyBnfu9xFjOFn1eucZGthzo1xdGGQ1TQ=="],
"@wrnexus/design-studio": ["@wrnexus/design-studio@0.1.6", "https://registry.workroot.in/repository/npm/@wrnexus/design-studio/-/design-studio-0.1.6.tgz", { "dependencies": { "@iconify-json/lucide": "1.2.123", "@wrnexus/ai": "^0.8.10", "@wrnexus/compiler": "^0.8.45", "@wrnexus/core": "^0.8.25", "@wrnexus/csr": "^0.8.75", "@wrnexus/design-system": "^0.1.2", "@wrnexus/ssr": "^0.8.20", "@wrnexus/styles": "^0.8.27", "@wrnexus/ui": "^0.8.122" }, "bin": { "wrnexus-design-studio": "dist/cli.js" } }, "sha512-qteSY3PJmvPfU8poj528pJXvv5HM+EEruzA0CIlJrSjJPT6TDyDjxDrEDVzehp1GFGRzORMZnhC9TW8bEnzpNg=="], "@wrnexus/design-studio": ["@wrnexus/design-studio@0.1.7", "https://registry.workroot.in/repository/npm/@wrnexus/design-studio/-/design-studio-0.1.7.tgz", { "dependencies": { "@iconify-json/lucide": "1.2.123", "@wrnexus/ai": "^0.8.10", "@wrnexus/compiler": "^0.8.45", "@wrnexus/core": "^0.8.25", "@wrnexus/csr": "^0.8.75", "@wrnexus/design-system": "^0.1.2", "@wrnexus/ssr": "^0.8.20", "@wrnexus/styles": "^0.8.27", "@wrnexus/ui": "^0.8.122" }, "bin": { "wrnexus-design-studio": "dist/cli.js" } }, "sha512-K5EGiEHjif/pLFtmaGkIOZ7+RskJ+q0/kUSCj8KNvuNk4qNui+7KhoCYwye5mCfMVUtlw6q1TIv7gPT0ffe9wQ=="],
"@wrnexus/design-system": ["@wrnexus/design-system@0.1.2", "https://registry.workroot.in/repository/npm/@wrnexus/design-system/-/design-system-0.1.2.tgz", {}, "sha512-43/1Pibj91xdd0HxIhkfwEChjgpK+WQutEwo4DLAd9oELFqqherlrmx47TXs3Qzq9vTCJ2uD6mW6zo9VaqkY2g=="], "@wrnexus/design-system": ["@wrnexus/design-system@0.1.2", "https://registry.workroot.in/repository/npm/@wrnexus/design-system/-/design-system-0.1.2.tgz", {}, "sha512-43/1Pibj91xdd0HxIhkfwEChjgpK+WQutEwo4DLAd9oELFqqherlrmx47TXs3Qzq9vTCJ2uD6mW6zo9VaqkY2g=="],
+4 -4
View File
@@ -10,7 +10,7 @@
"dependencies": { "dependencies": {
"@wrnexus/core": "0.8.25", "@wrnexus/core": "0.8.25",
"@wrnexus/csr": "0.8.75", "@wrnexus/csr": "0.8.75",
"@wrnexus/design-studio": "0.1.6", "@wrnexus/design-studio": "0.1.7",
"@wrnexus/design-system": "0.1.2", "@wrnexus/design-system": "0.1.2",
"@wrnexus/dev-server": "0.8.84", "@wrnexus/dev-server": "0.8.84",
"@wrnexus/helpers": "0.8.10", "@wrnexus/helpers": "0.8.10",
@@ -1186,9 +1186,9 @@
} }
}, },
"node_modules/@wrnexus/design-studio": { "node_modules/@wrnexus/design-studio": {
"version": "0.1.6", "version": "0.1.7",
"resolved": "https://registry.workroot.in/repository/npm/@wrnexus/design-studio/-/design-studio-0.1.6.tgz", "resolved": "https://registry.workroot.in/repository/npm/@wrnexus/design-studio/-/design-studio-0.1.7.tgz",
"integrity": "sha512-qteSY3PJmvPfU8poj528pJXvv5HM+EEruzA0CIlJrSjJPT6TDyDjxDrEDVzehp1GFGRzORMZnhC9TW8bEnzpNg==", "integrity": "sha512-K5EGiEHjif/pLFtmaGkIOZ7+RskJ+q0/kUSCj8KNvuNk4qNui+7KhoCYwye5mCfMVUtlw6q1TIv7gPT0ffe9wQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@iconify-json/lucide": "1.2.123", "@iconify-json/lucide": "1.2.123",
+1 -1
View File
@@ -23,7 +23,7 @@
"dependencies": { "dependencies": {
"@wrnexus/core": "0.8.25", "@wrnexus/core": "0.8.25",
"@wrnexus/csr": "0.8.75", "@wrnexus/csr": "0.8.75",
"@wrnexus/design-studio": "0.1.6", "@wrnexus/design-studio": "0.1.7",
"@wrnexus/design-system": "0.1.2", "@wrnexus/design-system": "0.1.2",
"@wrnexus/dev-server": "0.8.84", "@wrnexus/dev-server": "0.8.84",
"@wrnexus/helpers": "0.8.10", "@wrnexus/helpers": "0.8.10",
+23
View File
@@ -36,6 +36,13 @@ try {
]) { ]) {
const response = await fetch(origin + path); const response = await fetch(origin + path);
if (!response.ok) throw Error(`${path} returned ${response.status}`); if (!response.ok) throw Error(`${path} returned ${response.status}`);
if (
path === "/design-studio/studio.js" &&
!(await response.clone().text()).includes("respond-async")
)
throw Error(
"The deployed Studio client is missing background generation polling.",
);
if (path === "/design-studio/") { if (path === "/design-studio/") {
const html = await response.clone().text(); const html = await response.clone().text();
const presetMenu = const presetMenu =
@@ -97,6 +104,7 @@ try {
process.env.APIMART_API_KEY?.trim() && process.env.APIMART_API_KEY?.trim() &&
(generation.provider !== "APIMart" || (generation.provider !== "APIMart" ||
generation.model !== "gpt-6-luna" || generation.model !== "gpt-6-luna" ||
generation.delivery !== "polling" ||
generation.maxAttempts !== 2 || generation.maxAttempts !== 2 ||
generation.maxOutputTokens !== 8192 || generation.maxOutputTokens !== 8192 ||
generation.timeoutMs !== 60_000) generation.timeoutMs !== 60_000)
@@ -104,6 +112,21 @@ try {
throw Error( throw Error(
"The compiled app did not pick up its server-side AI configuration.", "The compiled app did not pick up its server-side AI configuration.",
); );
if (process.env.APIMART_API_KEY?.trim()) {
const missingJob = await fetch(
origin +
"/design-studio/api/generate/jobs/00000000-0000-4000-8000-000000000000",
);
if (
missingJob.status !== 410 ||
!(missingJob.headers.get("content-type") ?? "").includes(
"application/json",
)
)
throw Error(
"The compiled app did not serve the background generation job endpoint.",
);
}
if (application.enabled !== false) if (application.enabled !== false)
throw Error("Hosted Studio must remain export-only."); throw Error("Hosted Studio must remain export-only.");
const preview = await fetch(origin + "/design-studio/api/preview", { const preview = await fetch(origin + "/design-studio/api/preview", {
+88
View File
@@ -319,3 +319,91 @@ test("a retry followed by an invalid design does not spend a third request or mi
expect(batch.diagnostics[0].code).toBe("WRN-STUDIO-PROPOSAL"); expect(batch.diagnostics[0].code).toBe("WRN-STUDIO-PROPOSAL");
expect(batch.diagnostics[0].message).toContain("schema"); expect(batch.diagnostics[0].message).toContain("schema");
}); });
test("async generation acknowledges immediately and polling retrieves the validated batch", async () => {
let finish!: () => void;
let entered!: () => void;
const started = new Promise<void>((resolve) => {
entered = resolve;
});
const blocked = new Promise<void>((resolve) => {
finish = resolve;
});
const handler = studioAI(
"test-secret",
mockFetch(async () => {
entered();
await blocked;
return completion();
}),
);
const original = await request();
const headers = new Headers(original.headers);
headers.set("prefer", "respond-async");
const accepted = await handler(new Request(original, { headers }));
expect(accepted!.status).toBe(202);
const { job } = await accepted!.json();
await started;
const url = `https://components.example/design-studio/api/generate/jobs/${job.id}`;
expect((await (await handler(new Request(url)))!.json()).status).toBe(
"pending",
);
expect(
(await handler(
new Request(url, { headers: { origin: "https://other.example" } }),
))!.status,
).toBe(403);
finish();
let result: any;
for (let i = 0; i < 50; i++) {
result = await (await handler(new Request(url)))!.json();
if (result.status !== "pending") break;
await Bun.sleep(10);
}
expect(result.status).toBe("complete");
expect(result.result.alternatives).toHaveLength(3);
expect(
(await handler(
new Request(url.replace(job.id, "00000000-0000-4000-8000-000000000000")),
))!.status,
).toBe(410);
});
test("background generation cancellation aborts the provider and leaves no proposals", async () => {
let entered!: () => void;
const started = new Promise<void>((resolve) => {
entered = resolve;
});
let aborted = false;
const handler = studioAI(
"test-secret",
mockFetch(async (_url, init) => {
entered();
return await new Promise<Response>((_, reject) => {
init!.signal!.addEventListener(
"abort",
() => {
aborted = true;
reject(new DOMException("cancelled", "AbortError"));
},
{ once: true },
);
});
}),
);
const original = await request();
const headers = new Headers(original.headers);
headers.set("prefer", "respond-async");
const { job } = await (await handler(
new Request(original, { headers }),
))!.json();
await started;
const response = await handler(
new Request(
`https://components.example/design-studio/api/generate/jobs/${job.id}`,
{ method: "DELETE" },
),
);
expect((await response!.json()).status).toBe("cancelled");
expect(aborted).toBe(true);
});