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
24 changes: 20 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
94 changes: 86 additions & 8 deletions app/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand All @@ -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" }));

Expand All @@ -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<Response> {
async function listRuns(
c: Context,
user: string,
workflowId: WorkflowIdReader,
): Promise<Response> {
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);
Expand Down Expand Up @@ -167,7 +186,10 @@ async function createRun(
);
}

async function readRun(c: Context): Promise<Response> {
async function readRun(
c: Context,
workflowId: WorkflowIdReader,
): Promise<Response> {
const runId = c.req.param("runId");
if (!runId || !RUN_ID.test(runId)) return c.json({ error: "not found" }, 404);

Expand All @@ -184,7 +206,7 @@ async function readRun(c: Context): Promise<Response> {
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);
Expand Down Expand Up @@ -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,
Expand All @@ -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;
};
}

Expand Down
19 changes: 19 additions & 0 deletions app/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,25 @@ async function readApi<T>(path: string, what: string): Promise<T> {
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<string> {
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 {
Expand Down
11 changes: 11 additions & 0 deletions app/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
const { client, ownerId } = api();
const groups = await client.listGroups({ ownerId });
return groups.length === 1 ? groups[0].sandboxGroup.id : null;
}

/* ── Postgres in the sandbox ──────────────────────────────────────────── */

/**
Expand Down
18 changes: 17 additions & 1 deletion app/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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;

Expand Down Expand Up @@ -329,6 +335,17 @@ export async function deleteRuns(user: string, appName: string): Promise<void> {
);
}

/** 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<void> {
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 },
Expand All @@ -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],
);
Expand Down Expand Up @@ -395,7 +412,10 @@ function rowToRun(row: Record<string, unknown>): 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,
};
}
Loading
Loading