diff --git a/AGENTS.md b/AGENTS.md index 0209f90..4a80625 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,18 @@ 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. +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 +run took, and a delete button. It shows the stages in a table that tells what +each stage does and where it runs, with links to the workflow run and the +sandbox in the Render Dashboard. `public/runs.js` has what the views share; +`app.js` and `table.js` render only what differs. The gateway builds the links +from IDs in Postgres and gives null for a link that it cannot make: the SDK +does not give the ID of a subtask run, so each stage links to the run of +`prompt-to-app`, and a workspace with more than one sandbox group gets no +sandbox link. + ## Repository map Each concern is one file under `app/`. There are no barrels, no path aliases, @@ -112,7 +124,7 @@ logs of a failed deploy. `render` is imported by `claude` (for the MCP URL), by `teardown`, and by `deploy` and `delete`. `blueprint`, `git`, `images`, `teardown`, and `store` are used by `workflow` and the stages; `teardown` uses `render` and `blueprint`; `gateway` uses `store`, `policy`, -and `contracts`. `contracts` is a leaf, and `config` uses only its `slug` +`contracts`, and `render`, for the workflow ID of the Dashboard links. `contracts` is a leaf, and `config` uses only its `slug` schema. Adding an edge that points backwards is a design smell. ```text @@ -143,7 +155,9 @@ app/ schema.sql Schema, applied by scripts/migrate.ts server.ts Gateway entrypoint host.ts Workflows entrypoint -public/ Basic-Auth-protected prompt and deployment-status UI +public/ Basic-Auth-protected prompt and deployment-status UI: + runs.js (shared), index.html + app.js (classic view), + table.html + table.js (table view) templates/ fullstack/ web/ (Vite + React + Tailwind + shadcn/ui), api/ (Hono + pg) scripts/ migrate, doctor, demo, support @@ -422,8 +436,10 @@ API. The API checks cannot see the hostname that a browser uses. Each stage in `RUN_STAGES` has an item in the stage list of `public/index.html`, in the same order, with a tooltip that tells what the -stage does and where it runs. `tests/gateway.test.ts` checks this, so a new -stage needs an item and a tooltip. +stage does and where it runs. It also has a row in the stage table of +`public/table.html`, which tells the same and names the Dashboard links of +the stage. `tests/gateway.test.ts` checks both, so a new stage needs an item +and a row. When a deploy fails, the deploy manager diagnoses it from the logs of that deploy, which workflow code gives it. `fetchDeployLogs()` reads them in the diff --git a/app/gateway.ts b/app/gateway.ts index 6125a21..f1e0777 100644 --- a/app/gateway.ts +++ b/app/gateway.ts @@ -14,6 +14,7 @@ import { bodyLimit } from "hono/body-limit"; import { apiKey, uiCredentials } from "./config.js"; import { createAppRequestSchema } from "./contracts.js"; import { redactSecrets } from "./policy.js"; +import { workflowIdOfTaskRun } from "./render.js"; import { claimDelete, claimRun, @@ -34,6 +35,12 @@ const RUN_ID = /^[0-9a-f-]{36}$/; const TASK_NAME = "prompt-to-app"; const DELETE_TASK_NAME = "delete-app"; const UNAUTHORIZED = { error: "unauthorized" }; +const DASHBOARD = "https://dashboard.render.com"; +/** A failed read of the workflow ID is tried again after this, not on each poll. */ +const WORKFLOW_ID_RETRY_MS = 60_000; + +/** The ID of the workflow for links to the Render Dashboard, if it is known. */ +type WorkflowIdReader = (taskRunId: string | null) => string | null; export function createGateway(): Hono { const app = new Hono(); @@ -57,6 +64,7 @@ export function createGateway(): Hono { maxSize: MAX_BODY_BYTES, onError: (c) => c.json({ error: "payload too large" }, 413), }); + const workflowId = workflowIdReader(); app.get("/health", (c) => c.json({ status: "ok" })); @@ -71,29 +79,40 @@ export function createGateway(): Hono { app.use("/v1/*", apiAuth); app.post("/v1/apps", capBody, (c) => createRun(c, "/v1/apps")); - app.get("/v1/apps/:runId", readRun); + app.get("/v1/apps/:runId", (c) => readRun(c, workflowId)); app.delete("/v1/apps/:runId", (c) => deleteRun(c, "/v1/apps")); app.use("/ui/*", uiAuth); - app.get("/ui/apps", (c) => listRuns(c, credentials.username)); + app.get("/ui/apps", (c) => listRuns(c, credentials.username, workflowId)); app.post("/ui/apps", capBody, (c) => createRun(c, "/ui/apps", credentials.username), ); - app.get("/ui/apps/:runId", readRun); + app.get("/ui/apps/:runId", (c) => readRun(c, workflowId)); app.delete("/ui/apps/:runId", (c) => deleteRun(c, "/ui/apps", credentials.username), ); + // Two views of the same UI. Each one has a button that opens the other. app.get("/", uiAuth, serveStatic({ path: "./public/index.html" })); + app.get("/table", uiAuth, serveStatic({ path: "./public/table.html" })); app.get("/app.js", uiAuth, serveStatic({ path: "./public/app.js" })); + app.get("/table.js", uiAuth, serveStatic({ path: "./public/table.js" })); + app.get("/runs.js", uiAuth, serveStatic({ path: "./public/runs.js" })); app.get("/style.css", uiAuth, serveStatic({ path: "./public/style.css" })); return app; } -async function listRuns(c: Context, user: string): Promise { +async function listRuns( + c: Context, + user: string, + workflowId: WorkflowIdReader, +): Promise { try { const runs = await listRunsByUser(user); - return c.json({ runs: runs.map(runResponse) }); + const id = workflowId( + runs.find((run) => run.workflowRunId)?.workflowRunId ?? null, + ); + return c.json({ runs: runs.map((run) => runResponse(run, id)) }); } catch (error) { console.error("Failed to list runs:", error); return c.json({ error: "store unavailable" }, 503); @@ -167,7 +186,10 @@ async function createRun( ); } -async function readRun(c: Context): Promise { +async function readRun( + c: Context, + workflowId: WorkflowIdReader, +): Promise { const runId = c.req.param("runId"); if (!runId || !RUN_ID.test(runId)) return c.json({ error: "not found" }, 404); @@ -184,7 +206,7 @@ async function readRun(c: Context): Promise { if (!current) return c.json({ error: "not found" }, 404); run = current; } - return c.json(runResponse(run)); + return c.json(runResponse(run, workflowId(run.workflowRunId))); } catch (error) { console.error("Failed to read run:", error); return c.json({ error: "store unavailable" }, 503); @@ -279,13 +301,20 @@ export interface RunResponse { summary: string | null; createdAt: string; updatedAt: string; + /** When the run stopped, or null while it runs. */ + finishedAt: string | null; + /** Pages in the Render Dashboard, or null for a link that cannot be made yet. */ + links: { workflowRun: string | null; sandbox: string | null }; } /** * The public shape of a run. Summaries are model text, and progress can hold * the error of a failed Render read, so redact both. */ -export function runResponse(run: RunRecord): RunResponse { +export function runResponse( + run: RunRecord, + workflowId: string | null = null, +): RunResponse { return { runId: run.id, status: run.status, @@ -299,6 +328,55 @@ export function runResponse(run: RunRecord): RunResponse { summary: run.summary ? redactSecrets(run.summary) : null, createdAt: run.createdAt, updatedAt: run.updatedAt, + finishedAt: run.finishedAt, + links: { + // The task run that owns the status: prompt-to-app, or delete-app while + // the app is deleted. The Dashboard shows the subtasks of a run on its + // page. The SDK does not give the ID of a subtask run. + workflowRun: + workflowId && run.workflowRunId + ? `${DASHBOARD}/wf/${encodeURIComponent(workflowId)}/runs/${encodeURIComponent(run.workflowRunId)}` + : null, + sandbox: + run.sandboxGroupId && run.sandboxId + ? `${DASHBOARD}/sandbox-group/${encodeURIComponent(run.sandboxGroupId)}/sandboxes/${encodeURIComponent(run.sandboxId)}` + : null, + }, + }; +} + +/** + * Every task run of the factory belongs to the same workflow, so the gateway + * reads its ID once, from the task run of any run. The read does not delay a + * response: until it is done, the responses have no workflow link. A local + * task run is not in the Dashboard, so local development gets no workflow + * links. + */ +function workflowIdReader(): WorkflowIdReader { + let workflowId: string | null = null; + let reading = false; + return (taskRunId) => { + if ( + workflowId || + reading || + !taskRunId || + process.env.RENDER_USE_LOCAL_DEV === "true" + ) { + return workflowId; + } + reading = true; + workflowIdOfTaskRun(taskRunId).then( + (id) => { + workflowId = id; + }, + (error) => { + console.error("Failed to read the workflow ID:", error); + setTimeout(() => { + reading = false; + }, WORKFLOW_ID_RETRY_MS).unref(); + }, + ); + return null; }; } diff --git a/app/render.ts b/app/render.ts index 70643c8..f5f7d8a 100644 --- a/app/render.ts +++ b/app/render.ts @@ -387,6 +387,25 @@ async function readApi(path: string, what: string): Promise { return (await response.json()) as T; } +/* ── Workflows ────────────────────────────────────────────────────────── */ + +/** + * The ID of the workflow that a task run belongs to, for links to the Render + * Dashboard. A task run names only its task, so this reads the task too. + */ +export async function workflowIdOfTaskRun(taskRunId: string): Promise { + const run = await readApi<{ taskId: string }>( + `/task-runs/${encodeURIComponent(taskRunId)}`, + `Reading task run ${taskRunId}`, + ); + const task = await readApi<{ workflowId?: string }>( + `/tasks/${encodeURIComponent(run.taskId)}`, + `Reading task ${run.taskId}`, + ); + if (!task.workflowId) throw new Error(`Task ${run.taskId} names no workflow`); + return task.workflowId; +} + /* ── Blueprints ───────────────────────────────────────────────────────── */ export interface BlueprintRecord { diff --git a/app/sandbox.ts b/app/sandbox.ts index 1bb3991..85adf5c 100644 --- a/app/sandbox.ts +++ b/app/sandbox.ts @@ -143,6 +143,17 @@ export function connectSandbox(sandboxId: string): Sandbox { return new Sandbox(sandboxId); } +/** + * The sandbox group of the workspace, for the link to a sandbox in the Render + * Dashboard. A sandbox does not name its group, but in the alpha a workspace + * has at most one. With more than one, the group of a sandbox is not known. + */ +export async function sandboxGroupId(): Promise { + const { client, ownerId } = api(); + const groups = await client.listGroups({ ownerId }); + return groups.length === 1 ? groups[0].sandboxGroup.id : null; +} + /* ── Postgres in the sandbox ──────────────────────────────────────────── */ /** diff --git a/app/schema.sql b/app/schema.sql index 11f3d28..77c5250 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -20,14 +20,30 @@ create table if not exists runs ( -- Path of the app's own Blueprint inside the apps repository. blueprint_path text, summary text, + -- The sandbox of the run and its sandbox group, for the link to the + -- sandbox in the Render Dashboard. + sandbox_id text, + sandbox_group_id text, created_at timestamptz not null default now(), - updated_at timestamptz not null default now() + updated_at timestamptz not null default now(), + -- When the run stopped. The UI shows how long it took. A delete changes + -- updated_at, so updated_at cannot tell. + finished_at timestamptz ); -- Keep migrations safe for databases created before these columns existed. alter table runs add column if not exists progress text; alter table runs add column if not exists workflow_run_id text; alter table runs add column if not exists workflow_checked_at timestamptz; +alter table runs add column if not exists sandbox_id text; +alter table runs add column if not exists sandbox_group_id text; +alter table runs add column if not exists finished_at timestamptz; + +-- A run that stopped before finished_at existed stopped when it was last +-- updated. A run that a delete changed after that gets no time. +update runs set finished_at = updated_at + where finished_at is null + and status in ('deployed', 'awaiting_blueprint', 'build_failed', 'deploy_failed', 'failed'); -- Makes the concurrency count in claimRun cheap. create index if not exists runs_running diff --git a/app/store.ts b/app/store.ts index 5562a74..91d9efc 100644 --- a/app/store.ts +++ b/app/store.ts @@ -56,8 +56,13 @@ export interface RunRecord { apiUrl: string | null; blueprintPath: string | null; summary: string | null; + /** The sandbox of the run and its sandbox group, for a dashboard link. */ + sandboxId: string | null; + sandboxGroupId: string | null; createdAt: string; updatedAt: string; + /** When the run stopped, or null while it runs. */ + finishedAt: string | null; } export type ClaimResult = @@ -76,7 +81,8 @@ export type DeleteClaim = const COLUMNS = `id, idempotency_key, prompt, user_name, status, stage, progress, workflow_run_id, app_name, web_url, api_url, blueprint_path, summary, - created_at, updated_at`; + sandbox_id, sandbox_group_id, + created_at, updated_at, finished_at`; let pool: pg.Pool | undefined; @@ -329,6 +335,17 @@ export async function deleteRuns(user: string, appName: string): Promise { ); } +/** Cosmetic, as the stage is: the UI links to the sandbox in the Dashboard. */ +export async function setRunSandbox( + id: string, + sandbox: { id: string; groupId: string | null }, +): Promise { + await db().query( + "update runs set sandbox_id = $2, sandbox_group_id = $3 where id = $1", + [id, sandbox.id, sandbox.groupId], + ); +} + export async function setRunUrls( id: string, urls: { webUrl: string | null; apiUrl: string | null }, @@ -351,7 +368,7 @@ export async function finishRun( set status = $2, stage = case when $2 in ('deployed', 'awaiting_blueprint') then 'done' else stage end, progress = null, - summary = $3, updated_at = now() + summary = $3, updated_at = now(), finished_at = now() where id = $1`, [id, status, details.summary ?? null], ); @@ -395,7 +412,10 @@ function rowToRun(row: Record): RunRecord { apiUrl: (row.api_url as string | null) ?? null, blueprintPath: (row.blueprint_path as string | null) ?? null, summary: (row.summary as string | null) ?? null, + sandboxId: (row.sandbox_id as string | null) ?? null, + sandboxGroupId: (row.sandbox_group_id as string | null) ?? null, createdAt: (row.created_at as Date).toISOString(), updatedAt: (row.updated_at as Date).toISOString(), + finishedAt: row.finished_at ? (row.finished_at as Date).toISOString() : null, }; } diff --git a/app/workflow.ts b/app/workflow.ts index 9df8ef7..dbf2975 100644 --- a/app/workflow.ts +++ b/app/workflow.ts @@ -34,12 +34,18 @@ import { awaitDeployment } from "./deploy.js"; import { initAppDir } from "./git.js"; import { collectImages } from "./images.js"; import { oneLine, publishAppTask } from "./publish.js"; -import { createSandbox, ensureSandboxPostgres } from "./sandbox.js"; +import { + createSandbox, + ensureSandboxPostgres, + type Sandbox, + sandboxGroupId, +} from "./sandbox.js"; import { claimRunApp, deleteRuns, failDelete, finishRun, + setRunSandbox, setRunStage, } from "./store.js"; import { materializeTemplate } from "./templates.js"; @@ -128,6 +134,7 @@ async function run( timeoutSeconds: SANDBOX_TIMEOUT_SECONDS, }); try { + await recordSandbox(runId, sandbox); const appDir = appPath(user, appName); // Every agent path resolves against this, so it has to exist first. // The agents can run any command in this sandbox, so it gets no clone @@ -214,6 +221,20 @@ async function run( } } +/** + * The UI links to the sandbox in the Render Dashboard. Only the link needs + * this, so a failure does not stop the run. + */ +async function recordSandbox(runId: string, sandbox: Sandbox): Promise { + const groupId = await sandboxGroupId().catch((error) => { + console.error("Failed to read the sandbox group:", error); + return null; + }); + await setRunSandbox(runId, { id: sandbox.id, groupId }).catch((error) => + console.error("Failed to record the sandbox of the run:", error), + ); +} + interface DeleteResult { status: "deleted"; user: string; diff --git a/docs/FAQ.md b/docs/FAQ.md index 60b0dcf..0d5247f 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -74,6 +74,10 @@ Architecture overview and how Render products fit together: Waiting For Deploys, Smoke Testing, and Done. - Each stage has a tooltip that tells what the stage does and where it runs. Hover over the stage, or go to it with the Tab key. +- For a talk track, click **Table view**. It shows the stages as a table: what + each stage does, where it runs, and links to the workflow run and the sandbox + in the Render Dashboard. The row of the stage that runs now has a tint. The + sites table shows each URL and how long each run took. - A typical run takes **5–10 minutes**. The builder takes the largest part. - The CLI `npm run demo` follows the status endpoint and prints final URLs. - If a run looks stuck, `GET /v1/apps/:runId` shows the current `stage` and `progress`. diff --git a/docs/README.md b/docs/README.md index b47d809..59b10de 100644 --- a/docs/README.md +++ b/docs/README.md @@ -299,6 +299,11 @@ 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`. +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 +workflow run and the sandbox in the Render Dashboard. Local task runs are not +in the Dashboard, so local development shows no workflow links. The UI calls same-origin `/ui` endpoints; `FACTORY_API_KEY` stays on the gateway and is never delivered to browser JavaScript. `UI_USERNAME` is also the generated-app namespace: a user named `jacob` creates apps under diff --git a/public/app.js b/public/app.js index 375b4d8..afddb87 100644 --- a/public/app.js +++ b/public/app.js @@ -1,84 +1,18 @@ -const form = document.querySelector("#prompt-form"); -const runPanel = document.querySelector("#run-panel"); +import { activeStatuses, formatDate, label, startRunsPage, truncate } from "/runs.js"; + const runList = document.querySelector("#run-list"); -const emptyHistory = document.querySelector("#empty-history"); -const refreshRuns = document.querySelector("#refresh-runs"); -const status = document.querySelector("#status"); -const activity = document.querySelector("#activity"); const stages = document.querySelector("#stages"); -const progress = document.querySelector("#progress"); -const result = document.querySelector("#result"); -const resultName = document.querySelector("#result-name"); -const webUrl = document.querySelector("#web-url"); -const summary = document.querySelector("#summary"); -const runDetails = document.querySelector("#run-details"); -const submit = form.querySelector("button"); const deleteButton = document.querySelector("#delete-run"); -const deleteDialog = document.querySelector("#delete-dialog"); -const deleteForm = document.querySelector("#delete-form"); -const deleteTitle = document.querySelector("#delete-title"); -const deleteDescription = document.querySelector("#delete-description"); -const deleteConfirmField = document.querySelector("#delete-confirm-field"); -const deleteAppName = document.querySelector("#delete-app-name"); -const deleteConfirm = document.querySelector("#delete-confirm"); -const deleteSubmit = document.querySelector("#delete-submit"); -const deleteCancel = document.querySelector("#delete-cancel"); - -/** A task still owns these runs, so the page keeps polling them. */ -const activeStatuses = ["running", "deleting"]; -const healthyStatuses = [...activeStatuses, "deployed", "awaiting_blueprint"]; -/** - * The stage list is static HTML, with the tooltip of each stage. A poll - * changes only the class of each stage. It does not replace the stages, so an - * open tooltip stays open and a focused stage keeps the focus. - */ -const stageItems = [...stages.querySelectorAll("li")]; -const stageOrder = stageItems.map((item) => item.dataset.stage); +/** The run that the run panel shows, for its delete button. */ +let shownRun = null; -let runs = []; -let selectedRunId = null; -let pollGeneration = 0; - -form.addEventListener("submit", async (event) => { - event.preventDefault(); - setBusy(true); - runPanel.hidden = false; - status.textContent = "Submitting prompt"; - - 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})`); - } - form.reset(); - await loadRuns(body.runId); - } catch (error) { - showFailure(error instanceof Error ? error.message : String(error)); - } -}); - -refreshRuns.addEventListener("click", () => { - loadRuns(selectedRunId).catch((error) => showFailure(error.message)); -}); +const page = startRunsPage({ renderHistory, renderRun }); deleteButton.addEventListener("click", () => { - const run = runs.find((candidate) => candidate.runId === selectedRunId); - if (run) openDeleteDialog(run); -}); - -deleteConfirm.addEventListener("input", () => { - deleteSubmit.disabled = - deleteConfirm.value.trim() !== deleteForm.dataset.appName; + if (shownRun) page.openDeleteDialog(shownRun); }); -deleteCancel.addEventListener("click", () => deleteDialog.close()); - /** * Escape closes an open stage tooltip, and the pointer and the focus stay * where they are (WCAG 1.4.13). The next stage that the pointer or the focus @@ -91,85 +25,7 @@ for (const type of ["pointerover", "focusin"]) { stages.addEventListener(type, () => stages.classList.remove("tips-closed")); } -deleteForm.addEventListener("submit", async (event) => { - event.preventDefault(); - deleteDialog.close(); - try { - await deleteRun(deleteForm.dataset.runId); - } catch (error) { - showFailure(error instanceof Error ? error.message : String(error)); - } -}); - -/** - * An app is deleted on Render and in the apps repository, with all of its - * runs, and the delete cannot be undone. So the dialog asks for the app's - * name, as hosting dashboards do before such a delete. - */ -function openDeleteDialog(run) { - const appName = run.appName || ""; - deleteForm.dataset.runId = run.runId; - deleteForm.dataset.appName = appName; - deleteTitle.textContent = appName - ? `Delete ${titleFromSlug(appName)}?` - : "Delete this run?"; - deleteDescription.textContent = appName - ? "This deletes the app's services and databases on Render, with all their data, " + - "and removes its files from the apps repository. Every run of this app leaves your " + - "history. You cannot undo this." - : "This run did not create an app, so only the run leaves your history."; - deleteConfirmField.hidden = !appName; - deleteAppName.textContent = appName; - deleteConfirm.value = ""; - 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; - } - // 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 || []; - 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); -} - -function renderHistory() { - emptyHistory.hidden = runs.length > 0; +function renderHistory(runs, selectedRunId, { select }) { runList.replaceChildren( ...runs.map((run) => { const button = document.createElement("button"); @@ -189,129 +45,14 @@ function renderHistory() { meta.className = "run-meta"; meta.append(state, ` · ${formatDate(run.createdAt)}`); button.append(name, meta); - button.addEventListener("click", () => selectRun(run.runId)); + button.addEventListener("click", () => select(run.runId)); return button; }), ); } -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)); - } -} - -function upsertRun(run) { - const index = runs.findIndex((candidate) => candidate.runId === run.runId); - if (index === -1) runs.unshift(run); - else runs[index] = 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); - progress.textContent = run.progress || ""; - activity.hidden = !activeStatuses.includes(run.status); + shownRun = run; deleteButton.hidden = activeStatuses.includes(run.status); deleteButton.textContent = run.appName ? "Delete app" : "Delete run"; - 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); - - const current = stageOrder.indexOf(run.stage); - stageItems.forEach((item, index) => { - let state = ""; - if (index < current || run.status === "deployed") state = "complete"; - if (index === current && run.status === "running") state = "active"; - if ( - index === current && - !["running", "deployed", "awaiting_blueprint"].includes(run.status) - ) { - state = "failed-stage"; - } - item.className = state; - }); - - const deployed = run.status === "deployed" && Boolean(run.urls?.web); - result.hidden = !deployed; - if (deployed) { - resultName.textContent = titleFromSlug(run.appName) || "Your website"; - summary.textContent = resultSummary(run.summary); - webUrl.href = run.urls.web; - } - - const showDetails = - !activeStatuses.includes(run.status) && - run.status !== "deployed" && - Boolean(run.summary); - runDetails.hidden = !showDetails; - runDetails.textContent = showDetails ? run.summary : ""; -} - -function setBusy(busy) { - submit.disabled = busy; - submit.textContent = busy ? "Building…" : "Build and deploy"; -} - -function showFailure(message) { - runPanel.hidden = false; - runPanel.classList.add("failed"); - status.textContent = "Request failed"; - progress.textContent = message; - setBusy(false); -} - -function label(value) { - return value.replaceAll("_", " ").replace(/\b\w/g, (character) => character.toUpperCase()); -} - -function truncate(value, length) { - return value.length > length ? `${value.slice(0, length - 1)}…` : value; -} - -function titleFromSlug(value) { - return value ? label(value.replaceAll("-", " ")) : ""; -} - -function resultSummary(value) { - if (!value) return "Your website is ready."; - const built = value.split(/\n\s*\n/)[0].replace(/\s+/g, " ").trim(); - return truncate(built, 220); } - -function formatDate(value) { - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(new Date(value)); -} - -loadRuns(localStorage.getItem("vibe-code-selected-run")).catch((error) => - showFailure(error.message), -); diff --git a/public/index.html b/public/index.html index d90f078..15992ac 100644 --- a/public/index.html +++ b/public/index.html @@ -21,6 +21,7 @@
Vibe Code Demo + Table view
diff --git a/public/runs.js b/public/runs.js new file mode 100644 index 0000000..3135f16 --- /dev/null +++ b/public/runs.js @@ -0,0 +1,293 @@ +/** + * 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. + */ + +/** A task still owns these runs, so the page keeps polling them. */ +export const activeStatuses = ["running", "deleting"]; +const healthyStatuses = [...activeStatuses, "deployed", "awaiting_blueprint"]; + +/** + * 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)`. + */ +export function startRunsPage(view) { + const form = document.querySelector("#prompt-form"); + const runPanel = document.querySelector("#run-panel"); + const emptyHistory = document.querySelector("#empty-history"); + const refreshRuns = document.querySelector("#refresh-runs"); + const status = document.querySelector("#status"); + const activity = document.querySelector("#activity"); + const stages = document.querySelector("#stages"); + const progress = document.querySelector("#progress"); + const result = document.querySelector("#result"); + const resultName = document.querySelector("#result-name"); + const webUrl = document.querySelector("#web-url"); + const summary = document.querySelector("#summary"); + const runDetails = document.querySelector("#run-details"); + const submit = form.querySelector("button"); + const deleteDialog = document.querySelector("#delete-dialog"); + const deleteForm = document.querySelector("#delete-form"); + const deleteTitle = document.querySelector("#delete-title"); + const deleteDescription = document.querySelector("#delete-description"); + const deleteConfirmField = document.querySelector("#delete-confirm-field"); + const deleteAppName = document.querySelector("#delete-app-name"); + const deleteConfirm = document.querySelector("#delete-confirm"); + 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. + */ + const stageItems = [...stages.querySelectorAll("[data-stage]")]; + const stageOrder = stageItems.map((item) => item.dataset.stage); + + const actions = { select: selectRun, openDeleteDialog }; + let runs = []; + let selectedRunId = null; + let pollGeneration = 0; + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + setBusy(true); + runPanel.hidden = false; + status.textContent = "Submitting prompt"; + + 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})`); + } + form.reset(); + await loadRuns(body.runId); + } catch (error) { + showFailure(error instanceof Error ? error.message : String(error)); + } + }); + + refreshRuns.addEventListener("click", () => { + loadRuns(selectedRunId).catch((error) => showFailure(error.message)); + }); + + deleteConfirm.addEventListener("input", () => { + deleteSubmit.disabled = + deleteConfirm.value.trim() !== deleteForm.dataset.appName; + }); + + deleteCancel.addEventListener("click", () => deleteDialog.close()); + + deleteForm.addEventListener("submit", async (event) => { + event.preventDefault(); + deleteDialog.close(); + try { + await deleteRun(deleteForm.dataset.runId); + } catch (error) { + showFailure(error instanceof Error ? error.message : String(error)); + } + }); + + /** + * An app is deleted on Render and in the apps repository, with all of its + * runs, and the delete cannot be undone. So the dialog asks for the app's + * name, as hosting dashboards do before such a delete. + */ + function openDeleteDialog(run) { + const appName = run.appName || ""; + deleteForm.dataset.runId = run.runId; + deleteForm.dataset.appName = appName; + deleteTitle.textContent = appName + ? `Delete ${titleFromSlug(appName)}?` + : "Delete this run?"; + deleteDescription.textContent = appName + ? "This deletes the app's services and databases on Render, with all their data, " + + "and removes its files from the apps repository. Every run of this app leaves your " + + "history. You cannot undo this." + : "This run did not create an app, so only the run leaves your history."; + deleteConfirmField.hidden = !appName; + deleteAppName.textContent = appName; + deleteConfirm.value = ""; + 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; + } + // 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 || []; + 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); + } + + function renderHistory() { + emptyHistory.hidden = runs.length > 0; + 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)); + } + } + + function upsertRun(run) { + const index = runs.findIndex((candidate) => candidate.runId === run.runId); + if (index === -1) runs.unshift(run); + else runs[index] = 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); + 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); + + const current = stageOrder.indexOf(run.stage); + stageItems.forEach((item, index) => { + let state = ""; + if (index < current || run.status === "deployed") state = "complete"; + if (index === current && run.status === "running") state = "active"; + if ( + index === current && + !["running", "deployed", "awaiting_blueprint"].includes(run.status) + ) { + state = "failed-stage"; + } + item.className = state; + }); + + const deployed = run.status === "deployed" && Boolean(run.urls?.web); + result.hidden = !deployed; + if (deployed) { + resultName.textContent = titleFromSlug(run.appName) || "Your website"; + summary.textContent = resultSummary(run.summary); + webUrl.href = run.urls.web; + } + + const showDetails = + !activeStatuses.includes(run.status) && + run.status !== "deployed" && + Boolean(run.summary); + runDetails.hidden = !showDetails; + runDetails.textContent = showDetails ? run.summary : ""; + + view.renderRun?.(run, actions); + } + + function setBusy(busy) { + submit.disabled = busy; + submit.textContent = busy ? "Building…" : "Build and deploy"; + } + + function showFailure(message) { + runPanel.hidden = false; + runPanel.classList.add("failed"); + status.textContent = "Request failed"; + progress.textContent = message; + setBusy(false); + } + + loadRuns(localStorage.getItem("vibe-code-selected-run")).catch((error) => + showFailure(error.message), + ); + return actions; +} + +export function label(value) { + return value.replaceAll("_", " ").replace(/\b\w/g, (character) => character.toUpperCase()); +} + +export function truncate(value, length) { + return value.length > length ? `${value.slice(0, length - 1)}…` : value; +} + +function titleFromSlug(value) { + return value ? label(value.replaceAll("-", " ")) : ""; +} + +function resultSummary(value) { + if (!value) return "Your website is ready."; + const built = value.split(/\n\s*\n/)[0].replace(/\s+/g, " ").trim(); + return truncate(built, 220); +} + +export function formatDate(value) { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(value)); +} diff --git a/public/style.css b/public/style.css index a4cde90..4a37c83 100644 --- a/public/style.css +++ b/public/style.css @@ -96,6 +96,13 @@ code { height: 20px; } +/* The button that opens the other view of the UI. */ +.view-switch { + margin-left: auto; + text-decoration: none; + white-space: nowrap; +} + .topbar-label { padding-left: 16px; border-left: 1px solid var(--border); @@ -659,11 +666,243 @@ input { .danger-button:focus-visible, .run-item:focus-visible, .stage-label:focus-visible, +.site-name:focus-visible, +.site-url:focus-visible, +.stage-links a:focus-visible, .primary-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +/* + * The table view. Its tables have a mono header row, 1px rules, and no fills, + * as Render's tables do. The row of the selected site, and the row of the + * stage that runs now, have a neutral tint. + */ +.sites-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.table-scroll { + overflow-x: auto; +} + +.sites-table, +.stage-table { + width: 100%; + border-collapse: collapse; +} + +.sites-table th, +.sites-table td, +.stage-table th, +.stage-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +.sites-table thead th, +.stage-table thead th { + padding-top: 0; + color: var(--text-faint); + font-family: var(--font-mono); + font-size: 12px; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.02em; + text-transform: uppercase; + white-space: nowrap; +} + +/* On a narrow screen, the sites table scrolls sideways in its panel. */ +.sites-table { + min-width: 760px; +} + +.sites-table tbody th, +.sites-table td { + color: var(--text-secondary); + font-size: 14px; + font-weight: 400; + line-height: 20px; + vertical-align: middle; +} + +.sites-table tbody tr { + cursor: pointer; +} + +.sites-table tbody tr:hover { + background: var(--hover-tint); +} + +.sites-table tbody tr.selected { + background: var(--selected); +} + +.site-name { + padding: 0; + border: 0; + background: none; + color: var(--text); + font: inherit; + font-size: 15px; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.sites-table tr.selected .site-name { + font-weight: 500; +} + +.sites-table .run-state, +.sites-table td:nth-child(4), +.sites-table td:nth-child(5) { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0; + white-space: nowrap; +} + +.site-url { + display: block; + max-width: 300px; + overflow: hidden; + color: var(--link); + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.site-url:hover, +.stage-links a:hover { + color: var(--link-hover); + text-decoration: underline; +} + +.table-button { + min-height: 28px; + padding: 0 10px; + font-size: 13px; +} + +.none { + color: var(--text-faint); +} + +.stage-table { + table-layout: fixed; +} + +.stage-table thead th:nth-child(1) { + width: 18%; +} + +.stage-table thead th:nth-child(3) { + width: 24%; +} + +.stage-table thead th:nth-child(4) { + width: 14%; +} + +/* The name of a stage, after a square that shows its state. */ +.stage-table tbody th { + position: relative; + padding-left: 32px; + color: var(--text-faint); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0; +} + +.stage-table tbody th::before { + position: absolute; + top: 15px; + left: 12px; + width: 10px; + height: 10px; + border: 1px solid var(--border-strong); + content: ""; +} + +.stage-table td { + color: var(--text-secondary); + font-size: 14px; + line-height: 20px; +} + +.stage-table .stage-where { + color: var(--text); +} + +.stage-where span { + display: block; + color: var(--text-faint); + font-size: 13px; + line-height: 18px; +} + +.stage-links a { + display: block; + color: var(--link); + text-decoration: none; +} + +.stage-table tr.complete th { + color: var(--success); +} + +.stage-table tr.complete th::before { + border-color: var(--success); + background: var(--success); +} + +.stage-table tr.active { + background: var(--selected); +} + +.stage-table tr.active th, +.stage-table tr.active td { + color: var(--text); +} + +.stage-table tr.active th { + font-weight: 500; +} + +.stage-table tr.active th::before { + border-color: var(--text); + background: var(--text); + animation: pulse 1.2s var(--ease) infinite; +} + +.stage-table tr.failed-stage th { + color: var(--danger); +} + +.stage-table tr.failed-stage th::before { + border-color: var(--danger); + background: var(--danger); +} + +/* Text for a screen reader only, such as the header of the delete column. */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + @keyframes pulse { 50% { opacity: 0.25; @@ -672,7 +911,8 @@ input { @media (prefers-reduced-motion: reduce) { .activity, - .stages li.active::before { + .stages li.active::before, + .stage-table tr.active th::before { animation: none; } @@ -742,6 +982,54 @@ input { width: 100%; min-height: 44px; } + + /* Each stage is a block: its name, what it does, where it runs, and links. */ + .stage-table, + .stage-table tbody, + .stage-table tbody tr, + .stage-table th, + .stage-table td { + display: block; + } + + .stage-table thead { + display: none; + } + + .stage-table tbody { + border-top: 1px solid var(--border); + } + + .stage-table tbody tr { + padding: 10px 0; + border-bottom: 1px solid var(--border); + } + + .stage-table th, + .stage-table td { + padding: 0 12px 0 32px; + border-bottom: 0; + } + + .stage-table td { + margin-top: 4px; + } + + .stage-table tbody th::before { + top: 5px; + } + + .stage-links a { + display: inline; + margin-right: 16px; + } +} + +/* A phone has no room for the name of the demo beside the view button. */ +@media (max-width: 479px) { + .topbar-label { + display: none; + } } /* Last, so that it wins over each display value above. */ diff --git a/public/table.html b/public/table.html new file mode 100644 index 0000000..4a90c2f --- /dev/null +++ b/public/table.html @@ -0,0 +1,205 @@ + + + + + + + Vibe Code Demo + + + + + + + + +
+ + Vibe Code Demo + Classic view +
+ +
+
+

Render Workflows demo

+

Describe it. Ship it.

+

+ Submit an app idea and follow it from design through a live Render deployment. +

+
+ +
+
+ + +
+ +
+
+
+ +
+
+
+

History

+

Your sites

+
+ +
+

No sites yet.

+ +
+ + + + +
+
+

Delete

+

Delete this app?

+
+

+
+ + +
+
+ + +
+
+
+
+ + + diff --git a/public/table.js b/public/table.js new file mode 100644 index 0000000..6a70750 --- /dev/null +++ b/public/table.js @@ -0,0 +1,125 @@ +import { activeStatuses, formatDate, label, startRunsPage, truncate } from "/runs.js"; + +const sites = document.querySelector("#sites"); +const siteRows = document.querySelector("#site-rows"); +const linkCells = [...document.querySelectorAll("#stages [data-links]")]; +const linkLabels = { workflowRun: "Workflow run", sandbox: "Sandbox" }; + +/** The links in the stage table, so that a poll changes them only when they change. */ +let shownLinks = null; + +startRunsPage({ renderHistory, renderRun }); + +function renderHistory(runs, selectedRunId, { select, openDeleteDialog }) { + sites.hidden = runs.length === 0; + siteRows.replaceChildren( + ...runs.map((run) => { + const selected = run.runId === selectedRunId; + const row = document.createElement("tr"); + row.className = selected ? "selected" : ""; + // A click anywhere on the row selects it, except on its link or its button. + row.addEventListener("click", (event) => { + if (!event.target.closest("a, button")) select(run.runId); + }); + + const name = document.createElement("button"); + name.type = "button"; + name.className = "site-name"; + name.textContent = run.appName || truncate(run.prompt, 34); + name.setAttribute("aria-pressed", String(selected)); + name.addEventListener("click", () => select(run.runId)); + const nameCell = document.createElement("th"); + nameCell.scope = "row"; + nameCell.append(name); + + const state = document.createElement("span"); + state.className = "run-state"; + state.dataset.status = run.status; + state.textContent = label(run.status); + + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "danger-button table-button"; + remove.textContent = "Delete"; + remove.setAttribute("aria-label", `Delete ${run.appName || "this run"}`); + remove.hidden = activeStatuses.includes(run.status); + remove.addEventListener("click", () => openDeleteDialog(run)); + + row.append( + nameCell, + cell(state), + cell(run.urls?.web ? siteLink(run.urls.web) : none()), + cell(generationTime(run)), + cell(formatDate(run.createdAt)), + cell(remove), + ); + return row; + }), + ); +} + +/** + * Each stage links to the pages in the Render Dashboard where it runs: the + * workflow run, which lists its subtasks, and the sandbox of the run. The + * gateway gives null for a link that it cannot make. + */ +function renderRun(run) { + const links = JSON.stringify(run.links ?? {}); + if (links === shownLinks) return; + shownLinks = links; + for (const linkCell of linkCells) { + const anchors = linkCell.dataset.links + .split(" ") + .filter((name) => run.links?.[name]) + .map((name) => dashboardLink(run.links[name], linkLabels[name])); + linkCell.replaceChildren(...(anchors.length > 0 ? anchors : [none()])); + } +} + +function cell(content) { + const td = document.createElement("td"); + td.append(content); + return td; +} + +function siteLink(url) { + const anchor = dashboardLink(url, new URL(url).host); + anchor.className = "site-url"; + anchor.title = url; + return anchor; +} + +function dashboardLink(url, text) { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.target = "_blank"; + anchor.rel = "noreferrer"; + anchor.textContent = text; + return anchor; +} + +function none() { + const span = document.createElement("span"); + span.className = "none"; + span.textContent = "—"; + return span; +} + +/** How long the run took to build and deploy, or how long it has run so far. */ +function generationTime(run) { + const end = run.finishedAt + ? Date.parse(run.finishedAt) + : run.status === "running" + ? Date.now() + : null; + return end === null ? "—" : formatDuration(end - Date.parse(run.createdAt)); +} + +function formatDuration(milliseconds) { + const seconds = Math.max(0, Math.round(milliseconds / 1000)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; +} diff --git a/scripts/doctor.ts b/scripts/doctor.ts index 8a8789c..d1e65e9 100644 --- a/scripts/doctor.ts +++ b/scripts/doctor.ts @@ -340,6 +340,9 @@ async function checkPostgres(): Promise { "workflow_run_id", "web_url", "blueprint_path", + "sandbox_id", + "sandbox_group_id", + "finished_at", ].filter((name) => !columns.has(name)); record( missing.length === 0 diff --git a/tests/gateway.test.ts b/tests/gateway.test.ts index 3cd4cb5..f017536 100644 --- a/tests/gateway.test.ts +++ b/tests/gateway.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ setWorkflowRunId: vi.fn(), startTask: vi.fn(), getTaskRun: vi.fn(), + workflowIdOfTaskRun: vi.fn(), })); vi.mock("../app/store.js", () => ({ @@ -30,6 +31,10 @@ vi.mock("../app/store.js", () => ({ setWorkflowRunId: mocks.setWorkflowRunId, })); +vi.mock("../app/render.js", () => ({ + workflowIdOfTaskRun: mocks.workflowIdOfTaskRun, +})); + vi.mock("@renderinc/sdk", () => ({ Render: class { workflows = { @@ -70,6 +75,7 @@ beforeEach(() => { mocks.startTask.mockResolvedValue({ taskRunId: "trn-1" }); mocks.claimWorkflowCheck.mockResolvedValue(false); mocks.setWorkflowRunId.mockResolvedValue(undefined); + mocks.workflowIdOfTaskRun.mockResolvedValue("wfl-1"); process.env.FACTORY_API_KEY = KEY; process.env.UI_USERNAME = "demo"; @@ -77,6 +83,7 @@ beforeEach(() => { process.env.RENDER_WORKFLOW_SLUG = "wfs-1"; delete process.env.UI_AUTH_DISABLED; delete process.env.NODE_ENV; + delete process.env.RENDER_USE_LOCAL_DEV; }); describe("health", () => { @@ -242,6 +249,38 @@ describe("browser UI", () => { } }); + it.each(["/table", "/table.js", "/runs.js", "/app.js"])( + "requires Basic Auth for %s", + async (path) => { + expect((await createGateway().request(path)).status).toBe(401); + const response = await createGateway().request(path, { + headers: { authorization }, + }); + expect(response.status).toBe(200); + }, + ); + + it("explains each run stage in the table view, with its dashboard links", async () => { + const { RUN_STAGES } = + await vi.importActual("../app/store.js"); + const response = await createGateway().request("/table", { + headers: { authorization }, + }); + const html = await response.text(); + const rows = [...html.matchAll(/([\s\S]*?)<\/tr>/g)]; + + expect(html).toContain(''); + expect(rows.map(([, stage]) => stage)).toEqual(RUN_STAGES); + for (const [, , cells] of rows) { + // The name, what the stage does, where it runs, and its links. + const text = [...cells.matchAll(/]*>([\s\S]*?)<\/t[hd]>/g)].map( + ([, content]) => content.replace(/<[^>]+>/g, "").trim(), + ); + expect(text.slice(0, 3).every(Boolean)).toBe(true); + expect(cells).toMatch(//); + } + }); + it("allows the explicit auth bypass only outside production", async () => { process.env.UI_AUTH_DISABLED = "true"; expect((await createGateway().request("/")).status).toBe(200); @@ -364,6 +403,60 @@ describe("status", () => { }, ); + /** Two reads of the run from one gateway, as the polls of the UI do. */ + async function readTwice() { + const gateway = createGateway(); + const read = async () => + (await ( + await gateway.request(`/v1/apps/${RUN_ID}`, { + headers: { authorization: `Bearer ${KEY}` }, + }) + ).json()) as { finishedAt: string; links: Record }; + return [await read(), await read()]; + } + + it("links the run to its task run and its sandbox in the Render Dashboard", async () => { + mocks.getRun.mockResolvedValue( + storedRun({ sandboxId: "sbx-1", sandboxGroupId: "sbg-1" }), + ); + + const [first, second] = await readTwice(); + + // The workflow ID is read after the first response, and only once. + expect(first.links.workflowRun).toBeNull(); + expect(mocks.workflowIdOfTaskRun).toHaveBeenCalledTimes(1); + expect(mocks.workflowIdOfTaskRun).toHaveBeenCalledWith("trn-1"); + expect(second.finishedAt).toBe("2026-01-01T00:09:00.000Z"); + expect(second.links).toEqual({ + workflowRun: "https://dashboard.render.com/wf/wfl-1/runs/trn-1", + sandbox: "https://dashboard.render.com/sandbox-group/sbg-1/sandboxes/sbx-1", + }); + }); + + it("gives no link that it cannot make", async () => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.getRun.mockResolvedValue(storedRun({ sandboxId: "sbx-1" })); + mocks.workflowIdOfTaskRun.mockRejectedValue(new Error("403")); + + const [, second] = await readTwice(); + logged.mockRestore(); + + expect(second.links).toEqual({ workflowRun: null, sandbox: null }); + // A failed read is tried again after a minute, not on each poll. + expect(mocks.workflowIdOfTaskRun).toHaveBeenCalledTimes(1); + }); + + // A local task run is not in the Dashboard. + it("gives no workflow link in local development", async () => { + process.env.RENDER_USE_LOCAL_DEV = "true"; + mocks.getRun.mockResolvedValue(storedRun()); + + const [, second] = await readTwice(); + + expect(second.links.workflowRun).toBeNull(); + expect(mocks.workflowIdOfTaskRun).not.toHaveBeenCalled(); + }); + it("404s an id that is not a run id without querying Postgres", async () => { const response = await createGateway().request("/v1/apps/not-a-uuid", { headers: { authorization: `Bearer ${KEY}` }, @@ -389,8 +482,11 @@ function storedRun(overrides: Record = {}) { apiUrl: null, blueprintPath: "apps/demo/furniture-catalog/render.yaml", summary: "Deployed.", + sandboxId: null, + sandboxGroupId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:10:00.000Z", + finishedAt: "2026-01-01T00:09:00.000Z", ...overrides, }; } diff --git a/tests/render.test.ts b/tests/render.test.ts index dd14a0b..e243f8e 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -10,6 +10,7 @@ import { pageScripts, waitForDeploy, waitForServices, + workflowIdOfTaskRun, } from "../app/render.js"; const REST_API = "https://api.render.com/v1"; @@ -581,3 +582,41 @@ describe("findBlueprint", () => { expect(urls).toHaveLength(1); }); }); + +/** The gateway links each run to its task run in the Render Dashboard. */ +describe("workflowIdOfTaskRun", () => { + beforeEach(() => vi.stubEnv("RENDER_API_KEY", "rnd_test")); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + function renderHas(task: Record) { + const fetchMock = vi.fn(async (url: string | URL | Request) => + String(url).includes("/task-runs/") + ? Response.json({ id: "trn-1", taskId: "tsk-1" }) + : Response.json(task), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("reads the task of the task run, and gives its workflow", async () => { + const fetchMock = renderHas({ id: "tsk-1", workflowId: "wfl-1" }); + + expect(await workflowIdOfTaskRun("trn-1")).toBe("wfl-1"); + expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([ + `${REST_API}/task-runs/trn-1`, + `${REST_API}/tasks/tsk-1`, + ]); + }); + + it("fails when the task names no workflow", async () => { + renderHas({ id: "tsk-1" }); + + await expect(workflowIdOfTaskRun("trn-1")).rejects.toThrow( + "Task tsk-1 names no workflow", + ); + }); +}); diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index f6a4e58..9d8944d 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -47,6 +47,8 @@ const mocks = vi.hoisted(() => ({ deleteRuns: vi.fn(async () => {}), failDelete: vi.fn(async () => {}), finishRun: vi.fn(async () => {}), + setRunSandbox: vi.fn(async () => {}), + sandboxGroupId: vi.fn(async (): Promise => "sbg-test"), })); vi.mock("../app/agents.js", () => ({ @@ -90,6 +92,7 @@ vi.mock("../app/store.js", () => ({ failDelete: mocks.failDelete, finishRun: mocks.finishRun, setDeleteProgress: vi.fn(async () => {}), + setRunSandbox: mocks.setRunSandbox, setRunStage: vi.fn(async () => {}), setRunUrls: vi.fn(async () => {}), touchRun: vi.fn(async () => {}), @@ -113,6 +116,7 @@ vi.mock("../app/sandbox.js", async (importOriginal) => ({ ...(await importOriginal()), createSandbox: mocks.createSandbox, connectSandbox: mocks.connectSandbox, + sandboxGroupId: mocks.sandboxGroupId, })); vi.mock("../app/teardown.js", () => ({ @@ -1484,12 +1488,32 @@ describe("promptToApp", () => { vi.restoreAllMocks(); }); + // Only a link in the UI needs the sandbox record. + it("runs on when it cannot record the sandbox", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.sandboxGroupId.mockRejectedValueOnce(new Error("forbidden")); + mocks.setRunSandbox.mockRejectedValueOnce(new Error("store down")); + + const result = await promptToApp.func(tasks, INPUT); + + expect(result.status, result.summary).toBe("awaiting_blueprint"); + expect(mocks.setRunSandbox).toHaveBeenCalledWith("run-1", { + id: build.sandbox.id, + groupId: null, + }); + }); + it("verifies the app and then publishes it", async () => { const { context, runs } = recordSubtasks(); const result = await promptToApp.func(context, INPUT); expect(result.status, result.summary).toBe("awaiting_blueprint"); + // The UI links to the sandbox of the run in the Render Dashboard. + expect(mocks.setRunSandbox).toHaveBeenCalledWith("run-1", { + id: build.sandbox.id, + groupId: "sbg-test", + }); expect(runs.map(({ name }) => name)).toEqual([ "architect", "builder",