Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,11 @@ run becomes `deployed`. The storefront check must also pass: the HTML of the
storefront, or a script that it loads, must contain the public hostname of the
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.

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
time range of the deploy, with no type filter. Thus it gets the build logs,
Expand Down
33 changes: 19 additions & 14 deletions app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,26 @@ export type FinishedStatus = Exclude<
>;

/**
* Coarse progress for GET /v1/apps/:runId. Cosmetic; never gates a run.
* stageOrder in public/app.js must list these stages in this order. If the
* stage of a run is not in that list, the UI shows no progress for the run.
* Coarse progress for GET /v1/apps/:runId, in pipeline order. Cosmetic;
* never gates a run. The stage list in public/index.html must list these
* stages in this order, each with a tooltip, and tests/gateway.test.ts checks
* it. If the stage of a run is not in that list, the UI shows no progress for
* the run.
*/
export type RunStage =
| "designing"
| "provisioning"
| "curating"
| "building"
| "verifying"
| "publishing"
| "waiting_for_services"
| "waiting_for_deploys"
| "smoke_testing"
| "done";
export const RUN_STAGES = [
"designing",
"provisioning",
"curating",
"building",
"verifying",
"publishing",
"waiting_for_services",
"waiting_for_deploys",
"smoke_testing",
"done",
] as const;

export type RunStage = (typeof RUN_STAGES)[number];

export interface RunRecord {
id: string;
Expand Down
2 changes: 2 additions & 0 deletions docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ Architecture overview and how Render products fit together:
- The UI shows these stages: Designing, Provisioning (only for an app with a
database), Curating, Building, Verifying, Publishing, Waiting For Services,
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.
- 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`.
Expand Down
58 changes: 31 additions & 27 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,13 @@ const deleteCancel = document.querySelector("#delete-cancel");
const activeStatuses = ["running", "deleting"];
const healthyStatuses = [...activeStatuses, "deployed", "awaiting_blueprint"];

const stageOrder = [
"designing",
"provisioning",
"curating",
"building",
"verifying",
"publishing",
"waiting_for_services",
"waiting_for_deploys",
"smoke_testing",
"done",
];
/**
* 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);

let runs = [];
let selectedRunId = null;
Expand Down Expand Up @@ -84,6 +79,18 @@ deleteConfirm.addEventListener("input", () => {

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
* goes to opens its tooltip again.
*/
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") stages.classList.add("tips-closed");
});
for (const type of ["pointerover", "focusin"]) {
stages.addEventListener(type, () => stages.classList.remove("tips-closed"));
}

deleteForm.addEventListener("submit", async (event) => {
event.preventDefault();
deleteDialog.close();
Expand Down Expand Up @@ -236,21 +243,18 @@ function renderRun(run) {
stages.hidden = ["deleting", "delete_failed"].includes(run.status);

const current = stageOrder.indexOf(run.stage);
stages.replaceChildren(
...stageOrder.map((stage, index) => {
const item = document.createElement("li");
item.textContent = label(stage);
if (index < current || run.status === "deployed") item.className = "complete";
if (index === current && run.status === "running") item.className = "active";
if (
index === current &&
!["running", "deployed", "awaiting_blueprint"].includes(run.status)
) {
item.className = "failed-stage";
}
return item;
}),
);
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;
Expand Down
77 changes: 76 additions & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,82 @@ <h2 id="status">Starting</h2>
</div>
<button id="delete-run" class="danger-button" type="button" hidden>Delete app</button>
</div>
<ol id="stages" class="stages"></ol>
<!--
RUN_STAGES in app/store.ts lists the same stages in the same order.
Each tooltip tells what its stage does and where it runs.
-->
<ol id="stages" class="stages">
<li data-stage="designing">
<button type="button" class="stage-label" aria-describedby="stage-tip-designing">Designing</button>
<span id="stage-tip-designing" class="stage-tip" role="tooltip" aria-hidden="true">
<span>The architect agent reads the prompt and plans the app: its pages, its content, and the Render resources that it needs.</span>
<span class="stage-tip-where"><span class="eyebrow">Runs in</span> The architect task on Render Workflows</span>
</span>
</li>
<li data-stage="provisioning">
<button type="button" class="stage-label" aria-describedby="stage-tip-provisioning">Provisioning</button>
<span id="stage-tip-provisioning" class="stage-tip" role="tooltip" aria-hidden="true">
<span>Starts a real Postgres in the sandbox, so that the builder can test its schema and seed data. An app without a database skips this stage.</span>
<span class="stage-tip-where"><span class="eyebrow">Runs in</span> The Render Sandbox of this run</span>
</span>
</li>
<li data-stage="curating">
<button type="button" class="stage-label" aria-describedby="stage-tip-curating">Curating</button>
<span id="stage-tip-curating" class="stage-tip" role="tooltip" aria-hidden="true">
<span>Finds openly licensed photographs on Wikimedia Commons and downloads them into the app. No model chooses a URL or a file name.</span>
<span class="stage-tip-where"><span class="eyebrow">Runs in</span> The prompt-to-app task on Render Workflows</span>
</span>
</li>
<li data-stage="building">
<button type="button" class="stage-label" aria-describedby="stage-tip-building">Building</button>
<span id="stage-tip-building" class="stage-tip" role="tooltip" aria-hidden="true">
<span>The builder agent writes the app: it selects the stack, writes the files, and runs commands. It also fixes each failure that verification finds.</span>
<span class="stage-tip-where"><span class="eyebrow">Runs in</span> The builder task on Render Workflows. Its tools run in the Render Sandbox, which holds no credentials.</span>
</span>
</li>
<li data-stage="verifying">
<button type="button" class="stage-label" aria-describedby="stage-tip-verifying">Verifying</button>
<span id="stage-tip-verifying" class="stage-tip" role="tooltip" aria-hidden="true">
<span>Builds the app from only the files that a commit holds, as Render will. Then it boots the app and checks its endpoints.</span>
<span class="stage-tip-where"><span class="eyebrow">Runs in</span> The verify-app task on Render Workflows. The builds run in the Render Sandbox.</span>
</span>
</li>
<li data-stage="publishing">
<button type="button" class="stage-label" aria-describedby="stage-tip-publishing">Publishing</button>
<span id="stage-tip-publishing" class="stage-tip" role="tooltip" aria-hidden="true">
<span>Copies the app into a clean clone of the apps repository, writes its Blueprint, and pushes one commit to GitHub.</span>
<span class="stage-tip-where"><span class="eyebrow">Runs in</span> The publish-app task on Render Workflows, in a second Render Sandbox that no agent uses</span>
</span>
</li>
<li data-stage="waiting_for_services">
<button type="button" class="stage-label" aria-describedby="stage-tip-waiting_for_services">Waiting For Services</button>
<span id="stage-tip-waiting_for_services" class="stage-tip" role="tooltip" aria-hidden="true">
<span>The push starts a Blueprint sync. Render creates the project and the services of the app from render.yaml.</span>
<span class="stage-tip-where"><span class="eyebrow">Runs in</span> Render. The prompt-to-app task waits on the Render API.</span>
</span>
</li>
<li data-stage="waiting_for_deploys">
<button type="button" class="stage-label" aria-describedby="stage-tip-waiting_for_deploys">Waiting For Deploys</button>
<span id="stage-tip-waiting_for_deploys" class="stage-tip" role="tooltip" aria-hidden="true">
<span>Render builds and deploys each service. If a deploy fails, the deploy manager agent finds the cause in its logs, and the builder fixes it.</span>
<span class="stage-tip-where"><span class="eyebrow">Runs in</span> Render. The prompt-to-app task waits on the Render API. A repair runs in the deploy-manager and builder tasks.</span>
</span>
</li>
<li data-stage="smoke_testing">
<button type="button" class="stage-label" aria-describedby="stage-tip-smoke_testing">Smoke Testing</button>
<span id="stage-tip-smoke_testing" class="stage-tip" role="tooltip" aria-hidden="true">
<span>Render says that the app is live. Now the workflow requests its public URLs and checks the storefront, the data, and CORS.</span>
<span class="stage-tip-where"><span class="eyebrow">Runs in</span> The prompt-to-app task on Render Workflows</span>
</span>
</li>
<li data-stage="done">
<button type="button" class="stage-label" aria-describedby="stage-tip-done">Done</button>
<span id="stage-tip-done" class="stage-tip" role="tooltip" aria-hidden="true">
<span>The app passed every check and is live on Render.</span>
<span class="stage-tip-where"><span class="eyebrow">Runs in</span> The app's own project on Render</span>
</span>
</li>
</ol>
<p id="progress" class="progress"></p>
<div id="result" class="result" hidden>
<p class="eyebrow">Here’s what was built</p>
Expand Down
60 changes: 60 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ input {
}

.stages li {
position: relative;
display: flex;
align-items: center;
gap: 10px;
Expand All @@ -462,6 +463,23 @@ input {
font-size: 13px;
line-height: 20px;
letter-spacing: 0;
cursor: help;
}

.stages li:hover {
background: var(--hover-tint);
}

/* The name of a stage is a button only so that the keyboard can open its tooltip. */
.stage-label {
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-align: left;
cursor: inherit;
}

.stages li::before {
Expand Down Expand Up @@ -501,6 +519,47 @@ input {
background: var(--danger);
}

/*
* The tooltip of a stage opens above it on hover or keyboard focus, so a
* pointer that moves down the list does not go into an open tooltip. It
* touches its stage, so the pointer can move onto it and it stays open.
* Escape closes it (app.js). It is as wide as the column of its stage.
*/
.stage-tip {
position: absolute;
right: 0;
bottom: 100%;
left: 0;
z-index: 1;
display: none;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--border-strong);
background: var(--surface-muted);
color: var(--text-secondary);
font-family: var(--font-default);
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.01em;
cursor: auto;
}

.stages:not(.tips-closed) li:hover .stage-tip,
.stages:not(.tips-closed) .stage-label:focus-visible + .stage-tip {
display: flex;
}

.stage-tip-where {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 8px;
border-top: 1px solid var(--border);
color: var(--text);
}

.progress {
color: var(--text-secondary);
font-size: 14px;
Expand Down Expand Up @@ -599,6 +658,7 @@ input {
.secondary-button:focus-visible,
.danger-button:focus-visible,
.run-item:focus-visible,
.stage-label:focus-visible,
.primary-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
Expand Down
18 changes: 18 additions & 0 deletions tests/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,24 @@ describe("browser UI", () => {
expect(await response.text()).toContain("<title>Vibe Code Demo</title>");
});

it("lists each run stage in order, with a tooltip that says where it runs", async () => {
const { RUN_STAGES } =
await vi.importActual<typeof import("../app/store.js")>("../app/store.js");
const response = await createGateway().request("/", {
headers: { authorization },
});
const items = [
...(await response.text()).matchAll(/<li data-stage="([a-z_]+)">[\s\S]*?<\/li>/g),
];

expect(items.map(([, stage]) => stage)).toEqual(RUN_STAGES);
for (const [item, stage] of items) {
expect(item).toContain(`aria-describedby="stage-tip-${stage}"`);
expect(item).toContain(`id="stage-tip-${stage}" class="stage-tip" role="tooltip"`);
expect(item).toContain('<span class="eyebrow">Runs in</span>');
}
});

it("allows the explicit auth bypass only outside production", async () => {
process.env.UI_AUTH_DISABLED = "true";
expect((await createGateway().request("/")).status).toBe(200);
Expand Down
Loading