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
18 changes: 13 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
36 changes: 28 additions & 8 deletions app/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Response> {
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,
);
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -389,7 +411,6 @@ async function reconcileWorkflowRun(run: RunRecord): Promise<void> {
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;

Expand Down Expand Up @@ -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],
Expand Down
41 changes: 29 additions & 12 deletions app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -110,7 +117,8 @@ export async function ping(): Promise<void> {
/**
* 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<T>(
user: string,
Expand Down Expand Up @@ -216,24 +224,33 @@ export async function claimWorkflowCheck(id: string): Promise<boolean> {

/**
* 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<boolean> {
): Promise<AppClaim> {
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 };
});
}

Expand Down
12 changes: 8 additions & 4 deletions app/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
};
}

Expand Down
5 changes: 4 additions & 1 deletion docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,17 @@ 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.

**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-<user>-<app>-{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).

Expand Down
10 changes: 7 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions public/app.js
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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");
Expand All @@ -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)}`);
Expand Down
2 changes: 2 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ <h1>Describe it. <span class="accent">Ship it.</span></h1>
required
placeholder="Create a neighborhood plant exchange with listings, pickup details, and a friendly visual style."
></textarea>
<p id="form-error" class="form-error" role="alert"></p>
<div class="form-actions">
<button class="primary-button" type="submit">Build and deploy</button>
</div>
Expand Down Expand Up @@ -177,6 +178,7 @@ <h2 id="delete-title">Delete this app?</h2>
<label for="delete-confirm">Type <code id="delete-app-name"></code> to confirm</label>
<input id="delete-confirm" name="confirm" autocomplete="off" spellcheck="false" />
</div>
<p id="delete-error" class="form-error" role="alert"></p>
<div class="form-actions">
<button id="delete-cancel" class="secondary-button" type="button">Cancel</button>
<button id="delete-submit" class="danger-button" type="submit">Delete</button>
Expand Down
Loading
Loading