From 58687d8679ddfc91bb6e17f297b2ede6eb181126 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Mon, 19 Oct 2026 20:11:49 +0530 Subject: [PATCH] fix(studio): move AI generation behind short-lived job requests --- README.md | 1 + app/lib/studio-ai.ts | 130 ++++++++++++++++++++++++++++++++++- bun.lock | 4 +- package-lock.json | 8 +-- package.json | 2 +- scripts/verify-deployment.ts | 23 +++++++ test/studio-ai.test.ts | 88 ++++++++++++++++++++++++ 7 files changed, 248 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1074fef..847d423 100644 --- a/README.md +++ b/README.md @@ -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. 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. +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; local rules do not interpret arbitrary product descriptions. Drafts remain in browser storage; export before clearing browser data or moving to another origin. diff --git a/app/lib/studio-ai.ts b/app/lib/studio-ai.ts index f78efa3..90c36c1 100644 --- a/app/lib/studio-ai.ts +++ b/app/lib/studio-ai.ts @@ -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, windowStart = Date.now(), count = 0; @@ -200,6 +200,7 @@ export function studioAI(apiKey?: string, transport: typeof fetch = fetch) { provider: "APIMart", model: APIMART_MODEL, templateVersion: 1, + delivery: "polling", maxAttempts: 2, maxOutputTokens: AI_OUTPUT_TOKENS, 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(); + const json = (value: unknown, status = 200) => + Response.json(value, { status, headers: { "cache-control": "no-store" } }); + return async (request: Request): Promise => { + 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); + }; +} diff --git a/bun.lock b/bun.lock index 0087236..fdaae0f 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@wrnexus/core": "0.8.25", "@wrnexus/csr": "0.8.75", - "@wrnexus/design-studio": "0.1.6", + "@wrnexus/design-studio": "0.1.7", "@wrnexus/design-system": "0.1.2", "@wrnexus/dev-server": "0.8.84", "@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/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=="], diff --git a/package-lock.json b/package-lock.json index a594c22..7582b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@wrnexus/core": "0.8.25", "@wrnexus/csr": "0.8.75", - "@wrnexus/design-studio": "0.1.6", + "@wrnexus/design-studio": "0.1.7", "@wrnexus/design-system": "0.1.2", "@wrnexus/dev-server": "0.8.84", "@wrnexus/helpers": "0.8.10", @@ -1186,9 +1186,9 @@ } }, "node_modules/@wrnexus/design-studio": { - "version": "0.1.6", - "resolved": "https://registry.workroot.in/repository/npm/@wrnexus/design-studio/-/design-studio-0.1.6.tgz", - "integrity": "sha512-qteSY3PJmvPfU8poj528pJXvv5HM+EEruzA0CIlJrSjJPT6TDyDjxDrEDVzehp1GFGRzORMZnhC9TW8bEnzpNg==", + "version": "0.1.7", + "resolved": "https://registry.workroot.in/repository/npm/@wrnexus/design-studio/-/design-studio-0.1.7.tgz", + "integrity": "sha512-K5EGiEHjif/pLFtmaGkIOZ7+RskJ+q0/kUSCj8KNvuNk4qNui+7KhoCYwye5mCfMVUtlw6q1TIv7gPT0ffe9wQ==", "license": "MIT", "dependencies": { "@iconify-json/lucide": "1.2.123", diff --git a/package.json b/package.json index 8712839..f8d8ac1 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "dependencies": { "@wrnexus/core": "0.8.25", "@wrnexus/csr": "0.8.75", - "@wrnexus/design-studio": "0.1.6", + "@wrnexus/design-studio": "0.1.7", "@wrnexus/design-system": "0.1.2", "@wrnexus/dev-server": "0.8.84", "@wrnexus/helpers": "0.8.10", diff --git a/scripts/verify-deployment.ts b/scripts/verify-deployment.ts index 4e19a88..f9d5e47 100644 --- a/scripts/verify-deployment.ts +++ b/scripts/verify-deployment.ts @@ -36,6 +36,13 @@ try { ]) { const response = await fetch(origin + path); 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/") { const html = await response.clone().text(); const presetMenu = @@ -97,6 +104,7 @@ try { process.env.APIMART_API_KEY?.trim() && (generation.provider !== "APIMart" || generation.model !== "gpt-6-luna" || + generation.delivery !== "polling" || generation.maxAttempts !== 2 || generation.maxOutputTokens !== 8192 || generation.timeoutMs !== 60_000) @@ -104,6 +112,21 @@ try { throw Error( "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) throw Error("Hosted Studio must remain export-only."); const preview = await fetch(origin + "/design-studio/api/preview", { diff --git a/test/studio-ai.test.ts b/test/studio-ai.test.ts index a0689b1..658387c 100644 --- a/test/studio-ai.test.ts +++ b/test/studio-ai.test.ts @@ -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].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((resolve) => { + entered = resolve; + }); + const blocked = new Promise((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((resolve) => { + entered = resolve; + }); + let aborted = false; + const handler = studioAI( + "test-secret", + mockFetch(async (_url, init) => { + entered(); + return await new Promise((_, 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); +});