From 3539dff45c583439dd2a0e9a825f2d795e253ea1 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Mon, 19 Oct 2026 20:08:06 +0530 Subject: [PATCH] fix(studio): poll background generation and handle gateway failures --- packages/design-studio/package.json | 2 +- .../design-studio/src/generation-transport.ts | 72 +++++++++++++++++++ packages/design-studio/src/guidance.ts | 14 ++-- .../test/generation-transport.test.ts | 66 +++++++++++++++++ 4 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 packages/design-studio/src/generation-transport.ts create mode 100644 packages/design-studio/test/generation-transport.test.ts diff --git a/packages/design-studio/package.json b/packages/design-studio/package.json index bd3c91f3..77e1c1d1 100644 --- a/packages/design-studio/package.json +++ b/packages/design-studio/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/design-studio", - "version": "0.1.6", + "version": "0.1.7", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/design-studio/src/generation-transport.ts b/packages/design-studio/src/generation-transport.ts new file mode 100644 index 00000000..73aa9f52 --- /dev/null +++ b/packages/design-studio/src/generation-transport.ts @@ -0,0 +1,72 @@ +/** Read gateway failures without exposing an HTML error page as a JSON parse error. */ +export async function generationResponse(response: Response): Promise { + const text = await response.text(); + let value: any; + try { + value = JSON.parse(text); + } catch { + throw Error( + [502, 503, 504].includes(response.status) + ? `The deployment gateway could not complete this request (HTTP ${response.status}). Try again shortly.` + : `The generation server returned an unreadable response (HTTP ${response.status}). Reload after deploying the latest server.`, + ); + } + if (!response.ok) + throw Error( + typeof value?.error === "string" + ? value.error + : `Generation request failed (HTTP ${response.status}).`, + ); + return value; +} + +export async function requestGeneration( + url: URL, + input: unknown, + signal: AbortSignal, + transport: typeof fetch = fetch, +) { + let jobUrl: URL | undefined; + const deadline = AbortSignal.any([signal, AbortSignal.timeout(75_000)]); + try { + let result = await generationResponse( + await transport(url, { + method: "POST", + headers: { "content-type": "application/json", prefer: "respond-async" }, + body: JSON.stringify(input), + signal: deadline, + }), + ); + if (!result.job) return result; // Compatible with local engines and older synchronous servers. + if (typeof result.job.id !== "string" || !/^[a-f0-9-]{36}$/.test(result.job.id)) + throw Error("Invalid generation job response."); + jobUrl = new URL(url.href.replace(/\/$/, "") + "/jobs/" + result.job.id); + while (true) { + await new Promise((resolve, reject) => { + const aborted = () => { + clearTimeout(timer); + reject(deadline.reason); + }; + const timer = setTimeout(() => { + deadline.removeEventListener("abort", aborted); + resolve(); + }, 1000); + if (deadline.aborted) aborted(); + else deadline.addEventListener("abort", aborted, { once: true }); + }); + result = await generationResponse( + await transport(jobUrl, { signal: deadline, cache: "no-store" }), + ); + if (result.status === "complete") return result.result; + if (result.status === "failed" || result.status === "cancelled") + throw Error(result.error ?? "Generation did not complete."); + if (result.status !== "pending") throw Error("Invalid generation job status."); + } + } finally { + if (jobUrl && deadline.aborted) { + void transport(jobUrl, { method: "DELETE", signal: AbortSignal.timeout(3000) }).catch( + () => {}, + ); + } + } +} diff --git a/packages/design-studio/src/guidance.ts b/packages/design-studio/src/guidance.ts index ee05f44f..ae166bcc 100644 --- a/packages/design-studio/src/guidance.ts +++ b/packages/design-studio/src/guidance.ts @@ -1,3 +1,4 @@ +import { requestGeneration } from "./generation-transport.ts"; import { DEFAULT_DESIGN_BRIEF, type DesignBrief, @@ -280,21 +281,18 @@ export function setupGuidance( render(); try { await hooks.review?.(undefined); - const response = await fetch(new URL("./api/generate", document.baseURI), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ + const result = await requestGeneration( + new URL("./api/generate", document.baseURI), + { system, locks, brief: requestedBrief, baseRevision, kind, instruction: kind === "edit" ? value("edit-instruction").value : undefined, - }), + }, signal, - }); - const result = await response.json(); - if (!response.ok) throw Error(result.error ?? "Generation failed."); + ); if (current !== epoch || signal.aborted) return; if (result.diagnostics.length) { status = diff --git a/packages/design-studio/test/generation-transport.test.ts b/packages/design-studio/test/generation-transport.test.ts new file mode 100644 index 00000000..aece61d1 --- /dev/null +++ b/packages/design-studio/test/generation-transport.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test"; +import { generationResponse, requestGeneration } from "../src/generation-transport.ts"; + +test("HTML gateway failures produce a clear diagnostic rather than a JSON syntax error", async () => { + for (const status of [502, 503, 504]) + await expect( + generationResponse(new Response("Gateway error", { status })), + ).rejects.toThrow(`HTTP ${status}`); + await expect( + generationResponse(new Response("private proxy text", { status: 504 })), + ).rejects.not.toThrow("private proxy text"); +}); +test("generation uses short job polls and preserves legacy response compatibility", async () => { + const batch = { alternatives: [1, 2, 3], diagnostics: [] }; + let calls = 0; + const transport = (async (_url: unknown, init?: RequestInit) => { + calls++; + if (calls === 1) { + expect(new Headers(init?.headers).get("prefer")).toBe("respond-async"); + return Response.json( + { job: { id: "11111111-1111-4111-8111-111111111111" } }, + { status: 202 }, + ); + } + expect(init?.method).toBeUndefined(); + return Response.json({ status: "complete", result: batch }); + }) as unknown as typeof fetch; + expect( + await requestGeneration( + new URL("https://example.test/design-studio/api/generate"), + {}, + new AbortController().signal, + transport, + ), + ).toEqual(batch); + expect(calls).toBe(2); + expect( + await requestGeneration( + new URL("https://example.test/api/generate"), + {}, + new AbortController().signal, + (async () => Response.json(batch)) as unknown as typeof fetch, + ), + ).toEqual(batch); +}); +test("cancelling a pending job sends cancellation without replacing a draft", async () => { + const controller = new AbortController(); + let cancelled = false; + const transport = (async (_url: unknown, init?: RequestInit) => { + if (init?.method === "DELETE") { + cancelled = true; + return Response.json({ status: "cancelled" }); + } + controller.abort(); + return Response.json({ job: { id: "11111111-1111-4111-8111-111111111111" } }, { status: 202 }); + }) as unknown as typeof fetch; + await expect( + requestGeneration( + new URL("https://example.test/api/generate"), + {}, + controller.signal, + transport, + ), + ).rejects.toBeDefined(); + expect(cancelled).toBe(true); +});