From 4b35aade84b52964979cd1c93989954c7e4b8cb0 Mon Sep 17 00:00:00 2001 From: Jacob Prall Date: Fri, 23 Oct 2026 10:13:37 -0700 Subject: [PATCH] Simplify vibe-coding-app for launch - Point the Deploy button and workflow repo at render-examples/vibe-coding-app and use the vibe-coding-app name throughout. - Remove the deploy-manager agent: workflow code gives the builder redacted deploy logs directly. - Suffix app names with the start of the run ID so similar prompts never overwrite each other's app. - Add npm run setup:apps-repo to commit an empty root Blueprint, so the apps Blueprint can be connected before the first run. - Keep one UI page (the stage table) and merge the shared UI script into app.js. - Rewrite stilted comments, shrink AGENTS.md, and update the docs. Co-authored-by: Cursor --- AGENTS.md | 720 ++++++------------------------------- README.md | 21 +- app/agents.ts | 51 +-- app/blueprint.ts | 4 +- app/build.ts | 3 +- app/claude.ts | 2 +- app/config.ts | 2 +- app/contracts.ts | 55 +-- app/delete.ts | 38 +- app/deploy.ts | 148 +++----- app/files.ts | 63 ++-- app/gateway.ts | 19 +- app/git.ts | 22 +- app/host.ts | 2 +- app/images.ts | 2 +- app/policy.ts | 14 +- app/publish.ts | 79 ++-- app/render.ts | 2 +- app/sandbox.ts | 7 +- app/store.ts | 37 +- app/teardown.ts | 2 +- app/verify.ts | 33 +- app/workflow.ts | 32 +- docs/0_QUICKSTART.md | 77 ++-- docs/1_EXTENSION.md | 11 +- docs/2_ARCHITECTURE.md | 28 +- docs/3_TAKEAWAYS.md | 294 ++++++--------- package-lock.json | 4 +- package.json | 3 +- public/app.js | 502 +++++++++++++++++++++++--- public/index.html | 245 +++++++------ public/runs.js | 320 ----------------- public/style.css | 225 +----------- public/table.html | 219 ----------- public/table.js | 179 --------- render.yaml | 2 +- scripts/doctor.ts | 12 +- scripts/setup-apps-repo.ts | 106 ++++++ tests/agents.test.ts | 10 +- tests/contracts.test.ts | 23 +- tests/gateway.test.ts | 17 +- tests/host.test.ts | 1 - tests/workflow.test.ts | 112 +++--- 43 files changed, 1335 insertions(+), 2413 deletions(-) delete mode 100644 public/runs.js delete mode 100644 public/table.html delete mode 100644 public/table.js create mode 100644 scripts/setup-apps-repo.ts diff --git a/AGENTS.md b/AGENTS.md index e97eb64..333dcfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,649 +1,141 @@ # Repository guide for agents and contributors -This file applies to the entire repository. It holds the conventions, the -invariants (the canonical list of safety boundaries), and the checklists for -changing the code. For setup, read -[docs/0_QUICKSTART.md](docs/0_QUICKSTART.md). To follow one run through the -code, read [docs/2_ARCHITECTURE.md](docs/2_ARCHITECTURE.md). +This file applies to the whole repository. Read +[docs/2_ARCHITECTURE.md](docs/2_ARCHITECTURE.md) for how a run moves through +the code, and [docs/1_EXTENSION.md](docs/1_EXTENSION.md) for step-by-step +guides to common changes. ## What this is -`vibe-code-demo` turns one authenticated prompt into a deployed application on -Render. It is a reference architecture for wiring a vibe-coding product to -Render Workflows, Sandboxes, Blueprints, Postgres, and the Render MCP server. -It is written to be read, not to be a framework. - -A caller posts a prompt. The gateway validates it and starts the -`prompt-to-app` workflow task. The workflow designs the app against Render -primitives, downloads openly licensed photos, and has an agent build the app in -an isolated sandbox. The `verify-app` subtask checks the build, and the -`publish-app` subtask copies it into a clone of the apps repository in a -separate sandbox and commits it with a Blueprint that Render deploys. - -A delete runs the other way. `DELETE /v1/apps/:runId` claims every run of -that run's app, and the `delete-app` task runs one subtask per step: take the -app out of the Blueprint, wait until no Blueprint sync can bring its resources -back, delete the resources, and remove its files. +`vibe-coding-app` turns one authenticated prompt into a deployed application +on Render. A caller posts a prompt; the gateway claims a run in Postgres and +starts the `prompt-to-app` workflow task. The architect agent plans the app, +the builder agent writes it in a credential-free Render Sandbox, `verify-app` +builds and boots it there, and `publish-app` commits it with a generated +Blueprint from a separate sandbox. Render's Blueprint sync deploys it, and the +workflow smoke-tests the public URLs. `delete-app` reverses this in four +restartable steps. Two processes deploy independently: - `app/server.ts`: the Hono gateway web service. -- `app/host.ts`: the Render Workflows host, which registers every task. - -The gateway runs no models, holds no repository credential, and creates or -deletes no infrastructure. Agents never write to GitHub and never call a -Render write API. Only `publish-app` and the two pushing steps of `delete-app` -write to GitHub. The only Render write calls are the deletes in -`app/teardown.ts`, made only by the `delete-app-resources` step. Code that -agents write runs in a Render Sandbox that holds only the app being built, -with no clone of the apps repository and no credential. Each push clones the -repository in a separate sandbox that no agent uses. - -## Running and verifying changes - -Setup is in [docs/0_QUICKSTART.md](docs/0_QUICKSTART.md). The commands you -need while changing code: - -- `npm run check` runs Biome, `tsc`, and Vitest. Run it before you finish. -- `npx vitest run tests/blueprint.test.ts` runs one file. -- `render blueprints validate` validates this repository's `render.yaml`. - Run it when you change that file. -- `npm run doctor` diagnoses a live deployment and is read-only. When you add - a required environment variable, a task name, or a schema object, add a - check for it there. That script is where setup mistakes get caught. -- `npm run demo` runs the end-to-end demo against a live gateway. - -Production entrypoints are `npm run start:gateway` and -`npm run start:workflows`. +- `app/host.ts`: the Render Workflows host, which registers every task in one + synchronous import pass. + +## Commands + +```bash +npm run check # Biome, tsc --noEmit, Vitest +npx vitest run tests/blueprint.test.ts # one file +render blueprints validate render.yaml # when render.yaml changes +npm run setup:apps-repo # commit the empty root Blueprint, once +npm run doctor # read-only checks of a live setup +npm run demo # one prompt end to end +``` Environment gotchas: -- `npm run dev:gateway` sets `RENDER_USE_LOCAL_DEV=true`, so the local gateway - starts tasks on the `dev:workflows` task server, which finds a task by name - and ignores the slug. Set that variable only there, never in `.env`: the - workflows host also loads `.env` and must use the Render API. -- `render.yaml` creates both services and the database in the `vibe-factory` - project, and sets the gateway's `RENDER_WORKFLOW_SLUG` from the workflow - with `fromService`. Keep that environment's network isolation off: an - isolated workflow cannot reach the database. +- `npm run dev:gateway` sets `RENDER_USE_LOCAL_DEV=true` so the gateway + dispatches to the local task server. Never put it in `.env`: the workflows + host also loads `.env` and must use the real Render API. +- Keep network isolation off in the factory's environment. An isolated + workflow cannot reach the database on the private network. - The workflow service in `render.yaml` sets `repo:` to the upstream - repository, because a workflow service in a Blueprint needs it. A fork must - change it to the fork's URL. -- The apps repository needs a one-time Blueprint watching `main:render.yaml` - with Auto Sync on, in the `RENDER_WORKSPACE_ID` workspace. `findBlueprint()` - looks only there. - -## The HTTP API and browser UI + repository. A fork must change it. -The gateway listens on `0.0.0.0:${PORT:-3000}`. `GET /health` is liveness, -`GET /ready` checks Postgres and is the Render health check, prompts arrive at -`POST /v1/apps`, runs are read at `GET /v1/apps/:runId`, and -`DELETE /v1/apps/:runId` deletes the run's app with all of its runs. The full -reference is in -[docs/2_ARCHITECTURE.md](docs/2_ARCHITECTURE.md#gateway). +## Module map -The browser UI at `/` and `/table` is protected by HTTP Basic Auth -(`UI_USERNAME`, `UI_PASSWORD`) and submits through `/ui/apps`, which keeps -`FACTORY_API_KEY` on the server. Rules for UI changes: - -- Do not add a browser route that bypasses Basic Auth. -- The signed-in `UI_USERNAME` is a validated lowercase slug and is injected as - the app namespace. Never accept a `user` from the browser. - `GET /ui/apps` lists only that namespace's runs, and - `DELETE /ui/apps/:runId` deletes only a run in that namespace. -- Postgres is the source of truth. The UI restores the selected run from - local storage, but renders from the server's list. -- The UI polls only `GET /ui/apps`, every 5 seconds while a run or delete is - in progress, and renders the history and the selected run from that list. - So the list handler must reconcile each task-owned run, as - `GET /ui/apps/:runId` does. -- Keep a submit error in the form and a delete error in its dialog. Each - refresh re-renders the run panel and would erase an error shown there. -- Runs build in parallel, up to `maxConcurrentRuns`. The UI disables the - submit button only while the gateway is accepting a prompt. - -The two views share `public/runs.js`; `app.js` (classic view, `index.html`) -and `table.js` (table view, `table.html`) render only what differs. Each view -links to the other. `setRunStage()` appends each stage a run enters, with its -start time, to `stage_history`, and the gateway gives each item an end time: -when the next item started, or when the run stopped. A stage entered more than -once, such as building after a failed verification, shows the sum of its -times. The gateway builds Dashboard links from IDs in Postgres and returns -`null` for a link it cannot make: the SDK does not return subtask run IDs, so -every stage links to the `prompt-to-app` run, 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, -and no SDK wrapper layer. Imports are relative, with `.js` extensions -(NodeNext). - -Imports flow one way: +Each concern is one file under `app/`: no barrels, no path aliases, relative +imports with `.js` extensions. Imports flow one way: ```text sandbox → tools → claude → agents → stages → workflow ``` The stages are `build`, `verify`, `publish`, `deploy`, and `delete`. -`workflow` runs them. `deploy` uses `build`, `verify`, and `publish`; `build` -uses `verify`; `delete` uses `publish`. - -The supporting modules: - -- `contracts`, `policy`, `store`, and `sandbox` import nothing else in `app/`. - `config` imports only the `slug` schema from `contracts`. -- `policy` defines the MCP allowlist and the path and redaction rules. `tools`, - `claude`, `agents`, `deploy`, and `gateway` import it. -- `render` is imported by `claude` (for the MCP URL), `deploy`, `delete`, - `teardown`, and `gateway` (for the workflow ID in Dashboard links). -- `git` (clone, commit, push), `files` (copying an app's files as data), and - `github` (credentials) are used by the stages: `publish` uses all three, - `verify` uses `git` and `files`, and `workflow` uses `git` for - `initAppDir()`. -- `blueprint`, `images`, `templates`, `teardown`, and `store` are used by - `workflow` and the stages. `teardown` uses `render` and `blueprint`. -- `gateway` uses `store`, `policy`, `contracts`, `config`, and `render`. - -Adding an import that points backwards is a design smell. +`contracts`, `policy`, `store`, and `sandbox` import nothing else in `app/`. +An import that points backwards is a design smell. ```text -factory.config.ts Directories, branch, plans, asset hosts, concurrency cap, model tiers +factory.config.ts Non-secret defaults: branch, plans, asset hosts, concurrency, models app/ - config.ts Environment parsing and per-process validation - contracts.ts Zod schemas for API input, agent output, and the stored spec - gateway.ts Auth, body cap, dispatch, status, reconciliation, run formatting - agents.ts The three agents, their prompts, and agentTask() - claude.ts The Agent type, runClaude(), md, agentJson() - tools.ts The sandbox tools and the Tool contract - policy.ts checkToolCall(), path rules, the MCP allowlist, redactSecrets() - sandbox.ts Render Sandboxes, shellEscape, Postgres in the sandbox - blueprint.ts render.yaml generation: the only path that creates resources - render.ts REST reads of services, deploys, logs, and Blueprints; HTTP probes - teardown.ts Deletes an app's resources: the only Render write API calls - git.ts Clone, commit, push and verify, .gitignore generation, - initAppDir(), removeIgnored() - files.ts readAppFiles(), appFiles(), writeAppFiles(), MAX_APP_BYTES: - copying an app's files between sandboxes as data - github.ts GitHub App JWT and installation tokens, the PAT fallback: - githubToken(), usingGitHubApp() - store.ts Postgres: the runs table, claims, and app locks - templates.ts Read a template and write it into the sandbox - images.ts Photos from Wikimedia Commons for an app - workflow.ts The prompt-to-app and delete-app pipelines - build.ts The builder rounds with verify-app, and the builder's message - verify.ts verify-app: build, boot, and query the app; runVerification() - publish.ts publish-app, and each clone, change, commit, and push - deploy.ts The deploy wait, the deploy repair, and the smoke checks - delete.ts The four steps of delete-app - schema.sql Schema, applied by scripts/migrate.ts - server.ts Gateway entrypoint - host.ts Workflows entrypoint -public/ The Basic Auth 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 -tests/ agents, blueprint, contracts, files, gateway, git, - github-auth, host, images, policy, render, shell, store, - teardown, templates, tools, workflow + server.ts, host.ts Process entrypoints + gateway.ts Auth, routes, dispatch, run responses, reconciliation + workflow.ts The prompt-to-app and delete-app narratives + build.ts Builder and verify-app rounds, the builder's message + verify.ts verify-app: build, boot, and query in the sandbox + publish.ts publish-app and every clone, commit, and push + deploy.ts Deploy waits, deploy repairs, smoke checks + delete.ts The four delete-app steps + agents.ts, claude.ts Agent definitions, agentTask(), runClaude(), agentJson() + tools.ts, policy.ts Sandbox tools, path rules, MCP allowlist, redaction + contracts.ts Zod schemas for API input, agent output, factory.json + blueprint.ts render.yaml generation: the only way to create resources + teardown.ts Scoped deletes: the only Render write API calls + render.ts Render REST reads and HTTP probes + sandbox.ts, git.ts, files.ts, github.ts, store.ts, images.ts, templates.ts +public/ The Basic Auth UI: index.html, app.js, style.css +templates/fullstack/ Vite + React web and Hono + pg API that full-stack apps start from +scripts/ migrate, setup-apps-repo, doctor, demo ``` -There is no `tasks.ts`, `scaffold.ts`, `shell.ts`, or `format.ts`. -`agentTask()` lives in `agents.ts`, `shellEscape` sits beside the only code -that executes a command in `sandbox.ts`, and run formatting is in -`gateway.ts`. The builder chooses its own stack, so there is no skeleton to -scaffold: the manifest it returns is what gets deployed. - ## Conventions -- Strict TypeScript, ESM, NodeNext. Include `.js` in relative imports. -- Validate external and workflow input with Zod before using it. -- Use `md` for dedented multi-line prompts. +- Strict TypeScript, ESM, NodeNext. Validate external and workflow input with + Zod before using it. - Escape shell arguments with `shellEscape`. Use `execGitWithToken` for - authenticated Git. -- Write each free-form manifest value into a Blueprint with `yamlString`. One - value that breaks the YAML stops the deploy of every app in the root - Blueprint. -- Keep task inputs and results small and JSON-serializable. The Dashboard - shows them. -- Comments explain intent and constraints, not what the next line does. -- Add or update tests with every behavior change. Tests never call live - Render, GitHub, Postgres, model, or Commons APIs. -- Test behavior, not implementation: go through public entry points, fake - only the systems at the edge, and assert what a user or operator sees, not - exact messages, SQL text, or call counts. One test per behavior, named as a - sentence. Keep the suite near a hundred tests; replace a test rather than - add a near-duplicate. -- Helpers that outgrow a file belong in a new `app/.ts`, not in a - subdirectory. + authenticated Git. Write free-form manifest values into a Blueprint with + `yamlString`: one bad value breaks the root Blueprint for every app. +- Keep task inputs and results small, JSON-serializable, and free of secrets. + The Render Dashboard shows them. +- Comments state constraints the code cannot show, not what the next line does. +- Tests never call live Render, GitHub, Postgres, model, or Commons APIs. Test + behavior through public entry points, fake only the edges, and keep the + suite near a hundred tests. ## Invariants -This is the canonical list of the factory's safety boundaries. Other documents -link here instead of restating it. Do not weaken any of these without an -explicit change to the security model, and update this list when you change -one. - -**Gateway and input** - -- One deployment is bound to one validated `APPS_REPO` and one branch. -- Only a request with the correct bearer token starts a run, and the token is - compared in constant time. -- Browser requests require valid Basic Auth. The UI handlers reuse the same - validation and claim path as `/v1`, and no HTML or JavaScript response - contains the factory bearer token. -- The body is capped before it is parsed. -- The UI submits, lists, and deletes only in its own `UI_USERNAME` - namespace. -- The gateway never receives the GitHub credential or the Anthropic key. -- Secret-shaped strings, including anything shaped like a Postgres - connection string, are redacted from everything the API returns. - -**Agents and tools** - -- Agents receive only the tools listed in their definition. Claude's - built-in Bash, Read, Write, and Edit are never granted: `runClaude()` always - passes `tools: []`. -- The Claude subprocess gets only the environment variables in - `ENV_ALLOWLIST`, which has no Render or GitHub credential. -- Only the builder gets sandbox tools. -- The architect and the deploy manager get only Render MCP tools from - `RENDER_READ_ONLY_TOOLS`. `checkToolCall()` denies every other Render tool, - so the allowlist is enforced twice. -- No agent reads Render logs. `RENDER_READ_ONLY_TOOLS` has no log tool, so - `checkToolCall()` denies `list_logs` too. Logs can hold secrets, and the - factory cannot redact what an agent reads directly. In a deploy repair - round, workflow code reads each failed deploy's logs with - `fetchDeployLogs()` and gives them to the deploy manager in its input. The - Dashboard shows every task input, so `failedDeployReport()` applies - `redactSecrets()` first and only then cuts the logs to - `MAX_DEPLOY_LOG_CHARS`. Do not cut first: a cut can split a secret, and the - redaction cannot find half of one. -- Every agent-supplied path is resolved against the workflow-owned `workDir` - on `ToolContext`, which is the app directory, and must land inside it or - `/tmp`. Both `checkToolCall()` and the tool itself check this. - `sandbox__exec` always `cd`s into the app directory first: the exec API - starts in `/`, and a relative path would otherwise build an app outside the - directory that gets committed. These rules keep the file tools on one app, - but they cannot limit a shell command. The next invariant does that. -- `checkToolCall()` does not inspect shell commands. A pattern cannot tell a - safe command from a harmful one; the sandbox's contents limit what a command - can reach. `sandbox__exec` still bounds every command to 10 minutes so a - foreground development server cannot hold a run slot for the parent's full - two-hour deadline. -- `sandboxId` and `workDir` come from workflow code, never from a model. -- Structured agent output is validated with Zod and fails closed after one - repair attempt (`agentJson()`). -- A failed Render deploy status is never overridden by an agent. If the - deploy manager lists no failures, the run ends as `deploy_failed`. - -**The build sandbox** - -- The sandbox the agents and `verify-app` use holds only the app being - built. `initAppDir()` creates the app directory in a repository with no - remote. No clone of the apps repository and no credential ever enters that - sandbox: the builder can run any command there, leave a process running, - and modify `git` itself. -- The agent-authored build, pre-deploy, and start commands run only in that - sandbox and in the app's own services on Render. Neither place holds a - factory credential. -- The image download in `app/images.ts` accepts only HTTPS, only hosts in - `factoryConfig.assets.allowedHosts`, and only `image/*` responses. It - follows redirects itself and checks the protocol and host of every hop - (`allowedUrl()`), refuses a declared length over `assets.maxBytes`, and - stops reading once the body passes it. It writes only into the app's - `assets/` directory, under a name the code makes. - -**Publishing** - -- The GitHub token never goes into a task input, because the Dashboard shows - every task input, and never into the build sandbox. -- `publish-app` reads the app's files from the build sandbox as data. - `readAppFiles()` packs what a commit of the app directory would hold, and - `appFiles()` accepts only regular files with plain paths below it, up to - `MAX_APP_BYTES`. Then `publish-app` gets the token itself, and clones, - writes the files, commits, and pushes in a sandbox no agent uses. -- The clone checks out symbolic links as plain files (`core.symlinks=false`), - so no factory write can follow a link that an earlier commit put in the - repository. -- A commit changes only `apps///` and the root `render.yaml`. - `commitPaths()` stages only these paths, and `pushVerified()` refuses a - commit that changes any other path, before every push attempt. This holds - for `publish-app` and for both pushing steps of `delete-app`. -- The root Blueprint is built from the `factory.json` of each app in the - push's clone. `readAllSpecs()` accepts a spec only in the directory of the - app it names, so one app's directory cannot declare another app's - resources. -- The push is verified against the remote SHA before the factory waits on a - deploy, so Render always builds the commit that passed verification. - -**Infrastructure** - -- Infrastructure is created only by committing a Blueprint, and agents - cannot publish, because their sandbox has no clone and no token. -- The only Render write API calls are the deletes in `app/teardown.ts`, made - only by `delete-app-resources`, a step of `delete-app`. The gateway starts - only `delete-app`, and `removeApp()` runs that step only after a push has - taken the app out of the root Blueprint and `wait-for-blueprint-syncs` has - found no pending or running sync. The deletes remove only services and - databases in the app's own project whose names start with the app's stem, - and then the project, which Render deletes only when it is empty. - -**State and concurrency** - -- `runs.idempotency_key` is unique, so a retried request cannot start a - second run. -- `claimRun()` caps concurrent runs at `factoryConfig.maxConcurrentRuns` with - a conditional insert, serialized by a transaction-scoped Postgres advisory - lock so two requests cannot both take the last slot. -- A delete claims every run of one app, and `claimRunApp()` claims an app - name for a run. Both take the same per-app advisory lock. So no run builds - an app while it is being deleted, a delete is refused while a run of the - app is running, and no two runs build the same app at once. -- Render filesystems are ephemeral. Cross-process state goes in Postgres. - -**Verification** - -- Verification is workflow-owned. `verify-app` runs the same install, build, - and pre-deploy commands the Blueprint gives Render, against a real Postgres - in the sandbox, and `publish-app` runs only after it passes. A health - endpoint must answer with the database unreachable, because Render calls it - before Postgres is ready. A `dataCheckPath` must return rows, because - nothing else proves the schema was applied and the seed loaded. -- `node_modules/` and static-site build output are not committed. - `appGitignore()` builds each app's `.gitignore` from its manifest: - `node_modules/`, plus each static site's publish directory below its - `rootDir`, never the service directory itself. `verify()` writes it and - deletes every ignored file before it builds, so it builds from the same - files Render's fresh clone gets. -- The sandbox is terminated in a `finally` block. - -## Durability - -There is no step memoization. A failed run is not resumed; the caller tries -again by posting the prompt again. The gateway stores the Render task-run ID. -Whenever a caller reads a run, or the UI lists runs, the gateway checks the -task run and marks the row failed if the task failed or was canceled, so an -interrupted task cannot leave a row `running` forever. A task that succeeds -writes its result before it returns, so the gateway never reads results. Long -service, deploy, and HTTP waits heartbeat `progress`; keep those waits -bounded. - -Render Workflows does not retry a failed run either: `prompt-to-app` sets -`retry: { maxRetries: 0, waitDurationMs: 0 }` instead of the default three -retries. A retry would start again at the architect with a new sandbox, and -after a push it could deploy a second app under a new name. The catch block in -`promptToApp` sets the row to `failed` before a retry would start, so -`claimRun()` would not count the retry, the gateway would not reconcile it, -and its result could overwrite a final status. Do not turn these retries on -without adding resumability. - -A transient fault is not a reason to retry the whole run. Retry the one call -that failed, with a limit, as `pushVerified()` does when another run pushed -first: the clone takes the new tip, and the push's change is made again. Each -change the factory makes is derived from its inputs, so it is never merged. -Agent subtasks also set `maxRetries: 0` and an explicit deadline. A model -failure or timeout returns to the parent once instead of repeating expensive -tool calls while the row holds a concurrency slot. - -`verify-app` and `publish-app` set `maxRetries: 0`, so a failure fails the -run. A retry of `publish-app` after its push would find nothing to commit, and -the run would end as if the push changed no files. A retry of `verify-app` -after a timeout would run every build again, and a service that the failed -attempt started could still answer on the port of the next boot. - -The service and deploy waits, the Blueprint lookup, and the -`wait-for-blueprint-syncs` step read Render through `retryRead()` in -`app/render.ts`. It retries a failed read after the poll interval, and fails -after five failures in a row with the last error. It fails at once on a 401 or -403, because retrying cannot fix the API key. Because `findBlueprint()` uses -it, only a lookup that completes and finds no Blueprint produces -`awaiting_blueprint`. `fetchDeployLogs()` uses it too, but if its reads cannot -finish it returns no logs instead of failing the run, because the logs are -only diagnostic. Workflow code reads Render over REST, which returns typed -records; only the agents use the Render MCP server. - -If you add resumability, `ctx.step()` from Render Workflows' Durability 2.0 -API is the place to start. Do not build a checkpoint store here. - -`delete-app` and each of its four steps set `maxRetries: 0`. A failed step -fails the delete. A failed delete marks the app's runs `delete_failed`, and -the next `DELETE` starts the task again at the first step. Each step reads the -state an earlier attempt left, so a new attempt does only what is left. A -successful delete removes the rows. The gateway reconciles a `deleting` row -against the delete task in the same way as a `running` row. - -Do not give the delete steps the default retries. A retry of -`remove-app-from-blueprint` after its push would find nothing to commit, so -the next step would not wait for the push event. A retry of -`wait-for-blueprint-syncs` would give a sync more time than -`SYNC_TIMEOUT_MS`. - -## Add an agent - -1. Add it to `app/agents.ts`. `id`, `model`, `prompt`, and - `timeoutSeconds` are required. `id` is also the registered task name, so - keep it unique and stable. Give it `tools` only if it needs the sandbox, - and `renderTools` only from `RENDER_READ_ONLY_TOOLS`. -2. Wrap it with `agentTask()` at the bottom of `app/agents.ts`, beside the - other registrations. -3. Call it from the stage that needs it, in `app/workflow.ts` or a stage - module such as `app/build.ts`, with `tasks.run(Task, input)`. - `tasks` is the `TaskContext` that Render Workflows passes to - `prompt-to-app`; pass it down to the stage. A task definition is not a - function, so calling it directly does not compile. Pass - `sandboxId: sandbox.id` and `workDir: appDir` only when the agent declares - tools; `runClaude()` refuses tools without both. -4. If it returns JSON, add a schema to `app/contracts.ts`, register it in - `OUTPUT_SCHEMAS`, and call the agent through `agentJson()`, which retries - once and then fails closed. -5. Update `tests/agents.test.ts` for tool access, and add the task name to - `tests/host.test.ts` and `scripts/doctor.ts`. - -## Add a Render primitive - -1. Add the kind to `TIER_KINDS` in `app/contracts.ts`, and describe in the - architect's prompt when to choose it. -2. Give the builder a way to declare it in `manifestSchema`. The Blueprint is - generated from the manifest, not from the plan. -3. Emit its resource block from `serviceBlocks()` or `databaseBlocks()` in - `app/blueprint.ts`; `projectBlock()` places it in the app's project - environment. Wire dependent env vars declaratively with `fromDatabase` or - `fromService`, never by reading a value back from an API. A static site is - not on the private network, and a browser uses its values, so give it only - public values such as `envVarKey: RENDER_EXTERNAL_HOSTNAME`, never `host`, - `port`, or `hostport`. -4. Extend `resourceNames()` so the new resource is namespaced by user and app - and cannot collide with another resource in the workspace. -5. Give `verify()` in `app/verify.ts` a way to exercise it before the push. A - primitive nothing verifies is a primitive that fails in production. -6. Add assertions to `tests/blueprint.test.ts`. That suite is the contract for - what gets deployed. -7. Make `deleteAppResources()` in `app/teardown.ts` list and delete it. A - resource the teardown does not know about stays in the app's project, and - Render then refuses to delete the project. - -## Change a template - -`templates/fullstack` is a working three-tier app that every run with a web -service starts from. It exists for the contracts a prompt cannot reliably -rediscover on every run: CORS, the API base URL built from the API's public -hostname, and an idempotent migrate-and-seed wired to `preDeployCommand`. - -It lives here, not in its own repository, so it stays version-locked to the -code that deploys it and CI builds it. If you change it: - -1. Keep it building. `npm ci && npm run build` in both halves is a CI job, and - a template nobody builds quietly stops building. -2. Keep the seed non-empty and both SQL files idempotent. Verification fails - a run whose data endpoint returns no rows. -3. Update `templateLines()` in `app/build.ts` if the manifest it implies - changes. `tests/templates.test.ts` pins the two together, and is what - catches the template and the prompt drifting apart. -4. Keep templates text-only. They are written into the sandbox as one - self-extracting shell script, so a binary file will not survive. - -## Change the pipeline - -`app/workflow.ts` holds the two narratives, `prompt-to-app` and `delete-app`. -Keep them readable: a stage is one call there, with its detail in the stage -module. Fetch large state inside the workflow instead of passing it through a -task input, and keep repeated execution safe. A rerun that chooses an existing -app name replaces the app directory with the new build's files, on the newest -tip of the branch. - -Verification and publishing are subtasks of `prompt-to-app`, as the agents -are. `verify-app` returns its failures as a result, not an error. -`publish-app` returns a `PublishResult`: the commit it pushed (or `null` when -no file changed) and `changedServices`, the services that commit gives a new -deploy. Both connect to the run's -sandbox by the `sandboxId` in their input, and the parent terminates that -sandbox. `publish-app` only reads the app's files from it; it clones, -commits, and pushes in its own sandbox and terminates that sandbox -itself. The last check in `verify-app` reads the files the way `publish-app` -does, so the builder can fix anything `publish-app` would refuse, such as a -symbolic link. - -Never give a credential to a sandbox an agent uses, and never push from one. -An agent can leave a process running, or a modified `git`, waiting for the -next command that has the token. - -Deployment progress distinguishes `waiting_for_services`, -`waiting_for_deploys`, and `smoke_testing`. Render reporting `live` is not the -end: the public URL, data endpoint, and CORS checks must pass before the run -becomes `deployed`. Render can take minutes to route a new hostname, and -returns 404 until then, so the first check of each public URL waits -`ROUTE_TIMEOUT_MS`, not the shorter `DATA_TIMEOUT_MS`. The storefront check -must also pass: the storefront's HTML, or a script it loads, must contain the -API's public hostname. The API checks cannot see the hostname 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 saying what the stage -does and where it runs. It also has a row in the stage table of -`public/table.html`, with the same description, a cell for its time, and its -Dashboard links. `tests/gateway.test.ts` checks both, so a new stage needs an -item and a row. - -### Deploy waits - -Right after a push, a service's newest deploy is still the one from before -the push. Render creates the new deploy only after the GitHub webhook and the -Blueprint sync. So every wait after a push names the deploy to ignore, through -the `after` option of `waitForDeploy()`: - -- **First push.** Before the push, `run()` calls `deploysBeforePublish()`, - which records the newest deploy of each of the app's services that already - exists. `publish-app` returns `changedServices`, from `servicesChangedBy()`: - the services whose directory holds a changed path, which matches each - service's `buildFilter`. `firstWaits()` passes each changed service's - recorded deploy as `after`. Without this, a rebuild of an existing app - would accept the old live deploy as the result of the new commit. A service - the commit did not change gets no new deploy, so its newest deploy is its - result. -- **Repair rounds.** A repair round waits only for the services that failed, - and passes each failed deploy as `after`. - -A push that starts no new deploy within `DEPLOY_TIMEOUT_MS` ends the run as -`deploy_failed`. Never send such a run to the smoke checks: Render keeps the -last live deploy of a failed service, so those checks could pass on old code. -Do not call `trigger_deploy` to start a deploy either; only a commit deploys. -`tests/workflow.test.ts` tests this loop. - -### Deploy repairs - -When a deploy fails, the deploy manager diagnoses it from that deploy's logs, -which workflow code gives it. `fetchDeployLogs()` reads them for the deploy's -time range with no type filter, so it gets the build logs, the pre-deploy -command's output, and the new instance's logs, and nothing from an earlier -deploy. Do not add a type filter: the logs API returns pre-deploy output as -`app` logs, not `build` logs, so a `build` filter loses it. Each round reads -the deploy that failed in that round. Do not give an agent `list_logs` in -place of this read: it could read the logs of every service in the workspace, -and the factory could not redact them first. - -The workflow runs the deploy manager only after Render reported a failed -deploy, and the agent returns only a JSON list of `failures`. There is no -"healthy" answer: an empty list ends the run as `deploy_failed` in -`repairDeploys()`. Only Render's deploy status and the smoke checks decide -that an app works. - -A repair ships through the same path as the first build. The repaired -manifest must pass `verify-app`, and then `publish-app` rewrites -`factory.json` and both Blueprints before the commit, because Render gets the -manifest only through these files. A repair can change commands, paths, and -env wiring, but not the list from `declaredResources()`: Render does not -delete a resource that leaves the Blueprint, it cannot change a service's -runtime, and the loop watches only the services it already knows. So the -workflow fails such a repair and pushes nothing. A repair that changes no -files also ends the run as `deploy_failed`. - -### Resource names - -Each `factory.json` records its `resourcePrefix`, which is part of every -Render resource name of the app. Read the prefix from the spec, not from -`factoryConfig`, so that a new default never renames an existing app's -resources. - -## Delete an app - -A delete removes an app, not just a run, because the runs of one app share -its files and resources. `removeApp()` in `app/workflow.ts` runs one subtask -per step, in this order. The steps are in `app/delete.ts`, and -`tests/workflow.test.ts` tests them: - -1. `remove-app-from-blueprint`: write `deletedAt` into the app's - `factory.json`, regenerate the root Blueprint (which leaves the app out), - and push. Remove no source file yet: a commit that removes a service's - files starts a build of that service, and the build fails. -2. `wait-for-blueprint-syncs`: wait a minute for the push event of the - previous push, then until no sync of the Blueprint is pending or running. - From step 1's commit on, the file does not declare the app, and a sync - recreates only declared resources. So only a sync of an earlier commit can - bring a deleted resource back. -3. `delete-app-resources`: delete the app's services, then its databases, - then its project. Its input is only the spec fields that name them. -4. `remove-app-files`: remove the app's directory and push. - -Do not change this order. A resource deleted before step 1 comes back on the -next sync. The spec holds the resource names, so if step 4 ran before step 3, -a failed delete would lose them. The files stay in the Git history. - -Do not wait for the resources to leave the Blueprint's resource list. A push -that only removes resources starts no sync, and Render keeps them in that -list, so such a wait never finishes. - -The two pushing steps each clone the apps repository in their own sandbox. -Their commits change only the app's directory and the root Blueprint, like -`publish-app`'s commit. Keep task inputs and results small and -JSON-serializable: they go through Render, and the Dashboard shows them on -each step's run. - -Each step logs one JSON line for each thing it changes or waits for: the -commits, the unfinished syncs, and each resource it deletes or keeps. These -logs are the record of what the factory deleted, so keep them when you change -a step. +Do not weaken these without an explicit change to the security model. +[docs/2_ARCHITECTURE.md](docs/2_ARCHITECTURE.md#trust-boundaries) shows where +each is enforced. + +1. The gateway holds no model key or GitHub credential. It authenticates + every route, caps the body before parsing, takes the UI's namespace from + `UI_USERNAME`, and redacts secret-shaped text from every response. +2. Agents get only the tools in their definition. `runClaude()` disables + Claude's built-in tools, forwards only `ENV_ALLOWLIST`, and runs + `checkToolCall()` before every call. +3. Only the builder gets sandbox tools. The architect gets only + `RENDER_READ_ONLY_TOOLS`, which has no log tool. Deploy logs reach the + builder only through workflow code, redacted before they are cut. +4. Every agent path resolves inside the app directory or `/tmp`. Shell + commands are not filtered; the sandbox's contents are the boundary. +5. The build sandbox holds only the app: no clone of the apps repository and + no credential. `sandboxId` and `workDir` come from workflow code, never a + model. +6. `publish-app` reads the app's files as untrusted data and pushes from a + fresh sandbox no agent used. Each commit changes only + `apps///` and the root `render.yaml`, and the push is verified + against the remote SHA. +7. Infrastructure is created only by committing a Blueprint generated from a + validated manifest. Only `delete-app-resources` calls a Render write API, + only after the app has left the Blueprint and no older sync is pending. +8. Agent output is schema-validated and fails closed. No agent can override a + failed verification or a failed Render deploy status. +9. `prompt-to-app`, every agent task, `verify-app`, `publish-app`, and every + delete step set `maxRetries: 0`. Retry only the narrow operation that + failed, as `retryRead()` and `pushVerified()` do. +10. Run claims use a unique idempotency key and advisory locks, so a retried + request cannot start a second run and no app is built and deleted at once. +11. Every sandbox is terminated in a `finally` block. ## Checklist -1. Trust boundaries between the gateway, the workflow, and the agents are - preserved, and the [Invariants](#invariants) still hold. No credential - goes into a sandbox an agent uses, and each commit changes only one app and - the root Blueprint. -2. New agents are registered at the bottom of `app/agents.ts`, and task names - match between the definition, the dispatch, `scripts/doctor.ts`, and the - tests. -3. New external input is validated, and task inputs and results stay - JSON-serializable. -4. Infrastructure is created through `app/blueprint.ts`, not an API call, and - deleted only through `app/teardown.ts`. -5. Sandbox cleanup and repeated side effects are safe. -6. If configuration changed, `.env.example`, `render.yaml`, - `scripts/doctor.ts`, `README.md`, `docs/0_QUICKSTART.md`, and the - configuration reference in `docs/1_EXTENSION.md` are updated. -7. If behavior that the docs describe changed, `docs/2_ARCHITECTURE.md`, - `docs/3_TAKEAWAYS.md`, `docs/1_EXTENSION.md`, and - `docs/0_QUICKSTART.md` are updated, and this file's invariants still match - the code. -8. Browser UI changes preserve Basic Auth and never serialize secrets. -9. `npm run check` passes. +1. The invariants above still hold. +2. New agents and tasks are registered, and their names match in the + definition, `tests/host.test.ts`, and `scripts/doctor.ts`. +3. A new stage is in `RUN_STAGES` and the stage table in `public/index.html`. +4. Configuration changes are reflected in `.env.example`, `render.yaml`, + `scripts/doctor.ts`, and `docs/1_EXTENSION.md`. +5. Behavior the docs describe is updated in `docs/`. +6. `npm run check` passes. diff --git a/README.md b/README.md index 8e08fd4..45231bf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# vibe-code-demo +# vibe-coding-app -vibe-code-demo turns a plain-English app idea into a live, verified +vibe-coding-app turns a plain-English app idea into a live, verified application on Render. You type "Build a website for a local barbershop", and a few minutes later you have a public URL. When the idea needs saved data, you also get an API and a Postgres database, all wired together and smoke-tested. @@ -9,7 +9,7 @@ It is a reference app: real, running TypeScript that shows how Render Workflows, Sandboxes, Blueprints, Postgres, and the read-only Render MCP server fit together in an agent product. -[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/render-examples/vibe-code-demo) +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/render-examples/vibe-coding-app) Before you deploy, prepare: @@ -21,8 +21,8 @@ Before you deploy, prepare: - Anthropic and Render API keys, plus the Render workspace ID; and - a lowercase UI username and a UI password of at least 16 characters. -After the factory deploys, complete the -[one-time apps Blueprint step](docs/0_QUICKSTART.md#connect-the-apps-blueprint). +After the factory deploys, run `npm run setup:apps-repo` and complete the +[one-time apps Blueprint step](docs/0_QUICKSTART.md#3-connect-the-apps-blueprint). ## Why it matters @@ -80,8 +80,8 @@ product: see [Current limitations](docs/0_QUICKSTART.md#current-limitations). its own managed database. - **Blueprints** are the only way the factory creates infrastructure: the workflow commits a `render.yaml`, and Render's Blueprint sync deploys it. -- **Render MCP** gives two agents read-only views of the workspace, enforced - by an allowlist in code. +- **Render MCP** gives the architect a read-only view of the workspace, + enforced by an allowlist in code. ## Prerequisites @@ -99,11 +99,12 @@ product: see [Current limitations](docs/0_QUICKSTART.md#current-limitations). ## Quick start The [Quickstart](docs/0_QUICKSTART.md) is the canonical setup guide. It covers -installation, local processes, the first app, the one-time apps Blueprint, +installation, local processes, the one-time apps Blueprint, the first app, deployment, diagnostics, operations, and cleanup. -Local development still creates real Render Sandboxes and deploys real, -billable services. +Local development still creates real Render Sandboxes and deploys real +services. Generated static sites are free; a generated API and database use +paid plans and bill until you delete the app. ## Deploy the factory to Render diff --git a/app/agents.ts b/app/agents.ts index 8606f0d..cbfeb6a 100644 --- a/app/agents.ts +++ b/app/agents.ts @@ -2,11 +2,7 @@ import { type TaskContext, task } from "@renderinc/sdk/workflows"; import { factoryConfig } from "../factory.config.js"; import { type Agent, md, runClaude, zodToJsonSchema } from "./claude.js"; -import { - buildOutputSchema, - deployDiagnosisSchema, - deployPlanSchema, -} from "./contracts.js"; +import { buildOutputSchema, deployPlanSchema } from "./contracts.js"; import { RENDER_READ_ONLY_TOOLS } from "./policy.js"; import { connectSandbox } from "./sandbox.js"; import { sandboxTools } from "./tools.js"; @@ -138,7 +134,8 @@ export const builder: Agent = { .git directory in the app directory; copy a file instead of linking to it. - When you receive build output or Render deploy logs describing a - failure, fix exactly what the output names and nothing else. + failure, find the first real error, fix exactly what it names, and + nothing else. Deploy logs show each secret as [REDACTED]. When the app has a database: @@ -197,42 +194,6 @@ export const builder: Agent = { `, }; -export const deployManager: Agent = { - id: "deploy-manager", - model: "medium", - effort: "low", - renderTools: RENDER_READ_ONLY_TOOLS, - maxTurns: 20, - timeoutSeconds: 15 * 60, - plan: "flex", - prompt: md` - You are the deploy manager. You diagnose Render deploys that failed. - - Your instructions give each service that failed: its name and ID, the - ID and the status of the deploy that failed, and the last lines of the - logs of that deploy. They can include the build, the pre-deploy - command, and the new instance. Each secret in them is [REDACTED]. Your - Render tools cannot read logs. Use them only to read the settings of a - service and the details of a deploy. - - For each service that failed, find the error in its logs. Then tell the - builder what to fix. Be specific: quote the exact error from the logs. - If the logs do not show the cause, say so. - - Respond ONLY with JSON, with one entry for each service that failed: - { - "failures": [ - { - "serviceName": "the service that failed", - "status": "build_failed", - "diagnosis": "exact error and what to fix", - "logs": "relevant log excerpt" - } - ] - } - `, -}; - /* ── Registration ─────────────────────────────────────────────────────── */ export interface AgentTaskInput { @@ -249,7 +210,6 @@ export interface AgentTaskInput { const OUTPUT_SCHEMAS: Record> = { architect: zodToJsonSchema(deployPlanSchema), builder: zodToJsonSchema(buildOutputSchema), - "deploy-manager": zodToJsonSchema(deployDiagnosisSchema), }; /** @@ -295,8 +255,8 @@ export function agentTask(agent: Agent) { }), ); - // The output that the SDK checked against the schema of the agent, or - // the text of an agent that has no schema. + // The output the SDK checked against the agent's schema, or the text + // of an agent that has none. return run.structuredOutput ?? run.result; }, ); @@ -304,4 +264,3 @@ export function agentTask(agent: Agent) { export const architectTask = agentTask(architect); export const buildTask = agentTask(builder); -export const deployManagerTask = agentTask(deployManager); diff --git a/app/blueprint.ts b/app/blueprint.ts index 00de555..18bb0e6 100644 --- a/app/blueprint.ts +++ b/app/blueprint.ts @@ -141,7 +141,7 @@ function slugify(name: string): string { */ export function appBlueprint(spec: AppSpec): string { return [ - `# ${spec.user}/${spec.appName} — generated by the Vibe Code factory.`, + `# ${spec.user}/${spec.appName} — generated by vibe-coding-app.`, `# Prompt: ${oneLine(spec.prompt)}`, "", "projects:", @@ -168,7 +168,7 @@ export function rootBlueprint(specs: readonly AppSpec[]): string { ); const header = [ - "# Generated by the Vibe Code factory. Do not edit by hand.", + "# Generated by vibe-coding-app. Do not edit by hand.", "#", "# One Blueprint holds every app the factory has built, each in its own", "# project. A run appends its app here, and Render's Blueprint sync", diff --git a/app/build.ts b/app/build.ts index 9bfb1ad..8acf556 100644 --- a/app/build.ts +++ b/app/build.ts @@ -31,6 +31,7 @@ export async function buildAndVerify(opts: { sandbox: Sandbox; appDir: string; user: string; + appName: string; plan: DeployPlan; prompt: string; runId: string; @@ -51,7 +52,7 @@ export async function buildAndVerify(opts: { const { failures } = await opts.tasks.run(verifyAppTask, { sandboxId: opts.sandbox.id, user: opts.user, - appName: opts.plan.appName, + appName: opts.appName, manifest: buildOutput.manifest, databaseUrl: opts.databaseUrl, }); diff --git a/app/claude.ts b/app/claude.ts index 43f604a..31e9a7f 100644 --- a/app/claude.ts +++ b/app/claude.ts @@ -184,7 +184,7 @@ export function md( * Call an agent that must return structured output, and check the output * with its Zod schema. The SDK checks it against the JSON Schema of the * agent, but a JSON Schema cannot hold each Zod rule, such as a refinement. - * So a failure goes back to the agent one time, with the Zod error, and a + * So a failure goes back to the agent once, with the Zod error, and a * second failure stops the stage. */ export async function agentJson( diff --git a/app/config.ts b/app/config.ts index f43e7a3..c74c657 100644 --- a/app/config.ts +++ b/app/config.ts @@ -52,7 +52,7 @@ export function apiKey(): string { export function uiCredentials(): { username: string; password: string } { const username = requireEnv("UI_USERNAME"); const password = requireEnv("UI_PASSWORD"); - // The gateway gives it to every run of the UI as the namespace `user`. + // The gateway uses it as the `user` namespace for every UI run. if (!slug.safeParse(username).success) { throw new Error("UI_USERNAME must be a lowercase slug"); } diff --git a/app/contracts.ts b/app/contracts.ts index 5701ccd..4d73179 100644 --- a/app/contracts.ts +++ b/app/contracts.ts @@ -42,10 +42,10 @@ export const deleteAppInputSchema = z.object({ export type DeleteAppInput = z.infer; -/** The input of the step of a delete that waits for the syncs of the apps Blueprint. */ +/** Input to the delete step that waits for apps Blueprint syncs to finish. */ export const waitForSyncsInputSchema = deleteAppInputSchema.extend({ /** - * When the delete pushed the commit that took the app out of the + * When this delete pushed the commit that removed the app from the * Blueprint, or null if an earlier attempt pushed it. */ pushedAt: z.number().int().nonnegative().nullable(), @@ -74,9 +74,9 @@ export const deployPlanSchema = z.object({ .min(1) .max(4), /** - * The subjects that the workflow finds photographs of, one each. Capped - * low on purpose — each subject is a Commons round trip, and a landing - * page needs a few good photographs, not a gallery. + * Subjects the workflow finds one photograph for each. Capped low on + * purpose: each subject is a Commons round trip, and a landing page needs + * a few good photographs, not a gallery. */ assetQueries: z.array(z.string().min(3).max(120)).max(4).optional().default([]), brief: z.object({ @@ -199,25 +199,6 @@ export const buildOutputSchema = z.object({ export type BuildOutput = z.infer; -/* ── Deploy manager ───────────────────────────────────────────────────── */ - -export const deployFailureSchema = z.object({ - serviceName: z.string().min(1).max(120), - status: z.string().min(1).max(60), - diagnosis: z.string().min(1).max(4000), - logs: z.string().max(8000).optional(), -}); - -/** - * The workflow runs the deploy manager only when Render reported a failed - * deploy, so the diagnosis cannot declare the services healthy. - */ -export const deployDiagnosisSchema = z.object({ - failures: z.array(deployFailureSchema).max(10), -}); - -export type DeployDiagnosis = z.infer; - /* ── The stored app spec ──────────────────────────────────────────────── */ /** @@ -235,9 +216,9 @@ export const appSpecSchema = z.object({ resourcePrefix: slug, manifest: manifestSchema, /** - * Set when a delete of the app starts. The root Blueprint leaves the app - * out, and this file stays until the app's Render resources are gone, - * because a retry of the delete reads it to find them. + * Set when the app's delete starts. The root Blueprint leaves the app out, + * and this file stays until the app's Render resources are gone, because a + * retried delete reads it to find them. */ deletedAt: z.string().min(1).optional(), }); @@ -245,8 +226,8 @@ export const appSpecSchema = z.object({ export type AppSpec = z.infer; /** - * The input of the step of a delete that deletes the app's Render resources: - * only the fields of the spec that name them. + * Input to the delete step that removes the app's Render resources: only the + * spec fields that name them. */ export const deleteResourcesInputSchema = appSpecSchema.pick({ user: true, @@ -259,9 +240,9 @@ export type DeleteResourcesInput = z.infer; /* ── Verify and publish ───────────────────────────────────────────────── */ /** - * The input of verify-app. The sandbox holds the builder's files, and - * workflow code gives its id, never a model. The app directory comes from - * user and appName, so the input names no path. + * Input to verify-app. The sandbox holds the builder's files, and workflow + * code supplies its ID, never a model. The app directory comes from user and + * appName, so the input names no path. */ export const verifyAppInputSchema = z.object({ sandboxId: z.string().min(1), @@ -275,11 +256,11 @@ export const verifyAppInputSchema = z.object({ export type VerifyAppInput = z.infer; /** - * The input of publish-app. `sandboxId` is the sandbox of the build, which - * publish-app reads the files of the app from. It pushes from a sandbox of - * its own. The spec gives factory.json and both Blueprints. The repository - * and the GitHub token come from the configuration of the workflow, never - * from the input: the Render Dashboard shows the input of each task run. + * Input to publish-app. `sandboxId` is the build sandbox that publish-app + * reads the app's files from; it pushes from its own sandbox. The spec yields + * factory.json and both Blueprints. The repository and GitHub token come from + * the workflow's environment, never from the input, because the Render + * Dashboard shows every task run's input. */ export const publishAppInputSchema = z.object({ sandboxId: z.string().min(1), diff --git a/app/delete.ts b/app/delete.ts index acfc9c7..8108a35 100644 --- a/app/delete.ts +++ b/app/delete.ts @@ -1,6 +1,6 @@ /** * The four steps of delete-app. removeApp() in app/workflow.ts runs them in - * order, and AGENTS.md tells why the order is as it is. + * order and explains why the order matters. */ import { type TaskContext, task } from "@renderinc/sdk/workflows"; import { @@ -25,20 +25,16 @@ import { type Sandbox, shellEscape } from "./sandbox.js"; import { setDeleteProgress } from "./store.js"; import { deleteAppResources, waitForBlueprintSyncs } from "./teardown.js"; -/** - * A step of a delete makes one push, waits for the syncs of the Blueprint, or - * makes the deletes on Render. - */ +/** Each delete step makes one push, waits for Blueprint syncs, or deletes resources. */ export const DELETE_STEP_TIMEOUT_SECONDS = 10 * 60; -/** How long an unfinished sync of the apps Blueprint gets before a delete. */ +/** How long an unfinished apps Blueprint sync may run before the delete gives up. */ const SYNC_TIMEOUT_MS = 6 * 60 * 1000; /** - * The options of each step. Render does not retry a step: a failed step - * fails the delete, and the next attempt starts again at the first step. A - * retry of the first step after its push finds nothing to commit, so the wait - * would not wait for the push event. A retry of the wait would give a sync - * more time than SYNC_TIMEOUT_MS. + * Render does not retry a step: a failed step fails the delete, and the next + * attempt starts again at the first step. A retried first step would find + * nothing to commit after its push, so the wait would not know when the push + * happened. A retried wait would give a sync more than SYNC_TIMEOUT_MS. */ const DELETE_STEP = { plan: "flex", @@ -47,7 +43,7 @@ const DELETE_STEP = { }; interface RemovedFromBlueprint { - /** As in factory.json. It is in the name of each Render resource of the app. */ + /** From factory.json. Every Render resource name of the app starts with it. */ resourcePrefix: string; /** The commit that took the app out, or null if an earlier attempt pushed it. */ commit: string | null; @@ -57,9 +53,9 @@ interface RemovedFromBlueprint { /** * Write deletedAt into the app's factory.json, and push a root Blueprint that - * leaves the app out. The commit changes only these two files: a commit that - * removed the source of a service would start a build of it, and that build - * would fail before the service is deleted. + * leaves the app out. The commit changes only these two files: removing a + * service's source would start a build of it, and that build would fail + * before the service is deleted. * * Returns null when the repository has no spec for the app. Only the spec * names the resources, so then no step deletes anything on Render. @@ -118,9 +114,9 @@ export const removeFromBlueprintTask = task( ); /** - * Wait until no sync of the apps Blueprint waits or runs. When no Blueprint - * watches the apps repository, no sync can bring a resource back, and there - * is nothing to wait for. + * Wait until no apps Blueprint sync is pending or running. When no Blueprint + * watches the apps repository, no sync can bring a resource back, so there is + * nothing to wait for. */ export const waitForSyncsTask = task( { name: "wait-for-blueprint-syncs", ...DELETE_STEP }, @@ -202,9 +198,9 @@ export const removeFilesTask = task( ); /** - * The spec of one app in the clone, or null when the repository has no spec - * for it. The names of the resources to delete come from this file, so a - * file that names a different app stops the delete. + * One app's spec in the clone, or null when the repository has none. The + * resource names to delete come from this file, so a file that names a + * different app stops the delete. */ async function readSpec( sandbox: Sandbox, diff --git a/app/deploy.ts b/app/deploy.ts index fbea9c8..1a73035 100644 --- a/app/deploy.ts +++ b/app/deploy.ts @@ -4,20 +4,13 @@ */ import type { TaskContext } from "@renderinc/sdk/workflows"; import { factoryConfig } from "../factory.config.js"; -import { deployManagerTask } from "./agents.js"; import { declaredResources, type ResourceNames, resourceNames, } from "./blueprint.js"; import { runBuilder } from "./build.js"; -import { agentJson } from "./claude.js"; -import { - type AppSpec, - deployDiagnosisSchema, - type Service, - type WorkflowResult, -} from "./contracts.js"; +import type { AppSpec, Service, WorkflowResult } from "./contracts.js"; import { redactSecrets } from "./policy.js"; import { publishAppTask } from "./publish.js"; import { @@ -52,7 +45,7 @@ const DATA_TIMEOUT_MS = 3 * 60 * 1000; const MAX_DEPLOY_LOG_CHARS = 4_000; export interface DeployContext { - /** Runs the deploy manager, the builder, verify-app, and publish-app. */ + /** Runs the builder, verify-app, and publish-app for a repair. */ tasks: TaskContext; sandbox: Sandbox; workspaceId: string; @@ -64,20 +57,20 @@ export interface DeployContext { databaseUrl: string | null; /** From deploysBeforePublish(), read before the push. */ deploysBefore: ReadonlyMap; - /** The manifest services that the push gives a new deploy, from publish-app. */ + /** The manifest services the push will redeploy, from publish-app. */ changedServices: readonly string[]; } /** The services of the app on Render, keyed by Render service name. */ type AppServices = Map; -/** What a step of the deploy gives the next step, or the end of the run. */ +/** A deploy step's value for the next step, or the run's final result. */ type Step = { value: T } | { end: WorkflowResult }; /** - * The newest deploy of each service of the app that exists, keyed by Render - * service name. A rerun of an app replaces an app whose services exist, and - * right after the push their newest deploys are still these. + * The newest deploy of each of the app's services that already exists, keyed + * by Render service name. Right after the push, these are still the newest + * deploys, so a wait must look for a newer one. */ export function deploysBeforePublish( workspaceId: string, @@ -88,8 +81,8 @@ export function deploysBeforePublish( /** * The deploy stage after the first push: find the Blueprint, wait for it to - * create the services, wait for their deploys and repair the ones that fail, - * and smoke-test the live app. + * create the services, wait for their deploys and repair failures, and + * smoke-test the live app. */ export async function awaitDeployment( ctx: DeployContext, @@ -97,10 +90,10 @@ export async function awaitDeployment( const names = resourceNames(ctx.spec); const heartbeat = runHeartbeat(ctx.runId); - // An error is not caught. awaiting_blueprint tells the user to create a - // Blueprint, so only a lookup that finds none can give it. findBlueprint - // tries a failed request again, and an error that stays ends the run with - // that error as its summary. + // Errors are not caught. awaiting_blueprint tells the user to create a + // Blueprint, so only a lookup that finds none may return it. findBlueprint + // retries failed requests, and a persistent error ends the run with that + // error as its summary. const blueprint = await findBlueprint({ workspaceId: ctx.workspaceId, repo: ctx.repoUrl, @@ -140,7 +133,7 @@ function awaitingBlueprint(ctx: DeployContext): WorkflowResult { }; } -/** Wait for the Blueprint sync to create every service of the app. */ +/** Wait for the Blueprint sync to create all of the app's services. */ async function waitForAppServices( ctx: DeployContext, blueprint: BlueprintRecord, @@ -182,15 +175,14 @@ async function waitForAppServices( } /** - * Wait for the deploys of the push, and repair the deploys that fail, up to - * MAX_DEPLOY_REPAIR_ROUNDS times. Gives the spec that is live: a repair + * Wait for the push's deploys and repair failed ones, up to + * MAX_DEPLOY_REPAIR_ROUNDS times. Returns the live spec, because a repair * replaces the manifest. Each repair round: - * 1. reads the logs of each failed deploy. The deploy-manager agent - * diagnoses the failures from these logs - * 2. the builder fixes what the deploy manager diagnosed - * 3. verify-app verifies the repair. publish-app rewrites factory.json and - * the Blueprints from the repaired manifest, and pushes - * 4. the next round waits for a new deploy of each service that failed + * 1. workflow code reads and redacts each failed deploy's logs + * 2. the builder fixes the error the logs name + * 3. verify-app checks the fix, and publish-app rewrites factory.json and + * the Blueprints from the repaired manifest and pushes + * 4. the next round waits for a new deploy of each failed service */ async function deployWithRepairs( ctx: DeployContext, @@ -204,8 +196,8 @@ async function deployWithRepairs( const failed = await waitForDeploys(ctx.runId, waits, round, heartbeat); if (failed.length === 0) return { value: spec }; - // Another round cannot repair a service that the push did not deploy - // again. The deploy manager reads the same failed deploy. + // Another round cannot repair a service the push did not redeploy: the + // builder would read the same failed deploy again. if ( round === MAX_DEPLOY_REPAIR_ROUNDS || failed.some(({ deploy }) => deploy.result === "not_started") @@ -219,9 +211,9 @@ async function deployWithRepairs( if ("end" in repaired) return repaired; spec = repaired.value; - // Right after the push, the newest deploy of each failed service is - // still the deploy that failed. A live service that the repair did not - // change gets no new deploy, so it keeps its live deploy. + // Right after the repair push, each failed service's newest deploy is + // still the failed one. A live service the repair did not change gets + // no new deploy, so it is not waited on. waits = failed.map(({ service, deploy }) => ({ service, after: deploy.deployId, @@ -236,7 +228,7 @@ interface DeployWait { after: string | null; } -/** A service, and the deploy that it ended its wait with. */ +/** A service, and the deploy its wait ended with. */ interface DeployResult { service: ServiceRecord; deploy: DeployOutcome; @@ -244,9 +236,9 @@ interface DeployResult { /** * The first round waits for every service. A service that existed before the - * push and whose files the push changed waits for a deploy newer than its - * newest deploy from before the push. Render does not deploy a service whose - * files did not change again, so its newest deploy is its result. + * push, and whose files the push changed, waits for a deploy newer than its + * pre-push deploy. Render does not redeploy a service whose files did not + * change, so its newest deploy is its result. */ function firstWaits( ctx: DeployContext, @@ -264,7 +256,7 @@ function firstWaits( })); } -/** Wait for each deploy, and give the deploys that are not live. */ +/** Wait for each deploy, and return the ones that did not go live. */ async function waitForDeploys( runId: string, waits: readonly DeployWait[], @@ -290,9 +282,9 @@ async function waitForDeploys( } /** - * One repair round: the deploy manager diagnoses the failed deploys, the - * builder fixes them, verify-app verifies the fix, and publish-app pushes it. - * Gives the repaired spec. + * One repair round: the builder fixes the failed deploys from their redacted + * logs, verify-app checks the fix, and publish-app pushes it. Returns the + * repaired spec. */ async function repairDeploys( ctx: DeployContext, @@ -304,56 +296,24 @@ async function repairDeploys( const reports = await Promise.all( failed.map((failure) => failedDeployReport(ctx.workspaceId, failure)), ); - const diagnosis = await agentJson( - (message) => ctx.tasks.run(deployManagerTask, { message }), - deployDiagnosisSchema, - [ - `Workspace: ${ctx.workspaceId}`, - "", - "The following services failed to deploy:", - "", - reports.join("\n\n"), - "", - "Diagnose exactly what went wrong. Quote the exact error from the logs.", - ].join("\n"), - `deploy-manager-${round + 1}`, - ); - // Render reported these deploys failed, so a diagnosis with nothing to fix - // cannot send the run to the smoke checks. - if (diagnosis.failures.length === 0) { - return { - end: { - status: "deploy_failed", - summary: `The deploy manager diagnosed no failure to repair in round ${round + 1}. ${deployFailures(failed, round)}`, - }, - }; - } - - const diagnosisText = diagnosis.failures - .map( - (f) => - `--- ${f.serviceName} (${f.status}) ---\n${f.diagnosis}${f.logs ? `\n\nLogs:\n${f.logs}` : ""}`, - ) - .join("\n\n"); const buildOutput = await runBuilder( ctx.tasks, ctx.sandbox, ctx.appDir, [ "The app built and passed verification in the sandbox, but Render's deploy failed.", - "The deploy manager diagnosed these issues:", "", - diagnosisText, + reports.join("\n\n"), "", - "Fix exactly what the diagnosis names.", + "Find the error in the logs and fix exactly what it names.", "Keep the same services and databases in the manifest. Do not add, remove, or rename one, and do not change the kind of a service.", ].join("\n"), `builder-deploy-fix-${round + 1}`, ); - // The repaired manifest must keep every resource, because the services - // that the deploy waits for come from the first push. The rest of the - // spec stays: resourcePrefix is in each resource name. + // The repaired manifest must keep every resource, because the deploy waits + // only on services from the first push. The rest of the spec stays the + // same: resourcePrefix is part of every resource name. const repaired: AppSpec = { ...spec, manifest: buildOutput.manifest }; const rejection = resourceChange(spec, repaired); if (rejection) { @@ -381,17 +341,17 @@ async function repairDeploys( }; } - // Render deploys from the root Blueprint, which comes from each - // factory.json. If these files keep the old manifest, Render keeps the - // old commands, paths, and env vars. + // Render deploys from the root Blueprint, which is generated from every + // factory.json. If they kept the old manifest, Render would keep the old + // commands, paths, and env vars. const { commit } = await ctx.tasks.run(publishAppTask, { sandboxId: ctx.sandbox.id, spec: repaired, message: `Fix Render deploy for ${repaired.user}/${repaired.appName} (round ${round + 1})`, }); - // With no commit, Render deploys nothing, and the failed deploys stay. - // Render keeps the last live deploy of a failed service, so the smoke - // checks can pass against the old code. + // With no commit, Render deploys nothing and the failed deploys stay. + // Render keeps serving a failed service's last live deploy, so the smoke + // checks could pass against the old code. if (!commit) { return { end: { @@ -405,7 +365,7 @@ async function repairDeploys( /** * The checks that a deploy status cannot make: each public URL answers, the - * storefront names the public hostname of the API, and the API gives data + * storefront names the public hostname of the API, and the API returns data * that the storefront's origin can read. */ async function smokeTest( @@ -500,8 +460,8 @@ function deployFailures( } /** - * One failed deploy, with the last lines of its logs. Redact before the cut: - * a cut can divide a secret, and redactSecrets() does not find a part of one. + * One failed deploy, with the last lines of its logs. Redact before cutting: + * a cut can split a secret, and redactSecrets() cannot match half of one. */ async function failedDeployReport( workspaceId: string, @@ -520,10 +480,10 @@ async function failedDeployReport( /** * Why a repaired spec cannot replace the deployed one, or null when both - * declare the same resources. A removed resource stays live outside the + * declare the same resources. A removed resource would stay live outside the * Blueprint: Render does not delete it, and the factory calls no Render write - * API. The deploy loop also cannot monitor an added resource, because it waits - * only for the services of the first push. + * API. The deploy loop also cannot watch an added resource, because it waits + * only on services from the first push. */ function resourceChange(deployed: AppSpec, repaired: AppSpec): string | null { const before = declaredResources(deployed); @@ -543,9 +503,9 @@ function resourceChange(deployed: AppSpec, repaired: AppSpec): string | null { } /** - * The build of the storefront writes the API hostname into its bundle, and - * only a browser uses that hostname. The API checks cannot find a wrong - * hostname: the private-network `host`, for example, passes all of them. + * The storefront build writes the API hostname into its bundle, and only a + * browser uses it. The API checks cannot catch a wrong hostname: the + * private-network `host`, for example, passes all of them. */ async function smokeStorefront( webUrl: string | null, diff --git a/app/files.ts b/app/files.ts index 1a228c4..bca5096 100644 --- a/app/files.ts +++ b/app/files.ts @@ -1,21 +1,21 @@ /** - * The files of an app, copied between sandboxes as data. readAppFiles() packs - * them in the sandbox of the build, and writeAppFiles() writes them into a - * clone in a sandbox that no agent uses. + * An app's files, copied between sandboxes as data. readAppFiles() packs them + * in the build sandbox, and writeAppFiles() writes them into a clone in a + * sandbox no agent uses. * - * The builder can run any command in the sandbox of its build, so nothing - * read out of it is trusted: appFiles() accepts only regular files with plain - * paths below the app directory, up to MAX_APP_BYTES. + * The builder can run any command in its build sandbox, so nothing read out + * of it is trusted: appFiles() accepts only regular files with plain paths + * below the app directory, up to MAX_APP_BYTES. */ import { randomUUID } from "node:crypto"; import { Parser } from "tar"; import { type Sandbox, shellEscape } from "./sandbox.js"; /** - * The limit of the files of one app. Each service of each app builds from a - * clone of the apps repository, so a large app makes every deploy slower. The - * workflow downloads at most four photographs of 2 MB, and the builder can - * copy them one time. publish-app holds the files in memory, on the flex plan. + * The size limit for one app's files. Every service of every app builds from + * a clone of the apps repository, so a large app slows every deploy. The + * workflow downloads at most four 2 MB photographs, and the builder may copy + * each once. publish-app holds the files in memory on the flex plan. */ export const MAX_APP_BYTES = 50 * 1024 * 1024; @@ -26,7 +26,7 @@ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); /** Ends each heredoc. Base64 has no "_", so no line of a file can end one. */ const FILE_EOF = "FACTORY_FILE_EOF"; -/** A file of an app, as the factory copies it out of the sandbox of its build. */ +/** One app file, as the factory copies it out of the build sandbox. */ export interface AppFile { /** Relative to the app directory. */ path: string; @@ -35,16 +35,15 @@ export interface AppFile { } /** - * The files of an app, or why the factory does not copy them. The error is - * for the builder: verify-app gives it to the builder to fix. + * An app's files, or why the factory refuses to copy them. The error is for + * the builder: verify-app passes it on to fix. */ export type AppFiles = { files: AppFile[] } | { error: string }; /** - * Read the files that a commit of the app holds out of the sandbox of its - * build. The builder can run any command in that sandbox, so this is data - * that nothing trusts: appFiles() accepts only regular files with plain paths - * below the app directory. + * Read the files a commit of the app would hold out of its build sandbox. The + * builder can run any command there, so this data is untrusted: appFiles() + * accepts only regular files with plain paths below the app directory. */ export async function readAppFiles( sandbox: Sandbox, @@ -56,16 +55,16 @@ export async function readAppFiles( try { const packed = await sandbox.run( `cd ${shellEscape(appDir)} && ` + - // An index that does not exist, so that git lists each file that - // a first commit holds, also a file that the builder staged. + // A fresh index, so git lists every file a first commit would hold, + // including any file the builder staged. `GIT_INDEX_FILE=${shellEscape(`${base}.index`)} git ls-files -z --others --exclude-standard >${list} && ` + `tar --null --no-recursion -T ${list} -cf ${archive} && ` + `wc -c <${archive}`, ); - // For example, a process of the builder changed a file during the pack. + // For example, a builder process changed a file during the pack. if (packed.exitCode !== 0) { return { - error: `The files of the app could not be packed: ${packed.output.slice(0, 1_000)}`, + error: `The app's files could not be packed: ${packed.output.slice(0, 1_000)}`, }; } // Stop an archive that is too large before the download. @@ -77,7 +76,7 @@ export async function readAppFiles( } } -/** The files in a tar archive of an app, or why the factory does not copy them. */ +/** The files in an app's tar archive, or why the factory refuses to copy them. */ export async function appFiles(archive: Buffer): Promise { if (archive.byteLength > MAX_APP_BYTES) { return { error: tooLarge(archive.byteLength) }; @@ -88,7 +87,7 @@ export async function appFiles(archive: Buffer): Promise { archive.subarray(0, GZIP_MAGIC.length).equals(GZIP_MAGIC) || archive.subarray(0, ZSTD_MAGIC.length).equals(ZSTD_MAGIC) ) { - return { error: "The archive of the app is compressed." }; + return { error: "The app's archive is compressed." }; } let entries: TarEntry[]; @@ -96,7 +95,7 @@ export async function appFiles(archive: Buffer): Promise { entries = await tarEntries(archive); } catch (error) { return { - error: `The archive of the app is not a valid tar archive: ${error instanceof Error ? error.message : String(error)}`, + error: `The app's archive is not a valid tar archive: ${error instanceof Error ? error.message : String(error)}`, }; } @@ -106,7 +105,7 @@ export async function appFiles(archive: Buffer): Promise { const error = entryError(entry, seen); if (error) return { error }; seen.add(entry.path); - // Git keeps only the executable bit of the owner. + // Git keeps only the owner's executable bit. files.push({ path: entry.path, data: entry.data, @@ -151,7 +150,7 @@ function tarEntries(archive: Buffer): Promise { }); } -/** Why the factory does not copy an entry of the archive, or null. */ +/** Why the factory refuses an archive entry, or null. */ function entryError( entry: TarEntry, seen: ReadonlySet, @@ -160,7 +159,7 @@ function entryError( return `${JSON.stringify(entry.path)} has a control character in its name.`; } const path = entry.path.replace(/\/+$/, ""); - // git ls-files gives a Git repository in the app as one directory. + // git ls-files lists a nested Git repository as one directory. if (entry.type === "Directory") { return `${path} is a Git repository in the app directory. Remove ${path}/.git, so that a commit holds its files.`; } @@ -189,15 +188,15 @@ function entryError( function tooLarge(bytes: number): string { const mb = (value: number) => Math.ceil(value / (1024 * 1024)); return ( - `The files of the app are ${mb(bytes)} MB, and the limit is ${mb(MAX_APP_BYTES)} MB. ` + + `The app's files are ${mb(bytes)} MB, and the limit is ${mb(MAX_APP_BYTES)} MB. ` + "Remove large files, and files that are in the app two times." ); } /** - * Replace the app directory of a clone with the files of the build. One - * script writes them all, as materializeTemplate() writes a template, and - * base64 carries each file, so that binary data comes through unchanged. + * Replace a clone's app directory with the build's files. One script writes + * them all, as materializeTemplate() does for a template, and base64 carries + * each file so binary data comes through unchanged. */ export async function writeAppFiles( sandbox: Sandbox, @@ -209,7 +208,7 @@ export async function writeAppFiles( try { await sandbox.mustRun( `sh ${shellEscape(scriptPath)}`, - "Write the files of the app", + "Write the app's files", ); } finally { await sandbox.run(`rm -f ${shellEscape(scriptPath)}`).catch(() => {}); diff --git a/app/gateway.ts b/app/gateway.ts index 8560f00..a1bb8f0 100644 --- a/app/gateway.ts +++ b/app/gateway.ts @@ -3,7 +3,7 @@ * * The gateway runs no models, creates or deletes no infrastructure, and holds * no repository credential. It authenticates the caller, claims the run in - * Postgres, and dispatches a prompt, or the delete of the run's app. + * Postgres, and dispatches either a prompt or the delete of the run's app. */ import { randomUUID } from "node:crypto"; import { serveStatic } from "@hono/node-server/serve-static"; @@ -99,15 +99,12 @@ export function createGateway(): Hono { app.delete("/ui/apps/:runId", (c) => deleteRun(c, "/ui/apps", credentials.username), ); - // Each page and script expects the others from the same deploy. A cached - // script from an earlier deploy looks up elements the new page lacks. + // The page and its script must come from the same deploy. A cached script + // from an earlier deploy looks up elements the new page lacks. const uiFile = (path: string) => [uiAuth, noCache, serveStatic({ path: `./public/${path}` })] as const; app.get("/", ...uiFile("index.html")); - app.get("/table", ...uiFile("table.html")); app.get("/app.js", ...uiFile("app.js")); - app.get("/table.js", ...uiFile("table.js")); - app.get("/runs.js", ...uiFile("runs.js")); app.get("/style.css", ...uiFile("style.css")); return app; @@ -140,9 +137,9 @@ async function listRuns( } /** - * Claim a run and dispatch prompt-to-app. `statusBase` is the route prefix of - * the caller. The UI gives `userOverride`, the namespace of its Basic Auth - * user, because a browser must not choose one. + * Claim a run and dispatch prompt-to-app. `statusBase` is the caller's route + * prefix. The UI passes `userOverride`, its Basic Auth user's namespace, + * because a browser must not choose one. */ async function createRun( c: Context, @@ -370,8 +367,8 @@ export function runResponse( 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. + // the app is being deleted. The Dashboard lists a run's subtasks on its + // page, and the SDK does not return subtask run IDs. workflowRun: workflowId && run.workflowRunId ? `${DASHBOARD}/wf/${encodeURIComponent(workflowId)}/runs/${encodeURIComponent(run.workflowRunId)}` diff --git a/app/git.ts b/app/git.ts index e228862..9bd63de 100644 --- a/app/git.ts +++ b/app/git.ts @@ -1,10 +1,10 @@ /** * Clone, commit, push, verify — all workflow-owned. Agents never run git. * - * The sandbox of a build gets no clone and no credential. The factory copies - * the files of the app out of it as data (files.ts), and commits them in a - * clone in a different sandbox. Each commit changes only the directory of one - * app and the root Blueprint. + * The build sandbox gets no clone and no credential. The factory copies the + * app's files out of it as data (files.ts) and commits them in a clone in a + * different sandbox. Each commit changes only one app's directory and the + * root Blueprint. */ import { randomUUID } from "node:crypto"; import { factoryConfig } from "../factory.config.js"; @@ -157,7 +157,7 @@ export function appGitignore(manifest: Manifest): string { } return [ - "# Generated by the Vibe Code factory. Do not edit by hand.", + "# Generated by vibe-coding-app. Do not edit by hand.", "# Render builds each service from a fresh clone. Its buildCommand makes", "# these paths, so a commit does not need them.", "node_modules/", @@ -182,9 +182,9 @@ function plainPath(path: string): string | null { } /** - * Delete all that git ignores in a directory. The files that stay are the - * files a commit holds, which is all that Render's fresh clone gets. -X, not - * -x: an untracked file that is not ignored is part of the app, so it stays. + * Delete everything git ignores in a directory. What stays is what a commit + * holds, which is all Render's fresh clone gets. -X, not -x: an untracked + * file that is not ignored is part of the app, so it stays. */ export async function removeIgnored( sandbox: Sandbox, @@ -359,10 +359,10 @@ export async function headChangedPaths(sandbox: Sandbox): Promise { } /** - * Make the app directory in the sandbox of a build, in a new repository with + * Create the app directory in the build sandbox, inside a new repository with * no remote. The sandbox gets no clone of the apps repository and no - * credential. Git in it only tells which files a commit of the app holds: - * removeIgnored() deletes the others, and readAppFiles() packs these. + * credential. Git there only decides which files a commit would hold: + * removeIgnored() deletes the rest, and readAppFiles() packs these. */ export async function initAppDir( sandbox: Sandbox, diff --git a/app/host.ts b/app/host.ts index 964b0e3..78a842a 100644 --- a/app/host.ts +++ b/app/host.ts @@ -9,4 +9,4 @@ assertWorkflowEnv(); // await in these modules, lets the server start first and leaves tasks out. await import("./workflow.js"); -console.log("vibe code factory workflows ready"); +console.log("vibe-coding-app workflows ready"); diff --git a/app/images.ts b/app/images.ts index ce1a446..7fd5436 100644 --- a/app/images.ts +++ b/app/images.ts @@ -51,7 +51,7 @@ export interface CommonsCandidate { /** * Find one photograph for each subject, and download it into the assets - * directory of the app. All subjects search at one time. A subject that + * directory of the app. All subjects search at once. A subject that * finds nothing, or whose download fails, is left out. */ export async function collectImages( diff --git a/app/policy.ts b/app/policy.ts index a267869..6e433ff 100644 --- a/app/policy.ts +++ b/app/policy.ts @@ -1,13 +1,13 @@ /** - * The rules for the tool calls of an agent: each path must stay in the app - * directory or /tmp, and each Render tool must be read-only. No rule reads a - * shell command. The sandbox limits what a command can reach: it holds only - * the app of the run, and no credential. + * The rules for agent tool calls: every path must stay in the app directory + * or /tmp, and every Render tool must be read-only. No rule inspects shell + * commands. The sandbox limits what a command can reach: it holds only the + * run's app and no credential. */ /** - * The input fields of each tool that hold a path. Other fields, such as the - * content of a file or a shell command, can hold any text. + * The input fields of each tool that hold a path. Other fields, such as file + * contents or a shell command, can hold any text. */ const PATH_FIELDS: Readonly> = { sandbox__read_file: ["path"], @@ -28,7 +28,7 @@ const SCRATCH_DIR = "/tmp"; * here, which denies every Render tool not on this list. * * The list has no log tool: logs can hold secrets, and no code can redact - * what an agent reads. Workflow code gives the deploy manager redacted logs. + * what an agent reads. Workflow code gives the builder redacted deploy logs. */ export const RENDER_READ_ONLY_TOOLS = [ "list_workspaces", diff --git a/app/publish.ts b/app/publish.ts index a4d0dba..9947a3d 100644 --- a/app/publish.ts +++ b/app/publish.ts @@ -1,7 +1,7 @@ /** - * publish-app, and each other write to the apps repository. A write clones - * the repository in a sandbox of its own, makes one change, commits only the - * directory of one app and the root Blueprint, and pushes. + * publish-app, and every other write to the apps repository. Each write + * clones the repository into its own sandbox, makes one change, commits only + * one app's directory and the root Blueprint, and pushes. */ import { type TaskContext, task } from "@renderinc/sdk/workflows"; import { @@ -41,21 +41,19 @@ import { const PUBLISH_TIMEOUT_SECONDS = 10 * 60; /** - * Copy the files of the app out of the sandbox of the run, into a new clone - * of the apps repository in a sandbox of its own. There, write factory.json, - * the app's render.yaml and README, and the root Blueprint from the spec, - * commit them with the app, and push. Render deploys the push, so the parent - * starts this only after verify-app passes. Returns the pushed commit, or - * null when no file changed, and the services that Render deploys again for - * it. + * Copy the app's files out of the run's sandbox into a fresh clone of the + * apps repository, in a separate sandbox. There, write factory.json, the + * app's render.yaml and README, and the root Blueprint from the spec, commit + * them with the app, and push. Render deploys the push, so the parent starts + * this only after verify-app passes. Returns the pushed commit (null when no + * file changed) and the services Render will redeploy for it. * - * The builder can run any command in the sandbox of the run. A process that - * it starts stays after the command, and it can change git itself. So the - * GitHub token never goes into that sandbox, and publish-app reads the files - * of the app from it only as data. + * The builder can run any command in the run's sandbox, leave a process + * running, and even replace git. So the GitHub token never enters that + * sandbox, and publish-app reads the app's files from it only as data. * - * Render does not retry it. A retry after the push finds nothing to commit, - * and the run then ends as if the push changed no files. + * Render does not retry it. A retry after the push would find nothing to + * commit, and the run would end as if the push changed no files. */ export const publishAppTask = task( { @@ -109,15 +107,14 @@ export const publishAppTask = task( export interface PublishResult { /** The pushed commit, or null when no file changed. */ commit: string | null; - /** The manifest services that the commit gives a new deploy. */ + /** The manifest services the commit will redeploy. */ changedServices: string[]; } /** - * The manifest services whose directory holds a path that the commit - * changes. The buildFilter of each service is its directory, so Render - * deploys only these again. A rootDir of "." is the app directory, which - * holds factory.json. + * The manifest services whose directory contains a path the commit changes. + * Each service's buildFilter is its directory, so Render redeploys only + * these. A rootDir of "." is the app directory, which holds factory.json. */ export function servicesChangedBy( spec: AppSpec, @@ -138,8 +135,8 @@ async function writeBlueprints( appDir: string, repoUrl: string, ): Promise { - // The .gitignore comes with the files of the app. verify() writes it, - // because it must build from the same files that this commit holds. + // The .gitignore arrives with the app's files. verify() writes it, because + // it must build from the same files this commit holds. await sandbox.writeFile( `${appDir}/factory.json`, `${JSON.stringify(spec, null, 2)}\n`, @@ -154,12 +151,11 @@ async function writeBlueprints( * Regenerate the repository-root Blueprint from every app's factory.json. * * Derived state, never merged: a concurrent run adds its own app to the same - * file. So when another run pushes first, the change that calls this runs - * again on the new tip, and the file gets the apps of both runs. + * file. When another run pushes first, the change that calls this reruns on + * the new tip, and the file gets both runs' apps. * - * It reads the specs from a clone that no agent used. In it, the builder - * wrote only the files of its own app, and publish-app wrote its - * factory.json. + * It reads the specs from a clone no agent used. There, the builder's files + * cover only its own app, and publish-app wrote that app's factory.json. */ export async function writeRootBlueprint(sandbox: Sandbox): Promise { const specs = await readAllSpecs(sandbox); @@ -170,9 +166,9 @@ export async function writeRootBlueprint(sandbox: Sandbox): Promise { } /** - * The spec of each app in the clone. A spec counts only in the directory of - * the app that it names, so a file in one app directory cannot declare the - * resources of a different app. + * Every app's spec in the clone. A spec counts only in the directory of the + * app it names, so a file in one app's directory cannot declare resources for + * another app. */ async function readAllSpecs(sandbox: Sandbox): Promise { const root = `${factoryConfig.repoDir}/${factoryConfig.appsDir}`; @@ -214,13 +210,13 @@ interface AppsClone { } /** - * Clone the apps repository in a new sandbox, do the work, and terminate the - * sandbox. No agent uses this sandbox, so the GitHub token can go into it. - * The token comes just before the clone: an installation token expires after - * an hour, and a run can take two hours. + * Clone the apps repository into a new sandbox, do the work, and terminate + * the sandbox. No agent uses this sandbox, so the GitHub token can enter it. + * The token is fetched just before the clone: an installation token expires + * after an hour, and a run can take two. * * Each push makes its own clone, so it starts from the newest commit, and no - * sandbox stays up while a run or the steps of a delete wait. + * sandbox stays up while a run or a delete waits. */ export async function inAppsClone( timeoutSeconds: number, @@ -245,11 +241,10 @@ export async function inAppsClone( } /** - * Make the change of one app in the clone, commit it, and push it. The - * commit holds only the app's directory and the root Blueprint. When another - * run pushes first, `change` runs again on the new tip. A change that - * changes no file makes no commit, and there is nothing to push. Returns the - * commit that it pushed, or null. + * Apply one app's change in the clone, commit it, and push. The commit holds + * only the app's directory and the root Blueprint. When another run pushes + * first, `change` reruns on the new tip. A change that touches no file makes + * no commit. Returns the pushed commit, or null. */ export async function commitAndPush( clone: AppsClone, @@ -283,7 +278,7 @@ function appReadme(spec: AppSpec, repoUrl: string): string { "", spec.summary, "", - `Generated by the Vibe Code factory from the prompt: "${oneLine(spec.prompt)}"`, + `Generated by vibe-coding-app from the prompt: "${oneLine(spec.prompt)}"`, "", "## Infrastructure", "", diff --git a/app/render.ts b/app/render.ts index 3e28e06..37471d4 100644 --- a/app/render.ts +++ b/app/render.ts @@ -236,7 +236,7 @@ async function latestDeploy( * and the new instance. The API gives the pre-deploy output as app logs, so a * build filter loses it. The time range of the deploy keeps out the lines of * an earlier deploy. It starts at createdAt: the API does not document - * startedAt. The logs are only diagnostic, so a read that cannot finish gives + * startedAt. The logs are only diagnostic, so a read that cannot finish returns * no logs and does not fail the run. */ export async function fetchDeployLogs( diff --git a/app/sandbox.ts b/app/sandbox.ts index cd8cef8..160871a 100644 --- a/app/sandbox.ts +++ b/app/sandbox.ts @@ -144,10 +144,9 @@ export function connectSandbox(sandboxId: string): Sandbox { } /** - * 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 early access a - * workspace has at most one. With more than one, the group of a sandbox is - * not known. + * The workspace's sandbox group, for Render Dashboard links. A sandbox does + * not name its group, but in early access a workspace has at most one. With + * more than one, the group is unknown. */ export async function sandboxGroupId(): Promise { const { client, ownerId } = api(); diff --git a/app/store.ts b/app/store.ts index 20d26d4..7abdcba 100644 --- a/app/store.ts +++ b/app/store.ts @@ -22,10 +22,9 @@ export type FinishedStatus = Exclude< /** * 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. + * never gates a run. The stage table in public/index.html must list these + * stages in this order, and tests/gateway.test.ts checks it. If a run's stage + * is not in that table, the UI shows no progress for the run. */ export const RUN_STAGES = [ "designing", @@ -134,10 +133,10 @@ export async function ping(): Promise { const RUN_CLAIM_LOCK = 0x76696265; /** - * 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, - * or while a different run builds it. + * Run statements in one transaction holding one app's lock. A delete takes + * the lock to claim the app's runs, and a run takes it to claim an app name. + * So a run cannot start building an app while it is being deleted, or while + * another run builds it. */ function withAppLock( user: string, @@ -179,8 +178,8 @@ async function withLock( await client.query("rollback").catch(() => {}); throw error; } finally { - // release() attaches the listener of the pool again. If this listener - // remains, each transaction on the client adds one more. + // release() reattaches the pool's listener. If this one stayed, each + // transaction on the client would add another. client.off("error", logError); client.release(); } @@ -192,9 +191,9 @@ async function withLock( * should. * * Under READ COMMITTED, two concurrent inserts each count the running rows - * without the row of the other, and both can pass the cap. So each claim - * holds one advisory lock, and a claim counts after the commit of the claim - * before it. + * without seeing the other's row, so both could pass the cap. Each claim + * therefore holds one advisory lock and counts only after the previous claim + * commits. */ export function claimRun(input: { id: string; @@ -284,10 +283,10 @@ export async function claimWorkflowCheck(id: string): Promise { } /** - * Record the app that a run builds. Refused while a delete of the same app is - * in progress, because the delete removes what this run publishes. 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. + * Record the app a run builds. Refused while the same app is being deleted, + * because the delete would remove what this run publishes. Also refused while + * another run builds the app: both would write the same directory and wait on + * each other's deploys. */ export async function claimRunApp( id: string, @@ -484,8 +483,8 @@ function rowToRun(row: Record): RunRecord { status: row.status as RunStatus, stage: (row.stage as RunStage | null) ?? null, progress: (row.progress as string | null) ?? null, - // Postgres writes each time in the JSON with the offset of its session. - // Give it in UTC, as the other times of the run are. + // Postgres writes JSON timestamps in the session's offset. Return UTC, like + // the run's other timestamps. stageHistory: ( row.stage_history as { stage: RunStage; started_at: string }[] ).map((item) => ({ diff --git a/app/teardown.ts b/app/teardown.ts index 2653774..5464f05 100644 --- a/app/teardown.ts +++ b/app/teardown.ts @@ -78,7 +78,7 @@ interface Project { * its list. * * A read of the syncs that fails is tried again, as retryRead() describes. - * Each failed attempt goes to the progress of the delete. + * Each failed attempt is reported as delete progress. */ export async function waitForBlueprintSyncs( blueprintId: string, diff --git a/app/verify.ts b/app/verify.ts index 503d5c6..df2b088 100644 --- a/app/verify.ts +++ b/app/verify.ts @@ -1,6 +1,6 @@ /** - * verify-app: build, boot, and query the app in the sandbox of the run, as - * Render will. A check that fails is a result, not an error. + * verify-app: build, boot, and query the app in the run's sandbox, the way + * Render will. A failed check is a result, not an error. */ import { type TaskContext, task } from "@renderinc/sdk/workflows"; import { appPath } from "../factory.config.js"; @@ -31,13 +31,12 @@ interface VerificationResult { } /** - * Verify the builder's files in the sandbox. A check that fails is a result, - * not an error: the parent gives the failures to the builder, or it ends the - * run. + * Verify the builder's files in the sandbox. A failed check is a result, not + * an error: the parent sends the failures to the builder, or ends the run. * - * Render does not retry it. A retry after a timeout runs every build again. - * And a service that the failed attempt started can still answer on the port - * of the next boot, so a retry can pass a check that failed. + * Render does not retry it. A retry after a timeout reruns every build, and a + * service the failed attempt started could still answer on the next boot's + * port, so a retry could pass a check that failed. */ export const verifyAppTask = task( { @@ -88,8 +87,8 @@ export const verifyAppTask = task( * Postgres, and require the endpoint to answer with data * - Check for placeholder content in built output * - * Last, it reads the files as publish-app does, so that the builder can fix - * what publish-app would refuse: a symbolic link, for example. + * Last, it reads the files the way publish-app does, so the builder can fix + * anything publish-app would refuse, such as a symbolic link. */ async function verify( sandbox: Sandbox, @@ -111,8 +110,8 @@ async function verify( } // Render's build gets only what the commit holds. Delete every ignored file - // first, so that a file the build needs but a commit leaves out fails here - // and not in a deploy. + // first, so a file the build needs but the commit leaves out fails here, + // not in a deploy. await sandbox.writeFile(`${appDir}/.gitignore`, appGitignore(manifest)); await removeIgnored(sandbox, appDir); @@ -193,11 +192,11 @@ async function runVerification( /** * A static site gets its env vars at build time, and a browser uses them. The - * sandbox build passes with a private-network address in the bundle. Without - * this check, only smokeStorefront() finds the fault, after the Blueprint has - * created every resource. The builder must make the fix, because the - * storefront code must agree with the value. Web services can use these - * properties, because they connect on the private network. + * sandbox build passes with a private-network address in the bundle, so + * without this check only smokeStorefront() would catch it, after the + * Blueprint has created every resource. The builder must make the fix, + * because the storefront code has to agree with the value. Web services may + * use these properties, because they are on the private network. */ export function checkStaticSiteEnvVars(service: Service): string[] { if (service.kind !== "static_site") return []; diff --git a/app/workflow.ts b/app/workflow.ts index 6c6d4d1..2744f11 100644 --- a/app/workflow.ts +++ b/app/workflow.ts @@ -2,7 +2,7 @@ * prompt-to-app — one API call to a deployed app on Render. * delete-app — one API call to remove that app again. * - * Each stage is one call here. Its detail is in the module of the stage: + * Each stage is one call here. The details live in each stage's module: * build, verify, publish, deploy, or delete. */ import { type TaskContext, task } from "@renderinc/sdk/workflows"; @@ -93,13 +93,13 @@ export const promptToApp = task( ); /** - * The pipeline. Each agent runs as a subtask on its own compute, through - * `tasks`, the context that Render Workflows gives to prompt-to-app. So do - * the verification of the app and its publish. On Render, each subtask has - * its own run, with its input, its result, and its logs. + * The pipeline. Each agent, verify-app, and publish-app runs as a subtask on + * its own compute through `tasks`, the context Render Workflows gives + * prompt-to-app. On Render, each subtask has its own run, input, result, and + * logs. * - * The agents and verify-app work in the sandbox of the run. publish-app - * copies the files of the app out of it. + * The builder and verify-app work in the run's sandbox. publish-app copies + * the app's files out of it. */ async function run( tasks: TaskContext, @@ -119,7 +119,7 @@ async function run( "architect", ); - const appName = plan.appName; + const appName = uniqueAppName(plan.appName, runId); const blueprintPath = `${appRelativePath(user, appName)}/render.yaml`; // 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. @@ -174,6 +174,7 @@ async function run( sandbox, appDir, user, + appName, plan, prompt, runId, @@ -197,8 +198,8 @@ async function run( // ── Publish ───────────────────────────────────────────────────── await setRunStage(runId, "publishing"); - // Before the push: right after it, the newest deploys of a service that - // a rerun replaces are still those of the old app. + // Read before the push. If the app's services already exist, their + // newest deploys right after the push are still the old ones. const deploysBefore = await deploysBeforePublish(workspaceId, spec); const { commit, changedServices } = await tasks.run(publishAppTask, { sandboxId: sandbox.id, @@ -230,6 +231,17 @@ async function run( } } +/** + * The architect names the app from the prompt, so two similar prompts can + * choose the same name, and the second run would replace the first app. The + * start of the run ID keeps them apart. resourceStem() keeps 24 characters + * of the name, so the base is cut to 19 to keep the suffix. + */ +export function uniqueAppName(base: string, runId: string): string { + const suffix = runId.replace(/[^a-z0-9]/gi, "").slice(0, 4).toLowerCase(); + return `${base.slice(0, 19).replace(/-+$/, "")}-${suffix}`; +} + /** * The UI links to the sandbox in the Render Dashboard. Only the link needs * this, so a failure does not stop the run. diff --git a/docs/0_QUICKSTART.md b/docs/0_QUICKSTART.md index 6704814..98acbe8 100644 --- a/docs/0_QUICKSTART.md +++ b/docs/0_QUICKSTART.md @@ -88,10 +88,39 @@ render workflows tasks list --local If the workflow host is not running, submissions fail with `dispatch failed`. -### 3. Create the first app +### 3. Connect the apps Blueprint + +This is a one-time step. Render deploys generated apps from a Blueprint that +watches the apps repository's root `render.yaml`, and Render does not expose +an API for creating a Blueprint. + +First commit an empty root Blueprint to the apps repository: + +```bash +npm run setup:apps-repo +``` + +The script does nothing if the file already exists. Then, in the Render +Dashboard: + +1. Open the workspace identified by `RENDER_WORKSPACE_ID`. +2. Choose **New > Blueprint** and select the apps repository. +3. Use branch `main` and Blueprint path `render.yaml`. +4. Keep **Auto Sync** enabled. + +These values must match `branch` and `blueprintPath` in +[`factory.config.ts`](../factory.config.ts). `npm run doctor` confirms the +Blueprint is watching with Auto Sync on. + +If you skip this step, runs still commit their apps but end as +`awaiting_blueprint`. Connecting the Blueprint later deploys everything +already committed. + +### 4. Create the first app Open `http://localhost:3000`, sign in with `UI_USERNAME` and `UI_PASSWORD`, -and submit an idea. The table view at `/table` shows stage timings. +and submit an idea. The stage table shows how long each stage takes, where it +runs, and links to the matching Render Dashboard pages. An embedded preview that cannot show a Basic Auth prompt may use `UI_AUTH_DISABLED=true` locally. The gateway ignores that setting in @@ -111,36 +140,25 @@ A brochure or landing-page prompt usually produces one static site. A prompt that explicitly requires persistent user-written data produces a site, API, and Postgres database. -The first successful build normally ends as `awaiting_blueprint`. The code is -in the apps repository, but no Render Blueprint watches it yet. +A successful run finishes as `deployed` only after the workflow smoke-tests +the public site and, when present, its API. -## Connect the apps Blueprint - -This is a one-time manual step. Render does not expose an API for creating a -Blueprint. - -1. Open the workspace identified by `RENDER_WORKSPACE_ID`. -2. Choose **New > Blueprint** and select the apps repository. -3. Use branch `main` and Blueprint path `render.yaml`. -4. Keep **Auto Sync** enabled. - -These values must match `branch` and `blueprintPath` in -[`factory.config.ts`](../factory.config.ts). - -Creating the Blueprint syncs the root `render.yaml` already committed by the -first run. Submit another idea to exercise the complete path. A successful -run finishes as `deployed` only after the workflow smoke-tests the public -site and, when present, its API. - -Each app gets its own Render project: +Each app gets its own Render project. The app name is the architect's slug +plus the first four characters of the run ID, so two similar prompts never +overwrite each other's app: ```text -vibe-- -├── vibe---web -├── vibe---api # when needed -└── vibe---db # when needed +vibe--- +├── vibe----web +├── vibe----api # when needed +└── vibe----db # when needed ``` +Generated static sites are free. An API uses the `0.5c-512mb` plan and a +database the `0.1c-256mb` plan from +[`factory.config.ts`](../factory.config.ts), and both bill until you delete +the app. + ## Deploy the factory to Render The factory's [`render.yaml`](../render.yaml) creates: @@ -166,7 +184,10 @@ Keep these deployment constraints: - Only one factory using the default workflow service name can exist in a workspace. - The apps Blueprint must exist in the workspace identified by - `RENDER_WORKSPACE_ID`. + `RENDER_WORKSPACE_ID`. If you have not connected it yet, follow + [Connect the apps Blueprint](#3-connect-the-apps-blueprint). + `npm run setup:apps-repo` needs only `APPS_REPO` and a GitHub credential in + your local `.env`. After deployment, open the gateway URL or point the demo client at it: diff --git a/docs/1_EXTENSION.md b/docs/1_EXTENSION.md index 2503c13..cd2c7bf 100644 --- a/docs/1_EXTENSION.md +++ b/docs/1_EXTENSION.md @@ -85,12 +85,11 @@ code. `agentTask()` definitions are Render task objects, not ordinary functions. Always run them through the parent task's `TaskContext`. -The existing roles illustrate three capability profiles: +The two existing roles illustrate opposite capability profiles: - `architect`: read-only Render MCP, no Sandbox. -- `builder`: six scoped Sandbox tools, no Render MCP. -- `deploy-manager`: read-only Render MCP plus redacted logs supplied by - workflow code. +- `builder`: six scoped Sandbox tools, no Render MCP. During a deploy repair + it also receives redacted deploy logs supplied by workflow code. Do not grant an agent direct log access. Logs can contain secrets. The deploy repair path fetches logs in trusted workflow code, redacts them, limits their @@ -179,7 +178,7 @@ When adding a stage: - decide whether it needs judgment (agent task) or deterministic code; - keep its input and result small and JSON-serializable; - add a bounded timeout and make repeated execution safe; -- update `RUN_STAGES` and both browser views when it is user-visible; +- update `RUN_STAGES` and the UI's stage table when it is user-visible; - preserve Sandbox cleanup in `finally`; and - add a workflow test that exercises the public outcome. @@ -241,7 +240,7 @@ For UI changes: - never serialize `FACTORY_API_KEY` into HTML or JavaScript; - ignore a browser-supplied `user` and use `UI_USERNAME`; - keep Postgres as the source of truth; and -- update both the classic and table views when changing stages. +- add a row to the stage table in `public/index.html` when changing stages. A production multi-tenant service needs real identity, authorization, per-tenant quotas, and abuse prevention in front of `claimRun()`. User slugs diff --git a/docs/2_ARCHITECTURE.md b/docs/2_ARCHITECTURE.md index 190ae7d..ef06f33 100644 --- a/docs/2_ARCHITECTURE.md +++ b/docs/2_ARCHITECTURE.md @@ -88,14 +88,14 @@ app/ images.ts Bounded Wikimedia search and download config.ts Environment parsing and process-specific validation public/ - index.html, app.js Stage-oriented UI - table.html, table.js Tabular UI - runs.js Shared rendering and polling helpers + index.html, app.js Run table and per-stage timing UI + style.css Styles templates/fullstack/ web/ Vite + React storefront api/ Hono + Postgres API scripts/ migrate.ts Apply app/schema.sql + setup-apps-repo.ts Commit the empty root Blueprint before the first run doctor.ts Read-only configuration and integration diagnostics demo.ts Submit and poll a prompt support.ts Shared script helpers @@ -128,7 +128,7 @@ The public routes are: - `POST /v1/apps`: start an app run with bearer authentication. - `GET /v1/apps/:runId`: read and reconcile a run. - `DELETE /v1/apps/:runId`: delete the run's app. -- `/ui/*`, `/`, and `/table`: Basic-Auth browser surface scoped to +- `/ui/*` and `/`: Basic-Auth browser surface scoped to `UI_USERNAME`. The gateway holds `FACTORY_API_KEY`, `RENDER_API_KEY`, and `DATABASE_URL`, but @@ -179,8 +179,10 @@ It returns a `DeployPlan`: app name, summary, required primitives, asset queries, and a product brief. `agentJson()` validates the response with Zod, asks for one schema repair if needed, and then fails closed. -`claimRunApp()` records the app name under a per-app advisory lock. It refuses -to build an app currently being built or deleted. +`uniqueAppName()` appends the first four characters of the run ID to the +architect's slug, so two similar prompts never write to the same app. +`claimRunApp()` records the name under a per-app advisory lock. It refuses to +build an app currently being built or deleted. #### 3. Prepare an isolated build Sandbox @@ -256,17 +258,18 @@ Render's apps Blueprint sees the push and creates or updates the app project. 2. Returns `awaiting_blueprint` if no such Blueprint exists. 3. Waits for every expected service to appear. 4. Waits for deploys caused by the new commit. -5. On a failed deploy, fetches and redacts that deploy's logs, asks the - `deploy-manager` for a diagnosis, and gives the diagnosis to the builder. +5. On a failed deploy, fetches and redacts that deploy's logs and gives them + to the builder. 6. Re-verifies and republishes a bounded repair. The first wait records deploy IDs before publishing so an old live deploy cannot be mistaken for the result of the new commit. A repair waits only for deploys newer than the failed deploy. -The deploy manager is advisory. An empty diagnosis cannot override a failed -Render deploy status, and a repair cannot change the declared set or runtime -of resources. +The builder reads logs only through workflow code: no agent has a log tool, +because logs can hold secrets the factory must redact first. A repair cannot +change the declared set or runtime of resources, and only a new `live` deploy +status ends the loop. #### 7. Smoke-test the public application @@ -362,7 +365,7 @@ Workflow code binds the context; the model supplies only validated tool input. File tools resolve paths inside `workDir` or `/tmp`. The builder receives read, write, list, search, exec, and patch tools. The -architect and deploy manager receive no Sandbox tools. Builder shell commands +architect receives no Sandbox tools. Builder shell commands have their own 10-minute limit, shorter than the builder task and parent workflow deadlines. @@ -479,6 +482,7 @@ The suite tests public behavior and boundaries: - `workflow.test.ts`: create, repair, deploy, and delete orchestration. - `blueprint.test.ts`: generated infrastructure contract. - `agents.test.ts`, `policy.test.ts`, `tools.test.ts`: capability boundaries. +- `contracts.test.ts`: schema rules and the one-repair `agentJson()` path. - `git.test.ts`, `files.test.ts`, `github-auth.test.ts`: publish boundary. - `render.test.ts`, `teardown.test.ts`, `images.test.ts`: external systems. - `store.test.ts`: claims, locks, and connection failure behavior. diff --git a/docs/3_TAKEAWAYS.md b/docs/3_TAKEAWAYS.md index b90c03d..9495fb7 100644 --- a/docs/3_TAKEAWAYS.md +++ b/docs/3_TAKEAWAYS.md @@ -1,140 +1,107 @@ # Takeaways -This repository is a reference architecture, not a framework. The useful -parts are the boundaries and feedback loops, not the exact stack. The lessons -below describe what to carry into your own coding agent, internal platform, -or AI-assisted deployment system. - -For the implementation behind each lesson, read -[2_ARCHITECTURE.md](2_ARCHITECTURE.md). - -## 1. Split public control from privileged execution - -The gateway authenticates, validates, records, and dispatches. A separate -workflow process holds model and repository credentials and performs risky -work. - -**Apply it:** inventory credentials by process. Give an internet-facing -service only the secrets required to accept and route a request. Move model, -source-control, and infrastructure credentials to a non-public worker. - -**Tradeoff:** another service and deployment boundary to operate. - -In this repository: `app/server.ts`, `app/host.ts`, `app/config.ts`, and -`render.yaml`. - -## 2. Treat isolation as the shell-command policy - -The builder can execute arbitrary shell commands. The factory does not -pretend a command regex can make arbitrary shell safe. Instead, it runs the -builder in a disposable Sandbox containing one app and no valuable -credential. - -**Apply it:** assume a capable model will eventually bypass prompt rules and -command filters. Make the execution environment safe even when that happens: -minimal files, minimal environment, bounded lifetime, explicit egress, and -cleanup in `finally`. - -**Tradeoff:** provisioning latency and compute cost. - -In this repository: `app/sandbox.ts`, `app/tools.ts`, and `app/policy.ts`. - -## 3. Separate untrusted building from trusted publishing - -The build Sandbox never receives a GitHub token. Publishing copies regular -files out as data, then commits from a fresh Sandbox no agent has touched. -The push rejects changes outside the app directory and root Blueprint. - -**Apply it:** split CI or agent pipelines into an untrusted build phase and a -small trusted release phase. Pass an artifact across a narrow, validated +This repository is a reference architecture, not a framework: the boundaries +and feedback loops are worth copying, the exact stack is not. For the +implementation behind each lesson, read [2_ARCHITECTURE.md](2_ARCHITECTURE.md). + +## 1. Keep credentials out of the code that runs untrusted work + +The internet-facing gateway authenticates, validates, records, and dispatches; +a separate workflow process holds the model and repository credentials. The +same split repeats inside the workflow: the build Sandbox never receives a +GitHub token. Publishing copies regular files out as data, then commits from a +fresh Sandbox no agent has touched, and the push rejects changes outside the +app directory and root Blueprint. + +**Apply it:** inventory credentials by process and give each one only what its +job needs. Split agent or CI pipelines into an untrusted build phase and a +small trusted release phase, and pass an artifact across a narrow, validated boundary instead of adding credentials to the build environment. -**Tradeoff:** an extra artifact format, validation step, and execution -environment. - -In this repository: `app/files.ts`, `app/publish.ts`, and `app/git.ts`. - -## 4. Keep capabilities in code, not prompts - -Each agent receives an explicit tool set. Built-in file and shell tools are -disabled, environment forwarding is allowlisted, and a pre-tool hook checks -every call. - -**Apply it:** define capabilities beside the agent in versioned code and make -permission changes reviewable. Use prompts to explain permissions, never to -enforce them. - -**Tradeoff:** adding a capability requires code, tests, and usually a -deployment. - -In this repository: `Agent` and `runClaude()` in `app/claude.ts`, -`app/agents.ts`, and `tests/agents.test.ts`. - -## 5. Let models propose; let contracts and systems decide - -Architect, builder, and deploy-manager output is schema-validated. A model -may diagnose a failed deploy, but it cannot override Render's failed status -or deterministic verification. - -**Apply it:** put a typed contract after every model boundary. Give models -room to generate or diagnose, then let parsers, compilers, tests, databases, -and platform status make acceptance decisions. - -**Tradeoff:** useful but schema-incompatible output fails closed. - -In this repository: `app/contracts.ts`, `agentJson()` in `app/claude.ts`, and -`repairDeploys()` in `app/deploy.ts`. +**Tradeoff:** more services, an artifact format, and an extra execution +environment to operate. -## 6. Generate infrastructure from a closed manifest +In this repository: `app/server.ts`, `app/host.ts`, `app/config.ts`, +`app/files.ts`, `app/publish.ts`, `app/git.ts`, and `render.yaml`. -The builder returns a limited manifest. Trusted code turns it into a Render -Blueprint. Agents cannot submit arbitrary YAML or directly create resources. +## 2. Enforce capabilities with isolation and code, not prompts or filters -**Apply it:** model only the primitives your product can verify, observe, -price, and delete. Generate provider configuration from that model and keep -the resulting Git diff as the audit record. +The builder can run arbitrary shell commands, and no command regex can make +that safe. Instead, it runs in a disposable Sandbox that holds one app and no +valuable credential. Each agent gets an explicit tool set in code: built-in +file and shell tools are disabled, environment forwarding is allowlisted, and +a pre-tool hook keeps every path inside the app directory and every Render +tool read-only. -**Tradeoff:** adding a new primitive requires work across planning, -validation, deployment, verification, and cleanup. - -In this repository: `manifestSchema` in `app/contracts.ts` and -`app/blueprint.ts`. - -## 7. Verify the artifact that will actually ship - -Before publishing, the workflow removes ignored output, runs deployment -commands, starts services, applies migrations, and queries real data. After -deployment, it checks public routing, browser-to-API wiring, data, and CORS. - -**Apply it:** test from a clean artifact with production commands. Add -post-deploy checks for integration behavior that a build and health endpoint -cannot prove. +**Apply it:** assume a capable model will eventually bypass prompt rules and +command filters, and make the environment safe when it does: minimal files, +minimal environment, bounded lifetime, explicit egress, and cleanup in +`finally`. Define capabilities beside the agent in versioned code so +permission changes are reviewable. Use prompts to explain permissions, never +to enforce them. + +**Tradeoff:** provisioning latency and compute cost, and a new capability +needs code, tests, and a deployment. + +In this repository: `app/sandbox.ts`, `app/tools.ts`, `app/policy.ts`, +`Agent` and `runClaude()` in `app/claude.ts`, `app/agents.ts`, and +`tests/agents.test.ts`. + +## 3. Narrow what the model can generate + +Architect and builder output is schema-validated. The builder returns a +limited manifest, and trusted code turns it into a Render Blueprint, so agents +never submit arbitrary YAML or create resources directly. A template supplies +the integration details models repeatedly get wrong (CORS, public API host +wiring, idempotent migrations) and leaves product content and behavior to the +builder. + +**Apply it:** put a typed contract after every model boundary. Model only the +infrastructure primitives your product can verify, observe, price, and +delete, generate provider configuration from that model, and keep the +resulting Git diff as the audit record. Template the small set of contracts +models get wrong, and build those templates in CI alongside the orchestrator +that consumes them. -**Tradeoff:** longer runs, additional dependencies, and more failure modes to -operate. +**Tradeoff:** useful but schema-incompatible output fails closed, and each +new primitive or stack needs work across planning, validation, deployment, +verification, and cleanup. -In this repository: `app/verify.ts` and the smoke checks in `app/deploy.ts`. +In this repository: `app/contracts.ts` (including `manifestSchema`), +`agentJson()` in `app/claude.ts`, `app/blueprint.ts`, `templates/fullstack`, +`app/templates.ts`, and `tests/templates.test.ts`. -## 8. Bound repair loops and preserve their invariants +## 4. Accept only what real systems verify, and bound the repairs -Build and deploy repairs have fixed round limits. Every repair repeats -verification and publishing. A deploy repair cannot change the resource set, -and each wait requires a deploy newer than the one that failed. +No model output can end a run as `deployed`. Before publishing, the workflow +removes ignored output, runs deployment commands, starts services, applies +migrations, and queries real data. After deployment, only a new `live` deploy +status and smoke checks of public routing, browser-to-API wiring, data, and +CORS count. Build and deploy repairs have fixed round limits, and every repair +repeats verification and publishing. A deploy repair cannot change the +resource set, and each wait requires a deploy newer than the one that failed. -**Apply it:** define what a repair may change, cap attempts, and run the same -acceptance path after every fix. Record the prior external state so stale -success cannot be mistaken for a new result. +**Apply it:** give models room to generate and diagnose, then let compilers, +tests, databases, and platform status make the acceptance decision. Test a +clean artifact with production commands, and add post-deploy checks for +integration behavior a health endpoint can't prove. Define what a repair may +change, cap attempts, rerun the same acceptance path after every fix, and +record the prior external state so stale success can't pass for a new result. -**Tradeoff:** some fixable runs stop and require a new request. +**Tradeoff:** longer runs, more failure modes to operate, and some fixable +runs stop and need a new request. -In this repository: `app/build.ts`, `firstWaits()` and `resourceChange()` in -`app/deploy.ts`, and `waitForDeploy()` in `app/render.ts`. +In this repository: `app/verify.ts`, `app/build.ts`, `repairDeploys()`, +`firstWaits()`, `resourceChange()`, and the smoke checks in `app/deploy.ts`, +and `waitForDeploy()` in `app/render.ts`. -## 9. Use agents only where judgment earns its cost +## 5. Use agents only where judgment earns its cost -Agents design the product, write code, and interpret deploy failures. -Workflow code downloads images, verifies builds, writes Blueprints, publishes -Git, waits on Render, and deletes resources. +Two agents design the product and write and repair the code. Workflow code +downloads images, verifies builds, fetches and redacts deploy logs, writes +Blueprints, publishes Git, waits on Render, and deletes resources. A separate +agent that only summarized deploy logs would add a model call and a schema +without adding judgment the builder lacks, so the builder reads them directly. **Apply it:** ask whether a step needs interpretation or creativity. If its correct behavior can be expressed as bounded deterministic code, implement @@ -145,39 +112,34 @@ and test it directly. In this repository: compare `app/agents.ts` with `app/images.ts`, `app/verify.ts`, and `app/publish.ts`. -## 10. Make durable state explicit and reconcile it +## 6. Make durable state explicit, and size tasks by their side effects Postgres stores run status, progress, task IDs, and app ownership. Unique keys -make requests idempotent; advisory locks protect global capacity and per-app -races. API reads reconcile rows with the workflow system when a task dies -before recording its result. - -**Apply it:** model the job state machine in durable storage. Assume a worker -can vanish between the side effect and the final status write, and provide a -reconciliation path. - -**Tradeoff:** more state-machine and database code than a fire-and-forget -queue. - -In this repository: `app/store.ts` and `reconcileWorkflowRun()` in -`app/gateway.ts`. - -## 11. Retry the smallest safe operation - -Top-level workflows do not retry because they contain expensive, -non-idempotent side effects. Narrow operations do retry: Render reads and Git -pushes racing on the branch tip. - -**Apply it:** classify side effects before enabling retries. Retry a read, a -conditional write, or a derived commit—not an entire agent workflow—unless -the workflow has explicit checkpoints and idempotent steps. - -**Tradeoff:** without checkpointing, a failed run starts over as a new run. - -In this repository: task retry settings in `app/workflow.ts`, -`retryRead()` in `app/render.ts`, and `pushVerified()` in `app/git.ts`. - -## 12. Design deletion with the same care as creation +make requests idempotent, advisory locks protect global capacity and per-app +races, and API reads reconcile rows with the workflow system when a task dies +before recording its result. Agents, verification, publishing, and delete +steps are separate workflow tasks with their own inputs, outputs, timings, +and logs. Top-level workflows don't retry because they contain expensive, +non-idempotent side effects; narrow operations do, such as Render reads and +Git pushes racing on the branch tip. + +**Apply it:** model the job state machine in durable storage, assume a worker +can vanish between a side effect and its status write, and provide a +reconciliation path. Split a long workflow where permissions, retries, +timeouts, or operator diagnosis differ. Retry a read, a conditional write, or +a derived commit, not a whole agent workflow, unless it has explicit +checkpoints. Keep task payloads small and assume operators can see them. + +**Tradeoff:** more state-machine, orchestration, and database code than a +fire-and-forget queue, and a failed run starts over as a new run. + +In this repository: `app/store.ts` (including `RUN_STAGES`), +`reconcileWorkflowRun()` in `app/gateway.ts`, task definitions and retry +settings in `app/workflow.ts` and the stage modules, `agentTask()` in +`app/agents.ts`, `retryRead()` in `app/render.ts`, and `pushVerified()` in +`app/git.ts`. + +## 7. Design deletion with the same care as creation Removing an app from a declarative file does not delete its resources, and an older sync can recreate a resource deleted too early. The delete path first @@ -193,36 +155,6 @@ ownership, and make every step safe to rerun. In this repository: `removeApp()` in `app/workflow.ts`, `app/delete.ts`, and `app/teardown.ts`. -## 13. Put platform contracts in templates - -The template captures recurring integration details—CORS, public API host -wiring, idempotent migrations—while leaving product-specific content and -behavior to the builder. - -**Apply it:** template the small set of contracts models repeatedly get -wrong. Build templates in CI and keep them versioned with the orchestrator -that consumes them. - -**Tradeoff:** templates narrow the supported stack and require maintenance. - -In this repository: `templates/fullstack`, `app/templates.ts`, and -`tests/templates.test.ts`. - -## 14. Make task boundaries useful to operators - -Agents, verification, publishing, and delete steps are separate workflow -tasks with distinct inputs, outputs, timings, and logs. Run stages and -structured events expose progress without giving clients internal control. - -**Apply it:** split long workflows where permissions, retries, timeouts, or -operator diagnosis differ. Keep task payloads small and assume operators can -see them. - -**Tradeoff:** more task definitions and orchestration code. - -In this repository: `agentTask()` in `app/agents.ts`, task definitions in -the stage modules, `RUN_STAGES` in `app/store.ts`, and the browser UI. - ## What not to copy unchanged Some choices are intentionally demo-sized: diff --git a/package-lock.json b/package-lock.json index aaff371..723a131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "vibe-code-demo", + "name": "vibe-coding-app", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "vibe-code-demo", + "name": "vibe-coding-app", "version": "0.0.1", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.0", diff --git a/package.json b/package.json index 24efbb2..f7879d4 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "vibe-code-demo", + "name": "vibe-coding-app", "version": "0.0.1", "private": true, "type": "module", @@ -13,6 +13,7 @@ "dev:workflows": "render workflows dev -- node --env-file=.env node_modules/.bin/tsx app/host.ts", "dev:postgres": "docker compose up postgres", "db:migrate": "node --env-file-if-exists=.env --import tsx scripts/migrate.ts", + "setup:apps-repo": "node --env-file-if-exists=.env --import tsx scripts/setup-apps-repo.ts", "doctor": "node --env-file-if-exists=.env --import tsx scripts/doctor.ts", "demo": "node --env-file-if-exists=.env --import tsx scripts/demo.ts", "lint": "biome lint --error-on-warnings app public scripts tests factory.config.ts vitest.config.ts", diff --git a/public/app.js b/public/app.js index 5409448..c84d205 100644 --- a/public/app.js +++ b/public/app.js @@ -1,59 +1,475 @@ -import { activeStatuses, formatDate, startRunsPage, statusLabel, truncate } from "/runs.js"; +/** + * The browser UI: the prompt form, the table of runs, the selected run with + * its stage table, and the delete dialog. Postgres is the source of truth, so + * every render comes from the gateway's list of runs. + */ + +/** A task still owns these runs, so the page keeps reading them. */ +const activeStatuses = ["running", "deleting"]; +const healthyStatuses = [...activeStatuses, "deployed", "awaiting_blueprint"]; +/** How long the page waits before it reads the runs again. */ +const REFRESH_MS = 5000; +const SELECTED_RUN_KEY = "vibe-code-selected-run"; +const linkLabels = { workflowRun: "Workflow run", sandbox: "Sandbox" }; -const runList = document.querySelector("#run-list"); +const form = document.querySelector("#prompt-form"); +const formError = document.querySelector("#form-error"); +const submit = form.querySelector("button"); +const emptyHistory = document.querySelector("#empty-history"); +const refreshRuns = document.querySelector("#refresh-runs"); +const sites = document.querySelector("#sites"); +const siteRows = document.querySelector("#site-rows"); +const runPanel = document.querySelector("#run-panel"); +const status = document.querySelector("#status"); +const activity = document.querySelector("#activity"); +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 stages = document.querySelector("#stages"); -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 deleteError = document.querySelector("#delete-error"); +const deleteSubmit = document.querySelector("#delete-submit"); +const deleteCancel = document.querySelector("#delete-cancel"); + +/** + * The stage rows are static HTML. A refresh changes only their classes and + * cells, so a screen reader does not read the table again. + */ +const stageRows = [...stages.querySelectorAll("tr[data-stage]")]; +const stageOrder = stageRows.map((row) => row.dataset.stage); +const timeCells = new Map( + stageRows.map((row) => [row.dataset.stage, row.querySelector(".stage-time")]), +); +const linkCells = [...stages.querySelectorAll("[data-links]")]; -/** The run that the run panel shows, for its delete button. */ +let runs = []; +let selectedRunId = null; +/** The selected run as JSON. A refresh renders the run panel only when it changes. */ let shownRun = null; +/** The links in the stage table, so a refresh changes them only when they change. */ +let shownLinks = null; +/** The time of each stage of the selected run, from the last read. */ +let stageTimes = new Map(); +let refreshTimer; +/** The number of reads of the runs. Only the newest read changes the page. */ +let reads = 0; -const page = startRunsPage({ renderHistory, renderRun }); +/** + * The button is busy only while the gateway accepts the prompt. Then the form + * takes the next prompt, and the runs build in parallel. + */ +form.addEventListener("submit", async (event) => { + event.preventDefault(); + setSubmitting(true); + formError.textContent = ""; + try { + const runId = await submitPrompt(form.prompt.value); + form.reset(); + loadRuns(runId).catch(showFailure); + } catch (error) { + formError.textContent = messageOf(error); + } finally { + setSubmitting(false); + } +}); + +refreshRuns.addEventListener("click", () => { + loadRuns().catch(showFailure); +}); -deleteButton.addEventListener("click", () => { - if (shownRun) page.openDeleteDialog(shownRun); +deleteConfirm.addEventListener("input", () => { + deleteSubmit.disabled = deleteConfirm.value.trim() !== deleteForm.dataset.appName; }); +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. + * The dialog stays open until the gateway accepts the delete, and shows the + * error of a delete that fails. The run panel cannot show it: the next + * refresh renders the run panel again. */ -document.addEventListener("keydown", (event) => { - if (event.key === "Escape") stages.classList.add("tips-closed"); +deleteForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const { runId } = deleteForm.dataset; + deleteSubmit.disabled = true; + deleteError.textContent = ""; + try { + await deleteRun(runId); + } catch (error) { + deleteError.textContent = messageOf(error); + deleteSubmit.disabled = false; + return; + } + deleteDialog.close(); + // Each run of the app is deleting now. A run with no app is gone. + loadRuns(runId).catch(showFailure); }); -for (const type of ["pointerover", "focusin"]) { - stages.addEventListener(type, () => stages.classList.remove("tips-closed")); -} - -function renderHistory(runs, selectedRunId, { select }) { - runList.replaceChildren( - ...runs.map((run) => { - const button = document.createElement("button"); - 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"); - name.textContent = run.appName || truncate(run.prompt, 34); - // The status is its own element, so the stylesheet can color it by state. - const state = document.createElement("span"); - state.className = "run-state"; - state.dataset.status = run.status; - state.textContent = statusLabel(run); - const meta = document.createElement("span"); - meta.className = "run-meta"; - meta.append(state, ` · ${formatDate(run.createdAt)}`); - button.append(name, meta); - button.addEventListener("click", () => select(run.runId)); - return button; - }), + +// Four times each second, so the timer shows each second. A timer that runs +// once each second can skip a second when it runs late. +setInterval(renderTimes, 250); + +loadRuns(localStorage.getItem(SELECTED_RUN_KEY)).catch(showFailure); + +/** + * Read the runs, and select the preferred run, the selected run, or the + * newest run. While a task owns a run, read them again after REFRESH_MS. A + * failed read is tried again too, so a short gateway outage does not stop the + * updates. + */ +async function loadRuns(preferredRunId) { + window.clearTimeout(refreshTimer); + const read = ++reads; + try { + const response = await fetch("/ui/apps"); + const body = response.ok ? await response.json() : null; + // A newer read started, for example after a submit. + if (read !== reads) return; + if (!body) throw new Error(`Could not load run history (${response.status})`); + runs = body.runs || []; + const listed = (runId) => runs.some((run) => run.runId === runId); + selectRun( + [preferredRunId, selectedRunId].find((runId) => runId && listed(runId)) ?? + runs[0]?.runId ?? + null, + ); + } finally { + if (read === reads && runs.some((run) => activeStatuses.includes(run.status))) { + refreshTimer = window.setTimeout(() => loadRuns().catch(showFailure), REFRESH_MS); + } + } +} + +function selectRun(runId) { + selectedRunId = runId; + if (runId) localStorage.setItem(SELECTED_RUN_KEY, runId); + renderHistory(); + renderSelectedRun(); +} + +/* ── The table of runs ────────────────────────────────────────────────── */ + +/** The rows are replaced, so the focus goes back to the same control. */ +function renderHistory() { + emptyHistory.hidden = runs.length > 0; + sites.hidden = runs.length === 0; + const focusKey = document.activeElement?.dataset.focusKey; + siteRows.replaceChildren(...runs.map(historyRow)); + if (focusKey) { + document.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`)?.focus(); + } +} + +function historyRow(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")) selectRun(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.dataset.focusKey = `${run.runId}:name`; + name.addEventListener("click", () => selectRun(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 = statusLabel(run); + + 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.dataset.focusKey = `${run.runId}:delete`; + remove.addEventListener("click", () => openDeleteDialog(run)); + + row.append( + nameCell, + cell(state), + cell(run.urls?.web ? siteLink(run.urls.web, run.runId) : none()), + cell(generationTime(run)), + cell(formatDate(run.createdAt)), + cell(remove), ); + return row; +} + +/* ── The selected run ─────────────────────────────────────────────────── */ + +function renderSelectedRun() { + const run = runs.find((candidate) => candidate.runId === selectedRunId); + if (!run) { + runPanel.hidden = true; + shownRun = null; + return; + } + // The run panel is a live region, so change it only when the run changes. + const json = JSON.stringify(run); + if (json === shownRun) return; + shownRun = json; + renderRun(run); } function renderRun(run) { - shownRun = run; - deleteButton.hidden = activeStatuses.includes(run.status); - deleteButton.textContent = run.appName ? "Delete app" : "Delete run"; + runPanel.hidden = false; + runPanel.classList.toggle("failed", !healthyStatuses.includes(run.status)); + status.textContent = statusLabel(run); + progress.textContent = run.progress || ""; + activity.hidden = !activeStatuses.includes(run.status); + // 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); + stageRows.forEach((row, 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"; + } + row.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 : ""; + + stageTimes = timesOfStages(run.stageHistory ?? []); + renderTimes(); + renderLinks(run); +} + +/** + * How long the run was in each stage. A stage entered more than once adds up + * its times. `runningSince` is when the current stage started, or null for a + * stage that stopped. + */ +function timesOfStages(history) { + const times = new Map(); + for (const { stage, startedAt, finishedAt } of history) { + const time = times.get(stage) ?? { elapsed: 0, runningSince: null }; + if (finishedAt) time.elapsed += Date.parse(finishedAt) - Date.parse(startedAt); + else time.runningSince = Date.parse(startedAt); + times.set(stage, time); + } + return times; +} + +/** + * A read gives the times of the stages. Between reads, the timer adds the time + * since the current stage started. A stage that did not start has no time. + */ +function renderTimes() { + const now = Date.now(); + for (const [stage, timeCell] of timeCells) { + const time = stageTimes.get(stage); + const running = time && time.runningSince !== null ? now - time.runningSince : 0; + const text = time ? formatDuration(time.elapsed + running) : ""; + // The timer runs four times each second, so write only a time that changed. + if (timeCell.textContent !== text) timeCell.textContent = text; + } +} + +/** + * Each stage links to the Render Dashboard pages where it runs: the workflow + * run, which lists its subtasks, and the run's sandbox. The gateway gives null + * for a link it cannot make. + */ +function renderLinks(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) => externalLink(run.links[name], linkLabels[name])); + linkCell.replaceChildren(...(anchors.length > 0 ? anchors : [none()])); + } +} + +/** A read of the runs failed. The next read that succeeds shows the run again. */ +function showFailure(error) { + shownRun = null; + runPanel.hidden = false; + runPanel.classList.add("failed"); + status.textContent = "Request failed"; + progress.textContent = messageOf(error); +} + +/* ── Delete ───────────────────────────────────────────────────────────── */ + +/** + * A delete removes the app on Render and in the apps repository, with all of + * its runs, and cannot be undone. So the dialog asks for the app's name, as + * hosting dashboards do. + */ +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 = ""; + deleteError.textContent = ""; + deleteSubmit.disabled = Boolean(appName); + deleteDialog.showModal(); + (appName ? deleteConfirm : deleteSubmit).focus(); +} + +/* ── Gateway calls ────────────────────────────────────────────────────── */ + +/** Start a run. The gateway gives its ID, or an error that the form shows. */ +async function submitPrompt(prompt) { + const response = await fetch("/ui/apps", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || !body.runId) { + throw new Error(body.detail || body.error || `Request failed (${response.status})`); + } + return body.runId; +} + +async function deleteRun(runId) { + const response = await fetch(`/ui/apps/${encodeURIComponent(runId)}`, { + method: "DELETE", + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body.error || `Delete failed (${response.status})`); + } +} + +/* ── Formatting ───────────────────────────────────────────────────────── */ + +function setSubmitting(submitting) { + submit.disabled = submitting; + submit.textContent = submitting ? "Submitting…" : "Build and deploy"; +} + +function messageOf(error) { + return error instanceof Error ? error.message : String(error); +} + +/** The stage of a run that builds, or else its status. */ +function statusLabel(run) { + return run.status === "running" ? label(run.stage || "queued") : label(run.status); +} + +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)); +} + +/** 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)); +} + +/** Always with the seconds, so the timer of a stage shows each second. */ +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 ${seconds % 60}s`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; +} + +function cell(content) { + const td = document.createElement("td"); + td.append(content); + return td; +} + +function siteLink(url, runId) { + const anchor = externalLink(url, new URL(url).host); + anchor.className = "site-url"; + anchor.title = url; + anchor.dataset.focusKey = `${runId}:url`; + return anchor; +} + +function externalLink(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; } diff --git a/public/index.html b/public/index.html index ae4a66b..e8e04a5 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@ - Vibe Code Demo + Vibe Coding App
- Vibe Code Demo - Table view + Vibe Coding App
@@ -51,116 +54,140 @@

Describe it. Ship it.

-
- + +
+

No sites yet.

+ + - item.dataset.stage); - - const actions = { select: selectRun, openDeleteDialog }; - let runs = []; - let selectedRunId = null; - /** The run in the run panel, as JSON. A refresh renders it only when it changes. */ - let shownRun = null; - let refreshTimer; - /** The number of reads of the runs. Only the newest read changes the page. */ - let reads = 0; - - /** - * The button is busy only while the gateway accepts the prompt. Then the - * form takes the next prompt, and the runs build in parallel. - */ - form.addEventListener("submit", async (event) => { - event.preventDefault(); - setSubmitting(true); - formError.textContent = ""; - try { - const runId = await submitPrompt(form.prompt.value); - form.reset(); - loadRuns(runId).catch(showFailure); - } catch (error) { - formError.textContent = messageOf(error); - } finally { - setSubmitting(false); - } - }); - - refreshRuns.addEventListener("click", () => { - loadRuns().catch(showFailure); - }); - - deleteConfirm.addEventListener("input", () => { - deleteSubmit.disabled = - deleteConfirm.value.trim() !== deleteForm.dataset.appName; - }); - - deleteCancel.addEventListener("click", () => deleteDialog.close()); - - /** - * The dialog stays open until the gateway accepts the delete, and it shows - * the error of a delete that fails. The run panel cannot show it: the next - * refresh renders the run panel again. - */ - deleteForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const { runId } = deleteForm.dataset; - deleteSubmit.disabled = true; - deleteError.textContent = ""; - try { - await deleteRun(runId); - } catch (error) { - deleteError.textContent = messageOf(error); - deleteSubmit.disabled = false; - return; - } - deleteDialog.close(); - // Each run of the app is deleting now. A run with no app is gone. - loadRuns(runId).catch(showFailure); - }); - - /** - * 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 = ""; - deleteError.textContent = ""; - deleteSubmit.disabled = Boolean(appName); - deleteDialog.showModal(); - (appName ? deleteConfirm : deleteSubmit).focus(); - } - - /** - * Read the runs, and select the preferred run, the selected run, or the - * newest run. While a task owns a run, read them again after REFRESH_MS. - * A read that fails is tried again too, so that a short outage of the - * gateway does not stop the updates. - */ - async function loadRuns(preferredRunId) { - window.clearTimeout(refreshTimer); - const read = ++reads; - try { - const response = await fetch("/ui/apps"); - const body = response.ok ? await response.json() : null; - // A newer read started, for example after a submit. - if (read !== reads) return; - if (!body) throw new Error(`Could not load run history (${response.status})`); - runs = body.runs || []; - const listed = (runId) => runs.some((run) => run.runId === runId); - selectRun( - [preferredRunId, selectedRunId].find((runId) => runId && listed(runId)) ?? - runs[0]?.runId ?? - null, - ); - } finally { - if (read === reads && runs.some((run) => activeStatuses.includes(run.status))) { - refreshTimer = window.setTimeout(() => loadRuns().catch(showFailure), REFRESH_MS); - } - } - } - - function selectRun(runId) { - selectedRunId = runId; - if (runId) localStorage.setItem("vibe-code-selected-run", runId); - renderHistory(); - renderSelectedRun(); - } - - /** A view replaces its history, so give the focus back to the same control. */ - function renderHistory() { - emptyHistory.hidden = runs.length > 0; - const focusKey = document.activeElement?.dataset.focusKey; - view.renderHistory(runs, selectedRunId, actions); - if (focusKey) { - document.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`)?.focus(); - } - } - - function renderSelectedRun() { - const run = runs.find((candidate) => candidate.runId === selectedRunId); - if (!run) { - runPanel.hidden = true; - shownRun = null; - return; - } - // The run panel is a live region, so change it only when the run changes. - const json = JSON.stringify(run); - if (json === shownRun) return; - shownRun = json; - renderRun(run); - } - - function renderRun(run) { - runPanel.hidden = false; - runPanel.classList.toggle("failed", !healthyStatuses.includes(run.status)); - status.textContent = statusLabel(run); - progress.textContent = run.progress || ""; - activity.hidden = !activeStatuses.includes(run.status); - // 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 setSubmitting(submitting) { - submit.disabled = submitting; - submit.textContent = submitting ? "Submitting…" : "Build and deploy"; - } - - /** A read of the runs failed. The next read that succeeds shows the run again. */ - function showFailure(error) { - shownRun = null; - runPanel.hidden = false; - runPanel.classList.add("failed"); - status.textContent = "Request failed"; - progress.textContent = messageOf(error); - } - - loadRuns(localStorage.getItem("vibe-code-selected-run")).catch(showFailure); - return actions; -} - -/** Start a run. The gateway gives its ID, or an error that the form shows. */ -async function submitPrompt(prompt) { - const response = await fetch("/ui/apps", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ prompt }), - }); - const body = await response.json().catch(() => ({})); - if (!response.ok || !body.runId) { - throw new Error(body.detail || body.error || `Request failed (${response.status})`); - } - return body.runId; -} - -async function deleteRun(runId) { - const response = await fetch(`/ui/apps/${encodeURIComponent(runId)}`, { - method: "DELETE", - }); - const body = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(body.error || `Delete failed (${response.status})`); - } -} - -function messageOf(error) { - return error instanceof Error ? error.message : String(error); -} - -/** The stage of a run that builds, or else its status. */ -export function statusLabel(run) { - return run.status === "running" ? label(run.stage || "queued") : label(run.status); -} - -export function label(value) { - return value.replaceAll("_", " ").replace(/\b\w/g, (character) => character.toUpperCase()); -} - -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 b35fd6c..d22c06b 100644 --- a/public/style.css +++ b/public/style.css @@ -96,13 +96,6 @@ 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); @@ -332,80 +325,12 @@ input { cursor: not-allowed; } -/* Workspace: the run history beside the selected run. */ -.workspace { - display: grid; - grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); - gap: 24px; - align-items: start; -} - -.history-panel { - display: flex; - flex-direction: column; - gap: 16px; -} - .empty-history { color: var(--text-faint); font-size: 14px; line-height: 20px; } -.run-list { - display: flex; - flex-direction: column; - max-height: 560px; - overflow-y: auto; - border-top: 1px solid var(--border); -} - -.run-item { - display: flex; - flex-direction: column; - gap: 4px; - width: 100%; - padding: 12px; - border: 0; - border-bottom: 1px solid var(--border); - border-radius: 0; - background: transparent; - color: var(--text); - font: inherit; - text-align: left; - cursor: pointer; -} - -.run-item:hover { - background: var(--hover-tint); -} - -.run-item.selected { - background: var(--selected); -} - -.run-item strong { - overflow: hidden; - font-size: 15px; - font-weight: 400; - line-height: 22px; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Medium weight too, so that the selected run differs from a hover. */ -.run-item.selected strong { - font-weight: 500; -} - -.run-meta { - color: var(--text-faint); - font-family: var(--font-mono); - font-size: 12px; - line-height: 16px; - letter-spacing: 0; -} - .run-state[data-status="running"], .run-state[data-status="deleting"] { color: var(--text); @@ -453,131 +378,6 @@ input { animation: pulse 1.2s var(--ease) infinite; } -/* - * The stages read down the first column and then the second. Done is green, - * the stage that runs now is the text color, and a stage that failed is red. - */ -.stages { - display: grid; - grid-auto-flow: column; - grid-template-rows: repeat(5, auto); - grid-template-columns: repeat(2, minmax(0, 1fr)); - column-gap: 24px; - margin: 0; - padding: 0; - border-top: 1px solid var(--border); - list-style: none; -} - -.stages li { - position: relative; - display: flex; - align-items: center; - gap: 10px; - padding: 8px 0; - border-bottom: 1px solid var(--border); - color: var(--text-faint); - font-family: var(--font-mono); - 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 { - flex: none; - width: 10px; - height: 10px; - border: 1px solid var(--border-strong); - content: ""; -} - -.stages li.complete { - color: var(--success); -} - -.stages li.complete::before { - border-color: var(--success); - background: var(--success); -} - -.stages li.active { - color: var(--text); - font-weight: 500; -} - -.stages li.active::before { - border-color: var(--text); - background: var(--text); - animation: pulse 1.2s var(--ease) infinite; -} - -.stages li.failed-stage { - color: var(--danger); -} - -.stages li.failed-stage::before { - border-color: var(--danger); - 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; @@ -675,8 +475,6 @@ input { .primary-button:focus-visible, .secondary-button:focus-visible, .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, @@ -686,7 +484,7 @@ input { } /* - * The table view. Its tables have a mono header row, 1px rules, and no fills, + * The 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. */ @@ -936,7 +734,6 @@ input { @media (prefers-reduced-motion: reduce) { .activity, - .stages li.active::before, .stage-table tr.active th::before { animation: none; } @@ -947,7 +744,7 @@ input { } } -/* Tablet: one column. */ +/* Tablet: tighter padding and a smaller headline. */ @media (max-width: 1023px) { .topbar { padding: 0 24px; @@ -961,17 +758,9 @@ input { font-size: 48px; line-height: 52px; } - - .workspace { - grid-template-columns: minmax(0, 1fr); - } - - .run-list { - max-height: 320px; - } } -/* Mobile: one column of stages, and full-width form buttons. */ +/* Mobile: stacked stage rows, and full-width form buttons. */ @media (max-width: 767px) { .topbar { padding: 0 16px; @@ -991,12 +780,6 @@ input { padding: 16px; } - .stages { - grid-auto-flow: row; - grid-template-rows: none; - grid-template-columns: minmax(0, 1fr); - } - .form-actions { flex-direction: column-reverse; } @@ -1061,7 +844,7 @@ input { } } -/* A phone has no room for the name of the demo beside the view button. */ +/* A phone has no room for the name of the demo beside the wordmark. */ @media (max-width: 479px) { .topbar-label { display: none; diff --git a/public/table.html b/public/table.html deleted file mode 100644 index 3a42780..0000000 --- a/public/table.html +++ /dev/null @@ -1,219 +0,0 @@ - - - - - - - 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 deleted file mode 100644 index b520458..0000000 --- a/public/table.js +++ /dev/null @@ -1,179 +0,0 @@ -import { activeStatuses, formatDate, startRunsPage, statusLabel, 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 time cell of each stage, by the name of the stage. */ -const timeCells = new Map( - [...document.querySelectorAll("#stages tr[data-stage]")].map((row) => [ - row.dataset.stage, - row.querySelector(".stage-time"), - ]), -); - -/** The links in the stage table, so that a poll changes them only when they change. */ -let shownLinks = null; -/** The time of each stage of the shown run, from the last poll. */ -let stageTimes = new Map(); - -startRunsPage({ renderHistory, renderRun }); -// Four times each second, so that the timer shows each second. A timer that -// runs once each second can skip a second when it runs late. -setInterval(renderTimes, 250); - -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.dataset.focusKey = `${run.runId}:name`; - 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 = statusLabel(run); - - 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.dataset.focusKey = `${run.runId}:delete`; - remove.addEventListener("click", () => openDeleteDialog(run)); - - row.append( - nameCell, - cell(state), - cell(run.urls?.web ? siteLink(run.urls.web, run.runId) : none()), - cell(generationTime(run)), - cell(formatDate(run.createdAt)), - cell(remove), - ); - return row; - }), - ); -} - -function renderRun(run) { - stageTimes = timesOfStages(run.stageHistory ?? []); - renderTimes(); - renderLinks(run); -} - -/** - * How long the run was in each stage. A stage that the run went into more - * than once adds up its times. `runningSince` is when the stage that runs now - * started, or null for a stage that stopped. - */ -function timesOfStages(history) { - const times = new Map(); - for (const { stage, startedAt, finishedAt } of history) { - const time = times.get(stage) ?? { elapsed: 0, runningSince: null }; - if (finishedAt) time.elapsed += Date.parse(finishedAt) - Date.parse(startedAt); - else time.runningSince = Date.parse(startedAt); - times.set(stage, time); - } - return times; -} - -/** - * A poll gives the times of the stages. Between polls, the timer adds the - * time since the stage that runs now started. A stage that did not start has - * no time. - */ -function renderTimes() { - const now = Date.now(); - for (const [stage, timeCell] of timeCells) { - const time = stageTimes.get(stage); - const running = time && time.runningSince !== null ? now - time.runningSince : 0; - const text = time ? formatDuration(time.elapsed + running) : ""; - // The timer runs four times each second, so write only a time that changed. - if (timeCell.textContent !== text) timeCell.textContent = text; - } -} - -/** - * 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 renderLinks(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, runId) { - const anchor = dashboardLink(url, new URL(url).host); - anchor.className = "site-url"; - anchor.title = url; - anchor.dataset.focusKey = `${runId}: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)); -} - -/** Always with the seconds, so that the timer of a stage shows each second. */ -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 ${seconds % 60}s`; - if (minutes > 0) return `${minutes}m ${seconds % 60}s`; - return `${seconds}s`; -} diff --git a/render.yaml b/render.yaml index 4e18c1a..ce89716 100644 --- a/render.yaml +++ b/render.yaml @@ -52,7 +52,7 @@ projects: - type: workflow name: vibe-factory-workflows runtime: node - repo: https://github.com/render-examples/vibe-code-demo + repo: https://github.com/render-examples/vibe-coding-app # A workflow must set its region. Task runs connect to the database # on the private network, and that network does not cross regions. # Use the same region as the database, which is oregon by default. diff --git a/scripts/doctor.ts b/scripts/doctor.ts index c0ce894..7a31577 100644 --- a/scripts/doctor.ts +++ b/scripts/doctor.ts @@ -263,9 +263,9 @@ async function checkBlueprint(): Promise { level: "fail", message: `No Blueprint in workspace ${workspaceId} watches ${repo.fullName} (${factoryConfig.branch}:${factoryConfig.blueprintPath})`, fix: - "Create it once in that workspace in the Render Dashboard: New > Blueprint, pick the apps " + - `repository, branch ${factoryConfig.branch}, Blueprint Path ${factoryConfig.blueprintPath}. ` + - "Until then runs commit their app but stop at awaiting_blueprint.", + "Run npm run setup:apps-repo, then create the Blueprint once in that workspace in the Render " + + `Dashboard: New > Blueprint, pick the apps repository, branch ${factoryConfig.branch}, ` + + `Blueprint Path ${factoryConfig.blueprintPath}. Until then runs commit their app but stop at awaiting_blueprint.`, }); return; } @@ -293,9 +293,8 @@ async function checkBlueprint(): Promise { } /** - * The architect and the deploy manager read Render through the MCP server. - * If it does not have a tool on the allowlist, those agents lose it and no - * error shows it. + * The architect reads Render through the MCP server. If the server lacks a + * tool on the allowlist, the architect silently loses it. */ async function checkRenderMcp(): Promise { heading("Render MCP"); @@ -420,7 +419,6 @@ async function checkTasks(): Promise { const expected = [ "architect", "builder", - "deploy-manager", "prompt-to-app", "verify-app", "publish-app", diff --git a/scripts/setup-apps-repo.ts b/scripts/setup-apps-repo.ts new file mode 100644 index 0000000..787724e --- /dev/null +++ b/scripts/setup-apps-repo.ts @@ -0,0 +1,106 @@ +/** + * Commit an empty root Blueprint to the apps repository, once, before the + * first run. + * + * npm run setup:apps-repo + * + * Render can only create a Blueprint from a file that exists. With this file + * in place, you connect the apps Blueprint before the first run, and that run + * deploys all the way instead of stopping at awaiting_blueprint. A repository + * that already has the file is left alone. + */ +import { factoryConfig } from "../factory.config.js"; +import { rootBlueprint } from "../app/blueprint.js"; +import { appsRepo } from "../app/config.js"; +import { githubToken } from "../app/github.js"; +import { bold, dim, exitWith, heading, print } from "./support.js"; + +const API = process.env.GITHUB_API_URL ?? "https://api.github.com"; + +const repo = appsRepo(); +const token = await githubToken(); +const { branch, blueprintPath } = factoryConfig; +const base = `/repos/${repo.owner}/${repo.repo}`; + +heading(`Apps repository ${repo.fullName}`); + +const info = await github<{ default_branch: string }>("GET", base); +if (!info.ok || !info.body) { + fail( + `Cannot read ${repo.fullName} (HTTP ${info.status})`, + "Create the repository and give the credential Contents: write on it.", + ); +} + +const existing = await github( + "GET", + `${base}/contents/${blueprintPath}?ref=${encodeURIComponent(branch)}`, +); +if (existing.ok) { + print({ + level: "ok", + message: `${branch}:${blueprintPath} already exists, so nothing changed`, + }); +} else { + // An empty repository has no branch yet. A commit without a branch name + // creates the default branch, so it must be the branch the factory uses. + const hasBranch = ( + await github("GET", `${base}/branches/${encodeURIComponent(branch)}`) + ).ok; + if (!hasBranch && info.body.default_branch !== branch) { + fail( + `${repo.fullName} has no branch ${branch}`, + `Create it, or set branch in factory.config.ts to ${info.body.default_branch}.`, + ); + } + + const created = await github("PUT", `${base}/contents/${blueprintPath}`, { + message: "Add the empty apps Blueprint", + content: Buffer.from(rootBlueprint([])).toString("base64"), + ...(hasBranch ? { branch } : {}), + }); + if (!created.ok) { + fail( + `Could not commit ${blueprintPath} (HTTP ${created.status})`, + "The credential needs Contents: write on the apps repository.", + ); + } + print({ level: "ok", message: `Committed ${branch}:${blueprintPath}` }); +} + +heading("Next: connect the apps Blueprint, once"); +const workspace = process.env.RENDER_WORKSPACE_ID ?? "RENDER_WORKSPACE_ID"; +console.log(` In the Render Dashboard for workspace ${workspace}:`); +console.log(` ${bold("New > Blueprint")}, repository ${bold(repo.fullName)},`); +console.log(` branch ${bold(branch)}, Blueprint Path ${bold(blueprintPath)}.`); +console.log(` ${dim("Keep Auto Sync on. npm run doctor confirms it.")}`); + +exitWith([{ level: "ok", message: "The apps repository is ready." }]); + +async function github( + method: "GET" | "PUT", + path: string, + body?: Record, +): Promise<{ ok: boolean; status: number; body: T | null }> { + const response = await fetch(`${API}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "vibe-factory-setup", + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(15_000), + }); + return { + ok: response.ok, + status: response.status, + body: response.ok ? ((await response.json()) as T) : null, + }; +} + +function fail(message: string, fix: string): never { + print({ level: "fail", message, fix }); + return exitWith([{ level: "fail", message }]); +} diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 1119d27..a7498b8 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -8,7 +8,7 @@ import * as agents from "../app/agents.js"; import type { Agent } from "../app/claude.js"; import { RENDER_READ_ONLY_TOOLS } from "../app/policy.js"; -const all: Agent[] = [agents.architect, agents.builder, agents.deployManager]; +const all: Agent[] = [agents.architect, agents.builder]; const toolNames = (agent: Agent) => (agent.tools ?? []).map((tool) => tool.name); @@ -22,13 +22,11 @@ describe("agent definitions", () => { ]), ); expect(toolNames(agents.architect)).toEqual([]); - expect(toolNames(agents.deployManager)).toEqual([]); }); - it("gives the architect and the deploy manager only read-only Render tools, and no agent a log tool", () => { - for (const agent of [agents.architect, agents.deployManager]) { - expect(agent.renderTools?.length, agent.id).toBeGreaterThan(0); - } + it("gives the architect only read-only Render tools, and no agent a log tool", () => { + expect(agents.architect.renderTools?.length).toBeGreaterThan(0); + expect(agents.builder.renderTools ?? []).toEqual([]); for (const agent of all) { const granted = agent.renderTools ?? []; expect(RENDER_READ_ONLY_TOOLS, agent.id).toEqual( diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts index 600270b..3d6b181 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -1,11 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { agentJson } from "../app/claude.js"; -import { - deployDiagnosisSchema, - deployPlanSchema, - manifestSchema, - slug, -} from "../app/contracts.js"; +import { deployPlanSchema, manifestSchema, slug } from "../app/contracts.js"; const plan = { appName: "furniture-catalog", @@ -159,19 +154,3 @@ describe("manifestSchema", () => { ).toBe(false); }); }); - -/** - * The deploy manager runs only after Render reported a failed deploy. An - * "allHealthy" answer once sent such a run to the smoke checks, which can - * pass against the last live deploy. - */ -describe("deployDiagnosisSchema", () => { - it("is only a list of failures, with no way to declare the services healthy", () => { - expect( - deployDiagnosisSchema.parse({ allHealthy: true, failures: [] }), - ).toEqual({ failures: [] }); - expect(deployDiagnosisSchema.safeParse({ allHealthy: true }).success).toBe( - false, - ); - }); -}); diff --git a/tests/gateway.test.ts b/tests/gateway.test.ts index d21c3eb..9597768 100644 --- a/tests/gateway.test.ts +++ b/tests/gateway.test.ts @@ -226,7 +226,7 @@ describe("POST /v1/apps", () => { }); describe("browser UI", () => { - const pages = ["/", "/table", "/app.js", "/table.js", "/runs.js"]; + const pages = ["/", "/app.js"]; it("requires Basic Auth for every UI file and route, and serves no file that holds FACTORY_API_KEY", async () => { for (const path of [...pages, "/style.css", "/ui/apps"]) { @@ -266,19 +266,8 @@ describe("browser UI", () => { expect(mocks.deleteRunWithoutApp).not.toHaveBeenCalled(); }); - it("lists every stage of RUN_STAGES in order in the stage list and the stage table", async () => { - const index = await (await request("/", { authorization: UI_AUTH })).text(); - const items = [ - ...index.matchAll(/
  • ([\s\S]*?)<\/li>/g), - ]; - expect(items.map(([, stage]) => stage)).toEqual(RUN_STAGES); - for (const [, stage, item] of items) { - expect(item, stage).toContain('role="tooltip"'); - } - - const table = await ( - await request("/table", { authorization: UI_AUTH }) - ).text(); + it("lists every stage of RUN_STAGES in order in the stage table", async () => { + const table = await (await request("/", { authorization: UI_AUTH })).text(); const rows = [ ...table.matchAll(/([\s\S]*?)<\/tr>/g), ]; diff --git a/tests/host.test.ts b/tests/host.test.ts index f2cec64..b9f3c22 100644 --- a/tests/host.test.ts +++ b/tests/host.test.ts @@ -22,7 +22,6 @@ const TASKS = [ "builder", "delete-app", "delete-app-resources", - "deploy-manager", "prompt-to-app", "publish-app", "remove-app-files", diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 72ce0d2..51fbcab 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -7,7 +7,13 @@ import { type TaskContext, TaskRegistry } from "@renderinc/sdk/workflows"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { appPath, factoryConfig } from "../factory.config.js"; -import type { AppSpec, Manifest, Service } from "../app/contracts.js"; +import { resourceStem } from "../app/blueprint.js"; +import { + type AppSpec, + type Manifest, + type Service, + slug, +} from "../app/contracts.js"; import type { AppFile } from "../app/files.js"; import type { DeployOutcome } from "../app/render.js"; import type { ExecResult, Sandbox } from "../app/sandbox.js"; @@ -21,12 +27,11 @@ import { import { awaitDeployment, type DeployContext } from "../app/deploy.js"; import { publishAppTask } from "../app/publish.js"; import { verifyAppTask } from "../app/verify.js"; -import { deleteApp, promptToApp } from "../app/workflow.js"; +import { deleteApp, promptToApp, uniqueAppName } from "../app/workflow.js"; const mocks = vi.hoisted(() => ({ architectTask: vi.fn(), buildTask: vi.fn(), - deployManagerTask: vi.fn(), commitPaths: vi.fn(), pushVerified: vi.fn(), githubToken: vi.fn(), @@ -53,7 +58,6 @@ const mocks = vi.hoisted(() => ({ vi.mock("../app/agents.js", () => ({ architectTask: { name: "architect", func: mocks.architectTask }, buildTask: { name: "builder", func: mocks.buildTask }, - deployManagerTask: { name: "deploy-manager", func: mocks.deployManagerTask }, })); vi.mock("../app/store.js", () => ({ @@ -115,8 +119,10 @@ const INPUT = { user: "demo", runId: "run-1", }; -const SHOP_APP = { user: "demo", appName: "shop" }; -const APP_DIR = appPath("demo", "shop"); +/** The architect names the app "shop"; the run ID "run-1" adds the suffix. */ +const APP_NAME = "shop-run1"; +const SHOP_APP = { user: "demo", appName: APP_NAME }; +const APP_DIR = appPath("demo", APP_NAME); const APP_SPEC = `${APP_DIR}/factory.json`; const APP_SOURCE = `${APP_DIR}/web/index.html`; const ROOT_BLUEPRINT = `${factoryConfig.repoDir}/${factoryConfig.blueprintPath}`; @@ -183,7 +189,7 @@ const privateHost: Manifest = { /** The shop app as its first push published it. */ const spec: AppSpec = { user: "demo", - appName: "shop", + appName: APP_NAME, prompt: INPUT.prompt, summary: "A storefront, an API, and Postgres behind it.", createdAt: "2026-01-01T00:00:00.000Z", @@ -194,8 +200,8 @@ const spec: AppSpec = { }; const cafe: AppSpec = { ...spec, appName: "cafe", prompt: "A menu for a cafe" }; const CAFE_SPEC = `${appPath("demo", "cafe")}/factory.json`; -const WEB_ID = "srv-acme-demo-shop-web"; -const API_ID = "srv-acme-demo-shop-api"; +const WEB_ID = "srv-acme-demo-shop-run1-web"; +const API_ID = "srv-acme-demo-shop-run1-api"; function plan(...kinds: string[]) { return { @@ -533,6 +539,25 @@ afterEach(() => { /* ── prompt-to-app ────────────────────────────────────────────────────── */ +describe("uniqueAppName", () => { + it("gives two runs of the same name different apps, and keeps the suffix in every resource name", () => { + const first = uniqueAppName("bakery", "3f2a9c1e-0000-4000-8000-000000000000"); + const second = uniqueAppName("bakery", "b81d0f77-0000-4000-8000-000000000000"); + expect(first).toBe("bakery-3f2a"); + expect(second).toBe("bakery-b81d"); + + const long = uniqueAppName( + "neighborhood-sourdough-bakery", + "3f2a9c1e-0000-4000-8000-000000000000", + ); + expect(long).toBe("neighborhood-sourdo-3f2a"); + expect(slug.safeParse(long).success).toBe(true); + expect( + resourceStem({ resourcePrefix: "vibe", user: "demo", appName: long }), + ).toMatch(/-3f2a$/); + }); +}); + describe("promptToApp", () => { beforeEach(() => { mocks.architectTask.mockResolvedValue(plan("static_site")); @@ -546,7 +571,7 @@ describe("promptToApp", () => { expect(result).toMatchObject({ status: "deployed", - webUrl: expect.stringMatching(/demo-shop-web\.onrender\.com$/), + webUrl: expect.stringMatching(/demo-shop-run1-web\.onrender\.com$/), }); expect(mocks.finishRun).toHaveBeenCalledWith( "run-1", @@ -581,7 +606,7 @@ describe("promptToApp", () => { expect(result).toMatchObject({ status: "deployed", - apiUrl: expect.stringMatching(/demo-shop-api\.onrender\.com$/), + apiUrl: expect.stringMatching(/demo-shop-run1-api\.onrender\.com$/), }); expect(startedWith).toEqual( expect.arrayContaining([ @@ -652,8 +677,8 @@ describe("promptToApp", () => { it("fails the run before any build or push when the app is being deleted or built by another run", async () => { const refusals = [ - ["deleting", /demo\/shop is being deleted/], - ["running", /different run is building demo\/shop/], + ["deleting", /demo\/shop-run1 is being deleted/], + ["running", /different run is building demo\/shop-run1/], ] as const; for (const [reason, summary] of refusals) { mocks.claimRunApp.mockResolvedValueOnce({ claimed: false, reason }); @@ -713,7 +738,7 @@ describe("promptToApp", () => { token, expect.any(String), factoryConfig.branch, - ["apps/demo/shop", factoryConfig.blueprintPath], + [`apps/demo/${APP_NAME}`, factoryConfig.blueprintPath], expect.any(Function), ); @@ -729,7 +754,7 @@ describe("promptToApp", () => { expect(commit.get(`${victimDir}/index.html`)).toBe(STOREFRONT_HTML); expect(JSON.parse(commit.get(APP_SPEC) ?? "")).toMatchObject({ user: "demo", - appName: "shop", + appName: APP_NAME, manifest: siteOnly, }); expect(commit.get(ROOT_BLUEPRINT)).not.toMatch(/demo-evil/); @@ -782,18 +807,9 @@ describe("awaitDeployment", () => { [`${APP_DIR}/api/src/index.ts`, "export {};"], ]); builderReturns(repair); - mocks.deployManagerTask.mockResolvedValue({ - failures: [ - { - serviceName: "acme-demo-shop-api", - status: "pre_deploy_failed", - diagnosis: 'npm error Missing script: "migrate"', - }, - ], - }); }); - it("diagnoses a failed deploy, repairs it through verify-app and publish-app, and ends deployed when the new deploy is live", async () => { + it("repairs a failed deploy from its logs through verify-app and publish-app, and ends deployed when the new deploy is live", async () => { apiDeployFails(); nextDeploys.set(API_ID, { id: "dep-api2", status: "live" }); mocks.fetchDeployLogs.mockResolvedValue( @@ -803,17 +819,12 @@ describe("awaitDeployment", () => { const result = await deploy(); expect(result.status, result.summary).toBe("deployed"); - expect(subtasks()).toEqual([ - "deploy-manager", - "builder", - "verify-app", - "publish-app", - ]); + expect(subtasks()).toEqual(["builder", "verify-app", "publish-app"]); // The Dashboard shows every task input, so the logs arrive redacted. - const diagnosed = JSON.stringify(ran[0].input); - expect(diagnosed).toContain("npm error Missing script"); - expect(diagnosed).toContain("[REDACTED]"); - expect(diagnosed).not.toContain("s3cret"); + const repairInput = JSON.stringify(ran[0].input); + expect(repairInput).toContain("npm error Missing script"); + expect(repairInput).toContain("[REDACTED]"); + expect(repairInput).not.toContain("s3cret"); expect(pushes).toHaveLength(1); expect(JSON.parse(pushes[0].get(APP_SPEC) ?? "")).toEqual({ ...spec, @@ -821,19 +832,6 @@ describe("awaitDeployment", () => { }); }); - it("ends deploy_failed without smoke tests when the deploy manager diagnoses no failure", async () => { - apiDeployFails(); - mocks.deployManagerTask.mockResolvedValue({ failures: [] }); - - const result = await deploy(); - - expect(result.status).toBe("deploy_failed"); - expect(result.summary).toMatch(/diagnosed no failure/); - expect(mocks.buildTask).not.toHaveBeenCalled(); - expect(pushes).toEqual([]); - expect(mocks.waitForHttpOk).not.toHaveBeenCalled(); - }); - it("pushes nothing and ends deploy_failed when a repair adds a resource", async () => { apiDeployFails(); builderReturns({ @@ -869,8 +867,8 @@ describe("awaitDeployment", () => { const result = await deploy({ deploysBefore: new Map([ - ["acme-demo-shop-web", "dep-web0"], - ["acme-demo-shop-api", "dep-api0"], + ["acme-demo-shop-run1-web", "dep-web0"], + ["acme-demo-shop-run1-api", "dep-api0"], ]), changedServices: ["api"], }); @@ -914,7 +912,7 @@ describe("verifyAppTask", () => { return verifyAppTask.func(tasks, { sandboxId: runSandbox.id, user: "demo", - appName: "shop", + appName: APP_NAME, manifest, databaseUrl, }); @@ -984,9 +982,9 @@ describe("verifyAppTask", () => { describe("deleteApp", () => { const SHOP_RESOURCES = [ - "acme-demo-shop-web", - "acme-demo-shop-api", - "acme-demo-shop-db", + "acme-demo-shop-run1-web", + "acme-demo-shop-run1-api", + "acme-demo-shop-run1-db", ]; beforeEach(() => { @@ -1020,7 +1018,7 @@ describe("deleteApp", () => { [...removal.keys()].filter((path) => path.startsWith(`${APP_DIR}/`)), ).toEqual([]); expect(removal.get(CAFE_SPEC)).toBe(json(cafe)); - expect(mocks.deleteRuns).toHaveBeenCalledWith("demo", "shop"); + expect(mocks.deleteRuns).toHaveBeenCalledWith("demo", APP_NAME); }); it("deletes no Render resource when the repository has no spec for the app, and still removes its files", async () => { @@ -1042,14 +1040,14 @@ describe("deleteApp", () => { it("marks the app delete_failed, and keeps its runs and files, when a step fails", async () => { mocks.deleteAppResources.mockRejectedValue( - new Error("Render did not delete acme-demo-shop-db (403)"), + new Error("Render did not delete acme-demo-shop-run1-db (403)"), ); await expect(deleteApp.func(tasks, SHOP_APP)).rejects.toThrow("(403)"); expect(mocks.failDelete).toHaveBeenCalledWith( "demo", - "shop", + APP_NAME, expect.stringMatching(/\(403\)/), ); expect(mocks.deleteRuns).not.toHaveBeenCalled();