From 5bf0ae2a83470cdb8034af0de9cdc0339ed74661 Mon Sep 17 00:00:00 2001 From: Anurag Goel Date: Thu, 24 Sep 2026 08:46:22 -0700 Subject: [PATCH] Build more than one app at the same time from the UI The gateway already accepted three runs at the same time, but the UI disabled the submit button while the selected run was running. It also polled only the selected run, so the history did not show the progress of the other runs. - The UI disables the submit button only while the gateway accepts a prompt. It reads the list of runs every 5 seconds while a run or a delete is in progress, and it renders the history and the selected run from that list. The history shows the stage of each run that builds. - GET /ui/apps reconciles each run that a task owns, as a read of one run does. Thus a run whose task failed releases its concurrency slot when the UI refreshes, also when nobody selects that run. - The 429 response gives a detail that states the limit. The form shows it below the prompt, and the delete dialog shows the error of a delete that fails. Each refresh renders the run panel again, so the run panel cannot keep these errors. - claimRunApp refuses an app name while a different run builds that app. The two runs would write the same directory, and each run would wait for the deploys of the other. - The gateway imports the Render SDK statically. Vitest can give the real module to two dynamic imports of a mocked module that run at the same time. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 18 ++- app/gateway.ts | 36 ++++-- app/store.ts | 41 +++++-- app/workflow.ts | 12 +- docs/FAQ.md | 5 +- docs/README.md | 10 +- public/app.js | 5 +- public/index.html | 2 + public/runs.js | 241 +++++++++++++++++++++++------------------ public/style.css | 11 ++ public/table.html | 2 + public/table.js | 11 +- tests/gateway.test.ts | 68 ++++++++++++ tests/workflow.test.ts | 30 ++++- 14 files changed, 344 insertions(+), 148 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4a80625..2416e56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,14 @@ and is injected as the app namespace; never accept a browser-supplied `user`. deletes only a run in that namespace, and the UI restores selection from local storage while treating Postgres as the source of truth. +Runs build in parallel, up to `maxConcurrentRuns`. The UI disables the submit +button only while the gateway accepts a prompt. It reads `GET /ui/apps` again +every 5 seconds while a run or a delete is in progress, and renders the +history and the selected run from that list. It does not poll each run, so +the list reconciles each run that a task owns, as `GET /ui/apps/:runId` does. +Keep the error of a submit in the form and the error of a delete in its +dialog: each refresh renders the run panel again. + The UI has two views of the same runs, and each has a button that opens the other. The classic view at `/` explains each stage in a tooltip. The table view at `/table` shows the sites in a table, with each URL, the time that each @@ -259,8 +267,8 @@ Do not weaken these without an explicit security-model change: then the project, which Render deletes only when it is empty. - A delete claims every run of one app, and `claimRunApp` claims an app name for a run. Both take the same Postgres advisory lock. So no run builds an app - while a delete of it is in progress, and a delete is refused while a run of - the app is running. + while a delete of it is in progress, a delete is refused while a run of the + app is running, and no two runs build the same app at one time. - Verification is workflow-owned. The `verify-app` subtask runs the same install, build, and pre-deploy commands the Blueprint gives Render, against a real Postgres running in the sandbox, and `publish-app` runs only after it @@ -283,9 +291,9 @@ Do not weaken these without an explicit security-model change: There is no step memoization. A failed run is not resumed; the caller retries by posting the prompt again. The gateway persists the Render task-run ID. -While a caller polls, it marks a run failed when its task run failed or was -canceled, so an interrupted task cannot leave a database row `running` -forever. A task that succeeds writes its result before it returns, so the +While a caller polls a run, or the UI polls its list of runs, the gateway +marks a run failed when its task run failed or was canceled, so an +interrupted task cannot leave a database row `running` forever. A task that succeeds writes its result before it returns, so the gateway does not read the result. Long service, deploy, and HTTP waits heartbeat `progress`; keep those waits bounded. diff --git a/app/gateway.ts b/app/gateway.ts index f1e0777..53fb942 100644 --- a/app/gateway.ts +++ b/app/gateway.ts @@ -7,10 +7,12 @@ */ import { randomUUID } from "node:crypto"; import { serveStatic } from "@hono/node-server/serve-static"; +import { Render } from "@renderinc/sdk"; import { Hono, type Context, type MiddlewareHandler } from "hono"; import { basicAuth } from "hono/basic-auth"; import { bearerAuth } from "hono/bearer-auth"; import { bodyLimit } from "hono/body-limit"; +import { factoryConfig } from "../factory.config.js"; import { apiKey, uiCredentials } from "./config.js"; import { createAppRequestSchema } from "./contracts.js"; import { redactSecrets } from "./policy.js"; @@ -102,13 +104,22 @@ export function createGateway(): Hono { return app; } +/** + * The UI polls only this list, also while runs build in parallel. So the list + * reconciles each run that a task owns, as a read of one run does. + */ async function listRuns( c: Context, user: string, workflowId: WorkflowIdReader, ): Promise { try { - const runs = await listRunsByUser(user); + let runs = await listRunsByUser(user); + const owned = runs.filter(ownedByTask); + if (owned.length > 0) { + await Promise.all(owned.map(reconcileWorkflowRun)); + runs = await listRunsByUser(user); + } const id = workflowId( runs.find((run) => run.workflowRunId)?.workflowRunId ?? null, ); @@ -162,7 +173,13 @@ async function createRun( if (!claim.claimed) { return claim.reason === "duplicate" ? c.json({ runId: claim.runId, duplicate: true }, 200) - : c.json({ error: "too many concurrent runs" }, 429); + : c.json( + { + error: "too many concurrent runs", + detail: `The factory builds at most ${factoryConfig.maxConcurrentRuns} apps at a time, for all users. Submit the prompt again when a run finishes.`, + }, + 429, + ); } const workflowRunId = await dispatchWorkflow(TASK_NAME, { @@ -196,10 +213,7 @@ async function readRun( try { let run = await getRun(runId); if (!run) return c.json({ error: "not found" }, 404); - if ( - (run.status === "running" || run.status === "deleting") && - run.workflowRunId - ) { + if (ownedByTask(run)) { await reconcileWorkflowRun(run); const current = await getRun(runId); // A delete that finished removed the run. @@ -380,6 +394,14 @@ function workflowIdReader(): WorkflowIdReader { }; } +/** A task run owns the status of this run: prompt-to-app or delete-app. */ +function ownedByTask(run: RunRecord): boolean { + return ( + (run.status === "running" || run.status === "deleting") && + Boolean(run.workflowRunId) + ); +} + /** * A timeout, a crash, or a cancel stops a task before its own catch block, * so its row stays running or deleting. Mark that row failed. A task that @@ -389,7 +411,6 @@ async function reconcileWorkflowRun(run: RunRecord): Promise { if (!run.workflowRunId || !(await claimWorkflowCheck(run.id))) return; try { - const { Render } = await import("@renderinc/sdk"); const taskRun = await new Render().workflows.getTaskRun(run.workflowRunId); if (taskRun.status !== "failed" && taskRun.status !== "canceled") return; @@ -428,7 +449,6 @@ export async function dispatchWorkflow( } try { - const { Render } = await import("@renderinc/sdk"); const started = await new Render().workflows.startTask( `${slug}/${taskName}`, [payload], diff --git a/app/store.ts b/app/store.ts index 91d9efc..e3f3b64 100644 --- a/app/store.ts +++ b/app/store.ts @@ -70,6 +70,13 @@ export type ClaimResult = | { claimed: false; reason: "duplicate"; runId: string } | { claimed: false; reason: "at_capacity" }; +export type AppClaim = + | { claimed: true } + /** A delete of the app is in progress. */ + | { claimed: false; reason: "deleting" } + /** A different run builds the app now. */ + | { claimed: false; reason: "running" }; + export type DeleteClaim = | { claimed: true; runIds: string[] } /** A delete of the app is already in progress. */ @@ -110,7 +117,8 @@ export async function ping(): Promise { /** * Run statements in one transaction that holds the lock of one app. A delete * takes the lock to claim the runs of the app, and a run takes it to claim an - * app name. So a run cannot start to build an app while it is being deleted. + * app name. So a run cannot start to build an app while it is being deleted, + * or while a different run builds it. */ async function withAppLock( user: string, @@ -216,24 +224,33 @@ export async function claimWorkflowCheck(id: string): Promise { /** * Record the app that a run builds. Refused while a delete of the same app is - * in progress, because the delete removes what this run publishes. + * in progress, because the delete removes what this run publishes. Also + * refused while a different run builds the app: the two runs would write the + * same directory, and each one would wait for the deploys of the other. */ export async function claimRunApp( id: string, user: string, app: { appName: string; blueprintPath: string }, -): Promise { +): Promise { return withAppLock(user, app.appName, async (client) => { - const result = await client.query( - `update runs set app_name = $3, blueprint_path = $4, updated_at = now() - where id = $1 - and not exists ( - select 1 from runs - where user_name = $2 and app_name = $3 and status = 'deleting' - )`, - [id, user, app.appName, app.blueprintPath], + const { rows } = await client.query<{ status: RunStatus }>( + `select status from runs + where user_name = $1 and app_name = $2 and id <> $3 + and status in ('running', 'deleting')`, + [user, app.appName, id], + ); + if (rows.some((row) => row.status === "deleting")) { + return { claimed: false, reason: "deleting" }; + } + if (rows.length > 0) return { claimed: false, reason: "running" }; + + await client.query( + `update runs set app_name = $2, blueprint_path = $3, updated_at = now() + where id = $1`, + [id, app.appName, app.blueprintPath], ); - return result.rowCount === 1; + return { claimed: true }; }); } diff --git a/app/workflow.ts b/app/workflow.ts index dbf2975..534bc1c 100644 --- a/app/workflow.ts +++ b/app/workflow.ts @@ -121,12 +121,16 @@ async function run( const appName = plan.appName; const blueprintPath = `${appRelativePath(user, appName)}/render.yaml`; - // Returned, not thrown: a delete in progress is an expected result, not a - // fault in the run. - if (!(await claimRunApp(runId, user, { appName, blueprintPath }))) { + // Returned, not thrown: a delete in progress, or a different run of the + // same app, is an expected result, not a fault in the run. + const claim = await claimRunApp(runId, user, { appName, blueprintPath }); + if (!claim.claimed) { return { status: "failed", - summary: `${user}/${appName} is being deleted. Submit the prompt again when the delete finishes.`, + summary: + claim.reason === "deleting" + ? `${user}/${appName} is being deleted. Submit the prompt again when the delete finishes.` + : `A different run is building ${user}/${appName}. Submit the prompt again when that run finishes.`, }; } diff --git a/docs/FAQ.md b/docs/FAQ.md index 0d5247f..09425a8 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -176,7 +176,7 @@ A: The workflow has a repair loop — up to 2 build-fix rounds with the builder. A: The workflow reads the logs of each failed deploy and removes their secrets. The Deploy Manager agent diagnoses the issue from these logs and its read-only MCP tools, and hands it to the Builder for repair. Up to 2 deploy-repair rounds. After that it's `deploy_failed`. The workflow verifies each repair in the sandbox and writes its manifest back to `factory.json` and the Blueprints, so changed commands reach Render. After each repair push, it waits for a new deploy of each failed service. If a repair adds or removes a service or database, or changes the kind of a service, the run ends as `deploy_failed` with no push. If a repair changes no files, or Render starts no new deploy in 15 minutes, the run ends as `deploy_failed` at once. **Q: What if a run gets stuck?** -A: Heartbeats and deadlines prevent silent hangs. If the task of a run failed or was canceled before it wrote its result, for example at a timeout, the next status poll marks the run failed and releases its concurrency slot. `GET /v1/apps/:runId` always shows the current stage. +A: Heartbeats and deadlines prevent silent hangs. If the task of a run failed or was canceled before it wrote its result, for example at a timeout, the next status poll, or the next refresh of the UI, marks the run failed and releases its concurrency slot. `GET /v1/apps/:runId` always shows the current stage. **Q: How do I delete a generated app?** A: Select one of its runs in the UI and click **Delete app**, or send `DELETE /v1/apps/:runId`. The delete removes the app with all of its runs. The workflow takes the app out of the root Blueprint, waits until no Blueprint sync can bring its resources back, deletes its services, database, and project, and then removes its files from the apps repo. It takes a few minutes. The files stay in the Git history. If it ends as `delete_failed`, the summary says why; fix that and delete again. In the Render Dashboard, each step is a run of its own under the `delete-app` run, with its own logs, so you can see which step failed. @@ -184,6 +184,9 @@ A: Select one of its runs in the UI and click **Delete app**, or send `DELETE /v **Q: Can multiple people demo at once?** A: Yes, up to 3 concurrent runs (configurable). Each run gets its own sandbox and app namespace (`vibe---{web,api,db}`). Concurrent runs push to the same branch: a run whose push fails takes the new tip and makes its commit again. +**Q: Can I build more than one app at a time?** +A: Yes. Submit the next prompt while the first run builds. The history shows the stage of each run, and updates every 5 seconds while a run is in progress. When 3 runs are in progress, the gateway refuses a new prompt, and the UI tells why below the prompt. Two runs of the same app cannot build at one time: if the architect gives a new run the name of an app that a different run builds, the new run stops as `failed`. Submit it again when the first run finishes. + **Q: Is this safe for public/untrusted users?** A: No. It's a demonstration. Auth is HTTP Basic, there's no tenant isolation, no quotas, and no abuse controls. See [When to use this reference](README.md#when-to-use-this-reference) and [Current limitations](README.md#current-limitations). diff --git a/docs/README.md b/docs/README.md index 59b10de..c1809e1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -299,6 +299,8 @@ real, billable resources. Local development changes where orchestration runs; it does not emulate the Render data plane. Open `http://localhost:3000` and sign in with `UI_USERNAME` and `UI_PASSWORD`. +You can submit a prompt while other runs build, up to the cap of three runs. +The history shows the stage of each run. The **Table view** button opens `/table`, which shows the same runs as tables: the sites with their URLs, the time that each run took, and a delete button, and the stages with what each one does, where it runs, and links to the @@ -405,8 +407,9 @@ Copy `.env.example` when setting up locally. All deploy and HTTP waits have deadlines and heartbeat the database. The gateway also stores the Render task-run ID and periodically reconciles a `running` or `deleting` row with Workflows. If the task failed or was canceled -before it wrote its result, for example at a timeout, the next status poll -marks the row failed and releases its concurrency slot. A task that succeeds +before it wrote its result, for example at a timeout, the next status poll, +or the next refresh of the list in the UI, marks the row failed and releases +its concurrency slot. A task that succeeds writes its result before it returns. A service or deploy wait does a failed Render read again after five seconds, @@ -489,7 +492,8 @@ See [AGENTS.md](../AGENTS.md) for checklists when adding agents, primitives, or the app's name, in the app's own project. The UI can delete only the runs of its own namespace. - A delete and a run of the same app take the same Postgres advisory lock, so a - run cannot build an app while it is being deleted. + run cannot build an app while it is being deleted, or while a different run + builds it. - The image download accepts only HTTPS, only allowlisted hosts, and only `image/*` responses under the size cap. It writes only into the `assets/` directory of the app, under a name that it makes. diff --git a/public/app.js b/public/app.js index afddb87..5409448 100644 --- a/public/app.js +++ b/public/app.js @@ -1,4 +1,4 @@ -import { activeStatuses, formatDate, label, startRunsPage, truncate } from "/runs.js"; +import { activeStatuses, formatDate, startRunsPage, statusLabel, truncate } from "/runs.js"; const runList = document.querySelector("#run-list"); const stages = document.querySelector("#stages"); @@ -32,6 +32,7 @@ function renderHistory(runs, selectedRunId, { select }) { button.type = "button"; button.className = `run-item${run.runId === selectedRunId ? " selected" : ""}`; button.dataset.runId = run.runId; + button.dataset.focusKey = run.runId; button.setAttribute("aria-pressed", String(run.runId === selectedRunId)); const name = document.createElement("strong"); @@ -40,7 +41,7 @@ function renderHistory(runs, selectedRunId, { select }) { const state = document.createElement("span"); state.className = "run-state"; state.dataset.status = run.status; - state.textContent = label(run.status); + state.textContent = statusLabel(run); const meta = document.createElement("span"); meta.className = "run-meta"; meta.append(state, ` · ${formatDate(run.createdAt)}`); diff --git a/public/index.html b/public/index.html index 15992ac..ae4a66b 100644 --- a/public/index.html +++ b/public/index.html @@ -44,6 +44,7 @@

Describe it. Ship it.

required placeholder="Create a neighborhood plant exchange with listings, pickup details, and a friendly visual style." > +
@@ -177,6 +178,7 @@

Delete this app?

+
diff --git a/public/runs.js b/public/runs.js index 3135f16..0cdbda0 100644 --- a/public/runs.js +++ b/public/runs.js @@ -1,22 +1,26 @@ /** * What the two views of the UI share: the prompt form, the history of runs, - * the poll of the selected run, the run panel, the stages, and the delete - * dialog. The two pages use the same IDs for these elements. Each view renders - * its own history, and it can add to the run panel. + * the refresh of the runs, the run panel, the stages, and the delete dialog. + * The two pages use the same IDs for these elements. Each view renders its + * own history, and it can add to the run panel. */ -/** A task still owns these runs, so the page keeps polling them. */ +/** A task still owns these runs, so the page keeps reading them. */ export const activeStatuses = ["running", "deleting"]; const healthyStatuses = [...activeStatuses, "deployed", "awaiting_blueprint"]; +/** How long the page waits before it reads the runs again. */ +const REFRESH_MS = 5000; /** * Start the page. `view.renderHistory(runs, selectedRunId, actions)` renders * the history, and `view.renderRun(run, actions)`, if the view has it, adds to * the run panel. `actions`, which this also returns, has `select(runId)` and - * `openDeleteDialog(run)`. + * `openDeleteDialog(run)`. A control in the history that has + * `data-focus-key` gets the focus again after the history renders. */ export function startRunsPage(view) { const form = document.querySelector("#prompt-form"); + const formError = document.querySelector("#form-error"); const runPanel = document.querySelector("#run-panel"); const emptyHistory = document.querySelector("#empty-history"); const refreshRuns = document.querySelector("#refresh-runs"); @@ -37,13 +41,14 @@ export function startRunsPage(view) { const deleteConfirmField = document.querySelector("#delete-confirm-field"); const deleteAppName = document.querySelector("#delete-app-name"); const deleteConfirm = document.querySelector("#delete-confirm"); + const deleteError = document.querySelector("#delete-error"); const deleteSubmit = document.querySelector("#delete-submit"); const deleteCancel = document.querySelector("#delete-cancel"); /** - * The stages are static HTML. A poll changes only the class of each stage. - * It does not replace the stages, so the live region of the run panel - * does not read them again, and a focused stage keeps the focus. + * The stages are static HTML. A refresh changes only the class of each + * stage. It does not replace the stages, so the live region of the run + * panel does not read them again, and a focused stage keeps the focus. */ const stageItems = [...stages.querySelectorAll("[data-stage]")]; const stageOrder = stageItems.map((item) => item.dataset.stage); @@ -51,33 +56,33 @@ export function startRunsPage(view) { const actions = { select: selectRun, openDeleteDialog }; let runs = []; let selectedRunId = null; - let pollGeneration = 0; + /** The run in the run panel, as JSON. A refresh renders it only when it changes. */ + let shownRun = null; + let refreshTimer; + /** The number of reads of the runs. Only the newest read changes the page. */ + let reads = 0; + /** + * The button is busy only while the gateway accepts the prompt. Then the + * form takes the next prompt, and the runs build in parallel. + */ form.addEventListener("submit", async (event) => { event.preventDefault(); - setBusy(true); - runPanel.hidden = false; - status.textContent = "Submitting prompt"; - + setSubmitting(true); + formError.textContent = ""; try { - const response = await fetch("/ui/apps", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ prompt: form.prompt.value }), - }); - const body = await response.json(); - if (!response.ok || !body.runId) { - throw new Error(body.detail || body.error || `Request failed (${response.status})`); - } + const runId = await submitPrompt(form.prompt.value); form.reset(); - await loadRuns(body.runId); + loadRuns(runId).catch(showFailure); } catch (error) { - showFailure(error instanceof Error ? error.message : String(error)); + formError.textContent = messageOf(error); + } finally { + setSubmitting(false); } }); refreshRuns.addEventListener("click", () => { - loadRuns(selectedRunId).catch((error) => showFailure(error.message)); + loadRuns().catch(showFailure); }); deleteConfirm.addEventListener("input", () => { @@ -87,14 +92,26 @@ export function startRunsPage(view) { deleteCancel.addEventListener("click", () => deleteDialog.close()); + /** + * The dialog stays open until the gateway accepts the delete, and it shows + * the error of a delete that fails. The run panel cannot show it: the next + * refresh renders the run panel again. + */ deleteForm.addEventListener("submit", async (event) => { event.preventDefault(); - deleteDialog.close(); + const { runId } = deleteForm.dataset; + deleteSubmit.disabled = true; + deleteError.textContent = ""; try { - await deleteRun(deleteForm.dataset.runId); + await deleteRun(runId); } catch (error) { - showFailure(error instanceof Error ? error.message : String(error)); + deleteError.textContent = messageOf(error); + deleteSubmit.disabled = false; + return; } + deleteDialog.close(); + // Each run of the app is deleting now. A run with no app is gone. + loadRuns(runId).catch(showFailure); }); /** @@ -117,100 +134,78 @@ export function startRunsPage(view) { deleteConfirmField.hidden = !appName; deleteAppName.textContent = appName; deleteConfirm.value = ""; + deleteError.textContent = ""; deleteSubmit.disabled = Boolean(appName); deleteDialog.showModal(); (appName ? deleteConfirm : deleteSubmit).focus(); } - async function deleteRun(runId) { - const response = await fetch(`/ui/apps/${encodeURIComponent(runId)}`, { - method: "DELETE", - }); - const body = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(body.error || `Delete failed (${response.status})`); - } - if (body.status === "deleted") { - await runRemoved(); - return; + /** + * Read the runs, and select the preferred run, the selected run, or the + * newest run. While a task owns a run, read them again after REFRESH_MS. + * A read that fails is tried again too, so that a short outage of the + * gateway does not stop the updates. + */ + async function loadRuns(preferredRunId) { + window.clearTimeout(refreshTimer); + const read = ++reads; + try { + const response = await fetch("/ui/apps"); + const body = response.ok ? await response.json() : null; + // A newer read started, for example after a submit. + if (read !== reads) return; + if (!body) throw new Error(`Could not load run history (${response.status})`); + runs = body.runs || []; + const listed = (runId) => runs.some((run) => run.runId === runId); + selectRun( + [preferredRunId, selectedRunId].find((runId) => runId && listed(runId)) ?? + runs[0]?.runId ?? + null, + ); + } finally { + if (read === reads && runs.some((run) => activeStatuses.includes(run.status))) { + refreshTimer = window.setTimeout(() => loadRuns().catch(showFailure), REFRESH_MS); + } } - // Every run of the app is deleting now, so read them all again. - await loadRuns(runId); - } - - /** The run is gone: a delete finished, or the run had no app to delete. */ - async function runRemoved() { - selectedRunId = null; - pollGeneration += 1; - runPanel.hidden = true; - await loadRuns(); } - async function loadRuns(preferredRunId) { - const response = await fetch("/ui/apps"); - if (!response.ok) throw new Error(`Could not load run history (${response.status})`); - const body = await response.json(); - runs = body.runs || []; + function selectRun(runId) { + selectedRunId = runId; + if (runId) localStorage.setItem("vibe-code-selected-run", runId); renderHistory(); - - const nextRunId = - (preferredRunId && runs.some((run) => run.runId === preferredRunId) - ? preferredRunId - : null) || - (selectedRunId && runs.some((run) => run.runId === selectedRunId) - ? selectedRunId - : null) || - runs[0]?.runId; - if (nextRunId) await selectRun(nextRunId); + renderSelectedRun(); } + /** A view replaces its history, so give the focus back to the same control. */ function renderHistory() { emptyHistory.hidden = runs.length > 0; + const focusKey = document.activeElement?.dataset.focusKey; view.renderHistory(runs, selectedRunId, actions); - } - - async function selectRun(runId) { - selectedRunId = runId; - localStorage.setItem("vibe-code-selected-run", runId); - pollGeneration += 1; - const generation = pollGeneration; - renderHistory(); - await poll(runId, generation); - } - - async function poll(runId, generation) { - while (generation === pollGeneration && runId === selectedRunId) { - const response = await fetch(`/ui/apps/${encodeURIComponent(runId)}`); - if (generation !== pollGeneration) return; - // A delete that finished removed the run. - if (response.status === 404) { - await runRemoved(); - return; - } - if (!response.ok) throw new Error(`Status check failed (${response.status})`); - const run = await response.json(); - upsertRun(run); - renderRun(run); - renderHistory(); - if (!activeStatuses.includes(run.status)) return; - await new Promise((resolve) => setTimeout(resolve, 5000)); + if (focusKey) { + document.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`)?.focus(); } } - function upsertRun(run) { - const index = runs.findIndex((candidate) => candidate.runId === run.runId); - if (index === -1) runs.unshift(run); - else runs[index] = run; + function renderSelectedRun() { + const run = runs.find((candidate) => candidate.runId === selectedRunId); + if (!run) { + runPanel.hidden = true; + shownRun = null; + return; + } + // The run panel is a live region, so change it only when the run changes. + const json = JSON.stringify(run); + if (json === shownRun) return; + shownRun = json; + renderRun(run); } function renderRun(run) { runPanel.hidden = false; runPanel.classList.toggle("failed", !healthyStatuses.includes(run.status)); - status.textContent = - run.status === "running" ? label(run.stage || "queued") : label(run.status); + status.textContent = statusLabel(run); progress.textContent = run.progress || ""; activity.hidden = !activeStatuses.includes(run.status); - setBusy(run.status === "running"); // The stages are those of a build, so a delete does not show them. stages.hidden = ["deleting", "delete_failed"].includes(run.status); @@ -246,25 +241,57 @@ export function startRunsPage(view) { view.renderRun?.(run, actions); } - function setBusy(busy) { - submit.disabled = busy; - submit.textContent = busy ? "Building…" : "Build and deploy"; + function setSubmitting(submitting) { + submit.disabled = submitting; + submit.textContent = submitting ? "Submitting…" : "Build and deploy"; } - function showFailure(message) { + /** A read of the runs failed. The next read that succeeds shows the run again. */ + function showFailure(error) { + shownRun = null; runPanel.hidden = false; runPanel.classList.add("failed"); status.textContent = "Request failed"; - progress.textContent = message; - setBusy(false); + progress.textContent = messageOf(error); } - loadRuns(localStorage.getItem("vibe-code-selected-run")).catch((error) => - showFailure(error.message), - ); + loadRuns(localStorage.getItem("vibe-code-selected-run")).catch(showFailure); return actions; } +/** Start a run. The gateway gives its ID, or an error that the form shows. */ +async function submitPrompt(prompt) { + const response = await fetch("/ui/apps", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || !body.runId) { + throw new Error(body.detail || body.error || `Request failed (${response.status})`); + } + return body.runId; +} + +async function deleteRun(runId) { + const response = await fetch(`/ui/apps/${encodeURIComponent(runId)}`, { + method: "DELETE", + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body.error || `Delete failed (${response.status})`); + } +} + +function messageOf(error) { + return error instanceof Error ? error.message : String(error); +} + +/** The stage of a run that builds, or else its status. */ +export function statusLabel(run) { + return run.status === "running" ? label(run.stage || "queued") : label(run.status); +} + export function label(value) { return value.replaceAll("_", " ").replace(/\b\w/g, (character) => character.toUpperCase()); } diff --git a/public/style.css b/public/style.css index 4a37c83..8b877c3 100644 --- a/public/style.css +++ b/public/style.css @@ -248,6 +248,17 @@ input { gap: 12px; } +/* Why the gateway refused the request of a form: a submit or a delete. */ +.form-error { + color: var(--danger); + font-size: 14px; + line-height: 20px; +} + +.form-error:empty { + display: none; +} + /* * Buttons change color at once on hover, with no transition, as a tool does. * The primary button is neutral until hover. Only one is in view. diff --git a/public/table.html b/public/table.html index 4a90c2f..35a6851 100644 --- a/public/table.html +++ b/public/table.html @@ -48,6 +48,7 @@

Describe it. Ship it.

required placeholder="Create a neighborhood plant exchange with listings, pickup details, and a friendly visual style." > +
@@ -193,6 +194,7 @@

Delete this app?

+
diff --git a/public/table.js b/public/table.js index 6a70750..149c20b 100644 --- a/public/table.js +++ b/public/table.js @@ -1,4 +1,4 @@ -import { activeStatuses, formatDate, label, startRunsPage, truncate } from "/runs.js"; +import { activeStatuses, formatDate, startRunsPage, statusLabel, truncate } from "/runs.js"; const sites = document.querySelector("#sites"); const siteRows = document.querySelector("#site-rows"); @@ -27,6 +27,7 @@ function renderHistory(runs, selectedRunId, { select, openDeleteDialog }) { name.className = "site-name"; name.textContent = run.appName || truncate(run.prompt, 34); name.setAttribute("aria-pressed", String(selected)); + name.dataset.focusKey = `${run.runId}:name`; name.addEventListener("click", () => select(run.runId)); const nameCell = document.createElement("th"); nameCell.scope = "row"; @@ -35,7 +36,7 @@ function renderHistory(runs, selectedRunId, { select, openDeleteDialog }) { const state = document.createElement("span"); state.className = "run-state"; state.dataset.status = run.status; - state.textContent = label(run.status); + state.textContent = statusLabel(run); const remove = document.createElement("button"); remove.type = "button"; @@ -43,12 +44,13 @@ function renderHistory(runs, selectedRunId, { select, openDeleteDialog }) { remove.textContent = "Delete"; remove.setAttribute("aria-label", `Delete ${run.appName || "this run"}`); remove.hidden = activeStatuses.includes(run.status); + remove.dataset.focusKey = `${run.runId}:delete`; remove.addEventListener("click", () => openDeleteDialog(run)); row.append( nameCell, cell(state), - cell(run.urls?.web ? siteLink(run.urls.web) : none()), + cell(run.urls?.web ? siteLink(run.urls.web, run.runId) : none()), cell(generationTime(run)), cell(formatDate(run.createdAt)), cell(remove), @@ -82,10 +84,11 @@ function cell(content) { return td; } -function siteLink(url) { +function siteLink(url, runId) { const anchor = dashboardLink(url, new URL(url).host); anchor.className = "site-url"; anchor.title = url; + anchor.dataset.focusKey = `${runId}:url`; return anchor; } diff --git a/tests/gateway.test.ts b/tests/gateway.test.ts index f017536..f45adfb 100644 --- a/tests/gateway.test.ts +++ b/tests/gateway.test.ts @@ -188,6 +188,12 @@ describe("dispatch", () => { const response = await post({ prompt: PROMPT }); expect(response.status).toBe(429); + // The UI shows the detail below the prompt. + expect(await response.json()).toEqual({ + error: "too many concurrent runs", + detail: + "The factory builds at most 3 apps at a time, for all users. Submit the prompt again when a run finishes.", + }); expect(mocks.startTask).not.toHaveBeenCalled(); }); @@ -319,6 +325,68 @@ describe("browser UI", () => { expect(mocks.listRunsByUser).toHaveBeenCalledWith("demo"); expect(await response.json()).toEqual({ runs: [] }); }); + + // The UI polls only the list, so the list marks a run failed when its task + // run failed. Else the run keeps a concurrency slot until someone selects it. + it("reconciles each run that a task owns before it lists the runs", async () => { + const building = storedRun({ + id: "run-building", + status: "running", + workflowRunId: "trn-building", + }); + const crashed = storedRun({ + id: "run-crashed", + status: "running", + workflowRunId: "trn-crashed", + }); + const deployed = storedRun({ id: "run-deployed" }); + mocks.listRunsByUser + .mockResolvedValueOnce([building, crashed, deployed]) + .mockResolvedValueOnce([ + building, + { ...crashed, status: "failed", summary: "Workflow failed: timed out" }, + deployed, + ]); + mocks.claimWorkflowCheck.mockResolvedValue(true); + mocks.getTaskRun.mockImplementation(async (id: string) => + id === "trn-crashed" + ? { status: "failed", error: "timed out" } + : { status: "running" }, + ); + + const response = await createGateway().request("/ui/apps", { + headers: { authorization }, + }); + const body = (await response.json()) as { + runs: { runId: string; status: string }[]; + }; + + expect(mocks.claimWorkflowCheck.mock.calls).toEqual([ + ["run-building"], + ["run-crashed"], + ]); + expect(mocks.finishRun).toHaveBeenCalledOnce(); + expect(mocks.finishRun).toHaveBeenCalledWith("run-crashed", "failed", { + summary: "Workflow failed: timed out", + }); + expect(body.runs.map((run) => [run.runId, run.status])).toEqual([ + ["run-building", "running"], + ["run-crashed", "failed"], + ["run-deployed", "deployed"], + ]); + }); + + it("reads the list one time when no task owns a run", async () => { + mocks.listRunsByUser.mockResolvedValue([storedRun()]); + + const response = await createGateway().request("/ui/apps", { + headers: { authorization }, + }); + + expect(response.status).toBe(200); + expect(mocks.listRunsByUser).toHaveBeenCalledOnce(); + expect(mocks.claimWorkflowCheck).not.toHaveBeenCalled(); + }); }); describe("status", () => { diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 9d8944d..e068473 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -12,6 +12,7 @@ import type { AppSpec, Manifest, Service } from "../app/contracts.js"; import type { AppFile } from "../app/git.js"; import type { DeployOutcome } from "../app/render.js"; import type { ExecResult, Sandbox } from "../app/sandbox.js"; +import type { AppClaim } from "../app/store.js"; import { deleteResourcesTask, removeFilesTask, @@ -43,7 +44,7 @@ const mocks = vi.hoisted(() => ({ waitForHttpOk: vi.fn(), deleteAppResources: vi.fn(), waitForBlueprintSyncs: vi.fn(), - claimRunApp: vi.fn(async () => true), + claimRunApp: vi.fn(async (): Promise => ({ claimed: true })), deleteRuns: vi.fn(async () => {}), failDelete: vi.fn(async () => {}), finishRun: vi.fn(async () => {}), @@ -1375,7 +1376,10 @@ describe("promptToApp", () => { // The delete would remove what the run publishes. it("builds nothing when a delete of the app is in progress", async () => { - mocks.claimRunApp.mockResolvedValueOnce(false); + mocks.claimRunApp.mockResolvedValueOnce({ + claimed: false, + reason: "deleting", + }); const result = await promptToApp.func(tasks, { prompt: "Sell handmade walnut furniture online", @@ -1397,6 +1401,28 @@ describe("promptToApp", () => { }); }); + // Both runs would write apps/demo/shop/, and each one would wait for the + // deploys of the other. + it("builds nothing when a different run builds the same app", async () => { + mocks.claimRunApp.mockResolvedValueOnce({ + claimed: false, + reason: "running", + }); + + const result = await promptToApp.func(tasks, { + prompt: "Sell handmade walnut furniture online", + user: "demo", + runId: "run-2", + }); + + expect(result).toEqual({ + status: "failed", + summary: + "A different run is building demo/shop. Submit the prompt again when that run finishes.", + }); + expect(mocks.createSandbox).not.toHaveBeenCalled(); + }); + // A failed run is final. The task sets its runs row to "failed" and throws, // and Render then records a failed task run. A retry by Render starts the // pipeline again outside the concurrency limit, and its result can replace