Public: anyone can read this; nobody can change it.
WIP: preview — apps worktree #2
apps-preview → main 4 commits
89 files
changed +18529
−352
.forgejo/workflows/preview.yml added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | name: preview | |
| 2 | # Every pull request gets its own web-only forge at | |
| 3 | # https://pr-<N>-forge-trevato-apps.open-platform.sh with its own volume; | |
| 4 | # closing the PR tears it down. Same build as release.yml, tagged for the PR. | |
| 5 | on: | |
| 6 | pull_request: | |
| 7 | types: [opened, synchronize, reopened, closed] | |
| 8 | ||
| 9 | jobs: | |
| 10 | preview: | |
| 11 | runs-on: isolated | |
| 12 | env: | |
| 13 | REGISTRY: forgejo-http.forgejo.svc.cluster.local:3000 | |
| 14 | MCP: http://mcp.mcp.svc.cluster.local | |
| 15 | steps: | |
| 16 | - name: derive owner + app + pr + endpoints | |
| 17 | env: | |
| 18 | TOKEN: ${{ github.token }} | |
| 19 | run: | | |
| 20 | set -eu | |
| 21 | OWNER="${GITHUB_REPOSITORY%%/*}"; APP="${GITHUB_REPOSITORY##*/}" | |
| 22 | echo "OWNER=$OWNER" >> $GITHUB_ENV | |
| 23 | echo "APP=$APP" >> $GITHUB_ENV | |
| 24 | echo "PR=${{ github.event.pull_request.number }}" >> $GITHUB_ENV | |
| 25 | HEADSHA="${{ github.event.pull_request.head.sha }}" | |
| 26 | echo "HEADSHA=$HEADSHA" >> $GITHUB_ENV | |
| 27 | echo "TAG=0.0.0-pr.${{ github.event.pull_request.number }}.$(echo "$HEADSHA" | cut -c1-7)" >> $GITHUB_ENV | |
| 28 | BASE=$(curl -fsS -H "Authorization: token $TOKEN" \ | |
| 29 | "http://$REGISTRY/api/v1/repos/$OWNER/$APP" \ | |
| 30 | | sed -n "s#.*\"html_url\":\"\(https\{0,1\}://[^\"/]*\)/$OWNER/$APP\".*#\1#p") | |
| 31 | [ -n "$BASE" ] || { echo "FATAL: could not derive public registry base"; exit 1; } | |
| 32 | REG_PUB=$(printf '%s' "$BASE" | sed -E 's#^https?://##; s#/$##') | |
| 33 | echo "REG_PUB=$REG_PUB" >> $GITHUB_ENV | |
| 34 | ||
| 35 | - name: teardown | |
| 36 | if: ${{ github.event.action == 'closed' }} | |
| 37 | env: | |
| 38 | TOKEN: ${{ github.token }} | |
| 39 | run: | | |
| 40 | set -eu | |
| 41 | code=$(curl -sS -o /tmp/out.json -w '%{http_code}' -X POST \ | |
| 42 | -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ | |
| 43 | "$MCP/ci/preview" \ | |
| 44 | -d "{\"action\":\"delete\",\"owner\":\"$OWNER\",\"name\":\"$APP\",\"pr\":$PR}") | |
| 45 | cat /tmp/out.json; echo | |
| 46 | [ "$code" = "200" ] || { echo "FATAL: teardown failed (HTTP $code)"; exit 1; } | |
| 47 | ||
| 48 | - name: write registry auth | |
| 49 | if: ${{ github.event.action != 'closed' }} | |
| 50 | env: | |
| 51 | TOKEN: ${{ secrets.REGISTRY_TOKEN }} | |
| 52 | run: | | |
| 53 | set -eu | |
| 54 | mkdir -p ~/.docker | |
| 55 | AUTH=$(printf '%s:%s' "$OWNER" "$TOKEN" | base64 -w0 2>/dev/null || printf '%s:%s' "$OWNER" "$TOKEN" | base64 | tr -d '\n') | |
| 56 | cat > ~/.docker/config.json <<JSON | |
| 57 | {"auths":{"$REG_PUB":{"auth":"$AUTH"},"$REGISTRY":{"auth":"$AUTH"}}} | |
| 58 | JSON | |
| 59 | ||
| 60 | - name: clone PR branch | |
| 61 | if: ${{ github.event.action != 'closed' }} | |
| 62 | env: | |
| 63 | TOKEN: ${{ github.token }} | |
| 64 | run: | | |
| 65 | set -eu | |
| 66 | git clone --depth 1 --branch "$GITHUB_HEAD_REF" \ | |
| 67 | "http://x-access-token:$TOKEN@$REGISTRY/$OWNER/$APP.git" src | |
| 68 | ||
| 69 | - name: build & push PR image (rootless buildkit) | |
| 70 | if: ${{ github.event.action != 'closed' }} | |
| 71 | run: | | |
| 72 | set -eu | |
| 73 | buildctl --addr "$BUILDKIT_HOST" build \ | |
| 74 | --frontend dockerfile.v0 \ | |
| 75 | --local context=src --local dockerfile=src \ | |
| 76 | --opt build-arg:VERSION="$TAG" \ | |
| 77 | --output type=image,name=$REG_PUB/$OWNER/$APP:$TAG,push=true \ | |
| 78 | --metadata-file /tmp/md.json | |
| 79 | DIGEST=$(grep -o '"containerimage.digest":[^,]*' /tmp/md.json | grep -o 'sha256:[a-f0-9]\{64\}' | head -1) | |
| 80 | [ -n "$DIGEST" ] || { echo "FATAL: no pushed digest"; exit 1; } | |
| 81 | echo "DIGEST=$DIGEST" >> $GITHUB_ENV | |
| 82 | ||
| 83 | - name: sign image (cosign) | |
| 84 | if: ${{ github.event.action != 'closed' }} | |
| 85 | env: | |
| 86 | COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} | |
| 87 | COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} | |
| 88 | run: | | |
| 89 | set -eu | |
| 90 | if [ -z "${COSIGN_PRIVATE_KEY:-}" ]; then | |
| 91 | echo "cosign key not provisioned; skipping signing" | |
| 92 | exit 0 | |
| 93 | fi | |
| 94 | cosign sign --yes --tlog-upload=false --key env://COSIGN_PRIVATE_KEY "$REG_PUB/$OWNER/$APP@$DIGEST" | |
| 95 | ||
| 96 | - name: package & push helm chart | |
| 97 | if: ${{ github.event.action != 'closed' }} | |
| 98 | working-directory: src | |
| 99 | run: | | |
| 100 | set -eu | |
| 101 | sed -i "s/^name:.*/name: $APP/" charts/app/Chart.yaml | |
| 102 | helm package charts/app --version "$TAG" --app-version "$TAG" | |
| 103 | helm push "$APP-$TAG.tgz" "oci://$REGISTRY/$OWNER/charts" --plain-http | |
| 104 | ||
| 105 | - name: provision preview | |
| 106 | if: ${{ github.event.action != 'closed' }} | |
| 107 | env: | |
| 108 | TOKEN: ${{ github.token }} | |
| 109 | run: | | |
| 110 | set -eu | |
| 111 | code=$(curl -sS -o /tmp/out.json -w '%{http_code}' -X POST \ | |
| 112 | -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ | |
| 113 | "$MCP/ci/preview" \ | |
| 114 | -d "{\"action\":\"create\",\"owner\":\"$OWNER\",\"name\":\"$APP\",\"pr\":$PR,\"previewSha\":\"$HEADSHA\"}") | |
| 115 | cat /tmp/out.json; echo | |
| 116 | [ "$code" = "200" ] || { echo "FATAL: provision failed (HTTP $code)"; exit 1; } |
CLAUDE.md +51 −5
| @@ -39,6 +39,16 @@ canAdmin`), token scope (`readForm` floor + `minScope`), and branch | ||
| 39 | 39 | 9. **`not_found` is deliberately ambiguous.** A missing resource and a resource |
| 40 | 40 | you can't see both return `not_found`, so private repos never leak their |
| 41 | 41 | existence. Keep that conflation when you add routes. |
| 42 | 10. **The crew proposes; a person lands.** An agent (`user.kind === "agent"`) | |
| 43 | never moves the default branch — the pre-receive hook refuses it, | |
| 44 | protected or not — and `mergePull` refuses an agent actor. Its only grant | |
| 45 | is `repo_crew` (read + write, never admin); its only credential is a | |
| 46 | token minted for a run with an expiry, deleted when the run ends. Never | |
| 47 | give an agent a path around these (`docs/APPS.md`, rule 1 and 6). | |
| 48 | 11. **A child inherits nothing it was not handed.** App and agent processes | |
| 49 | start from `childEnv` (`apps/process.ts`) — an explicit, short | |
| 50 | environment — never from the forge's own, which carries secrets. A | |
| 51 | preview's data is a copy of production's, never a path to it. | |
| 42 | 52 | |
| 43 | 53 | ## How to work |
| 44 | 54 | |
| @@ -116,8 +126,29 @@ src/ | ||
| 116 | 126 | vocabulary; screens/* are adapters over actions/; forge.ts is the |
| 117 | 127 | root + palette; ssh.ts is the front door (identity, git exec, |
| 118 | 128 | `serveForgeSsh`); main.ts is the stdio dev runner |
| 129 | apps/ a repository that runs (docs/APPS.md): manifest (forge.json, | |
| 130 | fail-closed), checkout (a commit exported with read-tree + | |
| 131 | checkout-index), data (per-environment dirs, copy-on-write | |
| 132 | seeding, SQLite quiesce), process (install/test/start/ready, | |
| 133 | explicit child env), runtime (environments in memory, rebuilt | |
| 134 | on boot, serial deploys per env, crash restarts bounded), | |
| 135 | proxy (Host routing, signed grants for private apps), scaffold | |
| 136 | (the app a new app starts as) | |
| 137 | runs/ log (one file per run, appended live, tailed by listeners) | |
| 138 | crew/ agents as child processes: brief (the system + task prompts: | |
| 139 | the golden path, written down), claude (the headless adapter: | |
| 140 | argv, stream-json, outcome), runner (one run: token, clone, | |
| 141 | branch, Claude, push, open/update the pull, report), dispatch | |
| 142 | (outbox consumer + sweep + worker tick), running (in-flight | |
| 143 | runs, for cancel) | |
| 119 | 144 | |
| 120 | 145 | |
| 146 | Three tables carry all of it: `app` (slug, access, preview data policy), | |
| 147 | `run` (kind deploy|agent, state by compare-and-swap, summary, cost) and | |
| 148 | `agent` (model, brief, budget) plus `repo_crew` and `issue.assignee_id`. What | |
| 149 | is _running_ is not state: `AppsRuntime.start` rebuilds every environment | |
| 150 | from the default branch and the open pulls. | |
| 151 | ||
| 121 | 152 | Two rules the TUI must keep. `tui/term/` is the transport boundary — no screen, |
| 122 | 153 | widget or loop code reaches past it to `process`, which is what lets one runtime |
| 123 | 154 | serve stdio, an SSH channel and a headless test with no branch anywhere. And a |
| @@ -176,10 +207,25 @@ flag exists only in the stdio dev runner and is never reachable over SSH. | ||
| 176 | 207 | is the same rule for a commit — objects written, ref held back until the |
| 177 | 208 | origin agrees. |
| 178 | 209 | |
| 210 | - **Anything a worker records (a run's state, an app's environment):** the | |
| 211 | worker calls `announceRun` (`actions/runs.ts`) or another action — never | |
| 212 | `bus.append` itself. `apps/runtime.ts` and `crew/*` are workers, not | |
| 213 | adapters, and still speak only through actions. | |
| 214 | - **A new reason for an agent to run:** add the trigger in | |
| 215 | `crew/dispatch.ts`'s `route` (an outbox event → `queueAgentRun`), name the | |
| 216 | reason in `brief.ts`'s `RunReason` and write what the agent is told for it | |
| 217 | in `taskPrompt`. Bound it: anything automatic (not a person speaking) must | |
| 218 | count against a limit the way `check_failed` does. | |
| 219 | - **Something an app process needs to know:** add it to `appEnv` | |
| 220 | (`apps/process.ts`) and document it in `docs/APPS.md`'s table. Never pass | |
| 221 | the forge's own environment through. | |
| 222 | ||
| 179 | 223 | ## Not yet built (deliberately) |
| 180 | 224 | |
| 181 | CI runners / a checks API on PRs, a self-describing (OpenAPI) endpoint, and the | |
| 182 | AI build crew. The event outbox and API surface are shaped so these arrive as | |
| 183 | additions, not rewrites. `docs/ARCHITECTURE.md` closes with the | |
| 184 | reasoning. "Forgejo is my harness" is the north star — automation is a | |
| 185 | first-class user, not an afterthought. | |
| 225 | A self-describing (OpenAPI) endpoint, containers around app processes, | |
| 226 | custom domains and websockets through the proxy, and a gate that lands a | |
| 227 | pull on its own once review and checks agree. The event outbox and API | |
| 228 | surface are shaped so these arrive as additions, not rewrites. | |
| 229 | `docs/ARCHITECTURE.md` closes with the reasoning. "Forgejo is my harness" | |
| 230 | is the north star — automation is a first-class user, not an afterthought, | |
| 231 | and `docs/APPS.md` is where that user now lives. |
README.md +71 −5
| @@ -83,7 +83,6 @@ way in you get is a property of the forge, not a preference: | ||
| 83 | 83 | the code on whatever device has a browser, and the forge does the polling. |
| 84 | 84 | This is the flow that works over SSH with no browser on the server. Setting |
| 85 | 85 | it up is three steps, once, for everyone on the forge: |
| 86 | ||
| 87 | 86 | 1. Create an OAuth App at . Name it |
| 88 | 87 | anything, set the homepage to your forge's URL, set the authorization |
| 89 | 88 | callback URL to `https://your.forge/settings/origins/callback`, and tick |
| @@ -93,6 +92,7 @@ way in you get is a property of the forge, not a preference: | ||
| 93 | 92 | disconnecting revokes the grant on GitHub too. |
| 94 | 93 | 3. The GitHub button appears. Until it does, both surfaces print these three |
| 95 | 94 | steps rather than naming an environment variable and leaving you to it. |
| 95 | ||
| 96 | 96 | - **Forgejo / Gitea** — authorization code with PKCE. They implement no device |
| 97 | 97 | grant, and every instance needs its own OAuth application, so you paste a |
| 98 | 98 | client id once per host. The page gives you the exact redirect URI to enter |
| @@ -109,7 +109,7 @@ way in you get is a property of the forge, not a preference: | ||
| 109 | 109 | Both connect screens hand the job to whatever device has a browser: a **QR |
| 110 | 110 | code** to point a camera at, a **short link** (`https://your.forge/go/<10 |
| 111 | 111 | characters>`, live for ten minutes and gone once used) short enough to type by |
| 112 | hand, and — in the terminal — `c` to put the link on *your* clipboard through | |
| 112 | hand, and — in the terminal — `c` to put the link on _your_ clipboard through | |
| 113 | 113 | your own terminal (OSC 52, so it works over SSH; iTerm2, Ghostty, kitty and |
| 114 | 114 | WezTerm honour it, Apple Terminal does not). `o` opens a browser only when the |
| 115 | 115 | forge's terminal is the one in front of you; over SSH it says so rather than |
| @@ -123,7 +123,7 @@ choose is not a feature. | ||
| 123 | 123 | A mirrored repo wears its origin in the header (`mirrored from owner/name @ |
| 124 | 124 | host`), shows its last fetch and any fetch error in the About rail, and syncs |
| 125 | 125 | on a worker tick or on demand. Imported issues and comments show the login on |
| 126 | the *origin*, with that forge's mark, and link back to the canonical thread. | |
| 126 | the _origin_, with that forge's mark, and link back to the canonical thread. | |
| 127 | 127 | Merging a pull happens on the origin: the merge box says so and links there, |
| 128 | 128 | and the next sync brings back the merge commit it actually made. Pushes go |
| 129 | 129 | through too — the pre-receive hook forwards the push upstream first, so a push |
| @@ -188,6 +188,61 @@ something in the 60–110 MB range; `dist/` is gitignored and binaries are never | ||
| 188 | 188 | committed). Until you build it, `bun run src/cli/main.ts …` is the same |
| 189 | 189 | program. |
| 190 | 190 | |
| 191 | ## Apps | |
| 192 | ||
| 193 | A repository that runs. Press **New app**, give it a name and — if you like — | |
| 194 | a sentence about what it should do. Seconds later it is serving at | |
| 195 | `http://<name>.<your forge's host>` from a small, real app (notes in a SQLite | |
| 196 | file), and if you named an agent, the sentence became issue #1, assigned. | |
| 197 | ||
| 198 | - **Production is the default branch.** The only way code reaches it is that | |
| 199 | branch moving, and an agent cannot move it: the crew proposes through | |
| 200 | pulls, a person merges. | |
| 201 | - **Every open pull gets a preview** at `<name>--<pull number>.<host>`, | |
| 202 | redeployed on each push and gone when the pull closes. Its data starts as | |
| 203 | a copy of production's — copy-on-write where the filesystem can — and | |
| 204 | never writes back. Merging moves code and touches no data. | |
| 205 | - **Every deploy is a run**, and the run is the check on the pull. Turn on | |
| 206 | **require checks** and a pull cannot merge until its head is green. | |
| 207 | - A **private** app answers only to someone the forge has vouched for | |
| 208 | (`canRead` on the repository, then a signed grant); a public one to anyone. | |
| 209 | ||
| 210 | The repository says how it runs in `forge.json` (`run`, and optionally | |
| 211 | `install`, `test`, `health`, `ready`); a `package.json` with a `start` script | |
| 212 | is enough on its own. The forge hands the process `PORT`, `FORGE_APP_DATA` | |
| 213 | (this environment's directory — keep state there), `FORGE_APP_ENV`, | |
| 214 | `FORGE_APP_URL` and `FORGE_APP_COMMIT`, and nothing from its own | |
| 215 | environment. Browsers resolve `*.localhost` to loopback, so a laptop forge | |
| 216 | needs no DNS at all; elsewhere point a wildcard at it and set | |
| 217 | `FORGE_APP_DOMAIN`. | |
| 218 | ||
| 219 | ## The crew | |
| 220 | ||
| 221 | An **agent** is an account of kind `agent`: no password, no session, and it | |
| 222 | acts only through a token the forge mints for a run and destroys after it. | |
| 223 | An admin creates one (**Settings → Crew**, or `POST /api/v1/agents`) with a | |
| 224 | model, a brief in its own voice and a spend budget; a repository admin puts it | |
| 225 | on the repository's **crew**, which is the one grant an agent can hold — it | |
| 226 | reads and writes that repository and administers nothing. | |
| 227 | ||
| 228 | Assign an issue to an agent and it starts: the forge clones the repository, | |
| 229 | puts the agent on a branch named after the issue, and runs Claude Code | |
| 230 | headless with a brief that says who it is, what the task is, how to finish, | |
| 231 | and what will be refused. When it stops, the forge pushes what it committed, | |
| 232 | opens the pull (the agent's last message is the description), and the | |
| 233 | preview comes up. Leave feedback on the pull — a comment, a request for | |
| 234 | changes, a line comment — and the agent picks it up in the background, | |
| 235 | resuming its session. A failed check re-engages it too, a bounded number of | |
| 236 | times before a person must speak. | |
| 237 | ||
| 238 | The forge asks the host for one more binary: `claude`, on `PATH` or at | |
| 239 | `FORGE_CLAUDE`, authenticated as the operator (an `ANTHROPIC_API_KEY` in the | |
| 240 | forge's environment is handed to runs; the machine's own Claude Code login | |
| 241 | works when there is none). `FORGE_CREW_CONCURRENCY` (2) and | |
| 242 | `FORGE_CREW_BUDGET_USD` (5) bound it; `FORGE_APPS=0` turns apps and crew off. | |
| 243 | `docs/APPS.md` is the contract: the vocabulary, and the rules that are | |
| 244 | mechanical rather than advisory. | |
| 245 | ||
| 191 | 246 | ## Develop |
| 192 | 247 | |
| 193 | 248 | sh |
| @@ -212,6 +267,11 @@ src/ | ||
| 212 | 267 | tui/ the terminal UI: runtime, widgets, screens, the ssh front door, |
| 213 | 268 | and local/ — the same runtime over your own working trees |
| 214 | 269 | cli/ `forge` on your machine: login, sync, status, pins |
| 270 | apps/ a repository that runs: manifest, checkout, process, data, | |
| 271 | the runtime (environments), the proxy by Host | |
| 272 | runs/ run logs, appended live | |
| 273 | crew/ agents as child processes: the brief, Claude Code headless, | |
| 274 | the runner, dispatch off the outbox | |
| 215 | 275 | |
| 216 | 276 | |
| 217 | 277 | Read `docs/ARCHITECTURE.md` for the principles and the reasoning behind them. |
| @@ -252,10 +312,16 @@ work here. | ||
| 252 | 312 | - **Automation** — a JSON API covering repos, issues, forks, and orgs (the |
| 253 | 313 | label-as-queue surface), a durable event outbox, and **webhooks** (HMAC-signed |
| 254 | 314 | delivery with retry). |
| 315 | - **Apps** — a repository that runs: production on the default branch, a | |
| 316 | preview per open pull with its own copy of the data, served by hostname | |
| 317 | from the forge's own port, every deploy a run and every run a check. | |
| 318 | - **The crew** — agents as accounts with nothing but a run-scoped token, | |
| 319 | working issues in the background with Claude Code and proposing through | |
| 320 | pulls a person merges. | |
| 255 | 321 | |
| 256 | 322 | Pages render without a line of JavaScript and are designed thumb-first. Six |
| 257 | 323 | premade themes (from warm paper to polar night) switch instantly and persist in |
| 258 | 324 | a cookie — even that works script-free. Still **zero runtime dependencies**. |
| 259 | 325 | |
| 260 | Not yet: CI runners and an AI build crew. The event outbox and API surface are | |
| 261 | shaped so these arrive as additions, not rewrites. | |
| 326 | Not yet: containers around apps, custom domains, websockets through the | |
| 327 | proxy, and a gate that lands a pull on its own once review and checks agree. |
docs/APPS.md added +295
| @@ -0,0 +1,295 @@ | ||
| 1 | # Apps, runs, and the crew | |
| 2 | ||
| 3 | A forge holds repositories. An **app** is a repository that runs — here, on | |
| 4 | the forge's own port, with a production and a preview for every open pull — | |
| 5 | and the **crew** is the set of agents who work on it while you are elsewhere. | |
| 6 | This document is the contract between the two halves and the rest of the | |
| 7 | forge: what the words mean, which rules are mechanical, and where the state | |
| 8 | lives. `docs/ARCHITECTURE.md` explains the foundation these sit on. | |
| 9 | ||
| 10 | The shape is Vercel's — you have apps, apps have previews, git decides what | |
| 11 | is production and protects it — with one deliberate difference. The person | |
| 12 | using it need not be a developer. They know what an app is, they can hold | |
| 13 | "production data" and "preview data" apart in their head, and the golden path | |
| 14 | asks nothing more of them than that. Developers and agents use the same | |
| 15 | primitives underneath; nobody gets a second system. | |
| 16 | ||
| 17 | ## Vocabulary | |
| 18 | ||
| 19 | - **App** — a repository that runs. A repository becomes an app explicitly | |
| 20 | (`New app`, or `Run as an app` on an existing one), the same way a | |
| 21 | repository is mirrored explicitly: a choice you can see and reverse. An | |
| 22 | app has a **slug**, the label its hostnames are built from. | |
| 23 | - **Environment** — one place an app runs. `production` is the default | |
| 24 | branch. A **preview** is one open pull. Every environment owns a data | |
| 25 | directory, and nothing else does. | |
| 26 | - **Deployment** — an environment at one commit: a checkout, a process, a | |
| 27 | port, a log. An environment routes to its _current_ deployment; a newer | |
| 28 | one takes over only once it answers. | |
| 29 | - **Run** — a durable record of work the forge did on a commit. A `deploy` | |
| 30 | run installs, builds, tests, starts and waits for the app to answer. An | |
| 31 | `agent` run is an agent working on an issue or a pull. Runs are the | |
| 32 | **checks** on a pull. | |
| 33 | - **Agent** — an account of kind `agent`. No password, no session; it acts | |
| 34 | only through tokens the forge mints for a run and destroys after it. It | |
| 35 | is a user in every other respect: it owns nothing you did not give it, it | |
| 36 | is granted access the way a person is (collaborator or team), and its | |
| 37 | name appears on the pull it opened. | |
| 38 | - **Crew** — the agents with write access to a repository. | |
| 39 | ||
| 40 | ## Rules that are mechanical | |
| 41 | ||
| 42 | Each rule below is enforced in a code path with a test that tries to get | |
| 43 | around it. None is advisory. | |
| 44 | ||
| 45 | 1. **Production is the default branch, and agents cannot move it.** The only | |
| 46 | way code reaches production is the default branch moving. The | |
| 47 | pre-receive hook refuses an agent's push to the default branch outright — | |
| 48 | protected or not — and `mergePull` refuses an agent actor. An agent | |
| 49 | proposes; a person lands. (A merge gate that lands on its own once a | |
| 50 | review and the checks agree is a setting to add, not a rule to loosen.) | |
| 51 | Two corollaries keep an agent from starting a chain: an agent cannot | |
| 52 | assign anyone (assigning an agent is what spends money, and only a | |
| 53 | person may), and an agent cannot create a repository (it owns nothing it | |
| 54 | was not given; it may fork to contribute). | |
| 55 | 2. **A preview lives exactly as long as its pull is open.** Opened: deployed. | |
| 56 | Pushed: redeployed at the new head. Closed or merged: stopped, and its | |
| 57 | data deleted. There is no orphan state; the sweep on boot rebuilds the | |
| 58 | set of previews from the set of open pulls. | |
| 59 | 3. **Preview data is a copy, never a window.** When a preview is created its | |
| 60 | data directory is cloned from production's at that moment (copy-on-write | |
| 61 | where the filesystem offers it — APFS, XFS, btrfs — a plain copy | |
| 62 | otherwise, and `empty` is a per-app choice). A preview process is handed | |
| 63 | its own `FORGE_APP_DATA` and nothing that names production's. Merging a | |
| 64 | pull moves code; it never touches data. | |
| 65 | 4. **A deployment is a check.** Every deploy is a run; the run's state is | |
| 66 | what the pull shows. With `require_checks` on, `mergePull` refuses | |
| 67 | `checks_required` until the head's deploy run is `ok`, read live inside | |
| 68 | the merge — never from a status a caller computed earlier. | |
| 69 | 5. **A private app answers only to someone the forge vouches for.** An app's | |
| 70 | `access` is `public` or `private` (defaulting to the repository's | |
| 71 | visibility). A private environment sets no cookie of its own; a visitor | |
| 72 | is sent through the forge, which checks `canRead` on the repository and | |
| 73 | returns them with a signed, short-lived grant. Guessing the hostname | |
| 74 | yields the forge's own "not found". | |
| 75 | 6. **A run's credential dies with the run.** An agent run gets a token | |
| 76 | scoped `rw` (no settings, no tokens, no administration) with an expiry, | |
| 77 | and the token is deleted when the run ends however it ends. It never | |
| 78 | appears in argv or a URL; git receives it through `http.extraHeader`, | |
| 79 | the same channel origins and the local client use. | |
| 80 | 7. **Runs are bounded.** Every run has a spend budget and a wall-clock | |
| 81 | limit. A pull gets a fixed number of automatic follow-up runs (a failed | |
| 82 | check, a request for changes) before the forge stops and says so; a | |
| 83 | person speaking on the pull resets the count. Nothing loops unattended. | |
| 84 | 8. **A child process inherits nothing it was not handed.** The forge's own | |
| 85 | environment holds secrets (an API key, an OAuth client secret). App and | |
| 86 | agent processes are started with an explicit environment — `PATH`, | |
| 87 | `HOME`, a locale, and the variables this document names — never the | |
| 88 | forge's. | |
| 89 | ||
| 90 | ## The golden path | |
| 91 | ||
| 92 | What a person sees, in order: | |
| 93 | ||
| 94 | 1. **New app.** A name, and — optionally — a sentence about what it should | |
| 95 | do. The forge creates the repository with a small working app in it, | |
| 96 | makes it an app, and starts production. Seconds later there is a URL. | |
| 97 | If a sentence was given, it becomes the first issue, assigned to an agent | |
| 98 | on the crew, and the agent starts. | |
| 99 | 2. **A pull appears**, opened by the agent, with a preview. Open the preview | |
| 100 | (a link, or a QR code from a phone), use it. Leave feedback as review | |
| 101 | comments and a verdict. Request changes and the agent picks it up in the | |
| 102 | background; the preview updates when it pushes. | |
| 103 | 3. **Approve, merge.** Production redeploys from the default branch. The | |
| 104 | production data directory is untouched — the notes you took, the rows | |
| 105 | your app wrote, are all still there. The preview and its copied data are | |
| 106 | gone. | |
| 107 | 4. **Drop in whenever.** The dashboard lists apps with what is running, and | |
| 108 | **Needs you** — pulls waiting on a review — is the inbox. Everything | |
| 109 | else runs without you. | |
| 110 | ||
| 111 | Nothing above required a terminal. Everything above is also reachable from | |
| 112 | one, and from the JSON API, because each step is an action. | |
| 113 | ||
| 114 | ## How an app is declared | |
| 115 | ||
| 116 | The repository says how to run itself, in `forge.json` at its root: | |
| 117 | ||
| 118 | json | |
| 119 | { | |
| 120 | "app": { | |
| 121 | "run": "bun run src/server.ts", | |
| 122 | "install": "bun install", | |
| 123 | "test": "bun test", | |
| 124 | "health": "/", | |
| 125 | "ready": 30, | |
| 126 | "tty": "bun run src/tty.ts" | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | ||
| 131 | Only `run` is required, and even it defaults: a `package.json` with a `start` | |
| 132 | script is enough. `install` defaults to `bun install` when a `package.json` | |
| 133 | exists and to nothing otherwise; `test` defaults to nothing; `health` is the | |
| 134 | path polled until it answers; `ready` is the number of seconds to wait for | |
| 135 | that before the deploy fails; `tty` is the app's terminal interface (below). The file is read from the commit being | |
| 136 | deployed, never from the database — a pull that changes how the app runs is | |
| 137 | previewed the way it will run. | |
| 138 | ||
| 139 | The forge starts the app with: | |
| 140 | ||
| 141 | | variable | value | | |
| 142 | | ------------------ | ------------------------------------------------- | | |
| 143 | | `PORT` | the port to listen on, chosen by the forge | | |
| 144 | | `HOST` | `127.0.0.1` — only the forge's proxy reaches it | | |
| 145 | | `FORGE_APP_ENV` | `production` or `preview` | | |
| 146 | | `FORGE_APP_DATA` | this environment's data directory, created for it | | |
| 147 | | `FORGE_APP_URL` | the public URL this environment is served at | | |
| 148 | | `FORGE_APP_COMMIT` | the commit being run | | |
| 149 | | `FORGE_APP_PULL` | the pull number, on a preview | | |
| 150 | | `FORGE_URL` | the forge's own base URL | | |
| 151 | ||
| 152 | An app that keeps its state under `FORGE_APP_DATA` gets production and | |
| 153 | preview data for free. The scaffold does exactly that with `bun:sqlite`. | |
| 154 | ||
| 155 | ## A terminal for every app | |
| 156 | ||
| 157 | The forge's own port answers `ssh`, and the login name picks what opens: | |
| 158 | any name opens the forge's terminal UI, and an app's slug opens **that | |
| 159 | app's terminal** — `ssh -p 2222 notes@forge` runs the app's `tty` command | |
| 160 | on a real pty, in the deployed checkout, with the environment's own | |
| 161 | variables (`FORGE_APP_DATA` and the rest, plus `FORGE_APP_TTY=1` and | |
| 162 | `FORGE_APP_USER`, the forge account at the keyboard or empty for a guest). | |
| 163 | `notes--12@forge` is the preview of pull 12's. The key still decides who | |
| 164 | you are: a private app's terminal answers only to a key the forge knows | |
| 165 | that can read the repository; a public app's answers to anyone, as its web | |
| 166 | page does. Resizes pass through; closing the connection sends the program | |
| 167 | `SIGHUP`. | |
| 168 | ||
| 169 | The scaffold ships a small one (`src/tty.ts`, plain ANSI, no dependencies) | |
| 170 | over the same SQLite file the web page uses, so a new app is reachable two | |
| 171 | ways from its first minute. An app without a `tty` says so, in words, when | |
| 172 | someone tries. | |
| 173 | ||
| 174 | ## Hostnames and the proxy | |
| 175 | ||
| 176 | Apps are served from the forge's port by `Host`. With `FORGE_APP_DOMAIN` | |
| 177 | (default: the forge's own host), production is `<slug>.<domain>` and the | |
| 178 | preview of pull 12 is `<slug>--12.<domain>`. `--` cannot occur inside a slug, | |
| 179 | so the two never collide. Browsers resolve every `*.localhost` name to | |
| 180 | loopback, which is what makes a laptop forge work with no configuration at | |
| 181 | all: `http://hello.localhost:3000`. | |
| 182 | ||
| 183 | The proxy is a `fetch` to the deployment's loopback port, streamed back. | |
| 184 | Headers are forwarded with the usual `X-Forwarded-*` additions. An | |
| 185 | environment that is not running gets a page from the forge that says so and | |
| 186 | links the run — never a bare gateway error. Websockets are not proxied yet. | |
| 187 | ||
| 188 | ## Where state lives | |
| 189 | ||
| 190 | Git is truth for code. The app's serving configuration (slug, access, the | |
| 191 | preview-data policy) is forge state, like a webhook. Everything else is | |
| 192 | either derived or ephemeral: | |
| 193 | ||
| 194 | ||
| 195 | <data>/apps/<app id>/production/data production data — lives forever | |
| 196 | <data>/apps/<app id>/production/<commit7>/ a checkout, one per deployment | |
| 197 | <data>/apps/<app id>/pull-<n>/data preview data — dies with the pull | |
| 198 | <data>/apps/<app id>/pull-<n>/<commit7>/ the preview's checkout | |
| 199 | <data>/runs/<run id>.log every run's log, appended live | |
| 200 | <data>/crew/<agent>/<repo id>/<issue n>/ an agent's clone, kept while its pull is open | |
| 201 | ||
| 202 | ||
| 203 | Rows: `app` (repo, slug, access, preview_data), `run` (repo, kind, issue, | |
| 204 | commit, state, summary, cost, timings), `agent` (user, model, brief, budget), | |
| 205 | and `issue.assignee_id`. The current deployment of each environment is in | |
| 206 | memory only: a process is not state, and a restart rebuilds every | |
| 207 | environment from the default branch and the open pulls, exactly as | |
| 208 | `core/reconcile.ts` rebuilds the index from git. | |
| 209 | ||
| 210 | ## Runs | |
| 211 | ||
| 212 | A run row is created `queued`, claimed with a compare-and-swap into | |
| 213 | `running` (two workers cannot both take it), and finishes `ok`, `failed` or | |
| 214 | `cancelled` with a one-line summary. Its log is a file the surfaces tail live | |
| 215 | through an in-process listener; the file is the truth, the listener is the | |
| 216 | optimization. `run.queued`, `run.started` and `run.finished` ride the event | |
| 217 | outbox like every other state change, so webhooks and the SSE stream see | |
| 218 | them. | |
| 219 | ||
| 220 | On a pull, the runs for the current head are its checks. A deploy run that | |
| 221 | fails — install, build, test, or the app never answering — fails the check | |
| 222 | and the preview shows the log where it stopped. A push makes new runs; | |
| 223 | older ones are history, not status. | |
| 224 | ||
| 225 | ## The crew | |
| 226 | ||
| 227 | An agent is created once (`New agent`: a name, a model, a brief) and given | |
| 228 | access like anyone else. Work reaches it three ways, all off the event | |
| 229 | outbox with a sweep behind the wake-up: | |
| 230 | ||
| 231 | - **Assignment.** An issue assigned to an agent starts a run. The agent | |
| 232 | clones the repository, works on a branch named after the issue, and | |
| 233 | pushes. The forge opens the pull on its behalf — the agent's closing | |
| 234 | message is the description — so the bookkeeping is never the agent's to | |
| 235 | get wrong. Assigning a pull re-engages the agent on that pull. | |
| 236 | - **Feedback.** A review that requests changes, or a comment on the | |
| 237 | agent's pull that addresses it, starts a follow-up run that resumes the | |
| 238 | agent's session with the feedback quoted. | |
| 239 | - **A failed check.** A deploy that fails on the agent's pull starts a | |
| 240 | follow-up with the log's tail. Bounded by rule 7. | |
| 241 | ||
| 242 | An agent may decline. A last message that starts with `DECLINED:` ends the | |
| 243 | run as failed with that reason, posted on the issue or pull, and nothing it | |
| 244 | touched is pushed — a mis-scoped task parks with an explanation instead of | |
| 245 | becoming an empty or wrong pull. | |
| 246 | ||
| 247 | The agent runs Claude Code headless in its clone, with a prompt the forge | |
| 248 | composes: who it is, what the task is, how to finish, and what will be | |
| 249 | refused. Permissions are the forge's, passed as settings: file tools | |
| 250 | confined to the clone, no prompts (anything that would ask is denied), a | |
| 251 | spend budget, and Claude Code's own sandbox around the shell — seatbelt on | |
| 252 | macOS, bubblewrap on Linux where it is installed — with a policy the forge | |
| 253 | writes per run (`crew/sandbox.ts`): the network is the forge and the | |
| 254 | package registry (plus `FORGE_CREW_ALLOW_HOSTS`); writes stay in the clone | |
| 255 | and the temp dir; reads are refused for the forge's repositories, its | |
| 256 | index and key, every other agent's clone, and the operator's own | |
| 257 | credentials (`~/.ssh`, `~/.aws`, and their kin). The repository's own | |
| 258 | `AGENTS.md` is read the way Claude Code always reads it, so a repository | |
| 259 | can steer its crew without touching the forge. | |
| 260 | ||
| 261 | The forge asks the host for one more binary than before: `claude`, found on | |
| 262 | `PATH` or at `FORGE_CLAUDE`. Authentication is the operator's — an | |
| 263 | `ANTHROPIC_API_KEY` in the forge's environment is handed to the run, or the | |
| 264 | machine's own Claude Code login is used when there is none. When neither | |
| 265 | works the crew page says so, in words, before anyone assigns anything. | |
| 266 | ||
| 267 | ## Configuration | |
| 268 | ||
| 269 | | variable | default | meaning | | |
| 270 | | ------------------------- | ------------------------------- | ------------------------------------------------- | | |
| 271 | | `FORGE_APPS` | `1` | `0` runs no apps and no crew | | |
| 272 | | `FORGE_APP_DOMAIN` | the host in `FORGE_URL`/`_HOST` | the domain app hostnames hang off | | |
| 273 | | `FORGE_APPS_CONCURRENCY` | `2` | deploys running their heavy steps at once | | |
| 274 | | `FORGE_CREW_CONCURRENCY` | `2` | agent runs at once | | |
| 275 | | `FORGE_CREW_BUDGET_USD` | `5` | spend per run unless the agent says otherwise | | |
| 276 | | `FORGE_CREW_TIMEOUT_MIN` | `45` | wall-clock limit of one agent run | | |
| 277 | | `FORGE_CREW_ALLOW_HOSTS` | — | hosts an agent's shell may reach, comma-separated | | |
| 278 | | `FORGE_CLAUDE` | `claude` on `PATH` | the Claude Code binary | | |
| 279 | | `FORGE_CLAUDE_CONFIG_DIR` | Claude Code's own | a `CLAUDE_CONFIG_DIR` for runs, when wanted | | |
| 280 | | `ANTHROPIC_API_KEY` | — | handed to agent runs when set | | |
| 281 | ||
| 282 | The public URL apps and agents are told about the forge is `FORGE_URL` when | |
| 283 | set, else `http://<FORGE_HOST>:<PORT>`; its scheme and port are what app | |
| 284 | URLs are built with, so a forge behind TLS on a wildcard domain sets | |
| 285 | `FORGE_URL=https://forge.example` and `FORGE_APP_DOMAIN=apps.example`. | |
| 286 | ||
| 287 | ## What is deliberately not here | |
| 288 | ||
| 289 | Containers. An app runs as the forge's own user, in its own directory, and | |
| 290 | nothing more separates it from the host than that. On the machine one | |
| 291 | person owns this is the honest, fast answer; a sandboxed runtime is the | |
| 292 | next layer, and the process boundary here is where it will attach. Custom | |
| 293 | domains, TLS termination, websocket proxying, and an approve-and-land gate | |
| 294 | are additions of the same kind: the shapes above hold, and a new column or | |
| 295 | listener carries each of them. |
docs/ARCHITECTURE.md +80 −5
| @@ -186,7 +186,7 @@ Two lessons Forgejo is still paying for, designed in up front: | ||
| 186 | 186 | blob line _content hash_ alongside file/line, so a force-push either |
| 187 | 187 | re-anchors it exactly or marks it outdated explicitly — never silently lands |
| 188 | 188 | it on the wrong line (Forgejo #21113). |
| 189 | - **A review verdict anchors to a commit.** An approval says "of *this* | |
| 189 | - **A review verdict anchors to a commit.** An approval says "of _this_ | |
| 190 | 190 | code", so `review` stores the `commit_oid` it was passed on and the merge |
| 191 | 191 | gate counts only verdicts on the current head. A push therefore dismisses a |
| 192 | 192 | stale approval by construction rather than by a background job somebody has |
| @@ -374,7 +374,7 @@ is anchored to a row of a diff (`comment`, `kind='review'`, with the content | ||
| 374 | 374 | hash above). A **verdict** — `approved`, `changes_requested`, `commented` — |
| 375 | 375 | is anchored to a commit, and it is what decides whether a change may land. |
| 376 | 376 | They are separate tables because they answer separate questions, and because |
| 377 | the merge gate has to be able to ask "is there an approval *of this head*", | |
| 377 | the merge gate has to be able to ask "is there an approval _of this head_", | |
| 378 | 378 | which only a row carrying a commit id can answer. |
| 379 | 379 | |
| 380 | 380 | Three rules, each mechanical rather than advisory, each with an adversarial |
| @@ -412,6 +412,74 @@ and everyone else's verdicts arrive on the next sync. `docs/ORIGINS.md` has | ||
| 412 | 412 | the dialect details, including the one that bites — GitHub takes the verb |
| 413 | 413 | `APPROVE` and answers with the noun `APPROVED`, and Forgejo takes the noun. |
| 414 | 414 | |
| 415 | ## Apps, runs, and the crew | |
| 416 | ||
| 417 | `docs/APPS.md` is the contract; this is why it is shaped as it is. | |
| 418 | ||
| 419 | An **app** is a repository that runs, on the forge's own port, by `Host`: | |
| 420 | `<slug>.<domain>` is production (the default branch), `<slug>--<n>.<domain>` | |
| 421 | the preview of pull `n`. The runtime (`apps/runtime.ts`) holds every | |
| 422 | environment in memory and rebuilds them all on boot from the default branch | |
| 423 | and the open pulls — a process is not state, and the same instinct that | |
| 424 | makes `core/reconcile.ts` rebuild the index from git makes a restart the | |
| 425 | recovery path rather than a special case. The app row holds what git cannot | |
| 426 | say (slug, access, the preview-data policy); how the app runs is | |
| 427 | `forge.json` at the deployed commit, read from the tree and never cached in | |
| 428 | a row, so a pull that changes how the app starts is previewed as it will | |
| 429 | run. | |
| 430 | ||
| 431 | Three decisions carry the previews. **A preview lives exactly as long as | |
| 432 | its pull is open**, and its data directory is created when the pull opens | |
| 433 | and removed when it closes — there is no orphan state to garbage-collect | |
| 434 | because the set of previews _is_ the set of open pulls. **Preview data is a | |
| 435 | copy of production's, never a view of it**: `cpSync` with `FICLONE_FORCE` | |
| 436 | clones blocks on APFS/XFS/btrfs and falls back to a byte copy elsewhere, | |
| 437 | after every SQLite database under the directory has been checkpointed (the | |
| 438 | pair `db` + `db-wal` copied mid-write would be two moments stitched | |
| 439 | together). And **a failed deploy leaves the previous deployment serving**: | |
| 440 | the new process must answer on its health path before the proxy switches | |
| 441 | to it, so a broken push never takes production down. | |
| 442 | ||
| 443 | A **run** is the durable record of work on a commit — a deploy, or an agent | |
| 444 | working an issue — with a log file the surfaces tail live and a row claimed | |
| 445 | `queued` → `running` by compare-and-swap. A deploy run _is_ the check on a | |
| 446 | pull; with `require_checks` on, `mergePull` reads the head's latest deploy | |
| 447 | run inside the merge, exactly as it reads the review verdict, and refuses | |
| 448 | `checks_required`. There is no separate checks table to drift from the | |
| 449 | runs that produced it. | |
| 450 | ||
| 451 | The **crew** is agents as users. An agent is a user with `is_agent` set (a | |
| 452 | flag beside `kind`, because `kind`'s CHECK cannot be widened without | |
| 453 | rebuilding `user` under every table that references it, and `toUser` reads | |
| 454 | it as `kind: "agent"` so nothing above the row knows): no password (an empty | |
| 455 | hash that no verifier accepts), no session, and one grant — `repo_crew`, | |
| 456 | read and write, never admin. Its credential is a token minted for a run | |
| 457 | with an expiry and deleted when the run ends; `verifyToken` checks the | |
| 458 | expiry on every use so a late sweep is never a late-working token. Two | |
| 459 | refusals make the guardrail mechanical: the pre-receive hook refuses an | |
| 460 | agent's push to the default branch whether or not it is protected, and | |
| 461 | `mergePull` refuses an agent actor. Everything the agent can do, it does | |
| 462 | through a pull a person merges. | |
| 463 | ||
| 464 | Work reaches an agent off the outbox with a sweep behind it, like every | |
| 465 | other consumer: an assignment starts a run; a person's comment, verdict, or | |
| 466 | line comment on the agent's pull starts a follow-up that resumes the same | |
| 467 | Claude Code session in the same clone; a failed deploy starts a bounded | |
| 468 | follow-up. The runner does the bookkeeping the agent should never have to | |
| 469 | get right — it pushes what was committed, opens the pull with the agent's | |
| 470 | last message as the description, or updates the one that exists — so the | |
| 471 | golden path is a property of the harness, not of the prompt. The prompt | |
| 472 | still writes it down (`crew/brief.ts`), because prompting the golden path | |
| 473 | and enforcing its edges are both cheaper than recovering from a detour. | |
| 474 | ||
| 475 | Claude Code runs as a child process the way git does — a binary on the | |
| 476 | host, spawned with an explicit environment (`childEnv`: path, home, the | |
| 477 | operator's user, a locale; never the forge's own variables), file tools | |
| 478 | confined to the clone, permissions passed as settings, no prompts, a spend | |
| 479 | budget, a deadline. Containment is the forge's, not the agent's; the agent | |
| 480 | is trusted to work, and the forge is built so that trust costs at most one | |
| 481 | run. | |
| 482 | ||
| 415 | 483 | ## Search, and where semantic search would go |
| 416 | 484 | |
| 417 | 485 | Search (`core/search.ts`, `actions/search.ts`, `GET /api/v1/search`) holds no |
| @@ -463,6 +531,13 @@ a pull in the same call), open counts on every repo row through grouped | ||
| 463 | 531 | queries, and one search across local repos, cached origin repos, issues and |
| 464 | 532 | code. |
| 465 | 533 | |
| 466 | Not in v1: CI runners (the events + API surface is designed so a runner | |
| 467 | integrates without core changes), an AI build crew (the harness — it drives the | |
| 468 | API and events already built), federation, package registry. | |
| 534 | Shipped since: apps (a repository that runs — production on the default | |
| 535 | branch, a preview per open pull with its own copy of the data, served by | |
| 536 | hostname from the forge's own port), runs (every deploy a durable, logged | |
| 537 | run, and the run the check a pull's merge can require), and the crew | |
| 538 | (agents as accounts with nothing but a run-scoped token, dispatched off the | |
| 539 | outbox, working in Claude Code and proposing through pulls a person merges). | |
| 540 | ||
| 541 | Not yet: containers around app processes, custom domains and websockets | |
| 542 | through the proxy, a gate that lands a pull on its own once review and | |
| 543 | checks agree, federation, package registry. |
docs/DESIGN.md +154 −68
| @@ -126,64 +126,72 @@ database). One class, `data-*` for variants. "Terminal" is the shape the same | ||
| 126 | 126 | component takes in a TUI — a note for the terminal thread to fill in, not a |
| 127 | 127 | specification. |
| 128 | 128 | |
| 129 | | component | web class | variants (`data-*`) | states | terminal equivalent | | |
| 130 | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------ | ------ | | |
| 131 | | **button** `btn()` | `.btn` | `variant=primary\|secondary\|outline\|ghost\|danger\|link`, `size=sm\|lg` | hover, active, `:focus-visible`, `[disabled]`, `[aria-busy]` | a labelled key or a highlighted action cell | | |
| 132 | | **icon button** `iconBtn()` | `.btn.btn-icon` | same | `aria-label` required | a single glyph with a status-bar hint | | |
| 133 | | **input / textarea / select** `field()` | `.input` | `size=sm`, `[aria-invalid]` | focus, `:user-invalid`, `[readonly]` | a line editor with an inline error row | | |
| 134 | | **form field** `field()` | `.field` (`label`, `.hint`, `.error`) | `data-invalid` | — | label line, value line, hint line | | |
| 135 | | **checkbox** | `.check` | — | native, `accent-color` themed | `[x]` / `[ ]` | | |
| 136 | | **badge** `badge()` / `stateBadge()` | `.badge` | `variant=secondary\|outline\|success\|danger\|merged\|attn`; `data-state=open\|closed`, `data-pr=merged` | — | a bracketed word in the state color | | |
| 137 | | **label chip** `labelChip()` | `.label` | color via `--label` (see §7) | — | a colored `‹name›` | | |
| 138 | | **author** `userLink()` / `userTag()` | `.byline` (`.byline-name`) | — | `userTag` inside anything already a link — see below | `@name` in the avatar hue | | |
| 139 | | **avatar** `avatar()` | `.avatar` | `size=md\|lg`, `data-h=0..11` | initials, deterministic hue | two initial letters in the same hue | | |
| 140 | | **count** | `.count` | — | glyph plus a tabular number | `⌸ 3` | | |
| 141 | | **stat pill** `pill()` | `.pill` | `data-tone=accent\|danger` | tabular | a parenthesised count | | |
| 142 | | **status dot** `dot()` | `.dot` | `data-tone=success\|danger\|attn\|merged` | — | `●` in the tone | | |
| 143 | | **sha** `sha()` | `.sha` | — | first 7 chars | the same 7 chars, dim | | |
| 144 | | **kbd** `kbd()` | `kbd` | — | — | a reverse-video key name | | |
| 145 | | **card** `card()` / `listCard()` | `.card` (`.card-head`, `.card-body`, `.card-foot`) | `data-tone=success\|danger\|merged\|attn` | — | a box with a title bar | | |
| 146 | | **list row** `row()` | `.row` (`.row-lead`, `.row-main`, `.row-title`, `.row-meta`, `.row-trail`, `.row-actions`) | `data-state=open\|closed\|merged\|draft` — colours the **lead glyph**, so a list filtered by state carries no badge repeating the filter | hover, focus, `data-nav`; `.row-meta` is one honest line: nowrap, ellipsis, `title` | one line: glyph, title, right-aligned meta | | |
| 147 | | **edit row** | `.edit-row` | — | one line on a desktop, stacked below 720px | a form line | | |
| 148 | | **colour field** | `.color-field` | — | native `<input type=color>` plus its hex; works with no script | a hex entry | | |
| 149 | | **table** | `.table` | `data-dense` | — | aligned columns | | |
| 150 | | **tabs** `tabs()` | `.tabs > a` | — | `aria-current="page"` + 2px `--fg` underline | a header row with the active item underlined | | |
| 151 | | **segmented control** `seg()` | `.seg > a` | — | `aria-current`, or `:has(:checked)` for radios | `‹ Open | Closed | All ›` | | |
| 152 | | **menu** `menu()` | `.menu` (`<details>`) | `data-align=start\|end` | open/closed | a popup list | | |
| 153 | | **dialog** | `.dialog` | `data-side=bottom` (sheet) | JS `showModal()`; every dialog has a real page behind it | a modal box | | |
| 154 | | **tooltip** | `[data-tip]` | `data-tip-side=bottom` | hover/focus; hidden on coarse pointers | a status-bar hint | | |
| 155 | | **toast / flash** `Flash` | `.toast` | `data-tone=success\|danger\|attn` | one per page load, cookie-driven | a one-line banner | | |
| 156 | | **command palette** | `.palette` | — | JS only; degrades to `/search` | a fuzzy-find overlay | | |
| 157 | | **breadcrumb** `crumbs()` | `.crumbs` | `.mono` | `aria-current` on the last | `a / b / c` | | |
| 158 | | **pagination** `pager()` | `.pager` | — | cursor links, absent ends | `‹ Newer Page 2 Older ›` | | |
| 159 | | **timeline item** `tlEvent()` / `tlComment()` | `.tl-item` | `data-kind=comment\|event`, `data-tone` | — | a card, or one dim line with a glyph | | |
| 160 | | **empty state** `empty()` | `.card.empty` | — | icon, one title, one sentence, **exactly one** action | the same three lines, centered | | |
| 161 | | **diff file** | `.diff-file` | `data-status`, `<details open>`, `.diff-viewed` | collapsed, viewed | a foldable file section | | |
| 162 | | **code block** | `.code` | `:target` / `[data-hl]` line highlight | — | a numbered gutter | | |
| 163 | | **prose** | `.prose` | — | 15px, 72ch measure | wrapped markdown | | |
| 164 | | **settings shell** `settingsLayout()` | `.settings` (`.settings-nav`, `.settings-main`) | — | sticky nav ≥860px, scroll strip below | a sidebar and a pane | | |
| 165 | | **repo header** `repoHeader()` | `.repo-head` | — | tab counts, Settings only for admins | a title line and a tab row | | |
| 166 | | **where strip** `whereStrip()` (`scope.ts`) | `.where > .where-place` | `data-state=reading\|present\|absent` — accent dot, success dot, faint dot | three places, always in this order and these words; a place is a link where one exists, and the third is the pin toggle | `● codeberg.org · ● workbench · ◆ this machine` | | |
| 167 | | **live-browse banner** `liveBanner()` | `.live-note` | — | only on a live browse, above the content, one sentence | one accent line under the tab row | | |
| 168 | | **where-it-lives block** | `.where-block` | — | origin, last fetch, Sync now and where pushes land, as one block in the About rail | the same four lines under a rule | | |
| 169 | | **mirror stages** `stageList()` | `.stages > li` | `data-state=done\|now\|failed\|todo` | the stage order is the markup order; a script updates it in place and a `<meta refresh>` does it without one | a checklist with `●`/`◐`/`○` | | |
| 170 | | **origin chip** (`chrome.ts`) | `.repo-origin > .origin-chip` | the forge's mark: `github` / `forgejo` / `gitea` | the older one-line form, still used off the repo header | one `mirrored from owner/name @ host` fact line | | |
| 171 | | **origin state badge** `badge()` | `.badge` | `success` healthy · `danger` needs re-auth · `attn` error | the error message sits under the row, never inside the badge | the same three words beside the row | | |
| 172 | | **imported author** `originTag()` | `.byline.origin-by` | the origin's mark instead of an avatar | never a link to `/name` — that would be a different person | `▲ login` | | |
| 173 | | **pin toggle** `whereRow()` | `.repo-where form.where-pin > .btn` | `outline` unpinned · `secondary` pinned | reads `Keep on my machine` / `On my machine ◆`; the strip's third place submits the same form by id, so a `<button>` can sit in a row of links; the wanted state rides the form, so a double submit is idempotent | `p` on a row, `P` on a repo home; `◆` marks a pinned row | | |
| 174 | | **device code** | `.device-code` | — | the one deliberately large type in the product: monospace, tabular, `user-select: all` | the code spaced out and centred in a card, with a spinner under it | | |
| 175 | | **sticky action** `stickyAction()` | `.sticky-action` | — | static ≥720px | a footer action bar | | |
| 176 | | **count group** `counts()` / `repoCounts()` | `.counts > .count` | — | icon plus a tabular number; **a zero is never drawn** — the absence is the same information | `⊙3 ⑂1` after the row title | | |
| 177 | | **file explorer** `explorerColumn()` | `.code-layout` (`.explorer-panel`, `.explorer`, `.ex-dir`, `.ex-file`, `.ex-more`) | — | the path's ancestors arrive `open`; the current file is `aria-current`; a folder off the spine holds one `.ex-more` link so it works with no script | a left pane the cursor walks | | |
| 178 | | **fuzzy finder** `finderDialog()` | `.dialog.palette.finder` | — | JS only; `t` opens it, the explorer beside it is the fallback | the file picker overlay | | |
| 179 | | **file editor** | `.editor-form` (`.editor-head`, `.editor-wrap`, `textarea.editor`, `.editor-gutter`, `.editor-commit`) | — | a plain `<textarea>` in a `<form>`; the gutter, tab key, auto-indent and Preview tab are all enhancement | a modal buffer with a commit prompt | | |
| 180 | | **branch row** | `.branch-row` (`.branch-div` → `.ahead` / `.behind`) | — | default first, `Default` and `Protected` badges, `↑ahead ↓behind` | one line per branch with the same arrows | | |
| 181 | | **code hit** | `.code-hit` (`.code-line`, `mark`) | — | `path:line` in mono, the matching line beneath it with the query marked | the same two lines, the match reversed | | |
| 182 | | **verdict badge** `verdictBadge()` | `.badge` / `.badge.verdict-stale` | `success` approved · `danger` changes requested · plain commented | a verdict passed before the latest push renders `.verdict-stale`: same words, no colour, and the line says "before the latest push". It is history, not a position — and an approval a push dismissed must never look current | `[✓ approved]` / `[! changes]`, dim when stale | | |
| 183 | | **reviews chip** | `.reviews-chip` | — | one line inside the merge box: `✓ 2 approvals · ! changes requested by bob · an approval is required`. It names people, because a name is somebody to go and talk to and a number is not | the same line under the merge verdict | | |
| 184 | | **review card** | `.card.review-card` (`.review-verdicts`) | — | one form, radios plus an optional body. Which radios appear is the rule made visible: no Approve for the author, none of the gating verdicts for a reader — both refused in `submitReview` too | a three-way prompt with a body field | | |
| 185 | | **review thread** | `.dc-review` (`.rc`, `.rc-resolved`, `.rc-hidden`, `.rc-resolve`) | — | open: the comments, then Resolve. Settled: **one line** — `✓ resolved by alice · 2h ago` — with the conversation behind a `<details>` and Unresolve beside it. Resolving is for getting an answered question out of the way of the unanswered ones | a folded thread with a `✓` header | | |
| 186 | | **propose card** | `fieldset.editor-target.propose` (`.propose-line`) | — | replaces "Where this lands" when the change is a contribution: the whole outcome in one sentence with **both** repository names, then branch, pull title and pull body. There is no "commit directly" radio, because there is no such option | the same sentence above the commit prompt | | |
| 129 | | component | web class | variants (`data-*`) | states | terminal equivalent | | |
| 130 | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------ | ------ | | |
| 131 | | **button** `btn()` | `.btn` | `variant=primary\|secondary\|outline\|ghost\|danger\|link`, `size=sm\|lg` | hover, active, `:focus-visible`, `[disabled]`, `[aria-busy]` | a labelled key or a highlighted action cell | | |
| 132 | | **icon button** `iconBtn()` | `.btn.btn-icon` | same | `aria-label` required | a single glyph with a status-bar hint | | |
| 133 | | **input / textarea / select** `field()` | `.input` | `size=sm`, `[aria-invalid]` | focus, `:user-invalid`, `[readonly]` | a line editor with an inline error row | | |
| 134 | | **form field** `field()` | `.field` (`label`, `.hint`, `.error`) | `data-invalid` | — | label line, value line, hint line | | |
| 135 | | **checkbox** | `.check` | — | native, `accent-color` themed | `[x]` / `[ ]` | | |
| 136 | | **badge** `badge()` / `stateBadge()` | `.badge` | `variant=secondary\|outline\|success\|danger\|merged\|attn`; `data-state=open\|closed`, `data-pr=merged` | — | a bracketed word in the state color | | |
| 137 | | **label chip** `labelChip()` | `.label` | color via `--label` (see §7) | — | a colored `‹name›` | | |
| 138 | | **author** `userLink()` / `userTag()` | `.byline` (`.byline-name`) | — | `userTag` inside anything already a link — see below | `@name` in the avatar hue | | |
| 139 | | **avatar** `avatar()` | `.avatar` | `size=md\|lg`, `data-h=0..11` | initials, deterministic hue | two initial letters in the same hue | | |
| 140 | | **count** | `.count` | — | glyph plus a tabular number | `⌸ 3` | | |
| 141 | | **stat pill** `pill()` | `.pill` | `data-tone=accent\|danger` | tabular | a parenthesised count | | |
| 142 | | **status dot** `dot()` | `.dot` | `data-tone=success\|danger\|attn\|merged` | — | `●` in the tone | | |
| 143 | | **sha** `sha()` | `.sha` | — | first 7 chars | the same 7 chars, dim | | |
| 144 | | **kbd** `kbd()` | `kbd` | — | — | a reverse-video key name | | |
| 145 | | **card** `card()` / `listCard()` | `.card` (`.card-head`, `.card-body`, `.card-foot`) | `data-tone=success\|danger\|merged\|attn` | — | a box with a title bar | | |
| 146 | | **list row** `row()` | `.row` (`.row-lead`, `.row-main`, `.row-title`, `.row-meta`, `.row-trail`, `.row-actions`) | `data-state=open\|closed\|merged\|draft` — colours the **lead glyph**, so a list filtered by state carries no badge repeating the filter | hover, focus, `data-nav`; `.row-meta` is one honest line: nowrap, ellipsis, `title` | one line: glyph, title, right-aligned meta | | |
| 147 | | **edit row** | `.edit-row` | — | one line on a desktop, stacked below 720px | a form line | | |
| 148 | | **colour field** | `.color-field` | — | native `<input type=color>` plus its hex; works with no script | a hex entry | | |
| 149 | | **table** | `.table` | `data-dense` | — | aligned columns | | |
| 150 | | **tabs** `tabs()` | `.tabs > a` | — | `aria-current="page"` + 2px `--fg` underline | a header row with the active item underlined | | |
| 151 | | **segmented control** `seg()` | `.seg > a` | — | `aria-current`, or `:has(:checked)` for radios | `‹ Open | Closed | All ›` | | |
| 152 | | **menu** `menu()` | `.menu` (`<details>`) | `data-align=start\|end` | open/closed | a popup list | | |
| 153 | | **dialog** | `.dialog` | `data-side=bottom` (sheet) | JS `showModal()`; every dialog has a real page behind it | a modal box | | |
| 154 | | **tooltip** | `[data-tip]` | `data-tip-side=bottom` | hover/focus; hidden on coarse pointers | a status-bar hint | | |
| 155 | | **toast / flash** `Flash` | `.toast` | `data-tone=success\|danger\|attn` | one per page load, cookie-driven | a one-line banner | | |
| 156 | | **command palette** | `.palette` | — | JS only; degrades to `/search` | a fuzzy-find overlay | | |
| 157 | | **breadcrumb** `crumbs()` | `.crumbs` | `.mono` | `aria-current` on the last | `a / b / c` | | |
| 158 | | **pagination** `pager()` | `.pager` | — | cursor links, absent ends | `‹ Newer Page 2 Older ›` | | |
| 159 | | **timeline item** `tlEvent()` / `tlComment()` | `.tl-item` | `data-kind=comment\|event`, `data-tone` | — | a card, or one dim line with a glyph | | |
| 160 | | **empty state** `empty()` | `.card.empty` | — | icon, one title, one sentence, **exactly one** action | the same three lines, centered | | |
| 161 | | **diff file** | `.diff-file` | `data-status`, `<details open>`, `.diff-viewed` | collapsed, viewed | a foldable file section | | |
| 162 | | **code block** | `.code` | `:target` / `[data-hl]` line highlight | — | a numbered gutter | | |
| 163 | | **prose** | `.prose` | — | 15px, 72ch measure | wrapped markdown | | |
| 164 | | **settings shell** `settingsLayout()` | `.settings` (`.settings-nav`, `.settings-main`) | — | sticky nav ≥860px, scroll strip below | a sidebar and a pane | | |
| 165 | | **repo header** `repoHeader()` | `.repo-head` | — | tab counts, Settings only for admins | a title line and a tab row | | |
| 166 | | **where strip** `whereStrip()` (`scope.ts`) | `.where > .where-place` | `data-state=reading\|present\|absent` — accent dot, success dot, faint dot | three places, always in this order and these words; a place is a link where one exists, and the third is the pin toggle | `● codeberg.org · ● workbench · ◆ this machine` | | |
| 167 | | **live-browse banner** `liveBanner()` | `.live-note` | — | only on a live browse, above the content, one sentence | one accent line under the tab row | | |
| 168 | | **where-it-lives block** | `.where-block` | — | origin, last fetch, Sync now and where pushes land, as one block in the About rail | the same four lines under a rule | | |
| 169 | | **mirror stages** `stageList()` | `.stages > li` | `data-state=done\|now\|failed\|todo` | the stage order is the markup order; a script updates it in place and a `<meta refresh>` does it without one | a checklist with `●`/`◐`/`○` | | |
| 170 | | **origin chip** (`chrome.ts`) | `.repo-origin > .origin-chip` | the forge's mark: `github` / `forgejo` / `gitea` | the older one-line form, still used off the repo header | one `mirrored from owner/name @ host` fact line | | |
| 171 | | **origin state badge** `badge()` | `.badge` | `success` healthy · `danger` needs re-auth · `attn` error | the error message sits under the row, never inside the badge | the same three words beside the row | | |
| 172 | | **imported author** `originTag()` | `.byline.origin-by` | the origin's mark instead of an avatar | never a link to `/name` — that would be a different person | `▲ login` | | |
| 173 | | **pin toggle** `whereRow()` | `.repo-where form.where-pin > .btn` | `outline` unpinned · `secondary` pinned | reads `Keep on my machine` / `On my machine ◆`; the strip's third place submits the same form by id, so a `<button>` can sit in a row of links; the wanted state rides the form, so a double submit is idempotent | `p` on a row, `P` on a repo home; `◆` marks a pinned row | | |
| 174 | | **device code** | `.device-code` | — | the one deliberately large type in the product: monospace, tabular, `user-select: all` | the code spaced out and centred in a card, with a spinner under it | | |
| 175 | | **sticky action** `stickyAction()` | `.sticky-action` | — | static ≥720px | a footer action bar | | |
| 176 | | **count group** `counts()` / `repoCounts()` | `.counts > .count` | — | icon plus a tabular number; **a zero is never drawn** — the absence is the same information | `⊙3 ⑂1` after the row title | | |
| 177 | | **file explorer** `explorerColumn()` | `.code-layout` (`.explorer-panel`, `.explorer`, `.ex-dir`, `.ex-file`, `.ex-more`) | — | the path's ancestors arrive `open`; the current file is `aria-current`; a folder off the spine holds one `.ex-more` link so it works with no script | a left pane the cursor walks | | |
| 178 | | **fuzzy finder** `finderDialog()` | `.dialog.palette.finder` | — | JS only; `t` opens it, the explorer beside it is the fallback | the file picker overlay | | |
| 179 | | **file editor** | `.editor-form` (`.editor-head`, `.editor-wrap`, `textarea.editor`, `.editor-gutter`, `.editor-commit`) | — | a plain `<textarea>` in a `<form>`; the gutter, tab key, auto-indent and Preview tab are all enhancement | a modal buffer with a commit prompt | | |
| 180 | | **branch row** | `.branch-row` (`.branch-div` → `.ahead` / `.behind`) | — | default first, `Default` and `Protected` badges, `↑ahead ↓behind` | one line per branch with the same arrows | | |
| 181 | | **code hit** | `.code-hit` (`.code-line`, `mark`) | — | `path:line` in mono, the matching line beneath it with the query marked | the same two lines, the match reversed | | |
| 182 | | **verdict badge** `verdictBadge()` | `.badge` / `.badge.verdict-stale` | `success` approved · `danger` changes requested · plain commented | a verdict passed before the latest push renders `.verdict-stale`: same words, no colour, and the line says "before the latest push". It is history, not a position — and an approval a push dismissed must never look current | `[✓ approved]` / `[! changes]`, dim when stale | | |
| 183 | | **reviews chip** | `.reviews-chip` | — | one line inside the merge box: `✓ 2 approvals · ! changes requested by bob · an approval is required`. It names people, because a name is somebody to go and talk to and a number is not | the same line under the merge verdict | | |
| 184 | | **review card** | `.card.review-card` (`.review-verdicts`) | — | one form, radios plus an optional body. Which radios appear is the rule made visible: no Approve for the author, none of the gating verdicts for a reader — both refused in `submitReview` too | a three-way prompt with a body field | | |
| 185 | | **review thread** | `.dc-review` (`.rc`, `.rc-resolved`, `.rc-hidden`, `.rc-resolve`) | — | open: the comments, then Resolve. Settled: **one line** — `✓ resolved by alice · 2h ago` — with the conversation behind a `<details>` and Unresolve beside it. Resolving is for getting an answered question out of the way of the unanswered ones | a folded thread with a `✓` header | | |
| 186 | | **propose card** | `fieldset.editor-target.propose` (`.propose-line`) | — | replaces "Where this lands" when the change is a contribution: the whole outcome in one sentence with **both** repository names, then branch, pull title and pull body. There is no "commit directly" radio, because there is no such option | the same sentence above the commit prompt | | |
| 187 | | **run state badge** `runStateBadge()` | `.badge` | `queued` · `running` (attn) · `ok` (success: _Passed_, or _Done_ for an agent run) · `failed` (danger) · `cancelled` (outline) | the one badge a check wears on a pull, a run wears in a list, and a log page wears in its head; the log page's script repaints it on `done` | `[✓ passed]` / `[✗ failed]` / `[· running]` | | |
| 188 | | **env state badge** `envStateBadge()` | `.badge` | `stopped` (outline) · `deploying` (attn) · `running` (success) · `failed` (danger) | what production, or a preview, is doing now; `envTone()` is the same answer as a `dot()` for a list row | the same word in the same tone after the environment's name | | |
| 189 | | **app strip** | `.app-strip` | — | one line under the repo header of a repository that runs: env state badge, `sha`, the production URL as a link **through `…/app/production/open`** (never the bare hostname — a private app would 404), the preview count, App | one line under the where strip: `● running abc1234 · hello.localhost` | | |
| 190 | | **environment card** | `.card.env-card` (`.env-facts`, `.env-error`) | `data-tone=danger` when failed | name and state in the head; commit, uptime and the run in the body; a failed deploy's reason on its own line; Open / Redeploy / Stop in the foot — a card and not a row so an error has a whole line to be read on | a box per environment with the same three lines | | |
| 191 | | **checks line** | `.checks-line` (`.ok`, `.bad`, `.now`) | — | inside the merge box, in the reviews chip's voice: `✓ Checks passed · deploy abc1234 · a passing deploy is required`. Rendered only where the repository runs as an app or requires checks; a merge box gains _Waiting on checks_ | the same line under the review line | | |
| 192 | | **run log** | `pre.run-log` | `data-stream` while the run is going | the log as written, wrapped so a phone never scrolls sideways; a script tails the SSE stream and follows the bottom until the reader scrolls up, a `<meta refresh>` does it without one | a scrolling pane that follows the tail | | |
| 193 | | **assignee card** | `.card` with an `.edit-row` form | — | who is on it, then one select and one button for a writer. People first, the crew last and marked `(agent)`, because assigning an agent is what starts it working and the choice is not the same kind of choice | a picker with people, then agents | | |
| 194 | | **inbox** (`site.ts`) | two `listCard`s above the repositories | — | _Needs you_ (pulls the crew opened in repositories you can write, each with its check) and _On you_ (what is assigned to you). Absent when empty: a card saying nothing is a zero drawn | the same two panes on the dashboard | | |
| 187 | 195 | |
| 188 | 196 | Two things are deliberately **not** in the vocabulary: skeleton loaders (the |
| 189 | 197 | server renders whole pages — there is nothing to skeletonise) and a custom |
| @@ -286,6 +294,48 @@ propose, `status.reviews` for the gate, `canWrite` plus thread authorship for | ||
| 286 | 294 | resolve — so a button that is absent and a call that is refused are the same |
| 287 | 295 | fact said twice, and never two different facts. |
| 288 | 296 | |
| 297 | ### 5c. Apps, runs and the crew | |
| 298 | ||
| 299 | `docs/APPS.md` is the contract; these are its units on a page, and the same | |
| 300 | rule holds: the page never decides. Six of them: | |
| 301 | ||
| 302 | **New app** — `/apps/new`, one form: a name, a sentence about what it should | |
| 303 | do, visibility, and an agent from the crew when there is one. One POST makes | |
| 304 | the repository with a working app in it, makes it an app, starts production, | |
| 305 | and — with a sentence and an agent — opens issue #1 assigned. The flash names | |
| 306 | the production URL and the agent. A forge with no agents says so on the form | |
| 307 | in one line and offers the same button without the select. | |
| 308 | ||
| 309 | **Run as an app** — on a repository's settings, the App section: enable with a | |
| 310 | slug, an access choice and the preview-data policy; edit them; disable behind | |
| 311 | a confirm page. The app strip (§5) appears on the repository the moment it is | |
| 312 | on. | |
| 313 | ||
| 314 | **Open** — never the bare hostname. Every link to an environment goes through | |
| 315 | `…/app/:env/open`, which checks `canRead` and answers with the URL — carrying a | |
| 316 | grant for a private app — so the same link works for a public app and a | |
| 317 | private one and a stranger gets `not_found` from the forge rather than a | |
| 318 | guess at a hostname. | |
| 319 | ||
| 320 | **Redeploy / Stop** — two POSTs on the app page, writers only, one row per | |
| 321 | environment; a stop keeps the data (a redeploy brings it back), a stopped | |
| 322 | environment shows the honest word and the run that stopped it. | |
| 323 | ||
| 324 | **Assign** — the assignee card on an issue and a pull: nobody, then people | |
| 325 | who can read the repository, then the crew marked as agents. Assigning an | |
| 326 | agent is the dispatch, so the select says so beside the crew. An agent's own | |
| 327 | pull opens already assigned to it. | |
| 328 | ||
| 329 | **Cancel a run** — one POST on the run page while it is unfinished; the log | |
| 330 | keeps everything up to the cancel and the badge says `cancelled`. | |
| 331 | ||
| 332 | The merge box gains a seventh state, _Waiting on checks_, when | |
| 333 | `require_checks` is on and the head's deploy is not green; the refusal is | |
| 334 | `mergePull`'s (`checks_required`) and the chip links the run. A pull's | |
| 335 | **Preview** card and its **Checks** line read the same run rows a | |
| 336 | `runStateBadge` does elsewhere — a check is a deploy, never a separate | |
| 337 | object that can drift. | |
| 338 | ||
| 289 | 339 | --- |
| 290 | 340 | |
| 291 | 341 | ## 6. Keyboard |
| @@ -556,9 +606,11 @@ the binding itself so a label can never drift from the action. A list with no | ||
| 556 | 606 | `hitList` is a bug, not a design decision: "everything you can see is |
| 557 | 607 | something you can click" is the whole claim. |
| 558 | 608 | |
| 559 | ### 6·0a. The dashboard's four panes | |
| 609 | ### 6·0a. The dashboard's panes | |
| 560 | 610 | |
| 561 | `Repositories`, `Recent activity`, `On your machine`, `Organizations`. Each is | |
| 611 | `Apps` (only when there are any; `a` opens the apps screen regardless), | |
| 612 | `Repositories`, `Recent activity`, `On you`, `On your machine`, | |
| 613 | `Organizations`. Each is | |
| 562 | 614 | a pane with its own cursor: `tab` / `shift+tab` cycles, the focused one wears |
| 563 | 615 | the accent frame, `j`/`k` move inside it and `enter` opens what it is pointing |
| 564 | 616 | at — an activity row opens **the issue, pull or repository it is about**, a |
| @@ -714,7 +766,6 @@ arrangement got wrong: | ||
| 714 | 766 | | `u` | — | discard this tab's changes | |
| 715 | 767 | | `v` | — | the file's other view (below) | |
| 716 | 768 | |
| 717 | ||
| 718 | 769 | And the keys the whole workspace answers, from either pane: `]` `[` and `1`–`9` |
| 719 | 770 | move between tabs, `x` / `X` close one or the others, `c` commits, `b` lists |
| 720 | 771 | branches and tags, `B` creates a branch, `t` (or `ctrl+p`) is the fuzzy finder, |
| @@ -737,11 +788,11 @@ by accident, and the tab you mean to close is the one you are looking at. | ||
| 737 | 788 | **Two views, one key.** Some files have two honest ways of being seen, and `v` |
| 738 | 789 | is the toggle for all of them: |
| 739 | 790 | |
| 740 | | file | opens as | `v` shows | | |
| 741 | | ------------------------ | --------------------------- | -------------------------- | | |
| 742 | | `*.md`, `README*` | rendered prose (`¶`) | the source, highlighted | | |
| 743 | | `*.png`, `*.jpg`, … | the picture (§6·0c) | format · geometry · size | | |
| 744 | | everything else | code | (nothing else to show) | | |
| 791 | | file | opens as | `v` shows | | |
| 792 | | ------------------- | -------------------- | ------------------------ | | |
| 793 | | `*.md`, `README*` | rendered prose (`¶`) | the source, highlighted | | |
| 794 | | `*.png`, `*.jpg`, … | the picture (§6·0c) | format · geometry · size | | |
| 795 | | everything else | code | (nothing else to show) | | |
| 745 | 796 | |
| 746 | 797 | Prose opens **rendered**, because that is what a README is for and it is what |
| 747 | 798 | the repo card always did — but the rendered view is a view, not a mode: `j`/`k` |
| @@ -852,7 +903,7 @@ your fork: | ||
| 852 | 903 | |
| 853 | 904 | |
| 854 | 905 | `contribute` does the rest in one call: fork if a fork is needed, branch from |
| 855 | the *upstream's* base rather than a stale copy of it, commit into the fork, | |
| 906 | the _upstream's_ base rather than a stale copy of it, commit into the fork, | |
| 856 | 907 | push it there, and open the pull upstream with the `owner:branch` head form |
| 857 | 908 | both forges take. The toast reads `opened pull #12 on octo/widgets`, and when |
| 858 | 909 | we hold a mirror of that upstream the imported pull is pushed on the stack so |
| @@ -863,7 +914,7 @@ open". While such a workspace is open the status row says | ||
| 863 | 914 | `proposing to octo/widgets via your fork`, on every frame, so nobody has to |
| 864 | 915 | press `c` to find out where their change is going. |
| 865 | 916 | |
| 866 | A repository the origin says you *may* push to keeps the ordinary commit | |
| 917 | A repository the origin says you _may_ push to keeps the ordinary commit | |
| 867 | 918 | dialog: on a mirror that is `editFiles` plus push-through, which is |
| 868 | 919 | `contribute`'s own `via: "direct"` path by another name. |
| 869 | 920 | |
| @@ -978,6 +1029,41 @@ there is no lifecycle to get wrong. A burst is coalesced into one refresh. | ||
| 978 | 1029 | |
| 979 | 1030 | --- |
| 980 | 1031 | |
| 1032 | ### 6g. Apps, runs and the crew | |
| 1033 | ||
| 1034 | The terminal's half of §5c. `:apps`, `:new app`, `:runs` and `:app` (on a | |
| 1035 | repository) and `:crew` (admins) reach everything from the palette. | |
| 1036 | ||
| 1037 | - **Apps** — every app you can see: a state dot and word, the production URL, | |
| 1038 | the preview count. `enter` opens the app; `n` is New app as a flow of | |
| 1039 | prompts (name, a sentence, visibility, an agent when any exist) that lands on | |
| 1040 | the repository with a toast naming the URL and the agent. | |
| 1041 | - **App** — environments production-first, then previews by pull number, each | |
| 1042 | with state, commit, since, and on failure the error in words. `o` opens the | |
| 1043 | environment through `openEnvUrl` (an OSC 8 link plus an OSC 52 copy, with the | |
| 1044 | honest toast over SSH); `enter` opens its run's log; `r` redeploys, `s` stops | |
| 1045 | behind a confirm; `l` lists the runs. Live on the repository's `run.*` and | |
| 1046 | `app.*` events. | |
| 1047 | - **Runs** and **Run** — the list names each run's conversation (`#n title`) | |
| 1048 | through one grouped read; the run screen subscribes to the log before it | |
| 1049 | reads it (both synchronous, so no line is missed or doubled), follows the | |
| 1050 | tail until you scroll, `G` re-follows, `x` cancels for writers. | |
| 1051 | - **Pull** — a preview line when the repository is an app (`O` opens it), a | |
| 1052 | checks row in the merge box and a _waiting on checks_ state with the refusal | |
| 1053 | in words on `m`, `a` to assign (nobody, people, then the crew with the bot | |
| 1054 | glyph on a Nerd Font), and a Crew tab of the agent runs about it. | |
| 1055 | - **Issue** — `a` to assign; a Crew card when an agent has runs, `tab` moves | |
| 1056 | focus to it and `enter` opens the run. | |
| 1057 | - **Settings** — the App tab (enable with slug, access and preview data; edit; | |
| 1058 | `D` to stop running as an app behind a confirm), `C` for require checks on | |
| 1059 | General, the Crew tab (`a` adds an agent from the forge's, `d` removes). | |
| 1060 | - **Crew** (admins, from Account with `c`) — the roster with model and budget, | |
| 1061 | and the first line says whether Claude Code can run at all. `n` creates | |
| 1062 | through five prompts, `enter` edits a field, `d` retires behind a confirm. | |
| 1063 | ||
| 1064 | A guest, or a non-admin at the crew screen, is told so in words; nothing here | |
| 1065 | is a way around the actions' answers. | |
| 1066 | ||
| 981 | 1067 | ## 7. Two rules that are security boundaries |
| 982 | 1068 | |
| 983 | 1069 | **Label colors.** Inline `style=""` is blocked by the CSP — nonces apply to |
src/actions/apps.ts added +366
| @@ -0,0 +1,366 @@ | ||
| 1 | /** | |
| 2 | * App actions: make a repository an app, make a new app from nothing, | |
| 3 | * change how it is served, take it down, and redeploy or stop an | |
| 4 | * environment. Admin over the repository turns an app on and off and | |
| 5 | * changes its settings; a writer may redeploy or stop; anyone who can read | |
| 6 | * the repository may look at it and open its environments. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { appsRuntime, type EnvStatus } from "../apps/runtime.ts"; | |
| 11 | import { grantUrl } from "../apps/proxy.ts"; | |
| 12 | import { scaffoldFiles } from "../apps/scaffold.ts"; | |
| 13 | import { | |
| 14 | isEnvName, | |
| 15 | previewEnv, | |
| 16 | type EnvName, | |
| 17 | type PreviewData, | |
| 18 | } from "../apps/data.ts"; | |
| 19 | import { | |
| 20 | createApp as insertApp, | |
| 21 | deleteApp as removeApp, | |
| 22 | getAppByRepo, | |
| 23 | slugFor, | |
| 24 | updateApp as patchApp, | |
| 25 | type AppAccess, | |
| 26 | type AppRecord, | |
| 27 | type CreateAppError, | |
| 28 | type UpdateAppError, | |
| 29 | } from "../core/apps.ts"; | |
| 30 | import { addCrew, getAgentByName } from "../core/agents.ts"; | |
| 31 | import type { Issue } from "../core/issues.ts"; | |
| 32 | import { getPull } from "../core/pulls.ts"; | |
| 33 | import type { Repo } from "../core/repos.ts"; | |
| 34 | import type { Run } from "../core/runs.ts"; | |
| 35 | import type { User } from "../core/users.ts"; | |
| 36 | import { resolveOid } from "../git/merge.ts"; | |
| 37 | import { repoDir } from "../git/repos.ts"; | |
| 38 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 39 | import { | |
| 40 | requireReader, | |
| 41 | requireRepoAdmin, | |
| 42 | requireUser, | |
| 43 | requireWriter, | |
| 44 | type AccessError, | |
| 45 | } from "./access.ts"; | |
| 46 | import { assignIssue, openIssue } from "./issues.ts"; | |
| 47 | import { | |
| 48 | commitToBranch, | |
| 49 | createRepo, | |
| 50 | type CreateRepoActionError, | |
| 51 | } from "./repos.ts"; | |
| 52 | ||
| 53 | export interface AppSettingsInput { | |
| 54 | slug?: string; | |
| 55 | access?: AppAccess; | |
| 56 | previewData?: PreviewData; | |
| 57 | } | |
| 58 | ||
| 59 | /** Turn an existing repository into an app. */ | |
| 60 | export function enableApp( | |
| 61 | app: App, | |
| 62 | actor: User | undefined, | |
| 63 | repo: Repo, | |
| 64 | input: AppSettingsInput = {}, | |
| 65 | ): Result<AppRecord, "not_found" | CreateAppError> { | |
| 66 | const admin = requireRepoAdmin(app, actor, repo); | |
| 67 | if (!admin.ok) return admin; | |
| 68 | const created = insertApp(app.db, { | |
| 69 | repoId: repo.id, | |
| 70 | slug: input.slug ?? slugFor(repo.name), | |
| 71 | access: input.access ?? repo.visibility, | |
| 72 | ...(input.previewData === undefined | |
| 73 | ? {} | |
| 74 | : { previewData: input.previewData }), | |
| 75 | }); | |
| 76 | if (!created.ok) return created; | |
| 77 | app.bus.append({ | |
| 78 | kind: "app.created", | |
| 79 | repoId: repo.id, | |
| 80 | actorId: admin.value.id, | |
| 81 | payload: { slug: created.value.slug }, | |
| 82 | }); | |
| 83 | return created; | |
| 84 | } | |
| 85 | ||
| 86 | export function updateAppSettings( | |
| 87 | app: App, | |
| 88 | actor: User | undefined, | |
| 89 | repo: Repo, | |
| 90 | patch: AppSettingsInput, | |
| 91 | ): Result<AppRecord, "not_found" | UpdateAppError> { | |
| 92 | const admin = requireRepoAdmin(app, actor, repo); | |
| 93 | if (!admin.ok) return admin; | |
| 94 | const record = getAppByRepo(app.db, repo.id); | |
| 95 | if (!record) return err("not_found"); | |
| 96 | const updated = patchApp(app.db, record.id, patch); | |
| 97 | if (!updated.ok) return updated; | |
| 98 | if (!updated.value) return err("not_found"); | |
| 99 | app.bus.append({ | |
| 100 | kind: "app.updated", | |
| 101 | repoId: repo.id, | |
| 102 | actorId: admin.value.id, | |
| 103 | payload: { slug: updated.value.slug, access: updated.value.access }, | |
| 104 | }); | |
| 105 | return ok(updated.value); | |
| 106 | } | |
| 107 | ||
| 108 | /** Stop serving the repository as an app. Data and checkouts are removed. */ | |
| 109 | export function disableApp( | |
| 110 | app: App, | |
| 111 | actor: User | undefined, | |
| 112 | repo: Repo, | |
| 113 | ): Result<void, "not_found"> { | |
| 114 | const admin = requireRepoAdmin(app, actor, repo); | |
| 115 | if (!admin.ok) return admin; | |
| 116 | const record = getAppByRepo(app.db, repo.id); | |
| 117 | if (!record) return err("not_found"); | |
| 118 | removeApp(app.db, record.id); | |
| 119 | app.bus.append({ | |
| 120 | kind: "app.deleted", | |
| 121 | repoId: repo.id, | |
| 122 | actorId: admin.value.id, | |
| 123 | payload: { slug: record.slug }, | |
| 124 | }); | |
| 125 | return ok(); | |
| 126 | } | |
| 127 | ||
| 128 | export interface NewAppInput { | |
| 129 | name: string; | |
| 130 | /** The actor, or an org they can write to. */ | |
| 131 | owner?: string; | |
| 132 | /** What the app should become; becomes the first issue when given. */ | |
| 133 | description?: string; | |
| 134 | /** @default "private" */ | |
| 135 | visibility?: "public" | "private"; | |
| 136 | /** An agent to put on the crew and hand the first issue to. */ | |
| 137 | agent?: string; | |
| 138 | } | |
| 139 | ||
| 140 | export interface NewAppResult { | |
| 141 | readonly repo: Repo; | |
| 142 | readonly app: AppRecord; | |
| 143 | /** The first issue, when a description was given and an agent named. */ | |
| 144 | readonly issue?: Issue; | |
| 145 | } | |
| 146 | ||
| 147 | export type NewAppError = | |
| 148 | | CreateRepoActionError | |
| 149 | | CreateAppError | |
| 150 | | "scaffold_failed" | |
| 151 | | "no_such_agent"; | |
| 152 | ||
| 153 | /** | |
| 154 | * The golden path's first step, as one call: a repository with a working | |
| 155 | * app in it, an app record so production starts, and — when asked — the | |
| 156 | * first issue assigned to an agent on the crew. | |
| 157 | */ | |
| 158 | export async function newApp( | |
| 159 | app: App, | |
| 160 | actor: User | undefined, | |
| 161 | input: NewAppInput, | |
| 162 | ): Promise<Result<NewAppResult, NewAppError>> { | |
| 163 | const user = requireUser(actor); | |
| 164 | if (!user.ok) return user; | |
| 165 | const agent = | |
| 166 | input.agent === undefined || input.agent === "" | |
| 167 | ? undefined | |
| 168 | : getAgentByName(app.db, input.agent); | |
| 169 | if (input.agent && !agent) return err("no_such_agent"); | |
| 170 | ||
| 171 | const repo = await createRepo(app, user.value, { | |
| 172 | name: input.name, | |
| 173 | visibility: input.visibility ?? "private", | |
| 174 | description: input.description ?? "", | |
| 175 | ...(input.owner === undefined ? {} : { owner: input.owner }), | |
| 176 | }); | |
| 177 | if (!repo.ok) return repo; | |
| 178 | const committed = await commitToBranch(app, user.value, repo.value, { | |
| 179 | message: `Start ${input.name}`, | |
| 180 | files: scaffoldFiles({ | |
| 181 | name: input.name, | |
| 182 | description: input.description ?? "", | |
| 183 | }), | |
| 184 | }); | |
| 185 | if (!committed.ok) return err("scaffold_failed"); | |
| 186 | const record = enableApp(app, user.value, repo.value, {}); | |
| 187 | // The creator administers the repository they just made, so `not_found` | |
| 188 | // here can only mean the row vanished under us — a failed scaffold. | |
| 189 | if (!record.ok) | |
| 190 | return err(record.error === "not_found" ? "scaffold_failed" : record.error); | |
| 191 | ||
| 192 | let issue: Issue | undefined; | |
| 193 | if (agent) { | |
| 194 | addCrew(app.db, repo.value.id, agent.user.id, user.value.id); | |
| 195 | app.bus.append({ | |
| 196 | kind: "crew.added", | |
| 197 | repoId: repo.value.id, | |
| 198 | actorId: user.value.id, | |
| 199 | payload: { agent: agent.user.username }, | |
| 200 | }); | |
| 201 | if (input.description && input.description.trim() !== "") { | |
| 202 | const opened = openIssue(app, user.value, repo.value, { | |
| 203 | title: `Build ${input.name}`, | |
| 204 | body: input.description.trim(), | |
| 205 | }); | |
| 206 | if (opened.ok) { | |
| 207 | const assigned = assignIssue( | |
| 208 | app, | |
| 209 | user.value, | |
| 210 | repo.value, | |
| 211 | opened.value, | |
| 212 | agent.user, | |
| 213 | ); | |
| 214 | issue = assigned.ok ? assigned.value : opened.value; | |
| 215 | } | |
| 216 | } | |
| 217 | } | |
| 218 | return ok({ | |
| 219 | repo: repo.value, | |
| 220 | app: record.value, | |
| 221 | ...(issue === undefined ? {} : { issue }), | |
| 222 | }); | |
| 223 | } | |
| 224 | ||
| 225 | export interface AppView { | |
| 226 | readonly app: AppRecord; | |
| 227 | readonly environments: EnvStatus[]; | |
| 228 | /** Whether a runtime is serving at all (`FORGE_APPS=0` means no). */ | |
| 229 | readonly serving: boolean; | |
| 230 | /** `ssh -p 2222 notes@host` — how to reach production's terminal, when SSH is on. */ | |
| 231 | readonly ssh: string | undefined; | |
| 232 | } | |
| 233 | ||
| 234 | /** | |
| 235 | * The command that opens an environment's terminal interface, or undefined | |
| 236 | * when the forge runs without SSH. The login name is the app (`notes`) or | |
| 237 | * the preview (`notes--12`); the key decides who you are. | |
| 238 | */ | |
| 239 | export function sshCommandFor( | |
| 240 | app: App, | |
| 241 | record: AppRecord, | |
| 242 | env: EnvName, | |
| 243 | ): string | undefined { | |
| 244 | const port = app.config.sshPort; | |
| 245 | if (port === undefined) return undefined; | |
| 246 | const host = app.config.host ?? "localhost"; | |
| 247 | const login = | |
| 248 | env === "production" ? record.slug : `${record.slug}--${env.slice(5)}`; | |
| 249 | return `ssh${port === 22 ? "" : ` -p ${port}`} ${login}@${host}`; | |
| 250 | } | |
| 251 | ||
| 252 | /** What is running, for anyone who can see the repository. */ | |
| 253 | export function appView( | |
| 254 | app: App, | |
| 255 | actor: User | undefined, | |
| 256 | repo: Repo, | |
| 257 | ): Result<AppView, "not_found"> { | |
| 258 | const reader = requireReader(app, actor, repo); | |
| 259 | if (!reader.ok) return reader; | |
| 260 | const record = getAppByRepo(app.db, repo.id); | |
| 261 | if (!record) return err("not_found"); | |
| 262 | const runtime = appsRuntime(app); | |
| 263 | return ok({ | |
| 264 | app: record, | |
| 265 | environments: runtime === undefined ? [] : runtime.status(record), | |
| 266 | serving: runtime !== undefined, | |
| 267 | ssh: sshCommandFor(app, record, "production"), | |
| 268 | }); | |
| 269 | } | |
| 270 | ||
| 271 | export type EnvError = "not_found" | "no_runtime" | "no_such_env"; | |
| 272 | ||
| 273 | /** The URL to open an environment at — via a grant for a private app. */ | |
| 274 | export function openEnvUrl( | |
| 275 | app: App, | |
| 276 | actor: User | undefined, | |
| 277 | repo: Repo, | |
| 278 | env: string, | |
| 279 | ): Result<string, EnvError | "unauthorized"> { | |
| 280 | const reader = requireReader(app, actor, repo); | |
| 281 | if (!reader.ok) return reader; | |
| 282 | const record = getAppByRepo(app.db, repo.id); | |
| 283 | if (!record) return err("not_found"); | |
| 284 | if (!isEnvName(env)) return err("no_such_env"); | |
| 285 | const runtime = appsRuntime(app); | |
| 286 | if (runtime === undefined) return err("no_runtime"); | |
| 287 | if (record.access === "private") { | |
| 288 | // A grant names a person; anonymous visitors get the same answer a | |
| 289 | // private repo gives them. | |
| 290 | if (actor === undefined) return err("unauthorized"); | |
| 291 | return ok(grantUrl(runtime, { app: record, env })); | |
| 292 | } | |
| 293 | return ok(runtime.urlFor(record, env)); | |
| 294 | } | |
| 295 | ||
| 296 | /** Deploy an environment again at its current commit. */ | |
| 297 | export async function redeployEnv( | |
| 298 | app: App, | |
| 299 | actor: User | undefined, | |
| 300 | repo: Repo, | |
| 301 | env: string, | |
| 302 | ): Promise< | |
| 303 | Result<Run | undefined, AccessError | EnvError | "nothing_to_deploy"> | |
| 304 | > { | |
| 305 | const writer = requireWriter(app, actor, repo); | |
| 306 | if (!writer.ok) return writer; | |
| 307 | const record = getAppByRepo(app.db, repo.id); | |
| 308 | if (!record) return err("not_found"); | |
| 309 | if (!isEnvName(env)) return err("no_such_env"); | |
| 310 | const runtime = appsRuntime(app); | |
| 311 | if (runtime === undefined) return err("no_runtime"); | |
| 312 | const commit = await commitFor(app, repo, env); | |
| 313 | if (commit === undefined) return err("nothing_to_deploy"); | |
| 314 | return ok( | |
| 315 | runtime.requestDeploy( | |
| 316 | record, | |
| 317 | repo, | |
| 318 | env, | |
| 319 | commit, | |
| 320 | "redeploy", | |
| 321 | writer.value.id, | |
| 322 | ), | |
| 323 | ); | |
| 324 | } | |
| 325 | ||
| 326 | /** Stop an environment's process, keeping its data. A redeploy brings it back. */ | |
| 327 | export async function stopEnv( | |
| 328 | app: App, | |
| 329 | actor: User | undefined, | |
| 330 | repo: Repo, | |
| 331 | env: string, | |
| 332 | ): Promise<Result<void, AccessError | EnvError>> { | |
| 333 | const writer = requireWriter(app, actor, repo); | |
| 334 | if (!writer.ok) return writer; | |
| 335 | const record = getAppByRepo(app.db, repo.id); | |
| 336 | if (!record) return err("not_found"); | |
| 337 | if (!isEnvName(env)) return err("no_such_env"); | |
| 338 | const runtime = appsRuntime(app); | |
| 339 | if (runtime === undefined) return err("no_runtime"); | |
| 340 | await runtime.stopEnv(record, env); | |
| 341 | app.bus.append({ | |
| 342 | kind: "app.stopped", | |
| 343 | repoId: repo.id, | |
| 344 | actorId: writer.value.id, | |
| 345 | payload: { env }, | |
| 346 | }); | |
| 347 | return ok(); | |
| 348 | } | |
| 349 | ||
| 350 | async function commitFor( | |
| 351 | app: App, | |
| 352 | repo: Repo, | |
| 353 | env: EnvName, | |
| 354 | ): Promise<string | undefined> { | |
| 355 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 356 | if (env === "production") | |
| 357 | return resolveOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 358 | const pull = getPull(app.db, repo.id, Number(env.slice(5))); | |
| 359 | if (!pull || pull.state !== "open") return undefined; | |
| 360 | const ref = pull.crossRepo | |
| 361 | ? `refs/pull/${pull.number}/head` | |
| 362 | : `refs/heads/${pull.headBranch}`; | |
| 363 | return (await resolveOid(dir, ref)) ?? (pull.headOid || undefined); | |
| 364 | } | |
| 365 | ||
| 366 | export { previewEnv }; |
src/actions/crew.ts added +228
| @@ -0,0 +1,228 @@ | ||
| 1 | /** | |
| 2 | * Crew actions: create, change and retire agents (a forge admin's job — an | |
| 3 | * agent is an account that acts), and put agents on or take them off a | |
| 4 | * repository's crew (a repository admin's job). Every agent is a user, so | |
| 5 | * everything else about it — its pulls, its comments, its tokens — is | |
| 6 | * handled where users are. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { | |
| 11 | addCrew as insertCrew, | |
| 12 | createAgentRow, | |
| 13 | deleteAgent as removeAgent, | |
| 14 | getAgent, | |
| 15 | getAgentByName, | |
| 16 | listAgents, | |
| 17 | listCrew as queryCrew, | |
| 18 | removeCrew as deleteCrew, | |
| 19 | updateAgent as patchAgent, | |
| 20 | type Agent, | |
| 21 | type AgentPatch, | |
| 22 | BRIEF_MAX, | |
| 23 | BUDGET_MAX_USD, | |
| 24 | MODEL_MAX, | |
| 25 | } from "../core/agents.ts"; | |
| 26 | import type { Repo } from "../core/repos.ts"; | |
| 27 | import { createUser, type CreateUserError, type User } from "../core/users.ts"; | |
| 28 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 29 | import { requireReader, requireRepoAdmin, requireUser } from "./access.ts"; | |
| 30 | ||
| 31 | export interface CreateAgentInput { | |
| 32 | username: string; | |
| 33 | /** @default the username */ | |
| 34 | displayName?: string; | |
| 35 | model?: string; | |
| 36 | brief?: string; | |
| 37 | budgetUsd?: number; | |
| 38 | } | |
| 39 | ||
| 40 | export type CreateAgentError = | |
| 41 | | "unauthorized" | |
| 42 | | "forbidden" | |
| 43 | | CreateUserError | |
| 44 | | "invalid_model" | |
| 45 | | "brief_too_long" | |
| 46 | | "invalid_budget"; | |
| 47 | ||
| 48 | function requireForgeAdmin( | |
| 49 | actor: User | undefined, | |
| 50 | ): Result<User, "unauthorized" | "forbidden"> { | |
| 51 | const user = requireUser(actor); | |
| 52 | if (!user.ok) return user; | |
| 53 | return user.value.isAdmin ? user : err("forbidden"); | |
| 54 | } | |
| 55 | ||
| 56 | function checkPatch( | |
| 57 | patch: AgentPatch, | |
| 58 | ): Result<AgentPatch, "invalid_model" | "brief_too_long" | "invalid_budget"> { | |
| 59 | if (patch.model !== undefined) { | |
| 60 | if (patch.model.length > MODEL_MAX || /[\s\0]/.test(patch.model)) | |
| 61 | return err("invalid_model"); | |
| 62 | } | |
| 63 | if (patch.brief !== undefined && patch.brief.length > BRIEF_MAX) | |
| 64 | return err("brief_too_long"); | |
| 65 | if ( | |
| 66 | patch.budgetUsd !== undefined && | |
| 67 | (!Number.isFinite(patch.budgetUsd) || | |
| 68 | patch.budgetUsd < 0 || | |
| 69 | patch.budgetUsd > BUDGET_MAX_USD) | |
| 70 | ) | |
| 71 | return err("invalid_budget"); | |
| 72 | return ok(patch); | |
| 73 | } | |
| 74 | ||
| 75 | /** A new crew member. Admin only: this is an account that will act. */ | |
| 76 | export async function createAgent( | |
| 77 | app: App, | |
| 78 | actor: User | undefined, | |
| 79 | input: CreateAgentInput, | |
| 80 | ): Promise<Result<Agent, CreateAgentError>> { | |
| 81 | const admin = requireForgeAdmin(actor); | |
| 82 | if (!admin.ok) return admin; | |
| 83 | const patch = checkPatch({ | |
| 84 | ...(input.model === undefined ? {} : { model: input.model }), | |
| 85 | ...(input.brief === undefined ? {} : { brief: input.brief }), | |
| 86 | ...(input.budgetUsd === undefined ? {} : { budgetUsd: input.budgetUsd }), | |
| 87 | }); | |
| 88 | if (!patch.ok) return patch; | |
| 89 | const user = await createUser(app.db, { | |
| 90 | username: input.username, | |
| 91 | email: `${input.username.toLowerCase()}@agents.invalid`, | |
| 92 | password: "", | |
| 93 | displayName: input.displayName ?? input.username, | |
| 94 | agent: true, | |
| 95 | }); | |
| 96 | if (!user.ok) return user; | |
| 97 | const agent = createAgentRow(app.db, { | |
| 98 | userId: user.value.id, | |
| 99 | model: patch.value.model, | |
| 100 | brief: patch.value.brief, | |
| 101 | budgetUsd: patch.value.budgetUsd, | |
| 102 | createdBy: admin.value.id, | |
| 103 | }); | |
| 104 | app.bus.append({ | |
| 105 | kind: "agent.created", | |
| 106 | actorId: admin.value.id, | |
| 107 | payload: { agent: agent.user.username }, | |
| 108 | }); | |
| 109 | return ok(agent); | |
| 110 | } | |
| 111 | ||
| 112 | export function updateAgent( | |
| 113 | app: App, | |
| 114 | actor: User | undefined, | |
| 115 | username: string, | |
| 116 | patch: AgentPatch, | |
| 117 | ): Result<Agent, CreateAgentError | "not_found"> { | |
| 118 | const admin = requireForgeAdmin(actor); | |
| 119 | if (!admin.ok) return admin; | |
| 120 | const agent = getAgentByName(app.db, username); | |
| 121 | if (!agent) return err("not_found"); | |
| 122 | const checked = checkPatch(patch); | |
| 123 | if (!checked.ok) return checked; | |
| 124 | const updated = patchAgent(app.db, agent.user.id, checked.value); | |
| 125 | if (!updated) return err("not_found"); | |
| 126 | app.bus.append({ | |
| 127 | kind: "agent.updated", | |
| 128 | actorId: admin.value.id, | |
| 129 | payload: { agent: updated.user.username }, | |
| 130 | }); | |
| 131 | return ok(updated); | |
| 132 | } | |
| 133 | ||
| 134 | export function deleteAgent( | |
| 135 | app: App, | |
| 136 | actor: User | undefined, | |
| 137 | username: string, | |
| 138 | ): Result<void, "unauthorized" | "forbidden" | "not_found"> { | |
| 139 | const admin = requireForgeAdmin(actor); | |
| 140 | if (!admin.ok) return admin; | |
| 141 | const agent = getAgentByName(app.db, username); | |
| 142 | if (!agent) return err("not_found"); | |
| 143 | if (!removeAgent(app.db, agent.user.id)) return err("not_found"); | |
| 144 | app.bus.append({ | |
| 145 | kind: "agent.deleted", | |
| 146 | actorId: admin.value.id, | |
| 147 | payload: { agent: agent.user.username }, | |
| 148 | }); | |
| 149 | return ok(); | |
| 150 | } | |
| 151 | ||
| 152 | /** Every agent on the forge. Anyone signed in may see who is on the crew. */ | |
| 153 | export function listAllAgents( | |
| 154 | app: App, | |
| 155 | actor: User | undefined, | |
| 156 | ): Result<Agent[], "unauthorized"> { | |
| 157 | const user = requireUser(actor); | |
| 158 | if (!user.ok) return user; | |
| 159 | return ok(listAgents(app.db)); | |
| 160 | } | |
| 161 | ||
| 162 | export function getAgentView( | |
| 163 | app: App, | |
| 164 | actor: User | undefined, | |
| 165 | username: string, | |
| 166 | ): Result<Agent, "unauthorized" | "not_found"> { | |
| 167 | const user = requireUser(actor); | |
| 168 | if (!user.ok) return user; | |
| 169 | const agent = getAgentByName(app.db, username); | |
| 170 | return agent ? ok(agent) : err("not_found"); | |
| 171 | } | |
| 172 | ||
| 173 | // ---- a repository's crew ---- | |
| 174 | ||
| 175 | export function listCrew( | |
| 176 | app: App, | |
| 177 | actor: User | undefined, | |
| 178 | repo: Repo, | |
| 179 | ): Result<Agent[], "not_found"> { | |
| 180 | const reader = requireReader(app, actor, repo); | |
| 181 | if (!reader.ok) return reader; | |
| 182 | return ok(queryCrew(app.db, repo.id)); | |
| 183 | } | |
| 184 | ||
| 185 | /** Put an agent on the crew: it may now read and write this repository. */ | |
| 186 | export function addToCrew( | |
| 187 | app: App, | |
| 188 | actor: User | undefined, | |
| 189 | repo: Repo, | |
| 190 | username: string, | |
| 191 | ): Result<Agent, "not_found" | "no_such_agent"> { | |
| 192 | const admin = requireRepoAdmin(app, actor, repo); | |
| 193 | if (!admin.ok) return admin; | |
| 194 | const agent = getAgentByName(app.db, username); | |
| 195 | if (!agent) return err("no_such_agent"); | |
| 196 | if (insertCrew(app.db, repo.id, agent.user.id, admin.value.id)) { | |
| 197 | app.bus.append({ | |
| 198 | kind: "crew.added", | |
| 199 | repoId: repo.id, | |
| 200 | actorId: admin.value.id, | |
| 201 | payload: { agent: agent.user.username }, | |
| 202 | }); | |
| 203 | } | |
| 204 | return ok(agent); | |
| 205 | } | |
| 206 | ||
| 207 | export function removeFromCrew( | |
| 208 | app: App, | |
| 209 | actor: User | undefined, | |
| 210 | repo: Repo, | |
| 211 | username: string, | |
| 212 | ): Result<void, "not_found" | "no_such_agent"> { | |
| 213 | const admin = requireRepoAdmin(app, actor, repo); | |
| 214 | if (!admin.ok) return admin; | |
| 215 | const agent = getAgentByName(app.db, username); | |
| 216 | if (!agent) return err("no_such_agent"); | |
| 217 | if (deleteCrew(app.db, repo.id, agent.user.id)) { | |
| 218 | app.bus.append({ | |
| 219 | kind: "crew.removed", | |
| 220 | repoId: repo.id, | |
| 221 | actorId: admin.value.id, | |
| 222 | payload: { agent: agent.user.username }, | |
| 223 | }); | |
| 224 | } | |
| 225 | return ok(); | |
| 226 | } | |
| 227 | ||
| 228 | export { getAgent }; |
src/actions/issues.ts +43 −3
| @@ -26,6 +26,7 @@ import { | ||
| 26 | 26 | getLabel, |
| 27 | 27 | labelsFor, |
| 28 | 28 | listComments, |
| 29 | setAssignee, | |
| 29 | 30 | setIssueLabels as writeLabels, |
| 30 | 31 | setIssueState as writeState, |
| 31 | 32 | type Comment, |
| @@ -36,7 +37,7 @@ import { | ||
| 36 | 37 | type TitleError, |
| 37 | 38 | } from "../core/issues.ts"; |
| 38 | 39 | import { getPull } from "../core/pulls.ts"; |
| 39 | import { canWrite, type Repo } from "../core/repos.ts"; | |
| 40 | import { canRead, canWrite, type Repo } from "../core/repos.ts"; | |
| 40 | 41 | import type { User } from "../core/users.ts"; |
| 41 | 42 | import { |
| 42 | 43 | applyLabelNames, |
| @@ -88,6 +89,46 @@ export function openIssue( | ||
| 88 | 89 | return created; |
| 89 | 90 | } |
| 90 | 91 | |
| 92 | /** | |
| 93 | * Put someone on an issue or a pull, or take them off (`null`). A writer's | |
| 94 | * call. The assignee must be able to see the repository, and an agent must | |
| 95 | * be able to write it — assigning an agent is what starts it working | |
| 96 | * (docs/APPS.md), and an agent that cannot push has nothing to start. | |
| 97 | */ | |
| 98 | export function assignIssue( | |
| 99 | app: App, | |
| 100 | actor: User | undefined, | |
| 101 | repo: Repo, | |
| 102 | issue: Issue, | |
| 103 | assignee: User | null, | |
| 104 | ): Result<Issue, AccessError | "assignee_cannot_read" | "agent_not_on_crew"> { | |
| 105 | const writer = requireWriter(app, actor, repo); | |
| 106 | if (!writer.ok) return writer; | |
| 107 | if (issue.repoId !== repo.id) return err("not_found"); | |
| 108 | // Assigning an agent starts a run that costs money; only a person may | |
| 109 | // do that. An agent that could assign agents could start a chain of them. | |
| 110 | if (writer.value.kind === "agent") return err("forbidden"); | |
| 111 | if (assignee !== null) { | |
| 112 | if (assignee.kind === "org") return err("assignee_cannot_read"); | |
| 113 | if (!canRead(app.db, repo, assignee)) return err("assignee_cannot_read"); | |
| 114 | if (assignee.kind === "agent" && !canWrite(app.db, repo, assignee)) | |
| 115 | return err("agent_not_on_crew"); | |
| 116 | } | |
| 117 | const updated = setAssignee(app.db, issue.id, assignee?.id ?? null); | |
| 118 | if (!updated) return ok(issue); | |
| 119 | app.bus.append({ | |
| 120 | kind: `${issue.kind}.assigned`, | |
| 121 | repoId: repo.id, | |
| 122 | actorId: writer.value.id, | |
| 123 | payload: { | |
| 124 | number: issue.number, | |
| 125 | assignee: assignee?.username ?? null, | |
| 126 | agent: assignee?.kind === "agent", | |
| 127 | }, | |
| 128 | }); | |
| 129 | return ok(updated); | |
| 130 | } | |
| 131 | ||
| 91 | 132 | /** Comment on an issue or a pull; anyone who can see it may speak. */ |
| 92 | 133 | export function addComment( |
| 93 | 134 | app: App, |
| @@ -212,8 +253,7 @@ export async function openIssueThrough( | ||
| 212 | 253 | if (!user.ok) return user; |
| 213 | 254 | const through = writeThroughFor(app, user.value, repo); |
| 214 | 255 | if (!through.ok) return through; |
| 215 | if (through.value === undefined) | |
| 216 | return openIssue(app, actor, repo, input); | |
| 256 | if (through.value === undefined) return openIssue(app, actor, repo, input); | |
| 217 | 257 | |
| 218 | 258 | const title = input.title.trim(); |
| 219 | 259 | if (title === "") return err("empty_title"); |
src/actions/pulls.ts +33 −4
| @@ -5,7 +5,12 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { App } from "../app.ts"; |
| 8 | import { getIssueById, type BodyError } from "../core/issues.ts"; | |
| 8 | import { | |
| 9 | getIssueById, | |
| 10 | setAssignee, | |
| 11 | type BodyError, | |
| 12 | type Issue, | |
| 13 | } from "../core/issues.ts"; | |
| 9 | 14 | import { |
| 10 | 15 | createPull, |
| 11 | 16 | getPull, |
| @@ -138,15 +143,32 @@ export async function openPull( | ||
| 138 | 143 | }, |
| 139 | 144 | ); |
| 140 | 145 | if (!created.ok) return created; |
| 146 | // A pull the crew opens is the crew's to see through: it starts assigned | |
| 147 | // to the agent, so "who is on it" is legible without a second step (an | |
| 148 | // agent cannot assign, so this is the one place it happens). | |
| 149 | const assigned = | |
| 150 | user.value.kind === "agent" | |
| 151 | ? setAssignee(app.db, created.value.id, user.value.id) | |
| 152 | : undefined; | |
| 141 | 153 | app.bus.append({ |
| 142 | 154 | kind: "pull.opened", |
| 143 | 155 | repoId: baseRepo.id, |
| 144 | 156 | actorId: user.value.id, |
| 145 | payload: { number: created.value.number }, | |
| 157 | payload: { | |
| 158 | number: created.value.number, | |
| 159 | ...(assigned ? { assignee: user.value.username } : {}), | |
| 160 | }, | |
| 146 | 161 | }); |
| 147 | return created; | |
| 162 | return assigned ? ok({ ...created.value, ...assigneeOf(assigned) }) : created; | |
| 148 | 163 | } |
| 149 | 164 | |
| 165 | /** The assignee fields of an issue row, for folding into a pull. */ | |
| 166 | const assigneeOf = (issue: Issue) => ({ | |
| 167 | assigneeId: issue.assigneeId, | |
| 168 | assigneeName: issue.assigneeName, | |
| 169 | assigneeIsAgent: issue.assigneeIsAgent, | |
| 170 | }); | |
| 171 | ||
| 150 | 172 | /** |
| 151 | 173 | * Bring `refs/pull/N/head` up to date with the fork it came from. |
| 152 | 174 | * |
| @@ -261,6 +283,9 @@ export async function mergePull( | ||
| 261 | 283 | const writer = requireWriter(app, actor, repo); |
| 262 | 284 | if (!writer.ok) return writer; |
| 263 | 285 | if (pull.repoId !== repo.id) return err("not_found"); |
| 286 | // Rule 1 in docs/APPS.md: the crew proposes, a person lands. An agent on | |
| 287 | // the crew can write the repository and still may not merge into it. | |
| 288 | if (writer.value.kind === "agent") return err("forbidden"); | |
| 264 | 289 | // Merging on a mirror is not offered. A local merge would rewrite the base |
| 265 | 290 | // branch here and then be overwritten by the next fetch; a merge through |
| 266 | 291 | // the API would have to reproduce the origin's merge strategy, protected |
| @@ -276,7 +301,11 @@ export async function mergePull( | ||
| 276 | 301 | repoDir(app.reposRoot, repo.ownerName, repo.name), |
| 277 | 302 | pull, |
| 278 | 303 | { |
| 279 | gate: { db: app.db, requireReview: repo.requireReview }, | |
| 304 | gate: { | |
| 305 | db: app.db, | |
| 306 | requireReview: repo.requireReview, | |
| 307 | requireChecks: repo.requireChecks, | |
| 308 | }, | |
| 280 | 309 | strategy, |
| 281 | 310 | message: |
| 282 | 311 | strategy === "squash" |
src/actions/repos.ts +4
| @@ -83,6 +83,9 @@ export async function createRepo( | ||
| 83 | 83 | ): Promise<Result<Repo, CreateRepoActionError>> { |
| 84 | 84 | const user = requireUser(actor); |
| 85 | 85 | if (!user.ok) return user; |
| 86 | // An agent owns nothing it was not given (docs/APPS.md): it works on the | |
| 87 | // repositories whose crew it is on, and cannot make itself a new one. | |
| 88 | if (user.value.kind === "agent") return err("forbidden_owner"); | |
| 86 | 89 | const ownerId = resolveOwner(app, user.value, input.owner ?? ""); |
| 87 | 90 | if (ownerId === undefined) return err("forbidden_owner"); |
| 88 | 91 | const created = await initRepo(app.db, app.reposRoot, { |
| @@ -155,6 +158,7 @@ export async function updateRepoSettings( | ||
| 155 | 158 | "protectDefault", |
| 156 | 159 | "defaultBranch", |
| 157 | 160 | "requireReview", |
| 161 | "requireChecks", | |
| 158 | 162 | ] as const |
| 159 | 163 | ).filter((k) => settings[k] !== undefined && settings[k] !== repo[k]); |
| 160 | 164 | app.bus.append({ |
src/actions/runs.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | /** | |
| 2 | * Run actions: look at a repository's runs and their logs, cancel one, and | |
| 3 | * — for the workers that produce runs — announce a run's state on the | |
| 4 | * outbox. Reading follows the repository's visibility (a run's log is as | |
| 5 | * private as the code it ran); cancelling takes write access; announcing | |
| 6 | * is the system's own voice and takes no actor. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import type { Repo } from "../core/repos.ts"; | |
| 11 | import { | |
| 12 | cancelRun as markCancelled, | |
| 13 | getRun, | |
| 14 | listRuns as queryRuns, | |
| 15 | type ListRunsOptions, | |
| 16 | type Run, | |
| 17 | } from "../core/runs.ts"; | |
| 18 | import type { User } from "../core/users.ts"; | |
| 19 | import { abortAgentRun } from "../crew/running.ts"; | |
| 20 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 21 | import { readRunLog, runLogPath } from "../runs/log.ts"; | |
| 22 | import { requireReader, requireWriter, type AccessError } from "./access.ts"; | |
| 23 | ||
| 24 | /** What a run event carries: enough for a surface to render the card. */ | |
| 25 | export function runPayload(run: Run): Record<string, unknown> { | |
| 26 | return { | |
| 27 | runId: run.id, | |
| 28 | kind: run.kind, | |
| 29 | state: run.state, | |
| 30 | env: run.env, | |
| 31 | commitOid: run.commitOid, | |
| 32 | reason: run.reason, | |
| 33 | summary: run.summary, | |
| 34 | ...(run.issueId === null ? {} : { issueId: run.issueId }), | |
| 35 | }; | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * Put a run's current state on the outbox: `run.queued`, `run.started` or | |
| 40 | * `run.finished` by its state. The one place a worker speaks. | |
| 41 | */ | |
| 42 | export function announceRun(app: App, run: Run, actorId?: string): void { | |
| 43 | const kind = | |
| 44 | run.state === "queued" | |
| 45 | ? "run.queued" | |
| 46 | : run.state === "running" | |
| 47 | ? "run.started" | |
| 48 | : "run.finished"; | |
| 49 | app.bus.append({ | |
| 50 | kind, | |
| 51 | repoId: run.repoId, | |
| 52 | ...(actorId === undefined ? {} : { actorId }), | |
| 53 | payload: runPayload(run), | |
| 54 | }); | |
| 55 | } | |
| 56 | ||
| 57 | export function listRepoRuns( | |
| 58 | app: App, | |
| 59 | actor: User | undefined, | |
| 60 | repo: Repo, | |
| 61 | options: ListRunsOptions = {}, | |
| 62 | ): Result<Run[], "not_found"> { | |
| 63 | const reader = requireReader(app, actor, repo); | |
| 64 | if (!reader.ok) return reader; | |
| 65 | return ok(queryRuns(app.db, repo.id, options)); | |
| 66 | } | |
| 67 | ||
| 68 | /** A run by id, only if it belongs to a repository the actor can see. */ | |
| 69 | export function getRepoRun( | |
| 70 | app: App, | |
| 71 | actor: User | undefined, | |
| 72 | repo: Repo, | |
| 73 | runId: string, | |
| 74 | ): Result<Run, "not_found"> { | |
| 75 | const reader = requireReader(app, actor, repo); | |
| 76 | if (!reader.ok) return reader; | |
| 77 | const run = getRun(app.db, runId); | |
| 78 | if (!run || run.repoId !== repo.id) return err("not_found"); | |
| 79 | return ok(run); | |
| 80 | } | |
| 81 | ||
| 82 | export interface RunLogView { | |
| 83 | readonly run: Run; | |
| 84 | readonly lines: string[]; | |
| 85 | readonly truncated: boolean; | |
| 86 | readonly path: string; | |
| 87 | } | |
| 88 | ||
| 89 | export function runLog( | |
| 90 | app: App, | |
| 91 | actor: User | undefined, | |
| 92 | repo: Repo, | |
| 93 | runId: string, | |
| 94 | ): Result<RunLogView, "not_found"> { | |
| 95 | const found = getRepoRun(app, actor, repo, runId); | |
| 96 | if (!found.ok) return found; | |
| 97 | const { lines, truncated } = readRunLog(app.config.dataDir, runId); | |
| 98 | return ok({ | |
| 99 | run: found.value, | |
| 100 | lines, | |
| 101 | truncated, | |
| 102 | path: runLogPath(app.config.dataDir, runId), | |
| 103 | }); | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * Cancel a run that has not finished. A queued run never starts; a running | |
| 108 | * agent run is stopped; a running deploy is abandoned by its environment | |
| 109 | * when its step ends. | |
| 110 | */ | |
| 111 | export function cancelRepoRun( | |
| 112 | app: App, | |
| 113 | actor: User | undefined, | |
| 114 | repo: Repo, | |
| 115 | runId: string, | |
| 116 | ): Result<Run, AccessError | "finished"> { | |
| 117 | const writer = requireWriter(app, actor, repo); | |
| 118 | if (!writer.ok) return writer; | |
| 119 | const run = getRun(app.db, runId); | |
| 120 | if (!run || run.repoId !== repo.id) return err("not_found"); | |
| 121 | const cancelled = markCancelled( | |
| 122 | app.db, | |
| 123 | runId, | |
| 124 | `cancelled by ${writer.value.username}`, | |
| 125 | ); | |
| 126 | if (!cancelled) return err("finished"); | |
| 127 | if (run.kind === "agent") abortAgentRun(runId); | |
| 128 | announceRun(app, cancelled, writer.value.id); | |
| 129 | return ok(cancelled); | |
| 130 | } |
src/apps/checkout.ts added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | /** | |
| 2 | * A commit as files on disk, and a file read straight from a commit. | |
| 3 | * | |
| 4 | * A deployment runs from a checkout of exactly one commit, exported from | |
| 5 | * the bare repository with the plumbing git uses for the same job in a | |
| 6 | * post-receive deploy hook: `read-tree` into a throwaway index, then | |
| 7 | * `checkout-index` into the target directory. No clone, no work tree, no | |
| 8 | * `.git` in the result — the app gets its source and nothing that could | |
| 9 | * be pushed back. | |
| 10 | */ | |
| 11 | ||
| 12 | import { mkdirSync, rmSync } from "node:fs"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { runGit } from "../git/env.ts"; | |
| 15 | import { isSafeOid } from "../git/guard.ts"; | |
| 16 | import { readerFor } from "../git/read.ts"; | |
| 17 | import { id } from "../kernel/id.ts"; | |
| 18 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 19 | ||
| 20 | /** The text of `path` at `oid`, or undefined when the tree has no such file. */ | |
| 21 | export async function readFileAt( | |
| 22 | dir: string, | |
| 23 | oid: string, | |
| 24 | path: string, | |
| 25 | ): Promise<string | undefined> { | |
| 26 | if (!isSafeOid(oid)) return undefined; | |
| 27 | const object = await readerFor(dir).object(`${oid}:${path}`); | |
| 28 | if (!object.ok || object.value.type !== "blob") return undefined; | |
| 29 | return new TextDecoder().decode(object.value.body); | |
| 30 | } | |
| 31 | ||
| 32 | /** | |
| 33 | * Export the tree of `oid` into `target`, which is created (and emptied | |
| 34 | * first if it exists — a checkout is a function of the commit, never a | |
| 35 | * merge with what was there). | |
| 36 | */ | |
| 37 | export async function exportTree( | |
| 38 | bareDir: string, | |
| 39 | oid: string, | |
| 40 | target: string, | |
| 41 | ): Promise<Result<void, string>> { | |
| 42 | if (!isSafeOid(oid)) return err("not a commit id"); | |
| 43 | rmSync(target, { recursive: true, force: true }); | |
| 44 | mkdirSync(target, { recursive: true }); | |
| 45 | const index = join(target, "..", `.checkout-${id()}.index`); | |
| 46 | const env = { | |
| 47 | GIT_DIR: bareDir, | |
| 48 | GIT_WORK_TREE: target, | |
| 49 | GIT_INDEX_FILE: index, | |
| 50 | }; | |
| 51 | try { | |
| 52 | const read = await runGit(["read-tree", oid], { | |
| 53 | cwd: target, | |
| 54 | env, | |
| 55 | timeoutMs: 60_000, | |
| 56 | }); | |
| 57 | if (!read.ok) return err(`read-tree: ${read.error.stderr || "failed"}`); | |
| 58 | const written = await runGit(["checkout-index", "-a", "-f"], { | |
| 59 | cwd: target, | |
| 60 | env, | |
| 61 | timeoutMs: 300_000, | |
| 62 | }); | |
| 63 | if (!written.ok) | |
| 64 | return err(`checkout-index: ${written.error.stderr || "failed"}`); | |
| 65 | return ok(); | |
| 66 | } finally { | |
| 67 | rmSync(index, { force: true }); | |
| 68 | } | |
| 69 | } |
src/apps/data.ts added +183
| @@ -0,0 +1,183 @@ | ||
| 1 | /** | |
| 2 | * Where an environment keeps its state, and how a preview gets a copy of | |
| 3 | * production's. | |
| 4 | * | |
| 5 | * Every environment owns exactly one directory and is handed it as | |
| 6 | * `FORGE_APP_DATA`. Production's lives as long as the app; a preview's is | |
| 7 | * created when the pull opens and removed when it closes. A preview starts | |
| 8 | * from a *copy* of production data — never a view of it — taken at the | |
| 9 | * moment the preview is created: on APFS, XFS and btrfs the copy is | |
| 10 | * copy-on-write (bytes are shared until either side writes), elsewhere it is | |
| 11 | * a plain copy. Both are the same guarantee at different speeds. | |
| 12 | * | |
| 13 | * SQLite in WAL mode keeps recent writes in a `-wal` file beside the | |
| 14 | * database. Copying the pair mid-write can produce a database whose WAL | |
| 15 | * belongs to a different moment, so every SQLite file is checkpointed into | |
| 16 | * its main file first. The app under test is still running; WAL is designed | |
| 17 | * for exactly that. | |
| 18 | */ | |
| 19 | ||
| 20 | import { Database } from "bun:sqlite"; | |
| 21 | import { | |
| 22 | constants, | |
| 23 | cpSync, | |
| 24 | existsSync, | |
| 25 | mkdirSync, | |
| 26 | openSync, | |
| 27 | readdirSync, | |
| 28 | readSync, | |
| 29 | rmSync, | |
| 30 | closeSync, | |
| 31 | } from "node:fs"; | |
| 32 | import { join } from "node:path"; | |
| 33 | import { defect } from "../kernel/error.ts"; | |
| 34 | ||
| 35 | /** `production`, or `pull-<n>` for the preview of pull `n`. */ | |
| 36 | export type EnvName = "production" | `pull-${number}`; | |
| 37 | ||
| 38 | export const previewEnv = (pullNumber: number): EnvName => `pull-${pullNumber}`; | |
| 39 | ||
| 40 | export function isEnvName(value: string): value is EnvName { | |
| 41 | return value === "production" || /^pull-[1-9]\d*$/.test(value); | |
| 42 | } | |
| 43 | ||
| 44 | /** The pull a preview environment belongs to, or `undefined` for production. */ | |
| 45 | export function envPull(env: EnvName): number | undefined { | |
| 46 | return env === "production" ? undefined : Number(env.slice(5)); | |
| 47 | } | |
| 48 | ||
| 49 | export const appsRoot = (dataDir: string): string => join(dataDir, "apps"); | |
| 50 | ||
| 51 | /** | |
| 52 | * An environment's directory. The names are checked here as well as at | |
| 53 | * every call site: a path under `<data>/apps` is built from exactly an id | |
| 54 | * and an environment name, and anything else is a bug, loudly. | |
| 55 | */ | |
| 56 | export function envDir(dataDir: string, appId: string, env: EnvName): string { | |
| 57 | if (!isEnvName(env) || !/^[0-9a-z]+$/i.test(appId)) | |
| 58 | defect("apps.bad_env_path", `refusing to build a path for ${appId}/${env}`); | |
| 59 | return join(appsRoot(dataDir), appId, env); | |
| 60 | } | |
| 61 | ||
| 62 | export const envDataDir = ( | |
| 63 | dataDir: string, | |
| 64 | appId: string, | |
| 65 | env: EnvName, | |
| 66 | ): string => join(envDir(dataDir, appId, env), "data"); | |
| 67 | ||
| 68 | export type PreviewData = "copy" | "empty"; | |
| 69 | ||
| 70 | export type SeedOutcome = "cloned" | "copied" | "empty"; | |
| 71 | ||
| 72 | /** | |
| 73 | * Create an environment's data directory. A preview with `policy: "copy"` | |
| 74 | * starts as production's data at this moment; anything else starts empty. | |
| 75 | * Idempotent: an existing directory is left exactly as it is, because a | |
| 76 | * redeploy of the same preview must keep the state the reviewer built up. | |
| 77 | */ | |
| 78 | export function ensureEnvData( | |
| 79 | dataDir: string, | |
| 80 | appId: string, | |
| 81 | env: EnvName, | |
| 82 | policy: PreviewData, | |
| 83 | ): SeedOutcome | "kept" { | |
| 84 | const target = envDataDir(dataDir, appId, env); | |
| 85 | if (existsSync(target)) return "kept"; | |
| 86 | if (env === "production" || policy === "empty") { | |
| 87 | mkdirSync(target, { recursive: true }); | |
| 88 | return "empty"; | |
| 89 | } | |
| 90 | const source = envDataDir(dataDir, appId, "production"); | |
| 91 | if (!existsSync(source)) { | |
| 92 | mkdirSync(target, { recursive: true }); | |
| 93 | return "empty"; | |
| 94 | } | |
| 95 | quiesceSqlite(source); | |
| 96 | mkdirSync(join(target, ".."), { recursive: true }); | |
| 97 | return cloneDir(source, target); | |
| 98 | } | |
| 99 | ||
| 100 | /** Remove an environment entirely: checkouts, data, everything. */ | |
| 101 | export function removeEnv(dataDir: string, appId: string, env: EnvName): void { | |
| 102 | rmSync(envDir(dataDir, appId, env), { recursive: true, force: true }); | |
| 103 | } | |
| 104 | ||
| 105 | /** Remove an app's whole directory — every environment with it. */ | |
| 106 | export function removeApp(dataDir: string, appId: string): void { | |
| 107 | rmSync(join(appsRoot(dataDir), appId), { recursive: true, force: true }); | |
| 108 | } | |
| 109 | ||
| 110 | /** | |
| 111 | * Copy a directory, sharing blocks when the filesystem can. `FICLONE_FORCE` | |
| 112 | * either clones or refuses — it never silently degrades — so a refusal is | |
| 113 | * the signal to fall back to a byte copy, and the caller learns which | |
| 114 | * happened. | |
| 115 | */ | |
| 116 | export function cloneDir(source: string, target: string): SeedOutcome { | |
| 117 | try { | |
| 118 | cpSync(source, target, { | |
| 119 | recursive: true, | |
| 120 | mode: constants.COPYFILE_FICLONE_FORCE, | |
| 121 | }); | |
| 122 | return "cloned"; | |
| 123 | } catch { | |
| 124 | rmSync(target, { recursive: true, force: true }); | |
| 125 | cpSync(source, target, { recursive: true }); | |
| 126 | return "copied"; | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | const SQLITE_MAGIC = "SQLite format 3\0"; | |
| 131 | const MAX_FILES = 10_000; | |
| 132 | ||
| 133 | /** | |
| 134 | * Checkpoint every SQLite database under `dir` so its WAL is folded into the | |
| 135 | * main file before it is copied. Databases are recognised by their header, | |
| 136 | * not their name, because an app may call its file anything. Bounded by file | |
| 137 | * count: a data directory is not a place to walk forever. | |
| 138 | */ | |
| 139 | export function quiesceSqlite(dir: string): void { | |
| 140 | let seen = 0; | |
| 141 | const walk = (path: string): void => { | |
| 142 | let entries; | |
| 143 | try { | |
| 144 | entries = readdirSync(path, { withFileTypes: true }); | |
| 145 | } catch { | |
| 146 | return; | |
| 147 | } | |
| 148 | for (const entry of entries) { | |
| 149 | if (++seen > MAX_FILES) return; | |
| 150 | const full = join(path, entry.name); | |
| 151 | if (entry.isDirectory()) walk(full); | |
| 152 | else if (entry.isFile() && isSqlite(full)) checkpoint(full); | |
| 153 | } | |
| 154 | }; | |
| 155 | walk(dir); | |
| 156 | } | |
| 157 | ||
| 158 | function isSqlite(path: string): boolean { | |
| 159 | let fd: number | undefined; | |
| 160 | try { | |
| 161 | fd = openSync(path, "r"); | |
| 162 | const header = Buffer.alloc(SQLITE_MAGIC.length); | |
| 163 | const read = readSync(fd, header, 0, header.length, 0); | |
| 164 | return read === header.length && header.toString("latin1") === SQLITE_MAGIC; | |
| 165 | } catch { | |
| 166 | return false; | |
| 167 | } finally { | |
| 168 | if (fd !== undefined) closeSync(fd); | |
| 169 | } | |
| 170 | } | |
| 171 | ||
| 172 | function checkpoint(path: string): void { | |
| 173 | let db: Database | undefined; | |
| 174 | try { | |
| 175 | db = new Database(path, { readwrite: true }); | |
| 176 | db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); | |
| 177 | } catch { | |
| 178 | // A database another process holds exclusively, or one that is not | |
| 179 | // really SQLite despite its header: the copy proceeds without it. | |
| 180 | } finally { | |
| 181 | db?.close(); | |
| 182 | } | |
| 183 | } |
src/apps/manifest.ts added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | /** | |
| 2 | * How an app says it runs: `forge.json` at the root of the commit being | |
| 3 | * deployed. It is read from the tree, never from the database, so a pull | |
| 4 | * that changes how the app starts is previewed the way it will run. | |
| 5 | * | |
| 6 | * Fail closed. A file that is present and wrong is a deploy that fails with | |
| 7 | * the reason in its log, never a guess at what was meant. A file that is | |
| 8 | * absent falls back to the one convention everyone already has — a | |
| 9 | * `package.json` with a `start` script — and a repository with neither is | |
| 10 | * simply not an app yet, which the surfaces say in those words. | |
| 11 | */ | |
| 12 | ||
| 13 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 14 | ||
| 15 | export interface AppManifest { | |
| 16 | /** The command that serves on `$PORT`. Run through `sh -c`, `exec`-ed. */ | |
| 17 | readonly run: string; | |
| 18 | /** Runs before `run`, in the checkout. `null` skips it. */ | |
| 19 | readonly install: string | null; | |
| 20 | /** Runs after `install`; a non-zero exit fails the deploy. `null` skips. */ | |
| 21 | readonly test: string | null; | |
| 22 | /** The path polled until the app answers. */ | |
| 23 | readonly health: string; | |
| 24 | /** Seconds to wait for `health` before the deploy fails. */ | |
| 25 | readonly ready: number; | |
| 26 | /** | |
| 27 | * The app's terminal interface: a command run on a pty when someone | |
| 28 | * `ssh`es to the app (`<slug>@forge`), in the deployed checkout with the | |
| 29 | * environment's own variables. `null` means the app has none. | |
| 30 | */ | |
| 31 | readonly tty: string | null; | |
| 32 | } | |
| 33 | ||
| 34 | export type ManifestError = | |
| 35 | /** Neither `forge.json` nor a `package.json` start script. */ | |
| 36 | | { readonly kind: "not_an_app" } | |
| 37 | /** A file was present and wrong; `message` names the field. */ | |
| 38 | | { readonly kind: "invalid"; readonly message: string }; | |
| 39 | ||
| 40 | const READY_DEFAULT = 30; | |
| 41 | const READY_MAX = 600; | |
| 42 | const COMMAND_MAX = 1_000; | |
| 43 | ||
| 44 | /** | |
| 45 | * Resolve a manifest from the two files that can declare one. `forgeJson` | |
| 46 | * and `packageJson` are the file contents at the commit, or `undefined` | |
| 47 | * when the path is absent from the tree. | |
| 48 | */ | |
| 49 | export function parseManifest( | |
| 50 | forgeJson: string | undefined, | |
| 51 | packageJson: string | undefined, | |
| 52 | ): Result<AppManifest, ManifestError> { | |
| 53 | const pkg = packageJson === undefined ? undefined : readPackage(packageJson); | |
| 54 | if (pkg !== undefined && !pkg.ok) return pkg; | |
| 55 | const hasDeps = pkg?.value.hasDeps ?? false; | |
| 56 | const start = pkg?.value.start; | |
| 57 | ||
| 58 | if (forgeJson === undefined) { | |
| 59 | if (start === undefined) return err({ kind: "not_an_app" }); | |
| 60 | return ok({ | |
| 61 | run: start, | |
| 62 | install: hasDeps ? "bun install" : null, | |
| 63 | test: null, | |
| 64 | health: "/", | |
| 65 | ready: READY_DEFAULT, | |
| 66 | tty: null, | |
| 67 | }); | |
| 68 | } | |
| 69 | ||
| 70 | const parsed = parseJson(forgeJson, "forge.json"); | |
| 71 | if (!parsed.ok) return parsed; | |
| 72 | const app = parsed.value["app"]; | |
| 73 | if (app === undefined) return err({ kind: "not_an_app" }); | |
| 74 | if (!isObject(app)) return invalid("forge.json: app must be an object"); | |
| 75 | ||
| 76 | const run = command(app, "run"); | |
| 77 | if (!run.ok) return run; | |
| 78 | const runCommand = run.value ?? start; | |
| 79 | if (runCommand === undefined) | |
| 80 | return invalid( | |
| 81 | "forge.json: app.run is required (no package.json start script to fall back on)", | |
| 82 | ); | |
| 83 | const install = command(app, "install"); | |
| 84 | if (!install.ok) return install; | |
| 85 | const test = command(app, "test"); | |
| 86 | if (!test.ok) return test; | |
| 87 | const tty = command(app, "tty"); | |
| 88 | if (!tty.ok) return tty; | |
| 89 | ||
| 90 | const health = app["health"]; | |
| 91 | if ( | |
| 92 | health !== undefined && | |
| 93 | (typeof health !== "string" || !health.startsWith("/")) | |
| 94 | ) | |
| 95 | return invalid("forge.json: app.health must be a path starting with /"); | |
| 96 | const ready = app["ready"]; | |
| 97 | if ( | |
| 98 | ready !== undefined && | |
| 99 | (typeof ready !== "number" || | |
| 100 | !Number.isInteger(ready) || | |
| 101 | ready < 1 || | |
| 102 | ready > READY_MAX) | |
| 103 | ) | |
| 104 | return invalid( | |
| 105 | `forge.json: app.ready must be a whole number of seconds, 1–${READY_MAX}`, | |
| 106 | ); | |
| 107 | ||
| 108 | return ok({ | |
| 109 | run: runCommand, | |
| 110 | // `install` unset means "whatever the package says"; an explicit empty | |
| 111 | // string means "nothing", which is how an app opts out of bun install. | |
| 112 | install: | |
| 113 | install.value === undefined | |
| 114 | ? hasDeps | |
| 115 | ? "bun install" | |
| 116 | : null | |
| 117 | : install.value || null, | |
| 118 | test: test.value ?? null, | |
| 119 | health: health ?? "/", | |
| 120 | ready: ready ?? READY_DEFAULT, | |
| 121 | tty: tty.value || null, | |
| 122 | }); | |
| 123 | } | |
| 124 | ||
| 125 | /** Whether `forge.json` declares an app at all, without validating it. */ | |
| 126 | export function declaresApp(forgeJson: string | undefined): boolean { | |
| 127 | if (forgeJson === undefined) return false; | |
| 128 | const parsed = parseJson(forgeJson, "forge.json"); | |
| 129 | return parsed.ok && parsed.value["app"] !== undefined; | |
| 130 | } | |
| 131 | ||
| 132 | type Json = Record<string, unknown>; | |
| 133 | ||
| 134 | const isObject = (value: unknown): value is Json => | |
| 135 | typeof value === "object" && value !== null && !Array.isArray(value); | |
| 136 | ||
| 137 | const invalid = (message: string): Result<never, ManifestError> => | |
| 138 | err({ kind: "invalid", message }); | |
| 139 | ||
| 140 | function parseJson(source: string, name: string): Result<Json, ManifestError> { | |
| 141 | let value: unknown; | |
| 142 | try { | |
| 143 | value = JSON.parse(source); | |
| 144 | } catch (error) { | |
| 145 | return invalid( | |
| 146 | `${name}: ${error instanceof Error ? error.message : "not JSON"}`, | |
| 147 | ); | |
| 148 | } | |
| 149 | if (!isObject(value)) return invalid(`${name}: must be a JSON object`); | |
| 150 | return ok(value); | |
| 151 | } | |
| 152 | ||
| 153 | /** A command field: absent, or a bounded non-empty string. */ | |
| 154 | function command( | |
| 155 | app: Json, | |
| 156 | key: string, | |
| 157 | ): Result<string | undefined, ManifestError> { | |
| 158 | const value = app[key]; | |
| 159 | if (value === undefined) return ok(undefined); | |
| 160 | if (typeof value !== "string") | |
| 161 | return invalid(`forge.json: app.${key} must be a string`); | |
| 162 | if (value.length > COMMAND_MAX) | |
| 163 | return invalid( | |
| 164 | `forge.json: app.${key} is longer than ${COMMAND_MAX} characters`, | |
| 165 | ); | |
| 166 | if (value.includes("\0") || value.includes("\n")) | |
| 167 | return invalid(`forge.json: app.${key} must be one line`); | |
| 168 | return ok(value.trim()); | |
| 169 | } | |
| 170 | ||
| 171 | function readPackage( | |
| 172 | source: string, | |
| 173 | ): Result<{ start: string | undefined; hasDeps: boolean }, ManifestError> { | |
| 174 | const parsed = parseJson(source, "package.json"); | |
| 175 | if (!parsed.ok) return parsed; | |
| 176 | const scripts = parsed.value["scripts"]; | |
| 177 | const rawStart = isObject(scripts) ? scripts["start"] : undefined; | |
| 178 | const start = | |
| 179 | typeof rawStart === "string" && rawStart.trim() !== "" | |
| 180 | ? "bun run start" | |
| 181 | : undefined; | |
| 182 | const hasDeps = | |
| 183 | hasKeys(parsed.value["dependencies"]) || | |
| 184 | hasKeys(parsed.value["devDependencies"]); | |
| 185 | return ok({ start, hasDeps }); | |
| 186 | } | |
| 187 | ||
| 188 | const hasKeys = (value: unknown): boolean => | |
| 189 | isObject(value) && Object.keys(value).length > 0; |
src/apps/process.ts added +331
| @@ -0,0 +1,331 @@ | ||
| 1 | /** | |
| 2 | * One deployment's process: install, test, start, and wait until it | |
| 3 | * answers. Everything the app is handed is listed here and nothing else | |
| 4 | * reaches it — the forge's own environment carries secrets, and a child | |
| 5 | * inherits exactly what `childEnv` names (rule 8 in docs/APPS.md). | |
| 6 | * | |
| 7 | * Commands run through `sh -c` with an `exec` in front, so the shell | |
| 8 | * replaces itself with the app and a signal to the pid is a signal to the | |
| 9 | * app, not to a shell that would swallow it. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { AppManifest } from "./manifest.ts"; | |
| 13 | import type { EnvName } from "./data.ts"; | |
| 14 | import { envPull } from "./data.ts"; | |
| 15 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 16 | ||
| 17 | export interface ProcessHandle { | |
| 18 | readonly pid: number; | |
| 19 | readonly port: number; | |
| 20 | /** Resolves with the exit code once the process has ended. */ | |
| 21 | readonly exited: Promise<number>; | |
| 22 | /** SIGTERM, a grace period, then SIGKILL. Resolves once it is gone. */ | |
| 23 | stop(): Promise<void>; | |
| 24 | } | |
| 25 | ||
| 26 | export interface StartInput { | |
| 27 | readonly dir: string; | |
| 28 | readonly dataDir: string; | |
| 29 | readonly manifest: AppManifest; | |
| 30 | readonly env: EnvName; | |
| 31 | readonly url: string; | |
| 32 | readonly commitOid: string; | |
| 33 | readonly forgeUrl: string; | |
| 34 | readonly log: (line: string) => void; | |
| 35 | /** Aborting cancels whatever step is in flight and fails the start. */ | |
| 36 | readonly signal: AbortSignal; | |
| 37 | } | |
| 38 | ||
| 39 | export type StartStage = "install" | "test" | "start" | "ready"; | |
| 40 | ||
| 41 | export interface StartError { | |
| 42 | readonly stage: StartStage; | |
| 43 | readonly message: string; | |
| 44 | } | |
| 45 | ||
| 46 | const STEP_TIMEOUT_MS = 10 * 60_000; | |
| 47 | const STOP_GRACE_MS = 5_000; | |
| 48 | const HEALTH_INTERVAL_MS = 250; | |
| 49 | ||
| 50 | /** | |
| 51 | * The environment every child starts from. Explicit, and short: the path, | |
| 52 | * the home, who the operator is (the macOS keychain answers to `USER`, and | |
| 53 | * Claude Code's own login lives there), a locale, a temp dir. | |
| 54 | */ | |
| 55 | export function childEnv( | |
| 56 | extra: Record<string, string>, | |
| 57 | ): Record<string, string> { | |
| 58 | const base: Record<string, string> = { | |
| 59 | PATH: process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin", | |
| 60 | HOME: process.env["HOME"] ?? "/", | |
| 61 | LANG: process.env["LANG"] ?? "C.UTF-8", | |
| 62 | }; | |
| 63 | for (const name of ["USER", "LOGNAME", "TMPDIR"]) { | |
| 64 | const value = process.env[name]; | |
| 65 | if (value !== undefined) base[name] = value; | |
| 66 | } | |
| 67 | return { ...base, ...extra }; | |
| 68 | } | |
| 69 | ||
| 70 | /** What an app is told about where it runs. */ | |
| 71 | export function appEnv(input: { | |
| 72 | port: number; | |
| 73 | dataDir: string; | |
| 74 | env: EnvName; | |
| 75 | url: string; | |
| 76 | commitOid: string; | |
| 77 | forgeUrl: string; | |
| 78 | }): Record<string, string> { | |
| 79 | const pull = envPull(input.env); | |
| 80 | return childEnv({ | |
| 81 | PORT: String(input.port), | |
| 82 | HOST: "127.0.0.1", | |
| 83 | NODE_ENV: "production", | |
| 84 | FORGE_APP_ENV: input.env === "production" ? "production" : "preview", | |
| 85 | FORGE_APP_DATA: input.dataDir, | |
| 86 | FORGE_APP_URL: input.url, | |
| 87 | FORGE_APP_COMMIT: input.commitOid, | |
| 88 | FORGE_URL: input.forgeUrl, | |
| 89 | ...(pull === undefined ? {} : { FORGE_APP_PULL: String(pull) }), | |
| 90 | }); | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * Run a step to completion, streaming its output. A non-zero exit, a | |
| 95 | * timeout, or an abort is an Err with the reason a person would want. | |
| 96 | */ | |
| 97 | export async function runStep( | |
| 98 | name: string, | |
| 99 | command: string, | |
| 100 | options: { | |
| 101 | cwd: string; | |
| 102 | env: Record<string, string>; | |
| 103 | log: (line: string) => void; | |
| 104 | signal: AbortSignal; | |
| 105 | timeoutMs?: number; | |
| 106 | }, | |
| 107 | ): Promise<Result<void, string>> { | |
| 108 | options.log(`$ ${command}`); | |
| 109 | const proc = Bun.spawn(["/bin/sh", "-c", command], { | |
| 110 | cwd: options.cwd, | |
| 111 | env: options.env, | |
| 112 | stdin: "ignore", | |
| 113 | stdout: "pipe", | |
| 114 | stderr: "pipe", | |
| 115 | }); | |
| 116 | const timeoutMs = options.timeoutMs ?? STEP_TIMEOUT_MS; | |
| 117 | let why: string | undefined; | |
| 118 | const timer = setTimeout(() => { | |
| 119 | why = `${name} took longer than ${Math.round(timeoutMs / 1000)}s`; | |
| 120 | proc.kill("SIGKILL"); | |
| 121 | }, timeoutMs); | |
| 122 | const onAbort = (): void => { | |
| 123 | why = `${name} cancelled`; | |
| 124 | proc.kill("SIGKILL"); | |
| 125 | }; | |
| 126 | if (options.signal.aborted) onAbort(); | |
| 127 | else options.signal.addEventListener("abort", onAbort, { once: true }); | |
| 128 | try { | |
| 129 | const [, , code] = await Promise.all([ | |
| 130 | pump(proc.stdout, options.log), | |
| 131 | pump(proc.stderr, options.log), | |
| 132 | proc.exited, | |
| 133 | ]); | |
| 134 | if (why !== undefined) return err(why); | |
| 135 | if (code !== 0) return err(`${name} exited with code ${code}`); | |
| 136 | return ok(); | |
| 137 | } finally { | |
| 138 | clearTimeout(timer); | |
| 139 | options.signal.removeEventListener("abort", onAbort); | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * Install, test, start, and wait for the app to answer on its port. | |
| 145 | * Returns the running process; on failure the process (if it started) is | |
| 146 | * already gone. | |
| 147 | */ | |
| 148 | export async function startProcess( | |
| 149 | input: StartInput, | |
| 150 | ): Promise<Result<ProcessHandle, StartError>> { | |
| 151 | const { manifest, log } = input; | |
| 152 | const stepEnv = childEnv({ | |
| 153 | CI: "1", | |
| 154 | FORGE_APP_ENV: input.env === "production" ? "production" : "preview", | |
| 155 | }); | |
| 156 | ||
| 157 | if (manifest.install !== null) { | |
| 158 | const installed = await runStep("install", manifest.install, { | |
| 159 | cwd: input.dir, | |
| 160 | env: stepEnv, | |
| 161 | log, | |
| 162 | signal: input.signal, | |
| 163 | }); | |
| 164 | if (!installed.ok) | |
| 165 | return err({ stage: "install", message: installed.error }); | |
| 166 | } | |
| 167 | if (manifest.test !== null) { | |
| 168 | const tested = await runStep("test", manifest.test, { | |
| 169 | cwd: input.dir, | |
| 170 | env: stepEnv, | |
| 171 | log, | |
| 172 | signal: input.signal, | |
| 173 | }); | |
| 174 | if (!tested.ok) return err({ stage: "test", message: tested.error }); | |
| 175 | } | |
| 176 | if (input.signal.aborted) | |
| 177 | return err({ stage: "start", message: "cancelled" }); | |
| 178 | ||
| 179 | const port = freePort(); | |
| 180 | log(`$ ${manifest.run} (PORT=${port})`); | |
| 181 | const proc = Bun.spawn(["/bin/sh", "-c", `exec ${manifest.run}`], { | |
| 182 | cwd: input.dir, | |
| 183 | env: appEnv({ | |
| 184 | port, | |
| 185 | dataDir: input.dataDir, | |
| 186 | env: input.env, | |
| 187 | url: input.url, | |
| 188 | commitOid: input.commitOid, | |
| 189 | forgeUrl: input.forgeUrl, | |
| 190 | }), | |
| 191 | stdin: "ignore", | |
| 192 | stdout: "pipe", | |
| 193 | stderr: "pipe", | |
| 194 | }); | |
| 195 | // Output is pumped for the life of the process, not just the start. | |
| 196 | const recent: string[] = []; | |
| 197 | const remember = (line: string): void => { | |
| 198 | recent.push(line); | |
| 199 | if (recent.length > 20) recent.shift(); | |
| 200 | log(line); | |
| 201 | }; | |
| 202 | void pump(proc.stdout, remember); | |
| 203 | void pump(proc.stderr, remember); | |
| 204 | ||
| 205 | const handle: ProcessHandle = { | |
| 206 | pid: proc.pid, | |
| 207 | port, | |
| 208 | exited: proc.exited, | |
| 209 | stop: async () => { | |
| 210 | if (proc.exitCode !== null || proc.signalCode !== null) return; | |
| 211 | proc.kill("SIGTERM"); | |
| 212 | const gone = await Promise.race([ | |
| 213 | proc.exited.then(() => true), | |
| 214 | Bun.sleep(STOP_GRACE_MS).then(() => false), | |
| 215 | ]); | |
| 216 | if (!gone) { | |
| 217 | proc.kill("SIGKILL"); | |
| 218 | await proc.exited; | |
| 219 | } | |
| 220 | }, | |
| 221 | }; | |
| 222 | ||
| 223 | const ready = await waitUntilReady({ | |
| 224 | port, | |
| 225 | path: manifest.health, | |
| 226 | timeoutMs: manifest.ready * 1000, | |
| 227 | exited: proc.exited, | |
| 228 | signal: input.signal, | |
| 229 | }); | |
| 230 | if (!ready.ok) { | |
| 231 | await handle.stop(); | |
| 232 | const tail = | |
| 233 | recent.length > 0 | |
| 234 | ? ` — last output: ${recent.slice(-3).join(" | ")}` | |
| 235 | : ""; | |
| 236 | return err({ | |
| 237 | stage: ready.error.stage, | |
| 238 | message: ready.error.message + tail, | |
| 239 | }); | |
| 240 | } | |
| 241 | log(`ready: ${input.url}`); | |
| 242 | return ok(handle); | |
| 243 | } | |
| 244 | ||
| 245 | /** | |
| 246 | * Poll the health path until the app answers. Any HTTP answer below 500 | |
| 247 | * counts — a 404 on `/` is an app that is up and has no root page — and an | |
| 248 | * exit, an abort, or the deadline is a failure with the reason. | |
| 249 | */ | |
| 250 | async function waitUntilReady(input: { | |
| 251 | port: number; | |
| 252 | path: string; | |
| 253 | timeoutMs: number; | |
| 254 | exited: Promise<number>; | |
| 255 | signal: AbortSignal; | |
| 256 | }): Promise<Result<void, StartError>> { | |
| 257 | const deadline = Date.now() + input.timeoutMs; | |
| 258 | let exitCode: number | undefined; | |
| 259 | void input.exited.then((code) => { | |
| 260 | exitCode = code; | |
| 261 | }); | |
| 262 | let lastStatus: number | undefined; | |
| 263 | while (Date.now() < deadline) { | |
| 264 | if (input.signal.aborted) | |
| 265 | return err({ stage: "start", message: "cancelled" }); | |
| 266 | if (exitCode !== undefined) | |
| 267 | return err({ | |
| 268 | stage: "start", | |
| 269 | message: `exited with code ${exitCode} before answering`, | |
| 270 | }); | |
| 271 | try { | |
| 272 | const response = await fetch( | |
| 273 | `http://127.0.0.1:${input.port}${input.path}`, | |
| 274 | { | |
| 275 | signal: AbortSignal.timeout(2_000), | |
| 276 | redirect: "manual", | |
| 277 | }, | |
| 278 | ); | |
| 279 | lastStatus = response.status; | |
| 280 | await response.body?.cancel(); | |
| 281 | if (response.status < 500) return ok(); | |
| 282 | } catch { | |
| 283 | // Not listening yet. | |
| 284 | } | |
| 285 | await Bun.sleep(HEALTH_INTERVAL_MS); | |
| 286 | } | |
| 287 | return err({ | |
| 288 | stage: "ready", | |
| 289 | message: | |
| 290 | lastStatus === undefined | |
| 291 | ? `no answer on ${input.path} within ${Math.round(input.timeoutMs / 1000)}s` | |
| 292 | : `${input.path} answered ${lastStatus} for ${Math.round(input.timeoutMs / 1000)}s`, | |
| 293 | }); | |
| 294 | } | |
| 295 | ||
| 296 | /** A port the OS says is free right now. */ | |
| 297 | export function freePort(): number { | |
| 298 | const probe = Bun.serve({ | |
| 299 | port: 0, | |
| 300 | hostname: "127.0.0.1", | |
| 301 | fetch: () => new Response(""), | |
| 302 | }); | |
| 303 | const port = probe.port ?? 0; | |
| 304 | probe.stop(true); | |
| 305 | return port; | |
| 306 | } | |
| 307 | ||
| 308 | /** Read a stream as lines, calling `onLine` for each; never throws. */ | |
| 309 | export async function pump( | |
| 310 | stream: ReadableStream<Uint8Array> | null | undefined, | |
| 311 | onLine: (line: string) => void, | |
| 312 | ): Promise<void> { | |
| 313 | if (!stream) return; | |
| 314 | const decoder = new TextDecoder(); | |
| 315 | let carry = ""; | |
| 316 | try { | |
| 317 | for await (const chunk of stream) { | |
| 318 | carry += decoder.decode(chunk, { stream: true }); | |
| 319 | let at = carry.indexOf("\n"); | |
| 320 | while (at !== -1) { | |
| 321 | onLine(carry.slice(0, at).replace(/\r$/, "")); | |
| 322 | carry = carry.slice(at + 1); | |
| 323 | at = carry.indexOf("\n"); | |
| 324 | } | |
| 325 | } | |
| 326 | carry += decoder.decode(); | |
| 327 | if (carry !== "") onLine(carry); | |
| 328 | } catch { | |
| 329 | // The process went away mid-read; whatever arrived was delivered. | |
| 330 | } | |
| 331 | } |
src/apps/proxy.ts added +305
| @@ -0,0 +1,305 @@ | ||
| 1 | /** | |
| 2 | * Apps on the forge's own port, by `Host`. `<slug>.<domain>` is production, | |
| 3 | * `<slug>--<n>.<domain>` the preview of pull `n`; the request is fetched | |
| 4 | * from the deployment's loopback port and streamed back. | |
| 5 | * | |
| 6 | * A private app answers only to a visitor the forge has vouched for. The | |
| 7 | * app's host sets no cookie of its own; the forge sends the visitor to | |
| 8 | * `/__forge/grant?t=…` on the app's host with a token it signed after | |
| 9 | * checking `canRead`, and that handler sets the grant cookie. Nothing on | |
| 10 | * the app host can mint one — the key never leaves the forge's data | |
| 11 | * directory — so guessing a hostname yields the forge's own "not found". | |
| 12 | */ | |
| 13 | ||
| 14 | import { createHmac, timingSafeEqual } from "node:crypto"; | |
| 15 | import type { App } from "../app.ts"; | |
| 16 | import { getAppBySlug, type AppRecord } from "../core/apps.ts"; | |
| 17 | import { loadKey } from "../kernel/secret.ts"; | |
| 18 | import { previewEnv, type EnvName } from "./data.ts"; | |
| 19 | import type { AppsRuntime, EnvStatus } from "./runtime.ts"; | |
| 20 | ||
| 21 | export interface AppTarget { | |
| 22 | readonly app: AppRecord; | |
| 23 | readonly env: EnvName; | |
| 24 | } | |
| 25 | ||
| 26 | /** | |
| 27 | * Which app and environment a `Host` names, if any. Hostnames are | |
| 28 | * case-insensitive and may carry a port. Only one label may sit in front | |
| 29 | * of the domain: `a.b.<domain>` is not an app. | |
| 30 | */ | |
| 31 | export function parseAppHost( | |
| 32 | hostHeader: string, | |
| 33 | domain: string, | |
| 34 | ): { slug: string; env: EnvName } | undefined { | |
| 35 | const host = hostHeader.toLowerCase().replace(/:\d+$/, ""); | |
| 36 | const suffix = `.${domain.toLowerCase().replace(/:\d+$/, "")}`; | |
| 37 | if (!host.endsWith(suffix)) return undefined; | |
| 38 | const label = host.slice(0, -suffix.length); | |
| 39 | if (label === "" || label.includes(".")) return undefined; | |
| 40 | const at = label.indexOf("--"); | |
| 41 | if (at === -1) return { slug: label, env: "production" }; | |
| 42 | const slug = label.slice(0, at); | |
| 43 | const number = label.slice(at + 2); | |
| 44 | if (!/^[1-9]\d*$/.test(number) || slug === "") return undefined; | |
| 45 | return { slug, env: previewEnv(Number(number)) }; | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * The app a request's `Host` addresses, resolved against the database. | |
| 50 | * `"unknown"` is a hostname shaped like an app's that names none — it must | |
| 51 | * not fall through to the forge's own pages, or `nothing.<domain>` would | |
| 52 | * render the forge's home under a stranger's name. | |
| 53 | */ | |
| 54 | export function resolveAppHost( | |
| 55 | app: App, | |
| 56 | domain: string, | |
| 57 | request: Request, | |
| 58 | ): AppTarget | "unknown" | undefined { | |
| 59 | const host = request.headers.get("host"); | |
| 60 | if (host === null) return undefined; | |
| 61 | const parsed = parseAppHost(host, domain); | |
| 62 | if (parsed === undefined) return undefined; | |
| 63 | const record = getAppBySlug(app.db, parsed.slug); | |
| 64 | return record ? { app: record, env: parsed.env } : "unknown"; | |
| 65 | } | |
| 66 | ||
| 67 | const GRANT_COOKIE = "forge_grant"; | |
| 68 | const GRANT_URL_TTL_MS = 2 * 60_000; | |
| 69 | const GRANT_COOKIE_TTL_MS = 12 * 60 * 60_000; | |
| 70 | ||
| 71 | /** A signed grant: `<app id>.<env>.<expiry ms>.<signature>`. */ | |
| 72 | export function signGrant( | |
| 73 | dataDir: string, | |
| 74 | target: AppTarget, | |
| 75 | ttlMs: number, | |
| 76 | now = Date.now(), | |
| 77 | ): string { | |
| 78 | const payload = `${target.app.id}.${target.env}.${now + ttlMs}`; | |
| 79 | return `${payload}.${mac(dataDir, payload)}`; | |
| 80 | } | |
| 81 | ||
| 82 | function mac(dataDir: string, payload: string): string { | |
| 83 | return createHmac("sha256", loadKey(dataDir)) | |
| 84 | .update(payload) | |
| 85 | .digest("base64url"); | |
| 86 | } | |
| 87 | ||
| 88 | /** Whether `grant` vouches for this target right now. */ | |
| 89 | export function verifyGrant( | |
| 90 | dataDir: string, | |
| 91 | grant: string, | |
| 92 | target: AppTarget, | |
| 93 | now = Date.now(), | |
| 94 | ): boolean { | |
| 95 | const parts = grant.split("."); | |
| 96 | if (parts.length !== 4) return false; | |
| 97 | const [appId, env, expires, signature] = parts as [ | |
| 98 | string, | |
| 99 | string, | |
| 100 | string, | |
| 101 | string, | |
| 102 | ]; | |
| 103 | if (appId !== target.app.id || env !== target.env) return false; | |
| 104 | if (!/^\d+$/.test(expires) || Number(expires) <= now) return false; | |
| 105 | const expected = mac(dataDir, `${appId}.${env}.${expires}`); | |
| 106 | const a = Buffer.from(signature); | |
| 107 | const b = Buffer.from(expected); | |
| 108 | return a.length === b.length && timingSafeEqual(a, b); | |
| 109 | } | |
| 110 | ||
| 111 | /** The URL the forge redirects a vouched-for visitor to. */ | |
| 112 | export function grantUrl( | |
| 113 | runtime: AppsRuntime, | |
| 114 | target: AppTarget, | |
| 115 | path = "/", | |
| 116 | ): string { | |
| 117 | const token = signGrant(runtime.app.config.dataDir, target, GRANT_URL_TTL_MS); | |
| 118 | const base = runtime.urlFor(target.app, target.env); | |
| 119 | return `${base}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(path)}`; | |
| 120 | } | |
| 121 | ||
| 122 | const HOP_BY_HOP = [ | |
| 123 | "connection", | |
| 124 | "keep-alive", | |
| 125 | "proxy-authenticate", | |
| 126 | "proxy-authorization", | |
| 127 | "te", | |
| 128 | "trailer", | |
| 129 | "transfer-encoding", | |
| 130 | "upgrade", | |
| 131 | ]; | |
| 132 | ||
| 133 | /** Serve a request addressed to an app. */ | |
| 134 | export async function proxyApp( | |
| 135 | runtime: AppsRuntime, | |
| 136 | request: Request, | |
| 137 | target: AppTarget, | |
| 138 | ): Promise<Response> { | |
| 139 | const url = new URL(request.url); | |
| 140 | const status = runtime.envStatus(target.app, target.env); | |
| 141 | ||
| 142 | if (url.pathname === "/__forge/grant") { | |
| 143 | return grant(runtime, request, url, target); | |
| 144 | } | |
| 145 | if (target.app.access === "private") { | |
| 146 | const cookie = cookieValue(request, GRANT_COOKIE); | |
| 147 | if ( | |
| 148 | cookie === undefined || | |
| 149 | !verifyGrant(runtime.app.config.dataDir, cookie, target) | |
| 150 | ) { | |
| 151 | // The same answer the forge gives for a repository you cannot see. | |
| 152 | return new Response("not found", { status: 404 }); | |
| 153 | } | |
| 154 | } | |
| 155 | if (status === undefined || status.port === undefined) { | |
| 156 | return unavailable(runtime, target, status); | |
| 157 | } | |
| 158 | if (request.headers.get("upgrade")?.toLowerCase() === "websocket") { | |
| 159 | return new Response("websockets are not proxied to apps yet", { | |
| 160 | status: 501, | |
| 161 | }); | |
| 162 | } | |
| 163 | ||
| 164 | const upstream = new URL(url); | |
| 165 | upstream.protocol = "http:"; | |
| 166 | upstream.hostname = "127.0.0.1"; | |
| 167 | upstream.port = String(status.port); | |
| 168 | const headers = new Headers(request.headers); | |
| 169 | for (const name of HOP_BY_HOP) headers.delete(name); | |
| 170 | headers.set("x-forwarded-host", request.headers.get("host") ?? ""); | |
| 171 | headers.set("x-forwarded-proto", url.protocol.replace(":", "")); | |
| 172 | let response: Response; | |
| 173 | try { | |
| 174 | response = await fetch(upstream, { | |
| 175 | method: request.method, | |
| 176 | headers, | |
| 177 | body: | |
| 178 | request.method === "GET" || request.method === "HEAD" | |
| 179 | ? null | |
| 180 | : request.body, | |
| 181 | redirect: "manual", | |
| 182 | signal: request.signal, | |
| 183 | // Bytes through, untouched: the app chose its encoding. | |
| 184 | decompress: false, | |
| 185 | } as RequestInit); | |
| 186 | } catch { | |
| 187 | return unavailable(runtime, target, status, "the app stopped answering"); | |
| 188 | } | |
| 189 | const out = new Headers(response.headers); | |
| 190 | for (const name of HOP_BY_HOP) out.delete(name); | |
| 191 | return new Response(response.body, { | |
| 192 | status: response.status, | |
| 193 | statusText: response.statusText, | |
| 194 | headers: out, | |
| 195 | }); | |
| 196 | } | |
| 197 | ||
| 198 | function grant( | |
| 199 | runtime: AppsRuntime, | |
| 200 | request: Request, | |
| 201 | url: URL, | |
| 202 | target: AppTarget, | |
| 203 | ): Response { | |
| 204 | const token = url.searchParams.get("t") ?? ""; | |
| 205 | if (!verifyGrant(runtime.app.config.dataDir, token, target)) { | |
| 206 | return new Response("not found", { status: 404 }); | |
| 207 | } | |
| 208 | const cookie = signGrant( | |
| 209 | runtime.app.config.dataDir, | |
| 210 | target, | |
| 211 | GRANT_COOKIE_TTL_MS, | |
| 212 | ); | |
| 213 | const to = url.searchParams.get("to") ?? "/"; | |
| 214 | const path = to.startsWith("/") && !to.startsWith("//") ? to : "/"; | |
| 215 | const secure = | |
| 216 | url.protocol === "https:" || | |
| 217 | request.headers.get("x-forwarded-proto") === "https"; | |
| 218 | return new Response(null, { | |
| 219 | status: 303, | |
| 220 | headers: { | |
| 221 | location: path, | |
| 222 | "set-cookie": `${GRANT_COOKIE}=${cookie}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(GRANT_COOKIE_TTL_MS / 1000)}${secure ? "; Secure" : ""}`, | |
| 223 | "cache-control": "no-store", | |
| 224 | }, | |
| 225 | }); | |
| 226 | } | |
| 227 | ||
| 228 | function cookieValue(request: Request, name: string): string | undefined { | |
| 229 | const header = request.headers.get("cookie"); | |
| 230 | if (header === null) return undefined; | |
| 231 | for (const part of header.split(";")) { | |
| 232 | const at = part.indexOf("="); | |
| 233 | if (at === -1) continue; | |
| 234 | if (part.slice(0, at).trim() === name) return part.slice(at + 1).trim(); | |
| 235 | } | |
| 236 | return undefined; | |
| 237 | } | |
| 238 | ||
| 239 | /** | |
| 240 | * The page an environment that is not serving shows: what it is doing, in | |
| 241 | * words, with the run to look at. Never a bare gateway error. | |
| 242 | */ | |
| 243 | function unavailable( | |
| 244 | runtime: AppsRuntime, | |
| 245 | target: AppTarget, | |
| 246 | status: EnvStatus | undefined, | |
| 247 | reason?: string, | |
| 248 | ): Response { | |
| 249 | const name = target.app.slug; | |
| 250 | const where = | |
| 251 | target.env === "production" | |
| 252 | ? "production" | |
| 253 | : `the preview of pull #${target.env.slice(5)}`; | |
| 254 | const state = status?.state ?? "stopped"; | |
| 255 | const deploying = state === "deploying"; | |
| 256 | const message = | |
| 257 | reason ?? | |
| 258 | (deploying | |
| 259 | ? "A new version is starting." | |
| 260 | : state === "failed" | |
| 261 | ? (status?.error ?? "The last deploy failed.") | |
| 262 | : "Nothing is running here yet."); | |
| 263 | const runLink = | |
| 264 | status?.runId === undefined | |
| 265 | ? "" | |
| 266 | : `<p><a href="${escape(runtime.options.forgeUrl)}/runs/${escape(status.runId)}">See the run on the forge</a></p>`; | |
| 267 | const html = `<!doctype html> | |
| 268 | <meta charset="utf-8"> | |
| 269 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 270 | ${deploying ? '<meta http-equiv="refresh" content="3">' : ""} | |
| 271 | <title>${escape(name)} — ${deploying ? "starting" : "not running"}</title> | |
| 272 | <style> | |
| 273 | :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; } | |
| 274 | body { max-width: 34rem; margin: 20vh auto; padding: 0 1.5rem; } | |
| 275 | h1 { font-size: 1.25rem; margin: 0 0 .5rem; } | |
| 276 | p { margin: .5rem 0; opacity: .85; } | |
| 277 | code { font-size: .9em; } | |
| 278 | </style> | |
| 279 | <h1>${escape(name)} ${deploying ? "is starting" : "is not running"}</h1> | |
| 280 | <p>This is ${escape(where)}. ${escape(message)}</p> | |
| 281 | ${deploying ? "<p>This page refreshes on its own.</p>" : ""} | |
| 282 | ${runLink} | |
| 283 | `; | |
| 284 | return new Response(html, { | |
| 285 | status: deploying ? 503 : 502, | |
| 286 | headers: { | |
| 287 | "content-type": "text/html; charset=utf-8", | |
| 288 | "cache-control": "no-store", | |
| 289 | ...(deploying ? { "retry-after": "3" } : {}), | |
| 290 | }, | |
| 291 | }); | |
| 292 | } | |
| 293 | ||
| 294 | const escape = (s: string): string => | |
| 295 | s.replace(/[&<>"']/g, (c) => | |
| 296 | c === "&" | |
| 297 | ? "&" | |
| 298 | : c === "<" | |
| 299 | ? "<" | |
| 300 | : c === ">" | |
| 301 | ? ">" | |
| 302 | : c === '"' | |
| 303 | ? """ | |
| 304 | : "'", | |
| 305 | ); |
src/apps/runtime.ts added +734
| @@ -0,0 +1,734 @@ | ||
| 1 | /** | |
| 2 | * What is running. One `AppsRuntime` per forge holds every environment of | |
| 3 | * every app as an in-memory record — a process is not state — and rebuilds | |
| 4 | * the whole set on boot from the default branch and the open pulls, the | |
| 5 | * way `core/reconcile.ts` rebuilds the index from git. | |
| 6 | * | |
| 7 | * It listens to the outbox and it sweeps: a push to the default branch | |
| 8 | * redeploys production, a push to a pull's branch redeploys its preview, a | |
| 9 | * pull opening gets a preview and a pull closing loses it. Every deploy is | |
| 10 | * a run row with a log; a failed deploy leaves the previous deployment | |
| 11 | * serving. Deploys are serial per environment and a newer request | |
| 12 | * supersedes one still queued, so a burst of pushes ends at the last | |
| 13 | * commit, once. | |
| 14 | */ | |
| 15 | ||
| 16 | import { existsSync, readdirSync, rmSync } from "node:fs"; | |
| 17 | import { join } from "node:path"; | |
| 18 | import type { App } from "../app.ts"; | |
| 19 | import { getAppByRepo, listApps, type AppRecord } from "../core/apps.ts"; | |
| 20 | import type { ForgeEvent } from "../core/events.ts"; | |
| 21 | import { announceRun } from "../actions/runs.ts"; | |
| 22 | import { getPull, listPulls, type Pull } from "../core/pulls.ts"; | |
| 23 | import { getRepoById, type Repo } from "../core/repos.ts"; | |
| 24 | import { | |
| 25 | cancelRun, | |
| 26 | claimRun, | |
| 27 | createRun, | |
| 28 | failOrphanedRuns, | |
| 29 | finishRun, | |
| 30 | type Run, | |
| 31 | } from "../core/runs.ts"; | |
| 32 | import { Gate } from "../git/env.ts"; | |
| 33 | import { resolveOid } from "../git/merge.ts"; | |
| 34 | import { repoDir } from "../git/repos.ts"; | |
| 35 | import { createLog } from "../kernel/log.ts"; | |
| 36 | import { appendRunLog } from "../runs/log.ts"; | |
| 37 | import { exportTree, readFileAt } from "./checkout.ts"; | |
| 38 | import { | |
| 39 | appsRoot, | |
| 40 | ensureEnvData, | |
| 41 | envDataDir, | |
| 42 | envDir, | |
| 43 | envPull, | |
| 44 | isEnvName, | |
| 45 | previewEnv, | |
| 46 | removeApp, | |
| 47 | removeEnv, | |
| 48 | type EnvName, | |
| 49 | } from "./data.ts"; | |
| 50 | import { parseManifest, type AppManifest } from "./manifest.ts"; | |
| 51 | import { startProcess, type ProcessHandle } from "./process.ts"; | |
| 52 | ||
| 53 | const log = createLog("apps"); | |
| 54 | ||
| 55 | export interface RuntimeOptions { | |
| 56 | /** The domain app hostnames hang off: `<slug>.<domain>`. */ | |
| 57 | readonly domain: string; | |
| 58 | /** `http` or `https` — what the public URL of an app starts with. */ | |
| 59 | readonly scheme: "http" | "https"; | |
| 60 | /** The port in public app URLs, or undefined for the scheme's default. */ | |
| 61 | readonly publicPort?: number | undefined; | |
| 62 | /** The forge's own public URL, for the app's `FORGE_URL` and links. */ | |
| 63 | readonly forgeUrl: string; | |
| 64 | /** How many deploys may run their heavy steps at once. @default 2 */ | |
| 65 | readonly concurrency?: number | undefined; | |
| 66 | } | |
| 67 | ||
| 68 | export type EnvState = "stopped" | "deploying" | "running" | "failed"; | |
| 69 | ||
| 70 | export interface EnvStatus { | |
| 71 | readonly env: EnvName; | |
| 72 | readonly state: EnvState; | |
| 73 | /** The commit serving, or being brought up. */ | |
| 74 | readonly commitOid: string; | |
| 75 | readonly url: string; | |
| 76 | readonly port?: number | undefined; | |
| 77 | /** The run that produced this state — a link to its log. */ | |
| 78 | readonly runId?: string | undefined; | |
| 79 | readonly since?: string | undefined; | |
| 80 | readonly error?: string | undefined; | |
| 81 | } | |
| 82 | ||
| 83 | interface Live { | |
| 84 | readonly commitOid: string; | |
| 85 | readonly handle: ProcessHandle; | |
| 86 | readonly dir: string; | |
| 87 | readonly runId: string; | |
| 88 | readonly since: string; | |
| 89 | /** How this checkout runs — its `tty` is what an ssh session opens. */ | |
| 90 | readonly manifest: AppManifest; | |
| 91 | } | |
| 92 | ||
| 93 | /** A running environment, as a terminal session needs it. */ | |
| 94 | export interface LiveEnv { | |
| 95 | readonly env: EnvName; | |
| 96 | readonly dir: string; | |
| 97 | readonly dataDir: string; | |
| 98 | readonly port: number; | |
| 99 | readonly commitOid: string; | |
| 100 | readonly url: string; | |
| 101 | readonly tty: string | null; | |
| 102 | } | |
| 103 | ||
| 104 | interface Environment { | |
| 105 | readonly app: AppRecord; | |
| 106 | readonly env: EnvName; | |
| 107 | live?: Live | undefined; | |
| 108 | /** The deploy in flight, if one is. */ | |
| 109 | deploying?: { run: Run; abort: AbortController } | undefined; | |
| 110 | /** The next deploy, waiting its turn; replaced by a newer request. */ | |
| 111 | queued?: Run | undefined; | |
| 112 | chain: Promise<void>; | |
| 113 | lastError?: string | undefined; | |
| 114 | lastRunId?: string | undefined; | |
| 115 | /** Crash restarts in the current window, for backing off. */ | |
| 116 | restarts: number[]; | |
| 117 | } | |
| 118 | ||
| 119 | const RESTART_WINDOW_MS = 10 * 60_000; | |
| 120 | const RESTART_MAX = 3; | |
| 121 | const RESTART_BACKOFF_MS = [2_000, 10_000, 30_000]; | |
| 122 | ||
| 123 | const runtimes = new WeakMap<App, AppsRuntime>(); | |
| 124 | ||
| 125 | /** The runtime serving this forge, if apps are on. */ | |
| 126 | export const appsRuntime = (app: App): AppsRuntime | undefined => | |
| 127 | runtimes.get(app); | |
| 128 | ||
| 129 | export class AppsRuntime { | |
| 130 | private readonly envs = new Map<string, Environment>(); | |
| 131 | private readonly gate: Gate; | |
| 132 | private unsubscribe: (() => void) | undefined; | |
| 133 | private stopped = false; | |
| 134 | ||
| 135 | constructor( | |
| 136 | readonly app: App, | |
| 137 | readonly options: RuntimeOptions, | |
| 138 | ) { | |
| 139 | this.gate = new Gate(options.concurrency ?? 2); | |
| 140 | runtimes.set(app, this); | |
| 141 | } | |
| 142 | ||
| 143 | // ---- addressing ---- | |
| 144 | ||
| 145 | hostFor(slug: string, env: EnvName): string { | |
| 146 | const label = env === "production" ? slug : `${slug}--${env.slice(5)}`; | |
| 147 | return `${label}.${this.options.domain}`; | |
| 148 | } | |
| 149 | ||
| 150 | urlFor(record: AppRecord, env: EnvName): string { | |
| 151 | const port = | |
| 152 | this.options.publicPort === undefined || | |
| 153 | (this.options.scheme === "http" && this.options.publicPort === 80) || | |
| 154 | (this.options.scheme === "https" && this.options.publicPort === 443) | |
| 155 | ? "" | |
| 156 | : `:${this.options.publicPort}`; | |
| 157 | return `${this.options.scheme}://${this.hostFor(record.slug, env)}${port}`; | |
| 158 | } | |
| 159 | ||
| 160 | // ---- observation ---- | |
| 161 | ||
| 162 | envStatus(record: AppRecord, env: EnvName): EnvStatus | undefined { | |
| 163 | const state = this.envs.get(key(record.id, env)); | |
| 164 | if (state === undefined) return undefined; | |
| 165 | return this.describe(state); | |
| 166 | } | |
| 167 | ||
| 168 | /** Every environment of an app, production first. */ | |
| 169 | status(record: AppRecord): EnvStatus[] { | |
| 170 | const out: EnvStatus[] = []; | |
| 171 | for (const state of this.envs.values()) { | |
| 172 | if (state.app.id === record.id) out.push(this.describe(state)); | |
| 173 | } | |
| 174 | // Production first, then previews by pull number — a number, not a | |
| 175 | // string, so `pull-10` follows `pull-9`. | |
| 176 | return out.sort((a, b) => | |
| 177 | a.env === "production" | |
| 178 | ? -1 | |
| 179 | : b.env === "production" | |
| 180 | ? 1 | |
| 181 | : (envPull(a.env) ?? 0) - (envPull(b.env) ?? 0), | |
| 182 | ); | |
| 183 | } | |
| 184 | ||
| 185 | private describe(state: Environment): EnvStatus { | |
| 186 | const url = this.urlFor(state.app, state.env); | |
| 187 | if (state.deploying !== undefined) { | |
| 188 | return { | |
| 189 | env: state.env, | |
| 190 | state: "deploying", | |
| 191 | commitOid: state.deploying.run.commitOid, | |
| 192 | url, | |
| 193 | ...(state.live === undefined ? {} : { port: state.live.handle.port }), | |
| 194 | runId: state.deploying.run.id, | |
| 195 | ...(state.live === undefined ? {} : { since: state.live.since }), | |
| 196 | }; | |
| 197 | } | |
| 198 | if (state.live !== undefined) { | |
| 199 | return { | |
| 200 | env: state.env, | |
| 201 | state: "running", | |
| 202 | commitOid: state.live.commitOid, | |
| 203 | url, | |
| 204 | port: state.live.handle.port, | |
| 205 | runId: state.live.runId, | |
| 206 | since: state.live.since, | |
| 207 | }; | |
| 208 | } | |
| 209 | return { | |
| 210 | env: state.env, | |
| 211 | state: state.lastError === undefined ? "stopped" : "failed", | |
| 212 | commitOid: "", | |
| 213 | url, | |
| 214 | runId: state.lastRunId, | |
| 215 | error: state.lastError, | |
| 216 | }; | |
| 217 | } | |
| 218 | ||
| 219 | // ---- lifecycle ---- | |
| 220 | ||
| 221 | /** Rebuild every environment from git and the open pulls, then listen. */ | |
| 222 | async start(): Promise<void> { | |
| 223 | const orphaned = failOrphanedRuns(this.app.db); | |
| 224 | if (orphaned > 0) log.info("failed orphaned runs", { count: orphaned }); | |
| 225 | this.unsubscribe = this.app.bus.subscribe((event) => { | |
| 226 | // Never let an event handler's rejection reach the bus. | |
| 227 | this.onEvent(event).catch((error) => | |
| 228 | log.error("apps event handler failed", { | |
| 229 | kind: event.kind, | |
| 230 | error: String(error), | |
| 231 | }), | |
| 232 | ); | |
| 233 | }); | |
| 234 | const known = new Set<string>(); | |
| 235 | for (const record of listApps(this.app.db)) { | |
| 236 | known.add(record.id); | |
| 237 | const repo = getRepoById(this.app.db, record.repoId); | |
| 238 | if (!repo) continue; | |
| 239 | await this.reconcileApp(record, repo, "boot"); | |
| 240 | } | |
| 241 | this.pruneDirs(known); | |
| 242 | } | |
| 243 | ||
| 244 | /** Stop every process. The rows stay; a restart brings them back. */ | |
| 245 | async stop(): Promise<void> { | |
| 246 | this.stopped = true; | |
| 247 | this.unsubscribe?.(); | |
| 248 | const stops: Promise<void>[] = []; | |
| 249 | for (const state of this.envs.values()) { | |
| 250 | state.deploying?.abort.abort(); | |
| 251 | if (state.queued) | |
| 252 | cancelRun(this.app.db, state.queued.id, "the forge stopped"); | |
| 253 | if (state.live) stops.push(state.live.handle.stop()); | |
| 254 | // Abort only cancels the step in flight; the `execute()` continuation | |
| 255 | // chained onto `state.chain` still runs afterwards to record the | |
| 256 | // outcome (`finishRun`, `announceRun`). Wait for it too, or it can | |
| 257 | // touch `this.app.db` after a caller who awaited `stop()` closes it. | |
| 258 | stops.push(state.chain.catch(() => {})); | |
| 259 | } | |
| 260 | await Promise.all(stops); | |
| 261 | runtimes.delete(this.app); | |
| 262 | } | |
| 263 | ||
| 264 | /** | |
| 265 | * Make the environments match the repository: production at the default | |
| 266 | * branch, a preview per open pull, and nothing for pulls that closed | |
| 267 | * while the forge was away. | |
| 268 | */ | |
| 269 | async reconcileApp( | |
| 270 | record: AppRecord, | |
| 271 | repo: Repo, | |
| 272 | reason: string, | |
| 273 | ): Promise<void> { | |
| 274 | const dir = repoDir(this.app.reposRoot, repo.ownerName, repo.name); | |
| 275 | const tip = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 276 | if (tip !== undefined) | |
| 277 | this.requestDeploy(record, repo, "production", tip, reason); | |
| 278 | const open = new Set<EnvName>(["production"]); | |
| 279 | for (const pull of listPulls(this.app.db, repo.id, { | |
| 280 | state: "open", | |
| 281 | limit: 200, | |
| 282 | })) { | |
| 283 | const env = previewEnv(pull.number); | |
| 284 | open.add(env); | |
| 285 | const head = await this.pullHead(dir, pull); | |
| 286 | if (head !== undefined) | |
| 287 | this.requestDeploy(record, repo, env, head, reason); | |
| 288 | } | |
| 289 | // Environments on disk for pulls that are no longer open. | |
| 290 | const root = join(appsRoot(this.app.config.dataDir), record.id); | |
| 291 | if (existsSync(root)) { | |
| 292 | for (const name of readdirSync(root)) { | |
| 293 | if (isEnvName(name) && !open.has(name)) | |
| 294 | await this.teardown(record, name); | |
| 295 | } | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | /** Data on disk for apps that no longer exist. */ | |
| 300 | private pruneDirs(known: Set<string>): void { | |
| 301 | const root = appsRoot(this.app.config.dataDir); | |
| 302 | if (!existsSync(root)) return; | |
| 303 | for (const name of readdirSync(root)) { | |
| 304 | if (!known.has(name)) | |
| 305 | rmSync(join(root, name), { recursive: true, force: true }); | |
| 306 | } | |
| 307 | } | |
| 308 | ||
| 309 | private async pullHead(dir: string, pull: Pull): Promise<string | undefined> { | |
| 310 | const ref = pull.crossRepo | |
| 311 | ? `refs/pull/${pull.number}/head` | |
| 312 | : `refs/heads/${pull.headBranch}`; | |
| 313 | return (await resolveOid(dir, ref)) ?? (pull.headOid || undefined); | |
| 314 | } | |
| 315 | ||
| 316 | // ---- events ---- | |
| 317 | ||
| 318 | private async onEvent(event: ForgeEvent): Promise<void> { | |
| 319 | if (this.stopped || event.repoId === null) return; | |
| 320 | if (event.kind === "repo.deleted") { | |
| 321 | await this.teardownAll(event.repoId); | |
| 322 | return; | |
| 323 | } | |
| 324 | const record = getAppByRepo(this.app.db, event.repoId); | |
| 325 | if (!record) return; | |
| 326 | const repo = getRepoById(this.app.db, event.repoId); | |
| 327 | if (!repo) return; | |
| 328 | const dir = repoDir(this.app.reposRoot, repo.ownerName, repo.name); | |
| 329 | ||
| 330 | switch (event.kind) { | |
| 331 | case "app.created": | |
| 332 | await this.reconcileApp(record, repo, "enabled"); | |
| 333 | return; | |
| 334 | case "app.deleted": | |
| 335 | await this.teardownAll(event.repoId); | |
| 336 | return; | |
| 337 | case "push": { | |
| 338 | const updates = event.payload["updates"]; | |
| 339 | if (!Array.isArray(updates)) return; | |
| 340 | for (const update of updates as { ref?: unknown; newOid?: unknown }[]) { | |
| 341 | if ( | |
| 342 | typeof update.ref !== "string" || | |
| 343 | typeof update.newOid !== "string" | |
| 344 | ) | |
| 345 | continue; | |
| 346 | if (!update.ref.startsWith("refs/heads/")) continue; | |
| 347 | const branch = update.ref.slice("refs/heads/".length); | |
| 348 | const deleted = /^0+$/.test(update.newOid); | |
| 349 | if (branch === repo.defaultBranch && !deleted) { | |
| 350 | this.requestDeploy( | |
| 351 | record, | |
| 352 | repo, | |
| 353 | "production", | |
| 354 | update.newOid, | |
| 355 | "push", | |
| 356 | event.actorId ?? undefined, | |
| 357 | ); | |
| 358 | } | |
| 359 | // Every open pull whose head is this branch previews the new tip. | |
| 360 | for (const pull of listPulls(this.app.db, repo.id, { | |
| 361 | state: "open", | |
| 362 | limit: 200, | |
| 363 | })) { | |
| 364 | if (pull.crossRepo || pull.headBranch !== branch || deleted) | |
| 365 | continue; | |
| 366 | this.requestDeploy( | |
| 367 | record, | |
| 368 | repo, | |
| 369 | previewEnv(pull.number), | |
| 370 | update.newOid, | |
| 371 | "push", | |
| 372 | event.actorId ?? undefined, | |
| 373 | ); | |
| 374 | } | |
| 375 | } | |
| 376 | return; | |
| 377 | } | |
| 378 | case "pull.opened": | |
| 379 | case "pull.reopened": { | |
| 380 | const pull = this.pullFromEvent(repo, event); | |
| 381 | if (!pull) return; | |
| 382 | const head = await this.pullHead(dir, pull); | |
| 383 | if (head !== undefined) | |
| 384 | this.requestDeploy( | |
| 385 | record, | |
| 386 | repo, | |
| 387 | previewEnv(pull.number), | |
| 388 | head, | |
| 389 | "opened", | |
| 390 | event.actorId ?? undefined, | |
| 391 | ); | |
| 392 | return; | |
| 393 | } | |
| 394 | case "pull.closed": | |
| 395 | case "pull.merged": { | |
| 396 | const number = event.payload["number"]; | |
| 397 | if (typeof number === "number") | |
| 398 | await this.teardown(record, previewEnv(number)); | |
| 399 | // A merge moves the default branch with `update-ref`, which runs no | |
| 400 | // hooks and so announces no push: production follows it from here. | |
| 401 | if (event.kind === "pull.merged") { | |
| 402 | const tip = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 403 | if (tip !== undefined) | |
| 404 | this.requestDeploy( | |
| 405 | record, | |
| 406 | repo, | |
| 407 | "production", | |
| 408 | tip, | |
| 409 | "merge", | |
| 410 | event.actorId ?? undefined, | |
| 411 | ); | |
| 412 | } | |
| 413 | return; | |
| 414 | } | |
| 415 | default: | |
| 416 | return; | |
| 417 | } | |
| 418 | } | |
| 419 | ||
| 420 | private pullFromEvent(repo: Repo, event: ForgeEvent): Pull | undefined { | |
| 421 | const number = event.payload["number"]; | |
| 422 | return typeof number === "number" | |
| 423 | ? getPull(this.app.db, repo.id, number) | |
| 424 | : undefined; | |
| 425 | } | |
| 426 | ||
| 427 | // ---- deploying ---- | |
| 428 | ||
| 429 | /** | |
| 430 | * Ask for `env` to run `commitOid`. Returns the run that will do it — or | |
| 431 | * the one already serving that commit, when nothing needs to happen. | |
| 432 | */ | |
| 433 | requestDeploy( | |
| 434 | record: AppRecord, | |
| 435 | repo: Repo, | |
| 436 | env: EnvName, | |
| 437 | commitOid: string, | |
| 438 | reason: string, | |
| 439 | actorId?: string, | |
| 440 | ): Run | undefined { | |
| 441 | if (this.stopped) return undefined; | |
| 442 | const state = this.envFor(record, env); | |
| 443 | if ( | |
| 444 | state.live?.commitOid === commitOid && | |
| 445 | state.deploying === undefined && | |
| 446 | state.queued === undefined && | |
| 447 | reason !== "restart" && | |
| 448 | reason !== "redeploy" | |
| 449 | ) { | |
| 450 | return undefined; | |
| 451 | } | |
| 452 | if (state.deploying?.run.commitOid === commitOid && reason !== "redeploy") { | |
| 453 | return state.deploying.run; | |
| 454 | } | |
| 455 | const issue = | |
| 456 | env === "production" ? undefined : this.pullIssueId(repo, env); | |
| 457 | const run = createRun(this.app.db, { | |
| 458 | repoId: repo.id, | |
| 459 | kind: "deploy", | |
| 460 | env, | |
| 461 | commitOid, | |
| 462 | reason, | |
| 463 | ...(issue === undefined ? {} : { issueId: issue }), | |
| 464 | ...(actorId === undefined ? {} : { actorId }), | |
| 465 | }); | |
| 466 | announceRun(this.app, run, actorId); | |
| 467 | if (state.queued !== undefined) { | |
| 468 | cancelRun( | |
| 469 | this.app.db, | |
| 470 | state.queued.id, | |
| 471 | `superseded by ${commitOid.slice(0, 7)}`, | |
| 472 | ); | |
| 473 | } | |
| 474 | state.queued = run; | |
| 475 | state.chain = state.chain.then(async () => { | |
| 476 | if (state.queued?.id !== run.id) return; // superseded | |
| 477 | state.queued = undefined; | |
| 478 | await this.execute(state, repo, run); | |
| 479 | }); | |
| 480 | return run; | |
| 481 | } | |
| 482 | ||
| 483 | private pullIssueId(repo: Repo, env: EnvName): string | undefined { | |
| 484 | const pull = getPull(this.app.db, repo.id, Number(env.slice(5))); | |
| 485 | return pull?.id; | |
| 486 | } | |
| 487 | ||
| 488 | private envFor(record: AppRecord, env: EnvName): Environment { | |
| 489 | const k = key(record.id, env); | |
| 490 | let state = this.envs.get(k); | |
| 491 | if (state === undefined) { | |
| 492 | state = { app: record, env, chain: Promise.resolve(), restarts: [] }; | |
| 493 | this.envs.set(k, state); | |
| 494 | } | |
| 495 | return state; | |
| 496 | } | |
| 497 | ||
| 498 | private async execute( | |
| 499 | state: Environment, | |
| 500 | repo: Repo, | |
| 501 | run: Run, | |
| 502 | ): Promise<void> { | |
| 503 | const claimed = claimRun(this.app.db, run.id); | |
| 504 | if (!claimed) return; // cancelled meanwhile | |
| 505 | const abort = new AbortController(); | |
| 506 | state.deploying = { run: claimed, abort }; | |
| 507 | state.lastRunId = run.id; | |
| 508 | const write = (line: string): void => | |
| 509 | appendRunLog(this.app.config.dataDir, run.id, line); | |
| 510 | announceRun(this.app, claimed); | |
| 511 | write( | |
| 512 | `deploy ${state.env} of ${repo.ownerName}/${repo.name} at ${run.commitOid.slice(0, 7)} (${run.reason})`, | |
| 513 | ); | |
| 514 | ||
| 515 | const outcome = await this.gate.run(() => | |
| 516 | this.bringUp(state, repo, run, write, abort.signal), | |
| 517 | ); | |
| 518 | ||
| 519 | state.deploying = undefined; | |
| 520 | if (outcome.ok) { | |
| 521 | const previous = state.live; | |
| 522 | state.live = outcome.value; | |
| 523 | state.lastError = undefined; | |
| 524 | if (previous !== undefined) { | |
| 525 | write( | |
| 526 | `stopping the previous deployment (${previous.commitOid.slice(0, 7)})`, | |
| 527 | ); | |
| 528 | await previous.handle.stop(); | |
| 529 | if (previous.dir !== outcome.value.dir) | |
| 530 | rmSync(previous.dir, { recursive: true, force: true }); | |
| 531 | } | |
| 532 | this.watch(state, repo, outcome.value); | |
| 533 | const url = this.urlFor(state.app, state.env); | |
| 534 | const done = finishRun(this.app.db, run.id, { | |
| 535 | state: "ok", | |
| 536 | summary: `running at ${url}`, | |
| 537 | }); | |
| 538 | write(`done: ${url}`); | |
| 539 | if (done) announceRun(this.app, done); | |
| 540 | } else { | |
| 541 | state.lastError = outcome.error; | |
| 542 | const done = finishRun(this.app.db, run.id, { | |
| 543 | state: "failed", | |
| 544 | summary: outcome.error, | |
| 545 | }); | |
| 546 | write(`failed: ${outcome.error}`); | |
| 547 | if (state.live !== undefined) | |
| 548 | write( | |
| 549 | `the previous deployment (${state.live.commitOid.slice(0, 7)}) keeps serving`, | |
| 550 | ); | |
| 551 | if (done) announceRun(this.app, done); | |
| 552 | } | |
| 553 | } | |
| 554 | ||
| 555 | private async bringUp( | |
| 556 | state: Environment, | |
| 557 | repo: Repo, | |
| 558 | run: Run, | |
| 559 | write: (line: string) => void, | |
| 560 | signal: AbortSignal, | |
| 561 | ): Promise<{ ok: true; value: Live } | { ok: false; error: string }> { | |
| 562 | const bare = repoDir(this.app.reposRoot, repo.ownerName, repo.name); | |
| 563 | const [forgeJson, packageJson] = await Promise.all([ | |
| 564 | readFileAt(bare, run.commitOid, "forge.json"), | |
| 565 | readFileAt(bare, run.commitOid, "package.json"), | |
| 566 | ]); | |
| 567 | const manifest = parseManifest(forgeJson, packageJson); | |
| 568 | if (!manifest.ok) { | |
| 569 | return { | |
| 570 | ok: false, | |
| 571 | error: | |
| 572 | manifest.error.kind === "not_an_app" | |
| 573 | ? "this commit is not an app: no forge.json with an app, and no package.json start script" | |
| 574 | : manifest.error.message, | |
| 575 | }; | |
| 576 | } | |
| 577 | const dir = join( | |
| 578 | envDir(this.app.config.dataDir, state.app.id, state.env), | |
| 579 | run.commitOid.slice(0, 7), | |
| 580 | ); | |
| 581 | write(`checking out ${run.commitOid.slice(0, 7)}`); | |
| 582 | const exported = await exportTree(bare, run.commitOid, dir); | |
| 583 | if (!exported.ok) return { ok: false, error: exported.error }; | |
| 584 | ||
| 585 | const seeded = ensureEnvData( | |
| 586 | this.app.config.dataDir, | |
| 587 | state.app.id, | |
| 588 | state.env, | |
| 589 | state.app.previewData, | |
| 590 | ); | |
| 591 | const dataDir = envDataDir( | |
| 592 | this.app.config.dataDir, | |
| 593 | state.app.id, | |
| 594 | state.env, | |
| 595 | ); | |
| 596 | if (seeded === "cloned" || seeded === "copied") | |
| 597 | write(`data: ${seeded} from production`); | |
| 598 | else if (seeded === "empty" && state.env !== "production") | |
| 599 | write("data: starting empty"); | |
| 600 | ||
| 601 | const started = await startProcess({ | |
| 602 | dir, | |
| 603 | dataDir, | |
| 604 | manifest: manifest.value, | |
| 605 | env: state.env, | |
| 606 | url: this.urlFor(state.app, state.env), | |
| 607 | commitOid: run.commitOid, | |
| 608 | forgeUrl: this.options.forgeUrl, | |
| 609 | log: write, | |
| 610 | signal, | |
| 611 | }); | |
| 612 | if (!started.ok) { | |
| 613 | rmSync(dir, { recursive: true, force: true }); | |
| 614 | return { | |
| 615 | ok: false, | |
| 616 | error: `${started.error.stage}: ${started.error.message}`, | |
| 617 | }; | |
| 618 | } | |
| 619 | return { | |
| 620 | ok: true, | |
| 621 | value: { | |
| 622 | commitOid: run.commitOid, | |
| 623 | handle: started.value, | |
| 624 | dir, | |
| 625 | runId: run.id, | |
| 626 | since: new Date().toISOString(), | |
| 627 | manifest: manifest.value, | |
| 628 | }, | |
| 629 | }; | |
| 630 | } | |
| 631 | ||
| 632 | /** The running environment a terminal session would attach to, if any. */ | |
| 633 | liveFor(record: AppRecord, env: EnvName): LiveEnv | undefined { | |
| 634 | const state = this.envs.get(key(record.id, env)); | |
| 635 | const live = state?.live; | |
| 636 | if (state === undefined || live === undefined) return undefined; | |
| 637 | return { | |
| 638 | env, | |
| 639 | dir: live.dir, | |
| 640 | dataDir: envDataDir(this.app.config.dataDir, record.id, env), | |
| 641 | port: live.handle.port, | |
| 642 | commitOid: live.commitOid, | |
| 643 | url: this.urlFor(record, env), | |
| 644 | tty: live.manifest.tty, | |
| 645 | }; | |
| 646 | } | |
| 647 | ||
| 648 | /** When a live process ends on its own, say so and bring it back — bounded. */ | |
| 649 | private watch(state: Environment, repo: Repo, live: Live): void { | |
| 650 | live.handle.exited | |
| 651 | .then((code) => { | |
| 652 | if (this.stopped || state.live !== live) return; // replaced or stopped on purpose | |
| 653 | state.live = undefined; | |
| 654 | state.lastError = `exited with code ${code}`; | |
| 655 | appendRunLog( | |
| 656 | this.app.config.dataDir, | |
| 657 | live.runId, | |
| 658 | `process exited with code ${code}`, | |
| 659 | ); | |
| 660 | const now = Date.now(); | |
| 661 | state.restarts = state.restarts.filter( | |
| 662 | (at) => now - at < RESTART_WINDOW_MS, | |
| 663 | ); | |
| 664 | if (state.restarts.length >= RESTART_MAX) { | |
| 665 | state.lastError = `exited with code ${code}; restarted ${RESTART_MAX} times in ten minutes, giving up`; | |
| 666 | appendRunLog(this.app.config.dataDir, live.runId, state.lastError); | |
| 667 | return; | |
| 668 | } | |
| 669 | const delay = RESTART_BACKOFF_MS[state.restarts.length] ?? 30_000; | |
| 670 | state.restarts.push(now); | |
| 671 | setTimeout(() => { | |
| 672 | if (this.stopped) return; | |
| 673 | const fresh = getRepoById(this.app.db, repo.id); | |
| 674 | if (fresh) | |
| 675 | this.requestDeploy( | |
| 676 | state.app, | |
| 677 | fresh, | |
| 678 | state.env, | |
| 679 | live.commitOid, | |
| 680 | "restart", | |
| 681 | ); | |
| 682 | }, delay); | |
| 683 | }) | |
| 684 | .catch(() => {}); | |
| 685 | } | |
| 686 | ||
| 687 | // ---- tearing down ---- | |
| 688 | ||
| 689 | /** Stop an environment and remove everything it owned, data included. */ | |
| 690 | async teardown(record: AppRecord, env: EnvName): Promise<void> { | |
| 691 | const k = key(record.id, env); | |
| 692 | const state = this.envs.get(k); | |
| 693 | if (state !== undefined) { | |
| 694 | this.envs.delete(k); | |
| 695 | if (state.queued) | |
| 696 | cancelRun(this.app.db, state.queued.id, "the environment was removed"); | |
| 697 | state.deploying?.abort.abort(); | |
| 698 | await state.chain.catch(() => {}); | |
| 699 | // Null the reference before stopping it: `watch()`'s exit handler | |
| 700 | // only no-ops on a deliberate stop when `state.live` no longer points | |
| 701 | // at the process it is watching. Leaving it set here (as `stopEnv` | |
| 702 | // knows not to) had the exit handler read this as a crash and | |
| 703 | // schedule a restart — resurrecting a torn-down preview seconds | |
| 704 | // after its pull closed, checkout, cloned data and all. | |
| 705 | const live = state.live; | |
| 706 | state.live = undefined; | |
| 707 | if (live) await live.handle.stop(); | |
| 708 | } | |
| 709 | removeEnv(this.app.config.dataDir, record.id, env); | |
| 710 | } | |
| 711 | ||
| 712 | async teardownAll(repoId: string): Promise<void> { | |
| 713 | const states = [...this.envs.values()].filter( | |
| 714 | (s) => s.app.repoId === repoId, | |
| 715 | ); | |
| 716 | for (const state of states) await this.teardown(state.app, state.env); | |
| 717 | for (const state of states) | |
| 718 | removeApp(this.app.config.dataDir, state.app.id); | |
| 719 | } | |
| 720 | ||
| 721 | /** Stop an environment's process without removing its data. */ | |
| 722 | async stopEnv(record: AppRecord, env: EnvName): Promise<void> { | |
| 723 | const state = this.envs.get(key(record.id, env)); | |
| 724 | if (state === undefined) return; | |
| 725 | if (state.queued) cancelRun(this.app.db, state.queued.id, "stopped"); | |
| 726 | state.deploying?.abort.abort(); | |
| 727 | await state.chain.catch(() => {}); | |
| 728 | const live = state.live; | |
| 729 | state.live = undefined; | |
| 730 | if (live) await live.handle.stop(); | |
| 731 | } | |
| 732 | } | |
| 733 | ||
| 734 | const key = (appId: string, env: EnvName): string => `${appId}/${env}`; |
src/apps/scaffold.ts added +292
| @@ -0,0 +1,292 @@ | ||
| 1 | /** | |
| 2 | * The app a new app starts as. Small enough to read in a minute, real | |
| 3 | * enough to demonstrate the one idea a first-time user needs: this app has | |
| 4 | * *data*, that data lives under `FORGE_APP_DATA`, and so production and a | |
| 5 | * preview each have their own. Notes written in production are there in | |
| 6 | * the preview a moment later; notes written in the preview never reach | |
| 7 | * production. Zero dependencies, like the forge that grew it. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { FileWrite } from "../git/write.ts"; | |
| 11 | ||
| 12 | export interface ScaffoldInput { | |
| 13 | /** The repository name; also the title on the page. */ | |
| 14 | readonly name: string; | |
| 15 | /** What the app should become. Shown on the page until it does. */ | |
| 16 | readonly description: string; | |
| 17 | } | |
| 18 | ||
| 19 | export function scaffoldFiles(input: ScaffoldInput): FileWrite[] { | |
| 20 | const title = input.name; | |
| 21 | const description = input.description.trim(); | |
| 22 | return [ | |
| 23 | { path: "forge.json", content: FORGE_JSON }, | |
| 24 | { path: "package.json", content: packageJson(input.name) }, | |
| 25 | { path: ".gitignore", content: GITIGNORE }, | |
| 26 | { path: "README.md", content: readme(title, description) }, | |
| 27 | { path: "AGENTS.md", content: AGENTS_MD }, | |
| 28 | { path: "src/server.ts", content: server(title, description) }, | |
| 29 | { path: "src/server.test.ts", content: SERVER_TEST }, | |
| 30 | { path: "src/tty.ts", content: tty(title) }, | |
| 31 | ]; | |
| 32 | } | |
| 33 | ||
| 34 | const FORGE_JSON = `${JSON.stringify( | |
| 35 | { | |
| 36 | app: { | |
| 37 | run: "bun run src/server.ts", | |
| 38 | test: "bun test", | |
| 39 | tty: "bun run src/tty.ts", | |
| 40 | }, | |
| 41 | }, | |
| 42 | null, | |
| 43 | 2, | |
| 44 | )}\n`; | |
| 45 | ||
| 46 | const packageJson = (name: string): string => | |
| 47 | `${JSON.stringify( | |
| 48 | { | |
| 49 | name, | |
| 50 | private: true, | |
| 51 | type: "module", | |
| 52 | scripts: { start: "bun run src/server.ts", test: "bun test" }, | |
| 53 | }, | |
| 54 | null, | |
| 55 | 2, | |
| 56 | )}\n`; | |
| 57 | ||
| 58 | const GITIGNORE = `node_modules/ | |
| 59 | *.db | |
| 60 | *.db-wal | |
| 61 | *.db-shm | |
| 62 | `; | |
| 63 | ||
| 64 | const readme = (title: string, description: string): string => `# ${title} | |
| 65 | ||
| 66 | ${description || "A new app on the forge."} | |
| 67 | ||
| 68 | This is an app: the forge runs it. Production follows the default branch; | |
| 69 | every open pull gets a preview at its own address with its own copy of the | |
| 70 | data. Everything the app keeps lives under \`FORGE_APP_DATA\`, which is what | |
| 71 | makes "production data" and "preview data" two different things. | |
| 72 | ||
| 73 | \`\`\`sh | |
| 74 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here | |
| 75 | bun test # the check every pull runs | |
| 76 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | |
| 77 | \`\`\` | |
| 78 | ||
| 79 | It also has a terminal: \`ssh -p 2222 <name>@<forge>\` opens \`src/tty.ts\` on a | |
| 80 | real pty over the same data (\`<name>--<pull>@<forge>\` for a preview's). | |
| 81 | ||
| 82 | The forge sets \`PORT\`, \`FORGE_APP_DATA\`, \`FORGE_APP_ENV\` | |
| 83 | (\`production\` or \`preview\`), \`FORGE_APP_URL\` and \`FORGE_APP_COMMIT\` | |
| 84 | when it starts the app. \`forge.json\` says how. | |
| 85 | `; | |
| 86 | ||
| 87 | const AGENTS_MD = `# Working on this app | |
| 88 | ||
| 89 | You are one of the crew on this repository. The forge runs the app for you: | |
| 90 | production follows the default branch, and every pull you open gets a | |
| 91 | preview at its own address, deployed from your branch as you push. | |
| 92 | ||
| 93 | - Work on the branch you were given. You cannot move the default branch; | |
| 94 | a person merges your pull once it is reviewed. | |
| 95 | - Keep state under \`FORGE_APP_DATA\`. Never write elsewhere at runtime. | |
| 96 | - Run \`bun test\` before you push. It is the check the pull shows. | |
| 97 | - \`src/tty.ts\` is the app's terminal interface, reached with | |
| 98 | \`ssh <name>@<forge>\`. Keep it working and keep it useful: what a person | |
| 99 | can do on the web page they should be able to do here too. | |
| 100 | - Keep it dependency-free unless a dependency is clearly worth it, and say | |
| 101 | why in the pull. | |
| 102 | - When you are done, push and stop. Your last message becomes the pull's | |
| 103 | description: say what changed and what to look at in the preview. | |
| 104 | `; | |
| 105 | ||
| 106 | const server = (title: string, description: string): string => `/** | |
| 107 | * ${title} — a small app with real data. | |
| 108 | * | |
| 109 | * Notes live in a SQLite file under FORGE_APP_DATA. Production and each | |
| 110 | * preview get their own directory, so a preview starts with production's | |
| 111 | * notes and never writes back to them. | |
| 112 | */ | |
| 113 | ||
| 114 | import { Database } from "bun:sqlite"; | |
| 115 | import { mkdirSync } from "node:fs"; | |
| 116 | import { join } from "node:path"; | |
| 117 | ||
| 118 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 119 | mkdirSync(dataDir, { recursive: true }); | |
| 120 | const db = new Database(join(dataDir, "app.db")); | |
| 121 | db.exec("PRAGMA journal_mode = WAL"); | |
| 122 | db.exec( | |
| 123 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", | |
| 124 | ); | |
| 125 | ||
| 126 | const env = process.env.FORGE_APP_ENV ?? "local"; | |
| 127 | const pull = process.env.FORGE_APP_PULL; | |
| 128 | const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7); | |
| 129 | ||
| 130 | const escape = (s: string): string => | |
| 131 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); | |
| 132 | ||
| 133 | function page(): string { | |
| 134 | const notes = db | |
| 135 | .query<{ id: number; body: string; created_at: string }, []>( | |
| 136 | "SELECT id, body, created_at FROM note ORDER BY id DESC LIMIT 100", | |
| 137 | ) | |
| 138 | .all(); | |
| 139 | const where = | |
| 140 | env === "preview" ? \`preview of pull #\${pull ?? "?"}\` : env; | |
| 141 | return \`<!doctype html> | |
| 142 | <meta charset="utf-8"> | |
| 143 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 144 | <title>${escape(title)}</title> | |
| 145 | <style> | |
| 146 | :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; } | |
| 147 | body { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; } | |
| 148 | .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem; | |
| 149 | background: \${env === "production" ? "#16a34a" : "#2563eb"}; color: white; } | |
| 150 | form { display: flex; gap: .5rem; margin: 1.5rem 0; } | |
| 151 | input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; } | |
| 152 | button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; } | |
| 153 | li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; } | |
| 154 | small { opacity: .6; } | |
| 155 | </style> | |
| 156 | <h1>${escape(title)}</h1> | |
| 157 | <p>${escape(description || "A new app on the forge.")}</p> | |
| 158 | <p><span class="env">\${escape(where)}</span> <small>\${commit ? "@ " + commit : ""}</small></p> | |
| 159 | <form method="post" action="/notes"> | |
| 160 | <input name="body" placeholder="Write a note" required maxlength="500" autofocus> | |
| 161 | <button>Add</button> | |
| 162 | </form> | |
| 163 | <ul>\${notes | |
| 164 | .map((n) => \`<li>\${escape(n.body)} <small>\${n.created_at}</small></li>\`) | |
| 165 | .join("")}</ul> | |
| 166 | <p><small>\${notes.length === 0 ? "No notes yet. Notes written here stay in this environment's data." : \`\${notes.length} note\${notes.length === 1 ? "" : "s"} in \${escape(where)} data.\`}</small></p> | |
| 167 | \`; | |
| 168 | } | |
| 169 | ||
| 170 | export const server = Bun.serve({ | |
| 171 | port: Number(process.env.PORT ?? 8080), | |
| 172 | hostname: process.env.HOST ?? "127.0.0.1", | |
| 173 | async fetch(request) { | |
| 174 | const url = new URL(request.url); | |
| 175 | if (request.method === "POST" && url.pathname === "/notes") { | |
| 176 | const form = await request.formData(); | |
| 177 | const body = String(form.get("body") ?? "").trim().slice(0, 500); | |
| 178 | if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body); | |
| 179 | return Response.redirect("/", 303); | |
| 180 | } | |
| 181 | if (url.pathname === "/") { | |
| 182 | return new Response(page(), { | |
| 183 | headers: { "content-type": "text/html; charset=utf-8" }, | |
| 184 | }); | |
| 185 | } | |
| 186 | return new Response("not found", { status: 404 }); | |
| 187 | }, | |
| 188 | }); | |
| 189 | ||
| 190 | console.log(\`${escape(title)} listening on http://\${server.hostname}:\${server.port} (\${env})\`); | |
| 191 | `; | |
| 192 | ||
| 193 | const tty = (title: string): string => `/** | |
| 194 | * ${title}, in a terminal. The forge runs this on a pty when someone | |
| 195 | * \`ssh\`es to the app, with FORGE_APP_DATA pointing at the same data the | |
| 196 | * web page uses. j/k move, a adds a note, d deletes the one under the | |
| 197 | * cursor, q leaves. Plain ANSI, no dependencies. | |
| 198 | */ | |
| 199 | ||
| 200 | import { Database } from "bun:sqlite"; | |
| 201 | import { mkdirSync } from "node:fs"; | |
| 202 | import { join } from "node:path"; | |
| 203 | ||
| 204 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 205 | mkdirSync(dataDir, { recursive: true }); | |
| 206 | const db = new Database(join(dataDir, "app.db")); | |
| 207 | db.exec("PRAGMA journal_mode = WAL"); | |
| 208 | db.exec( | |
| 209 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", | |
| 210 | ); | |
| 211 | const where = process.env.FORGE_APP_ENV === "preview" ? \`preview of #\${process.env.FORGE_APP_PULL ?? "?"}\` : (process.env.FORGE_APP_ENV ?? "local"); | |
| 212 | ||
| 213 | type Note = { id: number; body: string; created_at: string }; | |
| 214 | const notes = (): Note[] => db.query<Note, []>("SELECT id, body, created_at FROM note ORDER BY id DESC").all(); | |
| 215 | ||
| 216 | let cursor = 0; | |
| 217 | let typing: string | undefined; | |
| 218 | const out = (s: string): void => { process.stdout.write(s); }; | |
| 219 | const esc = "\\x1b["; | |
| 220 | ||
| 221 | function draw(): void { | |
| 222 | const rows = notes(); | |
| 223 | const lines: string[] = []; | |
| 224 | lines.push(\`\${esc}1m${title}\${esc}0m \${esc}2m\${where}\${esc}0m\`); | |
| 225 | lines.push(""); | |
| 226 | if (rows.length === 0) lines.push(\`\${esc}2mNo notes yet. Press a to add one.\${esc}0m\`); | |
| 227 | rows.forEach((n, i) => { | |
| 228 | const mark = i === cursor ? "▸" : " "; | |
| 229 | const body = i === cursor ? \`\${esc}1m\${n.body}\${esc}0m\` : n.body; | |
| 230 | lines.push(\`\${mark} \${body} \${esc}2m\${n.created_at}\${esc}0m\`); | |
| 231 | }); | |
| 232 | lines.push(""); | |
| 233 | lines.push(typing === undefined ? \`\${esc}2mj/k move · a add · d delete · q quit\${esc}0m\` : \`New note: \${typing}\${esc}7m \${esc}0m\`); | |
| 234 | out(\`\${esc}2J\${esc}H\` + lines.join("\\r\\n")); | |
| 235 | } | |
| 236 | ||
| 237 | process.stdin.setRawMode?.(true); | |
| 238 | process.stdin.resume(); | |
| 239 | process.stdin.setEncoding("utf8"); | |
| 240 | draw(); | |
| 241 | process.stdin.on("data", (key: string) => { | |
| 242 | if (typing !== undefined) { | |
| 243 | if (key === "\\r") { | |
| 244 | const body = typing.trim(); | |
| 245 | if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body.slice(0, 500)); | |
| 246 | typing = undefined; | |
| 247 | cursor = 0; | |
| 248 | } else if (key === "\\x1b") typing = undefined; | |
| 249 | else if (key === "\\x7f") typing = typing.slice(0, -1); | |
| 250 | else if (key >= " ") typing += key; | |
| 251 | draw(); | |
| 252 | return; | |
| 253 | } | |
| 254 | if (key === "q" || key === "\\x03") { out(\`\${esc}2J\${esc}H\`); process.exit(0); } | |
| 255 | const count = notes().length; | |
| 256 | if (key === "j" || key === "\\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1)); | |
| 257 | if (key === "k" || key === "\\x1b[A") cursor = Math.max(cursor - 1, 0); | |
| 258 | if (key === "a") typing = ""; | |
| 259 | if (key === "d") { | |
| 260 | const n = notes()[cursor]; | |
| 261 | if (n) db.query("DELETE FROM note WHERE id = ?1").run(n.id); | |
| 262 | cursor = Math.min(cursor, Math.max(0, notes().length - 1)); | |
| 263 | } | |
| 264 | draw(); | |
| 265 | }); | |
| 266 | process.stdout.on("resize", draw); | |
| 267 | `; | |
| 268 | ||
| 269 | const SERVER_TEST = `import { expect, test } from "bun:test"; | |
| 270 | import { mkdtempSync } from "node:fs"; | |
| 271 | import { tmpdir } from "node:os"; | |
| 272 | import { join } from "node:path"; | |
| 273 | ||
| 274 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); | |
| 275 | process.env.PORT = "0"; | |
| 276 | const { server } = await import("./server.ts"); | |
| 277 | const base = \`http://\${server.hostname}:\${server.port}\`; | |
| 278 | ||
| 279 | test("serves the page", async () => { | |
| 280 | const response = await fetch(base + "/"); | |
| 281 | expect(response.status).toBe(200); | |
| 282 | expect(await response.text()).toContain("<h1>"); | |
| 283 | }); | |
| 284 | ||
| 285 | test("keeps a note", async () => { | |
| 286 | const form = new FormData(); | |
| 287 | form.set("body", "hello from a test"); | |
| 288 | const posted = await fetch(base + "/notes", { method: "POST", body: form, redirect: "manual" }); | |
| 289 | expect(posted.status).toBe(303); | |
| 290 | expect(await (await fetch(base + "/")).text()).toContain("hello from a test"); | |
| 291 | }); | |
| 292 | `; |
src/apps/tty.ts added +165
| @@ -0,0 +1,165 @@ | ||
| 1 | /** | |
| 2 | * A terminal for every app. `ssh -p 2222 notes@forge` opens the app's own | |
| 3 | * terminal interface — the `tty` command in its `forge.json` — on a real | |
| 4 | * pty, in the deployed checkout, with the environment's own variables; | |
| 5 | * `notes--12@forge` opens the preview of pull 12's. The login name picks | |
| 6 | * the app; the key still decides who you are, and a private app answers | |
| 7 | * only to someone who can read its repository, exactly as the proxy does. | |
| 8 | * | |
| 9 | * The bridge is the one `runEditor` uses for an operator's editor: pty | |
| 10 | * bytes to the channel verbatim, channel bytes to the pty, resizes through, | |
| 11 | * SIGHUP when the peer goes away. The app's terminal is any program that | |
| 12 | * draws on a tty; the scaffold ships a small one. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { App as Forge } from "../app.ts"; | |
| 16 | import { getAppBySlug, type AppRecord } from "../core/apps.ts"; | |
| 17 | import { canRead, getRepoById } from "../core/repos.ts"; | |
| 18 | import type { User } from "../core/users.ts"; | |
| 19 | import type { SshSession } from "../ssh/server.ts"; | |
| 20 | import { isEnvName, previewEnv, type EnvName } from "./data.ts"; | |
| 21 | import { appEnv } from "./process.ts"; | |
| 22 | import { appsRuntime } from "./runtime.ts"; | |
| 23 | ||
| 24 | const CRLF = "\r\n"; | |
| 25 | ||
| 26 | /** `notes` → production, `notes--12` → the preview of pull 12. */ | |
| 27 | export function parseAppLogin( | |
| 28 | login: string, | |
| 29 | ): { slug: string; env: EnvName } | undefined { | |
| 30 | const name = login.toLowerCase(); | |
| 31 | const at = name.indexOf("--"); | |
| 32 | if (at === -1) | |
| 33 | return isSlugLike(name) ? { slug: name, env: "production" } : undefined; | |
| 34 | const slug = name.slice(0, at); | |
| 35 | const number = name.slice(at + 2); | |
| 36 | if (!isSlugLike(slug) || !/^[1-9]\d*$/.test(number)) return undefined; | |
| 37 | const env = previewEnv(Number(number)); | |
| 38 | return isEnvName(env) ? { slug, env } : undefined; | |
| 39 | } | |
| 40 | ||
| 41 | const isSlugLike = (s: string): boolean => | |
| 42 | s.length > 0 && s.length <= 40 && /^[a-z0-9](?:-?[a-z0-9])*$/.test(s); | |
| 43 | ||
| 44 | /** The app a login name addresses, when one exists. */ | |
| 45 | export function appForLogin( | |
| 46 | forge: Forge, | |
| 47 | login: string, | |
| 48 | ): { app: AppRecord; env: EnvName } | undefined { | |
| 49 | const parsed = parseAppLogin(login); | |
| 50 | if (parsed === undefined) return undefined; | |
| 51 | const app = getAppBySlug(forge.db, parsed.slug); | |
| 52 | return app ? { app, env: parsed.env } : undefined; | |
| 53 | } | |
| 54 | ||
| 55 | /** | |
| 56 | * Attach the session to the app's terminal. Every refusal is a sentence on | |
| 57 | * stderr and a non-zero exit — a shell that goes quiet is the one thing a | |
| 58 | * person cannot act on. | |
| 59 | */ | |
| 60 | export function attachAppTty( | |
| 61 | forge: Forge, | |
| 62 | session: SshSession, | |
| 63 | user: User | undefined, | |
| 64 | target: { app: AppRecord; env: EnvName }, | |
| 65 | ): void { | |
| 66 | const refuse = (line: string): void => { | |
| 67 | session.writeStderr(`forge: ${line}${CRLF}`); | |
| 68 | session.exit(1); | |
| 69 | }; | |
| 70 | const repo = getRepoById(forge.db, target.app.repoId); | |
| 71 | // The same conflation as everywhere: an app you may not see is not there. | |
| 72 | if (!repo || !canRead(forge.db, repo, user)) { | |
| 73 | refuse(`no app named ${session.username}`); | |
| 74 | return; | |
| 75 | } | |
| 76 | if (target.app.access === "private" && user === undefined) { | |
| 77 | refuse( | |
| 78 | `${target.app.slug} is private: sign in to the forge first, so your key is known`, | |
| 79 | ); | |
| 80 | return; | |
| 81 | } | |
| 82 | if (session.term === undefined) { | |
| 83 | refuse(`this is a terminal interface — connect with a tty (ssh -t)`); | |
| 84 | return; | |
| 85 | } | |
| 86 | const runtime = appsRuntime(forge); | |
| 87 | const live = runtime?.liveFor(target.app, target.env); | |
| 88 | if (runtime === undefined || live === undefined) { | |
| 89 | const status = runtime?.envStatus(target.app, target.env); | |
| 90 | const why = | |
| 91 | status === undefined | |
| 92 | ? "nothing is running there" | |
| 93 | : status.state === "deploying" | |
| 94 | ? "a new version is starting; try again in a moment" | |
| 95 | : (status.error ?? `it is ${status.state}`); | |
| 96 | refuse(`${session.username} is not running: ${why}`); | |
| 97 | return; | |
| 98 | } | |
| 99 | if (live.tty === null) { | |
| 100 | refuse( | |
| 101 | `${target.app.slug} has no terminal interface — give it one with "tty" in forge.json`, | |
| 102 | ); | |
| 103 | return; | |
| 104 | } | |
| 105 | ||
| 106 | const terminal = new Bun.Terminal({ | |
| 107 | cols: Math.max(1, session.cols || 80), | |
| 108 | rows: Math.max(1, session.rows || 24), | |
| 109 | data: (_t, chunk) => { | |
| 110 | if (!session.ended) session.write(chunk); | |
| 111 | }, | |
| 112 | }); | |
| 113 | const env = appEnv({ | |
| 114 | port: live.port, | |
| 115 | dataDir: live.dataDir, | |
| 116 | env: live.env, | |
| 117 | url: live.url, | |
| 118 | commitOid: live.commitOid, | |
| 119 | forgeUrl: runtime.options.forgeUrl, | |
| 120 | }); | |
| 121 | env["TERM"] = session.term; | |
| 122 | env["COLORTERM"] = "truecolor"; | |
| 123 | env["FORGE_APP_TTY"] = "1"; | |
| 124 | env["FORGE_APP_USER"] = user?.username ?? ""; | |
| 125 | const proc = Bun.spawn(["/bin/sh", "-c", `exec ${live.tty}`], { | |
| 126 | terminal, | |
| 127 | env, | |
| 128 | cwd: live.dir, | |
| 129 | }); | |
| 130 | forge.log.info("app terminal opened", { | |
| 131 | app: target.app.slug, | |
| 132 | env: target.env, | |
| 133 | user: user?.username ?? "guest", | |
| 134 | peer: session.remoteAddress, | |
| 135 | }); | |
| 136 | ||
| 137 | session.onData((chunk) => { | |
| 138 | if (terminal.closed) return; | |
| 139 | try { | |
| 140 | terminal.write(chunk); | |
| 141 | } catch { | |
| 142 | /* the program is gone; the keystroke has nowhere to go */ | |
| 143 | } | |
| 144 | }); | |
| 145 | session.onResize((cols, rows) => { | |
| 146 | if (terminal.closed) return; | |
| 147 | try { | |
| 148 | terminal.resize(Math.max(1, cols), Math.max(1, rows)); | |
| 149 | } catch { | |
| 150 | /* same race, same answer */ | |
| 151 | } | |
| 152 | }); | |
| 153 | const hangup = (): void => { | |
| 154 | try { | |
| 155 | proc.kill("SIGHUP"); | |
| 156 | } catch { | |
| 157 | /* already gone */ | |
| 158 | } | |
| 159 | }; | |
| 160 | session.onClose(hangup); | |
| 161 | void proc.exited.then((code) => { | |
| 162 | terminal.close(); | |
| 163 | if (!session.ended) session.exit(code); | |
| 164 | }); | |
| 165 | } |
src/core/agents.ts added +249
| @@ -0,0 +1,249 @@ | ||
| 1 | /** | |
| 2 | * Agents — the crew's rows. An agent is a user of kind `agent` (a flag on | |
| 3 | * the user row; see migration 14) with the little the runner needs to know | |
| 4 | * about it: which model, a brief in its own voice, and how much a run may | |
| 5 | * spend. Access to repositories is granted the way it is for a person | |
| 6 | * (collaborator, team) and lives where that lives. Pure data: no authz, no | |
| 7 | * events. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { getUserById, type User } from "./users.ts"; | |
| 12 | ||
| 13 | export interface Agent { | |
| 14 | readonly user: User; | |
| 15 | /** A Claude Code model alias or id; empty means the runner's default. */ | |
| 16 | readonly model: string; | |
| 17 | /** Appended to the system prompt of every run: who this agent is. */ | |
| 18 | readonly brief: string; | |
| 19 | /** Spend per run in USD; 0 means the forge's default. */ | |
| 20 | readonly budgetUsd: number; | |
| 21 | readonly createdBy: string | null; | |
| 22 | readonly createdAt: string; | |
| 23 | } | |
| 24 | ||
| 25 | interface AgentRow { | |
| 26 | user_id: string; | |
| 27 | model: string; | |
| 28 | brief: string; | |
| 29 | budget_usd: number; | |
| 30 | created_by: string | null; | |
| 31 | created_at: string; | |
| 32 | } | |
| 33 | ||
| 34 | export const MODEL_MAX = 80; | |
| 35 | export const BRIEF_MAX = 4_000; | |
| 36 | export const BUDGET_MAX_USD = 100; | |
| 37 | ||
| 38 | function toAgent(db: Database, row: AgentRow): Agent | undefined { | |
| 39 | const user = getUserById(db, row.user_id); | |
| 40 | if (!user) return undefined; | |
| 41 | return { | |
| 42 | user, | |
| 43 | model: row.model, | |
| 44 | brief: row.brief, | |
| 45 | budgetUsd: row.budget_usd, | |
| 46 | createdBy: row.created_by, | |
| 47 | createdAt: row.created_at, | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | export function createAgentRow( | |
| 52 | db: Database, | |
| 53 | input: { | |
| 54 | userId: string; | |
| 55 | model?: string | undefined; | |
| 56 | brief?: string | undefined; | |
| 57 | budgetUsd?: number | undefined; | |
| 58 | createdBy?: string | undefined; | |
| 59 | }, | |
| 60 | ): Agent { | |
| 61 | db.query( | |
| 62 | "INSERT INTO agent (user_id, model, brief, budget_usd, created_by) VALUES (?1, ?2, ?3, ?4, ?5)", | |
| 63 | ).run( | |
| 64 | input.userId, | |
| 65 | input.model ?? "", | |
| 66 | input.brief ?? "", | |
| 67 | input.budgetUsd ?? 0, | |
| 68 | input.createdBy ?? null, | |
| 69 | ); | |
| 70 | return getAgent(db, input.userId)!; | |
| 71 | } | |
| 72 | ||
| 73 | export function getAgent(db: Database, userId: string): Agent | undefined { | |
| 74 | const row = db | |
| 75 | .query<AgentRow, [string]>("SELECT * FROM agent WHERE user_id = ?1") | |
| 76 | .get(userId); | |
| 77 | return row ? toAgent(db, row) : undefined; | |
| 78 | } | |
| 79 | ||
| 80 | export function getAgentByName( | |
| 81 | db: Database, | |
| 82 | username: string, | |
| 83 | ): Agent | undefined { | |
| 84 | const row = db | |
| 85 | .query< | |
| 86 | AgentRow, | |
| 87 | [string] | |
| 88 | >("SELECT agent.* FROM agent JOIN user ON user.id = agent.user_id WHERE user.username = ?1 COLLATE NOCASE") | |
| 89 | .get(username); | |
| 90 | return row ? toAgent(db, row) : undefined; | |
| 91 | } | |
| 92 | ||
| 93 | /** Every agent on the forge, by name. */ | |
| 94 | export function listAgents(db: Database): Agent[] { | |
| 95 | const rows = db | |
| 96 | .query< | |
| 97 | AgentRow, | |
| 98 | [] | |
| 99 | >("SELECT agent.* FROM agent JOIN user ON user.id = agent.user_id ORDER BY user.username COLLATE NOCASE") | |
| 100 | .all(); | |
| 101 | const out: Agent[] = []; | |
| 102 | for (const row of rows) { | |
| 103 | const agent = toAgent(db, row); | |
| 104 | if (agent) out.push(agent); | |
| 105 | } | |
| 106 | return out; | |
| 107 | } | |
| 108 | ||
| 109 | export interface AgentPatch { | |
| 110 | model?: string; | |
| 111 | brief?: string; | |
| 112 | budgetUsd?: number; | |
| 113 | } | |
| 114 | ||
| 115 | export function updateAgent( | |
| 116 | db: Database, | |
| 117 | userId: string, | |
| 118 | patch: AgentPatch, | |
| 119 | ): Agent | undefined { | |
| 120 | const sets: string[] = []; | |
| 121 | const params: (string | number)[] = []; | |
| 122 | if (patch.model !== undefined) { | |
| 123 | params.push(patch.model.slice(0, MODEL_MAX)); | |
| 124 | sets.push(`model = ?${params.length}`); | |
| 125 | } | |
| 126 | if (patch.brief !== undefined) { | |
| 127 | params.push(patch.brief.slice(0, BRIEF_MAX)); | |
| 128 | sets.push(`brief = ?${params.length}`); | |
| 129 | } | |
| 130 | if (patch.budgetUsd !== undefined) { | |
| 131 | params.push(Math.min(Math.max(patch.budgetUsd, 0), BUDGET_MAX_USD)); | |
| 132 | sets.push(`budget_usd = ?${params.length}`); | |
| 133 | } | |
| 134 | if (sets.length > 0) { | |
| 135 | params.push(userId); | |
| 136 | db.query( | |
| 137 | `UPDATE agent SET ${sets.join(", ")} WHERE user_id = ?${params.length}`, | |
| 138 | ).run(...params); | |
| 139 | } | |
| 140 | return getAgent(db, userId); | |
| 141 | } | |
| 142 | ||
| 143 | // ---- the crew of a repository ---- | |
| 144 | ||
| 145 | /** Put an agent on a repository's crew. Idempotent. */ | |
| 146 | export function addCrew( | |
| 147 | db: Database, | |
| 148 | repoId: string, | |
| 149 | userId: string, | |
| 150 | addedBy?: string, | |
| 151 | ): boolean { | |
| 152 | return ( | |
| 153 | db | |
| 154 | .query( | |
| 155 | "INSERT OR IGNORE INTO repo_crew (repo_id, user_id, added_by) VALUES (?1, ?2, ?3)", | |
| 156 | ) | |
| 157 | .run(repoId, userId, addedBy ?? null).changes > 0 | |
| 158 | ); | |
| 159 | } | |
| 160 | ||
| 161 | export function removeCrew( | |
| 162 | db: Database, | |
| 163 | repoId: string, | |
| 164 | userId: string, | |
| 165 | ): boolean { | |
| 166 | return ( | |
| 167 | db | |
| 168 | .query("DELETE FROM repo_crew WHERE repo_id = ?1 AND user_id = ?2") | |
| 169 | .run(repoId, userId).changes > 0 | |
| 170 | ); | |
| 171 | } | |
| 172 | ||
| 173 | export function isCrew(db: Database, repoId: string, userId: string): boolean { | |
| 174 | return ( | |
| 175 | db | |
| 176 | .query< | |
| 177 | { n: number }, | |
| 178 | [string, string] | |
| 179 | >("SELECT 1 AS n FROM repo_crew WHERE repo_id = ?1 AND user_id = ?2") | |
| 180 | .get(repoId, userId) !== null | |
| 181 | ); | |
| 182 | } | |
| 183 | ||
| 184 | /** The agents on a repository, by name. */ | |
| 185 | export function listCrew(db: Database, repoId: string): Agent[] { | |
| 186 | const rows = db | |
| 187 | .query<AgentRow, [string]>( | |
| 188 | `SELECT agent.* FROM repo_crew | |
| 189 | JOIN agent ON agent.user_id = repo_crew.user_id | |
| 190 | JOIN user ON user.id = agent.user_id | |
| 191 | WHERE repo_crew.repo_id = ?1 ORDER BY user.username COLLATE NOCASE`, | |
| 192 | ) | |
| 193 | .all(repoId); | |
| 194 | const out: Agent[] = []; | |
| 195 | for (const row of rows) { | |
| 196 | const agent = toAgent(db, row); | |
| 197 | if (agent) out.push(agent); | |
| 198 | } | |
| 199 | return out; | |
| 200 | } | |
| 201 | ||
| 202 | /** The crews of many repositories at once, keyed by repo id — one query per chunk. */ | |
| 203 | export function crewForRepos( | |
| 204 | db: Database, | |
| 205 | repoIds: readonly string[], | |
| 206 | ): Map<string, string[]> { | |
| 207 | const out = new Map<string, string[]>(); | |
| 208 | const unique = [...new Set(repoIds)]; | |
| 209 | const CHUNK = 400; | |
| 210 | for (let i = 0; i < unique.length; i += CHUNK) { | |
| 211 | const batch = unique.slice(i, i + CHUNK); | |
| 212 | const rows = db | |
| 213 | .query<{ repo_id: string; username: string }, string[]>( | |
| 214 | `SELECT repo_crew.repo_id, user.username FROM repo_crew | |
| 215 | JOIN user ON user.id = repo_crew.user_id | |
| 216 | WHERE repo_crew.repo_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")}) | |
| 217 | ORDER BY user.username COLLATE NOCASE`, | |
| 218 | ) | |
| 219 | .all(...batch); | |
| 220 | for (const row of rows) { | |
| 221 | const names = out.get(row.repo_id); | |
| 222 | if (names) names.push(row.username); | |
| 223 | else out.set(row.repo_id, [row.username]); | |
| 224 | } | |
| 225 | } | |
| 226 | return out; | |
| 227 | } | |
| 228 | ||
| 229 | /** | |
| 230 | * Retire an agent. Its tokens and its agent row go, so nothing can act as | |
| 231 | * it again and it leaves the crew. The user row goes too when nothing | |
| 232 | * references it; an agent that authored a pull or a comment keeps its | |
| 233 | * account as history — `author_id` has no cascade, and history is history. | |
| 234 | * Such a row still reads as kind `agent`, with no brief and no way in. | |
| 235 | */ | |
| 236 | export function deleteAgent(db: Database, userId: string): boolean { | |
| 237 | const removed = db | |
| 238 | .query("DELETE FROM agent WHERE user_id = ?1") | |
| 239 | .run(userId).changes; | |
| 240 | if (removed === 0) return false; | |
| 241 | db.query("DELETE FROM token WHERE user_id = ?1").run(userId); | |
| 242 | try { | |
| 243 | db.query("DELETE FROM user WHERE id = ?1 AND is_agent = 1").run(userId); | |
| 244 | } catch (error) { | |
| 245 | if (!(error instanceof Error && /FOREIGN KEY/.test(error.message))) | |
| 246 | throw error; | |
| 247 | } | |
| 248 | return true; | |
| 249 | } |
src/core/apps.ts added +206
| @@ -0,0 +1,206 @@ | ||
| 1 | /** | |
| 2 | * Apps — the rows. An app is a repository that runs; the row holds what git | |
| 3 | * cannot say about it (the slug its hostnames hang off, who may reach it, | |
| 4 | * whether a preview starts from a copy of production's data) and nothing | |
| 5 | * git can. How it runs is `forge.json` in the tree (`apps/manifest.ts`); | |
| 6 | * what is running is a process (`apps/runtime.ts`). Pure data: no authz, | |
| 7 | * no events. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { id } from "../kernel/id.ts"; | |
| 12 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 13 | import type { PreviewData } from "../apps/data.ts"; | |
| 14 | ||
| 15 | export type AppAccess = "public" | "private"; | |
| 16 | ||
| 17 | export interface AppRecord { | |
| 18 | readonly id: string; | |
| 19 | readonly repoId: string; | |
| 20 | /** The hostname label: `<slug>.<domain>`, `<slug>--<n>.<domain>`. */ | |
| 21 | readonly slug: string; | |
| 22 | /** Who may reach its environments: anyone, or someone the forge vouches for. */ | |
| 23 | readonly access: AppAccess; | |
| 24 | /** What a new preview's data directory starts as. */ | |
| 25 | readonly previewData: PreviewData; | |
| 26 | readonly createdAt: string; | |
| 27 | } | |
| 28 | ||
| 29 | interface AppRow { | |
| 30 | id: string; | |
| 31 | repo_id: string; | |
| 32 | slug: string; | |
| 33 | access: AppAccess; | |
| 34 | preview_data: PreviewData; | |
| 35 | created_at: string; | |
| 36 | } | |
| 37 | ||
| 38 | const toApp = (r: AppRow): AppRecord => ({ | |
| 39 | id: r.id, | |
| 40 | repoId: r.repo_id, | |
| 41 | slug: r.slug, | |
| 42 | access: r.access, | |
| 43 | previewData: r.preview_data, | |
| 44 | createdAt: r.created_at, | |
| 45 | }); | |
| 46 | ||
| 47 | /** | |
| 48 | * A slug is a DNS label: lowercase letters, digits, single hyphens inside. | |
| 49 | * `--` is reserved as the separator before a preview number, so a slug can | |
| 50 | * never contain it — which is what keeps `hello--12` unambiguous. | |
| 51 | */ | |
| 52 | export const SLUG_MAX = 40; | |
| 53 | const SLUG = /^[a-z0-9](?:-?[a-z0-9])*$/; | |
| 54 | ||
| 55 | export function isSlug(value: string): boolean { | |
| 56 | return value.length > 0 && value.length <= SLUG_MAX && SLUG.test(value); | |
| 57 | } | |
| 58 | ||
| 59 | /** The slug a repository name suggests: lowercased, runs of junk to one `-`. */ | |
| 60 | export function slugFor(name: string): string { | |
| 61 | const slug = name | |
| 62 | .toLowerCase() | |
| 63 | .replace(/[^a-z0-9]+/g, "-") | |
| 64 | .replace(/^-+|-+$/g, "") | |
| 65 | .slice(0, SLUG_MAX) | |
| 66 | .replace(/-+$/g, ""); | |
| 67 | return slug === "" ? "app" : slug; | |
| 68 | } | |
| 69 | ||
| 70 | export type CreateAppError = "invalid_slug" | "slug_taken" | "already_app"; | |
| 71 | ||
| 72 | export function createApp( | |
| 73 | db: Database, | |
| 74 | input: { | |
| 75 | repoId: string; | |
| 76 | slug: string; | |
| 77 | access: AppAccess; | |
| 78 | /** @default "copy" */ | |
| 79 | previewData?: PreviewData; | |
| 80 | }, | |
| 81 | ): Result<AppRecord, CreateAppError> { | |
| 82 | if (!isSlug(input.slug)) return err("invalid_slug"); | |
| 83 | if (getAppByRepo(db, input.repoId)) return err("already_app"); | |
| 84 | if (getAppBySlug(db, input.slug)) return err("slug_taken"); | |
| 85 | const appId = id(); | |
| 86 | try { | |
| 87 | db.query( | |
| 88 | "INSERT INTO app (id, repo_id, slug, access, preview_data) VALUES (?1, ?2, ?3, ?4, ?5)", | |
| 89 | ).run( | |
| 90 | appId, | |
| 91 | input.repoId, | |
| 92 | input.slug, | |
| 93 | input.access, | |
| 94 | input.previewData ?? "copy", | |
| 95 | ); | |
| 96 | } catch (error) { | |
| 97 | // Two racing creates: the constraint settles it, the check above only | |
| 98 | // named it early. | |
| 99 | if ( | |
| 100 | error instanceof Error && | |
| 101 | /UNIQUE constraint failed/.test(error.message) | |
| 102 | ) { | |
| 103 | return err(getAppByRepo(db, input.repoId) ? "already_app" : "slug_taken"); | |
| 104 | } | |
| 105 | throw error; | |
| 106 | } | |
| 107 | return ok(getAppById(db, appId)!); | |
| 108 | } | |
| 109 | ||
| 110 | export function getAppById(db: Database, appId: string): AppRecord | undefined { | |
| 111 | const row = db | |
| 112 | .query<AppRow, [string]>("SELECT * FROM app WHERE id = ?1") | |
| 113 | .get(appId); | |
| 114 | return row ? toApp(row) : undefined; | |
| 115 | } | |
| 116 | ||
| 117 | export function getAppByRepo( | |
| 118 | db: Database, | |
| 119 | repoId: string, | |
| 120 | ): AppRecord | undefined { | |
| 121 | const row = db | |
| 122 | .query<AppRow, [string]>("SELECT * FROM app WHERE repo_id = ?1") | |
| 123 | .get(repoId); | |
| 124 | return row ? toApp(row) : undefined; | |
| 125 | } | |
| 126 | ||
| 127 | export function getAppBySlug( | |
| 128 | db: Database, | |
| 129 | slug: string, | |
| 130 | ): AppRecord | undefined { | |
| 131 | const row = db | |
| 132 | .query<AppRow, [string]>("SELECT * FROM app WHERE slug = ?1 COLLATE NOCASE") | |
| 133 | .get(slug); | |
| 134 | return row ? toApp(row) : undefined; | |
| 135 | } | |
| 136 | ||
| 137 | /** Every app, oldest first — the runtime's boot list. */ | |
| 138 | export function listApps(db: Database): AppRecord[] { | |
| 139 | return db.query<AppRow, []>("SELECT * FROM app ORDER BY id").all().map(toApp); | |
| 140 | } | |
| 141 | ||
| 142 | /** The apps among these repositories, keyed by repo id — one query. */ | |
| 143 | export function appsForRepos( | |
| 144 | db: Database, | |
| 145 | repoIds: readonly string[], | |
| 146 | ): Map<string, AppRecord> { | |
| 147 | const out = new Map<string, AppRecord>(); | |
| 148 | const unique = [...new Set(repoIds)]; | |
| 149 | const CHUNK = 400; | |
| 150 | for (let i = 0; i < unique.length; i += CHUNK) { | |
| 151 | const batch = unique.slice(i, i + CHUNK); | |
| 152 | const rows = db | |
| 153 | .query< | |
| 154 | AppRow, | |
| 155 | string[] | |
| 156 | >(`SELECT * FROM app WHERE repo_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})`) | |
| 157 | .all(...batch); | |
| 158 | for (const row of rows) out.set(row.repo_id, toApp(row)); | |
| 159 | } | |
| 160 | return out; | |
| 161 | } | |
| 162 | ||
| 163 | export interface AppPatch { | |
| 164 | slug?: string; | |
| 165 | access?: AppAccess; | |
| 166 | previewData?: PreviewData; | |
| 167 | } | |
| 168 | ||
| 169 | export type UpdateAppError = "invalid_slug" | "slug_taken"; | |
| 170 | ||
| 171 | export function updateApp( | |
| 172 | db: Database, | |
| 173 | appId: string, | |
| 174 | patch: AppPatch, | |
| 175 | ): Result<AppRecord | undefined, UpdateAppError> { | |
| 176 | if (patch.slug !== undefined) { | |
| 177 | if (!isSlug(patch.slug)) return err("invalid_slug"); | |
| 178 | const holder = getAppBySlug(db, patch.slug); | |
| 179 | if (holder && holder.id !== appId) return err("slug_taken"); | |
| 180 | } | |
| 181 | const sets: string[] = []; | |
| 182 | const params: (string | null)[] = []; | |
| 183 | if (patch.slug !== undefined) { | |
| 184 | params.push(patch.slug); | |
| 185 | sets.push(`slug = ?${params.length}`); | |
| 186 | } | |
| 187 | if (patch.access !== undefined) { | |
| 188 | params.push(patch.access); | |
| 189 | sets.push(`access = ?${params.length}`); | |
| 190 | } | |
| 191 | if (patch.previewData !== undefined) { | |
| 192 | params.push(patch.previewData); | |
| 193 | sets.push(`preview_data = ?${params.length}`); | |
| 194 | } | |
| 195 | if (sets.length > 0) { | |
| 196 | params.push(appId); | |
| 197 | db.query( | |
| 198 | `UPDATE app SET ${sets.join(", ")} WHERE id = ?${params.length}`, | |
| 199 | ).run(...params); | |
| 200 | } | |
| 201 | return ok(getAppById(db, appId)); | |
| 202 | } | |
| 203 | ||
| 204 | export function deleteApp(db: Database, appId: string): boolean { | |
| 205 | return db.query("DELETE FROM app WHERE id = ?1").run(appId).changes > 0; | |
| 206 | } |
src/core/issues.ts +108 −4
| @@ -36,6 +36,15 @@ export interface Issue { | ||
| 36 | 36 | readonly originAuthor: string | null; |
| 37 | 37 | /** The canonical URL at the origin, for "view at source". */ |
| 38 | 38 | readonly originUrl: string | null; |
| 39 | /** | |
| 40 | * Who is on it — one person or agent, or nobody. Assigning an agent is | |
| 41 | * what starts it working (docs/APPS.md), so this is a dispatch, not a | |
| 42 | * decoration. | |
| 43 | */ | |
| 44 | readonly assigneeId: string | null; | |
| 45 | readonly assigneeName: string | null; | |
| 46 | /** Whether the assignee is a crew member. */ | |
| 47 | readonly assigneeIsAgent: boolean; | |
| 39 | 48 | } |
| 40 | 49 | |
| 41 | 50 | interface IssueRow { |
| @@ -53,11 +62,16 @@ interface IssueRow { | ||
| 53 | 62 | closed_at: string | null; |
| 54 | 63 | origin_author: string | null; |
| 55 | 64 | origin_html_url: string | null; |
| 65 | assignee_id: string | null; | |
| 66 | assignee_name: string | null; | |
| 67 | assignee_is_agent: number | null; | |
| 56 | 68 | } |
| 57 | 69 | |
| 58 | 70 | const SELECT = ` |
| 59 | SELECT issue.*, user.username AS author_name | |
| 60 | FROM issue JOIN user ON user.id = issue.author_id`; | |
| 71 | SELECT issue.*, user.username AS author_name, | |
| 72 | assignee.username AS assignee_name, assignee.is_agent AS assignee_is_agent | |
| 73 | FROM issue JOIN user ON user.id = issue.author_id | |
| 74 | LEFT JOIN user AS assignee ON assignee.id = issue.assignee_id`; | |
| 61 | 75 | |
| 62 | 76 | function toIssue(row: IssueRow): Issue { |
| 63 | 77 | return { |
| @@ -75,9 +89,95 @@ function toIssue(row: IssueRow): Issue { | ||
| 75 | 89 | closedAt: row.closed_at, |
| 76 | 90 | originAuthor: row.origin_author, |
| 77 | 91 | originUrl: row.origin_html_url, |
| 92 | assigneeId: row.assignee_id, | |
| 93 | assigneeName: row.assignee_name, | |
| 94 | assigneeIsAgent: row.assignee_is_agent === 1, | |
| 78 | 95 | }; |
| 79 | 96 | } |
| 80 | 97 | |
| 98 | /** | |
| 99 | * Set or clear the assignee. Returns the issue as it now stands, or | |
| 100 | * undefined when it already stood that way — a no-op is not an event. | |
| 101 | */ | |
| 102 | export function setAssignee( | |
| 103 | db: Database, | |
| 104 | issueId: string, | |
| 105 | assigneeId: string | null, | |
| 106 | ): Issue | undefined { | |
| 107 | const changed = db | |
| 108 | .query( | |
| 109 | `UPDATE issue SET assignee_id = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') | |
| 110 | WHERE id = ?1 AND assignee_id IS NOT ?2`, | |
| 111 | ) | |
| 112 | .run(issueId, assigneeId).changes; | |
| 113 | return changed > 0 ? getIssueById(db, issueId) : undefined; | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * Several conversations by id, in one query per chunk — what a run list | |
| 118 | * needs to name the pull each run is about without a read per row. | |
| 119 | */ | |
| 120 | export function getIssuesByIds( | |
| 121 | db: Database, | |
| 122 | issueIds: readonly string[], | |
| 123 | ): Map<string, Issue> { | |
| 124 | const out = new Map<string, Issue>(); | |
| 125 | const unique = [...new Set(issueIds)]; | |
| 126 | const CHUNK = 400; | |
| 127 | for (let i = 0; i < unique.length; i += CHUNK) { | |
| 128 | const batch = unique.slice(i, i + CHUNK); | |
| 129 | const rows = db | |
| 130 | .query< | |
| 131 | IssueRow, | |
| 132 | string[] | |
| 133 | >(`${SELECT} WHERE issue.id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})`) | |
| 134 | .all(...batch); | |
| 135 | for (const row of rows) out.set(row.id, toIssue(row)); | |
| 136 | } | |
| 137 | return out; | |
| 138 | } | |
| 139 | ||
| 140 | /** | |
| 141 | * Open pulls the crew has proposed, in repositories the viewer can see, | |
| 142 | * newest activity first — the "needs you" inbox, as one bounded query. The | |
| 143 | * caller narrows to repositories the viewer can *write* if it wants only | |
| 144 | * what they may merge; visibility is the part that must be in SQL. | |
| 145 | */ | |
| 146 | export function listOpenAgentPullsVisibleTo( | |
| 147 | db: Database, | |
| 148 | viewer: { id: string }, | |
| 149 | limit = 25, | |
| 150 | ): Issue[] { | |
| 151 | return db | |
| 152 | .query<IssueRow, [string, number]>( | |
| 153 | `${SELECT} JOIN repo ON repo.id = issue.repo_id | |
| 154 | WHERE issue.kind = 'pull' AND issue.state = 'open' AND user.is_agent = 1 | |
| 155 | AND ${repoVisible(true)} | |
| 156 | ORDER BY issue.updated_at DESC LIMIT ?2`, | |
| 157 | ) | |
| 158 | .all(viewer.id, Math.min(Math.max(limit, 1), 200)) | |
| 159 | .map(toIssue); | |
| 160 | } | |
| 161 | ||
| 162 | /** | |
| 163 | * Open conversations assigned to one account, across every repository — | |
| 164 | * the "on you" list. An assignee can always see what is assigned to them: | |
| 165 | * assigning needs write access, and write implies read. | |
| 166 | */ | |
| 167 | export function listAssignedTo( | |
| 168 | db: Database, | |
| 169 | userId: string, | |
| 170 | limit = 50, | |
| 171 | ): Issue[] { | |
| 172 | return db | |
| 173 | .query< | |
| 174 | IssueRow, | |
| 175 | [string, number] | |
| 176 | >(`${SELECT} WHERE issue.assignee_id = ?1 AND issue.state = 'open' ORDER BY issue.updated_at DESC LIMIT ?2`) | |
| 177 | .all(userId, limit) | |
| 178 | .map(toIssue); | |
| 179 | } | |
| 180 | ||
| 81 | 181 | /** Atomically hand out the next issue number for a repo. */ |
| 82 | 182 | export function nextNumber(db: Database, repoId: string): number { |
| 83 | 183 | const row = db |
| @@ -184,6 +284,8 @@ export interface ListIssuesOptions { | ||
| 184 | 284 | label?: string; |
| 185 | 285 | /** Only issues opened by this username. */ |
| 186 | 286 | author?: string; |
| 287 | /** Only issues assigned to this username. */ | |
| 288 | assignee?: string; | |
| 187 | 289 | /** Case-insensitive substring of the title — the list page's search box. */ |
| 188 | 290 | query?: string; |
| 189 | 291 | /** Page size. @default LIST_LIMIT (max LIST_LIMIT_MAX) */ |
| @@ -213,6 +315,10 @@ export function listIssues( | ||
| 213 | 315 | params.push(options.author); |
| 214 | 316 | where += ` AND user.username = ?${params.length} COLLATE NOCASE`; |
| 215 | 317 | } |
| 318 | if (options.assignee !== undefined) { | |
| 319 | params.push(options.assignee); | |
| 320 | where += ` AND assignee.username = ?${params.length} COLLATE NOCASE`; | |
| 321 | } | |
| 216 | 322 | if (options.label !== undefined) { |
| 217 | 323 | params.push(options.label); |
| 218 | 324 | const p = params.length; |
| @@ -606,8 +712,6 @@ const chunked = <T>(items: readonly T[]): T[][] => { | ||
| 606 | 712 | const placeholders = (n: number): string => |
| 607 | 713 | Array.from({ length: n }, (_, i) => `?${i + 1}`).join(","); |
| 608 | 714 | |
| 609 | ||
| 610 | ||
| 611 | 715 | /** |
| 612 | 716 | * Open and closed counts for one repo and kind, in a single pass over the |
| 613 | 717 | * covering index — the pair every list page shows above its tabs, which was |
src/core/pulls.ts +17 −3
| @@ -32,6 +32,7 @@ import { | ||
| 32 | 32 | type ListIssuesOptions, |
| 33 | 33 | } from "./issues.ts"; |
| 34 | 34 | import { reviewSummary } from "./reviews.ts"; |
| 35 | import { checkSummary } from "./runs.ts"; | |
| 35 | 36 | |
| 36 | 37 | export interface Pull extends Issue { |
| 37 | 38 | readonly baseBranch: string; |
| @@ -303,6 +304,11 @@ export interface PullReviewStatus { | ||
| 303 | 304 | export interface ReviewGate { |
| 304 | 305 | readonly db: Database; |
| 305 | 306 | readonly requireReview: boolean; |
| 307 | /** | |
| 308 | * Whether the head must also have a passing deploy run (`repo.requireChecks`). | |
| 309 | * Read live inside the merge like the review verdict. @default false | |
| 310 | */ | |
| 311 | readonly requireChecks?: boolean; | |
| 306 | 312 | } |
| 307 | 313 | |
| 308 | 314 | export interface PullStatus { |
| @@ -351,8 +357,7 @@ export async function pullStatus( | ||
| 351 | 357 | ? `refs/pull/${pull.number}/head` |
| 352 | 358 | : `refs/heads/${pull.headBranch}`; |
| 353 | 359 | const headOid = (await resolveOid(dir, headRef)) ?? pull.headOid; |
| 354 | const reviews = | |
| 355 | gate === undefined ? null : reviewGate(gate, pull, headOid); | |
| 360 | const reviews = gate === undefined ? null : reviewGate(gate, pull, headOid); | |
| 356 | 361 | const ahead = await aheadCount(dir, baseOid, headOid); |
| 357 | 362 | if (ahead === 0) { |
| 358 | 363 | return { |
| @@ -390,7 +395,9 @@ export type MergePullError = | ||
| 390 | 395 | | "closed" |
| 391 | 396 | | "mirrored" |
| 392 | 397 | /** The repo requires an approval of this head, or a change was requested. */ |
| 393 | | "review_required"; | |
| 398 | | "review_required" | |
| 399 | /** The repo requires a passing deploy of this head, and there is none yet. */ | |
| 400 | | "checks_required"; | |
| 394 | 401 | |
| 395 | 402 | export async function mergePull( |
| 396 | 403 | db: Database, |
| @@ -414,6 +421,13 @@ export async function mergePull( | ||
| 414 | 421 | const status = await pullStatus(dir, pull, input.gate); |
| 415 | 422 | if (status.reviews !== null && !status.reviews.mergeable) |
| 416 | 423 | return err("review_required"); |
| 424 | // The check is the latest deploy run of *this* head. Read here, not | |
| 425 | // handed in, for the same reason as the review verdict above. | |
| 426 | if ( | |
| 427 | input.gate?.requireChecks === true && | |
| 428 | checkSummary(input.gate.db, pull.id, status.headOid).state !== "ok" | |
| 429 | ) | |
| 430 | return err("checks_required"); | |
| 417 | 431 | const landed = await merge(dir, { |
| 418 | 432 | baseBranch: pull.baseBranch, |
| 419 | 433 | baseOid: status.baseOid, |
src/core/repos.ts +58 −3
| @@ -20,7 +20,7 @@ import { id } from "../kernel/id.ts"; | ||
| 20 | 20 | import { createLog } from "../kernel/log.ts"; |
| 21 | 21 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 22 | 22 | import { likeEscape, pageLimit } from "./issues.ts"; |
| 23 | import { REPO_VISIBLE } from "./visibility.ts"; | |
| 23 | import { REPO_VISIBLE, REPO_WRITABLE } from "./visibility.ts"; | |
| 24 | 24 | import { accessAllows, orgAccess, type Access } from "./orgs.ts"; |
| 25 | 25 | import { getUserById, type User } from "./users.ts"; |
| 26 | 26 | |
| @@ -65,6 +65,11 @@ export interface Repo { | ||
| 65 | 65 | * Mechanical, not advisory: `mergePull` refuses `review_required`. |
| 66 | 66 | */ |
| 67 | 67 | readonly requireReview: boolean; |
| 68 | /** | |
| 69 | * Whether a pull's current head must have a passing deploy run before it | |
| 70 | * merges. Read live inside `mergePull`, like `requireReview`. | |
| 71 | */ | |
| 72 | readonly requireChecks: boolean; | |
| 68 | 73 | /** The repo this was forked from, or null. */ |
| 69 | 74 | readonly forkParentId: string | null; |
| 70 | 75 | /** The origin this mirrors, or null for a native repo. */ |
| @@ -87,6 +92,7 @@ interface RepoRow { | ||
| 87 | 92 | default_branch: string; |
| 88 | 93 | protect_default: number; |
| 89 | 94 | require_review: number; |
| 95 | require_checks: number; | |
| 90 | 96 | fork_parent_id: string | null; |
| 91 | 97 | origin_id: string | null; |
| 92 | 98 | origin_kind: "github" | "forgejo" | "gitea" | null; |
| @@ -114,6 +120,7 @@ function toRepo(row: RepoRow): Repo { | ||
| 114 | 120 | defaultBranch: row.default_branch, |
| 115 | 121 | protectDefault: row.protect_default === 1, |
| 116 | 122 | requireReview: row.require_review === 1, |
| 123 | requireChecks: row.require_checks === 1, | |
| 117 | 124 | forkParentId: row.fork_parent_id, |
| 118 | 125 | origin: |
| 119 | 126 | row.origin_id !== null && row.origin_kind !== null |
| @@ -480,12 +487,26 @@ export async function deleteRepo( | ||
| 480 | 487 | } |
| 481 | 488 | |
| 482 | 489 | // Permissions. A personal repo is governed by ownership; an org's repo by the |
| 483 | // caller's team access in that org. Site admins see and do everything. | |
| 490 | // caller's team access in that org. Site admins see and do everything. An | |
| 491 | // agent on a repo's crew reads and writes it — and only that: the crew | |
| 492 | // grant stops at write, so administration stays with people. | |
| 484 | 493 | |
| 485 | 494 | function personalOrDirect(repo: Repo, user: User | undefined): boolean { |
| 486 | 495 | return user !== undefined && (user.id === repo.ownerId || user.isAdmin); |
| 487 | 496 | } |
| 488 | 497 | |
| 498 | function onCrew(db: Database, repo: Repo, user: User | undefined): boolean { | |
| 499 | if (user === undefined || user.kind !== "agent") return false; | |
| 500 | return ( | |
| 501 | db | |
| 502 | .query< | |
| 503 | { n: number }, | |
| 504 | [string, string] | |
| 505 | >("SELECT 1 AS n FROM repo_crew WHERE repo_id = ?1 AND user_id = ?2") | |
| 506 | .get(repo.id, user.id) !== null | |
| 507 | ); | |
| 508 | } | |
| 509 | ||
| 489 | 510 | /** Resolve org access for an org-owned repo, or "none" for a personal repo. */ |
| 490 | 511 | function orgAccessFor( |
| 491 | 512 | db: Database, |
| @@ -504,6 +525,7 @@ export function canRead( | ||
| 504 | 525 | ): boolean { |
| 505 | 526 | if (repo.visibility === "public") return true; |
| 506 | 527 | if (personalOrDirect(repo, user)) return true; |
| 528 | if (onCrew(db, repo, user)) return true; | |
| 507 | 529 | return accessAllows(orgAccessFor(db, repo, user), "read"); |
| 508 | 530 | } |
| 509 | 531 | |
| @@ -513,9 +535,39 @@ export function canWrite( | ||
| 513 | 535 | user: User | undefined, |
| 514 | 536 | ): boolean { |
| 515 | 537 | if (personalOrDirect(repo, user)) return true; |
| 538 | if (onCrew(db, repo, user)) return true; | |
| 516 | 539 | return accessAllows(orgAccessFor(db, repo, user), "write"); |
| 517 | 540 | } |
| 518 | 541 | |
| 542 | /** | |
| 543 | * Which of these repositories the user may write — `canWrite` for many rows | |
| 544 | * in one query per chunk, through `REPO_WRITABLE`. The dashboard's inbox | |
| 545 | * asks this for every crew pull it shows; asked per row that was a | |
| 546 | * permission check, and a user lookup, per pull. | |
| 547 | */ | |
| 548 | export function writableRepoIds( | |
| 549 | db: Database, | |
| 550 | user: User | undefined, | |
| 551 | repoIds: readonly string[], | |
| 552 | ): Set<string> { | |
| 553 | const out = new Set<string>(); | |
| 554 | if (user === undefined) return out; | |
| 555 | const unique = [...new Set(repoIds)]; | |
| 556 | const CHUNK = 400; | |
| 557 | for (let i = 0; i < unique.length; i += CHUNK) { | |
| 558 | const batch = unique.slice(i, i + CHUNK); | |
| 559 | const rows = db | |
| 560 | .query<{ id: string }, string[]>( | |
| 561 | `SELECT repo.id FROM repo | |
| 562 | WHERE repo.id IN (${batch.map((_, n) => `?${n + 2}`).join(",")}) | |
| 563 | AND ${REPO_WRITABLE}`, | |
| 564 | ) | |
| 565 | .all(user.id, ...batch); | |
| 566 | for (const row of rows) out.add(row.id); | |
| 567 | } | |
| 568 | return out; | |
| 569 | } | |
| 570 | ||
| 519 | 571 | /** Admin over a repo (settings, delete, webhooks, protection). */ |
| 520 | 572 | export function canAdmin( |
| 521 | 573 | db: Database, |
| @@ -532,6 +584,8 @@ export interface RepoSettings { | ||
| 532 | 584 | protectDefault?: boolean; |
| 533 | 585 | /** Require an approval of a pull's current head before it may merge. */ |
| 534 | 586 | requireReview?: boolean; |
| 587 | /** Require a passing deploy of a pull's current head before it may merge. */ | |
| 588 | requireChecks?: boolean; | |
| 535 | 589 | /** The caller must have pointed git HEAD at this branch first. */ |
| 536 | 590 | defaultBranch?: string; |
| 537 | 591 | } |
| @@ -544,7 +598,7 @@ export function updateRepoSettings( | ||
| 544 | 598 | ): Repo { |
| 545 | 599 | db.query( |
| 546 | 600 | `UPDATE repo SET visibility = ?1, description = ?2, protect_default = ?3, |
| 547 | default_branch = ?4, require_review = ?5 WHERE id = ?6`, | |
| 601 | default_branch = ?4, require_review = ?5, require_checks = ?7 WHERE id = ?6`, | |
| 548 | 602 | ).run( |
| 549 | 603 | settings.visibility ?? repo.visibility, |
| 550 | 604 | settings.description ?? repo.description, |
| @@ -552,6 +606,7 @@ export function updateRepoSettings( | ||
| 552 | 606 | settings.defaultBranch ?? repo.defaultBranch, |
| 553 | 607 | (settings.requireReview ?? repo.requireReview) ? 1 : 0, |
| 554 | 608 | repo.id, |
| 609 | (settings.requireChecks ?? repo.requireChecks) ? 1 : 0, | |
| 555 | 610 | ); |
| 556 | 611 | return getRepoById(db, repo.id)!; |
| 557 | 612 | } |
src/core/runs.ts added +401
| @@ -0,0 +1,401 @@ | ||
| 1 | /** | |
| 2 | * Runs — the durable record of work the forge did on a commit. A `deploy` | |
| 3 | * run is an environment being brought up at one commit, and its outcome is | |
| 4 | * the check a pull shows; an `agent` run is a crew member working an issue | |
| 5 | * or a pull. Rows move `queued` → `running` by compare-and-swap and end | |
| 6 | * `ok`, `failed` or `cancelled`. The log is a file (`runs/log.ts`); the | |
| 7 | * row carries the one-line summary and the numbers. Pure data: no authz, | |
| 8 | * no events. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Database } from "bun:sqlite"; | |
| 12 | import { id } from "../kernel/id.ts"; | |
| 13 | ||
| 14 | export type RunKind = "deploy" | "agent"; | |
| 15 | export type RunState = "queued" | "running" | "ok" | "failed" | "cancelled"; | |
| 16 | ||
| 17 | export const RUN_TERMINAL: readonly RunState[] = ["ok", "failed", "cancelled"]; | |
| 18 | export const isTerminal = (state: RunState): boolean => | |
| 19 | RUN_TERMINAL.includes(state); | |
| 20 | ||
| 21 | export interface Run { | |
| 22 | readonly id: string; | |
| 23 | readonly repoId: string; | |
| 24 | readonly kind: RunKind; | |
| 25 | /** The issue or pull this run is about, if any. */ | |
| 26 | readonly issueId: string | null; | |
| 27 | /** For a deploy: `production` or `pull-<n>`. Empty for an agent run. */ | |
| 28 | readonly env: string; | |
| 29 | readonly commitOid: string; | |
| 30 | /** For an agent run: the branch it works on. */ | |
| 31 | readonly branch: string; | |
| 32 | readonly state: RunState; | |
| 33 | /** One line: what happened, in words a person reads on a card. */ | |
| 34 | readonly summary: string; | |
| 35 | /** The agent for an agent run; whoever triggered a deploy otherwise. */ | |
| 36 | readonly actorId: string | null; | |
| 37 | /** Why this run exists: `assigned`, `feedback`, `check_failed`, `push`, `boot`, … */ | |
| 38 | readonly reason: string; | |
| 39 | /** The agent session to resume for a follow-up, when there is one. */ | |
| 40 | readonly sessionId: string; | |
| 41 | readonly costUsd: number; | |
| 42 | readonly createdAt: string; | |
| 43 | readonly startedAt: string | null; | |
| 44 | readonly finishedAt: string | null; | |
| 45 | } | |
| 46 | ||
| 47 | interface RunRow { | |
| 48 | id: string; | |
| 49 | repo_id: string; | |
| 50 | kind: RunKind; | |
| 51 | issue_id: string | null; | |
| 52 | env: string; | |
| 53 | commit_oid: string; | |
| 54 | branch: string; | |
| 55 | state: RunState; | |
| 56 | summary: string; | |
| 57 | actor_id: string | null; | |
| 58 | reason: string; | |
| 59 | session_id: string; | |
| 60 | cost_usd: number; | |
| 61 | created_at: string; | |
| 62 | started_at: string | null; | |
| 63 | finished_at: string | null; | |
| 64 | } | |
| 65 | ||
| 66 | const toRun = (r: RunRow): Run => ({ | |
| 67 | id: r.id, | |
| 68 | repoId: r.repo_id, | |
| 69 | kind: r.kind, | |
| 70 | issueId: r.issue_id, | |
| 71 | env: r.env, | |
| 72 | commitOid: r.commit_oid, | |
| 73 | branch: r.branch, | |
| 74 | state: r.state, | |
| 75 | summary: r.summary, | |
| 76 | actorId: r.actor_id, | |
| 77 | reason: r.reason, | |
| 78 | sessionId: r.session_id, | |
| 79 | costUsd: r.cost_usd, | |
| 80 | createdAt: r.created_at, | |
| 81 | startedAt: r.started_at, | |
| 82 | finishedAt: r.finished_at, | |
| 83 | }); | |
| 84 | ||
| 85 | export interface CreateRunInput { | |
| 86 | repoId: string; | |
| 87 | kind: RunKind; | |
| 88 | issueId?: string | undefined; | |
| 89 | env?: string | undefined; | |
| 90 | commitOid?: string | undefined; | |
| 91 | branch?: string | undefined; | |
| 92 | actorId?: string | undefined; | |
| 93 | reason?: string | undefined; | |
| 94 | } | |
| 95 | ||
| 96 | export function createRun(db: Database, input: CreateRunInput): Run { | |
| 97 | const runId = id(); | |
| 98 | db.query( | |
| 99 | `INSERT INTO run (id, repo_id, kind, issue_id, env, commit_oid, branch, actor_id, reason) | |
| 100 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`, | |
| 101 | ).run( | |
| 102 | runId, | |
| 103 | input.repoId, | |
| 104 | input.kind, | |
| 105 | input.issueId ?? null, | |
| 106 | input.env ?? "", | |
| 107 | input.commitOid ?? "", | |
| 108 | input.branch ?? "", | |
| 109 | input.actorId ?? null, | |
| 110 | input.reason ?? "", | |
| 111 | ); | |
| 112 | return getRun(db, runId)!; | |
| 113 | } | |
| 114 | ||
| 115 | export function getRun(db: Database, runId: string): Run | undefined { | |
| 116 | const row = db | |
| 117 | .query<RunRow, [string]>("SELECT * FROM run WHERE id = ?1") | |
| 118 | .get(runId); | |
| 119 | return row ? toRun(row) : undefined; | |
| 120 | } | |
| 121 | ||
| 122 | /** | |
| 123 | * Take a queued run. The UPDATE's WHERE is the claim: a second worker's | |
| 124 | * changes count is zero and it moves on. Returns the run as running, or | |
| 125 | * undefined when someone else has it (or it was cancelled meanwhile). | |
| 126 | */ | |
| 127 | export function claimRun(db: Database, runId: string): Run | undefined { | |
| 128 | const changes = db | |
| 129 | .query( | |
| 130 | `UPDATE run SET state = 'running', started_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') | |
| 131 | WHERE id = ?1 AND state = 'queued'`, | |
| 132 | ) | |
| 133 | .run(runId).changes; | |
| 134 | return changes > 0 ? getRun(db, runId) : undefined; | |
| 135 | } | |
| 136 | ||
| 137 | /** | |
| 138 | * Finish a run. Only a running run finishes; a cancelled one stays | |
| 139 | * cancelled however late its worker reports in. Returns the final row, or | |
| 140 | * undefined when nothing changed. | |
| 141 | */ | |
| 142 | export function finishRun( | |
| 143 | db: Database, | |
| 144 | runId: string, | |
| 145 | outcome: { | |
| 146 | state: "ok" | "failed"; | |
| 147 | summary: string; | |
| 148 | sessionId?: string | undefined; | |
| 149 | costUsd?: number | undefined; | |
| 150 | }, | |
| 151 | ): Run | undefined { | |
| 152 | const changes = db | |
| 153 | .query( | |
| 154 | `UPDATE run SET state = ?2, summary = ?3, | |
| 155 | session_id = COALESCE(?4, session_id), cost_usd = COALESCE(?5, cost_usd), | |
| 156 | finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') | |
| 157 | WHERE id = ?1 AND state = 'running'`, | |
| 158 | ) | |
| 159 | .run( | |
| 160 | runId, | |
| 161 | outcome.state, | |
| 162 | outcome.summary.slice(0, 500), | |
| 163 | outcome.sessionId ?? null, | |
| 164 | outcome.costUsd ?? null, | |
| 165 | ).changes; | |
| 166 | return changes > 0 ? getRun(db, runId) : undefined; | |
| 167 | } | |
| 168 | ||
| 169 | /** Cancel a run that has not finished. Returns the row, or undefined if it had. */ | |
| 170 | export function cancelRun( | |
| 171 | db: Database, | |
| 172 | runId: string, | |
| 173 | summary = "cancelled", | |
| 174 | ): Run | undefined { | |
| 175 | const changes = db | |
| 176 | .query( | |
| 177 | `UPDATE run SET state = 'cancelled', summary = ?2, | |
| 178 | finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') | |
| 179 | WHERE id = ?1 AND state IN ('queued','running')`, | |
| 180 | ) | |
| 181 | .run(runId, summary).changes; | |
| 182 | return changes > 0 ? getRun(db, runId) : undefined; | |
| 183 | } | |
| 184 | ||
| 185 | /** Record spend and the session as a run goes, so a cut-off run still reports it. */ | |
| 186 | export function noteRunProgress( | |
| 187 | db: Database, | |
| 188 | runId: string, | |
| 189 | progress: { | |
| 190 | sessionId?: string | undefined; | |
| 191 | costUsd?: number | undefined; | |
| 192 | branch?: string | undefined; | |
| 193 | }, | |
| 194 | ): void { | |
| 195 | db.query( | |
| 196 | `UPDATE run SET session_id = COALESCE(?2, session_id), cost_usd = COALESCE(?3, cost_usd), | |
| 197 | branch = COALESCE(?4, branch) | |
| 198 | WHERE id = ?1`, | |
| 199 | ).run( | |
| 200 | runId, | |
| 201 | progress.sessionId ?? null, | |
| 202 | progress.costUsd ?? null, | |
| 203 | progress.branch ?? null, | |
| 204 | ); | |
| 205 | } | |
| 206 | ||
| 207 | /** | |
| 208 | * The most recent finished agent run about an issue by one agent — the | |
| 209 | * session to resume and the moment after which conversation is news. | |
| 210 | */ | |
| 211 | export function lastAgentRun( | |
| 212 | db: Database, | |
| 213 | issueId: string, | |
| 214 | agentId: string, | |
| 215 | ): Run | undefined { | |
| 216 | const row = db | |
| 217 | .query<RunRow, [string, string]>( | |
| 218 | `SELECT * FROM run WHERE kind = 'agent' AND issue_id = ?1 AND actor_id = ?2 | |
| 219 | AND state IN ('ok','failed','cancelled') ORDER BY id DESC LIMIT 1`, | |
| 220 | ) | |
| 221 | .get(issueId, agentId); | |
| 222 | return row ? toRun(row) : undefined; | |
| 223 | } | |
| 224 | ||
| 225 | /** | |
| 226 | * Runs a process found on boot still marked running belong to a process | |
| 227 | * that no longer exists. Fail them honestly rather than leave a spinner. | |
| 228 | */ | |
| 229 | export function failOrphanedRuns(db: Database): number { | |
| 230 | return db | |
| 231 | .query( | |
| 232 | `UPDATE run SET state = 'failed', summary = 'the forge restarted while this was running', | |
| 233 | finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') | |
| 234 | WHERE state = 'running'`, | |
| 235 | ) | |
| 236 | .run().changes; | |
| 237 | } | |
| 238 | ||
| 239 | export interface ListRunsOptions { | |
| 240 | kind?: RunKind | undefined; | |
| 241 | /** Only runs about this issue or pull. */ | |
| 242 | issueId?: string | undefined; | |
| 243 | /** Only runs for this environment. */ | |
| 244 | env?: string | undefined; | |
| 245 | /** @default 50 */ | |
| 246 | limit?: number | undefined; | |
| 247 | /** Resume after this run id (exclusive) — lists run newest first. */ | |
| 248 | cursor?: string | undefined; | |
| 249 | } | |
| 250 | ||
| 251 | export function listRuns( | |
| 252 | db: Database, | |
| 253 | repoId: string, | |
| 254 | options: ListRunsOptions = {}, | |
| 255 | ): Run[] { | |
| 256 | const params: (string | number)[] = [repoId]; | |
| 257 | let where = "repo_id = ?1"; | |
| 258 | if (options.kind !== undefined) { | |
| 259 | params.push(options.kind); | |
| 260 | where += ` AND kind = ?${params.length}`; | |
| 261 | } | |
| 262 | if (options.issueId !== undefined) { | |
| 263 | params.push(options.issueId); | |
| 264 | where += ` AND issue_id = ?${params.length}`; | |
| 265 | } | |
| 266 | if (options.env !== undefined) { | |
| 267 | params.push(options.env); | |
| 268 | where += ` AND env = ?${params.length}`; | |
| 269 | } | |
| 270 | if (options.cursor !== undefined) { | |
| 271 | params.push(options.cursor); | |
| 272 | where += ` AND id < ?${params.length}`; | |
| 273 | } | |
| 274 | params.push(Math.min(Math.max(options.limit ?? 50, 1), 200)); | |
| 275 | return db | |
| 276 | .query<RunRow, (string | number)[]>( | |
| 277 | `SELECT * FROM run WHERE ${where} ORDER BY id DESC LIMIT ?${params.length}`, | |
| 278 | ) | |
| 279 | .all(...params) | |
| 280 | .map(toRun); | |
| 281 | } | |
| 282 | ||
| 283 | /** Queued runs of one kind, oldest first — what a worker takes from. */ | |
| 284 | export function listQueued(db: Database, kind: RunKind, limit = 10): Run[] { | |
| 285 | return db | |
| 286 | .query< | |
| 287 | RunRow, | |
| 288 | [RunKind, number] | |
| 289 | >("SELECT * FROM run WHERE state = 'queued' AND kind = ?1 ORDER BY id LIMIT ?2") | |
| 290 | .all(kind, limit) | |
| 291 | .map(toRun); | |
| 292 | } | |
| 293 | ||
| 294 | /** Runs of one kind not yet finished, for concurrency and "is one on it?". */ | |
| 295 | export function listActive(db: Database, kind: RunKind): Run[] { | |
| 296 | return db | |
| 297 | .query< | |
| 298 | RunRow, | |
| 299 | [RunKind] | |
| 300 | >("SELECT * FROM run WHERE state IN ('queued','running') AND kind = ?1 ORDER BY id") | |
| 301 | .all(kind) | |
| 302 | .map(toRun); | |
| 303 | } | |
| 304 | ||
| 305 | /** The unfinished run about one issue, if any: the reason a second is not queued. */ | |
| 306 | export function activeRunFor( | |
| 307 | db: Database, | |
| 308 | kind: RunKind, | |
| 309 | issueId: string, | |
| 310 | ): Run | undefined { | |
| 311 | const row = db | |
| 312 | .query< | |
| 313 | RunRow, | |
| 314 | [RunKind, string] | |
| 315 | >("SELECT * FROM run WHERE state IN ('queued','running') AND kind = ?1 AND issue_id = ?2 ORDER BY id LIMIT 1") | |
| 316 | .get(kind, issueId); | |
| 317 | return row ? toRun(row) : undefined; | |
| 318 | } | |
| 319 | ||
| 320 | /** | |
| 321 | * The checks on a pull: the deploy runs at its current head, newest first. | |
| 322 | * Older heads are history — a push makes new runs — so a caller that wants | |
| 323 | * "does this head pass" reads the first row's state. | |
| 324 | */ | |
| 325 | export function checksForHead( | |
| 326 | db: Database, | |
| 327 | issueId: string, | |
| 328 | headOid: string, | |
| 329 | ): Run[] { | |
| 330 | return db | |
| 331 | .query< | |
| 332 | RunRow, | |
| 333 | [string, string] | |
| 334 | >("SELECT * FROM run WHERE kind = 'deploy' AND issue_id = ?1 AND commit_oid = ?2 ORDER BY id DESC") | |
| 335 | .all(issueId, headOid) | |
| 336 | .map(toRun); | |
| 337 | } | |
| 338 | ||
| 339 | export interface CheckSummary { | |
| 340 | /** `ok` when the latest deploy of this head succeeded; `none` when there is none. */ | |
| 341 | readonly state: RunState | "none"; | |
| 342 | readonly run: Run | null; | |
| 343 | } | |
| 344 | ||
| 345 | export function checkSummary( | |
| 346 | db: Database, | |
| 347 | issueId: string, | |
| 348 | headOid: string, | |
| 349 | ): CheckSummary { | |
| 350 | const latest = checksForHead(db, issueId, headOid)[0]; | |
| 351 | return latest | |
| 352 | ? { state: latest.state, run: latest } | |
| 353 | : { state: "none", run: null }; | |
| 354 | } | |
| 355 | ||
| 356 | /** | |
| 357 | * The latest run per issue for a set of issues — one query, for a list that | |
| 358 | * shows a check mark beside every pull without asking per row. | |
| 359 | */ | |
| 360 | export function latestRunsForIssues( | |
| 361 | db: Database, | |
| 362 | issueIds: readonly string[], | |
| 363 | kind: RunKind = "deploy", | |
| 364 | ): Map<string, Run> { | |
| 365 | const out = new Map<string, Run>(); | |
| 366 | const unique = [...new Set(issueIds)]; | |
| 367 | const CHUNK = 400; | |
| 368 | for (let i = 0; i < unique.length; i += CHUNK) { | |
| 369 | const batch = unique.slice(i, i + CHUNK); | |
| 370 | const rows = db | |
| 371 | .query<RunRow, string[]>( | |
| 372 | `SELECT run.* FROM run | |
| 373 | JOIN (SELECT issue_id, MAX(id) AS id FROM run | |
| 374 | WHERE kind = ?1 AND issue_id IN (${batch.map((_, n) => `?${n + 2}`).join(",")}) | |
| 375 | GROUP BY issue_id) latest ON latest.id = run.id`, | |
| 376 | ) | |
| 377 | .all(kind, ...batch); | |
| 378 | for (const row of rows) if (row.issue_id) out.set(row.issue_id, toRun(row)); | |
| 379 | } | |
| 380 | return out; | |
| 381 | } | |
| 382 | ||
| 383 | /** | |
| 384 | * How many automatic follow-ups an agent has had on a pull since a person | |
| 385 | * last spoke: the bound behind rule 7 in docs/APPS.md. `since` is the | |
| 386 | * moment of the last human comment or review, or the pull's creation. | |
| 387 | */ | |
| 388 | export function automaticRunsSince( | |
| 389 | db: Database, | |
| 390 | issueId: string, | |
| 391 | since: string, | |
| 392 | ): number { | |
| 393 | const row = db | |
| 394 | .query<{ n: number }, [string, string]>( | |
| 395 | `SELECT COUNT(*) AS n FROM run | |
| 396 | WHERE kind = 'agent' AND issue_id = ?1 AND created_at > ?2 | |
| 397 | AND reason IN ('check_failed')`, | |
| 398 | ) | |
| 399 | .get(issueId, since); | |
| 400 | return row?.n ?? 0; | |
| 401 | } |
src/core/schema.ts +93
| @@ -506,4 +506,97 @@ export const migrations: readonly string[] = [ | ||
| 506 | 506 | ALTER TABLE repo ADD COLUMN origin_fork_of TEXT; |
| 507 | 507 | ALTER TABLE origin_repo ADD COLUMN fork_of TEXT; |
| 508 | 508 | `, |
| 509 | ||
| 510 | // 14 — apps, runs, and the crew (docs/APPS.md). | |
| 511 | // | |
| 512 | // An *app* is a repository that runs. The row holds only what git cannot: | |
| 513 | // the slug its hostnames are built from, who may reach it, and whether a | |
| 514 | // preview starts from a copy of production's data. How it runs is | |
| 515 | // `forge.json` in the tree; what is running is a process, and a process | |
| 516 | // is not state — every environment is rebuilt on boot from the default | |
| 517 | // branch and the open pulls. | |
| 518 | // | |
| 519 | // A *run* is the durable record of work done on a commit: a deploy (its | |
| 520 | // outcome is the check a pull shows) or an agent working an issue. Rows | |
| 521 | // are claimed `queued` → `running` with a compare-and-swap, so two | |
| 522 | // workers can never take one. A run belongs to a repository and, when it | |
| 523 | // is about a conversation, to that issue or pull; deleting either takes | |
| 524 | // its runs with it, because a run about nothing is nothing. | |
| 525 | // | |
| 526 | // An *agent* is a user (`is_agent`) with a model, a brief and a budget. | |
| 527 | // The flag lives beside `kind` rather than in it: `kind`'s CHECK was | |
| 528 | // written before agents existed and a column cannot be re-checked without | |
| 529 | // rebuilding `user` under every table that references it. `toUser` reads | |
| 530 | // the flag as `kind: "agent"`, so nothing above the row ever sees two | |
| 531 | // fields. Agents have no password (`password_hash` is empty, which no | |
| 532 | // verifier accepts) and act only through tokens minted for a run — | |
| 533 | // `token.expires_at` is what makes such a token die with the run. | |
| 534 | // | |
| 535 | // `issue.assignee_id` is how work reaches an agent: assigning is the | |
| 536 | // dispatch. It is a plain column with a single assignee on purpose — one | |
| 537 | // person or agent is on it, and that is legible. | |
| 538 | // | |
| 539 | // `repo.require_checks` is the second merge gate beside `require_review`: | |
| 540 | // the head's deploy run must be `ok`, read live inside `mergePull`. | |
| 541 | ` | |
| 542 | ALTER TABLE user ADD COLUMN is_agent INTEGER NOT NULL DEFAULT 0; | |
| 543 | ALTER TABLE token ADD COLUMN expires_at TEXT; | |
| 544 | ALTER TABLE issue ADD COLUMN assignee_id TEXT REFERENCES user(id) ON DELETE SET NULL; | |
| 545 | CREATE INDEX issue_assignee ON issue(assignee_id, state) WHERE assignee_id IS NOT NULL; | |
| 546 | ALTER TABLE repo ADD COLUMN require_checks INTEGER NOT NULL DEFAULT 0; | |
| 547 | ||
| 548 | CREATE TABLE app ( | |
| 549 | id TEXT PRIMARY KEY, | |
| 550 | repo_id TEXT NOT NULL UNIQUE REFERENCES repo(id) ON DELETE CASCADE, | |
| 551 | slug TEXT NOT NULL UNIQUE COLLATE NOCASE, | |
| 552 | access TEXT NOT NULL CHECK (access IN ('public','private')), | |
| 553 | preview_data TEXT NOT NULL DEFAULT 'copy' CHECK (preview_data IN ('copy','empty')), | |
| 554 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 555 | ); | |
| 556 | ||
| 557 | CREATE TABLE run ( | |
| 558 | id TEXT PRIMARY KEY, | |
| 559 | repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE, | |
| 560 | kind TEXT NOT NULL CHECK (kind IN ('deploy','agent')), | |
| 561 | issue_id TEXT REFERENCES issue(id) ON DELETE CASCADE, | |
| 562 | env TEXT NOT NULL DEFAULT '', | |
| 563 | commit_oid TEXT NOT NULL DEFAULT '', | |
| 564 | branch TEXT NOT NULL DEFAULT '', | |
| 565 | state TEXT NOT NULL DEFAULT 'queued' | |
| 566 | CHECK (state IN ('queued','running','ok','failed','cancelled')), | |
| 567 | summary TEXT NOT NULL DEFAULT '', | |
| 568 | actor_id TEXT REFERENCES user(id) ON DELETE SET NULL, | |
| 569 | reason TEXT NOT NULL DEFAULT '', | |
| 570 | session_id TEXT NOT NULL DEFAULT '', | |
| 571 | cost_usd REAL NOT NULL DEFAULT 0, | |
| 572 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 573 | started_at TEXT, | |
| 574 | finished_at TEXT | |
| 575 | ); | |
| 576 | CREATE INDEX run_repo ON run(repo_id, id DESC); | |
| 577 | CREATE INDEX run_issue ON run(issue_id, id DESC) WHERE issue_id IS NOT NULL; | |
| 578 | CREATE INDEX run_queue ON run(state, id) WHERE state IN ('queued','running'); | |
| 579 | ||
| 580 | CREATE TABLE agent ( | |
| 581 | user_id TEXT PRIMARY KEY REFERENCES user(id) ON DELETE CASCADE, | |
| 582 | model TEXT NOT NULL DEFAULT '', | |
| 583 | brief TEXT NOT NULL DEFAULT '', | |
| 584 | budget_usd REAL NOT NULL DEFAULT 0, | |
| 585 | created_by TEXT REFERENCES user(id) ON DELETE SET NULL, | |
| 586 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 587 | ); | |
| 588 | ||
| 589 | -- The crew of a repository: the agents that may read and write it. A | |
| 590 | -- personal repository is otherwise governed by ownership alone, and an | |
| 591 | -- agent is never an owner — so this is the one grant an agent can hold, | |
| 592 | -- and it stops at write. Admin over a repository stays with people. | |
| 593 | CREATE TABLE repo_crew ( | |
| 594 | repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE, | |
| 595 | user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, | |
| 596 | added_by TEXT REFERENCES user(id) ON DELETE SET NULL, | |
| 597 | added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 598 | PRIMARY KEY (repo_id, user_id) | |
| 599 | ); | |
| 600 | CREATE INDEX repo_crew_user ON repo_crew(user_id); | |
| 601 | `, | |
| 509 | 602 | ]; |
src/core/tokens.ts +28 −5
| @@ -24,20 +24,27 @@ export interface TokenInfo { | ||
| 24 | 24 | readonly scope: TokenScope; |
| 25 | 25 | readonly createdAt: string; |
| 26 | 26 | readonly lastUsedAt: string | null; |
| 27 | /** When the token stops working, or null for a token that does not. */ | |
| 28 | readonly expiresAt: string | null; | |
| 27 | 29 | } |
| 28 | 30 | |
| 29 | /** Returns the secret exactly once. It is never reconstructable. */ | |
| 31 | /** | |
| 32 | * Returns the secret exactly once. It is never reconstructable. A token with | |
| 33 | * `expiresAt` is refused after that instant and swept away afterwards — the | |
| 34 | * credential an agent run gets, which must not outlive the run. | |
| 35 | */ | |
| 30 | 36 | export function createToken( |
| 31 | 37 | db: Database, |
| 32 | 38 | userId: string, |
| 33 | 39 | name: string, |
| 34 | 40 | scope: TokenScope, |
| 41 | options: { expiresAt?: string } = {}, | |
| 35 | 42 | ): { id: string; token: string } { |
| 36 | 43 | const tokenId = id(); |
| 37 | 44 | const token = `forge_${secret()}`; |
| 38 | 45 | db.query( |
| 39 | "INSERT INTO token (id, token_hash, user_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5)", | |
| 40 | ).run(tokenId, sha256(token), userId, name, scope); | |
| 46 | "INSERT INTO token (id, token_hash, user_id, name, scope, expires_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", | |
| 47 | ).run(tokenId, sha256(token), userId, name, scope, options.expiresAt ?? null); | |
| 41 | 48 | return { id: tokenId, token }; |
| 42 | 49 | } |
| 43 | 50 | |
| @@ -66,13 +73,18 @@ export function verifyToken( | ||
| 66 | 73 | user_id: string; |
| 67 | 74 | scope: TokenScope; |
| 68 | 75 | last_used_at: string | null; |
| 76 | expires_at: string | null; | |
| 69 | 77 | }, |
| 70 | 78 | [string] |
| 71 | 79 | >( |
| 72 | "SELECT id, user_id, scope, last_used_at FROM token WHERE token_hash = ?1", | |
| 80 | "SELECT id, user_id, scope, last_used_at, expires_at FROM token WHERE token_hash = ?1", | |
| 73 | 81 | ) |
| 74 | 82 | .get(sha256(presented)); |
| 75 | 83 | if (!row) return err("invalid_token"); |
| 84 | // Expiry is decided here, on every use, not by the sweep: a sweep that | |
| 85 | // runs late must not be a token that works late. | |
| 86 | if (row.expires_at !== null && Date.parse(row.expires_at) <= Date.now()) | |
| 87 | return err("invalid_token"); | |
| 76 | 88 | const user = getUserById(db, row.user_id); |
| 77 | 89 | if (!user) return err("invalid_token"); |
| 78 | 90 | const now = Date.now(); |
| @@ -96,10 +108,11 @@ export function listTokens(db: Database, userId: string): TokenInfo[] { | ||
| 96 | 108 | scope: TokenScope; |
| 97 | 109 | created_at: string; |
| 98 | 110 | last_used_at: string | null; |
| 111 | expires_at: string | null; | |
| 99 | 112 | }, |
| 100 | 113 | [string] |
| 101 | 114 | >( |
| 102 | "SELECT id, name, scope, created_at, last_used_at FROM token WHERE user_id = ?1 ORDER BY id", | |
| 115 | "SELECT id, name, scope, created_at, last_used_at, expires_at FROM token WHERE user_id = ?1 ORDER BY id", | |
| 103 | 116 | ) |
| 104 | 117 | .all(userId); |
| 105 | 118 | return rows.map((r) => ({ |
| @@ -108,9 +121,19 @@ export function listTokens(db: Database, userId: string): TokenInfo[] { | ||
| 108 | 121 | scope: r.scope, |
| 109 | 122 | createdAt: r.created_at, |
| 110 | 123 | lastUsedAt: r.last_used_at, |
| 124 | expiresAt: r.expires_at, | |
| 111 | 125 | })); |
| 112 | 126 | } |
| 113 | 127 | |
| 128 | /** Remove tokens past their expiry. A cheap DELETE, run on the worker tick. */ | |
| 129 | export function pruneTokens(db: Database, now: Date = new Date()): number { | |
| 130 | return db | |
| 131 | .query( | |
| 132 | "DELETE FROM token WHERE expires_at IS NOT NULL AND expires_at <= ?1", | |
| 133 | ) | |
| 134 | .run(now.toISOString()).changes; | |
| 135 | } | |
| 136 | ||
| 114 | 137 | export function deleteToken( |
| 115 | 138 | db: Database, |
| 116 | 139 | userId: string, |
src/core/users.ts +59 −10
| @@ -14,8 +14,12 @@ export interface User { | ||
| 14 | 14 | readonly email: string; |
| 15 | 15 | readonly displayName: string; |
| 16 | 16 | readonly isAdmin: boolean; |
| 17 | /** "org" rows own repos and hold teams but can't authenticate. */ | |
| 18 | readonly kind: "user" | "org"; | |
| 17 | /** | |
| 18 | * "org" rows own repos and hold teams but can't authenticate. "agent" | |
| 19 | * rows are the crew: no password, no session; they act only through | |
| 20 | * tokens the forge mints for a run (docs/APPS.md). | |
| 21 | */ | |
| 22 | readonly kind: "user" | "org" | "agent"; | |
| 19 | 23 | readonly createdAt: string; |
| 20 | 24 | } |
| 21 | 25 | |
| @@ -27,6 +31,7 @@ interface UserRow { | ||
| 27 | 31 | is_admin: number; |
| 28 | 32 | password_hash: string; |
| 29 | 33 | kind: "user" | "org"; |
| 34 | is_agent: number; | |
| 30 | 35 | created_at: string; |
| 31 | 36 | } |
| 32 | 37 | |
| @@ -37,7 +42,9 @@ function toUser(row: UserRow): User { | ||
| 37 | 42 | email: row.email, |
| 38 | 43 | displayName: row.display_name, |
| 39 | 44 | isAdmin: row.is_admin === 1, |
| 40 | kind: row.kind, | |
| 45 | // The flag, not the column, decides: see migration 14 for why an agent | |
| 46 | // is stored as kind 'user' with is_agent set. | |
| 47 | kind: row.is_agent === 1 ? "agent" : row.kind, | |
| 41 | 48 | createdAt: row.created_at, |
| 42 | 49 | }; |
| 43 | 50 | } |
| @@ -66,6 +73,12 @@ export interface CreateUserInput { | ||
| 66 | 73 | * @default false |
| 67 | 74 | */ |
| 68 | 75 | adminIfFirst?: boolean; |
| 76 | /** | |
| 77 | * Create a crew member: no password is stored (`password` is ignored), | |
| 78 | * so nothing can ever sign in as it. Never admin, never first. | |
| 79 | * @default false | |
| 80 | */ | |
| 81 | agent?: boolean; | |
| 69 | 82 | } |
| 70 | 83 | |
| 71 | 84 | export async function createUser( |
| @@ -73,7 +86,9 @@ export async function createUser( | ||
| 73 | 86 | input: CreateUserInput, |
| 74 | 87 | ): Promise<Result<User, CreateUserError>> { |
| 75 | 88 | if (!isSafeName(input.username)) return err("invalid_username"); |
| 76 | if (input.password.length < MIN_PASSWORD) return err("weak_password"); | |
| 89 | const agent = input.agent === true; | |
| 90 | if (!agent && input.password.length < MIN_PASSWORD) | |
| 91 | return err("weak_password"); | |
| 77 | 92 | |
| 78 | 93 | const existing = db |
| 79 | 94 | .query< |
| @@ -90,17 +105,21 @@ export async function createUser( | ||
| 90 | 105 | } |
| 91 | 106 | |
| 92 | 107 | const userId = id(); |
| 93 | const hash = await Bun.password.hash(input.password, "argon2id"); | |
| 108 | // An agent's hash is empty, which no verifier accepts: argon2 refuses to | |
| 109 | // parse it, and verifyPassword refuses the kind besides. | |
| 110 | const hash = agent ? "" : await Bun.password.hash(input.password, "argon2id"); | |
| 94 | 111 | // The hash above yielded the event loop, so another sign-up may have landed |
| 95 | 112 | // meanwhile. Uniqueness is settled by the constraint, and "first user" by a |
| 96 | 113 | // subquery in the same statement — neither is a check-then-act. |
| 97 | 114 | try { |
| 98 | 115 | db.query( |
| 99 | `INSERT INTO user (id, username, email, password_hash, display_name, is_admin) | |
| 116 | `INSERT INTO user (id, username, email, password_hash, display_name, is_admin, is_agent) | |
| 100 | 117 | VALUES (?1, ?2, ?3, ?4, ?5, |
| 101 | CASE WHEN ?6 = 1 THEN 1 | |
| 118 | CASE WHEN ?8 = 1 THEN 0 | |
| 119 | WHEN ?6 = 1 THEN 1 | |
| 102 | 120 | WHEN ?7 = 1 AND (SELECT COUNT(*) FROM user) = 0 THEN 1 |
| 103 | ELSE 0 END)`, | |
| 121 | ELSE 0 END, | |
| 122 | ?8)`, | |
| 104 | 123 | ).run( |
| 105 | 124 | userId, |
| 106 | 125 | input.username, |
| @@ -109,6 +128,7 @@ export async function createUser( | ||
| 109 | 128 | input.displayName ?? "", |
| 110 | 129 | input.isAdmin === true ? 1 : 0, |
| 111 | 130 | input.adminIfFirst === true ? 1 : 0, |
| 131 | agent ? 1 : 0, | |
| 112 | 132 | ); |
| 113 | 133 | } catch (error) { |
| 114 | 134 | if (!isUniqueViolation(error)) throw error; |
| @@ -218,11 +238,40 @@ export async function verifyPassword( | ||
| 218 | 238 | row?.password_hash ?? |
| 219 | 239 | "$argon2id$v=19$m=65536,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; |
| 220 | 240 | const valid = await Bun.password.verify(password, hash).catch(() => false); |
| 221 | // Org rows own repos but never authenticate, whatever their stored hash. | |
| 222 | if (!valid || !row || row.kind === "org") return err("bad_credentials"); | |
| 241 | // Org rows own repos but never authenticate, whatever their stored hash; | |
| 242 | // neither does an agent, whose hash is empty besides. | |
| 243 | if (!valid || !row || row.kind === "org" || row.is_agent === 1) | |
| 244 | return err("bad_credentials"); | |
| 223 | 245 | return ok(toUser(row)); |
| 224 | 246 | } |
| 225 | 247 | |
| 248 | /** | |
| 249 | * People — accounts that are neither orgs nor agents — by name, bounded, | |
| 250 | * optionally narrowed by a substring. What an assignee picker offers before | |
| 251 | * the crew; the caller still filters by `canRead` on the repository, since | |
| 252 | * being an account is not being able to see it. | |
| 253 | */ | |
| 254 | export function listPeople( | |
| 255 | db: Database, | |
| 256 | options: { query?: string | undefined; limit?: number | undefined } = {}, | |
| 257 | ): User[] { | |
| 258 | const limit = Math.min(Math.max(options.limit ?? 50, 1), 200); | |
| 259 | const query = options.query?.trim() ?? ""; | |
| 260 | const params: (string | number)[] = []; | |
| 261 | let where = "kind = 'user' AND is_agent = 0"; | |
| 262 | if (query !== "") { | |
| 263 | params.push(`%${query.replace(/[\\%_]/g, (c) => `\\${c}`)}%`); | |
| 264 | where += ` AND username LIKE ?${params.length} ESCAPE '\\' COLLATE NOCASE`; | |
| 265 | } | |
| 266 | params.push(limit); | |
| 267 | return db | |
| 268 | .query<UserRow, (string | number)[]>( | |
| 269 | `SELECT * FROM user WHERE ${where} ORDER BY username COLLATE NOCASE LIMIT ?${params.length}`, | |
| 270 | ) | |
| 271 | .all(...params) | |
| 272 | .map(toUser); | |
| 273 | } | |
| 274 | ||
| 226 | 275 | export function countUsers(db: Database): number { |
| 227 | 276 | const row = db |
| 228 | 277 | .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM user") |
src/core/visibility.ts +17
| @@ -21,6 +21,23 @@ export const REPO_VISIBLE = `( | ||
| 21 | 21 | SELECT team.org_id FROM team |
| 22 | 22 | JOIN team_member ON team_member.team_id = team.id |
| 23 | 23 | WHERE team_member.user_id = ?1) |
| 24 | OR repo.id IN (SELECT repo_crew.repo_id FROM repo_crew WHERE repo_crew.user_id = ?1) | |
| 25 | OR EXISTS (SELECT 1 FROM user AS me WHERE me.id = ?1 AND me.is_admin = 1) | |
| 26 | )`; | |
| 27 | ||
| 28 | /** | |
| 29 | * Repos `?1` may write, as a boolean expression over a `repo` row — the SQL | |
| 30 | * twin of `canWrite`, for the lists that must answer "which of these can I | |
| 31 | * push to" for fifty rows without fifty permission checks. Ownership, site | |
| 32 | * admin, a team with write or admin in the owning org, or the crew grant. | |
| 33 | */ | |
| 34 | export const REPO_WRITABLE = `( | |
| 35 | repo.owner_id = ?1 | |
| 36 | OR repo.owner_id IN ( | |
| 37 | SELECT team.org_id FROM team | |
| 38 | JOIN team_member ON team_member.team_id = team.id | |
| 39 | WHERE team_member.user_id = ?1 AND team.permission IN ('write','admin')) | |
| 40 | OR repo.id IN (SELECT repo_crew.repo_id FROM repo_crew WHERE repo_crew.user_id = ?1) | |
| 24 | 41 | OR EXISTS (SELECT 1 FROM user AS me WHERE me.id = ?1 AND me.is_admin = 1) |
| 25 | 42 | )`; |
| 26 | 43 |
src/crew/brief.ts added +221
| @@ -0,0 +1,221 @@ | ||
| 1 | /** | |
| 2 | * What an agent is told. Two texts: a system prompt appended to Claude | |
| 3 | * Code's own — who the agent is, where it is, how to finish, what will be | |
| 4 | * refused — and the task itself, which differs by why the run exists: an | |
| 5 | * assignment, feedback on a pull, or a check that failed. The golden path | |
| 6 | * is written down here so it is prompted, not hoped for; the mechanical | |
| 7 | * rules (docs/APPS.md) hold whether or not the agent reads them. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Agent } from "../core/agents.ts"; | |
| 11 | import type { Comment, Issue } from "../core/issues.ts"; | |
| 12 | import type { Pull } from "../core/pulls.ts"; | |
| 13 | import type { Repo } from "../core/repos.ts"; | |
| 14 | import type { Review, ReviewComment } from "../core/reviews.ts"; | |
| 15 | ||
| 16 | export type RunReason = "assigned" | "feedback" | "check_failed"; | |
| 17 | ||
| 18 | export interface BriefInput { | |
| 19 | readonly agent: Agent; | |
| 20 | readonly repo: Repo; | |
| 21 | /** The task: the issue, or the pull when the run is about a pull. */ | |
| 22 | readonly issue: Issue; | |
| 23 | readonly pull?: Pull | undefined; | |
| 24 | readonly branch: string; | |
| 25 | readonly forgeUrl: string; | |
| 26 | readonly isApp: boolean; | |
| 27 | readonly previewUrl?: string | undefined; | |
| 28 | /** Whether a session is being continued (the task was already given). */ | |
| 29 | readonly resuming: boolean; | |
| 30 | } | |
| 31 | ||
| 32 | export function systemPrompt(input: BriefInput): string { | |
| 33 | const { agent, repo } = input; | |
| 34 | const lines = [ | |
| 35 | `You are ${agent.user.displayName || agent.user.username} (@${agent.user.username}), an agent on the crew of ${repo.ownerName}/${repo.name} on a forge at ${input.forgeUrl}.`, | |
| 36 | agent.brief.trim() === "" ? "" : `\n${agent.brief.trim()}\n`, | |
| 37 | `You are working in a clone of the repository on the branch \`${input.branch}\`. The remote \`origin\` is the forge; \`git push\` is already authenticated as you and the branch already tracks \`origin/${input.branch}\`. Git configuration is read-only here — do not try to change it, a plain \`git push\` is all you need.`, | |
| 38 | "", | |
| 39 | "How to finish:", | |
| 40 | "- Commit your work with clear messages and push: `git push`.", | |
| 41 | "- The forge opens the pull request for you, or updates the one that exists. Your last message is used verbatim as its description, so write it as one: what changed and what to look at, in a few sentences — no preamble, no status report, nothing addressed to a person.", | |
| 42 | "- Do not open the pull yourself, and do not merge anything.", | |
| 43 | "", | |
| 44 | "What is refused, mechanically:", | |
| 45 | `- Pushing to \`${repo.defaultBranch}\`. Agents propose through pulls; a person merges.`, | |
| 46 | "- Force pushes, repository settings, tokens, and administration.", | |
| 47 | "", | |
| 48 | "The forge's JSON API is available for reading the repository, its issues and pulls:", | |
| 49 | ` curl -sH "authorization: Bearer $FORGE_TOKEN" "$FORGE_URL/api/v1/repos/${repo.ownerName}/${repo.name}/issues/${input.issue.number}"`, | |
| 50 | "Prefer the working tree for code; use the API for conversation.", | |
| 51 | ]; | |
| 52 | if (input.isApp) { | |
| 53 | lines.push( | |
| 54 | "", | |
| 55 | "This repository is an app the forge runs. Production follows the default branch; every open pull gets a preview deployed from its branch each time you push.", | |
| 56 | "- `forge.json` says how the app runs; keep it working.", | |
| 57 | "- Keep state under `FORGE_APP_DATA`. Never write elsewhere at runtime.", | |
| 58 | "- Run the tests (`bun test`, or whatever `forge.json` names) before you push: the deploy runs them, and a failing test fails the preview.", | |
| 59 | ...(input.previewUrl === undefined | |
| 60 | ? [] | |
| 61 | : [ | |
| 62 | `- The preview of your pull is at ${input.previewUrl} once it is up.`, | |
| 63 | ]), | |
| 64 | ); | |
| 65 | } | |
| 66 | lines.push( | |
| 67 | "", | |
| 68 | "Your shell is sandboxed: writes stay in the clone, the network is the forge and the package registry, and you cannot signal processes you started in an earlier command. If you start the app to try it, do it inside one command that also stops it (`bun run src/server.ts & pid=$!; sleep 2; curl …; kill $pid`). Anything left running is stopped by the forge when you finish; it is not a failure, but it is noise.", | |
| 69 | "", | |
| 70 | "Work carefully and finish. If the task is unclear, impossible, or not something you should do, do not guess: make your last message start with `DECLINED:` followed by exactly why. Nothing is pushed then, and a person reads it.", | |
| 71 | ); | |
| 72 | return lines.filter((l) => l !== undefined).join("\n"); | |
| 73 | } | |
| 74 | ||
| 75 | export interface TaskInput extends BriefInput { | |
| 76 | readonly reason: RunReason; | |
| 77 | /** Conversation on the issue or pull, oldest first. */ | |
| 78 | readonly comments: readonly Comment[]; | |
| 79 | /** Verdicts on the pull, oldest first. */ | |
| 80 | readonly reviews: readonly Review[]; | |
| 81 | /** Line comments on the pull's diff. */ | |
| 82 | readonly reviewComments: readonly ReviewComment[]; | |
| 83 | /** Only comments, reviews and line comments after this instant are news. */ | |
| 84 | readonly since: string; | |
| 85 | /** For a failed check: the tail of the deploy log. */ | |
| 86 | readonly checkLog?: readonly string[] | undefined; | |
| 87 | readonly checkSummary?: string | undefined; | |
| 88 | } | |
| 89 | ||
| 90 | export function taskPrompt(input: TaskInput): string { | |
| 91 | const { issue } = input; | |
| 92 | const out: string[] = []; | |
| 93 | const isAgent = (name: string): boolean => name === input.agent.user.username; | |
| 94 | ||
| 95 | if (input.reason === "assigned" && !input.resuming) { | |
| 96 | if (input.pull !== undefined) { | |
| 97 | out.push( | |
| 98 | `You have been assigned pull request #${issue.number}: ${issue.title}`, | |
| 99 | "", | |
| 100 | issue.body || "(no description)", | |
| 101 | ); | |
| 102 | } else { | |
| 103 | out.push( | |
| 104 | `Issue #${issue.number}: ${issue.title}`, | |
| 105 | "", | |
| 106 | issue.body || "(no description)", | |
| 107 | ); | |
| 108 | } | |
| 109 | const talk = input.comments.filter((c) => !isAgent(c.authorName)); | |
| 110 | if (talk.length > 0) { | |
| 111 | out.push("", "Discussion so far:"); | |
| 112 | for (const c of talk) out.push(quote(c.authorName, c.body)); | |
| 113 | } | |
| 114 | out.push( | |
| 115 | "", | |
| 116 | `Work it on the branch \`${input.branch}\` and push when done.`, | |
| 117 | ); | |
| 118 | return out.join("\n"); | |
| 119 | } | |
| 120 | ||
| 121 | if (input.reason === "check_failed") { | |
| 122 | out.push( | |
| 123 | `The preview deploy of your pull #${issue.number} failed${input.checkSummary ? `: ${input.checkSummary}` : "."}`, | |
| 124 | ); | |
| 125 | if (input.checkLog !== undefined && input.checkLog.length > 0) { | |
| 126 | out.push( | |
| 127 | "", | |
| 128 | "The end of the deploy log:", | |
| 129 | "```", | |
| 130 | ...input.checkLog, | |
| 131 | "```", | |
| 132 | ); | |
| 133 | } | |
| 134 | out.push("", "Fix the cause, make sure the tests pass, and push again."); | |
| 135 | return out.join("\n"); | |
| 136 | } | |
| 137 | ||
| 138 | // feedback | |
| 139 | const news = { | |
| 140 | comments: input.comments.filter( | |
| 141 | (c) => c.createdAt > input.since && !isAgent(c.authorName), | |
| 142 | ), | |
| 143 | reviews: input.reviews.filter( | |
| 144 | (r) => r.createdAt > input.since && !isAgent(r.authorName), | |
| 145 | ), | |
| 146 | lines: input.reviewComments.filter( | |
| 147 | (c) => c.createdAt > input.since && !isAgent(c.authorName), | |
| 148 | ), | |
| 149 | }; | |
| 150 | out.push( | |
| 151 | input.pull !== undefined | |
| 152 | ? `There is feedback on your pull #${issue.number} (${issue.title}).` | |
| 153 | : `There is a new message on issue #${issue.number} (${issue.title}).`, | |
| 154 | ); | |
| 155 | for (const r of news.reviews) { | |
| 156 | const verdict = | |
| 157 | r.state === "changes_requested" | |
| 158 | ? "requested changes" | |
| 159 | : r.state === "approved" | |
| 160 | ? "approved" | |
| 161 | : "commented"; | |
| 162 | out.push( | |
| 163 | "", | |
| 164 | `@${r.authorName} ${verdict}${r.body.trim() === "" ? "." : ":"}`, | |
| 165 | ); | |
| 166 | if (r.body.trim() !== "") out.push(indent(r.body)); | |
| 167 | } | |
| 168 | if (news.lines.length > 0) { | |
| 169 | out.push("", "Line comments:"); | |
| 170 | for (const c of news.lines) | |
| 171 | out.push(`- ${c.path}:${c.line} — @${c.authorName}: ${oneLine(c.body)}`); | |
| 172 | } | |
| 173 | for (const c of news.comments) out.push("", quote(c.authorName, c.body)); | |
| 174 | if (news.comments.length + news.reviews.length + news.lines.length === 0) { | |
| 175 | out.push( | |
| 176 | "", | |
| 177 | "(The feedback was not captured in words; read the pull's conversation through the API.)", | |
| 178 | ); | |
| 179 | } | |
| 180 | out.push( | |
| 181 | "", | |
| 182 | input.pull !== undefined | |
| 183 | ? "Address it on the same branch and push again. If you disagree with something, say why in your last message." | |
| 184 | : `Take it into account, work on the branch \`${input.branch}\`, and push when done.`, | |
| 185 | ); | |
| 186 | return out.join("\n"); | |
| 187 | } | |
| 188 | ||
| 189 | const quote = (author: string, body: string): string => | |
| 190 | `@${author}:\n${indent(body)}`; | |
| 191 | const indent = (text: string): string => | |
| 192 | text | |
| 193 | .split("\n") | |
| 194 | .map((l) => ` ${l}`) | |
| 195 | .join("\n"); | |
| 196 | const oneLine = (text: string): string => text.replace(/\s+/g, " ").trim(); | |
| 197 | ||
| 198 | /** `<agent>/<issue number>-<slug>`: a branch name that says whose and what for. */ | |
| 199 | export function branchFor(agent: Agent, issue: Issue): string { | |
| 200 | const slug = issue.title | |
| 201 | .toLowerCase() | |
| 202 | .replace(/[^a-z0-9]+/g, "-") | |
| 203 | .replace(/^-+|-+$/g, "") | |
| 204 | .slice(0, 40) | |
| 205 | .replace(/-+$/, ""); | |
| 206 | return `${agent.user.username}/${issue.number}${slug === "" ? "" : `-${slug}`}`; | |
| 207 | } | |
| 208 | ||
| 209 | /** The issue number a crew branch was made for, if it was one. */ | |
| 210 | export function issueOfBranch( | |
| 211 | agent: Agent, | |
| 212 | branch: string, | |
| 213 | ): number | undefined { | |
| 214 | const match = new RegExp( | |
| 215 | `^${escapeRegExp(agent.user.username)}/(\\d+)(?:-|$)`, | |
| 216 | ).exec(branch); | |
| 217 | return match === null ? undefined : Number(match[1]); | |
| 218 | } | |
| 219 | ||
| 220 | const escapeRegExp = (s: string): string => | |
| 221 | s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
src/crew/claude.ts added +316
| @@ -0,0 +1,316 @@ | ||
| 1 | /** | |
| 2 | * Claude Code, headless, as a child process — the way the forge runs git. | |
| 3 | * `claude -p` with stream-json output gives one JSON object per line: an | |
| 4 | * init record with the session id, the assistant's turns, the tool results, | |
| 5 | * and a final result with the cost and why it stopped. This module builds | |
| 6 | * the invocation, streams the transcript into a log in words a person | |
| 7 | * reads, and returns the outcome. | |
| 8 | * | |
| 9 | * Containment is the forge's, not the agent's: file tools confined to the | |
| 10 | * clone (`--restricted`), permissions handed in as settings, no prompts | |
| 11 | * (anything that would ask is denied), no MCP servers the operator has, no | |
| 12 | * settings files of the operator's, a spend budget, and a deadline. | |
| 13 | */ | |
| 14 | ||
| 15 | import { childEnv, pump } from "../apps/process.ts"; | |
| 16 | import type { SandboxPolicy } from "./sandbox.ts"; | |
| 17 | ||
| 18 | export interface ClaudeInput { | |
| 19 | readonly binary: string; | |
| 20 | readonly cwd: string; | |
| 21 | readonly prompt: string; | |
| 22 | readonly appendSystemPrompt: string; | |
| 23 | readonly model?: string | undefined; | |
| 24 | readonly budgetUsd: number; | |
| 25 | /** A session id to continue. */ | |
| 26 | readonly resume?: string | undefined; | |
| 27 | readonly env: Record<string, string>; | |
| 28 | readonly sandbox: SandboxPolicy; | |
| 29 | readonly timeoutMs: number; | |
| 30 | readonly log: (line: string) => void; | |
| 31 | readonly signal: AbortSignal; | |
| 32 | } | |
| 33 | ||
| 34 | export interface ClaudeOutcome { | |
| 35 | /** Whether the run ended with a result rather than an error. */ | |
| 36 | readonly ok: boolean; | |
| 37 | readonly sessionId: string; | |
| 38 | /** The agent's final message. */ | |
| 39 | readonly result: string; | |
| 40 | readonly costUsd: number; | |
| 41 | readonly turns: number; | |
| 42 | /** `completed`, `max_budget_reached`, `timeout`, `cancelled`, `error`, `exit <n>`. */ | |
| 43 | readonly reason: string; | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * The tools an agent has. Enough to read, change, run and push code; no | |
| 48 | * subagents (a run is one agent), no web (the task is in the clone and the | |
| 49 | * forge's API), nothing that asks a person. | |
| 50 | */ | |
| 51 | export const TOOLS = ["Bash", "Read", "Edit", "Write", "Glob", "Grep"] as const; | |
| 52 | ||
| 53 | /** | |
| 54 | * The settings handed to every run. One object, so the policy is one place: | |
| 55 | * the tools, the commands refused outright, and the sandbox around the | |
| 56 | * shell (`crew/sandbox.ts` says what it may reach and read). | |
| 57 | */ | |
| 58 | export function claudeSettings( | |
| 59 | sandbox: SandboxPolicy, | |
| 60 | ): Record<string, unknown> { | |
| 61 | return { | |
| 62 | sandbox: { | |
| 63 | enabled: true, | |
| 64 | autoAllowBashIfSandboxed: true, | |
| 65 | network: { | |
| 66 | allowedDomains: [...sandbox.allowHosts], | |
| 67 | // An app under test listens on a local port. | |
| 68 | allowLocalBinding: true, | |
| 69 | }, | |
| 70 | filesystem: { denyRead: [...sandbox.denyRead] }, | |
| 71 | }, | |
| 72 | permissions: { | |
| 73 | allow: [...TOOLS], | |
| 74 | deny: [ | |
| 75 | "Bash(git push --force*)", | |
| 76 | "Bash(git push -f*)", | |
| 77 | "Bash(git push * --force*)", | |
| 78 | "Bash(sudo *)", | |
| 79 | "Bash(rm -rf /*)", | |
| 80 | "Bash(rm -rf ~*)", | |
| 81 | ], | |
| 82 | }, | |
| 83 | }; | |
| 84 | } | |
| 85 | ||
| 86 | export function claudeArgs(input: ClaudeInput): string[] { | |
| 87 | const args = [ | |
| 88 | "-p", | |
| 89 | "--verbose", | |
| 90 | "--output-format", | |
| 91 | "stream-json", | |
| 92 | "--restricted", | |
| 93 | // Restricted mode drops every tool that runs code unless it is named | |
| 94 | // here; "default" does not name Bash. This is the agent's whole kit. | |
| 95 | "--tools", | |
| 96 | TOOLS.join(","), | |
| 97 | "--permission-mode", | |
| 98 | "acceptEdits", | |
| 99 | "--permission-prompts", | |
| 100 | "none", | |
| 101 | "--strict-mcp-config", | |
| 102 | "--settings", | |
| 103 | JSON.stringify(claudeSettings(input.sandbox)), | |
| 104 | "--max-budget-usd", | |
| 105 | input.budgetUsd.toFixed(2), | |
| 106 | "--append-system-prompt", | |
| 107 | input.appendSystemPrompt, | |
| 108 | ]; | |
| 109 | if (input.model !== undefined && input.model !== "") | |
| 110 | args.push("--model", input.model); | |
| 111 | if (input.resume !== undefined && input.resume !== "") | |
| 112 | args.push("--resume", input.resume); | |
| 113 | return args; | |
| 114 | } | |
| 115 | ||
| 116 | /** Run Claude Code to completion. Never throws; every ending is an outcome. */ | |
| 117 | export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { | |
| 118 | const proc = Bun.spawn([input.binary, ...claudeArgs(input)], { | |
| 119 | cwd: input.cwd, | |
| 120 | env: childEnv({ | |
| 121 | CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", | |
| 122 | DISABLE_TELEMETRY: "1", | |
| 123 | ...input.env, | |
| 124 | }), | |
| 125 | stdin: new Blob([input.prompt]), | |
| 126 | stdout: "pipe", | |
| 127 | stderr: "pipe", | |
| 128 | }); | |
| 129 | ||
| 130 | let sessionId = ""; | |
| 131 | let result = ""; | |
| 132 | let costUsd = 0; | |
| 133 | let turns = 0; | |
| 134 | let reason = ""; | |
| 135 | let ok = false; | |
| 136 | let sawResult = false; | |
| 137 | ||
| 138 | const onLine = (line: string): void => { | |
| 139 | if (line.trim() === "") return; | |
| 140 | let event: unknown; | |
| 141 | try { | |
| 142 | event = JSON.parse(line); | |
| 143 | } catch { | |
| 144 | input.log(line); | |
| 145 | return; | |
| 146 | } | |
| 147 | if (typeof event !== "object" || event === null) return; | |
| 148 | const e = event as Record<string, unknown>; | |
| 149 | const type = e["type"]; | |
| 150 | if (typeof e["session_id"] === "string" && sessionId === "") | |
| 151 | sessionId = e["session_id"]; | |
| 152 | if (type === "system") { | |
| 153 | if (e["subtype"] === "init") | |
| 154 | input.log(`session ${sessionId} (${String(e["model"] ?? "")})`); | |
| 155 | return; | |
| 156 | } | |
| 157 | if (type === "assistant") { | |
| 158 | for (const text of assistantLines(e)) input.log(text); | |
| 159 | return; | |
| 160 | } | |
| 161 | if (type === "result") { | |
| 162 | sawResult = true; | |
| 163 | ok = e["is_error"] !== true && e["subtype"] === "success"; | |
| 164 | result = typeof e["result"] === "string" ? e["result"] : ""; | |
| 165 | costUsd = | |
| 166 | typeof e["total_cost_usd"] === "number" ? e["total_cost_usd"] : 0; | |
| 167 | turns = typeof e["num_turns"] === "number" ? e["num_turns"] : 0; | |
| 168 | const terminal = e["terminal_reason"]; | |
| 169 | reason = | |
| 170 | typeof terminal === "string" | |
| 171 | ? terminal | |
| 172 | : ok | |
| 173 | ? "completed" | |
| 174 | : String(e["subtype"] ?? "error"); | |
| 175 | if (!ok && result === "") { | |
| 176 | const errors = e["errors"]; | |
| 177 | if (Array.isArray(errors)) result = errors.map(String).join("; "); | |
| 178 | } | |
| 179 | return; | |
| 180 | } | |
| 181 | // `user` records are tool results: long and mostly file contents. | |
| 182 | }; | |
| 183 | ||
| 184 | let why: "timeout" | "cancelled" | undefined; | |
| 185 | const stopChild = (): void => { | |
| 186 | proc.kill("SIGTERM"); | |
| 187 | setTimeout(() => { | |
| 188 | if (proc.exitCode === null && proc.signalCode === null) | |
| 189 | proc.kill("SIGKILL"); | |
| 190 | }, 5_000); | |
| 191 | }; | |
| 192 | const timer = setTimeout(() => { | |
| 193 | why = "timeout"; | |
| 194 | stopChild(); | |
| 195 | }, input.timeoutMs); | |
| 196 | const onAbort = (): void => { | |
| 197 | why = "cancelled"; | |
| 198 | stopChild(); | |
| 199 | }; | |
| 200 | if (input.signal.aborted) onAbort(); | |
| 201 | else input.signal.addEventListener("abort", onAbort, { once: true }); | |
| 202 | ||
| 203 | const stderr: string[] = []; | |
| 204 | const [, , code] = await Promise.all([ | |
| 205 | pump(proc.stdout, onLine), | |
| 206 | pump(proc.stderr, (line) => { | |
| 207 | stderr.push(line); | |
| 208 | if (stderr.length > 50) stderr.shift(); | |
| 209 | input.log(`! ${line}`); | |
| 210 | }), | |
| 211 | proc.exited, | |
| 212 | ]); | |
| 213 | clearTimeout(timer); | |
| 214 | input.signal.removeEventListener("abort", onAbort); | |
| 215 | ||
| 216 | if (why !== undefined) { | |
| 217 | return { ok: false, sessionId, result, costUsd, turns, reason: why }; | |
| 218 | } | |
| 219 | if (!sawResult) { | |
| 220 | const tail = stderr.slice(-3).join(" | "); | |
| 221 | return { | |
| 222 | ok: false, | |
| 223 | sessionId, | |
| 224 | result: tail, | |
| 225 | costUsd, | |
| 226 | turns, | |
| 227 | reason: `exit ${code}`, | |
| 228 | }; | |
| 229 | } | |
| 230 | return { ok, sessionId, result, costUsd, turns, reason }; | |
| 231 | } | |
| 232 | ||
| 233 | /** The lines a person wants from an assistant turn: its words and its tool calls. */ | |
| 234 | function assistantLines(e: Record<string, unknown>): string[] { | |
| 235 | const message = e["message"]; | |
| 236 | if (typeof message !== "object" || message === null) return []; | |
| 237 | const content = (message as Record<string, unknown>)["content"]; | |
| 238 | if (!Array.isArray(content)) return []; | |
| 239 | const out: string[] = []; | |
| 240 | for (const block of content as Record<string, unknown>[]) { | |
| 241 | if (block["type"] === "text" && typeof block["text"] === "string") { | |
| 242 | for (const line of block["text"].split("\n")) | |
| 243 | if (line.trim() !== "") out.push(line); | |
| 244 | } else if (block["type"] === "tool_use") { | |
| 245 | out.push( | |
| 246 | `→ ${describeTool(String(block["name"] ?? "tool"), block["input"])}`, | |
| 247 | ); | |
| 248 | } | |
| 249 | } | |
| 250 | return out; | |
| 251 | } | |
| 252 | ||
| 253 | function describeTool(name: string, input: unknown): string { | |
| 254 | const args = | |
| 255 | typeof input === "object" && input !== null | |
| 256 | ? (input as Record<string, unknown>) | |
| 257 | : {}; | |
| 258 | const first = (keys: string[]): string => { | |
| 259 | for (const key of keys) { | |
| 260 | const value = args[key]; | |
| 261 | if (typeof value === "string" && value !== "") return value; | |
| 262 | } | |
| 263 | return ""; | |
| 264 | }; | |
| 265 | const detail = first([ | |
| 266 | "command", | |
| 267 | "file_path", | |
| 268 | "path", | |
| 269 | "pattern", | |
| 270 | "url", | |
| 271 | "description", | |
| 272 | "prompt", | |
| 273 | ]); | |
| 274 | const short = detail.length > 160 ? `${detail.slice(0, 157)}…` : detail; | |
| 275 | return short === "" ? name : `${name} ${short}`; | |
| 276 | } | |
| 277 | ||
| 278 | /** Whether `binary` is a Claude Code that speaks this interface. */ | |
| 279 | export async function detectClaude( | |
| 280 | binary: string, | |
| 281 | ): Promise<{ ok: true; version: string } | { ok: false; error: string }> { | |
| 282 | try { | |
| 283 | const proc = Bun.spawn([binary, "--version"], { | |
| 284 | env: childEnv({}), | |
| 285 | stdin: "ignore", | |
| 286 | stdout: "pipe", | |
| 287 | stderr: "pipe", | |
| 288 | }); | |
| 289 | const timer = setTimeout(() => proc.kill("SIGKILL"), 10_000); | |
| 290 | const [out, code] = await Promise.all([ | |
| 291 | new Response(proc.stdout).text(), | |
| 292 | proc.exited, | |
| 293 | ]); | |
| 294 | clearTimeout(timer); | |
| 295 | if (code !== 0) | |
| 296 | return { ok: false, error: `${binary} --version exited ${code}` }; | |
| 297 | const version = out.trim().split(/\s+/)[0] ?? ""; | |
| 298 | const [major, minor] = version.split(".").map(Number); | |
| 299 | if ( | |
| 300 | major === undefined || | |
| 301 | minor === undefined || | |
| 302 | major < 2 || | |
| 303 | (major === 2 && minor < 1) | |
| 304 | ) | |
| 305 | return { | |
| 306 | ok: false, | |
| 307 | error: `${binary} is ${version}; 2.1 or later is needed`, | |
| 308 | }; | |
| 309 | return { ok: true, version }; | |
| 310 | } catch (error) { | |
| 311 | return { | |
| 312 | ok: false, | |
| 313 | error: `${binary}: ${error instanceof Error ? error.message : String(error)}`, | |
| 314 | }; | |
| 315 | } | |
| 316 | } |
src/crew/dispatch.ts added +312
| @@ -0,0 +1,312 @@ | ||
| 1 | /** | |
| 2 | * How work reaches the crew. The dispatcher listens to the outbox and | |
| 3 | * sweeps behind it: an issue assigned to an agent becomes a run; a person | |
| 4 | * speaking on an agent's pull — a comment, a verdict, a line comment — | |
| 5 | * becomes a follow-up; a failed deploy of an agent's pull becomes a | |
| 6 | * bounded follow-up. Runs are rows first (`queued`), and a worker tick | |
| 7 | * takes them in order up to the concurrency limit, one per issue at a | |
| 8 | * time. A missed wake-up costs a tick, never a run. | |
| 9 | */ | |
| 10 | ||
| 11 | import { rmSync } from "node:fs"; | |
| 12 | import type { App } from "../app.ts"; | |
| 13 | import { announceRun } from "../actions/runs.ts"; | |
| 14 | import { getAgent, type Agent } from "../core/agents.ts"; | |
| 15 | import type { ForgeEvent } from "../core/events.ts"; | |
| 16 | import { | |
| 17 | getIssue, | |
| 18 | getIssueById, | |
| 19 | listComments, | |
| 20 | type Issue, | |
| 21 | } from "../core/issues.ts"; | |
| 22 | import { getPull, type Pull } from "../core/pulls.ts"; | |
| 23 | import { getRepoById, type Repo } from "../core/repos.ts"; | |
| 24 | import { listReviews } from "../core/reviews.ts"; | |
| 25 | import { | |
| 26 | activeRunFor, | |
| 27 | automaticRunsSince, | |
| 28 | createRun, | |
| 29 | getRun, | |
| 30 | listActive, | |
| 31 | listQueued, | |
| 32 | type Run, | |
| 33 | } from "../core/runs.ts"; | |
| 34 | import { getUserById } from "../core/users.ts"; | |
| 35 | import { createLog } from "../kernel/log.ts"; | |
| 36 | import { issueOfBranch, type RunReason } from "./brief.ts"; | |
| 37 | import { detectClaude } from "./claude.ts"; | |
| 38 | import { executeAgentRun, workdirFor, type CrewOptions } from "./runner.ts"; | |
| 39 | import { abortAgentRun } from "./running.ts"; | |
| 40 | ||
| 41 | const log = createLog("crew"); | |
| 42 | ||
| 43 | /** Automatic follow-ups after a failed check, before a person must speak. */ | |
| 44 | const CHECK_FAILED_LIMIT = 2; | |
| 45 | const TICK_MS = 5_000; | |
| 46 | ||
| 47 | export interface CrewHandle { | |
| 48 | /** Whether a usable Claude Code was found at start. */ | |
| 49 | readonly ready: boolean; | |
| 50 | readonly detail: string; | |
| 51 | stop(): Promise<void>; | |
| 52 | } | |
| 53 | ||
| 54 | const handles = new WeakMap<App, CrewHandle>(); | |
| 55 | ||
| 56 | /** The crew serving this forge, if one was started. */ | |
| 57 | export const crewFor = (app: App): CrewHandle | undefined => handles.get(app); | |
| 58 | ||
| 59 | export async function startCrew( | |
| 60 | app: App, | |
| 61 | options: CrewOptions, | |
| 62 | ): Promise<CrewHandle> { | |
| 63 | const detected = await detectClaude(options.binary); | |
| 64 | if (!detected.ok) | |
| 65 | log.warn("claude not usable; agent runs will fail", { | |
| 66 | error: detected.error, | |
| 67 | }); | |
| 68 | else | |
| 69 | log.info("crew is up", { | |
| 70 | claude: detected.version, | |
| 71 | concurrency: options.concurrency, | |
| 72 | }); | |
| 73 | ||
| 74 | let stopped = false; | |
| 75 | // Runs `pump` has started and not yet seen finish. `stop()` aborts each | |
| 76 | // and waits for it: without this, a run `pump` fired (fire-and-forget) | |
| 77 | // keeps cloning, spawning and eventually calling `finishRun`/`announceRun` | |
| 78 | // on `app.db` after a caller who awaited `stop()` believed the crew was | |
| 79 | // quiet and closed it. | |
| 80 | const inFlight = new Map<string, Promise<void>>(); | |
| 81 | const unsubscribe = app.bus.subscribe((event) => { | |
| 82 | try { | |
| 83 | route(app, event); | |
| 84 | } catch (error) { | |
| 85 | log.error("crew routing failed", { | |
| 86 | kind: event.kind, | |
| 87 | error: String(error), | |
| 88 | }); | |
| 89 | } | |
| 90 | }); | |
| 91 | const timer = setInterval(() => { | |
| 92 | if (!stopped) pump(app, options, inFlight); | |
| 93 | }, TICK_MS); | |
| 94 | sweep(app); | |
| 95 | pump(app, options, inFlight); | |
| 96 | ||
| 97 | const handle: CrewHandle = { | |
| 98 | ready: detected.ok, | |
| 99 | detail: detected.ok ? `Claude Code ${detected.version}` : detected.error, | |
| 100 | stop: async () => { | |
| 101 | stopped = true; | |
| 102 | clearInterval(timer); | |
| 103 | unsubscribe(); | |
| 104 | for (const runId of inFlight.keys()) abortAgentRun(runId); | |
| 105 | await Promise.all(inFlight.values()); | |
| 106 | handles.delete(app); | |
| 107 | }, | |
| 108 | }; | |
| 109 | handles.set(app, handle); | |
| 110 | return handle; | |
| 111 | } | |
| 112 | ||
| 113 | /** Queue a run about an issue for an agent, unless one is already waiting. */ | |
| 114 | export function queueAgentRun( | |
| 115 | app: App, | |
| 116 | repo: Repo, | |
| 117 | issue: Issue, | |
| 118 | agent: Agent, | |
| 119 | reason: RunReason, | |
| 120 | actorId?: string, | |
| 121 | ): Run | undefined { | |
| 122 | const active = activeRunFor(app.db, "agent", issue.id); | |
| 123 | // One queued follow-up is enough: the run that eventually starts reads | |
| 124 | // the whole conversation since the last one, so nothing is lost. | |
| 125 | if ( | |
| 126 | active !== undefined && | |
| 127 | (active.state === "queued" || reason === "assigned") | |
| 128 | ) | |
| 129 | return undefined; | |
| 130 | const run = createRun(app.db, { | |
| 131 | repoId: repo.id, | |
| 132 | kind: "agent", | |
| 133 | issueId: issue.id, | |
| 134 | actorId: agent.user.id, | |
| 135 | reason, | |
| 136 | }); | |
| 137 | announceRun(app, run, actorId); | |
| 138 | return run; | |
| 139 | } | |
| 140 | ||
| 141 | function route(app: App, event: ForgeEvent): void { | |
| 142 | if (event.repoId === null) return; | |
| 143 | const number = event.payload["number"]; | |
| 144 | const repo = getRepoById(app.db, event.repoId); | |
| 145 | if (!repo) return; | |
| 146 | ||
| 147 | switch (event.kind) { | |
| 148 | case "issue.assigned": | |
| 149 | case "pull.assigned": { | |
| 150 | if (event.payload["agent"] !== true || typeof number !== "number") return; | |
| 151 | const issue = getIssue(app.db, repo.id, number); | |
| 152 | const agent = issue?.assigneeId | |
| 153 | ? getAgent(app.db, issue.assigneeId) | |
| 154 | : undefined; | |
| 155 | if (!issue || !agent || issue.state !== "open") return; | |
| 156 | queueAgentRun( | |
| 157 | app, | |
| 158 | repo, | |
| 159 | issue, | |
| 160 | agent, | |
| 161 | "assigned", | |
| 162 | event.actorId ?? undefined, | |
| 163 | ); | |
| 164 | return; | |
| 165 | } | |
| 166 | case "issue.commented": | |
| 167 | case "pull.commented": | |
| 168 | case "pull.reviewed": { | |
| 169 | if (typeof number !== "number") return; | |
| 170 | if (event.payload["via"] === "origin") return; | |
| 171 | const actor = | |
| 172 | event.actorId === null ? undefined : getUserById(app.db, event.actorId); | |
| 173 | if (!actor || actor.kind === "agent") return; // agents talking is not feedback | |
| 174 | if ( | |
| 175 | event.kind === "pull.reviewed" && | |
| 176 | event.payload["state"] === "approved" | |
| 177 | ) | |
| 178 | return; | |
| 179 | const issue = getIssue(app.db, repo.id, number); | |
| 180 | if (!issue || issue.state !== "open") return; | |
| 181 | const agent = agentFor(app, issue); | |
| 182 | if (!agent) return; | |
| 183 | queueAgentRun(app, repo, issue, agent, "feedback", actor.id); | |
| 184 | return; | |
| 185 | } | |
| 186 | case "run.finished": { | |
| 187 | if ( | |
| 188 | event.payload["kind"] !== "deploy" || | |
| 189 | event.payload["state"] !== "failed" | |
| 190 | ) | |
| 191 | return; | |
| 192 | const issueId = event.payload["issueId"]; | |
| 193 | if (typeof issueId !== "string") return; | |
| 194 | const issue = getIssueById(app.db, issueId); | |
| 195 | if (!issue || issue.kind !== "pull" || issue.state !== "open") return; | |
| 196 | const agent = agentFor(app, issue); | |
| 197 | if (!agent) return; | |
| 198 | // Bounded: after a few failed checks in a row the agent stops and a | |
| 199 | // person must speak (docs/APPS.md, rule 7). | |
| 200 | const since = lastHumanWord(app, issue, agent); | |
| 201 | if (automaticRunsSince(app.db, issue.id, since) >= CHECK_FAILED_LIMIT) { | |
| 202 | log.info("check failed again; waiting for a person", { | |
| 203 | issue: issue.number, | |
| 204 | }); | |
| 205 | return; | |
| 206 | } | |
| 207 | queueAgentRun(app, repo, issue, agent, "check_failed"); | |
| 208 | return; | |
| 209 | } | |
| 210 | case "pull.closed": | |
| 211 | case "pull.merged": { | |
| 212 | if (typeof number !== "number") return; | |
| 213 | const pull = getPull(app.db, repo.id, number); | |
| 214 | if (!pull) return; | |
| 215 | forgetWorkdir(app, repo, pull); | |
| 216 | return; | |
| 217 | } | |
| 218 | default: | |
| 219 | return; | |
| 220 | } | |
| 221 | } | |
| 222 | ||
| 223 | /** The agent an issue or pull belongs to: the assignee, or the pull's author. */ | |
| 224 | function agentFor(app: App, issue: Issue): Agent | undefined { | |
| 225 | if (issue.assigneeId !== null) { | |
| 226 | const assigned = getAgent(app.db, issue.assigneeId); | |
| 227 | if (assigned) return assigned; | |
| 228 | } | |
| 229 | return issue.kind === "pull" ? getAgent(app.db, issue.authorId) : undefined; | |
| 230 | } | |
| 231 | ||
| 232 | /** When a person last said anything on the conversation, or its start. */ | |
| 233 | function lastHumanWord(app: App, issue: Issue, agent: Agent): string { | |
| 234 | let latest = issue.createdAt; | |
| 235 | for (const c of listComments(app.db, issue.id)) { | |
| 236 | if (c.authorName !== agent.user.username && c.createdAt > latest) | |
| 237 | latest = c.createdAt; | |
| 238 | } | |
| 239 | for (const r of listReviews(app.db, issue.id)) { | |
| 240 | if (r.authorName !== agent.user.username && r.createdAt > latest) | |
| 241 | latest = r.createdAt; | |
| 242 | } | |
| 243 | return latest; | |
| 244 | } | |
| 245 | ||
| 246 | function forgetWorkdir(app: App, repo: Repo, pull: Pull): void { | |
| 247 | const agent = agentFor(app, pull); | |
| 248 | if (!agent) return; | |
| 249 | const number = issueOfBranch(agent, pull.headBranch) ?? pull.number; | |
| 250 | rmSync(workdirFor(app.config.dataDir, agent, repo, number), { | |
| 251 | recursive: true, | |
| 252 | force: true, | |
| 253 | }); | |
| 254 | } | |
| 255 | ||
| 256 | /** | |
| 257 | * The sweep behind the wake-up: an open issue assigned to an agent that | |
| 258 | * has never had a run (assigned while the crew was off) gets one now. | |
| 259 | */ | |
| 260 | function sweep(app: App): void { | |
| 261 | const rows = app.db | |
| 262 | .query<{ id: string; repo_id: string }, []>( | |
| 263 | `SELECT issue.id, issue.repo_id FROM issue | |
| 264 | JOIN user ON user.id = issue.assignee_id AND user.is_agent = 1 | |
| 265 | WHERE issue.state = 'open' | |
| 266 | -- A pull an agent opened starts assigned to it; that is its own | |
| 267 | -- work landing, not a task waiting for a run. | |
| 268 | AND NOT (issue.kind = 'pull' AND issue.author_id = issue.assignee_id) | |
| 269 | AND NOT EXISTS (SELECT 1 FROM run WHERE run.issue_id = issue.id AND run.kind = 'agent') | |
| 270 | ORDER BY issue.id LIMIT 100`, | |
| 271 | ) | |
| 272 | .all(); | |
| 273 | for (const row of rows) { | |
| 274 | const issue = getIssueById(app.db, row.id); | |
| 275 | const repo = getRepoById(app.db, row.repo_id); | |
| 276 | const agent = issue?.assigneeId | |
| 277 | ? getAgent(app.db, issue.assigneeId) | |
| 278 | : undefined; | |
| 279 | if (issue && repo && agent) | |
| 280 | queueAgentRun(app, repo, issue, agent, "assigned"); | |
| 281 | } | |
| 282 | } | |
| 283 | ||
| 284 | /** Take queued runs, oldest first, up to the limit — one per issue at a time. */ | |
| 285 | function pump( | |
| 286 | app: App, | |
| 287 | options: CrewOptions, | |
| 288 | inFlight: Map<string, Promise<void>>, | |
| 289 | ): void { | |
| 290 | const active = listActive(app.db, "agent"); | |
| 291 | const runningIssues = new Set( | |
| 292 | active.filter((r) => r.state === "running").map((r) => r.issueId), | |
| 293 | ); | |
| 294 | let slots = options.concurrency - runningIssues.size; | |
| 295 | for (const queued of listQueued(app.db, "agent", 50)) { | |
| 296 | if (slots <= 0) break; | |
| 297 | if (queued.issueId !== null && runningIssues.has(queued.issueId)) continue; | |
| 298 | const fresh = getRun(app.db, queued.id); | |
| 299 | if (!fresh || fresh.state !== "queued") continue; | |
| 300 | slots--; | |
| 301 | if (queued.issueId !== null) runningIssues.add(queued.issueId); | |
| 302 | const running = executeAgentRun(app, fresh, options) | |
| 303 | .catch((error) => | |
| 304 | log.error("agent run failed to execute", { | |
| 305 | run: queued.id, | |
| 306 | error: String(error), | |
| 307 | }), | |
| 308 | ) | |
| 309 | .finally(() => inFlight.delete(queued.id)); | |
| 310 | inFlight.set(queued.id, running); | |
| 311 | } | |
| 312 | } |
src/crew/runner.ts added +616
| @@ -0,0 +1,616 @@ | ||
| 1 | /** | |
| 2 | * One agent run, end to end: claim it, mint a credential that dies with | |
| 3 | * it, put the agent in a clone on the right branch, tell it the task, let | |
| 4 | * Claude Code work, then do the bookkeeping the agent should never have to | |
| 5 | * get right — push what it committed, open or update the pull, report on | |
| 6 | * the issue — and record the outcome. | |
| 7 | * | |
| 8 | * The clone lives at `<data>/crew/<agent>/<repo id>/<issue n>/` for as long | |
| 9 | * as the pull is open, so a follow-up resumes the same session in the same | |
| 10 | * tree. The remote is truth: every run starts by resetting the branch to | |
| 11 | * what the forge has. | |
| 12 | */ | |
| 13 | ||
| 14 | import { existsSync, mkdirSync } from "node:fs"; | |
| 15 | import { join } from "node:path"; | |
| 16 | import type { App } from "../app.ts"; | |
| 17 | import { addComment } from "../actions/issues.ts"; | |
| 18 | import { openPull } from "../actions/pulls.ts"; | |
| 19 | import { announceRun } from "../actions/runs.ts"; | |
| 20 | import { previewEnv } from "../apps/data.ts"; | |
| 21 | import { appsRuntime } from "../apps/runtime.ts"; | |
| 22 | import { getAgent, type Agent } from "../core/agents.ts"; | |
| 23 | import { getAppByRepo } from "../core/apps.ts"; | |
| 24 | import { | |
| 25 | getIssue, | |
| 26 | getIssueById, | |
| 27 | listComments, | |
| 28 | type Issue, | |
| 29 | } from "../core/issues.ts"; | |
| 30 | import { getPull, listPulls, type Pull } from "../core/pulls.ts"; | |
| 31 | import { getRepoById, type Repo } from "../core/repos.ts"; | |
| 32 | import { listReviewComments, listReviews } from "../core/reviews.ts"; | |
| 33 | import { | |
| 34 | checkSummary, | |
| 35 | claimRun, | |
| 36 | finishRun, | |
| 37 | lastAgentRun, | |
| 38 | noteRunProgress, | |
| 39 | type Run, | |
| 40 | } from "../core/runs.ts"; | |
| 41 | import { createToken, deleteToken } from "../core/tokens.ts"; | |
| 42 | import { runGit } from "../git/env.ts"; | |
| 43 | import { isSafeRef } from "../git/guard.ts"; | |
| 44 | import { aheadCount, resolveOid } from "../git/merge.ts"; | |
| 45 | import { repoDir } from "../git/repos.ts"; | |
| 46 | import { createLog } from "../kernel/log.ts"; | |
| 47 | import { appendRunLog, readRunLog } from "../runs/log.ts"; | |
| 48 | import { | |
| 49 | branchFor, | |
| 50 | issueOfBranch, | |
| 51 | systemPrompt, | |
| 52 | taskPrompt, | |
| 53 | type RunReason, | |
| 54 | } from "./brief.ts"; | |
| 55 | import { runClaude } from "./claude.ts"; | |
| 56 | import { trackAgentRun, untrackAgentRun } from "./running.ts"; | |
| 57 | import { sandboxPolicy } from "./sandbox.ts"; | |
| 58 | import { workdirFor } from "./workdir.ts"; | |
| 59 | ||
| 60 | const log = createLog("crew"); | |
| 61 | ||
| 62 | export interface CrewOptions { | |
| 63 | /** The forge's public URL — what the agent is told and links to. */ | |
| 64 | readonly forgeUrl: string; | |
| 65 | /** Where the forge answers on this machine — what git clones from. */ | |
| 66 | readonly localUrl: string; | |
| 67 | readonly binary: string; | |
| 68 | readonly concurrency: number; | |
| 69 | /** Spend per run when the agent's own budget is 0. */ | |
| 70 | readonly budgetUsd: number; | |
| 71 | readonly runTimeoutMs: number; | |
| 72 | /** A `CLAUDE_CONFIG_DIR` for runs, when the operator wants one. */ | |
| 73 | readonly configDir?: string | undefined; | |
| 74 | /** Hosts an agent's shell may reach beyond the forge and the registry. */ | |
| 75 | readonly allowHosts?: readonly string[] | undefined; | |
| 76 | } | |
| 77 | ||
| 78 | export { crewRoot, workdirFor } from "./workdir.ts"; | |
| 79 | ||
| 80 | /** Claim and execute a queued agent run. Never throws. */ | |
| 81 | export async function executeAgentRun( | |
| 82 | app: App, | |
| 83 | run: Run, | |
| 84 | options: CrewOptions, | |
| 85 | ): Promise<void> { | |
| 86 | const claimed = claimRun(app.db, run.id); | |
| 87 | if (!claimed) return; | |
| 88 | const controller = new AbortController(); | |
| 89 | trackAgentRun(run.id, controller); | |
| 90 | const write = (line: string): void => | |
| 91 | appendRunLog(app.config.dataDir, run.id, line); | |
| 92 | announceRun(app, claimed); | |
| 93 | let outcome: Outcome; | |
| 94 | try { | |
| 95 | outcome = await work(app, claimed, options, write, controller.signal); | |
| 96 | } catch (error) { | |
| 97 | log.error("agent run threw", { run: run.id, error: String(error) }); | |
| 98 | outcome = { | |
| 99 | state: "failed", | |
| 100 | summary: `the run failed: ${String(error).slice(0, 200)}`, | |
| 101 | }; | |
| 102 | } finally { | |
| 103 | untrackAgentRun(run.id); | |
| 104 | } | |
| 105 | // Whatever the agent left running in its clone — a server it started to | |
| 106 | // try something — ends with the run. The sandbox refuses it `kill`, so the | |
| 107 | // forge does the tidying, by the directory the process is working in. | |
| 108 | await reapOrphans(app, run, write); | |
| 109 | write( | |
| 110 | outcome.state === "ok" | |
| 111 | ? `done: ${outcome.summary}` | |
| 112 | : `failed: ${outcome.summary}`, | |
| 113 | ); | |
| 114 | const done = finishRun(app.db, run.id, { | |
| 115 | state: outcome.state, | |
| 116 | summary: outcome.summary, | |
| 117 | sessionId: outcome.sessionId, | |
| 118 | costUsd: outcome.costUsd, | |
| 119 | }); | |
| 120 | if (done) announceRun(app, done); | |
| 121 | } | |
| 122 | ||
| 123 | /** | |
| 124 | * Processes still holding files under the run's clone, after the run: the | |
| 125 | * agent's leftovers. `lsof +D` is the honest way to find them on macOS and | |
| 126 | * Linux alike; a machine without it keeps its leftovers and the log says so. | |
| 127 | */ | |
| 128 | async function reapOrphans( | |
| 129 | app: App, | |
| 130 | run: Run, | |
| 131 | write: (line: string) => void, | |
| 132 | ): Promise<void> { | |
| 133 | const repo = getRepoById(app.db, run.repoId); | |
| 134 | const agent = | |
| 135 | run.actorId === null ? undefined : getAgent(app.db, run.actorId); | |
| 136 | const issue = | |
| 137 | run.issueId === null ? undefined : getIssueById(app.db, run.issueId); | |
| 138 | if (!repo || !agent || !issue) return; | |
| 139 | const branch = run.branch; | |
| 140 | const number = issueOfBranch(agent, branch) ?? issue.number; | |
| 141 | const dir = workdirFor(app.config.dataDir, agent, repo, number); | |
| 142 | if (!existsSync(dir)) return; | |
| 143 | let out = ""; | |
| 144 | try { | |
| 145 | const proc = Bun.spawn(["lsof", "-t", "+D", dir], { | |
| 146 | stdout: "pipe", | |
| 147 | stderr: "ignore", | |
| 148 | }); | |
| 149 | const timer = setTimeout(() => proc.kill("SIGKILL"), 15_000); | |
| 150 | out = await new Response(proc.stdout).text(); | |
| 151 | await proc.exited; | |
| 152 | clearTimeout(timer); | |
| 153 | } catch { | |
| 154 | return; // no lsof here | |
| 155 | } | |
| 156 | const pids = [...new Set(out.split(/\s+/).filter((p) => /^\d+$/.test(p)))] | |
| 157 | .map(Number) | |
| 158 | .filter((pid) => pid !== process.pid); | |
| 159 | for (const pid of pids) { | |
| 160 | try { | |
| 161 | process.kill(pid, "SIGTERM"); | |
| 162 | write(`stopped a process the agent left running (pid ${pid})`); | |
| 163 | } catch { | |
| 164 | // Already gone. | |
| 165 | } | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | interface Outcome { | |
| 170 | readonly state: "ok" | "failed"; | |
| 171 | readonly summary: string; | |
| 172 | readonly sessionId?: string | undefined; | |
| 173 | readonly costUsd?: number | undefined; | |
| 174 | } | |
| 175 | ||
| 176 | async function work( | |
| 177 | app: App, | |
| 178 | run: Run, | |
| 179 | options: CrewOptions, | |
| 180 | write: (line: string) => void, | |
| 181 | signal: AbortSignal, | |
| 182 | ): Promise<Outcome> { | |
| 183 | const repo = getRepoById(app.db, run.repoId); | |
| 184 | const issue = | |
| 185 | run.issueId === null ? undefined : getIssueById(app.db, run.issueId); | |
| 186 | const agent = | |
| 187 | run.actorId === null ? undefined : getAgent(app.db, run.actorId); | |
| 188 | if (!repo || !issue || !agent) | |
| 189 | return { | |
| 190 | state: "failed", | |
| 191 | summary: "the repository, issue or agent no longer exists", | |
| 192 | }; | |
| 193 | if (repo.origin !== null) | |
| 194 | return { | |
| 195 | state: "failed", | |
| 196 | summary: "agents do not work on mirrored repositories yet", | |
| 197 | }; | |
| 198 | const reason = run.reason as RunReason; | |
| 199 | ||
| 200 | const pull = | |
| 201 | issue.kind === "pull" ? getPull(app.db, repo.id, issue.number) : undefined; | |
| 202 | if (pull?.crossRepo) | |
| 203 | return { | |
| 204 | state: "failed", | |
| 205 | summary: "this pull's branch lives in a fork the agent cannot push to", | |
| 206 | }; | |
| 207 | const branch = pull?.headBranch ?? (run.branch || branchFor(agent, issue)); | |
| 208 | if (!isSafeRef(branch)) | |
| 209 | return { state: "failed", summary: `unsafe branch name ${branch}` }; | |
| 210 | noteRunProgress(app.db, run.id, { branch }); | |
| 211 | const taskNumber = | |
| 212 | pull === undefined | |
| 213 | ? issue.number | |
| 214 | : (issueOfBranch(agent, branch) ?? issue.number); | |
| 215 | const dir = workdirFor(app.config.dataDir, agent, repo, taskNumber); | |
| 216 | write( | |
| 217 | `${agent.user.username} on ${repo.ownerName}/${repo.name}#${issue.number} (${reason}), branch ${branch}`, | |
| 218 | ); | |
| 219 | ||
| 220 | // The credential: rw, named for the run, gone when it ends. | |
| 221 | const expires = new Date( | |
| 222 | Date.now() + options.runTimeoutMs + 10 * 60_000, | |
| 223 | ).toISOString(); | |
| 224 | const token = createToken(app.db, agent.user.id, `run:${run.id}`, "rw", { | |
| 225 | expiresAt: expires, | |
| 226 | }); | |
| 227 | const gitAuth = { | |
| 228 | GIT_CONFIG_COUNT: "2", | |
| 229 | GIT_CONFIG_KEY_0: "http.extraHeader", | |
| 230 | GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${agent.user.username}:${token.token}`).toString("base64")}`, | |
| 231 | // The operator's own credential helper (a keychain, usually) would try | |
| 232 | // to store the header's credential and fail loudly on every push. The | |
| 233 | // header is the credential; no helper applies. | |
| 234 | GIT_CONFIG_KEY_1: "credential.helper", | |
| 235 | GIT_CONFIG_VALUE_1: "", | |
| 236 | }; | |
| 237 | const identity = { | |
| 238 | GIT_AUTHOR_NAME: agent.user.displayName || agent.user.username, | |
| 239 | GIT_AUTHOR_EMAIL: agent.user.email, | |
| 240 | GIT_COMMITTER_NAME: agent.user.displayName || agent.user.username, | |
| 241 | GIT_COMMITTER_EMAIL: agent.user.email, | |
| 242 | }; | |
| 243 | ||
| 244 | try { | |
| 245 | const bare = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 246 | const prepared = await prepareClone( | |
| 247 | app, | |
| 248 | options, | |
| 249 | repo, | |
| 250 | branch, | |
| 251 | dir, | |
| 252 | { ...gitAuth, ...identity }, | |
| 253 | write, | |
| 254 | signal, | |
| 255 | ); | |
| 256 | if (!prepared.ok) return { state: "failed", summary: prepared.error }; | |
| 257 | ||
| 258 | // What the agent is told. | |
| 259 | // The session belongs to the task, which spans the issue and the pull | |
| 260 | // opened for it: a follow-up on the pull resumes the run on the issue. | |
| 261 | const taskIssue = | |
| 262 | pull === undefined ? undefined : getIssue(app.db, repo.id, taskNumber); | |
| 263 | const previous = | |
| 264 | lastAgentRun(app.db, issue.id, agent.user.id) ?? | |
| 265 | (taskIssue === undefined | |
| 266 | ? undefined | |
| 267 | : lastAgentRun(app.db, taskIssue.id, agent.user.id)); | |
| 268 | const since = previous?.finishedAt ?? issue.createdAt; | |
| 269 | const record = getAppByRepo(app.db, repo.id); | |
| 270 | const runtime = appsRuntime(app); | |
| 271 | const previewUrl = | |
| 272 | record && runtime && pull | |
| 273 | ? runtime.urlFor(record, previewEnv(pull.number)) | |
| 274 | : undefined; | |
| 275 | const brief = { | |
| 276 | agent, | |
| 277 | repo, | |
| 278 | issue, | |
| 279 | pull, | |
| 280 | branch, | |
| 281 | forgeUrl: options.forgeUrl, | |
| 282 | isApp: record !== undefined, | |
| 283 | previewUrl, | |
| 284 | resuming: previous?.sessionId !== undefined && previous.sessionId !== "", | |
| 285 | }; | |
| 286 | const failedCheck = | |
| 287 | reason === "check_failed" && pull | |
| 288 | ? checkSummary( | |
| 289 | app.db, | |
| 290 | pull.id, | |
| 291 | (await resolveOid(bare, `refs/heads/${branch}`)) ?? pull.headOid, | |
| 292 | ) | |
| 293 | : undefined; | |
| 294 | const prompt = taskPrompt({ | |
| 295 | ...brief, | |
| 296 | reason, | |
| 297 | comments: listComments(app.db, issue.id, { kind: "comment" }), | |
| 298 | reviews: pull ? listReviews(app.db, pull.id) : [], | |
| 299 | reviewComments: pull ? listReviewComments(app.db, pull.id) : [], | |
| 300 | since, | |
| 301 | checkSummary: failedCheck?.run?.summary, | |
| 302 | checkLog: failedCheck?.run | |
| 303 | ? readRunLog( | |
| 304 | app.config.dataDir, | |
| 305 | failedCheck.run.id, | |
| 306 | 8 * 1024, | |
| 307 | ).lines.slice(-40) | |
| 308 | : undefined, | |
| 309 | }); | |
| 310 | ||
| 311 | const env: Record<string, string> = { | |
| 312 | ...gitAuth, | |
| 313 | ...identity, | |
| 314 | FORGE_URL: options.forgeUrl, | |
| 315 | FORGE_TOKEN: token.token, | |
| 316 | ...(process.env["ANTHROPIC_API_KEY"] === undefined | |
| 317 | ? {} | |
| 318 | : { ANTHROPIC_API_KEY: process.env["ANTHROPIC_API_KEY"] }), | |
| 319 | ...(options.configDir === undefined | |
| 320 | ? {} | |
| 321 | : { CLAUDE_CONFIG_DIR: options.configDir }), | |
| 322 | }; | |
| 323 | const budget = agent.budgetUsd > 0 ? agent.budgetUsd : options.budgetUsd; | |
| 324 | const sandbox = sandboxPolicy({ | |
| 325 | dataDir: app.config.dataDir, | |
| 326 | agentName: agent.user.username, | |
| 327 | urls: [options.localUrl, options.forgeUrl], | |
| 328 | extraHosts: options.allowHosts, | |
| 329 | }); | |
| 330 | const invoke = (resume: string | undefined) => | |
| 331 | runClaude({ | |
| 332 | binary: options.binary, | |
| 333 | cwd: dir, | |
| 334 | prompt, | |
| 335 | appendSystemPrompt: systemPrompt(brief), | |
| 336 | model: agent.model || undefined, | |
| 337 | budgetUsd: budget, | |
| 338 | resume, | |
| 339 | env, | |
| 340 | sandbox, | |
| 341 | timeoutMs: options.runTimeoutMs, | |
| 342 | log: write, | |
| 343 | signal, | |
| 344 | }); | |
| 345 | write( | |
| 346 | `claude: budget $${budget.toFixed(2)}${brief.resuming ? `, resuming ${previous?.sessionId}` : ""}`, | |
| 347 | ); | |
| 348 | let result = await invoke(brief.resuming ? previous?.sessionId : undefined); | |
| 349 | if (!result.ok && brief.resuming && result.turns === 0 && !signal.aborted) { | |
| 350 | write("the session could not be resumed; starting fresh"); | |
| 351 | result = await invoke(undefined); | |
| 352 | } | |
| 353 | noteRunProgress(app.db, run.id, { | |
| 354 | sessionId: result.sessionId, | |
| 355 | costUsd: result.costUsd, | |
| 356 | }); | |
| 357 | write( | |
| 358 | `claude: ${result.reason}, ${result.turns} turns, $${result.costUsd.toFixed(2)}`, | |
| 359 | ); | |
| 360 | if (signal.aborted) | |
| 361 | return { | |
| 362 | state: "failed", | |
| 363 | summary: "cancelled", | |
| 364 | sessionId: result.sessionId, | |
| 365 | costUsd: result.costUsd, | |
| 366 | }; | |
| 367 | ||
| 368 | // The decline contract: an agent that should not do the task says so | |
| 369 | // instead of guessing, and nothing it touched becomes a proposal. | |
| 370 | const declined = /^\s*DECLINED\b/i.test(result.result); | |
| 371 | if (declined) { | |
| 372 | const why = result.result.replace(/^\s*DECLINED:?\s*/i, "").trim(); | |
| 373 | write(`declined: ${why.split("\n")[0] ?? ""}`); | |
| 374 | const target = pull ?? issue; | |
| 375 | addComment( | |
| 376 | app, | |
| 377 | agent.user, | |
| 378 | repo, | |
| 379 | target, | |
| 380 | `Declined: ${why || "no reason given"}`, | |
| 381 | ); | |
| 382 | return { | |
| 383 | state: "failed", | |
| 384 | summary: `declined: ${(why.split("\n")[0] ?? "").slice(0, 200)}`, | |
| 385 | sessionId: result.sessionId, | |
| 386 | costUsd: result.costUsd, | |
| 387 | }; | |
| 388 | } | |
| 389 | ||
| 390 | // Bookkeeping the agent does not have to get right. | |
| 391 | const landed = await landWork( | |
| 392 | app, | |
| 393 | options, | |
| 394 | repo, | |
| 395 | agent, | |
| 396 | issue, | |
| 397 | pull, | |
| 398 | branch, | |
| 399 | dir, | |
| 400 | bare, | |
| 401 | { ...gitAuth, ...identity }, | |
| 402 | result.result, | |
| 403 | write, | |
| 404 | ); | |
| 405 | return { | |
| 406 | state: result.ok || landed.pushed ? "ok" : "failed", | |
| 407 | summary: landed.summary + (result.ok ? "" : ` (${result.reason})`), | |
| 408 | sessionId: result.sessionId, | |
| 409 | costUsd: result.costUsd, | |
| 410 | }; | |
| 411 | } finally { | |
| 412 | deleteToken(app.db, agent.user.id, token.id); | |
| 413 | } | |
| 414 | } | |
| 415 | ||
| 416 | /** A clone on `branch`, matching what the forge holds. */ | |
| 417 | async function prepareClone( | |
| 418 | app: App, | |
| 419 | options: CrewOptions, | |
| 420 | repo: Repo, | |
| 421 | branch: string, | |
| 422 | dir: string, | |
| 423 | env: Record<string, string>, | |
| 424 | write: (line: string) => void, | |
| 425 | signal: AbortSignal, | |
| 426 | ): Promise<{ ok: true } | { ok: false; error: string }> { | |
| 427 | const url = `${options.localUrl}/${repo.ownerName}/${repo.name}.git`; | |
| 428 | if (signal.aborted) return { ok: false, error: "cancelled" }; | |
| 429 | if (!existsSync(join(dir, ".git"))) { | |
| 430 | mkdirSync(dir, { recursive: true }); | |
| 431 | write(`cloning ${repo.ownerName}/${repo.name}`); | |
| 432 | const cloned = await runGit(["clone", "--quiet", "--", url, dir], { | |
| 433 | env, | |
| 434 | timeoutMs: 5 * 60_000, | |
| 435 | }); | |
| 436 | if (!cloned.ok) | |
| 437 | return { | |
| 438 | ok: false, | |
| 439 | error: `clone failed: ${cloned.error.stderr.slice(0, 200)}`, | |
| 440 | }; | |
| 441 | } else { | |
| 442 | const fetched = await runGit(["fetch", "--quiet", "--prune", "origin"], { | |
| 443 | cwd: dir, | |
| 444 | env, | |
| 445 | timeoutMs: 2 * 60_000, | |
| 446 | }); | |
| 447 | if (!fetched.ok) | |
| 448 | return { | |
| 449 | ok: false, | |
| 450 | error: `fetch failed: ${fetched.error.stderr.slice(0, 200)}`, | |
| 451 | }; | |
| 452 | } | |
| 453 | const bare = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 454 | const remote = await resolveOid(bare, `refs/heads/${branch}`); | |
| 455 | const start = | |
| 456 | remote === undefined ? `origin/${repo.defaultBranch}` : `origin/${branch}`; | |
| 457 | // A leftover from a cut-off run is discarded: the remote is truth. | |
| 458 | await runGit(["reset", "--quiet", "--hard"], { cwd: dir, env }); | |
| 459 | await runGit(["clean", "-fdq"], { cwd: dir, env }); | |
| 460 | const checked = await runGit(["checkout", "--quiet", "-B", branch, start], { | |
| 461 | cwd: dir, | |
| 462 | env, | |
| 463 | }); | |
| 464 | if (!checked.ok) | |
| 465 | return { | |
| 466 | ok: false, | |
| 467 | error: `checkout failed: ${checked.error.stderr.slice(0, 200)}`, | |
| 468 | }; | |
| 469 | // The branch tracks its remote from the start, set here where git config | |
| 470 | // is writable: inside the sandbox `.git/config` is not, so a plain | |
| 471 | // `git push` must already know where to go. | |
| 472 | await runGit(["config", `branch.${branch}.remote`, "origin"], { | |
| 473 | cwd: dir, | |
| 474 | env, | |
| 475 | }); | |
| 476 | await runGit(["config", `branch.${branch}.merge`, `refs/heads/${branch}`], { | |
| 477 | cwd: dir, | |
| 478 | env, | |
| 479 | }); | |
| 480 | write( | |
| 481 | remote === undefined | |
| 482 | ? `branch ${branch} from ${repo.defaultBranch}` | |
| 483 | : `branch ${branch} at ${remote.slice(0, 7)}`, | |
| 484 | ); | |
| 485 | return { ok: true }; | |
| 486 | } | |
| 487 | ||
| 488 | /** | |
| 489 | * After the agent stops: push what it committed if it did not, open the | |
| 490 | * pull if there is none, and report on the conversation. | |
| 491 | */ | |
| 492 | async function landWork( | |
| 493 | app: App, | |
| 494 | options: CrewOptions, | |
| 495 | repo: Repo, | |
| 496 | agent: Agent, | |
| 497 | issue: Issue, | |
| 498 | pull: Pull | undefined, | |
| 499 | branch: string, | |
| 500 | dir: string, | |
| 501 | bare: string, | |
| 502 | env: Record<string, string>, | |
| 503 | message: string, | |
| 504 | write: (line: string) => void, | |
| 505 | ): Promise<{ pushed: boolean; summary: string }> { | |
| 506 | // Work left in the tree is committed on the agent's behalf: a proposal a | |
| 507 | // person can read beats an edit nobody sees. The summary says it happened. | |
| 508 | const status = await runGit(["status", "--porcelain"], { cwd: dir, env }); | |
| 509 | let committedForIt = false; | |
| 510 | if (status.ok && status.value.trim() !== "") { | |
| 511 | const subject = | |
| 512 | message | |
| 513 | .split("\n") | |
| 514 | .find((l) => l.trim() !== "") | |
| 515 | ?.trim() ?? issue.title; | |
| 516 | await runGit(["add", "-A"], { cwd: dir, env }); | |
| 517 | const commit = await runGit( | |
| 518 | [ | |
| 519 | "commit", | |
| 520 | "--quiet", | |
| 521 | "-m", | |
| 522 | subject.slice(0, 200), | |
| 523 | "-m", | |
| 524 | `Work by @${agent.user.username} on #${issue.number}, committed by the forge.`, | |
| 525 | ], | |
| 526 | { cwd: dir, env }, | |
| 527 | ); | |
| 528 | committedForIt = commit.ok; | |
| 529 | write( | |
| 530 | commit.ok | |
| 531 | ? "committed the agent's uncommitted changes" | |
| 532 | : `could not commit leftover changes: ${commit.error.stderr.slice(0, 200)}`, | |
| 533 | ); | |
| 534 | } | |
| 535 | const head = await runGit(["rev-parse", "--verify", "HEAD"], { | |
| 536 | cwd: dir, | |
| 537 | env, | |
| 538 | }); | |
| 539 | const local = head.ok ? head.value.trim() : undefined; | |
| 540 | let remote = await resolveOid(bare, `refs/heads/${branch}`); | |
| 541 | const base = await resolveOid(bare, `refs/heads/${repo.defaultBranch}`); | |
| 542 | if (local !== undefined && local !== remote) { | |
| 543 | // Ahead of what the forge holds: the branch if it exists there, the | |
| 544 | // default branch otherwise — a fresh branch at the base tip is nothing. | |
| 545 | const against = remote ?? base; | |
| 546 | const ahead = | |
| 547 | against === undefined ? 1 : await aheadCount(dir, against, local); | |
| 548 | if (ahead > 0) { | |
| 549 | write( | |
| 550 | `pushing ${branch} (${ahead} commit${ahead === 1 ? "" : "s"} the agent did not push)`, | |
| 551 | ); | |
| 552 | const pushed = await runGit( | |
| 553 | ["push", "--quiet", "-u", "origin", `${branch}:${branch}`], | |
| 554 | { cwd: dir, env, timeoutMs: 2 * 60_000 }, | |
| 555 | ); | |
| 556 | if (!pushed.ok) | |
| 557 | write(`push refused: ${pushed.error.stderr.slice(0, 300)}`); | |
| 558 | remote = await resolveOid(bare, `refs/heads/${branch}`); | |
| 559 | } | |
| 560 | } | |
| 561 | const proposed = | |
| 562 | remote !== undefined && base !== undefined | |
| 563 | ? await aheadCount(bare, base, remote) | |
| 564 | : 0; | |
| 565 | const report = message.trim(); | |
| 566 | ||
| 567 | if (proposed === 0) { | |
| 568 | write( | |
| 569 | "nothing to propose: the branch has no commits beyond the default branch", | |
| 570 | ); | |
| 571 | if (report !== "" && pull === undefined) | |
| 572 | addComment(app, agent.user, repo, issue, report); | |
| 573 | else if (report !== "" && pull !== undefined) | |
| 574 | addComment(app, agent.user, repo, pull, report); | |
| 575 | const first = report.split("\n").find((l) => l.trim() !== "") ?? ""; | |
| 576 | return { | |
| 577 | pushed: false, | |
| 578 | summary: | |
| 579 | first === "" | |
| 580 | ? "no changes pushed" | |
| 581 | : `no changes pushed: ${first.slice(0, 200)}`, | |
| 582 | }; | |
| 583 | } | |
| 584 | ||
| 585 | const existing = | |
| 586 | pull ?? | |
| 587 | listPulls(app.db, repo.id, { state: "open", limit: 200 }).find( | |
| 588 | (p) => !p.crossRepo && p.headBranch === branch, | |
| 589 | ); | |
| 590 | if (existing !== undefined) { | |
| 591 | if (report !== "") addComment(app, agent.user, repo, existing, report); | |
| 592 | return { | |
| 593 | pushed: true, | |
| 594 | summary: `updated pull #${existing.number} (${remote?.slice(0, 7)})${committedForIt ? ", committing its leftover changes" : ""}`, | |
| 595 | }; | |
| 596 | } | |
| 597 | const body = `${report === "" ? "" : `${report}\n\n`}Closes #${issue.number}`; | |
| 598 | const opened = await openPull(app, agent.user, repo, { | |
| 599 | title: issue.title, | |
| 600 | body, | |
| 601 | baseBranch: repo.defaultBranch, | |
| 602 | headBranch: branch, | |
| 603 | }); | |
| 604 | if (!opened.ok) { | |
| 605 | write(`could not open the pull: ${JSON.stringify(opened.error)}`); | |
| 606 | return { | |
| 607 | pushed: true, | |
| 608 | summary: `pushed ${branch} but could not open a pull (${String(opened.error)})`, | |
| 609 | }; | |
| 610 | } | |
| 611 | write(`opened pull #${opened.value.number}`); | |
| 612 | return { | |
| 613 | pushed: true, | |
| 614 | summary: `opened pull #${opened.value.number}${committedForIt ? ", committing its leftover changes" : ""}`, | |
| 615 | }; | |
| 616 | } |
src/crew/running.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | /** | |
| 2 | * The agent runs in flight in this process, by run id. A cancel from any | |
| 3 | * surface aborts the controller; the runner sees the abort and stops the | |
| 4 | * child. Kept apart from the dispatcher so an action can reach it without | |
| 5 | * importing the worker. | |
| 6 | */ | |
| 7 | ||
| 8 | const running = new Map<string, AbortController>(); | |
| 9 | ||
| 10 | export function trackAgentRun( | |
| 11 | runId: string, | |
| 12 | controller: AbortController, | |
| 13 | ): void { | |
| 14 | running.set(runId, controller); | |
| 15 | } | |
| 16 | ||
| 17 | export function untrackAgentRun(runId: string): void { | |
| 18 | running.delete(runId); | |
| 19 | } | |
| 20 | ||
| 21 | /** Stop a running agent run. A run not in flight here is left alone. */ | |
| 22 | export function abortAgentRun(runId: string): boolean { | |
| 23 | const controller = running.get(runId); | |
| 24 | if (controller === undefined) return false; | |
| 25 | controller.abort(); | |
| 26 | return true; | |
| 27 | } | |
| 28 | ||
| 29 | export const runningAgentRuns = (): number => running.size; |
src/crew/sandbox.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * What an agent's shell may touch. Claude Code sandboxes its Bash tool | |
| 3 | * (seatbelt on macOS, bubblewrap on Linux when installed); the forge hands | |
| 4 | * it the policy: the network is the forge and the package registry, and | |
| 5 | * nothing else; writes stay in the clone and the temp dir (the sandbox's | |
| 6 | * own default); reads are refused for everything on this machine that is | |
| 7 | * not the agent's business — the forge's repositories, its index and key, | |
| 8 | * the other agents' clones, and the operator's own credentials. | |
| 9 | * | |
| 10 | * A denied path denies everything under it, the working directory | |
| 11 | * included, which is why the crew root is not simply denied: the other | |
| 12 | * agents' directories are listed one by one at the moment the run starts. | |
| 13 | */ | |
| 14 | ||
| 15 | import { existsSync, readdirSync } from "node:fs"; | |
| 16 | import { homedir } from "node:os"; | |
| 17 | import { join } from "node:path"; | |
| 18 | import { crewRoot } from "./workdir.ts"; | |
| 19 | ||
| 20 | export interface SandboxPolicy { | |
| 21 | /** Hostnames the shell may connect to. */ | |
| 22 | readonly allowHosts: readonly string[]; | |
| 23 | /** Paths the shell may not read (and so not write). */ | |
| 24 | readonly denyRead: readonly string[]; | |
| 25 | } | |
| 26 | ||
| 27 | /** The operator's own secrets, wherever they usually are. */ | |
| 28 | const HOME_SECRETS = [ | |
| 29 | ".ssh", | |
| 30 | ".aws", | |
| 31 | ".gnupg", | |
| 32 | ".netrc", | |
| 33 | ".npmrc", | |
| 34 | ".docker", | |
| 35 | ".kube", | |
| 36 | ".config/gh", | |
| 37 | ".claude", | |
| 38 | ]; | |
| 39 | ||
| 40 | export function sandboxPolicy(input: { | |
| 41 | dataDir: string; | |
| 42 | agentName: string; | |
| 43 | /** URLs the agent must reach: the forge, locally and publicly. */ | |
| 44 | urls: readonly string[]; | |
| 45 | /** Hosts the operator vouches for beyond the forge and the registry. */ | |
| 46 | extraHosts?: readonly string[] | undefined; | |
| 47 | }): SandboxPolicy { | |
| 48 | const hosts = new Set<string>([ | |
| 49 | "127.0.0.1", | |
| 50 | "localhost", | |
| 51 | "registry.npmjs.org", | |
| 52 | ]); | |
| 53 | for (const url of input.urls) { | |
| 54 | try { | |
| 55 | hosts.add(new URL(url).hostname); | |
| 56 | } catch { | |
| 57 | // Not a URL: nothing to allow. | |
| 58 | } | |
| 59 | } | |
| 60 | for (const host of input.extraHosts ?? []) | |
| 61 | if (host.trim() !== "") hosts.add(host.trim()); | |
| 62 | ||
| 63 | const data = input.dataDir; | |
| 64 | const deny = [ | |
| 65 | join(data, "repos"), | |
| 66 | join(data, "forge.db"), | |
| 67 | join(data, "forge.db-wal"), | |
| 68 | join(data, "forge.db-shm"), | |
| 69 | join(data, "secret.key"), | |
| 70 | join(data, "forge.sock"), | |
| 71 | join(data, "ssh"), | |
| 72 | join(data, "apps"), | |
| 73 | join(data, "runs"), | |
| 74 | ]; | |
| 75 | const crew = crewRoot(data); | |
| 76 | if (existsSync(crew)) { | |
| 77 | for (const name of readdirSync(crew)) { | |
| 78 | if (name !== input.agentName) deny.push(join(crew, name)); | |
| 79 | } | |
| 80 | } | |
| 81 | const home = homedir(); | |
| 82 | for (const secret of HOME_SECRETS) deny.push(join(home, secret)); | |
| 83 | return { allowHosts: [...hosts], denyRead: deny }; | |
| 84 | } |
src/crew/workdir.ts added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | /** | |
| 2 | * Where an agent's clones live: `<data>/crew/<agent>/<repo id>/<issue n>/`, | |
| 3 | * for as long as the pull is open. One file, so the runner, the dispatcher | |
| 4 | * and the sandbox policy agree on the layout without importing each other. | |
| 5 | */ | |
| 6 | ||
| 7 | import { join } from "node:path"; | |
| 8 | import type { Agent } from "../core/agents.ts"; | |
| 9 | import type { Repo } from "../core/repos.ts"; | |
| 10 | ||
| 11 | export const crewRoot = (dataDir: string): string => join(dataDir, "crew"); | |
| 12 | ||
| 13 | export function workdirFor( | |
| 14 | dataDir: string, | |
| 15 | agent: Agent, | |
| 16 | repo: Repo, | |
| 17 | issueNumber: number, | |
| 18 | ): string { | |
| 19 | return join( | |
| 20 | crewRoot(dataDir), | |
| 21 | agent.user.username, | |
| 22 | repo.id, | |
| 23 | String(issueNumber), | |
| 24 | ); | |
| 25 | } |
src/design/activity.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | /** | |
| 2 | * What an event is called. One vocabulary for both surfaces, so "the forge | |
| 3 | * deployed the preview of #12" reads the same in a terminal pane and a | |
| 4 | * browser card, and a new event kind is named once. | |
| 5 | * | |
| 6 | * `eventVerb` is the verb phrase alone — the repository and the thing acted | |
| 7 | * on go on the next line, where they have room to be a link and to be | |
| 8 | * truncated at the end rather than mid-word. `eventActor` is who to credit | |
| 9 | * when the row has no actor: a deploy is the forge's own doing, and says so. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { ForgeEvent } from "../core/events.ts"; | |
| 13 | ||
| 14 | /** The pull number an environment name carries, for `pull-12`. */ | |
| 15 | const previewNumber = (env: unknown): string => | |
| 16 | typeof env === "string" && env.startsWith("pull-") ? env.slice(5) : ""; | |
| 17 | ||
| 18 | const environment = (env: unknown): string => { | |
| 19 | const preview = previewNumber(env); | |
| 20 | return preview === "" ? "production" : `the preview of #${preview}`; | |
| 21 | }; | |
| 22 | ||
| 23 | /** Moments rather than facts: a feed shows what came of them instead. */ | |
| 24 | export const isTransientEvent = (kind: string): boolean => | |
| 25 | kind === "run.queued" || kind === "run.started"; | |
| 26 | ||
| 27 | export function eventVerb(event: ForgeEvent): string { | |
| 28 | const { kind, payload } = event; | |
| 29 | if (kind === "run.finished") { | |
| 30 | const state = payload["state"]; | |
| 31 | if (payload["kind"] === "deploy") { | |
| 32 | const what = environment(payload["env"]); | |
| 33 | if (state === "ok") return `deployed ${what}`; | |
| 34 | if (state === "failed") return `failed to deploy ${what}`; | |
| 35 | return `cancelled a deploy of ${what}`; | |
| 36 | } | |
| 37 | if (state === "ok") return "finished a crew run"; | |
| 38 | if (state === "failed") return "ended a crew run without landing"; | |
| 39 | return "had a crew run cancelled"; | |
| 40 | } | |
| 41 | if (kind === "issue.assigned" || kind === "pull.assigned") { | |
| 42 | const thing = kind === "issue.assigned" ? "an issue" : "a pull request"; | |
| 43 | const to = payload["assignee"]; | |
| 44 | return typeof to === "string" && to !== "" | |
| 45 | ? `assigned ${thing} to @${to}` | |
| 46 | : `unassigned ${thing}`; | |
| 47 | } | |
| 48 | if (kind === "crew.added" || kind === "crew.removed") { | |
| 49 | const agent = payload["agent"]; | |
| 50 | const who = typeof agent === "string" ? `@${agent}` : "an agent"; | |
| 51 | return kind === "crew.added" | |
| 52 | ? `put ${who} on the crew` | |
| 53 | : `took ${who} off the crew`; | |
| 54 | } | |
| 55 | if (kind === "app.stopped") return `stopped ${environment(payload["env"])}`; | |
| 56 | return WORDS[kind] ?? "acted on this repository"; | |
| 57 | } | |
| 58 | ||
| 59 | /** Who to credit when the row names nobody. */ | |
| 60 | export function eventActor( | |
| 61 | event: ForgeEvent, | |
| 62 | actorName: string | undefined, | |
| 63 | ): string { | |
| 64 | if (actorName !== undefined) return actorName; | |
| 65 | return event.kind.startsWith("run.") || event.kind.startsWith("app.") | |
| 66 | ? "the forge" | |
| 67 | : "someone"; | |
| 68 | } | |
| 69 | ||
| 70 | const WORDS: Record<string, string> = { | |
| 71 | push: "pushed", | |
| 72 | "issue.opened": "opened an issue", | |
| 73 | "issue.closed": "closed an issue", | |
| 74 | "issue.reopened": "reopened an issue", | |
| 75 | "issue.commented": "commented on an issue", | |
| 76 | "issue.labeled": "labelled an issue", | |
| 77 | "pull.opened": "opened a pull request", | |
| 78 | "pull.merged": "merged a pull request", | |
| 79 | "pull.closed": "closed a pull request", | |
| 80 | "pull.reopened": "reopened a pull request", | |
| 81 | "pull.commented": "commented on a pull request", | |
| 82 | "pull.reviewed": "reviewed a pull request", | |
| 83 | "pull.labeled": "labelled a pull request", | |
| 84 | "pull.thread_resolved": "resolved a review thread", | |
| 85 | "repo.created": "created a repository", | |
| 86 | "repo.forked": "forked a repository", | |
| 87 | "repo.settings_updated": "changed repository settings", | |
| 88 | "repo.mirrored": "mirrored a repository", | |
| 89 | "repo.imported": "imported issues and pulls", | |
| 90 | "repo.pinned": "pinned a repository", | |
| 91 | "repo.unpinned": "unpinned a repository", | |
| 92 | "branch.created": "created a branch", | |
| 93 | "branch.deleted": "deleted a branch", | |
| 94 | "label.created": "added a label", | |
| 95 | "label.updated": "edited a label", | |
| 96 | "label.deleted": "removed a label", | |
| 97 | "webhook.created": "added a webhook", | |
| 98 | "webhook.deleted": "removed a webhook", | |
| 99 | "origin.connected": "connected an origin", | |
| 100 | "origin.disconnected": "disconnected an origin", | |
| 101 | "origin.needs_reauth": "needs to reconnect an origin", | |
| 102 | "run.queued": "queued a run", | |
| 103 | "run.started": "started a run", | |
| 104 | "app.created": "made it an app", | |
| 105 | "app.updated": "changed the app's settings", | |
| 106 | "app.deleted": "stopped serving it as an app", | |
| 107 | }; |
src/http/api.ts +486
| @@ -133,6 +133,42 @@ import { diffCommit } from "../git/diff.ts"; | ||
| 133 | 133 | import { runGit } from "../git/env.ts"; |
| 134 | 134 | import { isSafeRef, isSafeTreePath } from "../git/guard.ts"; |
| 135 | 135 | import { listBranches } from "../git/branches.ts"; |
| 136 | import { | |
| 137 | appView, | |
| 138 | disableApp, | |
| 139 | enableApp, | |
| 140 | newApp, | |
| 141 | openEnvUrl, | |
| 142 | redeployEnv, | |
| 143 | stopEnv, | |
| 144 | updateAppSettings, | |
| 145 | type AppSettingsInput, | |
| 146 | } from "../actions/apps.ts"; | |
| 147 | import { | |
| 148 | cancelRepoRun, | |
| 149 | getRepoRun, | |
| 150 | listRepoRuns, | |
| 151 | runLog, | |
| 152 | } from "../actions/runs.ts"; | |
| 153 | import { | |
| 154 | addToCrew, | |
| 155 | createAgent, | |
| 156 | deleteAgent, | |
| 157 | getAgentView, | |
| 158 | listAllAgents, | |
| 159 | listCrew, | |
| 160 | removeFromCrew, | |
| 161 | updateAgent, | |
| 162 | } from "../actions/crew.ts"; | |
| 163 | import { assignIssue } from "../actions/issues.ts"; | |
| 164 | import { appsRuntime, type AppsRuntime } from "../apps/runtime.ts"; | |
| 165 | import { isEnvName } from "../apps/data.ts"; | |
| 166 | import type { Agent } from "../core/agents.ts"; | |
| 167 | import { listApps, type AppRecord } from "../core/apps.ts"; | |
| 168 | import { listAssignedTo } from "../core/issues.ts"; | |
| 169 | import { checksForHead, getRun, isTerminal, type Run } from "../core/runs.ts"; | |
| 170 | import { resolveOid } from "../git/merge.ts"; | |
| 171 | import { readRunLog, subscribeRunLog } from "../runs/log.ts"; | |
| 136 | 172 | import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts"; |
| 137 | 173 | import { repoDir } from "../git/repos.ts"; |
| 138 | 174 | import type { Result } from "../kernel/result.ts"; |
| @@ -550,6 +586,52 @@ function repoQuery(ctx: Ctx) { | ||
| 550 | 586 | }; |
| 551 | 587 | } |
| 552 | 588 | |
| 589 | /** An app as the API returns it, with what is running when a runtime is up. */ | |
| 590 | function appJson( | |
| 591 | record: AppRecord, | |
| 592 | repo: Repo, | |
| 593 | runtime: AppsRuntime | undefined, | |
| 594 | ) { | |
| 595 | return { | |
| 596 | ...record, | |
| 597 | repo: { owner: repo.ownerName, name: repo.name }, | |
| 598 | url: runtime?.urlFor(record, "production") ?? null, | |
| 599 | serving: runtime !== undefined, | |
| 600 | environments: runtime?.status(record) ?? [], | |
| 601 | }; | |
| 602 | } | |
| 603 | ||
| 604 | function appPatch(input: Record<string, unknown>): AppSettingsInput { | |
| 605 | return { | |
| 606 | ...(typeof input.slug === "string" ? { slug: input.slug } : {}), | |
| 607 | ...(input.access === "public" || input.access === "private" | |
| 608 | ? { access: input.access } | |
| 609 | : {}), | |
| 610 | ...(input.previewData === "copy" || input.previewData === "empty" | |
| 611 | ? { previewData: input.previewData } | |
| 612 | : {}), | |
| 613 | }; | |
| 614 | } | |
| 615 | ||
| 616 | function agentJson(agent: Agent) { | |
| 617 | return { | |
| 618 | username: agent.user.username, | |
| 619 | displayName: agent.user.displayName, | |
| 620 | model: agent.model, | |
| 621 | brief: agent.brief, | |
| 622 | budgetUsd: agent.budgetUsd, | |
| 623 | createdAt: agent.createdAt, | |
| 624 | }; | |
| 625 | } | |
| 626 | ||
| 627 | /** The live head of a pull — the branch tip, or the snapshot for a fork's. */ | |
| 628 | async function pullHeadOid(ctx: Ctx, repo: Repo, pull: Pull): Promise<string> { | |
| 629 | const ref = pull.crossRepo | |
| 630 | ? `refs/pull/${pull.number}/head` | |
| 631 | : `refs/heads/${pull.headBranch}`; | |
| 632 | return (await resolveOid(dirOf(ctx, repo), ref)) ?? pull.headOid; | |
| 633 | } | |
| 634 | ||
| 553 | 635 | function positiveInt(raw: string | undefined): number | undefined { |
| 554 | 636 | const n = Number(raw); |
| 555 | 637 | return Number.isInteger(n) && n > 0 ? n : undefined; |
| @@ -963,6 +1045,9 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 963 | 1045 | ...(typeof input.requireReview === "boolean" |
| 964 | 1046 | ? { requireReview: input.requireReview } |
| 965 | 1047 | : {}), |
| 1048 | ...(typeof input.requireChecks === "boolean" | |
| 1049 | ? { requireChecks: input.requireChecks } | |
| 1050 | : {}), | |
| 966 | 1051 | ...(typeof input.description === "string" |
| 967 | 1052 | ? { description: input.description } |
| 968 | 1053 | : {}), |
| @@ -2242,6 +2327,407 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2242 | 2327 | return json({ pinned: true }); |
| 2243 | 2328 | }); |
| 2244 | 2329 | |
| 2330 | // ---- apps, runs, and the crew (docs/APPS.md) ---- | |
| 2331 | ||
| 2332 | router.add("GET", "/api/v1/apps", (ctx) => { | |
| 2333 | const records = listApps(ctx.app.db); | |
| 2334 | const repos = getReposByIds( | |
| 2335 | ctx.app.db, | |
| 2336 | records.map((a) => a.repoId), | |
| 2337 | ); | |
| 2338 | const runtime = appsRuntime(ctx.app); | |
| 2339 | const items = []; | |
| 2340 | for (const record of records) { | |
| 2341 | const repo = repos.get(record.repoId); | |
| 2342 | if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) continue; | |
| 2343 | items.push(appJson(record, repo, runtime)); | |
| 2344 | } | |
| 2345 | return json({ items, next_cursor: null }); | |
| 2346 | }); | |
| 2347 | ||
| 2348 | router.add("POST", "/api/v1/apps", async (ctx) => { | |
| 2349 | const denied = requireScope(ctx, "rw"); | |
| 2350 | if (denied) return denied; | |
| 2351 | const input = await body(ctx.request); | |
| 2352 | const made = await newApp(ctx.app, ctx.auth.user, { | |
| 2353 | name: str(input.name), | |
| 2354 | ...(typeof input.owner === "string" ? { owner: input.owner } : {}), | |
| 2355 | ...(typeof input.description === "string" | |
| 2356 | ? { description: input.description } | |
| 2357 | : {}), | |
| 2358 | ...(input.visibility === "public" || input.visibility === "private" | |
| 2359 | ? { visibility: input.visibility } | |
| 2360 | : {}), | |
| 2361 | ...(typeof input.agent === "string" ? { agent: input.agent } : {}), | |
| 2362 | }); | |
| 2363 | if (!made.ok) return respondAccess(made.error, 422); | |
| 2364 | return json( | |
| 2365 | { | |
| 2366 | repo: repoJson(ctx, made.value.repo), | |
| 2367 | app: appJson(made.value.app, made.value.repo, appsRuntime(ctx.app)), | |
| 2368 | issue: made.value.issue ? withLabels(ctx, made.value.issue) : null, | |
| 2369 | }, | |
| 2370 | 201, | |
| 2371 | ); | |
| 2372 | }); | |
| 2373 | ||
| 2374 | router.add("GET", "/api/v1/repos/:owner/:name/app", (ctx) => { | |
| 2375 | const repo = loadRepo(ctx); | |
| 2376 | if (!repo) return notFound(); | |
| 2377 | const view = appView(ctx.app, ctx.auth.user, repo); | |
| 2378 | if (!view.ok) return notFound(); | |
| 2379 | return json(appJson(view.value.app, repo, appsRuntime(ctx.app))); | |
| 2380 | }); | |
| 2381 | ||
| 2382 | router.add("POST", "/api/v1/repos/:owner/:name/app", async (ctx) => { | |
| 2383 | const denied = requireScope(ctx, "admin"); | |
| 2384 | if (denied) return denied; | |
| 2385 | const repo = loadRepo(ctx); | |
| 2386 | if (!repo) return notFound(); | |
| 2387 | const input = await body(ctx.request); | |
| 2388 | const enabled = enableApp(ctx.app, ctx.auth.user, repo, appPatch(input)); | |
| 2389 | if (!enabled.ok) return respondAccess(enabled.error, 422); | |
| 2390 | return json(appJson(enabled.value, repo, appsRuntime(ctx.app)), 201); | |
| 2391 | }); | |
| 2392 | ||
| 2393 | router.add("PATCH", "/api/v1/repos/:owner/:name/app", async (ctx) => { | |
| 2394 | const denied = requireScope(ctx, "admin"); | |
| 2395 | if (denied) return denied; | |
| 2396 | const repo = loadRepo(ctx); | |
| 2397 | if (!repo) return notFound(); | |
| 2398 | const input = await body(ctx.request); | |
| 2399 | const updated = updateAppSettings( | |
| 2400 | ctx.app, | |
| 2401 | ctx.auth.user, | |
| 2402 | repo, | |
| 2403 | appPatch(input), | |
| 2404 | ); | |
| 2405 | if (!updated.ok) return respondAccess(updated.error, 422); | |
| 2406 | return json(appJson(updated.value, repo, appsRuntime(ctx.app))); | |
| 2407 | }); | |
| 2408 | ||
| 2409 | router.add("DELETE", "/api/v1/repos/:owner/:name/app", (ctx) => { | |
| 2410 | const denied = requireScope(ctx, "admin"); | |
| 2411 | if (denied) return denied; | |
| 2412 | const repo = loadRepo(ctx); | |
| 2413 | if (!repo) return notFound(); | |
| 2414 | const disabled = disableApp(ctx.app, ctx.auth.user, repo); | |
| 2415 | if (!disabled.ok) return respondAccess(disabled.error, 422); | |
| 2416 | return json({}, 200); | |
| 2417 | }); | |
| 2418 | ||
| 2419 | router.add( | |
| 2420 | "POST", | |
| 2421 | "/api/v1/repos/:owner/:name/app/:env/redeploy", | |
| 2422 | async (ctx) => { | |
| 2423 | const denied = requireScope(ctx, "rw"); | |
| 2424 | if (denied) return denied; | |
| 2425 | const repo = loadRepo(ctx); | |
| 2426 | if (!repo) return notFound(); | |
| 2427 | const queued = await redeployEnv( | |
| 2428 | ctx.app, | |
| 2429 | ctx.auth.user, | |
| 2430 | repo, | |
| 2431 | ctx.params.env ?? "", | |
| 2432 | ); | |
| 2433 | if (!queued.ok) return respondAccess(queued.error, 422); | |
| 2434 | return json({ run: queued.value ?? null }, 202); | |
| 2435 | }, | |
| 2436 | ); | |
| 2437 | ||
| 2438 | router.add( | |
| 2439 | "POST", | |
| 2440 | "/api/v1/repos/:owner/:name/app/:env/stop", | |
| 2441 | async (ctx) => { | |
| 2442 | const denied = requireScope(ctx, "rw"); | |
| 2443 | if (denied) return denied; | |
| 2444 | const repo = loadRepo(ctx); | |
| 2445 | if (!repo) return notFound(); | |
| 2446 | const stopped = await stopEnv( | |
| 2447 | ctx.app, | |
| 2448 | ctx.auth.user, | |
| 2449 | repo, | |
| 2450 | ctx.params.env ?? "", | |
| 2451 | ); | |
| 2452 | if (!stopped.ok) return respondAccess(stopped.error, 422); | |
| 2453 | return json({}, 200); | |
| 2454 | }, | |
| 2455 | ); | |
| 2456 | ||
| 2457 | // The URL to open an environment at. For a private app it carries a | |
| 2458 | // grant, so the answer is for the caller alone and is not cached. | |
| 2459 | router.add("GET", "/api/v1/repos/:owner/:name/app/:env/open", (ctx) => { | |
| 2460 | const repo = loadRepo(ctx); | |
| 2461 | if (!repo) return notFound(); | |
| 2462 | const link = openEnvUrl(ctx.app, ctx.auth.user, repo, ctx.params.env ?? ""); | |
| 2463 | if (!link.ok) return respondAccess(link.error, 422); | |
| 2464 | return json({ url: link.value }); | |
| 2465 | }); | |
| 2466 | ||
| 2467 | router.add("GET", "/api/v1/repos/:owner/:name/runs", (ctx) => { | |
| 2468 | const repo = loadRepo(ctx); | |
| 2469 | if (!repo) return notFound(); | |
| 2470 | const q = ctx.url.searchParams; | |
| 2471 | const kind = q.get("kind"); | |
| 2472 | const env = q.get("env"); | |
| 2473 | const limit = Math.min(Math.max(Number(q.get("limit")) || 50, 1), 200); | |
| 2474 | const runs = listRepoRuns(ctx.app, ctx.auth.user, repo, { | |
| 2475 | ...(kind === "deploy" || kind === "agent" ? { kind } : {}), | |
| 2476 | ...(env !== null && isEnvName(env) ? { env } : {}), | |
| 2477 | ...(q.get("cursor") ? { cursor: q.get("cursor")! } : {}), | |
| 2478 | limit, | |
| 2479 | }); | |
| 2480 | if (!runs.ok) return notFound(); | |
| 2481 | return json(paged(runs.value, limit, (r) => r.id)); | |
| 2482 | }); | |
| 2483 | ||
| 2484 | router.add("GET", "/api/v1/repos/:owner/:name/runs/:id", (ctx) => { | |
| 2485 | const repo = loadRepo(ctx); | |
| 2486 | if (!repo) return notFound(); | |
| 2487 | const run = getRepoRun(ctx.app, ctx.auth.user, repo, ctx.params.id ?? ""); | |
| 2488 | return run.ok ? json(run.value) : notFound(); | |
| 2489 | }); | |
| 2490 | ||
| 2491 | // The log: text, or a live tail as server-sent events (`?follow=1`, or | |
| 2492 | // an `Accept: text/event-stream`). Lines already written come first, | |
| 2493 | // then each new one as it lands; `done` carries the finished run. | |
| 2494 | router.add("GET", "/api/v1/repos/:owner/:name/runs/:id/log", (ctx) => { | |
| 2495 | const repo = loadRepo(ctx); | |
| 2496 | if (!repo) return notFound(); | |
| 2497 | const found = runLog(ctx.app, ctx.auth.user, repo, ctx.params.id ?? ""); | |
| 2498 | if (!found.ok) return notFound(); | |
| 2499 | const follow = | |
| 2500 | ctx.url.searchParams.get("follow") === "1" || | |
| 2501 | (ctx.request.headers.get("accept") ?? "").includes("text/event-stream"); | |
| 2502 | if (!follow) { | |
| 2503 | return new Response(`${found.value.lines.join("\n")}\n`, { | |
| 2504 | headers: { | |
| 2505 | "content-type": "text/plain; charset=utf-8", | |
| 2506 | "cache-control": "no-store", | |
| 2507 | }, | |
| 2508 | }); | |
| 2509 | } | |
| 2510 | const run = found.value.run; | |
| 2511 | return eventStream(ctx.request, (emit, close) => { | |
| 2512 | const controller = new AbortController(); | |
| 2513 | // Subscribe, then read: both are synchronous, so no line can land in | |
| 2514 | // between — the tail is exact, not approximate. | |
| 2515 | subscribeRunLog(run.id, (line) => emit("line", line), controller.signal); | |
| 2516 | for (const line of readRunLog(ctx.app.config.dataDir, run.id).lines) | |
| 2517 | emit("line", line); | |
| 2518 | const finish = (final: Run): void => { | |
| 2519 | emit("done", final, final.id); | |
| 2520 | close(); | |
| 2521 | }; | |
| 2522 | const unsubscribe = ctx.app.bus.subscribe((event) => { | |
| 2523 | if (event.kind !== "run.finished" || event.payload["runId"] !== run.id) | |
| 2524 | return; | |
| 2525 | finish(getRun(ctx.app.db, run.id) ?? run); | |
| 2526 | }); | |
| 2527 | if (isTerminal(run.state)) finish(run); | |
| 2528 | return () => { | |
| 2529 | controller.abort(); | |
| 2530 | unsubscribe(); | |
| 2531 | }; | |
| 2532 | }); | |
| 2533 | }); | |
| 2534 | ||
| 2535 | router.add("POST", "/api/v1/repos/:owner/:name/runs/:id/cancel", (ctx) => { | |
| 2536 | const denied = requireScope(ctx, "rw"); | |
| 2537 | if (denied) return denied; | |
| 2538 | const repo = loadRepo(ctx); | |
| 2539 | if (!repo) return notFound(); | |
| 2540 | const cancelled = cancelRepoRun( | |
| 2541 | ctx.app, | |
| 2542 | ctx.auth.user, | |
| 2543 | repo, | |
| 2544 | ctx.params.id ?? "", | |
| 2545 | ); | |
| 2546 | if (!cancelled.ok) return respondAccess(cancelled.error, 409); | |
| 2547 | return json(cancelled.value); | |
| 2548 | }); | |
| 2549 | ||
| 2550 | // The checks on a pull: the deploy runs at its current head. | |
| 2551 | router.add( | |
| 2552 | "GET", | |
| 2553 | "/api/v1/repos/:owner/:name/pulls/:number/checks", | |
| 2554 | async (ctx) => { | |
| 2555 | const found = loadPull(ctx); | |
| 2556 | if ("error" in found) return found.error; | |
| 2557 | const head = await pullHeadOid(ctx, found.repo, found.pull); | |
| 2558 | const runs = checksForHead(ctx.app.db, found.pull.id, head); | |
| 2559 | const latest = runs[0]; | |
| 2560 | return json({ | |
| 2561 | head, | |
| 2562 | state: latest?.state ?? "none", | |
| 2563 | required: found.repo.requireChecks, | |
| 2564 | run: latest ?? null, | |
| 2565 | runs, | |
| 2566 | }); | |
| 2567 | }, | |
| 2568 | ); | |
| 2569 | ||
| 2570 | for (const kind of ["issues", "pulls"]) { | |
| 2571 | router.add( | |
| 2572 | "PUT", | |
| 2573 | `/api/v1/repos/:owner/:name/${kind}/:number/assignee`, | |
| 2574 | async (ctx) => { | |
| 2575 | const denied = requireScope(ctx, "rw"); | |
| 2576 | if (denied) return denied; | |
| 2577 | const found = loadConversation(ctx); | |
| 2578 | if ("error" in found) return found.error; | |
| 2579 | const input = await body(ctx.request); | |
| 2580 | const name = input.assignee; | |
| 2581 | if (name !== null && typeof name !== "string") | |
| 2582 | return apiError( | |
| 2583 | "invalid_assignee", | |
| 2584 | 422, | |
| 2585 | "assignee must be a username or null", | |
| 2586 | ); | |
| 2587 | const assignee = | |
| 2588 | name === null || name === "" ? null : getUserByName(ctx.app.db, name); | |
| 2589 | if (assignee === undefined) | |
| 2590 | return apiError("no_such_user", 422, `no account named ${name}`); | |
| 2591 | const assigned = assignIssue( | |
| 2592 | ctx.app, | |
| 2593 | ctx.auth.user, | |
| 2594 | found.repo, | |
| 2595 | found.issue, | |
| 2596 | assignee, | |
| 2597 | ); | |
| 2598 | if (!assigned.ok) return respondAccess(assigned.error, 422); | |
| 2599 | return json(withLabels(ctx, assigned.value)); | |
| 2600 | }, | |
| 2601 | ); | |
| 2602 | } | |
| 2603 | ||
| 2604 | // What is on the caller: open issues and pulls assigned to them, anywhere. | |
| 2605 | router.add("GET", "/api/v1/user/assigned", (ctx) => { | |
| 2606 | const denied = requireScope(ctx, "ro"); | |
| 2607 | if (denied) return denied; | |
| 2608 | const user = ctx.auth.user!; | |
| 2609 | const issues = listAssignedTo(ctx.app.db, user.id); | |
| 2610 | const repos = getReposByIds( | |
| 2611 | ctx.app.db, | |
| 2612 | issues.map((i) => i.repoId), | |
| 2613 | ); | |
| 2614 | const items = []; | |
| 2615 | for (const issue of issues) { | |
| 2616 | const repo = repos.get(issue.repoId); | |
| 2617 | if (!repo || !canRead(ctx.app.db, repo, user)) continue; | |
| 2618 | items.push({ | |
| 2619 | ...issue, | |
| 2620 | repo: { owner: repo.ownerName, name: repo.name }, | |
| 2621 | }); | |
| 2622 | } | |
| 2623 | return json({ items, next_cursor: null }); | |
| 2624 | }); | |
| 2625 | ||
| 2626 | router.add("GET", "/api/v1/agents", (ctx) => { | |
| 2627 | const agents = listAllAgents(ctx.app, ctx.auth.user); | |
| 2628 | if (!agents.ok) return respondAccess(agents.error, 401); | |
| 2629 | return json({ items: agents.value.map(agentJson), next_cursor: null }); | |
| 2630 | }); | |
| 2631 | ||
| 2632 | router.add("POST", "/api/v1/agents", async (ctx) => { | |
| 2633 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 2634 | if (denied) return denied; | |
| 2635 | const input = await body(ctx.request); | |
| 2636 | const made = await createAgent(ctx.app, ctx.auth.user, { | |
| 2637 | username: str(input.username), | |
| 2638 | ...(typeof input.displayName === "string" | |
| 2639 | ? { displayName: input.displayName } | |
| 2640 | : {}), | |
| 2641 | ...(typeof input.model === "string" ? { model: input.model } : {}), | |
| 2642 | ...(typeof input.brief === "string" ? { brief: input.brief } : {}), | |
| 2643 | ...(typeof input.budgetUsd === "number" | |
| 2644 | ? { budgetUsd: input.budgetUsd } | |
| 2645 | : {}), | |
| 2646 | }); | |
| 2647 | if (!made.ok) return respondAccess(made.error, 422); | |
| 2648 | return json(agentJson(made.value), 201); | |
| 2649 | }); | |
| 2650 | ||
| 2651 | router.add("GET", "/api/v1/agents/:username", (ctx) => { | |
| 2652 | const agent = getAgentView( | |
| 2653 | ctx.app, | |
| 2654 | ctx.auth.user, | |
| 2655 | ctx.params.username ?? "", | |
| 2656 | ); | |
| 2657 | if (!agent.ok) return respondAccess(agent.error, 404); | |
| 2658 | return json(agentJson(agent.value)); | |
| 2659 | }); | |
| 2660 | ||
| 2661 | router.add("PATCH", "/api/v1/agents/:username", async (ctx) => { | |
| 2662 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 2663 | if (denied) return denied; | |
| 2664 | const input = await body(ctx.request); | |
| 2665 | const updated = updateAgent( | |
| 2666 | ctx.app, | |
| 2667 | ctx.auth.user, | |
| 2668 | ctx.params.username ?? "", | |
| 2669 | { | |
| 2670 | ...(typeof input.model === "string" ? { model: input.model } : {}), | |
| 2671 | ...(typeof input.brief === "string" ? { brief: input.brief } : {}), | |
| 2672 | ...(typeof input.budgetUsd === "number" | |
| 2673 | ? { budgetUsd: input.budgetUsd } | |
| 2674 | : {}), | |
| 2675 | }, | |
| 2676 | ); | |
| 2677 | if (!updated.ok) return respondAccess(updated.error, 422); | |
| 2678 | return json(agentJson(updated.value)); | |
| 2679 | }); | |
| 2680 | ||
| 2681 | router.add("DELETE", "/api/v1/agents/:username", (ctx) => { | |
| 2682 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 2683 | if (denied) return denied; | |
| 2684 | const removed = deleteAgent( | |
| 2685 | ctx.app, | |
| 2686 | ctx.auth.user, | |
| 2687 | ctx.params.username ?? "", | |
| 2688 | ); | |
| 2689 | if (!removed.ok) return respondAccess(removed.error, 404); | |
| 2690 | return json({}, 200); | |
| 2691 | }); | |
| 2692 | ||
| 2693 | router.add("GET", "/api/v1/repos/:owner/:name/crew", (ctx) => { | |
| 2694 | const repo = loadRepo(ctx); | |
| 2695 | if (!repo) return notFound(); | |
| 2696 | const crew = listCrew(ctx.app, ctx.auth.user, repo); | |
| 2697 | if (!crew.ok) return notFound(); | |
| 2698 | return json({ items: crew.value.map(agentJson), next_cursor: null }); | |
| 2699 | }); | |
| 2700 | ||
| 2701 | router.add("PUT", "/api/v1/repos/:owner/:name/crew/:username", (ctx) => { | |
| 2702 | const denied = requireScope(ctx, "admin"); | |
| 2703 | if (denied) return denied; | |
| 2704 | const repo = loadRepo(ctx); | |
| 2705 | if (!repo) return notFound(); | |
| 2706 | const added = addToCrew( | |
| 2707 | ctx.app, | |
| 2708 | ctx.auth.user, | |
| 2709 | repo, | |
| 2710 | ctx.params.username ?? "", | |
| 2711 | ); | |
| 2712 | if (!added.ok) return respondAccess(added.error, 422); | |
| 2713 | return json(agentJson(added.value)); | |
| 2714 | }); | |
| 2715 | ||
| 2716 | router.add("DELETE", "/api/v1/repos/:owner/:name/crew/:username", (ctx) => { | |
| 2717 | const denied = requireScope(ctx, "admin"); | |
| 2718 | if (denied) return denied; | |
| 2719 | const repo = loadRepo(ctx); | |
| 2720 | if (!repo) return notFound(); | |
| 2721 | const removed = removeFromCrew( | |
| 2722 | ctx.app, | |
| 2723 | ctx.auth.user, | |
| 2724 | repo, | |
| 2725 | ctx.params.username ?? "", | |
| 2726 | ); | |
| 2727 | if (!removed.ok) return respondAccess(removed.error, 422); | |
| 2728 | return json({}, 200); | |
| 2729 | }); | |
| 2730 | ||
| 2245 | 2731 | router.add("DELETE", "/api/v1/user/pins/:owner/:name", (ctx) => { |
| 2246 | 2732 | const denied = requireScope(ctx, "rw"); |
| 2247 | 2733 | if (denied) return denied; |
src/http/server.ts +98 −1
| @@ -71,7 +71,33 @@ import { | ||
| 71 | 71 | newIssuePage, |
| 72 | 72 | newIssueSubmit, |
| 73 | 73 | } from "../web/pages/issues.ts"; |
| 74 | import { labelsSubmit, stateSubmit } from "../web/pages/thread.ts"; | |
| 74 | import { | |
| 75 | assigneeSubmit, | |
| 76 | labelsSubmit, | |
| 77 | stateSubmit, | |
| 78 | } from "../web/pages/thread.ts"; | |
| 79 | import { | |
| 80 | appOpen, | |
| 81 | appPage, | |
| 82 | appRedeploy, | |
| 83 | appsList, | |
| 84 | appStop, | |
| 85 | newAppPage, | |
| 86 | newAppSubmit, | |
| 87 | } from "../web/pages/apps.ts"; | |
| 88 | import { | |
| 89 | runCancel, | |
| 90 | runPage, | |
| 91 | runRedirect, | |
| 92 | runsList, | |
| 93 | } from "../web/pages/runs.ts"; | |
| 94 | import { | |
| 95 | agentCreate, | |
| 96 | agentDelete, | |
| 97 | agentPage, | |
| 98 | agentUpdate, | |
| 99 | crewPage, | |
| 100 | } from "../web/pages/crew.ts"; | |
| 75 | 101 | import { newPullPage, newPullSubmit, pullsList } from "../web/pages/pulls.ts"; |
| 76 | 102 | import { |
| 77 | 103 | pullCommentSubmit, |
| @@ -83,6 +109,12 @@ import { | ||
| 83 | 109 | threadResolveSubmit, |
| 84 | 110 | } from "../web/pages/pull.ts"; |
| 85 | 111 | import { |
| 112 | appDisable, | |
| 113 | appSettingsPage, | |
| 114 | appSettingsUpdate, | |
| 115 | crewAdd, | |
| 116 | crewRemove, | |
| 117 | crewSettingsPage, | |
| 86 | 118 | dangerPage, |
| 87 | 119 | hooksPage, |
| 88 | 120 | labelCreate, |
| @@ -175,6 +207,9 @@ import { | ||
| 175 | 207 | } from "../core/idempotency.ts"; |
| 176 | 208 | import { sseStream } from "./sse.ts"; |
| 177 | 209 | import { Router } from "./router.ts"; |
| 210 | import { getUserById } from "../core/users.ts"; | |
| 211 | import { appsRuntime } from "../apps/runtime.ts"; | |
| 212 | import { proxyApp, resolveAppHost } from "../apps/proxy.ts"; | |
| 178 | 213 | |
| 179 | 214 | // ---- auth ---- |
| 180 | 215 | |
| @@ -288,6 +323,12 @@ export function buildRouter(): Router<Handler> { | ||
| 288 | 323 | router.add("POST", "/new", newRepoSubmit); |
| 289 | 324 | router.add("GET", "/organizations/new", newOrgPage); |
| 290 | 325 | router.add("POST", "/organizations/new", newOrgSubmit); |
| 326 | // Apps and the crew (docs/APPS.md): the list, the golden path's first | |
| 327 | // step, and the short door to a run the proxy's "not running" page opens. | |
| 328 | router.add("GET", "/apps", appsList); | |
| 329 | router.add("GET", "/apps/new", newAppPage); | |
| 330 | router.add("POST", "/apps/new", newAppSubmit); | |
| 331 | router.add("GET", "/runs/:id", runRedirect); | |
| 291 | 332 | router.add("GET", "/settings", accountPage); |
| 292 | 333 | router.add("POST", "/settings", accountUpdate); |
| 293 | 334 | router.add("GET", "/settings/password", passwordPage); |
| @@ -295,6 +336,12 @@ export function buildRouter(): Router<Handler> { | ||
| 295 | 336 | router.add("GET", "/settings/tokens", tokensPage); |
| 296 | 337 | router.add("POST", "/settings/tokens", tokenCreate); |
| 297 | 338 | router.add("POST", "/settings/tokens/:id/delete", tokenDelete); |
| 339 | // The crew: a forge admin's section, hidden from everyone else. | |
| 340 | router.add("GET", "/settings/crew", crewPage); | |
| 341 | router.add("POST", "/settings/crew", agentCreate); | |
| 342 | router.add("GET", "/settings/crew/:username", agentPage); | |
| 343 | router.add("POST", "/settings/crew/:username", agentUpdate); | |
| 344 | router.add("POST", "/settings/crew/:username/delete", agentDelete); | |
| 298 | 345 | // Where an OAuth authorization comes back to. Deliberately a plain route |
| 299 | 346 | // and not an API one: it is a browser redirect, possibly from a phone, and |
| 300 | 347 | // the only thing it carries is a `state` this forge issued minutes ago. |
| @@ -455,6 +502,9 @@ export function buildRouter(): Router<Handler> { | ||
| 455 | 502 | router.add("POST", "/:owner/:name/issues/:number/labels", (ctx) => |
| 456 | 503 | labelsSubmit(ctx, "issue"), |
| 457 | 504 | ); |
| 505 | router.add("POST", "/:owner/:name/issues/:number/assignee", (ctx) => | |
| 506 | assigneeSubmit(ctx, "issue"), | |
| 507 | ); | |
| 458 | 508 | |
| 459 | 509 | // Pull requests. Same /new-before-/:number ordering discipline. |
| 460 | 510 | router.add("GET", "/:owner/:name/pulls", pullsList); |
| @@ -487,6 +537,19 @@ export function buildRouter(): Router<Handler> { | ||
| 487 | 537 | router.add("POST", "/:owner/:name/pulls/:number/labels", (ctx) => |
| 488 | 538 | labelsSubmit(ctx, "pull"), |
| 489 | 539 | ); |
| 540 | router.add("POST", "/:owner/:name/pulls/:number/assignee", (ctx) => | |
| 541 | assigneeSubmit(ctx, "pull"), | |
| 542 | ); | |
| 543 | ||
| 544 | // The app a repository runs as, and the runs it produced. Opening an | |
| 545 | // environment goes through the forge so a private app gets its grant. | |
| 546 | router.add("GET", "/:owner/:name/app", appPage); | |
| 547 | router.add("GET", "/:owner/:name/app/:env/open", appOpen); | |
| 548 | router.add("POST", "/:owner/:name/app/:env/redeploy", appRedeploy); | |
| 549 | router.add("POST", "/:owner/:name/app/:env/stop", appStop); | |
| 550 | router.add("GET", "/:owner/:name/runs", runsList); | |
| 551 | router.add("GET", "/:owner/:name/runs/:id", runPage); | |
| 552 | router.add("POST", "/:owner/:name/runs/:id/cancel", runCancel); | |
| 490 | 553 | |
| 491 | 554 | // Live event stream for a repo the viewer can read. |
| 492 | 555 | router.add("GET", "/:owner/:name/stream", (ctx) => { |
| @@ -545,6 +608,16 @@ export function buildRouter(): Router<Handler> { | ||
| 545 | 608 | // Settings (owner-gated inside the handlers). |
| 546 | 609 | router.add("GET", "/:owner/:name/settings", settingsPage); |
| 547 | 610 | router.add("POST", "/:owner/:name/settings", settingsUpdate); |
| 611 | router.add("GET", "/:owner/:name/settings/app", appSettingsPage); | |
| 612 | router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate); | |
| 613 | router.add("POST", "/:owner/:name/settings/app/disable", appDisable); | |
| 614 | router.add("GET", "/:owner/:name/settings/crew", crewSettingsPage); | |
| 615 | router.add("POST", "/:owner/:name/settings/crew", crewAdd); | |
| 616 | router.add( | |
| 617 | "POST", | |
| 618 | "/:owner/:name/settings/crew/:username/remove", | |
| 619 | crewRemove, | |
| 620 | ); | |
| 548 | 621 | router.add("GET", "/:owner/:name/settings/labels", labelsPage); |
| 549 | 622 | router.add("POST", "/:owner/:name/settings/labels", labelCreate); |
| 550 | 623 | router.add("POST", "/:owner/:name/settings/labels/:id", labelUpdate); |
| @@ -764,6 +837,9 @@ async function handleHook(app: App, request: Request): Promise<Response> { | ||
| 764 | 837 | if (!repo) return new Response("unknown repository", { status: 400 }); |
| 765 | 838 | |
| 766 | 839 | if (input.hook === "pre-receive") { |
| 840 | const actor = input.actorId | |
| 841 | ? getUserById(app.db, input.actorId) | |
| 842 | : undefined; | |
| 767 | 843 | for (const update of input.updates) { |
| 768 | 844 | // The pull namespace belongs to the forge, never to pushers. |
| 769 | 845 | if (update.ref.startsWith("refs/pull/")) { |
| @@ -771,6 +847,18 @@ async function handleHook(app: App, request: Request): Promise<Response> { | ||
| 771 | 847 | status: 403, |
| 772 | 848 | }); |
| 773 | 849 | } |
| 850 | // Rule 1 in docs/APPS.md: the crew proposes, a person lands. An agent | |
| 851 | // never moves the default branch — protected or not — so production | |
| 852 | // changes only through a pull someone merged. | |
| 853 | if ( | |
| 854 | actor?.kind === "agent" && | |
| 855 | update.ref === `refs/heads/${repo.defaultBranch}` | |
| 856 | ) { | |
| 857 | return new Response( | |
| 858 | `${repo.defaultBranch}: agents propose through pulls; a person merges`, | |
| 859 | { status: 403 }, | |
| 860 | ); | |
| 861 | } | |
| 774 | 862 | if ( |
| 775 | 863 | repo.protectDefault && |
| 776 | 864 | update.ref === `refs/heads/${repo.defaultBranch}` |
| @@ -877,6 +965,15 @@ export async function serve(app: App): Promise<Servers> { | ||
| 877 | 965 | port: app.config.port ?? 3000, |
| 878 | 966 | idleTimeout: 120, |
| 879 | 967 | async fetch(request) { |
| 968 | // An app's hostname is answered before any route: `hello.<domain>` is | |
| 969 | // the app, whatever its path, and the forge's own routes never apply. | |
| 970 | const runtime = appsRuntime(app); | |
| 971 | if (runtime !== undefined) { | |
| 972 | const target = resolveAppHost(app, runtime.options.domain, request); | |
| 973 | if (target === "unknown") | |
| 974 | return new Response("not found", { status: 404 }); | |
| 975 | if (target !== undefined) return proxyApp(runtime, request, target); | |
| 976 | } | |
| 880 | 977 | const url = publicUrl(request, app.config.url); |
| 881 | 978 | const matched = router.match(request.method, url.pathname); |
| 882 | 979 | if (!matched) return noRoute(request.method, url.pathname); |
src/main.ts +77 −5
| @@ -12,6 +12,9 @@ import { isHookName, runHookClient } from "./git/hooks.ts"; | ||
| 12 | 12 | import { serve } from "./http/server.ts"; |
| 13 | 13 | import { runSync } from "./origins/sync.ts"; |
| 14 | 14 | import { serveForgeSsh } from "./tui/ssh.ts"; |
| 15 | import { AppsRuntime } from "./apps/runtime.ts"; | |
| 16 | import { startCrew } from "./crew/dispatch.ts"; | |
| 17 | import { pruneTokens } from "./core/tokens.ts"; | |
| 15 | 18 | |
| 16 | 19 | // `forge hook <name>` re-enters here from a git hook stub. It relays to the |
| 17 | 20 | // running forge over its socket and exits — it must never boot a server. |
| @@ -46,16 +49,67 @@ const servers = await serve(app); | ||
| 46 | 49 | const ssh = |
| 47 | 50 | sshPort > 0 ? await serveForgeSsh(app, { port: sshPort }) : undefined; |
| 48 | 51 | |
| 52 | // Apps and the crew (docs/APPS.md). Both are on unless FORGE_APPS=0: an app | |
| 53 | // is served by Host on the same port, and the crew runs agents as child | |
| 54 | // processes. The public URL is what apps and agents are told about the | |
| 55 | // forge; FORGE_URL when the operator set one, the host and port otherwise. | |
| 56 | const host = app.config.host ?? "localhost"; | |
| 57 | const forgeUrl = | |
| 58 | process.env.FORGE_URL?.replace(/\/+$/, "") ?? | |
| 59 | `http://${host}:${servers.port}`; | |
| 60 | const publicUrl = new URL(forgeUrl); | |
| 61 | const appsOn = process.env.FORGE_APPS !== "0"; | |
| 62 | const apps = appsOn | |
| 63 | ? new AppsRuntime(app, { | |
| 64 | domain: process.env.FORGE_APP_DOMAIN ?? publicUrl.hostname, | |
| 65 | scheme: publicUrl.protocol === "https:" ? "https" : "http", | |
| 66 | publicPort: | |
| 67 | publicUrl.port === "" | |
| 68 | ? publicUrl.protocol === "https:" | |
| 69 | ? 443 | |
| 70 | : 80 | |
| 71 | : Number(publicUrl.port), | |
| 72 | forgeUrl, | |
| 73 | concurrency: positive(process.env.FORGE_APPS_CONCURRENCY, 2), | |
| 74 | }) | |
| 75 | : undefined; | |
| 76 | await apps?.start(); | |
| 77 | const crew = appsOn | |
| 78 | ? await startCrew(app, { | |
| 79 | forgeUrl, | |
| 80 | localUrl: servers.url, | |
| 81 | binary: process.env.FORGE_CLAUDE ?? "claude", | |
| 82 | concurrency: positive(process.env.FORGE_CREW_CONCURRENCY, 2), | |
| 83 | budgetUsd: Number(process.env.FORGE_CREW_BUDGET_USD) || 5, | |
| 84 | runTimeoutMs: positive(process.env.FORGE_CREW_TIMEOUT_MIN, 45) * 60_000, | |
| 85 | allowHosts: (process.env.FORGE_CREW_ALLOW_HOSTS ?? "") | |
| 86 | .split(",") | |
| 87 | .map((h) => h.trim()) | |
| 88 | .filter((h) => h !== ""), | |
| 89 | ...(process.env.FORGE_CLAUDE_CONFIG_DIR === undefined | |
| 90 | ? {} | |
| 91 | : { configDir: process.env.FORGE_CLAUDE_CONFIG_DIR }), | |
| 92 | }) | |
| 93 | : undefined; | |
| 94 | ||
| 49 | 95 | // The webhook delivery worker: poll the outbox for due deliveries. The event |
| 50 | 96 | // row is the source of truth, so a missed tick only delays delivery, never |
| 51 | 97 | // loses it. runDelivery never throws into the timer. |
| 52 | // Expired sessions ride the same tick: a cheap DELETE, never worth a worker. | |
| 98 | // Expired sessions and run tokens ride the same tick: cheap DELETEs, never | |
| 99 | // worth a worker. | |
| 53 | 100 | const deliveryTimer = setInterval(() => { |
| 54 | 101 | runDelivery(app.db, app.log); |
| 55 | 102 | pruneSessions(app.db); |
| 103 | pruneTokens(app.db); | |
| 56 | 104 | }, 5_000); |
| 57 | 105 | runDelivery(app.db, app.log); // sweep anything queued while we were down |
| 58 | 106 | pruneSessions(app.db); |
| 107 | pruneTokens(app.db); | |
| 108 | ||
| 109 | function positive(value: string | undefined, fallback: number): number { | |
| 110 | const parsed = Number(value); | |
| 111 | return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; | |
| 112 | } | |
| 59 | 113 | |
| 60 | 114 | // The origin sync worker, on its own slower tick. Mirrors are polled, not |
| 61 | 115 | // pushed to: an origin is due every ten minutes by default, and the tick only |
| @@ -65,6 +119,18 @@ const syncTimer = setInterval(() => runSync(app), 60_000); | ||
| 65 | 119 | runSync(app); // one pass on boot: a forge that was down has fetching to do |
| 66 | 120 | |
| 67 | 121 | app.log.info("forge is up", { url: servers.url, data: app.config.dataDir }); |
| 122 | if (apps) | |
| 123 | app.log.info("apps are up", { | |
| 124 | domain: apps.options.domain, | |
| 125 | example: apps.urlFor( | |
| 126 | { slug: "hello" } as Parameters<typeof apps.urlFor>[0], | |
| 127 | "production", | |
| 128 | ), | |
| 129 | }); | |
| 130 | if (crew) | |
| 131 | app.log.info(crew.ready ? "crew is ready" : "crew is not ready", { | |
| 132 | detail: crew.detail, | |
| 133 | }); | |
| 68 | 134 | if (ssh) |
| 69 | 135 | // The host key fingerprint on boot is what an operator compares against the |
| 70 | 136 | // one their client prints on first connect. |
| @@ -80,8 +146,14 @@ function shutdown(): void { | ||
| 80 | 146 | app.log.info("forge shutting down"); |
| 81 | 147 | clearInterval(deliveryTimer); |
| 82 | 148 | clearInterval(syncTimer); |
| 83 | ssh?.stop(); | |
| 84 | servers.stop(); | |
| 85 | app.close(); | |
| 86 | process.exit(0); | |
| 149 | // Apps and agents are child processes: stop them before the listeners, | |
| 150 | // so nothing is left running with nowhere to be reached. | |
| 151 | Promise.all([crew?.stop(), apps?.stop()]) | |
| 152 | .catch(() => {}) | |
| 153 | .finally(() => { | |
| 154 | ssh?.stop(); | |
| 155 | servers.stop(); | |
| 156 | app.close(); | |
| 157 | process.exit(0); | |
| 158 | }); | |
| 87 | 159 | } |
src/runs/log.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | /** | |
| 2 | * A run's log: one file, appended as the run goes, tailed live by whoever | |
| 3 | * is watching. The file is the truth — a surface that arrives late reads | |
| 4 | * it — and the in-process listeners are the optimisation that makes a log | |
| 5 | * line appear the moment it is written, on both surfaces, without a poll. | |
| 6 | * | |
| 7 | * Every line passes through `redactSecrets` on the way in. A run's token | |
| 8 | * is in its environment, a curl in the agent's transcript may echo a | |
| 9 | * header, and a log page is read by people the token was never meant for. | |
| 10 | */ | |
| 11 | ||
| 12 | import { | |
| 13 | appendFileSync, | |
| 14 | existsSync, | |
| 15 | mkdirSync, | |
| 16 | statSync, | |
| 17 | openSync, | |
| 18 | readSync, | |
| 19 | closeSync, | |
| 20 | } from "node:fs"; | |
| 21 | import { join } from "node:path"; | |
| 22 | import { redactSecrets } from "../kernel/log.ts"; | |
| 23 | ||
| 24 | export const runsRoot = (dataDir: string): string => join(dataDir, "runs"); | |
| 25 | ||
| 26 | export const runLogPath = (dataDir: string, runId: string): string => | |
| 27 | join(runsRoot(dataDir), `${runId}.log`); | |
| 28 | ||
| 29 | export type LogListener = (line: string) => void; | |
| 30 | ||
| 31 | const listeners = new Map<string, Set<LogListener>>(); | |
| 32 | ||
| 33 | /** A line without its newline; a trailing newline is added on write. */ | |
| 34 | const MAX_LINE = 16 * 1024; | |
| 35 | ||
| 36 | /** Append one line. Long lines are cut, never wrapped: a log is not a pager. */ | |
| 37 | export function appendRunLog( | |
| 38 | dataDir: string, | |
| 39 | runId: string, | |
| 40 | line: string, | |
| 41 | ): void { | |
| 42 | const clean = redactSecrets(line.replace(/\r?\n/g, " ")).slice(0, MAX_LINE); | |
| 43 | mkdirSync(runsRoot(dataDir), { recursive: true }); | |
| 44 | appendFileSync(runLogPath(dataDir, runId), `${clean}\n`); | |
| 45 | const watching = listeners.get(runId); | |
| 46 | if (watching === undefined) return; | |
| 47 | for (const listener of watching) { | |
| 48 | try { | |
| 49 | listener(clean); | |
| 50 | } catch { | |
| 51 | // A watcher's failure is its own; the run keeps logging. | |
| 52 | } | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | /** Every line so far, bounded to the last `maxBytes` of the file. */ | |
| 57 | export function readRunLog( | |
| 58 | dataDir: string, | |
| 59 | runId: string, | |
| 60 | maxBytes = 512 * 1024, | |
| 61 | ): { lines: string[]; truncated: boolean } { | |
| 62 | const path = runLogPath(dataDir, runId); | |
| 63 | if (!existsSync(path)) return { lines: [], truncated: false }; | |
| 64 | const size = statSync(path).size; | |
| 65 | const start = Math.max(0, size - maxBytes); | |
| 66 | const fd = openSync(path, "r"); | |
| 67 | try { | |
| 68 | const buffer = Buffer.alloc(size - start); | |
| 69 | readSync(fd, buffer, 0, buffer.length, start); | |
| 70 | let text = buffer.toString("utf8"); | |
| 71 | // A cut in the middle of a line leaves a partial first line; drop it. | |
| 72 | if (start > 0) text = text.slice(text.indexOf("\n") + 1); | |
| 73 | const lines = text.split("\n"); | |
| 74 | if (lines[lines.length - 1] === "") lines.pop(); | |
| 75 | return { lines, truncated: start > 0 }; | |
| 76 | } finally { | |
| 77 | closeSync(fd); | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Follow a run's log. Released by `signal` — a TUI screen's abort or an SSE | |
| 83 | * connection closing — so a dead watcher never holds a set entry. | |
| 84 | */ | |
| 85 | export function subscribeRunLog( | |
| 86 | runId: string, | |
| 87 | listener: LogListener, | |
| 88 | signal: AbortSignal, | |
| 89 | ): void { | |
| 90 | let watching = listeners.get(runId); | |
| 91 | if (watching === undefined) { | |
| 92 | watching = new Set(); | |
| 93 | listeners.set(runId, watching); | |
| 94 | } | |
| 95 | watching.add(listener); | |
| 96 | const release = (): void => { | |
| 97 | const set = listeners.get(runId); | |
| 98 | if (set === undefined) return; | |
| 99 | set.delete(listener); | |
| 100 | if (set.size === 0) listeners.delete(runId); | |
| 101 | }; | |
| 102 | if (signal.aborted) release(); | |
| 103 | else signal.addEventListener("abort", release, { once: true }); | |
| 104 | } | |
| 105 | ||
| 106 | /** How many runs currently have a listener set — a test seam for leaks. */ | |
| 107 | export const runLogListenerCount = (): number => listeners.size; |
src/tui/app/app.ts +16 −1
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { defect } from "../../kernel/error.ts"; |
| 14 | import { MOUSE_OFF, MOUSE_ON } from "../term/ansi.ts"; | |
| 14 | 15 | import type { InputEvent, KeyEvent, MouseEvent } from "../term/input.ts"; |
| 15 | 16 | import { InputDecoder } from "../term/input.ts"; |
| 16 | 17 | import { Renderer, type Cursor } from "../term/render.ts"; |
| @@ -146,6 +147,12 @@ export interface Ctx { | ||
| 146 | 147 | * say it was sent and never that it arrived. |
| 147 | 148 | */ |
| 148 | 149 | copy(text: string): void; |
| 150 | /** | |
| 151 | * Mouse reporting on or off. Off is what lets the terminal's own | |
| 152 | * selection work — a link dialog turns it off while it is open so a URL | |
| 153 | * can be dragged over and copied the ordinary way. | |
| 154 | */ | |
| 155 | mouse(on: boolean): void; | |
| 149 | 156 | /** |
| 150 | 157 | * Open a URL in a browser on the machine this terminal runs on. False when |
| 151 | 158 | * there is no such machine to reach — every SSH session — which a screen |
| @@ -371,6 +378,10 @@ export class App { | ||
| 371 | 378 | setTheme: (name) => app.applyTheme(name), |
| 372 | 379 | quit: () => app.stop(), |
| 373 | 380 | copy: (text) => copyToTerminal(app.opts.terminal, text), |
| 381 | mouse: (on) => | |
| 382 | app.opts.terminal.write( | |
| 383 | new TextEncoder().encode(on ? MOUSE_ON : MOUSE_OFF), | |
| 384 | ), | |
| 374 | 385 | openUrl: (url) => openInBrowser(app.opts.terminal, url), |
| 375 | 386 | suspend: (run) => app.suspend(run), |
| 376 | 387 | hit: (rect, on) => { |
| @@ -882,7 +893,11 @@ export class App { | ||
| 882 | 893 | ); |
| 883 | 894 | else if (e.key === "esc" || e.key === "q" || (e.ctrl && e.key === "t")) |
| 884 | 895 | this.overlay = "none"; |
| 885 | } else if (e.key === "enter" || e.key === "esc" || (e.ctrl && e.key === "t")) | |
| 896 | } else if ( | |
| 897 | e.key === "enter" || | |
| 898 | e.key === "esc" || | |
| 899 | (e.ctrl && e.key === "t") | |
| 900 | ) | |
| 886 | 901 | this.close(); |
| 887 | 902 | else if (e.key === "q") this.close(); |
| 888 | 903 | this.invalidate(); |
src/tui/forge.ts +41 −7
| @@ -19,6 +19,8 @@ import { fileIcon, icon, type IconName } from "./ui/icons.ts"; | ||
| 19 | 19 | import type { SessionOpts, Terminal } from "./term/terminal.ts"; |
| 20 | 20 | import { Session, type TuiIdentity } from "./session.ts"; |
| 21 | 21 | import { accountScreen } from "./screens/account.ts"; |
| 22 | import { appScreen } from "./screens/app.ts"; | |
| 23 | import { appsScreen, newAppFlow } from "./screens/apps.ts"; | |
| 22 | 24 | import { |
| 23 | 25 | ISSUE_MARK, |
| 24 | 26 | ORIGIN_MARK, |
| @@ -26,7 +28,9 @@ import { | ||
| 26 | 28 | errorText, |
| 27 | 29 | promptScreen, |
| 28 | 30 | } from "./screens/common.ts"; |
| 31 | import { crewScreen } from "./screens/crew.ts"; | |
| 29 | 32 | import { issueScreen } from "./screens/issue.ts"; |
| 33 | import { runsScreen } from "./screens/runs.ts"; | |
| 30 | 34 | import { pullScreen } from "./screens/pull.ts"; |
| 31 | 35 | import { blobScreen } from "./screens/repo.ts"; |
| 32 | 36 | import { dashboardScreen } from "./screens/dashboard.ts"; |
| @@ -70,6 +74,16 @@ const REPO_VERBS: readonly { | ||
| 70 | 74 | }, |
| 71 | 75 | { id: "new-issue", title: "New issue", open: newIssueScreen }, |
| 72 | 76 | { id: "new-pull", title: "New pull request", open: newPullScreen }, |
| 77 | { | |
| 78 | id: "runs", | |
| 79 | title: "Runs in a repository", | |
| 80 | open: (f, ctx, repo) => runsScreen(f, ctx, repo), | |
| 81 | }, | |
| 82 | { | |
| 83 | id: "app", | |
| 84 | title: "Open an app", | |
| 85 | open: (f, ctx, repo) => appScreen(f, ctx, repo), | |
| 86 | }, | |
| 73 | 87 | { id: "settings", title: "Repository settings", open: settingsScreen }, |
| 74 | 88 | ]; |
| 75 | 89 | |
| @@ -92,6 +106,12 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 92 | 106 | title: "Explore repositories", |
| 93 | 107 | run: () => ctx.push(exploreScreen(session, ctx)), |
| 94 | 108 | }, |
| 109 | { | |
| 110 | id: "apps", | |
| 111 | title: "Apps", | |
| 112 | hint: "what is running", | |
| 113 | run: () => ctx.push(appsScreen(session, ctx)), | |
| 114 | }, | |
| 95 | 115 | { |
| 96 | 116 | id: "profile", |
| 97 | 117 | title: "Open a profile", |
| @@ -144,6 +164,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 144 | 164 | ); |
| 145 | 165 | if (session.signedIn) |
| 146 | 166 | commands.push( |
| 167 | { | |
| 168 | id: "new-app", | |
| 169 | title: "New app", | |
| 170 | hint: "a repository that runs", | |
| 171 | // The flow is modals over whatever is on the stack; the palette | |
| 172 | // closed already, so they draw over the screen beneath it. | |
| 173 | run: () => newAppFlow(session, ctx, blank("New app")), | |
| 174 | }, | |
| 147 | 175 | { |
| 148 | 176 | id: "account", |
| 149 | 177 | title: "Account settings", |
| @@ -156,6 +184,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 156 | 184 | run: () => ctx.push(originsScreen(session, ctx)), |
| 157 | 185 | }, |
| 158 | 186 | ); |
| 187 | if (session.user?.isAdmin === true) | |
| 188 | commands.push({ | |
| 189 | id: "crew", | |
| 190 | title: "Crew", | |
| 191 | hint: "the agents on this forge", | |
| 192 | run: () => ctx.push(crewScreen(session, ctx)), | |
| 193 | }); | |
| 159 | 194 | commands.push({ |
| 160 | 195 | id: "quit", |
| 161 | 196 | title: "Quit", |
| @@ -320,6 +355,11 @@ function countsHint( | ||
| 320 | 355 | .join(" "); |
| 321 | 356 | } |
| 322 | 357 | |
| 358 | /** A screen that draws nothing — the parent a palette-launched modal has. */ | |
| 359 | function blank(title: string): Screen { | |
| 360 | return { title, keys: [], render: () => undefined, key: () => false }; | |
| 361 | } | |
| 362 | ||
| 323 | 363 | /** A prompt with no parent to draw behind it — the palette closed already. */ |
| 324 | 364 | function askFor( |
| 325 | 365 | ctx: Ctx, |
| @@ -327,13 +367,7 @@ function askFor( | ||
| 327 | 367 | label: string, |
| 328 | 368 | onSubmit: (value: string) => string | undefined, |
| 329 | 369 | ): Screen { |
| 330 | const blank: Screen = { | |
| 331 | title, | |
| 332 | keys: [], | |
| 333 | render: () => undefined, | |
| 334 | key: () => false, | |
| 335 | }; | |
| 336 | return promptScreen(ctx, blank, { title, label, onSubmit }); | |
| 370 | return promptScreen(ctx, blank(title), { title, label, onSubmit }); | |
| 337 | 371 | } |
| 338 | 372 | |
| 339 | 373 | export interface ForgeAppOptions extends SessionOpts { |
src/tui/screens/account.ts +12 −1
| @@ -38,6 +38,7 @@ import { | ||
| 38 | 38 | ago, |
| 39 | 39 | runAsync, |
| 40 | 40 | } from "./common.ts"; |
| 41 | import { crewScreen } from "./crew.ts"; | |
| 41 | 42 | import { originsScreen } from "./origins.ts"; |
| 42 | 43 | |
| 43 | 44 | const TABS = ["Profile", "Tokens", "SSH keys"] as const; |
| @@ -58,6 +59,9 @@ export function accountScreen(f: Session, ctx: Ctx): Screen { | ||
| 58 | 59 | const screen: Screen = { |
| 59 | 60 | title: "Account", |
| 60 | 61 | get keys() { |
| 62 | // The crew is a forge admin's to manage, so the key is only there for | |
| 63 | // one — a non-admin would land on a screen that says no to everything. | |
| 64 | const crew = f.user?.isAdmin === true ? [binding(["c"], "crew")] : []; | |
| 61 | 65 | if (tab === 0) |
| 62 | 66 | return [ |
| 63 | 67 | binding(["tab"], "next tab"), |
| @@ -65,6 +69,7 @@ export function accountScreen(f: Session, ctx: Ctx): Screen { | ||
| 65 | 69 | binding(["e"], "email"), |
| 66 | 70 | binding(["p"], "password"), |
| 67 | 71 | binding(["o"], "origins"), |
| 72 | ...crew, | |
| 68 | 73 | ]; |
| 69 | 74 | if (tab === 1) |
| 70 | 75 | return [ |
| @@ -72,12 +77,14 @@ export function accountScreen(f: Session, ctx: Ctx): Screen { | ||
| 72 | 77 | binding(["n"], "new token"), |
| 73 | 78 | binding(["D"], "revoke"), |
| 74 | 79 | binding(["o"], "origins"), |
| 80 | ...crew, | |
| 75 | 81 | ]; |
| 76 | 82 | return [ |
| 77 | 83 | binding(["tab"], "next tab"), |
| 78 | 84 | binding(["n"], "add key"), |
| 79 | 85 | binding(["D"], "delete key"), |
| 80 | 86 | binding(["o"], "origins"), |
| 87 | ...crew, | |
| 81 | 88 | ]; |
| 82 | 89 | }, |
| 83 | 90 | load() { |
| @@ -97,7 +104,7 @@ export function accountScreen(f: Session, ctx: Ctx): Screen { | ||
| 97 | 104 | ["username", f.user.username], |
| 98 | 105 | ["display name", f.user.displayName || "(none)"], |
| 99 | 106 | ["email", f.user.email], |
| 100 | ["role", f.user.isAdmin ? "site admin" : "user"], | |
| 107 | ["role", f.user.isAdmin ? "site admin — c manages the crew" : "user"], | |
| 101 | 108 | ["joined", ago(f.user.createdAt)], |
| 102 | 109 | ]); |
| 103 | 110 | return; |
| @@ -147,6 +154,10 @@ export function accountScreen(f: Session, ctx: Ctx): Screen { | ||
| 147 | 154 | ctx.push(originsScreen(f, ctx)); |
| 148 | 155 | return true; |
| 149 | 156 | } |
| 157 | if (e.key === "c" && f.user?.isAdmin === true) { | |
| 158 | ctx.push(crewScreen(f, ctx)); | |
| 159 | return true; | |
| 160 | } | |
| 150 | 161 | if (tab === 0) return profileKey(e); |
| 151 | 162 | if (tab === 1) return tokenKey(e); |
| 152 | 163 | return keyKey(e); |
src/tui/screens/activity.ts +26 −1
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { ForgeEvent } from "../../core/events.ts"; |
| 11 | import { eventVerb, isTransientEvent } from "../../design/activity.ts"; | |
| 11 | 12 | |
| 12 | 13 | export interface EventSubject { |
| 13 | 14 | /** The actor's username, or a stand-in when the actor is gone. */ |
| @@ -37,9 +38,20 @@ export interface FeedLine { | ||
| 37 | 38 | * not something anyone reads a dashboard to find out. |
| 38 | 39 | */ |
| 39 | 40 | function isNoise(kind: string): boolean { |
| 40 | return kind.startsWith("user.") || kind.startsWith("token."); | |
| 41 | return ( | |
| 42 | kind.startsWith("user.") || | |
| 43 | kind.startsWith("token.") || | |
| 44 | isTransientEvent(kind) | |
| 45 | ); | |
| 41 | 46 | } |
| 42 | 47 | |
| 48 | /** Kinds whose words live in the shared vocabulary (docs/APPS.md events). */ | |
| 49 | const shared = (kind: string): boolean => | |
| 50 | kind.startsWith("run.") || | |
| 51 | kind.startsWith("app.") || | |
| 52 | kind.startsWith("crew.") || | |
| 53 | kind.endsWith(".assigned"); | |
| 54 | ||
| 43 | 55 | const THING: Record<string, string> = { issue: "issue", pull: "pull" }; |
| 44 | 56 | |
| 45 | 57 | function short(repo: string | undefined): string { |
| @@ -89,6 +101,19 @@ export function describeEvent( | ||
| 89 | 101 | .filter((part) => part !== "") |
| 90 | 102 | .join(" · "); |
| 91 | 103 | |
| 104 | // Deploys, crew runs, assignments and crew changes are named once, for | |
| 105 | // both surfaces; the summary a run carries is the second line. | |
| 106 | if (shared(event.kind)) { | |
| 107 | const summary = payload["summary"]; | |
| 108 | const trail = | |
| 109 | typeof summary === "string" && summary !== "" ? summary : object; | |
| 110 | return { | |
| 111 | headline: `${who} ${eventVerb(event)}`, | |
| 112 | detail: [subject.repo ?? "", trail].filter((p) => p !== "").join(" · "), | |
| 113 | subject: trail, | |
| 114 | }; | |
| 115 | } | |
| 116 | ||
| 92 | 117 | const [concept, verb] = split(event.kind); |
| 93 | 118 | const noun = THING[concept]; |
| 94 | 119 | if (noun !== undefined && ref !== "") { |
src/tui/screens/app.ts added +330
| @@ -0,0 +1,330 @@ | ||
| 1 | /** | |
| 2 | * One app: its environments, what each is doing, and the four things a | |
| 3 | * person does about one — open it, read its log, deploy it again, stop it. | |
| 4 | * | |
| 5 | * Production is the first row and a preview per open pull follows, in pull | |
| 6 | * order. Every state is a word beside a dot, and a failed environment | |
| 7 | * carries its error on the row and in full in the panel beneath, because | |
| 8 | * "failed" alone is the one thing this screen must never say. | |
| 9 | * | |
| 10 | * Opening a private app goes through `openEnvUrl`, which returns a URL | |
| 11 | * carrying a signed grant; the screen hands that to `openLink`, so over SSH | |
| 12 | * it lands on the reader's clipboard with words that say so. | |
| 13 | */ | |
| 14 | ||
| 15 | import { | |
| 16 | appView, | |
| 17 | openEnvUrl, | |
| 18 | redeployEnv, | |
| 19 | sshCommandFor, | |
| 20 | stopEnv, | |
| 21 | type AppView, | |
| 22 | } from "../../actions/apps.ts"; | |
| 23 | import type { EnvStatus } from "../../apps/runtime.ts"; | |
| 24 | import { envPull } from "../../apps/data.ts"; | |
| 25 | import { getIssuesByRef } from "../../core/issues.ts"; | |
| 26 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 27 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 28 | import { binding } from "../app/keys.ts"; | |
| 29 | import { ITALIC, style } from "../term/style.ts"; | |
| 30 | import { truncate } from "../term/width.ts"; | |
| 31 | import { fill, len, split } from "../ui/layout.ts"; | |
| 32 | import { card, emptyState } from "../ui/widgets.ts"; | |
| 33 | import type { Session } from "../session.ts"; | |
| 34 | import { | |
| 35 | ListState, | |
| 36 | ago, | |
| 37 | confirmScreen, | |
| 38 | drawFacts, | |
| 39 | drawList, | |
| 40 | errorText, | |
| 41 | hitList, | |
| 42 | live, | |
| 43 | metaOf, | |
| 44 | openLink, | |
| 45 | runAsync, | |
| 46 | type ListRow, | |
| 47 | } from "./common.ts"; | |
| 48 | import { APP_EVENTS, envTone } from "./apps.ts"; | |
| 49 | import { runScreen } from "./run.ts"; | |
| 50 | import { envLabel, runsScreen } from "./runs.ts"; | |
| 51 | import { local, whereRows, whereStrip } from "./where.ts"; | |
| 52 | ||
| 53 | /** The panel under the list: state, commit, url, since, run, error. */ | |
| 54 | const PANEL = 8; | |
| 55 | ||
| 56 | export function appScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 57 | let view: AppView | undefined; | |
| 58 | let titles = new Map<string, { title: string }>(); | |
| 59 | let failed: string | undefined; | |
| 60 | const state = new ListState(); | |
| 61 | const src = local(f, repo); | |
| 62 | const mayWrite = (): boolean => canWrite(f.db, repo, f.user); | |
| 63 | ||
| 64 | const reload = (): void => { | |
| 65 | const read = appView(f.app, f.user, repo); | |
| 66 | if (!read.ok) { | |
| 67 | failed = | |
| 68 | read.error === "not_found" | |
| 69 | ? "this repository is not an app" | |
| 70 | : errorText(read.error); | |
| 71 | ctx.invalidate(); | |
| 72 | return; | |
| 73 | } | |
| 74 | failed = undefined; | |
| 75 | view = read.value; | |
| 76 | // The pull behind each preview, named — one grouped read for the page. | |
| 77 | titles = getIssuesByRef( | |
| 78 | f.db, | |
| 79 | view.environments.flatMap((env) => { | |
| 80 | const number = envPull(env.env); | |
| 81 | return number === undefined ? [] : [{ repoId: repo.id, number }]; | |
| 82 | }), | |
| 83 | ); | |
| 84 | ctx.invalidate(); | |
| 85 | }; | |
| 86 | ||
| 87 | const envs = (): EnvStatus[] => view?.environments ?? []; | |
| 88 | const selected = (): EnvStatus | undefined => envs()[state.index]; | |
| 89 | ||
| 90 | const nameOf = (env: EnvStatus): string => { | |
| 91 | const number = envPull(env.env); | |
| 92 | if (number === undefined) return "production"; | |
| 93 | const title = titles.get(`${repo.id}#${number}`)?.title; | |
| 94 | return title === undefined ? `pull #${number}` : `pull #${number} ${title}`; | |
| 95 | }; | |
| 96 | ||
| 97 | /** The one line that says what an environment is doing, in words. */ | |
| 98 | const stateText = (env: EnvStatus): string => { | |
| 99 | switch (env.state) { | |
| 100 | case "running": | |
| 101 | return env.since === undefined | |
| 102 | ? "running" | |
| 103 | : `running since ${ago(env.since)}`; | |
| 104 | case "deploying": | |
| 105 | return `deploying ${env.commitOid.slice(0, 7)}`; | |
| 106 | case "failed": | |
| 107 | return `failed: ${env.error ?? "the deploy did not finish"}`; | |
| 108 | case "stopped": | |
| 109 | return "stopped"; | |
| 110 | } | |
| 111 | }; | |
| 112 | ||
| 113 | const open = (env: EnvStatus | undefined): void => { | |
| 114 | if (env === undefined) return; | |
| 115 | const url = openEnvUrl(f.app, f.user, repo, env.env); | |
| 116 | if (!url.ok) { | |
| 117 | ctx.toast(errorText(url.error), "danger"); | |
| 118 | return; | |
| 119 | } | |
| 120 | if (env.state !== "running") | |
| 121 | ctx.toast( | |
| 122 | `${envLabel(env.env)} is ${env.state}; the page will say so`, | |
| 123 | "attn", | |
| 124 | ); | |
| 125 | openLink(ctx, url.value); | |
| 126 | }; | |
| 127 | ||
| 128 | const openLog = (env: EnvStatus | undefined): void => { | |
| 129 | if (env === undefined) return; | |
| 130 | if (env.runId === undefined) { | |
| 131 | ctx.toast(`${envLabel(env.env)} has no run yet`, "attn"); | |
| 132 | return; | |
| 133 | } | |
| 134 | ctx.push(runScreen(f, ctx, repo, env.runId)); | |
| 135 | }; | |
| 136 | ||
| 137 | const redeploy = (env: EnvStatus | undefined): void => { | |
| 138 | if (env === undefined) return; | |
| 139 | const label = envLabel(env.env); | |
| 140 | runAsync(ctx, async () => { | |
| 141 | ctx.toast(`redeploying ${label}…`); | |
| 142 | const queued = await redeployEnv(f.app, f.user, repo, env.env); | |
| 143 | if (!queued.ok) { | |
| 144 | ctx.toast(errorText(queued.error), "danger"); | |
| 145 | return; | |
| 146 | } | |
| 147 | ctx.toast( | |
| 148 | queued.value === undefined | |
| 149 | ? `${label}: nothing to do` | |
| 150 | : `${label}: deploy queued — enter opens its log`, | |
| 151 | "success", | |
| 152 | ); | |
| 153 | reload(); | |
| 154 | }); | |
| 155 | }; | |
| 156 | ||
| 157 | const stop = (env: EnvStatus | undefined): void => { | |
| 158 | if (env === undefined) return; | |
| 159 | const label = envLabel(env.env); | |
| 160 | ctx.push( | |
| 161 | confirmScreen(ctx, screen, { | |
| 162 | title: `Stop ${label}`, | |
| 163 | body: [ | |
| 164 | `${label} goes offline until it is deployed again.`, | |
| 165 | "Its data is kept; r brings it back.", | |
| 166 | ], | |
| 167 | actions: [ | |
| 168 | { label: "Keep it running", variant: "ghost" }, | |
| 169 | { label: "Stop", variant: "danger" }, | |
| 170 | ], | |
| 171 | onChoose: (choice) => { | |
| 172 | if (choice !== 1) return; | |
| 173 | runAsync(ctx, async () => { | |
| 174 | ctx.toast(`stopping ${label}…`); | |
| 175 | const stopped = await stopEnv(f.app, f.user, repo, env.env); | |
| 176 | if (!stopped.ok) ctx.toast(errorText(stopped.error), "danger"); | |
| 177 | else ctx.toast(`${label} stopped`, "success"); | |
| 178 | reload(); | |
| 179 | }); | |
| 180 | }, | |
| 181 | }), | |
| 182 | ); | |
| 183 | }; | |
| 184 | ||
| 185 | const screen: Screen = { | |
| 186 | title: `${repo.ownerName}/${repo.name} · app`, | |
| 187 | get keys() { | |
| 188 | const writer = mayWrite(); | |
| 189 | return [ | |
| 190 | binding(["j", "k"], "move"), | |
| 191 | binding(["o"], "open"), | |
| 192 | binding(["enter"], "log"), | |
| 193 | binding(["r"], "redeploy", { disabled: !writer }), | |
| 194 | binding(["s"], "stop", { disabled: !writer }), | |
| 195 | binding(["l"], "runs"), | |
| 196 | binding(["R"], "refresh"), | |
| 197 | ]; | |
| 198 | }, | |
| 199 | load(signal) { | |
| 200 | live(f, signal, { repoId: repo.id, kinds: APP_EVENTS }, reload); | |
| 201 | reload(); | |
| 202 | return Promise.resolve(); | |
| 203 | }, | |
| 204 | render(area, s) { | |
| 205 | const c = ctx.theme.c; | |
| 206 | const [strip, head, list, panel] = split(area, "v", [ | |
| 207 | len(whereRows(src)), | |
| 208 | len(2), | |
| 209 | fill(1), | |
| 210 | len(Math.min(PANEL, Math.max(0, area.h - whereRows(src) - 6))), | |
| 211 | ]); | |
| 212 | whereStrip(s, strip!, c, f, src, ctx.icons); | |
| 213 | if (failed !== undefined) { | |
| 214 | emptyState(s, list!, c, { title: failed }); | |
| 215 | return; | |
| 216 | } | |
| 217 | if (view === undefined) return; | |
| 218 | const right = head!.x + head!.w; | |
| 219 | s.text( | |
| 220 | head!.x, | |
| 221 | head!.y, | |
| 222 | truncate( | |
| 223 | metaOf( | |
| 224 | `slug ${view.app.slug}`, | |
| 225 | `${view.app.access} app`, | |
| 226 | view.app.previewData === "copy" | |
| 227 | ? "previews start from a copy of production's data" | |
| 228 | : "previews start empty", | |
| 229 | ), | |
| 230 | head!.w, | |
| 231 | ), | |
| 232 | style(c.mutedFg, c.bg), | |
| 233 | right, | |
| 234 | ); | |
| 235 | if (!view.serving) | |
| 236 | s.text( | |
| 237 | head!.x, | |
| 238 | head!.y + 1, | |
| 239 | truncate(errorText("no_runtime"), head!.w), | |
| 240 | style(c.attn, c.bg, ITALIC), | |
| 241 | right, | |
| 242 | ); | |
| 243 | const rows = envs(); | |
| 244 | if (rows.length === 0) { | |
| 245 | emptyState(s, list!, c, { | |
| 246 | title: view.serving ? "Nothing running yet" : "Nothing runs", | |
| 247 | body: view.serving | |
| 248 | ? "Production starts from the default branch once it has a commit." | |
| 249 | : errorText("no_runtime"), | |
| 250 | }); | |
| 251 | return; | |
| 252 | } | |
| 253 | drawList( | |
| 254 | s, | |
| 255 | list!, | |
| 256 | c, | |
| 257 | state, | |
| 258 | rows.map((env): ListRow => { | |
| 259 | const tone = envTone(env.state); | |
| 260 | return { | |
| 261 | title: nameOf(env), | |
| 262 | ...(tone === undefined ? { hollow: true } : { tone }), | |
| 263 | secondary: stateText(env), | |
| 264 | meta: metaOf( | |
| 265 | env.commitOid === "" ? "" : env.commitOid.slice(0, 7), | |
| 266 | env.since === undefined ? "" : ago(env.since), | |
| 267 | ), | |
| 268 | }; | |
| 269 | }), | |
| 270 | ); | |
| 271 | hitList(ctx, list!, state, rows.length, { | |
| 272 | open: (index) => openLog(rows[index]), | |
| 273 | }); | |
| 274 | const env = selected(); | |
| 275 | if (env === undefined || panel!.h < 3) return; | |
| 276 | const inner = card(s, panel!, c, { | |
| 277 | title: nameOf(env), | |
| 278 | tone: env.state === "failed" ? "danger" : "default", | |
| 279 | }); | |
| 280 | drawFacts( | |
| 281 | s, | |
| 282 | inner, | |
| 283 | c, | |
| 284 | [ | |
| 285 | ["state", stateText(env)], | |
| 286 | ["commit", env.commitOid === "" ? "—" : env.commitOid], | |
| 287 | ["url", env.url], | |
| 288 | ...(sshCommandFor(f.app, view.app, env.env) === undefined | |
| 289 | ? [] | |
| 290 | : [ | |
| 291 | ["ssh", sshCommandFor(f.app, view.app, env.env) ?? ""] as const, | |
| 292 | ]), | |
| 293 | ["since", env.since === undefined ? "—" : ago(env.since)], | |
| 294 | ["run", env.runId === undefined ? "none yet" : "enter opens the log"], | |
| 295 | ...(env.error === undefined ? [] : [["error", env.error] as const]), | |
| 296 | ], | |
| 297 | c.card, | |
| 298 | ); | |
| 299 | }, | |
| 300 | key(e) { | |
| 301 | if (state.key(e, envs().length)) return true; | |
| 302 | if (e.key === "enter") { | |
| 303 | openLog(selected()); | |
| 304 | return true; | |
| 305 | } | |
| 306 | if (e.key === "o") { | |
| 307 | open(selected()); | |
| 308 | return true; | |
| 309 | } | |
| 310 | if (e.key === "r" && mayWrite()) { | |
| 311 | redeploy(selected()); | |
| 312 | return true; | |
| 313 | } | |
| 314 | if (e.key === "s" && mayWrite()) { | |
| 315 | stop(selected()); | |
| 316 | return true; | |
| 317 | } | |
| 318 | if (e.key === "l") { | |
| 319 | ctx.push(runsScreen(f, ctx, repo)); | |
| 320 | return true; | |
| 321 | } | |
| 322 | if (e.key === "R") { | |
| 323 | reload(); | |
| 324 | return true; | |
| 325 | } | |
| 326 | return false; | |
| 327 | }, | |
| 328 | }; | |
| 329 | return screen; | |
| 330 | } |
src/tui/screens/apps.ts added +355
| @@ -0,0 +1,355 @@ | ||
| 1 | /** | |
| 2 | * Every app this session can see, and the way to make a new one. | |
| 3 | * | |
| 4 | * An app row is a repository row with one more fact: whether it is running. | |
| 5 | * That fact is read from the runtime's in-memory picture, never from a row | |
| 6 | * (a process is not state — docs/APPS.md), and the app records for the | |
| 7 | * whole page come from one grouped query, so the list costs the same at | |
| 8 | * two apps as at two hundred. | |
| 9 | * | |
| 10 | * `n` is the golden path's first step as a flow of small modals: a name, an | |
| 11 | * optional sentence, who may see it, who works on it. Each is one screen | |
| 12 | * that does one thing, and the whole is a single `newApp` call at the end. | |
| 13 | */ | |
| 14 | ||
| 15 | import { newApp } from "../../actions/apps.ts"; | |
| 16 | import { listAllAgents } from "../../actions/crew.ts"; | |
| 17 | import type { Agent } from "../../core/agents.ts"; | |
| 18 | import { appsForRepos, type AppRecord } from "../../core/apps.ts"; | |
| 19 | import { listReposVisibleTo, type Repo } from "../../core/repos.ts"; | |
| 20 | import { | |
| 21 | appsRuntime, | |
| 22 | type EnvState, | |
| 23 | type EnvStatus, | |
| 24 | } from "../../apps/runtime.ts"; | |
| 25 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 26 | import { binding } from "../app/keys.ts"; | |
| 27 | import { fill, len, split } from "../ui/layout.ts"; | |
| 28 | import { icon, type IconSet } from "../ui/icons.ts"; | |
| 29 | import { emptyState, type Tone } from "../ui/widgets.ts"; | |
| 30 | import type { Session } from "../session.ts"; | |
| 31 | import { | |
| 32 | Filter, | |
| 33 | ListState, | |
| 34 | drawList, | |
| 35 | errorText, | |
| 36 | hitList, | |
| 37 | later, | |
| 38 | live, | |
| 39 | metaOf, | |
| 40 | pickerScreen, | |
| 41 | plural, | |
| 42 | promptScreen, | |
| 43 | runAsync, | |
| 44 | type ListRow, | |
| 45 | } from "./common.ts"; | |
| 46 | import { appScreen } from "./app.ts"; | |
| 47 | import { repoScreen } from "./repo.ts"; | |
| 48 | import { RUN_EVENTS } from "./runs.ts"; | |
| 49 | import { local } from "./where.ts"; | |
| 50 | ||
| 51 | /** The event kinds that change what an app list or an app screen shows. */ | |
| 52 | export const APP_EVENTS: readonly string[] = [ | |
| 53 | ...RUN_EVENTS, | |
| 54 | "app.created", | |
| 55 | "app.updated", | |
| 56 | "app.deleted", | |
| 57 | "app.stopped", | |
| 58 | ]; | |
| 59 | ||
| 60 | /** The tone an environment's state wears; `stopped` wears none. */ | |
| 61 | export function envTone(state: EnvState): Tone | undefined { | |
| 62 | switch (state) { | |
| 63 | case "running": | |
| 64 | return "success"; | |
| 65 | case "deploying": | |
| 66 | return "attn"; | |
| 67 | case "failed": | |
| 68 | return "danger"; | |
| 69 | case "stopped": | |
| 70 | return undefined; | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | export interface AppItem { | |
| 75 | readonly repo: Repo; | |
| 76 | readonly app: AppRecord; | |
| 77 | /** Production's state, or undefined before the runtime has looked at it. */ | |
| 78 | readonly production: EnvStatus | undefined; | |
| 79 | readonly previews: number; | |
| 80 | readonly url: string; | |
| 81 | /** False when apps are off on this forge: nothing runs, rows still exist. */ | |
| 82 | readonly serving: boolean; | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * Every app among the repositories the session may read. Visibility is the | |
| 87 | * repository's, decided by `listReposVisibleTo`, exactly as the dashboard's | |
| 88 | * repo list decides it — an app is never more visible than its code. A | |
| 89 | * caller that already listed the repositories hands them in, so a page | |
| 90 | * that shows both never asks for the same rows (and their counts) twice. | |
| 91 | */ | |
| 92 | export function listVisibleApps( | |
| 93 | f: Session, | |
| 94 | repos: readonly Repo[] = listReposVisibleTo(f.db, f.user, { limit: 200 }), | |
| 95 | ): AppItem[] { | |
| 96 | const records = appsForRepos( | |
| 97 | f.db, | |
| 98 | repos.map((repo) => repo.id), | |
| 99 | ); | |
| 100 | const runtime = appsRuntime(f.app); | |
| 101 | const out: AppItem[] = []; | |
| 102 | for (const repo of repos) { | |
| 103 | const app = records.get(repo.id); | |
| 104 | if (app === undefined) continue; | |
| 105 | const envs = runtime === undefined ? [] : runtime.status(app); | |
| 106 | const production = envs.find((env) => env.env === "production"); | |
| 107 | out.push({ | |
| 108 | repo, | |
| 109 | app, | |
| 110 | production, | |
| 111 | previews: envs.length - (production === undefined ? 0 : 1), | |
| 112 | url: runtime === undefined ? "" : runtime.urlFor(app, "production"), | |
| 113 | serving: runtime !== undefined, | |
| 114 | }); | |
| 115 | } | |
| 116 | return out; | |
| 117 | } | |
| 118 | ||
| 119 | /** The word an app row wears for its production state. */ | |
| 120 | export function appStateWord(item: AppItem): string { | |
| 121 | if (!item.serving) return "off"; | |
| 122 | if (item.production === undefined) return "not started"; | |
| 123 | return item.production.state; | |
| 124 | } | |
| 125 | ||
| 126 | /** An app as a list row — the dashboard pane and the apps screen share it. */ | |
| 127 | export function appRow(item: AppItem, set: IconSet): ListRow { | |
| 128 | const tone = | |
| 129 | item.serving && item.production !== undefined | |
| 130 | ? envTone(item.production.state) | |
| 131 | : undefined; | |
| 132 | return { | |
| 133 | title: `${set === "nerd" ? `${icon("repo", set)} ` : ""}${item.repo.ownerName}/${item.repo.name}`, | |
| 134 | ...(tone === undefined ? { hollow: true } : { tone }), | |
| 135 | secondary: item.url, | |
| 136 | meta: metaOf( | |
| 137 | appStateWord(item), | |
| 138 | item.previews > 0 ? plural(item.previews, "preview") : "", | |
| 139 | ), | |
| 140 | }; | |
| 141 | } | |
| 142 | ||
| 143 | export function appsScreen(f: Session, ctx: Ctx): Screen { | |
| 144 | let apps: AppItem[] = []; | |
| 145 | const state = new ListState(); | |
| 146 | const filter = new Filter(); | |
| 147 | ||
| 148 | const reload = (): void => { | |
| 149 | apps = listVisibleApps(f); | |
| 150 | ctx.invalidate(); | |
| 151 | }; | |
| 152 | ||
| 153 | const shown = (): AppItem[] => | |
| 154 | apps.filter((item) => | |
| 155 | filter.matches(`${item.repo.ownerName}/${item.repo.name}`), | |
| 156 | ); | |
| 157 | ||
| 158 | const open = (item: AppItem | undefined): void => { | |
| 159 | if (item !== undefined) ctx.push(appScreen(f, ctx, item.repo)); | |
| 160 | }; | |
| 161 | ||
| 162 | const screen: Screen = { | |
| 163 | title: "Apps", | |
| 164 | get keys() { | |
| 165 | return [ | |
| 166 | binding(["j", "k"], "move"), | |
| 167 | binding(["enter"], "open"), | |
| 168 | binding(["n"], "new app", { disabled: !f.signedIn }), | |
| 169 | binding(["/"], "filter"), | |
| 170 | binding(["r"], "refresh"), | |
| 171 | ]; | |
| 172 | }, | |
| 173 | load(signal) { | |
| 174 | live(f, signal, { kinds: APP_EVENTS }, reload); | |
| 175 | reload(); | |
| 176 | return Promise.resolve(); | |
| 177 | }, | |
| 178 | render(area, s) { | |
| 179 | const c = ctx.theme.c; | |
| 180 | const [box, list] = split(area, "v", [len(1), fill(1)]); | |
| 181 | ctx.setCursor(filter.render(s, box!, c, "filter apps")); | |
| 182 | const rows = shown(); | |
| 183 | if (rows.length === 0) { | |
| 184 | emptyState(s, list!, c, { | |
| 185 | title: filter.on ? "Nothing matches" : "No apps yet", | |
| 186 | body: filter.on | |
| 187 | ? "" | |
| 188 | : f.signedIn | |
| 189 | ? "Press n to make one — a repository that runs." | |
| 190 | : "Sign in to make one — a repository that runs.", | |
| 191 | }); | |
| 192 | return; | |
| 193 | } | |
| 194 | drawList( | |
| 195 | s, | |
| 196 | list!, | |
| 197 | c, | |
| 198 | state, | |
| 199 | rows.map((item) => appRow(item, ctx.icons)), | |
| 200 | ); | |
| 201 | hitList(ctx, list!, state, rows.length, { | |
| 202 | open: (index) => open(rows[index]), | |
| 203 | }); | |
| 204 | }, | |
| 205 | key(e) { | |
| 206 | if (filter.key(e)) { | |
| 207 | state.clamp(shown().length); | |
| 208 | return true; | |
| 209 | } | |
| 210 | if (state.key(e, shown().length)) return true; | |
| 211 | if (e.key === "enter") { | |
| 212 | open(shown()[state.index]); | |
| 213 | return true; | |
| 214 | } | |
| 215 | if (e.key === "n" && f.signedIn) { | |
| 216 | newAppFlow(f, ctx, screen); | |
| 217 | return true; | |
| 218 | } | |
| 219 | if (e.key === "r") { | |
| 220 | reload(); | |
| 221 | return true; | |
| 222 | } | |
| 223 | return false; | |
| 224 | }, | |
| 225 | }; | |
| 226 | return screen; | |
| 227 | } | |
| 228 | ||
| 229 | /** An agent as a picker row: the bot mark where the font has one. */ | |
| 230 | export function agentPick( | |
| 231 | agent: Agent, | |
| 232 | set: IconSet, | |
| 233 | ): { id: string; label: string; hint: string } { | |
| 234 | return { | |
| 235 | id: agent.user.username, | |
| 236 | label: | |
| 237 | set === "nerd" | |
| 238 | ? `${icon("bot", set)} ${agent.user.username}` | |
| 239 | : agent.user.username, | |
| 240 | hint: metaOf("agent", agent.model === "" ? "" : agent.model), | |
| 241 | }; | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * New app, as four modals and one call. Each prompt hands its answer to the | |
| 246 | * next through the closure; nothing is created until the last one answers, | |
| 247 | * so Esc anywhere leaves the forge exactly as it was. | |
| 248 | */ | |
| 249 | export function newAppFlow(f: Session, ctx: Ctx, parent: Screen): void { | |
| 250 | const create = (input: { | |
| 251 | name: string; | |
| 252 | description: string; | |
| 253 | visibility: "public" | "private"; | |
| 254 | agent: string | undefined; | |
| 255 | }): void => { | |
| 256 | runAsync(ctx, async () => { | |
| 257 | ctx.toast(`creating ${input.name}…`); | |
| 258 | const made = await newApp(f.app, f.user, { | |
| 259 | name: input.name, | |
| 260 | visibility: input.visibility, | |
| 261 | ...(input.description === "" ? {} : { description: input.description }), | |
| 262 | ...(input.agent === undefined ? {} : { agent: input.agent }), | |
| 263 | }); | |
| 264 | if (!made.ok) { | |
| 265 | ctx.toast(errorText(made.error), "danger"); | |
| 266 | return; | |
| 267 | } | |
| 268 | const runtime = appsRuntime(f.app); | |
| 269 | const url = | |
| 270 | runtime === undefined | |
| 271 | ? undefined | |
| 272 | : runtime.urlFor(made.value.app, "production"); | |
| 273 | const where = | |
| 274 | url === undefined | |
| 275 | ? "apps are off on this forge, so nothing is running" | |
| 276 | : `starting at ${url}`; | |
| 277 | const who = | |
| 278 | input.agent === undefined | |
| 279 | ? "" | |
| 280 | : made.value.issue === undefined | |
| 281 | ? ` · ${input.agent} is on the crew` | |
| 282 | : ` · ${input.agent} is on #${made.value.issue.number}`; | |
| 283 | ctx.toast(`${input.name}: ${where}${who}`, "success"); | |
| 284 | ctx.push(repoScreen(f, ctx, local(f, made.value.repo))); | |
| 285 | }); | |
| 286 | }; | |
| 287 | ||
| 288 | const askAgent = ( | |
| 289 | input: Parameters<typeof create>[0], | |
| 290 | agents: readonly Agent[], | |
| 291 | ): void => { | |
| 292 | if (agents.length === 0) { | |
| 293 | create(input); | |
| 294 | return; | |
| 295 | } | |
| 296 | ctx.push( | |
| 297 | pickerScreen(ctx, parent, { | |
| 298 | title: "Who works on it?", | |
| 299 | items: [ | |
| 300 | { id: "", label: "nobody yet", hint: "you can add crew later" }, | |
| 301 | ...agents.map((agent) => agentPick(agent, ctx.icons)), | |
| 302 | ], | |
| 303 | onPick: ([chosen]) => | |
| 304 | create({ | |
| 305 | ...input, | |
| 306 | agent: chosen === undefined || chosen === "" ? undefined : chosen, | |
| 307 | }), | |
| 308 | }), | |
| 309 | ); | |
| 310 | }; | |
| 311 | ||
| 312 | const askVisibility = (name: string, description: string): Screen => | |
| 313 | pickerScreen(ctx, parent, { | |
| 314 | title: "Who may see it?", | |
| 315 | items: [ | |
| 316 | { id: "private", label: "private", hint: "you and the crew" }, | |
| 317 | { id: "public", label: "public", hint: "anyone" }, | |
| 318 | ], | |
| 319 | onPick: ([chosen]) => { | |
| 320 | const listed = listAllAgents(f.app, f.user); | |
| 321 | askAgent( | |
| 322 | { | |
| 323 | name, | |
| 324 | description, | |
| 325 | visibility: chosen === "public" ? "public" : "private", | |
| 326 | agent: undefined, | |
| 327 | }, | |
| 328 | listed.ok ? listed.value : [], | |
| 329 | ); | |
| 330 | }, | |
| 331 | }); | |
| 332 | ||
| 333 | const askDescription = (name: string): Screen => | |
| 334 | promptScreen(ctx, parent, { | |
| 335 | title: `What should ${name} do?`, | |
| 336 | label: "One sentence — optional", | |
| 337 | placeholder: "A place for notes", | |
| 338 | hint: "Becomes the first issue, handed to an agent on the crew.", | |
| 339 | onSubmit: (description) => | |
| 340 | later(ctx, () => askVisibility(name, description)), | |
| 341 | }); | |
| 342 | ||
| 343 | ctx.push( | |
| 344 | promptScreen(ctx, parent, { | |
| 345 | title: "New app", | |
| 346 | label: "Name", | |
| 347 | placeholder: "notes", | |
| 348 | hint: "Letters, digits, dot, dash, underscore. Its URL is built from this.", | |
| 349 | onSubmit: (name) => { | |
| 350 | if (name === "") return "a name is required"; | |
| 351 | return later(ctx, () => askDescription(name)); | |
| 352 | }, | |
| 353 | }), | |
| 354 | ); | |
| 355 | } |
src/tui/screens/assign.ts added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | /** | |
| 2 | * `a` on an issue or a pull: hand it to somebody, or to nobody. | |
| 3 | * | |
| 4 | * The picker lists nobody, then people — every account on the forge that | |
| 5 | * is neither an org nor an agent, filtered through `canRead` so a name that | |
| 6 | * cannot see the repository is never offered — and then the crew. Assigning | |
| 7 | * an agent is a dispatch, not a decoration (docs/APPS.md), so the crew sits | |
| 8 | * apart and wears the bot mark where the font has one. | |
| 9 | * | |
| 10 | * `assignIssue` decides. The list here shapes what is on offer; it cannot | |
| 11 | * widen what the action allows, and the action's refusals are shown in | |
| 12 | * words. | |
| 13 | */ | |
| 14 | ||
| 15 | import { listCrew } from "../../actions/crew.ts"; | |
| 16 | import { assignIssue } from "../../actions/issues.ts"; | |
| 17 | import type { Issue } from "../../core/issues.ts"; | |
| 18 | import { canRead, type Repo } from "../../core/repos.ts"; | |
| 19 | import { getUserById, listPeople, type User } from "../../core/users.ts"; | |
| 20 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 21 | import { icon, type IconSet } from "../ui/icons.ts"; | |
| 22 | import type { Session } from "../session.ts"; | |
| 23 | import { agentPick } from "./apps.ts"; | |
| 24 | import { errorText, pickerScreen, type PickItem } from "./common.ts"; | |
| 25 | ||
| 26 | /** | |
| 27 | * ` · assigned to robo` after the opener. An agent wears the bot mark where | |
| 28 | * the font has one and the word "agent" where it does not; a person's name | |
| 29 | * is just a name. | |
| 30 | */ | |
| 31 | export function assignedText(issue: Issue | undefined, set: IconSet): string { | |
| 32 | if (issue === undefined || issue.assigneeName === null) return ""; | |
| 33 | if (!issue.assigneeIsAgent) return ` · assigned to ${issue.assigneeName}`; | |
| 34 | return set === "nerd" | |
| 35 | ? ` · assigned to ${icon("bot", set)} ${issue.assigneeName}` | |
| 36 | : ` · assigned to ${issue.assigneeName} (agent)`; | |
| 37 | } | |
| 38 | ||
| 39 | /** How many people the picker reads before it stops; `listPeople`'s ceiling. */ | |
| 40 | const PEOPLE_LIMIT = 200; | |
| 41 | ||
| 42 | /** | |
| 43 | * The people who may read this repository, by name. The listing is bounded, | |
| 44 | * so whoever opened the issue and whoever holds it now are added by hand: | |
| 45 | * the two names a person most expects to find must not depend on where the | |
| 46 | * alphabet cut off. | |
| 47 | */ | |
| 48 | export function assignablePeople(f: Session, repo: Repo, issue: Issue): User[] { | |
| 49 | const seen = new Map<string, User>(); | |
| 50 | const consider = (user: User | undefined): void => { | |
| 51 | if (user === undefined || user.kind !== "user") return; | |
| 52 | if (seen.has(user.id) || !canRead(f.db, repo, user)) return; | |
| 53 | seen.set(user.id, user); | |
| 54 | }; | |
| 55 | for (const person of listPeople(f.db, { limit: PEOPLE_LIMIT })) | |
| 56 | consider(person); | |
| 57 | consider(getUserById(f.db, issue.authorId)); | |
| 58 | if (issue.assigneeId !== null) consider(getUserById(f.db, issue.assigneeId)); | |
| 59 | return [...seen.values()].sort((a, b) => | |
| 60 | a.username.localeCompare(b.username), | |
| 61 | ); | |
| 62 | } | |
| 63 | ||
| 64 | export function assignPicker( | |
| 65 | f: Session, | |
| 66 | ctx: Ctx, | |
| 67 | parent: Screen, | |
| 68 | repo: Repo, | |
| 69 | issue: Issue, | |
| 70 | onDone: () => void, | |
| 71 | ): void { | |
| 72 | const people = assignablePeople(f, repo, issue); | |
| 73 | const crew = listCrew(f.app, f.user, repo); | |
| 74 | const items: PickItem[] = [ | |
| 75 | { id: "", label: "nobody", hint: issue.assigneeId === null ? "now" : "" }, | |
| 76 | ...people.map( | |
| 77 | (user): PickItem => ({ | |
| 78 | id: user.id, | |
| 79 | label: user.username, | |
| 80 | hint: user.id === issue.assigneeId ? "now" : "", | |
| 81 | }), | |
| 82 | ), | |
| 83 | ...(crew.ok ? crew.value : []).map((agent): PickItem => { | |
| 84 | const pick = agentPick(agent, ctx.icons); | |
| 85 | return { | |
| 86 | id: agent.user.id, | |
| 87 | label: pick.label, | |
| 88 | hint: | |
| 89 | agent.user.id === issue.assigneeId ? `${pick.hint} · now` : pick.hint, | |
| 90 | }; | |
| 91 | }), | |
| 92 | ]; | |
| 93 | ctx.push( | |
| 94 | pickerScreen(ctx, parent, { | |
| 95 | title: `Assign #${issue.number}`, | |
| 96 | items, | |
| 97 | onPick: ([chosen]) => { | |
| 98 | if (chosen === undefined) return; | |
| 99 | const assignee = chosen === "" ? null : getUserById(f.db, chosen); | |
| 100 | if (assignee === undefined) { | |
| 101 | ctx.toast(errorText("no_such_user"), "danger"); | |
| 102 | return; | |
| 103 | } | |
| 104 | const done = assignIssue(f.app, f.user, repo, issue, assignee); | |
| 105 | if (!done.ok) { | |
| 106 | ctx.toast(errorText(done.error), "danger"); | |
| 107 | return; | |
| 108 | } | |
| 109 | ctx.toast( | |
| 110 | assignee === null | |
| 111 | ? "unassigned" | |
| 112 | : assignee.kind === "agent" | |
| 113 | ? `assigned to ${assignee.username} — it starts now` | |
| 114 | : `assigned to ${assignee.username}`, | |
| 115 | "success", | |
| 116 | ); | |
| 117 | onDone(); | |
| 118 | }, | |
| 119 | }), | |
| 120 | ); | |
| 121 | } |
src/tui/screens/common.ts +97 −1
| @@ -201,6 +201,9 @@ export class Filter { | ||
| 201 | 201 | export interface ListRow { |
| 202 | 202 | readonly title: string; |
| 203 | 203 | readonly state?: "open" | "closed" | "merged" | "draft"; |
| 204 | /** A status row's lead dot — see `RowOpts.tone`. */ | |
| 205 | readonly tone?: Tone; | |
| 206 | readonly hollow?: boolean; | |
| 204 | 207 | readonly secondary?: string; |
| 205 | 208 | readonly meta?: string; |
| 206 | 209 | } |
| @@ -515,6 +518,20 @@ export function promptScreen( | ||
| 515 | 518 | }; |
| 516 | 519 | } |
| 517 | 520 | |
| 521 | /** | |
| 522 | * Push the next screen of a flow *after* the current modal has popped. | |
| 523 | * | |
| 524 | * `promptScreen` calls `onSubmit` before it pops itself, so a screen pushed | |
| 525 | * synchronously from there is the one the pop removes. Returning this from | |
| 526 | * `onSubmit` defers the push by one microtask — after the pop, before the | |
| 527 | * next frame — which is what lets a flow chain prompts and pickers. | |
| 528 | */ | |
| 529 | export function later(ctx: Ctx, next: () => Screen): Promise<void> { | |
| 530 | return Promise.resolve().then(() => { | |
| 531 | ctx.push(next()); | |
| 532 | }); | |
| 533 | } | |
| 534 | ||
| 518 | 535 | export interface PickItem { |
| 519 | 536 | readonly id: string; |
| 520 | 537 | readonly label: string; |
| @@ -697,8 +714,70 @@ export function openLink(ctx: Ctx, url: string): void { | ||
| 697 | 714 | ctx.toast("opening in your browser", "success"); |
| 698 | 715 | return; |
| 699 | 716 | } |
| 717 | // The browser is on the far side of the connection. Copy through the | |
| 718 | // terminal (OSC 52, for the terminals that honour it) and show the link | |
| 719 | // where it can be selected the ordinary way, mouse reporting off. | |
| 700 | 720 | ctx.copy(url); |
| 701 | ctx.toast("copied — cmd-click the link to open it", "success"); | |
| 721 | ctx.push(linkScreen(ctx, url)); | |
| 722 | } | |
| 723 | ||
| 724 | /** | |
| 725 | * A link, shown plain so it can be selected and copied by hand, with mouse | |
| 726 | * reporting off for as long as it is open — the one thing that makes the | |
| 727 | * terminal's own drag-to-select work inside a mouse-aware app. `c` copies | |
| 728 | * through OSC 52 as well; `enter` opens a browser when one is reachable. | |
| 729 | */ | |
| 730 | export function linkScreen(ctx: Ctx, url: string, parent?: Screen): Screen { | |
| 731 | ctx.mouse(false); | |
| 732 | return { | |
| 733 | title: "Link", | |
| 734 | keys: [ | |
| 735 | binding(["c"], "copy"), | |
| 736 | binding(["enter"], "open"), | |
| 737 | binding(["esc", "q"], "close"), | |
| 738 | ], | |
| 739 | load(signal) { | |
| 740 | // Popping is what turns the mouse back on: nothing else has to remember. | |
| 741 | signal.addEventListener("abort", () => ctx.mouse(true), { once: true }); | |
| 742 | return Promise.resolve(); | |
| 743 | }, | |
| 744 | render(area, s) { | |
| 745 | parent?.render(area, s); | |
| 746 | dialog(s, area, ctx.theme.c, { | |
| 747 | title: "Link", | |
| 748 | body: [ | |
| 749 | url, | |
| 750 | "", | |
| 751 | "Select the address above to copy it — the mouse is yours while this is open.", | |
| 752 | "c copies through your terminal too, when it allows it.", | |
| 753 | ], | |
| 754 | actions: [{ label: "Copy" }, { label: "Close" }], | |
| 755 | selected: 0, | |
| 756 | }); | |
| 757 | }, | |
| 758 | key(e) { | |
| 759 | if (e.key === "c") { | |
| 760 | ctx.copy(url); | |
| 761 | ctx.toast("copied through the terminal", "success"); | |
| 762 | return true; | |
| 763 | } | |
| 764 | if (e.key === "enter") { | |
| 765 | if (canOpen && ctx.openUrl(url)) | |
| 766 | ctx.toast("opening in your browser", "success"); | |
| 767 | else | |
| 768 | ctx.toast( | |
| 769 | "no browser on this side of the connection — copy it instead", | |
| 770 | "attn", | |
| 771 | ); | |
| 772 | return true; | |
| 773 | } | |
| 774 | if (e.key === "esc" || e.key === "q") { | |
| 775 | ctx.pop(); | |
| 776 | return true; | |
| 777 | } | |
| 778 | return false; | |
| 779 | }, | |
| 780 | }; | |
| 702 | 781 | } |
| 703 | 782 | |
| 704 | 783 | /** A branch-safe slug of a path: `src/main.ts` → `src-main-ts`. */ |
| @@ -779,6 +858,23 @@ const MESSAGES: Record<string, string> = { | ||
| 779 | 858 | rate_limited: "too many connect attempts; wait a few minutes", |
| 780 | 859 | no_app: "register an OAuth application on that instance first", |
| 781 | 860 | provider_refused: "the origin refused to start that connect", |
| 861 | // Apps, runs and the crew (docs/APPS.md). `checks_required` is the merge | |
| 862 | // gate refusing, so like `review_required` it names what to do about it. | |
| 863 | checks_required: "this needs a passing deploy of its current head first", | |
| 864 | invalid_slug: "a slug is lowercase letters, digits and single hyphens", | |
| 865 | slug_taken: "that slug is taken by another app", | |
| 866 | already_app: "this repository is already an app", | |
| 867 | no_runtime: "apps are off on this forge (FORGE_APPS=0)", | |
| 868 | no_such_env: "no such environment", | |
| 869 | nothing_to_deploy: "there is nothing to deploy — the branch is empty", | |
| 870 | finished: "that run has already finished", | |
| 871 | no_such_agent: "no such agent", | |
| 872 | scaffold_failed: "the repository was made, but the app could not be started", | |
| 873 | assignee_cannot_read: "they cannot see this repository", | |
| 874 | agent_not_on_crew: "that agent is not on this repository's crew", | |
| 875 | invalid_model: "that model name is not valid", | |
| 876 | brief_too_long: "that brief is too long (4000 characters at most)", | |
| 877 | invalid_budget: "the budget must be between 0 and 100 dollars", | |
| 782 | 878 | }; |
| 783 | 879 | |
| 784 | 880 | /** |
src/tui/screens/crew.ts added +363
| @@ -0,0 +1,363 @@ | ||
| 1 | /** | |
| 2 | * The crew: every agent on this forge, and the admin's three verbs over | |
| 3 | * them — make one, change one, retire one. | |
| 4 | * | |
| 5 | * An agent is an account that acts, so the screen is a forge admin's; the | |
| 6 | * actions refuse anyone else and this screen says so rather than hiding | |
| 7 | * the keys and leaving a person wondering. The first line says whether the | |
| 8 | * crew can work at all — a Claude Code was found, or it was not and here is | |
| 9 | * why — because assigning an issue to an agent that cannot run is the one | |
| 10 | * mistake worth preventing before it is made (docs/APPS.md). | |
| 11 | */ | |
| 12 | ||
| 13 | import { | |
| 14 | createAgent, | |
| 15 | deleteAgent, | |
| 16 | listAllAgents, | |
| 17 | updateAgent, | |
| 18 | } from "../../actions/crew.ts"; | |
| 19 | import { crewFor } from "../../crew/dispatch.ts"; | |
| 20 | import type { Agent } from "../../core/agents.ts"; | |
| 21 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 22 | import { binding } from "../app/keys.ts"; | |
| 23 | import { ITALIC, style } from "../term/style.ts"; | |
| 24 | import { truncate } from "../term/width.ts"; | |
| 25 | import { fill, len, split } from "../ui/layout.ts"; | |
| 26 | import { icon } from "../ui/icons.ts"; | |
| 27 | import { dot, emptyState } from "../ui/widgets.ts"; | |
| 28 | import type { Session } from "../session.ts"; | |
| 29 | import { | |
| 30 | ListState, | |
| 31 | confirmScreen, | |
| 32 | drawList, | |
| 33 | errorText, | |
| 34 | hitList, | |
| 35 | later, | |
| 36 | live, | |
| 37 | metaOf, | |
| 38 | pickerScreen, | |
| 39 | promptScreen, | |
| 40 | runAsync, | |
| 41 | type ListRow, | |
| 42 | } from "./common.ts"; | |
| 43 | ||
| 44 | const CREW_EVENTS: readonly string[] = [ | |
| 45 | "agent.created", | |
| 46 | "agent.updated", | |
| 47 | "agent.deleted", | |
| 48 | ]; | |
| 49 | ||
| 50 | /** What `n` asks, in order. */ | |
| 51 | const NEW_AGENT: readonly { key: string; label: string; hint: string }[] = [ | |
| 52 | { | |
| 53 | key: "username", | |
| 54 | label: "Username", | |
| 55 | hint: "How it signs its pulls and comments.", | |
| 56 | }, | |
| 57 | { | |
| 58 | key: "displayName", | |
| 59 | label: "Display name", | |
| 60 | hint: "Optional; the username otherwise.", | |
| 61 | }, | |
| 62 | { | |
| 63 | key: "model", | |
| 64 | label: "Model", | |
| 65 | hint: "A Claude Code model alias or id. Empty means the runner's default.", | |
| 66 | }, | |
| 67 | { | |
| 68 | key: "brief", | |
| 69 | label: "Brief", | |
| 70 | hint: "A sentence or two in its own voice: who it is, how it works.", | |
| 71 | }, | |
| 72 | { | |
| 73 | key: "budget", | |
| 74 | label: "Budget per run (USD)", | |
| 75 | hint: "Empty means the forge's default (FORGE_CREW_BUDGET_USD).", | |
| 76 | }, | |
| 77 | ]; | |
| 78 | ||
| 79 | /** `$5.00/run`, or the forge's default said as such. */ | |
| 80 | export function budgetText(budgetUsd: number): string { | |
| 81 | return budgetUsd > 0 ? `$${budgetUsd.toFixed(2)}/run` : "default budget"; | |
| 82 | } | |
| 83 | ||
| 84 | export function crewScreen(f: Session, ctx: Ctx): Screen { | |
| 85 | let agents: Agent[] = []; | |
| 86 | let failed: string | undefined; | |
| 87 | const state = new ListState(); | |
| 88 | const admin = (): boolean => f.user?.isAdmin === true; | |
| 89 | ||
| 90 | const reload = (): void => { | |
| 91 | const read = listAllAgents(f.app, f.user); | |
| 92 | if (!read.ok) { | |
| 93 | failed = errorText(read.error); | |
| 94 | ctx.invalidate(); | |
| 95 | return; | |
| 96 | } | |
| 97 | failed = undefined; | |
| 98 | agents = read.value; | |
| 99 | state.clamp(agents.length); | |
| 100 | ctx.invalidate(); | |
| 101 | }; | |
| 102 | ||
| 103 | const selected = (): Agent | undefined => agents[state.index]; | |
| 104 | ||
| 105 | /** | |
| 106 | * Five prompts, one call. The prompts are a list walked by index, each | |
| 107 | * handing the next one on after the previous has popped; Esc anywhere | |
| 108 | * makes nothing. | |
| 109 | */ | |
| 110 | const create = (): void => { | |
| 111 | const answers: Record<string, string> = {}; | |
| 112 | const step = (i: number): Screen => { | |
| 113 | const field = NEW_AGENT[i]!; | |
| 114 | return promptScreen(ctx, screen, { | |
| 115 | title: "New agent", | |
| 116 | label: field.label, | |
| 117 | hint: field.hint, | |
| 118 | // The display name defaults to the username just typed. | |
| 119 | value: field.key === "displayName" ? (answers["username"] ?? "") : "", | |
| 120 | onSubmit: (value) => { | |
| 121 | if (field.key === "username" && value === "") | |
| 122 | return "a username is required"; | |
| 123 | if ( | |
| 124 | field.key === "budget" && | |
| 125 | value !== "" && | |
| 126 | !Number.isFinite(Number(value)) | |
| 127 | ) | |
| 128 | return errorText("invalid_budget"); | |
| 129 | answers[field.key] = value; | |
| 130 | if (i + 1 < NEW_AGENT.length) return later(ctx, () => step(i + 1)); | |
| 131 | return finish(); | |
| 132 | }, | |
| 133 | }); | |
| 134 | }; | |
| 135 | const finish = async (): Promise<void> => { | |
| 136 | const username = answers["username"] ?? ""; | |
| 137 | const displayName = answers["displayName"] ?? ""; | |
| 138 | const model = answers["model"] ?? ""; | |
| 139 | const brief = answers["brief"] ?? ""; | |
| 140 | const budget = answers["budget"] ?? ""; | |
| 141 | ctx.toast(`creating ${username}…`); | |
| 142 | const made = await createAgent(f.app, f.user, { | |
| 143 | username, | |
| 144 | ...(displayName === "" ? {} : { displayName }), | |
| 145 | ...(model === "" ? {} : { model }), | |
| 146 | ...(brief === "" ? {} : { brief }), | |
| 147 | ...(budget === "" ? {} : { budgetUsd: Number(budget) }), | |
| 148 | }); | |
| 149 | if (!made.ok) { | |
| 150 | ctx.toast(errorText(made.error), "danger"); | |
| 151 | return; | |
| 152 | } | |
| 153 | ctx.toast( | |
| 154 | `${made.value.user.username} is on the crew — add it to a repository to give it work`, | |
| 155 | "success", | |
| 156 | ); | |
| 157 | reload(); | |
| 158 | }; | |
| 159 | ctx.push(step(0)); | |
| 160 | }; | |
| 161 | ||
| 162 | /** `enter`: which fact to change, then the new value. */ | |
| 163 | const edit = (agent: Agent | undefined): void => { | |
| 164 | if (agent === undefined) return; | |
| 165 | const name = agent.user.username; | |
| 166 | ctx.push( | |
| 167 | pickerScreen(ctx, screen, { | |
| 168 | title: `Edit ${name}`, | |
| 169 | items: [ | |
| 170 | { id: "model", label: "model", hint: agent.model || "default" }, | |
| 171 | { | |
| 172 | id: "brief", | |
| 173 | label: "brief", | |
| 174 | hint: firstLine(agent.brief) || "none", | |
| 175 | }, | |
| 176 | { | |
| 177 | id: "budget", | |
| 178 | label: "budget per run", | |
| 179 | hint: budgetText(agent.budgetUsd), | |
| 180 | }, | |
| 181 | ], | |
| 182 | onPick: ([field]) => { | |
| 183 | if (field === undefined) return; | |
| 184 | ctx.push( | |
| 185 | promptScreen(ctx, screen, { | |
| 186 | title: `${name} · ${field}`, | |
| 187 | label: | |
| 188 | field === "model" | |
| 189 | ? "Model (empty for the default)" | |
| 190 | : field === "brief" | |
| 191 | ? "Brief" | |
| 192 | : "Budget per run (USD; 0 for the default)", | |
| 193 | value: | |
| 194 | field === "model" | |
| 195 | ? agent.model | |
| 196 | : field === "brief" | |
| 197 | ? agent.brief | |
| 198 | : agent.budgetUsd === 0 | |
| 199 | ? "" | |
| 200 | : String(agent.budgetUsd), | |
| 201 | onSubmit: (value) => { | |
| 202 | const patch = | |
| 203 | field === "model" | |
| 204 | ? { model: value } | |
| 205 | : field === "brief" | |
| 206 | ? { brief: value } | |
| 207 | : { budgetUsd: value === "" ? 0 : Number(value) }; | |
| 208 | if ("budgetUsd" in patch && !Number.isFinite(patch.budgetUsd)) | |
| 209 | return errorText("invalid_budget"); | |
| 210 | const done = updateAgent(f.app, f.user, name, patch); | |
| 211 | if (!done.ok) return errorText(done.error); | |
| 212 | ctx.toast(`${name} updated`, "success"); | |
| 213 | reload(); | |
| 214 | return undefined; | |
| 215 | }, | |
| 216 | }), | |
| 217 | ); | |
| 218 | }, | |
| 219 | }), | |
| 220 | ); | |
| 221 | }; | |
| 222 | ||
| 223 | const retire = (agent: Agent | undefined): void => { | |
| 224 | if (agent === undefined) return; | |
| 225 | const name = agent.user.username; | |
| 226 | ctx.push( | |
| 227 | confirmScreen(ctx, screen, { | |
| 228 | title: `Retire ${name}`, | |
| 229 | body: [ | |
| 230 | "Its tokens are revoked and it leaves every crew.", | |
| 231 | "Pulls and comments it wrote stay, in its name.", | |
| 232 | ], | |
| 233 | actions: [ | |
| 234 | { label: "Keep it", variant: "ghost" }, | |
| 235 | { label: "Retire", variant: "danger" }, | |
| 236 | ], | |
| 237 | onChoose: (choice) => { | |
| 238 | if (choice !== 1) return; | |
| 239 | const gone = deleteAgent(f.app, f.user, name); | |
| 240 | if (!gone.ok) ctx.toast(errorText(gone.error), "danger"); | |
| 241 | else { | |
| 242 | ctx.toast(`${name} retired`, "success"); | |
| 243 | reload(); | |
| 244 | } | |
| 245 | }, | |
| 246 | }), | |
| 247 | ); | |
| 248 | }; | |
| 249 | ||
| 250 | const screen: Screen = { | |
| 251 | title: "Crew", | |
| 252 | get keys() { | |
| 253 | return [ | |
| 254 | binding(["j", "k"], "move"), | |
| 255 | binding(["n"], "new agent", { disabled: !admin() }), | |
| 256 | binding(["enter"], "edit", { disabled: !admin() }), | |
| 257 | binding(["d"], "retire", { disabled: !admin() }), | |
| 258 | binding(["r"], "refresh"), | |
| 259 | ]; | |
| 260 | }, | |
| 261 | load(signal) { | |
| 262 | live(f, signal, { kinds: CREW_EVENTS }, reload); | |
| 263 | reload(); | |
| 264 | return Promise.resolve(); | |
| 265 | }, | |
| 266 | render(area, s) { | |
| 267 | const c = ctx.theme.c; | |
| 268 | const [head, list] = split(area, "v", [len(2), fill(1)]); | |
| 269 | const right = head!.x + head!.w; | |
| 270 | // The first line is whether the crew can work at all. | |
| 271 | const handle = crewFor(f.app); | |
| 272 | const ready = handle?.ready === true; | |
| 273 | let x = dot(s, head!.x, head!.y, c, { | |
| 274 | ...(handle === undefined | |
| 275 | ? { hollow: true } | |
| 276 | : { tone: ready ? "success" : "danger" }), | |
| 277 | }); | |
| 278 | s.text( | |
| 279 | x + 1, | |
| 280 | head!.y, | |
| 281 | truncate( | |
| 282 | handle === undefined | |
| 283 | ? "the crew is not running on this forge — agents can be made, but nothing will work until it is" | |
| 284 | : ready | |
| 285 | ? `${handle.detail} — agents can work` | |
| 286 | : `Claude Code is not usable: ${handle.detail}`, | |
| 287 | Math.max(0, right - x - 1), | |
| 288 | ), | |
| 289 | style(handle === undefined ? c.mutedFg : ready ? c.fg : c.danger, c.bg), | |
| 290 | right, | |
| 291 | ); | |
| 292 | if (!admin()) | |
| 293 | s.text( | |
| 294 | head!.x, | |
| 295 | head!.y + 1, | |
| 296 | truncate("only a forge admin can change the crew", head!.w), | |
| 297 | style(c.faint, c.bg, ITALIC), | |
| 298 | right, | |
| 299 | ); | |
| 300 | if (failed !== undefined) { | |
| 301 | emptyState(s, list!, c, { title: failed }); | |
| 302 | return; | |
| 303 | } | |
| 304 | if (agents.length === 0) { | |
| 305 | emptyState(s, list!, c, { | |
| 306 | title: "No agents yet", | |
| 307 | body: admin() | |
| 308 | ? "Press n to make one: a name, a model, a brief." | |
| 309 | : "A forge admin makes them.", | |
| 310 | }); | |
| 311 | return; | |
| 312 | } | |
| 313 | const set = ctx.icons; | |
| 314 | drawList( | |
| 315 | s, | |
| 316 | list!, | |
| 317 | c, | |
| 318 | state, | |
| 319 | agents.map( | |
| 320 | (agent): ListRow => ({ | |
| 321 | title: | |
| 322 | set === "nerd" | |
| 323 | ? `${icon("bot", set)} ${agent.user.username}` | |
| 324 | : agent.user.username, | |
| 325 | secondary: firstLine(agent.brief), | |
| 326 | meta: metaOf( | |
| 327 | agent.model === "" ? "default model" : agent.model, | |
| 328 | budgetText(agent.budgetUsd), | |
| 329 | ), | |
| 330 | }), | |
| 331 | ), | |
| 332 | ); | |
| 333 | hitList(ctx, list!, state, agents.length, { | |
| 334 | open: (index) => edit(agents[index]), | |
| 335 | }); | |
| 336 | }, | |
| 337 | key(e) { | |
| 338 | if (state.key(e, agents.length)) return true; | |
| 339 | if (e.key === "n" && admin()) { | |
| 340 | create(); | |
| 341 | return true; | |
| 342 | } | |
| 343 | if (e.key === "enter" && admin()) { | |
| 344 | edit(selected()); | |
| 345 | return true; | |
| 346 | } | |
| 347 | if (e.key === "d" && admin()) { | |
| 348 | retire(selected()); | |
| 349 | return true; | |
| 350 | } | |
| 351 | if (e.key === "r") { | |
| 352 | runAsync(ctx, async () => reload()); | |
| 353 | return true; | |
| 354 | } | |
| 355 | return false; | |
| 356 | }, | |
| 357 | }; | |
| 358 | return screen; | |
| 359 | } | |
| 360 | ||
| 361 | function firstLine(text: string): string { | |
| 362 | return text.split("\n", 1)[0]?.trim() ?? ""; | |
| 363 | } |
src/tui/screens/dashboard.ts +184 −22
| @@ -15,10 +15,15 @@ | ||
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | 17 | import { pinRepo, unpinRepo } from "../../actions/origins.ts"; |
| 18 | import { getIssuesByRef } from "../../core/issues.ts"; | |
| 18 | import { | |
| 19 | getIssuesByRef, | |
| 20 | listAssignedTo, | |
| 21 | type Issue, | |
| 22 | } from "../../core/issues.ts"; | |
| 19 | 23 | import { listPins, pinnedRepoIds, type Pin } from "../../core/pins.ts"; |
| 20 | 24 | import { listOrgsForUser } from "../../core/orgs.ts"; |
| 21 | 25 | import { listEventsVisibleTo, type ForgeEvent } from "../../core/events.ts"; |
| 26 | import { eventActor } from "../../design/activity.ts"; | |
| 22 | 27 | import { |
| 23 | 28 | canRead, |
| 24 | 29 | getRepo, |
| @@ -39,8 +44,10 @@ import { card, emptyState } from "../ui/widgets.ts"; | ||
| 39 | 44 | import type { Session } from "../session.ts"; |
| 40 | 45 | import { |
| 41 | 46 | Filter, |
| 47 | ISSUE_MARK, | |
| 42 | 48 | ListState, |
| 43 | 49 | PIN_MARK, |
| 50 | PULL_MARK, | |
| 44 | 51 | confirmScreen, |
| 45 | 52 | countsColumn, |
| 46 | 53 | metaOf, |
| @@ -54,6 +61,8 @@ import { | ||
| 54 | 61 | } from "./common.ts"; |
| 55 | 62 | import { describeEvent, type FeedLine } from "./activity.ts"; |
| 56 | 63 | import { accountScreen } from "./account.ts"; |
| 64 | import { appScreen } from "./app.ts"; | |
| 65 | import { appRow, appsScreen, listVisibleApps, type AppItem } from "./apps.ts"; | |
| 57 | 66 | import { exploreScreen } from "./explore.ts"; |
| 58 | 67 | import { issueScreen } from "./issue.ts"; |
| 59 | 68 | import { newRepoScreen } from "./newrepo.ts"; |
| @@ -63,27 +72,46 @@ import { repoScreen } from "./repo.ts"; | ||
| 63 | 72 | import { local } from "./where.ts"; |
| 64 | 73 | import { signInScreen } from "./signin.ts"; |
| 65 | 74 | |
| 66 | /** The four things a dashboard is about. Order is the `tab` order. */ | |
| 67 | type PaneId = "repos" | "activity" | "machine" | "orgs"; | |
| 75 | /** | |
| 76 | * The six things a dashboard is about. Order is the `tab` order: apps first | |
| 77 | * because "what is running" is the question a person drops in to answer, | |
| 78 | * then the repositories, then what happened, then what is waiting on you. | |
| 79 | */ | |
| 80 | type PaneId = "apps" | "repos" | "activity" | "onyou" | "machine" | "orgs"; | |
| 68 | 81 | |
| 69 | 82 | const TITLES: Readonly<Record<PaneId, string>> = { |
| 83 | apps: "Apps", | |
| 70 | 84 | repos: "Repositories", |
| 71 | 85 | activity: "Recent activity", |
| 86 | onyou: "On you", | |
| 72 | 87 | machine: "On your machine", |
| 73 | 88 | orgs: "Organizations", |
| 74 | 89 | }; |
| 75 | 90 | |
| 91 | /** An issue or a pull assigned to the viewer, with the repository it is in. */ | |
| 92 | interface OnYouItem { | |
| 93 | readonly issue: Issue; | |
| 94 | readonly repo: Repo; | |
| 95 | } | |
| 96 | ||
| 97 | /** The apps card shows this many before it scrolls. */ | |
| 98 | const APPS_ROWS = 5; | |
| 99 | ||
| 76 | 100 | export function dashboardScreen(f: Session, ctx: Ctx): Screen { |
| 77 | 101 | let repos: Repo[] = []; |
| 78 | 102 | let feed: FeedItem[] = []; |
| 79 | 103 | let orgs: string[] = []; |
| 80 | 104 | let pins: Pin[] = []; |
| 81 | 105 | let pinned = new Set<string>(); |
| 106 | let apps: AppItem[] = []; | |
| 107 | let onYou: OnYouItem[] = []; | |
| 82 | 108 | const filter = new Filter(); |
| 83 | 109 | /** One cursor per pane: moving in one must never move another. */ |
| 84 | 110 | const cursors: Record<PaneId, ListState> = { |
| 111 | apps: new ListState(), | |
| 85 | 112 | repos: new ListState(), |
| 86 | 113 | activity: new ListState(), |
| 114 | onyou: new ListState(), | |
| 87 | 115 | machine: new ListState(), |
| 88 | 116 | orgs: new ListState(), |
| 89 | 117 | }; |
| @@ -95,6 +123,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 95 | 123 | pins = f.user ? listPins(f.db, f.user.id) : []; |
| 96 | 124 | pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>(); |
| 97 | 125 | const all = listReposVisibleTo(f.db, f.user, { limit: 200 }); |
| 126 | // The apps are among the rows just listed: one grouped read, no second | |
| 127 | // listing. Every visible app is shown, not only "yours" — what is | |
| 128 | // running is the question, whoever owns it. | |
| 129 | apps = listVisibleApps(f, all); | |
| 130 | onYou = assignedToMe(); | |
| 98 | 131 | // "Yours" is ownership plus the orgs you are on a team of; everything |
| 99 | 132 | // else lives in Explore, one key away. |
| 100 | 133 | orgs = f.user |
| @@ -115,6 +148,27 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 115 | 148 | ctx.invalidate(); |
| 116 | 149 | }; |
| 117 | 150 | |
| 151 | /** | |
| 152 | * What is assigned to the viewer, open, newest first. The rows come from | |
| 153 | * one query and the repositories from one grouped read; `canRead` is the | |
| 154 | * same filter every list applies, so an assignment in a repository this | |
| 155 | * account has since lost cannot appear here. | |
| 156 | */ | |
| 157 | const assignedToMe = (): OnYouItem[] => { | |
| 158 | if (f.user === undefined) return []; | |
| 159 | const issues = listAssignedTo(f.db, f.user.id, 50); | |
| 160 | const byId = getReposByIds( | |
| 161 | f.db, | |
| 162 | issues.map((issue) => issue.repoId), | |
| 163 | ); | |
| 164 | return issues.flatMap((issue) => { | |
| 165 | const repo = byId.get(issue.repoId); | |
| 166 | return repo !== undefined && canRead(f.db, repo, f.user) | |
| 167 | ? [{ issue, repo }] | |
| 168 | : []; | |
| 169 | }); | |
| 170 | }; | |
| 171 | ||
| 118 | 172 | const shown = (): Repo[] => |
| 119 | 173 | repos.filter((repo) => |
| 120 | 174 | filter.matches(`${repo.ownerName}/${repo.name} ${repo.description}`), |
| @@ -123,24 +177,31 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 123 | 177 | /** |
| 124 | 178 | * The panes that exist right now. Below two columns only the repo list is |
| 125 | 179 | * drawn, so it is the only thing `tab` can reach — a focus ring on a card |
| 126 | * that is not on screen is worse than no focus ring. | |
| 180 | * that is not on screen is worse than no focus ring. The apps card is only | |
| 181 | * there when there is an app: an empty card saying so would take the rows | |
| 182 | * the repositories need, and `a` is the way to the apps screen regardless. | |
| 127 | 183 | */ |
| 128 | 184 | const panes = (): PaneId[] => { |
| 129 | 185 | if (!ctx.twoPane) return ["repos"]; |
| 186 | const withApps: PaneId[] = apps.length > 0 ? ["apps"] : []; | |
| 130 | 187 | return f.signedIn |
| 131 | ? ["repos", "activity", "machine", "orgs"] | |
| 132 | : ["repos", "activity"]; | |
| 188 | ? [...withApps, "repos", "activity", "onyou", "machine", "orgs"] | |
| 189 | : [...withApps, "repos", "activity"]; | |
| 133 | 190 | }; |
| 134 | 191 | |
| 135 | 192 | const at = (pane: PaneId): ListState => cursors[pane]; |
| 136 | 193 | const size = (pane: PaneId): number => |
| 137 | pane === "repos" | |
| 138 | ? shown().length | |
| 139 | : pane === "activity" | |
| 140 | ? feed.length | |
| 141 | : pane === "machine" | |
| 142 | ? pins.length | |
| 143 | : orgs.length; | |
| 194 | pane === "apps" | |
| 195 | ? apps.length | |
| 196 | : pane === "repos" | |
| 197 | ? shown().length | |
| 198 | : pane === "activity" | |
| 199 | ? feed.length | |
| 200 | : pane === "onyou" | |
| 201 | ? onYou.length | |
| 202 | : pane === "machine" | |
| 203 | ? pins.length | |
| 204 | : orgs.length; | |
| 144 | 205 | |
| 145 | 206 | const cycle = (step: number): void => { |
| 146 | 207 | const list = panes(); |
| @@ -211,10 +272,26 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 211 | 272 | ); |
| 212 | 273 | }; |
| 213 | 274 | |
| 275 | const openApp = (item: AppItem | undefined): void => { | |
| 276 | if (item !== undefined) ctx.push(appScreen(f, ctx, item.repo)); | |
| 277 | }; | |
| 278 | ||
| 279 | const openAssigned = (item: OnYouItem | undefined): void => { | |
| 280 | if (item === undefined) return; | |
| 281 | const src = local(f, item.repo); | |
| 282 | ctx.push( | |
| 283 | item.issue.kind === "pull" | |
| 284 | ? pullScreen(f, ctx, src, item.issue.number) | |
| 285 | : issueScreen(f, ctx, src, item.issue.number), | |
| 286 | ); | |
| 287 | }; | |
| 288 | ||
| 214 | 289 | /** `enter` on whatever the focused pane's cursor is on. */ |
| 215 | 290 | const openHere = (): void => { |
| 216 | if (focus === "repos") openRepo(shown()[at("repos").index]); | |
| 291 | if (focus === "apps") openApp(apps[at("apps").index]); | |
| 292 | else if (focus === "repos") openRepo(shown()[at("repos").index]); | |
| 217 | 293 | else if (focus === "activity") openEvent(feed[at("activity").index]); |
| 294 | else if (focus === "onyou") openAssigned(onYou[at("onyou").index]); | |
| 218 | 295 | else if (focus === "machine") openPin(pins[at("machine").index]); |
| 219 | 296 | else { |
| 220 | 297 | const org = orgs[at("orgs").index]; |
| @@ -230,6 +307,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 230 | 307 | binding(["j", "k"], "move"), |
| 231 | 308 | binding(["enter"], "open"), |
| 232 | 309 | binding(["/"], "filter"), |
| 310 | binding(["a"], "apps"), | |
| 233 | 311 | binding(["e"], "explore"), |
| 234 | 312 | binding(["p"], "pin", { disabled: !f.signedIn }), |
| 235 | 313 | binding(["d"], "unpin", { |
| @@ -243,8 +321,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 243 | 321 | }, |
| 244 | 322 | load(signal) { |
| 245 | 323 | // Anything anyone does anywhere the viewer can see lands here: the |
| 246 | // repo list, the counts beside it and the activity feed are all | |
| 247 | // functions of the outbox, so one subscription refreshes all three. | |
| 324 | // repo list, the counts beside it, the activity feed, the apps and | |
| 325 | // what is on you are all functions of the outbox, so one subscription | |
| 326 | // refreshes all of them. (Run events carry a repo id, so they pass the | |
| 327 | // same filter as everything else.) | |
| 248 | 328 | live(f, signal, {}, reload); |
| 249 | 329 | reload(); |
| 250 | 330 | return Promise.resolve(); |
| @@ -256,17 +336,28 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 256 | 336 | const [left, right] = ctx.twoPane |
| 257 | 337 | ? split(area, "h", [fill(3), fill(2)]) |
| 258 | 338 | : [area, undefined]; |
| 259 | const [box, list] = split(left!, "v", [len(1), fill(1)]); | |
| 339 | const appsH = | |
| 340 | right === undefined || apps.length === 0 | |
| 341 | ? 0 | |
| 342 | : Math.min(apps.length, APPS_ROWS) + 2; | |
| 343 | const [appsArea, box, list] = split(left!, "v", [ | |
| 344 | len(appsH), | |
| 345 | len(1), | |
| 346 | fill(1), | |
| 347 | ]); | |
| 348 | if (appsH > 0) drawApps(s, appsArea!, c, set); | |
| 260 | 349 | ctx.setCursor(filter.render(s, box!, c, "filter repositories")); |
| 261 | 350 | drawRepos(s, list!, c, set); |
| 262 | 351 | if (right === undefined) return; |
| 263 | 352 | |
| 264 | const [activity, machine, orgsArea] = split(right, "v", [ | |
| 353 | const [activity, onYouArea, machine, orgsArea] = split(right, "v", [ | |
| 265 | 354 | fill(1), |
| 355 | len(f.signedIn ? 6 : 0), | |
| 266 | 356 | len(6), |
| 267 | 357 | len(5), |
| 268 | 358 | ]); |
| 269 | 359 | drawActivity(s, activity!, c, set); |
| 360 | if (f.signedIn) drawOnYou(s, onYouArea!, c, set); | |
| 270 | 361 | drawMachine(s, machine!, c, set); |
| 271 | 362 | drawOrgs(s, orgsArea!, c, set); |
| 272 | 363 | }, |
| @@ -289,6 +380,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 289 | 380 | ctx.push(exploreScreen(f, ctx)); |
| 290 | 381 | return true; |
| 291 | 382 | } |
| 383 | if (e.key === "a") { | |
| 384 | ctx.push(appsScreen(f, ctx)); | |
| 385 | return true; | |
| 386 | } | |
| 292 | 387 | if (e.key === "d" && focus === "machine") { |
| 293 | 388 | unpin(pins[at("machine").index]); |
| 294 | 389 | return true; |
| @@ -333,6 +428,73 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 333 | 428 | |
| 334 | 429 | // ---- the panes ---- |
| 335 | 430 | |
| 431 | /** What is running: every app, its production state, and its URL. */ | |
| 432 | function drawApps(s: Surface, r: Rect, c: Tokens, set: IconSet): void { | |
| 433 | const on = focus === "apps"; | |
| 434 | const inner = card(s, r, c, { | |
| 435 | title: `${icon("terminal", set)} ${TITLES.apps}`, | |
| 436 | focused: on, | |
| 437 | }); | |
| 438 | drawList( | |
| 439 | s, | |
| 440 | inner, | |
| 441 | c, | |
| 442 | at("apps"), | |
| 443 | apps.map((item) => appRow(item, set)), | |
| 444 | on, | |
| 445 | ); | |
| 446 | hitList(ctx, inner, at("apps"), apps.length, { | |
| 447 | select: () => { | |
| 448 | focus = "apps"; | |
| 449 | }, | |
| 450 | open: (index) => { | |
| 451 | focus = "apps"; | |
| 452 | openApp(apps[index]); | |
| 453 | }, | |
| 454 | }); | |
| 455 | } | |
| 456 | ||
| 457 | /** The inbox: what is assigned to this account, across every repository. */ | |
| 458 | function drawOnYou(s: Surface, r: Rect, c: Tokens, set: IconSet): void { | |
| 459 | const on = focus === "onyou"; | |
| 460 | const inner = card(s, r, c, { | |
| 461 | title: `${icon("person", set)} ${TITLES.onyou}`, | |
| 462 | focused: on, | |
| 463 | }); | |
| 464 | if (onYou.length === 0) { | |
| 465 | note(s, inner, c, ["Nothing is assigned to you."]); | |
| 466 | return; | |
| 467 | } | |
| 468 | drawList( | |
| 469 | s, | |
| 470 | inner, | |
| 471 | c, | |
| 472 | at("onyou"), | |
| 473 | onYou.map((item): ListRow => { | |
| 474 | const mark = | |
| 475 | set === "nerd" | |
| 476 | ? icon(item.issue.kind === "pull" ? "pull" : "issue", set) | |
| 477 | : item.issue.kind === "pull" | |
| 478 | ? PULL_MARK | |
| 479 | : ISSUE_MARK; | |
| 480 | return { | |
| 481 | title: `${mark} #${item.issue.number} ${item.issue.title}`, | |
| 482 | meta: `${item.repo.ownerName}/${item.repo.name}`, | |
| 483 | }; | |
| 484 | }), | |
| 485 | on, | |
| 486 | ); | |
| 487 | hitList(ctx, inner, at("onyou"), onYou.length, { | |
| 488 | select: () => { | |
| 489 | focus = "onyou"; | |
| 490 | }, | |
| 491 | open: (index) => { | |
| 492 | focus = "onyou"; | |
| 493 | openAssigned(onYou[index]); | |
| 494 | }, | |
| 495 | }); | |
| 496 | } | |
| 497 | ||
| 336 | 498 | function drawRepos(s: Surface, r: Rect, c: Tokens, set: IconSet): void { |
| 337 | 499 | const rows = shown(); |
| 338 | 500 | const on = focus === "repos"; |
| @@ -562,10 +724,10 @@ function feedItems(f: Session, events: readonly ForgeEvent[]): FeedItem[] { | ||
| 562 | 724 | ), |
| 563 | 725 | ); |
| 564 | 726 | return events.flatMap((event) => { |
| 565 | const actor = | |
| 566 | event.actorId === null | |
| 567 | ? "someone" | |
| 568 | : (actors.get(event.actorId)?.username ?? "someone"); | |
| 727 | const actor = eventActor( | |
| 728 | event, | |
| 729 | event.actorId === null ? undefined : actors.get(event.actorId)?.username, | |
| 730 | ); | |
| 569 | 731 | const repo = event.repoId === null ? undefined : repos.get(event.repoId); |
| 570 | 732 | const number = event.payload["number"]; |
| 571 | 733 | const found = |
src/tui/screens/issue.ts +77 −6
| @@ -19,7 +19,8 @@ import { | ||
| 19 | 19 | setIssueLabelsThrough, |
| 20 | 20 | setIssueStateThrough, |
| 21 | 21 | } from "../../actions/issues.ts"; |
| 22 | import { getIssue, type Label } from "../../core/issues.ts"; | |
| 22 | import { listRepoRuns } from "../../actions/runs.ts"; | |
| 23 | import { getIssue, type Issue, type Label } from "../../core/issues.ts"; | |
| 23 | 24 | import type { Repo } from "../../core/repos.ts"; |
| 24 | 25 | import type { IssueDetail, RepoSource } from "../../view/source.ts"; |
| 25 | 26 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -28,7 +29,7 @@ import type { Span } from "../term/screen.ts"; | ||
| 28 | 29 | import { BOLD, style } from "../term/style.ts"; |
| 29 | 30 | import { truncate } from "../term/width.ts"; |
| 30 | 31 | import { fill, len, split } from "../ui/layout.ts"; |
| 31 | import { dot, labelChip } from "../ui/widgets.ts"; | |
| 32 | import { card, dot, labelChip } from "../ui/widgets.ts"; | |
| 32 | 33 | import type { Session } from "../session.ts"; |
| 33 | 34 | import { |
| 34 | 35 | ORIGIN_MARK, |
| @@ -42,10 +43,16 @@ import { | ||
| 42 | 43 | plural, |
| 43 | 44 | runAsync, |
| 44 | 45 | } from "./common.ts"; |
| 46 | import { assignPicker, assignedText } from "./assign.ts"; | |
| 45 | 47 | import { liveBindings, liveKey } from "./mirror.ts"; |
| 48 | import { runScreen } from "./run.ts"; | |
| 49 | import { RunList } from "./runs.ts"; | |
| 46 | 50 | import { Composer, threadLines, type ThreadEntry } from "./thread.ts"; |
| 47 | 51 | import { local, localRepo, whereRows, whereStrip } from "./where.ts"; |
| 48 | 52 | |
| 53 | /** The crew card shows this many runs before it scrolls. */ | |
| 54 | const CREW_ROWS = 4; | |
| 55 | ||
| 49 | 56 | export function issueScreen( |
| 50 | 57 | f: Session, |
| 51 | 58 | ctx: Ctx, |
| @@ -60,6 +67,12 @@ export function issueScreen( | ||
| 60 | 67 | const scroll = new Scroll(); |
| 61 | 68 | const composer = new Composer(); |
| 62 | 69 | const repo = localRepo(src); |
| 70 | /** The local row — who it is assigned to. Undefined on a live browse. */ | |
| 71 | let issueRow: Issue | undefined; | |
| 72 | /** What the crew has done here, newest first; a card when there is any. */ | |
| 73 | const crew = new RunList(f); | |
| 74 | /** Which pane `j`/`k`/`enter` belong to; `tab` moves between them. */ | |
| 75 | let focus: "thread" | "crew" = "thread"; | |
| 63 | 76 | const mayWrite = (): boolean => src.capabilities.write && repo !== undefined; |
| 64 | 77 | scroll.top = keep.top ?? 0; |
| 65 | 78 | |
| @@ -71,12 +84,35 @@ export function issueScreen( | ||
| 71 | 84 | return; |
| 72 | 85 | } |
| 73 | 86 | detail = read.value; |
| 87 | issueRow = repo === undefined ? undefined : getIssue(f.db, repo.id, number); | |
| 88 | const runs = | |
| 89 | repo === undefined || issueRow === undefined | |
| 90 | ? undefined | |
| 91 | : listRepoRuns(f.app, f.user, repo, { | |
| 92 | kind: "agent", | |
| 93 | issueId: issueRow.id, | |
| 94 | limit: 50, | |
| 95 | }); | |
| 96 | crew.set(runs !== undefined && runs.ok ? runs.value : []); | |
| 97 | if (crew.runs.length === 0) focus = "thread"; | |
| 74 | 98 | width = 0; |
| 75 | 99 | ctx.invalidate(); |
| 76 | 100 | const listed = await src.labels(); |
| 77 | 101 | labels = listed.ok ? listed.value : []; |
| 78 | 102 | }; |
| 79 | 103 | |
| 104 | /** `a` — hand the issue to a person, an agent, or nobody. */ | |
| 105 | const assign = (): void => { | |
| 106 | if (repo === undefined || issueRow === undefined) return; | |
| 107 | assignPicker(f, ctx, screen, repo, issueRow, () => runAsync(ctx, reload)); | |
| 108 | }; | |
| 109 | ||
| 110 | const openRun = (): void => { | |
| 111 | const run = crew.selected; | |
| 112 | if (run !== undefined && repo !== undefined) | |
| 113 | ctx.push(runScreen(f, ctx, repo, run.id)); | |
| 114 | }; | |
| 115 | ||
| 80 | 116 | /** The core row a write needs. Only ever reached on a local source. */ |
| 81 | 117 | const row = (): |
| 82 | 118 | | { repo: Repo; issue: ReturnType<typeof getIssue> } |
| @@ -155,10 +191,16 @@ export function issueScreen( | ||
| 155 | 191 | title: `#${number}`, |
| 156 | 192 | get keys() { |
| 157 | 193 | return [ |
| 158 | binding(["j", "k"], "scroll"), | |
| 194 | ...(crew.runs.length > 0 | |
| 195 | ? [binding(["tab"], "conversation/crew")] | |
| 196 | : []), | |
| 197 | focus === "crew" | |
| 198 | ? binding(["enter"], "open the run") | |
| 199 | : binding(["j", "k"], "scroll"), | |
| 159 | 200 | ...(mayWrite() |
| 160 | 201 | ? [ |
| 161 | 202 | binding(["c"], "comment"), |
| 203 | binding(["a"], "assign"), | |
| 162 | 204 | binding(["x"], "close", { |
| 163 | 205 | disabled: detail?.issue.state === "closed", |
| 164 | 206 | }), |
| @@ -184,11 +226,15 @@ export function issueScreen( | ||
| 184 | 226 | const foot = composer.height(); |
| 185 | 227 | // The where-strip, then three rows of head: the title, the summary |
| 186 | 228 | // line, and one blank so the first entry is not glued to the metadata. |
| 187 | const [strip, head, body, box] = split(area, "v", [ | |
| 229 | // Then the crew card, only when an agent has worked here. | |
| 230 | const crewH = | |
| 231 | crew.runs.length === 0 ? 0 : Math.min(crew.runs.length, CREW_ROWS) + 2; | |
| 232 | const [strip, head, crewRow, body, box] = split(area, "v", [ | |
| 188 | 233 | len(whereRows(src)), |
| 189 | 234 | len(3), |
| 235 | len(crewH), | |
| 190 | 236 | fill(1), |
| 191 | len(Math.min(foot, Math.max(0, area.h - 7 - whereRows(src)))), | |
| 237 | len(Math.min(foot, Math.max(0, area.h - 7 - crewH - whereRows(src)))), | |
| 192 | 238 | ]); |
| 193 | 239 | whereStrip(s, strip!, c, f, src); |
| 194 | 240 | const right = head!.x + head!.w; |
| @@ -214,7 +260,7 @@ export function issueScreen( | ||
| 214 | 260 | x, |
| 215 | 261 | head!.y + 1, |
| 216 | 262 | truncate( |
| 217 | ` ${issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)} · ${plural(detail.comments.length, "comment")}`, | |
| 263 | ` ${issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)} · ${plural(detail.comments.length, "comment")}${assignedText(issueRow, ctx.icons)}`, | |
| 218 | 264 | Math.max(0, right - x), |
| 219 | 265 | ), |
| 220 | 266 | style(c.mutedFg, c.bg), |
| @@ -229,6 +275,16 @@ export function issueScreen( | ||
| 229 | 275 | { text: label.name, color: label.color }, |
| 230 | 276 | right, |
| 231 | 277 | ); |
| 278 | if (crewH > 0) { | |
| 279 | const inner = card(s, crewRow!, c, { | |
| 280 | title: `Crew · ${plural(crew.runs.length, "run")}`, | |
| 281 | focused: focus === "crew", | |
| 282 | }); | |
| 283 | crew.render(s, inner, c, ctx, focus === "crew", (run) => { | |
| 284 | focus = "crew"; | |
| 285 | if (repo !== undefined) ctx.push(runScreen(f, ctx, repo, run.id)); | |
| 286 | }); | |
| 287 | } | |
| 232 | 288 | |
| 233 | 289 | if (width !== body!.w) { |
| 234 | 290 | width = body!.w; |
| @@ -264,6 +320,21 @@ export function issueScreen( | ||
| 264 | 320 | else if (out === "cancel") composer.close(); |
| 265 | 321 | return true; |
| 266 | 322 | } |
| 323 | if (e.key === "tab" && crew.runs.length > 0) { | |
| 324 | focus = focus === "thread" ? "crew" : "thread"; | |
| 325 | return true; | |
| 326 | } | |
| 327 | if (focus === "crew") { | |
| 328 | if (crew.state.key(e, crew.runs.length)) return true; | |
| 329 | if (e.key === "enter") { | |
| 330 | openRun(); | |
| 331 | return true; | |
| 332 | } | |
| 333 | } | |
| 334 | if (e.key === "a" && mayWrite()) { | |
| 335 | assign(); | |
| 336 | return true; | |
| 337 | } | |
| 267 | 338 | if (e.key === "c" && mayWrite()) { |
| 268 | 339 | composer.start("Comment"); |
| 269 | 340 | return true; |
src/tui/screens/origins.ts +1 −6
| @@ -43,6 +43,7 @@ import { | ||
| 43 | 43 | confirmScreen, |
| 44 | 44 | drawList, |
| 45 | 45 | errorText, |
| 46 | later, | |
| 46 | 47 | openLink, |
| 47 | 48 | promptScreen, |
| 48 | 49 | runAsync, |
| @@ -62,12 +63,6 @@ import { local } from "./where.ts"; | ||
| 62 | 63 | * push by a microtask, which is exactly the ordering wanted: prompt closes, |
| 63 | 64 | * next screen opens. |
| 64 | 65 | */ |
| 65 | function later(ctx: Ctx, next: () => Screen): Promise<void> { | |
| 66 | return Promise.resolve().then(() => { | |
| 67 | ctx.push(next()); | |
| 68 | }); | |
| 69 | } | |
| 70 | ||
| 71 | 66 | /** `codeberg.org` from `https://codeberg.org` — what a person calls a forge. */ |
| 72 | 67 | export function hostLabel(url: string): string { |
| 73 | 68 | return URL.parse(url)?.host ?? url; |
src/tui/screens/pull.ts +368 −41
| @@ -37,9 +37,15 @@ import { | ||
| 37 | 37 | resolveReviewThread, |
| 38 | 38 | submitReview, |
| 39 | 39 | } from "../../actions/pulls.ts"; |
| 40 | import { getIssue } from "../../core/issues.ts"; | |
| 40 | import { openEnvUrl } from "../../actions/apps.ts"; | |
| 41 | import { listRepoRuns } from "../../actions/runs.ts"; | |
| 42 | import { previewEnv } from "../../apps/data.ts"; | |
| 43 | import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts"; | |
| 44 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 45 | import { getIssue, type Issue } from "../../core/issues.ts"; | |
| 41 | 46 | import { getPull, type Pull } from "../../core/pulls.ts"; |
| 42 | 47 | import { getRepoById, type Repo } from "../../core/repos.ts"; |
| 48 | import { checkSummary, type CheckSummary } from "../../core/runs.ts"; | |
| 43 | 49 | import { err, ok } from "../../kernel/result.ts"; |
| 44 | 50 | import { getUsersByIds } from "../../core/users.ts"; |
| 45 | 51 | import { |
| @@ -59,11 +65,17 @@ import type { | ||
| 59 | 65 | } from "../../view/source.ts"; |
| 60 | 66 | import type { Ctx, Screen } from "../app/app.ts"; |
| 61 | 67 | import { binding } from "../app/keys.ts"; |
| 62 | import { G, type Span } from "../term/screen.ts"; | |
| 63 | import { BOLD, ITALIC, style } from "../term/style.ts"; | |
| 68 | import { | |
| 69 | G, | |
| 70 | type Rect, | |
| 71 | type Screen as Surface, | |
| 72 | type Span, | |
| 73 | } from "../term/screen.ts"; | |
| 74 | import { BOLD, ITALIC, style, type Color } from "../term/style.ts"; | |
| 64 | 75 | import { truncate } from "../term/width.ts"; |
| 65 | 76 | import { fill, len, split } from "../ui/layout.ts"; |
| 66 | import { badge, card, tabs } from "../ui/widgets.ts"; | |
| 77 | import type { Tokens } from "../ui/theme.ts"; | |
| 78 | import { badge, card, dot, tabs, type Tone } from "../ui/widgets.ts"; | |
| 67 | 79 | import type { Session } from "../session.ts"; |
| 68 | 80 | import { |
| 69 | 81 | ORIGIN_MARK, |
| @@ -74,13 +86,19 @@ import { | ||
| 74 | 86 | hitScroll, |
| 75 | 87 | errorText, |
| 76 | 88 | live, |
| 89 | metaOf, | |
| 90 | openLink, | |
| 77 | 91 | plural, |
| 78 | 92 | relTime, |
| 79 | 93 | runAsync, |
| 80 | 94 | } from "./common.ts"; |
| 95 | import { envTone } from "./apps.ts"; | |
| 96 | import { assignPicker, assignedText } from "./assign.ts"; | |
| 81 | 97 | import { DiffView, type DiffAnchor, type DiffNote } from "./commits.ts"; |
| 82 | 98 | import { liveBindings, liveKey } from "./mirror.ts"; |
| 83 | 99 | import { reviewScreen } from "./review.ts"; |
| 100 | import { runScreen } from "./run.ts"; | |
| 101 | import { RunList, runTone } from "./runs.ts"; | |
| 84 | 102 | import { Composer, threadLines, type ThreadEntry } from "./thread.ts"; |
| 85 | 103 | import { local, localRepo, whereRows, whereStrip } from "./where.ts"; |
| 86 | 104 | |
| @@ -130,10 +148,7 @@ export function standingOf(reviews: readonly ReviewFact[]): Standing { | ||
| 130 | 148 | } |
| 131 | 149 | |
| 132 | 150 | /** `2 approvals · changes requested by bob` — the merge box's second line. */ |
| 133 | export function standingText( | |
| 134 | standing: Standing, | |
| 135 | required: boolean, | |
| 136 | ): string { | |
| 151 | export function standingText(standing: Standing, required: boolean): string { | |
| 137 | 152 | const parts = [ |
| 138 | 153 | standing.approvals === 0 |
| 139 | 154 | ? required |
| @@ -148,7 +163,11 @@ export function standingText( | ||
| 148 | 163 | return parts.join(" · "); |
| 149 | 164 | } |
| 150 | 165 | |
| 151 | type Tab = 0 | 1; | |
| 166 | /** Conversation, files, and — when agents have worked here — the crew. */ | |
| 167 | type Tab = 0 | 1 | 2; | |
| 168 | ||
| 169 | /** The checks on a pull, before any deploy has run. */ | |
| 170 | const NO_CHECK: CheckSummary = { state: "none", run: null }; | |
| 152 | 171 | |
| 153 | 172 | /** What each verdict is called on screen — one wording, every surface. */ |
| 154 | 173 | const VERDICT_WORDS: Record<ReviewFact["state"], string> = { |
| @@ -186,6 +205,17 @@ export function pullScreen( | ||
| 186 | 205 | * the merge box says without anybody re-entering the screen. |
| 187 | 206 | */ |
| 188 | 207 | let requireReview = repo?.requireReview === true; |
| 208 | /** The second gate, read the same way: a passing deploy of this head. */ | |
| 209 | let requireChecks = repo?.requireChecks === true; | |
| 210 | /** The local issue row — who it is assigned to. Undefined on a live browse. */ | |
| 211 | let issueRow: Issue | undefined; | |
| 212 | /** Whether the repository is an app, and its preview of this pull if so. */ | |
| 213 | let isApp = false; | |
| 214 | let preview: EnvStatus | undefined; | |
| 215 | /** The deploy of the *current* head — what the merge box calls the checks. */ | |
| 216 | let check: CheckSummary = NO_CHECK; | |
| 217 | /** Agent runs about this pull, newest first. */ | |
| 218 | const crew = new RunList(f); | |
| 189 | 219 | const mayWrite = (): boolean => src.capabilities.write && repo !== undefined; |
| 190 | 220 | scroll.top = keep.top ?? 0; |
| 191 | 221 | |
| @@ -202,13 +232,38 @@ export function pullScreen( | ||
| 202 | 232 | // Review comments are anchored rows in *our* database; a live browse has |
| 203 | 233 | // none of them, and neither forge exposes an equivalent we could read. |
| 204 | 234 | const row = repo === undefined ? undefined : getPull(f.db, repo.id, number); |
| 205 | requireReview = | |
| 206 | repo !== undefined && getRepoById(f.db, repo.id)?.requireReview === true; | |
| 235 | const settings = | |
| 236 | repo === undefined ? undefined : getRepoById(f.db, repo.id); | |
| 237 | requireReview = settings?.requireReview === true; | |
| 238 | requireChecks = settings?.requireChecks === true; | |
| 207 | 239 | reviews = |
| 208 | 240 | row === undefined |
| 209 | 241 | ? [] |
| 210 | 242 | : withOutdated(listReviewComments(f.db, row.id), diff); |
| 211 | 243 | threads = row === undefined ? [] : readThreads(row); |
| 244 | issueRow = repo === undefined ? undefined : getIssue(f.db, repo.id, number); | |
| 245 | // The app half: the preview environment (in memory, from the runtime), | |
| 246 | // the checks at the live head (a row), and what the crew has done here. | |
| 247 | const record = repo === undefined ? undefined : getAppByRepo(f.db, repo.id); | |
| 248 | isApp = record !== undefined; | |
| 249 | preview = | |
| 250 | record === undefined | |
| 251 | ? undefined | |
| 252 | : appsRuntime(f.app)?.envStatus(record, previewEnv(number)); | |
| 253 | check = | |
| 254 | row === undefined | |
| 255 | ? NO_CHECK | |
| 256 | : checkSummary(f.db, row.id, row.headOid || (facts?.headOid ?? "")); | |
| 257 | const runs = | |
| 258 | repo === undefined || row === undefined | |
| 259 | ? undefined | |
| 260 | : listRepoRuns(f.app, f.user, repo, { | |
| 261 | kind: "agent", | |
| 262 | issueId: row.id, | |
| 263 | limit: 50, | |
| 264 | }); | |
| 265 | crew.set(runs !== undefined && runs.ok ? runs.value : []); | |
| 266 | if (tab === 2 && crew.runs.length === 0) tab = 0; | |
| 212 | 267 | view.setFiles(diff.files); |
| 213 | 268 | view.setNotes(threadNotes()); |
| 214 | 269 | width = 0; |
| @@ -334,7 +389,10 @@ export function pullScreen( | ||
| 334 | 389 | kind: "comment" as const, |
| 335 | 390 | actor: review.author, |
| 336 | 391 | at: review.createdAt, |
| 337 | body: review.body === "" ? `_${VERDICT_WORDS[review.state]}._` : review.body, | |
| 392 | body: | |
| 393 | review.body === "" | |
| 394 | ? `_${VERDICT_WORDS[review.state]}._` | |
| 395 | : review.body, | |
| 338 | 396 | badge: { |
| 339 | 397 | text: VERDICT_WORDS[review.state], |
| 340 | 398 | // A verdict a push has dismissed is muted, not green and not red: |
| @@ -370,6 +428,39 @@ export function pullScreen( | ||
| 370 | 428 | return now.blocking.length > 0 || (requireReview && now.approvals === 0); |
| 371 | 429 | }; |
| 372 | 430 | |
| 431 | /** | |
| 432 | * The checks gate: with `require_checks` on, the current head's deploy | |
| 433 | * must be `ok`. Rendered here; `mergePull` reads the row live and refuses | |
| 434 | * `checks_required` on its own. | |
| 435 | */ | |
| 436 | const checksGated = (): boolean => | |
| 437 | repo !== undefined && requireChecks && check.state !== "ok"; | |
| 438 | ||
| 439 | /** `O` — the preview, through a grant when the app is private. */ | |
| 440 | const openPreview = (): void => { | |
| 441 | if (repo === undefined || !isApp) return; | |
| 442 | const url = openEnvUrl(f.app, f.user, repo, previewEnv(number)); | |
| 443 | if (!url.ok) { | |
| 444 | ctx.toast(errorText(url.error), "danger"); | |
| 445 | return; | |
| 446 | } | |
| 447 | if (preview === undefined || preview.state !== "running") | |
| 448 | ctx.toast( | |
| 449 | `the preview is ${preview?.state ?? "not running"}; the page will say so`, | |
| 450 | "attn", | |
| 451 | ); | |
| 452 | openLink(ctx, url.value); | |
| 453 | }; | |
| 454 | ||
| 455 | /** `a` — hand the pull to a person, an agent, or nobody. */ | |
| 456 | const assign = (): void => { | |
| 457 | if (repo === undefined || issueRow === undefined) return; | |
| 458 | assignPicker(f, ctx, screen, repo, issueRow, () => runAsync(ctx, reload)); | |
| 459 | }; | |
| 460 | ||
| 461 | /** The tabs that exist right now: the crew's only once it has worked here. */ | |
| 462 | const tabCount = (): number => (crew.runs.length > 0 ? 3 : 2); | |
| 463 | ||
| 373 | 464 | const submit = (): void => { |
| 374 | 465 | if (repo === undefined) return; |
| 375 | 466 | const target = repo; |
| @@ -433,7 +524,10 @@ export function pullScreen( | ||
| 433 | 524 | }); |
| 434 | 525 | if (!sent.ok) return err(errorText(String(sent.error))); |
| 435 | 526 | await reload(); |
| 436 | ctx.toast(`review submitted: ${VERDICT_WORDS[input.state]}`, "success"); | |
| 527 | ctx.toast( | |
| 528 | `review submitted: ${VERDICT_WORDS[input.state]}`, | |
| 529 | "success", | |
| 530 | ); | |
| 437 | 531 | return ok(sent.value); |
| 438 | 532 | }, |
| 439 | 533 | }), |
| @@ -481,18 +575,26 @@ export function pullScreen( | ||
| 481 | 575 | |
| 482 | 576 | const mirrored = (): boolean => repo === undefined || repo.origin !== null; |
| 483 | 577 | const state = (): MergeState => |
| 484 | mergeState(detail?.issue, facts, mirrored(), gated()); | |
| 578 | mergeState(detail?.issue, facts, mirrored(), gated(), checksGated()); | |
| 485 | 579 | |
| 486 | 580 | const screen: Screen = { |
| 487 | 581 | title: `#${number}`, |
| 488 | 582 | get keys() { |
| 489 | 583 | return [ |
| 490 | binding(["tab"], "conversation/files"), | |
| 491 | ...(tab === 1 ? view.keys : [binding(["j", "k"], "scroll")]), | |
| 584 | binding(["tab"], tabCount() === 3 ? "next tab" : "conversation/files"), | |
| 585 | ...(tab === 1 | |
| 586 | ? view.keys | |
| 587 | : tab === 2 | |
| 588 | ? [binding(["j", "k"], "move"), binding(["enter"], "open the run")] | |
| 589 | : [binding(["j", "k"], "scroll")]), | |
| 590 | ...(isApp && preview !== undefined | |
| 591 | ? [binding(["O"], "open the preview")] | |
| 592 | : []), | |
| 492 | 593 | ...(mayWrite() |
| 493 | 594 | ? [ |
| 494 | 595 | binding(["c"], tab === 1 ? "review line" : "comment"), |
| 495 | 596 | binding(["R"], "review"), |
| 597 | binding(["a"], "assign"), | |
| 496 | 598 | binding(["m"], "merge", { disabled: state() !== "ready" }), |
| 497 | 599 | ...(tab === 1 && view.threadHere() !== null |
| 498 | 600 | ? [ |
| @@ -519,21 +621,30 @@ export function pullScreen( | ||
| 519 | 621 | if (!detail) return; |
| 520 | 622 | const issue = detail.issue; |
| 521 | 623 | const foot = composer.height(); |
| 522 | // Four rows when there are verdicts to report, three when there are | |
| 523 | // none: the box is exactly as tall as what is in it. | |
| 524 | const boxH = standingText(standing(), requireReview) === "" | |
| 525 | ? 3 | |
| 526 | : 4; | |
| 527 | const [strip, head, boxRow, tabRow, body, comp] = split(area, "v", [ | |
| 528 | len(whereRows(src)), | |
| 529 | len(1), | |
| 530 | len(boxH), | |
| 531 | len(2), | |
| 532 | fill(1), | |
| 533 | len(Math.min(foot, Math.max(0, area.h - 10 - whereRows(src)))), | |
| 534 | ]); | |
| 624 | // The box is exactly as tall as what is in it: the state, then a row | |
| 625 | // for the verdicts when there are any, then a row for the checks when | |
| 626 | // a deploy has run or the gate demands one. | |
| 627 | const checksLine = checksText(check, requireChecks, isApp); | |
| 628 | const boxH = | |
| 629 | 3 + | |
| 630 | (standingText(standing(), requireReview) === "" ? 0 : 1) + | |
| 631 | (checksLine === "" ? 0 : 1); | |
| 632 | const [strip, head, previewRow, boxRow, tabRow, body, comp] = split( | |
| 633 | area, | |
| 634 | "v", | |
| 635 | [ | |
| 636 | len(whereRows(src)), | |
| 637 | len(2), | |
| 638 | len(isApp ? 1 : 0), | |
| 639 | len(boxH), | |
| 640 | len(2), | |
| 641 | fill(1), | |
| 642 | len(Math.min(foot, Math.max(0, area.h - 13 - whereRows(src)))), | |
| 643 | ], | |
| 644 | ); | |
| 535 | 645 | whereStrip(s, strip!, c, f, src); |
| 536 | // Same head as an issue: the number is a locator, the title is the page. | |
| 646 | // Same head as an issue: the number is a locator, the title is the page, | |
| 647 | // and the second row says who opened it and who is on it. | |
| 537 | 648 | const edge = head!.x + head!.w; |
| 538 | 649 | const after = s.text( |
| 539 | 650 | head!.x, |
| @@ -549,8 +660,28 @@ export function pullScreen( | ||
| 549 | 660 | style(c.fg, c.bg, BOLD), |
| 550 | 661 | edge, |
| 551 | 662 | ); |
| 663 | const closed = issue.state !== "open"; | |
| 664 | const x1 = dot(s, head!.x, head!.y + 1, c, { | |
| 665 | tone: | |
| 666 | facts?.mergedAt != null ? "merged" : closed ? "danger" : "success", | |
| 667 | }); | |
| 668 | s.text( | |
| 669 | x1, | |
| 670 | head!.y + 1, | |
| 671 | truncate( | |
| 672 | ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${assignedText(issueRow, ctx.icons)}`, | |
| 673 | Math.max(0, edge - x1), | |
| 674 | ), | |
| 675 | style(c.mutedFg, c.bg), | |
| 676 | edge, | |
| 677 | ); | |
| 678 | if (isApp && previewRow!.h > 0) | |
| 679 | drawPreviewLine(s, previewRow!, c, preview); | |
| 552 | 680 | drawMergeBox(s, boxRow!, c, issue, facts, view.summary(), mirrored(), { |
| 553 | 681 | gated: gated(), |
| 682 | checksGated: checksGated(), | |
| 683 | check, | |
| 684 | checksLine, | |
| 554 | 685 | standing: standing(), |
| 555 | 686 | required: requireReview, |
| 556 | 687 | }); |
| @@ -561,10 +692,17 @@ export function pullScreen( | ||
| 561 | 692 | count: conversation().length + reviews.length + verdicts().length, |
| 562 | 693 | }, |
| 563 | 694 | { label: "Files", count: diff.files.length }, |
| 695 | ...(crew.runs.length > 0 | |
| 696 | ? [{ label: "Crew", count: crew.runs.length }] | |
| 697 | : []), | |
| 564 | 698 | ], |
| 565 | 699 | active: tab, |
| 566 | 700 | }); |
| 567 | if (tab === 1) { | |
| 701 | if (tab === 2) { | |
| 702 | crew.render(s, body!, c, ctx, true, (run) => { | |
| 703 | if (repo !== undefined) ctx.push(runScreen(f, ctx, repo, run.id)); | |
| 704 | }); | |
| 705 | } else if (tab === 1) { | |
| 568 | 706 | // The thread count is a section header, not a badge on the tab: how |
| 569 | 707 | // many conversations are still open is the question a reviewer opens |
| 570 | 708 | // this tab to answer, and it deserves a line. |
| @@ -612,13 +750,31 @@ export function pullScreen( | ||
| 612 | 750 | return true; |
| 613 | 751 | } |
| 614 | 752 | if (e.key === "tab") { |
| 615 | tab = tab === 0 ? 1 : 0; | |
| 753 | tab = ((tab + 1) % tabCount()) as Tab; | |
| 616 | 754 | return true; |
| 617 | 755 | } |
| 618 | 756 | if (e.key === "R" && mayWrite()) { |
| 619 | 757 | review(); |
| 620 | 758 | return true; |
| 621 | 759 | } |
| 760 | if (e.key === "O" && isApp && preview !== undefined) { | |
| 761 | openPreview(); | |
| 762 | return true; | |
| 763 | } | |
| 764 | if (e.key === "a" && mayWrite()) { | |
| 765 | assign(); | |
| 766 | return true; | |
| 767 | } | |
| 768 | // The crew tab is a list: the cursor moves, enter opens the run. | |
| 769 | if (tab === 2) { | |
| 770 | if (crew.state.key(e, crew.runs.length)) return true; | |
| 771 | if (e.key === "enter") { | |
| 772 | const run = crew.selected; | |
| 773 | if (run !== undefined && repo !== undefined) | |
| 774 | ctx.push(runScreen(f, ctx, repo, run.id)); | |
| 775 | return true; | |
| 776 | } | |
| 777 | } | |
| 622 | 778 | // A thread the cursor is standing in owns `x` and `enter` before the |
| 623 | 779 | // screen does: settling a conversation is the act on offer there, and |
| 624 | 780 | // closing the whole pull from inside one would be a surprise. |
| @@ -664,6 +820,10 @@ export function pullScreen( | ||
| 664 | 820 | ); |
| 665 | 821 | return true; |
| 666 | 822 | } |
| 823 | if (e.key === "m" && mayWrite() && state() === "checks_required") { | |
| 824 | ctx.toast(checksRefusal(check), "attn"); | |
| 825 | return true; | |
| 826 | } | |
| 667 | 827 | if ( |
| 668 | 828 | e.key === "m" && |
| 669 | 829 | mayWrite() && |
| @@ -694,6 +854,18 @@ export function pullScreen( | ||
| 694 | 854 | ); |
| 695 | 855 | return true; |
| 696 | 856 | } |
| 857 | // `o` on an open pull with a preview opens the preview; on a closed | |
| 858 | // one it reopens the pull, as it always has. Nothing is lost: the | |
| 859 | // "reopen" reading only ever meant something on a closed pull. | |
| 860 | if ( | |
| 861 | e.key === "o" && | |
| 862 | isApp && | |
| 863 | preview !== undefined && | |
| 864 | detail?.issue.state === "open" | |
| 865 | ) { | |
| 866 | openPreview(); | |
| 867 | return true; | |
| 868 | } | |
| 697 | 869 | if ( |
| 698 | 870 | (e.key === "x" || e.key === "o") && |
| 699 | 871 | mayWrite() && |
| @@ -733,6 +905,7 @@ export function pullScreen( | ||
| 733 | 905 | }) |
| 734 | 906 | ) |
| 735 | 907 | return true; |
| 908 | if (tab === 2) return true; | |
| 736 | 909 | return tab === 1 ? view.key(e, ctx) : scroll.key(e, lines.length); |
| 737 | 910 | }, |
| 738 | 911 | }; |
| @@ -747,9 +920,120 @@ export type MergeState = | ||
| 747 | 920 | | "empty" |
| 748 | 921 | /** The verdicts do not let this land yet — `mergePull` refuses it too. */ |
| 749 | 922 | | "review_required" |
| 923 | /** The head's deploy is not green yet — `mergePull` refuses it too. */ | |
| 924 | | "checks_required" | |
| 750 | 925 | | "ready" |
| 751 | 926 | | "unknown"; |
| 752 | 927 | |
| 928 | /** | |
| 929 | * The preview, in one line: a dot in the environment's tone, the word, the | |
| 930 | * URL as a link, and the error when there is one — never "failed" alone. | |
| 931 | */ | |
| 932 | function drawPreviewLine( | |
| 933 | s: Surface, | |
| 934 | r: Rect, | |
| 935 | c: Tokens, | |
| 936 | preview: EnvStatus | undefined, | |
| 937 | ): void { | |
| 938 | const right = r.x + r.w; | |
| 939 | const tone = preview === undefined ? undefined : envTone(preview.state); | |
| 940 | let x = dot(s, r.x, r.y, c, { | |
| 941 | ...(tone === undefined ? { hollow: true } : { tone }), | |
| 942 | }); | |
| 943 | x = s.text(x + 1, r.y, "preview ", style(c.mutedFg, c.bg), right); | |
| 944 | if (preview === undefined) { | |
| 945 | s.text( | |
| 946 | x, | |
| 947 | r.y, | |
| 948 | "none — a preview runs while the pull is open", | |
| 949 | style(c.faint, c.bg), | |
| 950 | right, | |
| 951 | ); | |
| 952 | return; | |
| 953 | } | |
| 954 | const words = | |
| 955 | preview.state === "failed" | |
| 956 | ? `failed: ${preview.error ?? "the deploy did not finish"}` | |
| 957 | : preview.state === "running" | |
| 958 | ? `running${preview.since === undefined ? "" : ` since ${ago(preview.since)}`}` | |
| 959 | : preview.state === "deploying" | |
| 960 | ? `deploying ${preview.commitOid.slice(0, 7)}` | |
| 961 | : "stopped"; | |
| 962 | x = s.text( | |
| 963 | x, | |
| 964 | r.y, | |
| 965 | truncate(words, Math.max(0, right - x)), | |
| 966 | style(tone === undefined ? c.mutedFg : toneColor(c, tone), c.bg), | |
| 967 | right, | |
| 968 | ); | |
| 969 | if (preview.state !== "failed") | |
| 970 | s.linkText( | |
| 971 | x + 2, | |
| 972 | r.y, | |
| 973 | truncate(preview.url, Math.max(0, right - x - 2)), | |
| 974 | style(c.mutedFg, c.bg), | |
| 975 | preview.url, | |
| 976 | right, | |
| 977 | ); | |
| 978 | } | |
| 979 | ||
| 980 | function toneColor(c: Tokens, tone: Tone): Color { | |
| 981 | return tone === "success" | |
| 982 | ? c.success | |
| 983 | : tone === "danger" | |
| 984 | ? c.danger | |
| 985 | : tone === "attn" | |
| 986 | ? c.attn | |
| 987 | : tone === "merged" | |
| 988 | ? c.merged | |
| 989 | : c.mutedFg; | |
| 990 | } | |
| 991 | ||
| 992 | /** | |
| 993 | * The checks row of the merge box: what the current head's deploy did. | |
| 994 | * Empty when nothing has deployed and nothing demands it — a box row that | |
| 995 | * says "no checks" on a repository that is not an app is noise. | |
| 996 | */ | |
| 997 | export function checksText( | |
| 998 | check: CheckSummary, | |
| 999 | required: boolean, | |
| 1000 | isApp: boolean, | |
| 1001 | ): string { | |
| 1002 | if (check.state === "none") | |
| 1003 | return required | |
| 1004 | ? "checks: none yet — a deploy of this head is required" | |
| 1005 | : isApp | |
| 1006 | ? "checks: no deploy of this head yet" | |
| 1007 | : ""; | |
| 1008 | const run = check.run; | |
| 1009 | const summary = run === null || run.summary === "" ? "" : ` · ${run.summary}`; | |
| 1010 | switch (check.state) { | |
| 1011 | case "ok": | |
| 1012 | return `checks: passed${summary}`; | |
| 1013 | case "failed": | |
| 1014 | return `checks: failed${summary}`; | |
| 1015 | case "running": | |
| 1016 | return "checks: deploying this head"; | |
| 1017 | case "queued": | |
| 1018 | return "checks: a deploy of this head is queued"; | |
| 1019 | case "cancelled": | |
| 1020 | return `checks: the deploy was cancelled${summary}`; | |
| 1021 | } | |
| 1022 | } | |
| 1023 | ||
| 1024 | /** Why `m` is refused by the checks gate, in words a person can act on. */ | |
| 1025 | export function checksRefusal(check: CheckSummary): string { | |
| 1026 | switch (check.state) { | |
| 1027 | case "failed": | |
| 1028 | return `the deploy of this head failed${check.run?.summary ? `: ${check.run.summary}` : ""}`; | |
| 1029 | case "running": | |
| 1030 | case "queued": | |
| 1031 | return "wait for the deploy of this head to pass"; | |
| 1032 | default: | |
| 1033 | return errorText("checks_required"); | |
| 1034 | } | |
| 1035 | } | |
| 1036 | ||
| 753 | 1037 | /** |
| 754 | 1038 | * What the merge box says, from what the source could tell us. |
| 755 | 1039 | * |
| @@ -761,6 +1045,7 @@ export function mergeState( | ||
| 761 | 1045 | pull: PullFacts | null | undefined, |
| 762 | 1046 | mirrored = false, |
| 763 | 1047 | gated = false, |
| 1048 | checksGated = false, | |
| 764 | 1049 | ): MergeState { |
| 765 | 1050 | if (issue === undefined || pull === null || pull === undefined) |
| 766 | 1051 | return "unknown"; |
| @@ -774,7 +1059,9 @@ export function mergeState( | ||
| 774 | 1059 | if (!pull.mergeable) return "conflicts"; |
| 775 | 1060 | // Git could take it; the reviewers have not. Said after the conflict check |
| 776 | 1061 | // because a conflict is the more urgent of the two and only one line fits. |
| 777 | return gated ? "review_required" : "ready"; | |
| 1062 | // The checks come after the reviewers for the same reason: a person's | |
| 1063 | // objection outranks a machine's. | |
| 1064 | return gated ? "review_required" : checksGated ? "checks_required" : "ready"; | |
| 778 | 1065 | } |
| 779 | 1066 | |
| 780 | 1067 | function drawMergeBox( |
| @@ -785,9 +1072,17 @@ function drawMergeBox( | ||
| 785 | 1072 | pull: PullFacts | null, |
| 786 | 1073 | summary: string, |
| 787 | 1074 | mirrored: boolean, |
| 788 | o: { gated: boolean; standing: Standing; required: boolean }, | |
| 1075 | o: { | |
| 1076 | gated: boolean; | |
| 1077 | checksGated: boolean; | |
| 1078 | check: CheckSummary; | |
| 1079 | /** The checks row, already worded; "" draws none. */ | |
| 1080 | checksLine: string; | |
| 1081 | standing: Standing; | |
| 1082 | required: boolean; | |
| 1083 | }, | |
| 789 | 1084 | ): void { |
| 790 | const state = mergeState(issue, pull, mirrored, o.gated); | |
| 1085 | const state = mergeState(issue, pull, mirrored, o.gated, o.checksGated); | |
| 791 | 1086 | const tone = |
| 792 | 1087 | state === "merged" |
| 793 | 1088 | ? "merged" |
| @@ -795,7 +1090,9 @@ function drawMergeBox( | ||
| 795 | 1090 | ? "success" |
| 796 | 1091 | : state === "conflicts" || state === "closed" |
| 797 | 1092 | ? "danger" |
| 798 | : state === "mirrored" || state === "review_required" | |
| 1093 | : state === "mirrored" || | |
| 1094 | state === "review_required" || | |
| 1095 | state === "checks_required" | |
| 799 | 1096 | ? "attn" |
| 800 | 1097 | : "default"; |
| 801 | 1098 | // Where it is going belongs on the frame, not on a body row: the box is |
| @@ -824,9 +1121,20 @@ function drawMergeBox( | ||
| 824 | 1121 | ? o.standing.blocking.length > 0 |
| 825 | 1122 | ? `Changes requested by ${o.standing.blocking.join(", ")}` |
| 826 | 1123 | : "Needs an approval of this head before it can merge" |
| 827 | : `Ready to merge · ${summary}`; | |
| 1124 | : state === "checks_required" | |
| 1125 | ? o.check.state === "failed" | |
| 1126 | ? "The deploy of this head failed; it cannot merge until one passes" | |
| 1127 | : o.check.state === "running" || o.check.state === "queued" | |
| 1128 | ? "Waiting for the deploy of this head to pass" | |
| 1129 | : "Needs a passing deploy of this head before it can merge" | |
| 1130 | : `Ready to merge · ${summary}`; | |
| 828 | 1131 | const x = badge(s, inner.x, inner.y, c, { |
| 829 | text: state === "review_required" ? "review" : state, | |
| 1132 | text: | |
| 1133 | state === "review_required" | |
| 1134 | ? "review" | |
| 1135 | : state === "checks_required" | |
| 1136 | ? "checks" | |
| 1137 | : state, | |
| 830 | 1138 | variant: tone as "success", |
| 831 | 1139 | }); |
| 832 | 1140 | s.text( |
| @@ -840,13 +1148,32 @@ function drawMergeBox( | ||
| 840 | 1148 | // something to say — a box with an empty second line reads as a box that |
| 841 | 1149 | // lost something. |
| 842 | 1150 | const verdicts = standingText(o.standing, o.required); |
| 843 | if (verdicts !== "" && inner.h > 1) | |
| 1151 | let y = inner.y + 1; | |
| 1152 | if (verdicts !== "" && y < inner.y + inner.h) { | |
| 844 | 1153 | s.text( |
| 845 | 1154 | inner.x, |
| 846 | inner.y + 1, | |
| 1155 | y, | |
| 847 | 1156 | truncate(verdicts, inner.w), |
| 848 | 1157 | style(o.standing.blocking.length > 0 ? c.danger : c.mutedFg, c.card), |
| 849 | 1158 | inner.x + inner.w, |
| 850 | 1159 | ); |
| 1160 | y++; | |
| 1161 | } | |
| 1162 | // The checks, under the verdicts: a deploy's outcome in the tone a run | |
| 1163 | // wears everywhere else, with its summary — the failed one names why. | |
| 1164 | if (o.checksLine !== "" && y < inner.y + inner.h) { | |
| 1165 | const checkTone = runTone( | |
| 1166 | o.check.state === "none" ? "queued" : o.check.state, | |
| 1167 | ); | |
| 1168 | s.text( | |
| 1169 | inner.x, | |
| 1170 | y, | |
| 1171 | truncate(o.checksLine, inner.w), | |
| 1172 | style( | |
| 1173 | checkTone === undefined ? c.mutedFg : toneColor(c, checkTone), | |
| 1174 | c.card, | |
| 1175 | ), | |
| 1176 | inner.x + inner.w, | |
| 1177 | ); | |
| 1178 | } | |
| 851 | 1179 | } |
| 852 |
src/tui/screens/run.ts added +299
| @@ -0,0 +1,299 @@ | ||
| 1 | /** | |
| 2 | * One run: what it is, how it ended (or that it has not), and its log, | |
| 3 | * tailed live for as long as the screen is on the stack. | |
| 4 | * | |
| 5 | * The order of the two reads on load is the whole correctness story. The | |
| 6 | * subscription is taken *first* and the file read *second*; both are | |
| 7 | * synchronous, so no line can land between them — every line is either in | |
| 8 | * the file we read or delivered to the listener we registered, never both | |
| 9 | * and never neither. The listener is released by the screen's own abort | |
| 10 | * signal, so popping the screen is what stops the tail. | |
| 11 | * | |
| 12 | * The log follows its end the way `tail -f` does until the reader scrolls | |
| 13 | * up to look at something, and `G` puts it back on the end. | |
| 14 | */ | |
| 15 | ||
| 16 | import { cancelRepoRun, getRepoRun, runLog } from "../../actions/runs.ts"; | |
| 17 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 18 | import { isTerminal, type Run } from "../../core/runs.ts"; | |
| 19 | import { subscribeRunLog } from "../../runs/log.ts"; | |
| 20 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 21 | import { binding } from "../app/keys.ts"; | |
| 22 | import type { Span } from "../term/screen.ts"; | |
| 23 | import { BOLD, ITALIC, style, type Color, type Style } from "../term/style.ts"; | |
| 24 | import { truncate } from "../term/width.ts"; | |
| 25 | import { fill, len, split } from "../ui/layout.ts"; | |
| 26 | import type { Tokens } from "../ui/theme.ts"; | |
| 27 | import { dot, spinner, type Tone } from "../ui/widgets.ts"; | |
| 28 | import type { Session } from "../session.ts"; | |
| 29 | import { | |
| 30 | Scroll, | |
| 31 | ago, | |
| 32 | confirmScreen, | |
| 33 | drawLines, | |
| 34 | errorText, | |
| 35 | hitScroll, | |
| 36 | live, | |
| 37 | metaOf, | |
| 38 | runAsync, | |
| 39 | } from "./common.ts"; | |
| 40 | import { | |
| 41 | costText, | |
| 42 | envLabel, | |
| 43 | runActors, | |
| 44 | runHollow, | |
| 45 | runTitle, | |
| 46 | runTone, | |
| 47 | } from "./runs.ts"; | |
| 48 | ||
| 49 | /** Head rows: the state line, the summary, and the times. */ | |
| 50 | const HEAD = 4; | |
| 51 | ||
| 52 | export function runScreen( | |
| 53 | f: Session, | |
| 54 | ctx: Ctx, | |
| 55 | repo: Repo, | |
| 56 | runId: string, | |
| 57 | ): Screen { | |
| 58 | let run: Run | undefined; | |
| 59 | let names = new Map<string, { username: string }>(); | |
| 60 | let lines: string[] = []; | |
| 61 | let truncated = false; | |
| 62 | let styled: Span[][] = []; | |
| 63 | let follow = true; | |
| 64 | let failed: string | undefined; | |
| 65 | const scroll = new Scroll(); | |
| 66 | ||
| 67 | // The action decides; this only shapes the help bar. A cancel the action | |
| 68 | // refuses is a toast, never a silent keypress. | |
| 69 | const mayCancel = (): boolean => | |
| 70 | run !== undefined && !isTerminal(run.state) && canWrite(f.db, repo, f.user); | |
| 71 | ||
| 72 | const restyle = (): void => { | |
| 73 | const c = ctx.theme.c; | |
| 74 | styled = lines.map((line) => [{ text: line, style: lineStyle(line, c) }]); | |
| 75 | if (truncated) | |
| 76 | styled.unshift([ | |
| 77 | { | |
| 78 | text: "… earlier lines are not shown; the log is large", | |
| 79 | style: style(c.faint, c.bg, ITALIC), | |
| 80 | }, | |
| 81 | ]); | |
| 82 | }; | |
| 83 | ||
| 84 | /** Re-read the row: the state, the summary and the cost move as it runs. */ | |
| 85 | const refreshRun = (): void => { | |
| 86 | const read = getRepoRun(f.app, f.user, repo, runId); | |
| 87 | if (!read.ok) { | |
| 88 | failed = errorText(read.error); | |
| 89 | ctx.invalidate(); | |
| 90 | return; | |
| 91 | } | |
| 92 | run = read.value; | |
| 93 | names = runActors(f, [run]); | |
| 94 | ctx.invalidate(); | |
| 95 | }; | |
| 96 | ||
| 97 | const cancel = (): void => { | |
| 98 | if (run === undefined) return; | |
| 99 | ctx.push( | |
| 100 | confirmScreen(ctx, screen, { | |
| 101 | title: "Cancel this run", | |
| 102 | body: [ | |
| 103 | run.kind === "agent" | |
| 104 | ? "The agent is stopped where it is; what it pushed stays." | |
| 105 | : "The deploy is abandoned; whatever was serving keeps serving.", | |
| 106 | ], | |
| 107 | actions: [ | |
| 108 | { label: "Keep going", variant: "ghost" }, | |
| 109 | { label: "Cancel the run", variant: "danger" }, | |
| 110 | ], | |
| 111 | onChoose: (choice) => { | |
| 112 | if (choice !== 1) return; | |
| 113 | const done = cancelRepoRun(f.app, f.user, repo, runId); | |
| 114 | if (!done.ok) ctx.toast(errorText(done.error), "danger"); | |
| 115 | else { | |
| 116 | run = done.value; | |
| 117 | ctx.toast("run cancelled", "success"); | |
| 118 | ctx.invalidate(); | |
| 119 | } | |
| 120 | }, | |
| 121 | }), | |
| 122 | ); | |
| 123 | }; | |
| 124 | ||
| 125 | const screen: Screen = { | |
| 126 | title: "Run", | |
| 127 | get keys() { | |
| 128 | return [ | |
| 129 | binding(["j", "k"], "scroll"), | |
| 130 | binding(["G"], "follow the end"), | |
| 131 | binding(["x"], "cancel", { disabled: !mayCancel() }), | |
| 132 | ]; | |
| 133 | }, | |
| 134 | load(signal) { | |
| 135 | // Subscribe, then read — see the file comment. | |
| 136 | subscribeRunLog( | |
| 137 | runId, | |
| 138 | (line) => { | |
| 139 | if (signal.aborted) return; | |
| 140 | lines.push(line); | |
| 141 | restyle(); | |
| 142 | ctx.invalidate(); | |
| 143 | }, | |
| 144 | signal, | |
| 145 | ); | |
| 146 | const read = runLog(f.app, f.user, repo, runId); | |
| 147 | if (!read.ok) { | |
| 148 | failed = errorText(read.error); | |
| 149 | ctx.invalidate(); | |
| 150 | return Promise.resolve(); | |
| 151 | } | |
| 152 | run = read.value.run; | |
| 153 | names = runActors(f, [run]); | |
| 154 | lines = read.value.lines; | |
| 155 | truncated = read.value.truncated; | |
| 156 | restyle(); | |
| 157 | live( | |
| 158 | f, | |
| 159 | signal, | |
| 160 | { repoId: repo.id, kinds: ["run.started", "run.finished"] }, | |
| 161 | refreshRun, | |
| 162 | ); | |
| 163 | ctx.invalidate(); | |
| 164 | return Promise.resolve(); | |
| 165 | }, | |
| 166 | render(area, s) { | |
| 167 | const c = ctx.theme.c; | |
| 168 | if (failed !== undefined) { | |
| 169 | s.text(area.x, area.y, failed, style(c.danger, c.bg), area.x + area.w); | |
| 170 | return; | |
| 171 | } | |
| 172 | if (run === undefined) return; | |
| 173 | const [head, body] = split(area, "v", [len(HEAD), fill(1)]); | |
| 174 | const right = head!.x + head!.w; | |
| 175 | const tone = runTone(run.state); | |
| 176 | let x = head!.x; | |
| 177 | if (run.state === "running") x = spinner(s, x, head!.y, c, ctx.tick); | |
| 178 | else | |
| 179 | x = dot(s, x, head!.y, c, { | |
| 180 | ...(tone === undefined ? {} : { tone }), | |
| 181 | ...(runHollow(run.state) ? { hollow: true } : {}), | |
| 182 | }); | |
| 183 | x = s.text( | |
| 184 | x + 1, | |
| 185 | head!.y, | |
| 186 | run.state, | |
| 187 | style(tone === undefined ? c.mutedFg : toneOf(c, tone), c.bg, BOLD), | |
| 188 | right, | |
| 189 | ); | |
| 190 | s.text( | |
| 191 | x + 2, | |
| 192 | head!.y, | |
| 193 | truncate( | |
| 194 | metaOf( | |
| 195 | runTitle(run, names, ctx.icons), | |
| 196 | run.reason === "" ? "" : run.reason, | |
| 197 | run.commitOid === "" ? "" : run.commitOid.slice(0, 7), | |
| 198 | run.branch === "" ? "" : run.branch, | |
| 199 | costText(run.costUsd), | |
| 200 | ), | |
| 201 | Math.max(0, right - x - 2), | |
| 202 | ), | |
| 203 | style(c.fg, c.bg), | |
| 204 | right, | |
| 205 | ); | |
| 206 | s.text( | |
| 207 | head!.x, | |
| 208 | head!.y + 1, | |
| 209 | truncate( | |
| 210 | run.summary !== "" | |
| 211 | ? run.summary | |
| 212 | : run.state === "queued" | |
| 213 | ? "waiting for a worker" | |
| 214 | : run.state === "running" | |
| 215 | ? "no summary yet — the log below is live" | |
| 216 | : "no summary", | |
| 217 | head!.w, | |
| 218 | ), | |
| 219 | style( | |
| 220 | run.state === "failed" ? c.danger : c.mutedFg, | |
| 221 | c.bg, | |
| 222 | run.summary === "" ? ITALIC : 0, | |
| 223 | ), | |
| 224 | right, | |
| 225 | ); | |
| 226 | s.text( | |
| 227 | head!.x, | |
| 228 | head!.y + 2, | |
| 229 | truncate( | |
| 230 | metaOf( | |
| 231 | run.kind === "deploy" ? envLabel(run.env) : "", | |
| 232 | `queued ${ago(run.createdAt)}`, | |
| 233 | run.startedAt === null ? "" : `started ${ago(run.startedAt)}`, | |
| 234 | run.finishedAt === null ? "" : `finished ${ago(run.finishedAt)}`, | |
| 235 | ), | |
| 236 | head!.w, | |
| 237 | ), | |
| 238 | style(c.faint, c.bg), | |
| 239 | right, | |
| 240 | ); | |
| 241 | if (styled.length === 0) { | |
| 242 | s.text( | |
| 243 | body!.x, | |
| 244 | body!.y, | |
| 245 | run.state === "queued" | |
| 246 | ? "Nothing logged yet." | |
| 247 | : "This run wrote no log.", | |
| 248 | style(c.faint, c.bg, ITALIC), | |
| 249 | body!.x + body!.w, | |
| 250 | ); | |
| 251 | return; | |
| 252 | } | |
| 253 | // Following means "the last line is the bottom row", and the bottom | |
| 254 | // row is only known here, where the height is. | |
| 255 | if (follow) scroll.to(styled.length - body!.h, styled.length); | |
| 256 | drawLines(s, body!, c, scroll, styled); | |
| 257 | hitScroll(ctx, body!, scroll, styled.length); | |
| 258 | }, | |
| 259 | key(e) { | |
| 260 | if (e.key === "x" && mayCancel()) { | |
| 261 | cancel(); | |
| 262 | return true; | |
| 263 | } | |
| 264 | if (e.key === "G" || e.key === "end") { | |
| 265 | follow = true; | |
| 266 | return true; | |
| 267 | } | |
| 268 | if (scroll.key(e, styled.length)) { | |
| 269 | // Scrolling away from the end is a choice to read; new lines stop | |
| 270 | // moving the view until `G` asks for the end again. | |
| 271 | follow = scroll.top + scroll.height >= styled.length; | |
| 272 | return true; | |
| 273 | } | |
| 274 | if (e.key === "r") { | |
| 275 | runAsync(ctx, async () => refreshRun()); | |
| 276 | return true; | |
| 277 | } | |
| 278 | return false; | |
| 279 | }, | |
| 280 | }; | |
| 281 | return screen; | |
| 282 | } | |
| 283 | ||
| 284 | /** The words a worker writes at the edges of a run stand out; the rest is log. */ | |
| 285 | function lineStyle(line: string, c: Tokens): Style { | |
| 286 | if (/^failed[: ]/.test(line)) return style(c.danger, c.bg); | |
| 287 | if (/^done[: ]/.test(line)) return style(c.success, c.bg); | |
| 288 | return style(c.fg, c.bg); | |
| 289 | } | |
| 290 | ||
| 291 | function toneOf(c: Tokens, tone: Tone): Color { | |
| 292 | return tone === "success" | |
| 293 | ? c.success | |
| 294 | : tone === "danger" | |
| 295 | ? c.danger | |
| 296 | : tone === "attn" | |
| 297 | ? c.attn | |
| 298 | : c.mutedFg; | |
| 299 | } |
src/tui/screens/runs.ts added +263
| @@ -0,0 +1,263 @@ | ||
| 1 | /** | |
| 2 | * Runs, as rows: the list a repository keeps, and the vocabulary every other | |
| 3 | * screen borrows to show one — a pull's checks, an issue's crew activity, an | |
| 4 | * environment's last deploy. One place decides what "failed" looks like so | |
| 5 | * it looks the same everywhere. | |
| 6 | * | |
| 7 | * A run row says four things in order: its state (a dot in the tone and the | |
| 8 | * word), what it was (`deploy production`, `agent robo`), why (the reason), | |
| 9 | * and the one-line summary the worker wrote. Nothing is a colour alone: a | |
| 10 | * queued run reads "queued" beside its hollow dot, and a failed one carries | |
| 11 | * the summary that says what failed. | |
| 12 | */ | |
| 13 | ||
| 14 | import { listRepoRuns } from "../../actions/runs.ts"; | |
| 15 | import { getIssuesByIds, type Issue } from "../../core/issues.ts"; | |
| 16 | import type { Repo } from "../../core/repos.ts"; | |
| 17 | import type { Run, RunState } from "../../core/runs.ts"; | |
| 18 | import { getUsersByIds } from "../../core/users.ts"; | |
| 19 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 20 | import { binding } from "../app/keys.ts"; | |
| 21 | import type { Rect, Screen as Surface } from "../term/screen.ts"; | |
| 22 | import type { Tokens } from "../ui/theme.ts"; | |
| 23 | import { icon, type IconSet } from "../ui/icons.ts"; | |
| 24 | import { emptyState, type Tone } from "../ui/widgets.ts"; | |
| 25 | import type { Session } from "../session.ts"; | |
| 26 | import { | |
| 27 | ListState, | |
| 28 | drawList, | |
| 29 | errorText, | |
| 30 | hitList, | |
| 31 | live, | |
| 32 | metaOf, | |
| 33 | relTime, | |
| 34 | type ListRow, | |
| 35 | } from "./common.ts"; | |
| 36 | import { runScreen } from "./run.ts"; | |
| 37 | ||
| 38 | /** The tone a run's state wears — the same reading on every surface. */ | |
| 39 | export function runTone(state: RunState): Tone | undefined { | |
| 40 | switch (state) { | |
| 41 | case "ok": | |
| 42 | return "success"; | |
| 43 | case "failed": | |
| 44 | return "danger"; | |
| 45 | case "running": | |
| 46 | return "attn"; | |
| 47 | case "cancelled": | |
| 48 | return "secondary"; | |
| 49 | case "queued": | |
| 50 | return undefined; | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | /** Whether the dot is hollow: nothing has happened yet, or nothing will. */ | |
| 55 | export const runHollow = (state: RunState): boolean => | |
| 56 | state === "queued" || state === "cancelled"; | |
| 57 | ||
| 58 | /** The event kinds a run passes through; what a live run list listens for. */ | |
| 59 | export const RUN_EVENTS: readonly string[] = [ | |
| 60 | "run.queued", | |
| 61 | "run.started", | |
| 62 | "run.finished", | |
| 63 | ]; | |
| 64 | ||
| 65 | /** `deploy production`, `deploy pull #4`, `agent robo`. */ | |
| 66 | export function runTitle( | |
| 67 | run: Run, | |
| 68 | names: ReadonlyMap<string, { username: string }>, | |
| 69 | set: IconSet = "plain", | |
| 70 | ): string { | |
| 71 | if (run.kind === "deploy") return `deploy ${envLabel(run.env)}`; | |
| 72 | const who = | |
| 73 | run.actorId === null | |
| 74 | ? "an agent" | |
| 75 | : (names.get(run.actorId)?.username ?? "an agent"); | |
| 76 | return set === "nerd" ? `${icon("bot", set)} ${who}` : `agent ${who}`; | |
| 77 | } | |
| 78 | ||
| 79 | /** `production`, or `pull #4` — the environment as a person says it. */ | |
| 80 | export function envLabel(env: string): string { | |
| 81 | return env.startsWith("pull-") ? `pull #${env.slice(5)}` : env || "—"; | |
| 82 | } | |
| 83 | ||
| 84 | /** The dollars a run spent, or nothing when it spent none. */ | |
| 85 | export function costText(costUsd: number): string { | |
| 86 | return costUsd > 0 ? `$${costUsd.toFixed(2)}` : ""; | |
| 87 | } | |
| 88 | ||
| 89 | /** The moment a run is best dated by: when it finished, started, or was made. */ | |
| 90 | export function runWhen(run: Run): string { | |
| 91 | return run.finishedAt ?? run.startedAt ?? run.createdAt; | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * One row per run. The summary sits where a row's secondary text goes, so | |
| 96 | * a failed run's reason is on the row itself and not a keypress away. | |
| 97 | * | |
| 98 | * `issues` names the conversation each run is about — `#4 Say hello` after | |
| 99 | * the title — for a list that spans a repository; a list inside one issue | |
| 100 | * leaves it out, since every row would repeat the page it is on. | |
| 101 | */ | |
| 102 | export function runRows( | |
| 103 | runs: readonly Run[], | |
| 104 | names: ReadonlyMap<string, { username: string }>, | |
| 105 | set: IconSet = "plain", | |
| 106 | issues?: ReadonlyMap<string, Issue>, | |
| 107 | ): ListRow[] { | |
| 108 | return runs.map((run) => { | |
| 109 | const tone = runTone(run.state); | |
| 110 | const about = | |
| 111 | issues === undefined || run.issueId === null | |
| 112 | ? undefined | |
| 113 | : issues.get(run.issueId); | |
| 114 | return { | |
| 115 | title: `${run.state.padEnd(9)} ${runTitle(run, names, set)}${about === undefined ? "" : ` · #${about.number} ${about.title}`}`, | |
| 116 | ...(tone === undefined ? {} : { tone }), | |
| 117 | ...(runHollow(run.state) ? { hollow: true } : {}), | |
| 118 | secondary: run.summary === "" ? run.reason : run.summary, | |
| 119 | meta: metaOf( | |
| 120 | run.commitOid === "" ? "" : run.commitOid.slice(0, 7), | |
| 121 | costText(run.costUsd), | |
| 122 | relTime(runWhen(run)), | |
| 123 | ), | |
| 124 | }; | |
| 125 | }); | |
| 126 | } | |
| 127 | ||
| 128 | /** The actors behind a set of runs, in one grouped read. */ | |
| 129 | export function runActors( | |
| 130 | f: Session, | |
| 131 | runs: readonly Run[], | |
| 132 | ): Map<string, { username: string }> { | |
| 133 | return getUsersByIds( | |
| 134 | f.db, | |
| 135 | runs.flatMap((run) => (run.actorId === null ? [] : [run.actorId])), | |
| 136 | ); | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * A list of runs a screen embeds — a pull's crew tab, an issue's crew card. | |
| 141 | * It owns its cursor and its rows; the host decides where it goes and when | |
| 142 | * it has focus. | |
| 143 | */ | |
| 144 | export class RunList { | |
| 145 | readonly state = new ListState(); | |
| 146 | runs: Run[] = []; | |
| 147 | private names = new Map<string, { username: string }>(); | |
| 148 | private issues: Map<string, Issue> | undefined; | |
| 149 | ||
| 150 | /** | |
| 151 | * `nameIssues` reads the conversation behind every run — one grouped | |
| 152 | * query per list — for a list that spans a repository. | |
| 153 | */ | |
| 154 | constructor( | |
| 155 | private readonly f: Session, | |
| 156 | private readonly o: { nameIssues?: boolean } = {}, | |
| 157 | ) {} | |
| 158 | ||
| 159 | set(runs: Run[]): void { | |
| 160 | this.runs = runs; | |
| 161 | this.names = runActors(this.f, runs); | |
| 162 | this.issues = | |
| 163 | this.o.nameIssues === true | |
| 164 | ? getIssuesByIds( | |
| 165 | this.f.db, | |
| 166 | runs.flatMap((run) => (run.issueId === null ? [] : [run.issueId])), | |
| 167 | ) | |
| 168 | : undefined; | |
| 169 | this.state.clamp(runs.length); | |
| 170 | } | |
| 171 | ||
| 172 | get selected(): Run | undefined { | |
| 173 | return this.runs[this.state.index]; | |
| 174 | } | |
| 175 | ||
| 176 | render( | |
| 177 | s: Surface, | |
| 178 | r: Rect, | |
| 179 | c: Tokens, | |
| 180 | ctx: Ctx, | |
| 181 | focused: boolean, | |
| 182 | open: (run: Run) => void, | |
| 183 | ): void { | |
| 184 | drawList( | |
| 185 | s, | |
| 186 | r, | |
| 187 | c, | |
| 188 | this.state, | |
| 189 | runRows(this.runs, this.names, ctx.icons, this.issues), | |
| 190 | focused, | |
| 191 | ); | |
| 192 | hitList(ctx, r, this.state, this.runs.length, { | |
| 193 | open: (index) => { | |
| 194 | const run = this.runs[index]; | |
| 195 | if (run !== undefined) open(run); | |
| 196 | }, | |
| 197 | }); | |
| 198 | } | |
| 199 | } | |
| 200 | ||
| 201 | /** Every run of one repository, newest first, live. */ | |
| 202 | export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 203 | const list = new RunList(f, { nameIssues: true }); | |
| 204 | let failed: string | undefined; | |
| 205 | ||
| 206 | const reload = (): void => { | |
| 207 | const read = listRepoRuns(f.app, f.user, repo, { limit: 100 }); | |
| 208 | if (!read.ok) { | |
| 209 | failed = errorText(read.error); | |
| 210 | ctx.invalidate(); | |
| 211 | return; | |
| 212 | } | |
| 213 | failed = undefined; | |
| 214 | list.set(read.value); | |
| 215 | ctx.invalidate(); | |
| 216 | }; | |
| 217 | ||
| 218 | const open = (run: Run | undefined): void => { | |
| 219 | if (run !== undefined) ctx.push(runScreen(f, ctx, repo, run.id)); | |
| 220 | }; | |
| 221 | ||
| 222 | const screen: Screen = { | |
| 223 | title: `${repo.ownerName}/${repo.name} · runs`, | |
| 224 | keys: [ | |
| 225 | binding(["j", "k"], "move"), | |
| 226 | binding(["enter"], "open the log"), | |
| 227 | binding(["r"], "refresh"), | |
| 228 | ], | |
| 229 | load(signal) { | |
| 230 | live(f, signal, { repoId: repo.id, kinds: RUN_EVENTS }, reload); | |
| 231 | reload(); | |
| 232 | return Promise.resolve(); | |
| 233 | }, | |
| 234 | render(area, s) { | |
| 235 | const c = ctx.theme.c; | |
| 236 | if (failed !== undefined) { | |
| 237 | emptyState(s, area, c, { title: failed }); | |
| 238 | return; | |
| 239 | } | |
| 240 | if (list.runs.length === 0) { | |
| 241 | emptyState(s, area, c, { | |
| 242 | title: "No runs yet", | |
| 243 | body: "A deploy or an agent working here will appear as a run.", | |
| 244 | }); | |
| 245 | return; | |
| 246 | } | |
| 247 | list.render(s, area, c, ctx, true, open); | |
| 248 | }, | |
| 249 | key(e) { | |
| 250 | if (list.state.key(e, list.runs.length)) return true; | |
| 251 | if (e.key === "enter") { | |
| 252 | open(list.selected); | |
| 253 | return true; | |
| 254 | } | |
| 255 | if (e.key === "r") { | |
| 256 | reload(); | |
| 257 | return true; | |
| 258 | } | |
| 259 | return false; | |
| 260 | }, | |
| 261 | }; | |
| 262 | return screen; | |
| 263 | } |
src/tui/screens/settings.ts +404 −2
| @@ -7,6 +7,17 @@ | ||
| 7 | 7 | * confirmation is deliberately more work than pressing Enter. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { | |
| 11 | disableApp, | |
| 12 | enableApp, | |
| 13 | updateAppSettings, | |
| 14 | } from "../../actions/apps.ts"; | |
| 15 | import { | |
| 16 | addToCrew, | |
| 17 | listAllAgents, | |
| 18 | listCrew, | |
| 19 | removeFromCrew, | |
| 20 | } from "../../actions/crew.ts"; | |
| 10 | 21 | import { |
| 11 | 22 | createLabel, |
| 12 | 23 | createWebhook, |
| @@ -15,8 +26,17 @@ import { | ||
| 15 | 26 | deleteWebhook, |
| 16 | 27 | updateRepoSettings, |
| 17 | 28 | } from "../../actions/repos.ts"; |
| 29 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 30 | import type { PreviewData } from "../../apps/data.ts"; | |
| 31 | import type { Agent } from "../../core/agents.ts"; | |
| 32 | import { | |
| 33 | getAppByRepo, | |
| 34 | slugFor, | |
| 35 | type AppAccess, | |
| 36 | type AppRecord, | |
| 37 | } from "../../core/apps.ts"; | |
| 18 | 38 | import { listLabels, type Label } from "../../core/issues.ts"; |
| 19 | import type { Repo } from "../../core/repos.ts"; | |
| 39 | import { getRepoById, type Repo } from "../../core/repos.ts"; | |
| 20 | 40 | import { |
| 21 | 41 | listWebhooks, |
| 22 | 42 | recentDeliveries, |
| @@ -31,6 +51,7 @@ import { truncate } from "../term/width.ts"; | ||
| 31 | 51 | import { fill, len, split } from "../ui/layout.ts"; |
| 32 | 52 | import { labelChip, tabs } from "../ui/widgets.ts"; |
| 33 | 53 | import type { Session } from "../session.ts"; |
| 54 | import { agentPick } from "./apps.ts"; | |
| 34 | 55 | import { |
| 35 | 56 | ListState, |
| 36 | 57 | confirmScreen, |
| @@ -38,28 +59,48 @@ import { | ||
| 38 | 59 | drawList, |
| 39 | 60 | hitList, |
| 40 | 61 | errorText, |
| 62 | later, | |
| 63 | metaOf, | |
| 64 | pickerScreen, | |
| 41 | 65 | promptScreen, |
| 42 | 66 | runAsync, |
| 43 | 67 | } from "./common.ts"; |
| 68 | import { budgetText } from "./crew.ts"; | |
| 44 | 69 | import { refPickerScreen } from "./refs.ts"; |
| 45 | 70 | import { local } from "./where.ts"; |
| 46 | 71 | |
| 47 | const TABS = ["General", "Labels", "Webhooks", "Danger"] as const; | |
| 72 | const TABS = [ | |
| 73 | "General", | |
| 74 | "Labels", | |
| 75 | "Webhooks", | |
| 76 | "App", | |
| 77 | "Crew", | |
| 78 | "Danger", | |
| 79 | ] as const; | |
| 48 | 80 | |
| 49 | 81 | export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 50 | 82 | let current = repo; |
| 51 | 83 | let labels: Label[] = []; |
| 52 | 84 | let hooks: Webhook[] = []; |
| 53 | 85 | let deliveries = new Map<string, DeliveryInfo[]>(); |
| 86 | /** The app row, when this repository runs as one. */ | |
| 87 | let app: AppRecord | undefined; | |
| 88 | let crew: Agent[] = []; | |
| 54 | 89 | let tab = 0; |
| 55 | 90 | const state = new ListState(); |
| 56 | 91 | |
| 57 | 92 | const reload = (): void => { |
| 93 | // The row handed down a stack is a snapshot; a change made on the web, | |
| 94 | // or by another terminal, must show here without re-entering the screen. | |
| 95 | current = getRepoById(f.db, current.id) ?? current; | |
| 58 | 96 | labels = listLabels(f.db, current.id); |
| 59 | 97 | hooks = listWebhooks(f.db, current.id); |
| 60 | 98 | deliveries = new Map( |
| 61 | 99 | hooks.map((hook) => [hook.id, recentDeliveries(f.db, hook.id, 3)]), |
| 62 | 100 | ); |
| 101 | app = getAppByRepo(f.db, current.id); | |
| 102 | const agents = listCrew(f.app, f.user, current); | |
| 103 | crew = agents.ok ? agents.value : []; | |
| 63 | 104 | ctx.invalidate(); |
| 64 | 105 | }; |
| 65 | 106 | |
| @@ -87,6 +128,7 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 87 | 128 | binding(["b"], "default branch"), |
| 88 | 129 | binding(["p"], "protection"), |
| 89 | 130 | binding(["R"], "require a review"), |
| 131 | binding(["C"], "require checks"), | |
| 90 | 132 | ]; |
| 91 | 133 | if (tab === 1) |
| 92 | 134 | return [ |
| @@ -100,6 +142,22 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 100 | 142 | binding(["n"], "new webhook"), |
| 101 | 143 | binding(["D"], "delete webhook"), |
| 102 | 144 | ]; |
| 145 | if (tab === 3) | |
| 146 | return app === undefined | |
| 147 | ? [binding(["tab"], "next tab"), binding(["e"], "run as an app")] | |
| 148 | : [ | |
| 149 | binding(["tab"], "next tab"), | |
| 150 | binding(["s"], "slug"), | |
| 151 | binding(["a"], "access"), | |
| 152 | binding(["p"], "preview data"), | |
| 153 | binding(["D"], "stop running as an app"), | |
| 154 | ]; | |
| 155 | if (tab === 4) | |
| 156 | return [ | |
| 157 | binding(["tab"], "next tab"), | |
| 158 | binding(["a"], "add an agent"), | |
| 159 | binding(["d"], "remove", { disabled: crew.length === 0 }), | |
| 160 | ]; | |
| 103 | 161 | return [binding(["tab"], "next tab"), binding(["X"], "delete repo")]; |
| 104 | 162 | }, |
| 105 | 163 | load() { |
| @@ -128,6 +186,12 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 128 | 186 | ? "on — a pull needs an approval of its current head" |
| 129 | 187 | : "off", |
| 130 | 188 | ], |
| 189 | [ | |
| 190 | "require checks", | |
| 191 | current.requireChecks | |
| 192 | ? "on — a pull needs a passing deploy of its current head" | |
| 193 | : "off", | |
| 194 | ], | |
| 131 | 195 | ]); |
| 132 | 196 | return; |
| 133 | 197 | } |
| @@ -167,6 +231,14 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 167 | 231 | hitList(ctx, body!, state, hooks.length, {}); |
| 168 | 232 | return; |
| 169 | 233 | } |
| 234 | if (tab === 3) { | |
| 235 | drawAppTab(s, body!, c); | |
| 236 | return; | |
| 237 | } | |
| 238 | if (tab === 4) { | |
| 239 | drawCrewTab(s, body!, c); | |
| 240 | return; | |
| 241 | } | |
| 170 | 242 | s.text( |
| 171 | 243 | body!.x, |
| 172 | 244 | body!.y, |
| @@ -194,6 +266,8 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 194 | 266 | if (tab === 0) return generalKey(e); |
| 195 | 267 | if (tab === 1) return labelsKey(e); |
| 196 | 268 | if (tab === 2) return hooksKey(e); |
| 269 | if (tab === 3) return appKey(e); | |
| 270 | if (tab === 4) return crewKey(e); | |
| 197 | 271 | if (e.key === "X") { |
| 198 | 272 | ctx.push( |
| 199 | 273 | promptScreen(ctx, screen, { |
| @@ -251,6 +325,12 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 251 | 325 | save({ requireReview: !current.requireReview }); |
| 252 | 326 | return true; |
| 253 | 327 | } |
| 328 | // The second gate: with this on, a pull whose current head has no | |
| 329 | // passing deploy is refused `checks_required` — read live in `mergePull`. | |
| 330 | if (key === "C") { | |
| 331 | save({ requireChecks: !current.requireChecks }); | |
| 332 | return true; | |
| 333 | } | |
| 254 | 334 | if (key === "b") { |
| 255 | 335 | ctx.push( |
| 256 | 336 | refPickerScreen( |
| @@ -367,6 +447,328 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 367 | 447 | return false; |
| 368 | 448 | } |
| 369 | 449 | |
| 450 | // ---- the app tab ---- | |
| 451 | ||
| 452 | function drawAppTab( | |
| 453 | s: Parameters<typeof drawFacts>[0], | |
| 454 | body: Parameters<typeof drawFacts>[1], | |
| 455 | c: Parameters<typeof drawFacts>[2], | |
| 456 | ): void { | |
| 457 | const runtime = appsRuntime(f.app); | |
| 458 | if (app === undefined) { | |
| 459 | const lines = [ | |
| 460 | "This repository does not run as an app.", | |
| 461 | "", | |
| 462 | "Press e to run it: production from the default branch, a preview per", | |
| 463 | "open pull, each with its own data directory. forge.json in the tree", | |
| 464 | "says how (docs/APPS.md); a package.json start script is enough.", | |
| 465 | ...(runtime === undefined ? ["", errorText("no_runtime")] : []), | |
| 466 | ]; | |
| 467 | lines.forEach((line, i) => { | |
| 468 | if (i >= body.h) return; | |
| 469 | s.text( | |
| 470 | body.x, | |
| 471 | body.y + i, | |
| 472 | truncate(line, body.w), | |
| 473 | style(i === 0 ? c.fg : c.mutedFg, c.bg), | |
| 474 | body.x + body.w, | |
| 475 | ); | |
| 476 | }); | |
| 477 | return; | |
| 478 | } | |
| 479 | drawFacts(s, body, c, [ | |
| 480 | ["app", "on — this repository runs"], | |
| 481 | ["slug", app.slug], | |
| 482 | [ | |
| 483 | "access", | |
| 484 | app.access === "private" | |
| 485 | ? "private — only people who can read the repository" | |
| 486 | : "public — anyone with the URL", | |
| 487 | ], | |
| 488 | [ | |
| 489 | "preview data", | |
| 490 | app.previewData === "copy" | |
| 491 | ? "copy — a preview starts from production's data" | |
| 492 | : "empty — a preview starts with nothing", | |
| 493 | ], | |
| 494 | [ | |
| 495 | "production", | |
| 496 | runtime === undefined | |
| 497 | ? errorText("no_runtime") | |
| 498 | : runtime.urlFor(app, "production"), | |
| 499 | ], | |
| 500 | ]); | |
| 501 | } | |
| 502 | ||
| 503 | /** `e` — three prompts, then `enableApp`. Esc anywhere makes nothing. */ | |
| 504 | const enable = (): void => { | |
| 505 | const askPreview = (slug: string, access: AppAccess): Screen => | |
| 506 | pickerScreen(ctx, screen, { | |
| 507 | title: "What does a preview start with?", | |
| 508 | items: [ | |
| 509 | { | |
| 510 | id: "copy", | |
| 511 | label: "a copy of production's data", | |
| 512 | hint: "copy-on-write where the disk allows", | |
| 513 | }, | |
| 514 | { id: "empty", label: "nothing", hint: "an empty data directory" }, | |
| 515 | ], | |
| 516 | onPick: ([chosen]) => { | |
| 517 | const previewData: PreviewData = | |
| 518 | chosen === "empty" ? "empty" : "copy"; | |
| 519 | const made = enableApp(f.app, f.user, current, { | |
| 520 | slug, | |
| 521 | access, | |
| 522 | previewData, | |
| 523 | }); | |
| 524 | if (!made.ok) { | |
| 525 | ctx.toast(errorText(made.error), "danger"); | |
| 526 | return; | |
| 527 | } | |
| 528 | const runtime = appsRuntime(f.app); | |
| 529 | ctx.toast( | |
| 530 | runtime === undefined | |
| 531 | ? `${current.name} is an app; ${errorText("no_runtime")}` | |
| 532 | : `${current.name} is starting at ${runtime.urlFor(made.value, "production")}`, | |
| 533 | "success", | |
| 534 | ); | |
| 535 | reload(); | |
| 536 | }, | |
| 537 | }); | |
| 538 | // The repository's own visibility is the default, so it is offered first. | |
| 539 | const askAccess = (slug: string): Screen => { | |
| 540 | const choices = [ | |
| 541 | { | |
| 542 | id: "private", | |
| 543 | label: "private", | |
| 544 | hint: "only people who can read the repository", | |
| 545 | }, | |
| 546 | { id: "public", label: "public", hint: "anyone with the URL" }, | |
| 547 | ]; | |
| 548 | return pickerScreen(ctx, screen, { | |
| 549 | title: "Who may reach it?", | |
| 550 | items: | |
| 551 | current.visibility === "public" ? [...choices].reverse() : choices, | |
| 552 | onPick: ([chosen]) => | |
| 553 | ctx.push( | |
| 554 | askPreview(slug, chosen === "public" ? "public" : "private"), | |
| 555 | ), | |
| 556 | }); | |
| 557 | }; | |
| 558 | ctx.push( | |
| 559 | promptScreen(ctx, screen, { | |
| 560 | title: "Run as an app", | |
| 561 | label: "Slug — the hostname label", | |
| 562 | value: slugFor(current.name), | |
| 563 | hint: "Lowercase letters, digits, single hyphens: <slug>.<domain>", | |
| 564 | onSubmit: (slug) => { | |
| 565 | if (slug === "") return "a slug is required"; | |
| 566 | return later(ctx, () => askAccess(slug)); | |
| 567 | }, | |
| 568 | }), | |
| 569 | ); | |
| 570 | }; | |
| 571 | ||
| 572 | const patchApp = ( | |
| 573 | patch: Parameters<typeof updateAppSettings>[3], | |
| 574 | said: string, | |
| 575 | ): string | undefined => { | |
| 576 | const updated = updateAppSettings(f.app, f.user, current, patch); | |
| 577 | if (!updated.ok) return errorText(updated.error); | |
| 578 | ctx.toast(said, "success"); | |
| 579 | reload(); | |
| 580 | return undefined; | |
| 581 | }; | |
| 582 | ||
| 583 | function appKey(e: KeyEvent): boolean { | |
| 584 | const key = e.key; | |
| 585 | if (app === undefined) { | |
| 586 | if (key === "e") { | |
| 587 | enable(); | |
| 588 | return true; | |
| 589 | } | |
| 590 | return false; | |
| 591 | } | |
| 592 | const record = app; | |
| 593 | if (key === "s") { | |
| 594 | ctx.push( | |
| 595 | promptScreen(ctx, screen, { | |
| 596 | title: "Slug", | |
| 597 | label: "Slug — the hostname label", | |
| 598 | value: record.slug, | |
| 599 | hint: "Every environment's URL changes with it.", | |
| 600 | onSubmit: (slug) => { | |
| 601 | if (slug === "") return "a slug is required"; | |
| 602 | return patchApp({ slug }, `slug is now ${slug}`); | |
| 603 | }, | |
| 604 | }), | |
| 605 | ); | |
| 606 | return true; | |
| 607 | } | |
| 608 | if (key === "a") { | |
| 609 | const access: AppAccess = | |
| 610 | record.access === "private" ? "public" : "private"; | |
| 611 | const said = patchApp({ access }, `the app is now ${access}`); | |
| 612 | if (said !== undefined) ctx.toast(said, "danger"); | |
| 613 | return true; | |
| 614 | } | |
| 615 | if (key === "p") { | |
| 616 | const previewData: PreviewData = | |
| 617 | record.previewData === "copy" ? "empty" : "copy"; | |
| 618 | const said = patchApp( | |
| 619 | { previewData }, | |
| 620 | previewData === "copy" | |
| 621 | ? "new previews start from a copy of production's data" | |
| 622 | : "new previews start empty", | |
| 623 | ); | |
| 624 | if (said !== undefined) ctx.toast(said, "danger"); | |
| 625 | return true; | |
| 626 | } | |
| 627 | if (key === "D") { | |
| 628 | ctx.push( | |
| 629 | confirmScreen(ctx, screen, { | |
| 630 | title: "Stop running as an app", | |
| 631 | body: [ | |
| 632 | "Production and every preview stop, and their data", | |
| 633 | "directories are removed. The repository is untouched.", | |
| 634 | ], | |
| 635 | actions: [ | |
| 636 | { label: "Keep running", variant: "ghost" }, | |
| 637 | { label: "Stop", variant: "danger" }, | |
| 638 | ], | |
| 639 | onChoose: (choice) => { | |
| 640 | if (choice !== 1) return; | |
| 641 | const gone = disableApp(f.app, f.user, current); | |
| 642 | if (!gone.ok) ctx.toast(errorText(gone.error), "danger"); | |
| 643 | else { | |
| 644 | ctx.toast(`${current.name} no longer runs as an app`, "success"); | |
| 645 | reload(); | |
| 646 | } | |
| 647 | }, | |
| 648 | }), | |
| 649 | ); | |
| 650 | return true; | |
| 651 | } | |
| 652 | return false; | |
| 653 | } | |
| 654 | ||
| 655 | // ---- the crew tab ---- | |
| 656 | ||
| 657 | function drawCrewTab( | |
| 658 | s: Parameters<typeof drawFacts>[0], | |
| 659 | body: Parameters<typeof drawFacts>[1], | |
| 660 | c: Parameters<typeof drawFacts>[2], | |
| 661 | ): void { | |
| 662 | if (crew.length === 0) { | |
| 663 | const lines = [ | |
| 664 | "No agents on this crew.", | |
| 665 | "", | |
| 666 | "Press a to add one from the forge's agents. An agent on the crew can", | |
| 667 | "read and write this repository, and starts working when an issue is", | |
| 668 | "assigned to it.", | |
| 669 | ]; | |
| 670 | lines.forEach((line, i) => { | |
| 671 | if (i >= body.h) return; | |
| 672 | s.text( | |
| 673 | body.x, | |
| 674 | body.y + i, | |
| 675 | truncate(line, body.w), | |
| 676 | style(i === 0 ? c.fg : c.mutedFg, c.bg), | |
| 677 | body.x + body.w, | |
| 678 | ); | |
| 679 | }); | |
| 680 | return; | |
| 681 | } | |
| 682 | drawList( | |
| 683 | s, | |
| 684 | body, | |
| 685 | c, | |
| 686 | state, | |
| 687 | crew.map((agent) => { | |
| 688 | const pick = agentPick(agent, ctx.icons); | |
| 689 | return { | |
| 690 | title: pick.label, | |
| 691 | secondary: agent.brief.split("\n", 1)[0] ?? "", | |
| 692 | meta: metaOf( | |
| 693 | agent.model === "" ? "default model" : agent.model, | |
| 694 | budgetText(agent.budgetUsd), | |
| 695 | ), | |
| 696 | }; | |
| 697 | }), | |
| 698 | ); | |
| 699 | hitList(ctx, body, state, crew.length, {}); | |
| 700 | } | |
| 701 | ||
| 702 | function crewKey(e: KeyEvent): boolean { | |
| 703 | const key = e.key; | |
| 704 | if (state.key(e, crew.length)) return true; | |
| 705 | if (key === "a") { | |
| 706 | const all = listAllAgents(f.app, f.user); | |
| 707 | if (!all.ok) { | |
| 708 | ctx.toast(errorText(all.error), "danger"); | |
| 709 | return true; | |
| 710 | } | |
| 711 | const onCrew = new Set(crew.map((agent) => agent.user.id)); | |
| 712 | const candidates = all.value.filter( | |
| 713 | (agent) => !onCrew.has(agent.user.id), | |
| 714 | ); | |
| 715 | if (candidates.length === 0) { | |
| 716 | ctx.toast( | |
| 717 | all.value.length === 0 | |
| 718 | ? "the forge has no agents yet — a forge admin makes them (:crew)" | |
| 719 | : "every agent on the forge is already on this crew", | |
| 720 | "attn", | |
| 721 | ); | |
| 722 | return true; | |
| 723 | } | |
| 724 | ctx.push( | |
| 725 | pickerScreen(ctx, screen, { | |
| 726 | title: "Add to the crew", | |
| 727 | items: candidates.map((agent) => agentPick(agent, ctx.icons)), | |
| 728 | onPick: ([username]) => { | |
| 729 | if (username === undefined) return; | |
| 730 | const added = addToCrew(f.app, f.user, current, username); | |
| 731 | if (!added.ok) ctx.toast(errorText(added.error), "danger"); | |
| 732 | else { | |
| 733 | ctx.toast(`${username} is on the crew`, "success"); | |
| 734 | reload(); | |
| 735 | } | |
| 736 | }, | |
| 737 | }), | |
| 738 | ); | |
| 739 | return true; | |
| 740 | } | |
| 741 | if (key === "d") { | |
| 742 | const agent = crew[state.index]; | |
| 743 | if (!agent) return true; | |
| 744 | const name = agent.user.username; | |
| 745 | ctx.push( | |
| 746 | confirmScreen(ctx, screen, { | |
| 747 | title: `Remove ${name} from the crew`, | |
| 748 | body: [ | |
| 749 | `${name} can no longer read or write this repository.`, | |
| 750 | "Its open pulls and comments stay.", | |
| 751 | ], | |
| 752 | actions: [ | |
| 753 | { label: "Keep it", variant: "ghost" }, | |
| 754 | { label: "Remove", variant: "danger" }, | |
| 755 | ], | |
| 756 | onChoose: (choice) => { | |
| 757 | if (choice !== 1) return; | |
| 758 | const removed = removeFromCrew(f.app, f.user, current, name); | |
| 759 | if (!removed.ok) ctx.toast(errorText(removed.error), "danger"); | |
| 760 | else { | |
| 761 | ctx.toast(`${name} is off the crew`, "success"); | |
| 762 | reload(); | |
| 763 | } | |
| 764 | }, | |
| 765 | }), | |
| 766 | ); | |
| 767 | return true; | |
| 768 | } | |
| 769 | return false; | |
| 770 | } | |
| 771 | ||
| 370 | 772 | return screen; |
| 371 | 773 | } |
| 372 | 774 |
src/tui/ssh.ts +17 −1
| @@ -21,11 +21,18 @@ import { repoDir } from "../git/repos.ts"; | ||
| 21 | 21 | import { parseGitCommand, runGitService } from "../git/smart-ssh.ts"; |
| 22 | 22 | import { serveSsh, type SshServer, type SshSession } from "../ssh/server.ts"; |
| 23 | 23 | import { createForgeApp } from "./forge.ts"; |
| 24 | import { appForLogin, attachAppTty } from "../apps/tty.ts"; | |
| 24 | 25 | import type { Terminal } from "./term/terminal.ts"; |
| 25 | 26 | import type { TuiIdentity } from "./session.ts"; |
| 26 | 27 | |
| 27 | 28 | const CRLF = "\r\n"; |
| 28 | 29 | |
| 30 | /** Whether an address is this machine talking to itself. */ | |
| 31 | export function isLoopback(address: string): boolean { | |
| 32 | const a = address.replace(/^\[|\]$/g, "").replace(/^::ffff:/, ""); | |
| 33 | return a === "::1" || a === "localhost" || a.startsWith("127."); | |
| 34 | } | |
| 35 | ||
| 29 | 36 | /** A `Terminal` over an SSH channel — the whole SSH-specific surface. */ |
| 30 | 37 | export function sshTerminal( |
| 31 | 38 | session: SshSession, |
| @@ -33,6 +40,10 @@ export function sshTerminal( | ||
| 33 | 40 | ): Terminal { |
| 34 | 41 | let closed = false; |
| 35 | 42 | return { |
| 43 | // A peer on this machine is looking at this machine's screen: `o` can | |
| 44 | // open a browser for real, exactly as the stdio runner can. Any other | |
| 45 | // peer gets the link and the clipboard, and honest words. | |
| 46 | local: isLoopback(session.remoteAddress), | |
| 36 | 47 | // Getters, not a snapshot: `pty-req` may arrive after this object is |
| 37 | 48 | // built, and `window-change` moves both of these mid-session. |
| 38 | 49 | get term() { |
| @@ -149,7 +160,12 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void { | ||
| 149 | 160 | try { |
| 150 | 161 | const identity = identityFor(forge, session); |
| 151 | 162 | if (session.kind === "shell") { |
| 152 | attachTui(forge, session, identity); | |
| 163 | // The login name is decorative for the forge — any name opens the | |
| 164 | // terminal UI — unless it names an app, in which case it opens | |
| 165 | // that app's own terminal (docs/APPS.md). | |
| 166 | const app = appForLogin(forge, session.username); | |
| 167 | if (app !== undefined) attachAppTty(forge, session, identity.user, app); | |
| 168 | else attachTui(forge, session, identity); | |
| 153 | 169 | return; |
| 154 | 170 | } |
| 155 | 171 | void serveGitCommand(forge, session, identity.user).catch( |
src/tui/ui/icons.ts +5
| @@ -48,6 +48,7 @@ export type IconName = | ||
| 48 | 48 | | "milestone" |
| 49 | 49 | // people and places |
| 50 | 50 | | "person" |
| 51 | | "bot" | |
| 51 | 52 | | "org" |
| 52 | 53 | | "star" |
| 53 | 54 | | "pin" |
| @@ -99,6 +100,10 @@ const ICONS: Readonly<Record<IconName, Pair>> = { | ||
| 99 | 100 | diff: { plain: G.comment, nerd: "\u{f440}" }, // nf-oct-diff |
| 100 | 101 | milestone: { plain: G.diamond, nerd: "\u{f45d}" }, // nf-oct-milestone |
| 101 | 102 | person: { plain: G.ring, nerd: "\u{f415}" }, // nf-oct-person |
| 103 | // The crew. A plain session marks an agent with the same hollow ring a | |
| 104 | // person wears and says "agent" in words beside it; a Nerd Font session | |
| 105 | // gets the robot, which is the one glyph that needs no caption. | |
| 106 | bot: { plain: G.ring, nerd: "\u{f06a9}" }, // nf-md-robot | |
| 102 | 107 | org: { plain: G.diamond, nerd: "\u{f42b}" }, // nf-oct-organization |
| 103 | 108 | star: { plain: G.diamond, nerd: "\u{f41e}" }, // nf-oct-star |
| 104 | 109 | pin: { plain: G.dot, nerd: "\u{f435}" }, // nf-oct-pin |
src/tui/ui/widgets.ts +23 −2
| @@ -409,6 +409,14 @@ export function card(s: Screen, r: Rect, c: Tokens, o: CardOpts = {}): Rect { | ||
| 409 | 409 | export interface RowOpts { |
| 410 | 410 | readonly title: string; |
| 411 | 411 | readonly state?: RowState; |
| 412 | /** | |
| 413 | * The lead dot's tone when the row is not a conversation — an environment | |
| 414 | * that is running, a run that failed. `state` wins when both are given, | |
| 415 | * so a pull row can never be recoloured by accident. | |
| 416 | */ | |
| 417 | readonly tone?: Tone; | |
| 418 | /** A hollow lead dot: queued, stopped, not started yet. */ | |
| 419 | readonly hollow?: boolean; | |
| 412 | 420 | readonly secondary?: string; |
| 413 | 421 | readonly meta?: string; |
| 414 | 422 | readonly selected?: boolean; |
| @@ -441,8 +449,21 @@ export function row(s: Screen, r: Rect, c: Tokens, o: RowOpts): void { | ||
| 441 | 449 | style(c.accent, bg), |
| 442 | 450 | right, |
| 443 | 451 | ); |
| 444 | if (o.state !== undefined) | |
| 445 | x = dot(s, x, r.y, c, { tone: ROW_TONE[o.state], bg }, right) + 1; | |
| 452 | const lead = o.state !== undefined ? ROW_TONE[o.state] : o.tone; | |
| 453 | if (lead !== undefined || o.hollow === true) | |
| 454 | x = | |
| 455 | dot( | |
| 456 | s, | |
| 457 | x, | |
| 458 | r.y, | |
| 459 | c, | |
| 460 | { | |
| 461 | bg, | |
| 462 | ...(lead === undefined ? {} : { tone: lead }), | |
| 463 | ...(o.hollow === true ? { hollow: true } : {}), | |
| 464 | }, | |
| 465 | right, | |
| 466 | ) + 1; | |
| 446 | 467 | |
| 447 | 468 | const meta = o.meta ?? ""; |
| 448 | 469 | const metaW = meta === "" ? 0 : displayWidth(meta) + 1; |
src/web/chrome.ts +13
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * `ui.ts` may not. That is the line between the two files. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { getAppByRepo } from "../core/apps.ts"; | |
| 11 | 12 | import { canAdmin, canRead, getRepo, getRepoById } from "../core/repos.ts"; |
| 12 | 13 | import type { Repo } from "../core/repos.ts"; |
| 13 | 14 | import type { Ctx } from "../http/context.ts"; |
| @@ -83,6 +84,7 @@ export type RepoTab = | ||
| 83 | 84 | | "pulls" |
| 84 | 85 | | "commits" |
| 85 | 86 | | "branches" |
| 87 | | "app" | |
| 86 | 88 | | "settings"; |
| 87 | 89 | |
| 88 | 90 | export interface RepoHeaderOptions { |
| @@ -180,6 +182,16 @@ export function scopeHeader( | ||
| 180 | 182 | shortcut: "g b", |
| 181 | 183 | }, |
| 182 | 184 | ]; |
| 185 | // A repository that runs gets an App tab; one that does not has no such | |
| 186 | // page, and a tab that 404s is a lie about what is there. | |
| 187 | if (repo !== undefined && getAppByRepo(ctx.app.db, repo.id) !== undefined) | |
| 188 | items.push({ | |
| 189 | label: "App", | |
| 190 | href: `${base}/app`, | |
| 191 | icon: "rocket", | |
| 192 | current: active === "app", | |
| 193 | shortcut: "g a", | |
| 194 | }); | |
| 183 | 195 | if (admin) |
| 184 | 196 | items.push({ |
| 185 | 197 | label: "Settings", |
| @@ -255,6 +267,7 @@ export function repoPalette(repo: Repo): PaletteItem[] { | ||
| 255 | 267 | { t: `${name} — Pull requests`, h: `${base}/pulls`, c: "This repo" }, |
| 256 | 268 | { t: `${name} — Commits`, h: `${base}/commits`, c: "This repo" }, |
| 257 | 269 | { t: `${name} — New issue`, h: `${base}/issues/new`, c: "This repo" }, |
| 270 | { t: `${name} — Runs`, h: `${base}/runs`, c: "This repo" }, | |
| 258 | 271 | { t: `${name} — Settings`, h: `${base}/settings`, c: "This repo" }, |
| 259 | 272 | ]; |
| 260 | 273 | } |
src/web/client.ts +61
| @@ -102,6 +102,67 @@ export const mirrorJs = /* js */ ` | ||
| 102 | 102 | })(); |
| 103 | 103 | `; |
| 104 | 104 | |
| 105 | /** | |
| 106 | * A run's log, live. The page renders every line already written and, for | |
| 107 | * a run still going, refreshes itself without a script; this replaces that | |
| 108 | * poll with the log's own event stream, so a line lands the moment it is | |
| 109 | * written and the page never reloads under a reader. The stream replays | |
| 110 | * what the page already shows, so the first line clears the pre and the | |
| 111 | * tail is exact. Auto-scroll follows the bottom until the reader scrolls | |
| 112 | * up, and resumes when they come back down. | |
| 113 | */ | |
| 114 | export const runLogJs = /* js */ ` | |
| 115 | (function () { | |
| 116 | var log = document.getElementById('run-log'); | |
| 117 | if (!log || typeof EventSource === 'undefined') return; | |
| 118 | var url = log.getAttribute('data-stream'); | |
| 119 | if (!url) return; | |
| 120 | var meta = document.querySelector('meta[http-equiv="refresh"]'); | |
| 121 | if (meta && !/url=/i.test(meta.getAttribute('content') || '')) meta.remove(); | |
| 122 | ||
| 123 | var state = document.getElementById('run-state'); | |
| 124 | var summary = document.getElementById('run-summary'); | |
| 125 | var cancel = document.getElementById('run-cancel'); | |
| 126 | var follow = true; | |
| 127 | var seeded = false; | |
| 128 | log.addEventListener('scroll', function () { | |
| 129 | follow = log.scrollTop + log.clientHeight >= log.scrollHeight - 12; | |
| 130 | }); | |
| 131 | ||
| 132 | var words = { | |
| 133 | queued: ['Queued', 'secondary'], running: ['Running', 'attn'], | |
| 134 | ok: ['Passed', 'success'], failed: ['Failed', 'danger'], cancelled: ['Cancelled', 'outline'], | |
| 135 | }; | |
| 136 | function paint(run) { | |
| 137 | if (state) { | |
| 138 | var look = words[run.state]; | |
| 139 | var badge = state.querySelector('.badge') || state; | |
| 140 | if (look) { | |
| 141 | badge.setAttribute('data-variant', look[1]); | |
| 142 | badge.textContent = run.state === 'ok' && run.kind === 'agent' ? 'Done' : look[0]; | |
| 143 | } | |
| 144 | } | |
| 145 | if (summary && run.summary) summary.textContent = run.summary; | |
| 146 | if (cancel) cancel.hidden = true; | |
| 147 | } | |
| 148 | ||
| 149 | var source = new EventSource(url); | |
| 150 | var empty = document.getElementById('run-empty'); | |
| 151 | source.addEventListener('line', function (event) { | |
| 152 | if (!seeded) { log.textContent = ''; seeded = true; if (empty) empty.hidden = true; } | |
| 153 | log.appendChild(document.createTextNode(event.data + '\\n')); | |
| 154 | if (follow) log.scrollTop = log.scrollHeight; | |
| 155 | }); | |
| 156 | source.addEventListener('done', function (event) { | |
| 157 | source.close(); | |
| 158 | var run; | |
| 159 | try { run = JSON.parse(event.data); } catch (err) { return; } | |
| 160 | paint(run); | |
| 161 | }); | |
| 162 | source.addEventListener('error', function () { /* a reload shows the log as written */ }); | |
| 163 | })(); | |
| 164 | `; | |
| 165 | ||
| 105 | 166 | /** |
| 106 | 167 | * The code browser: the explorer column, the preview it paints beside itself, |
| 107 | 168 | * and the fuzzy file finder. |
src/web/icons.ts +10
| @@ -79,6 +79,16 @@ const PATHS = { | ||
| 79 | 79 | github: `<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>`, |
| 80 | 80 | forgejo: `<circle cx="6.5" cy="5" r="2.5"/><circle cx="17.5" cy="5" r="2.5"/><circle cx="12" cy="19" r="2.5"/><path d="M17.5 7.5v1.75a3 3 0 0 1-3 3h-2a2 2 0 0 0-2 2v2.25"/><path d="M6.5 7.5v6"/>`, |
| 81 | 81 | gitea: `<path d="M3 8h12v7a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4z"/><path d="M15 10h2.5a3 3 0 0 1 0 6H15"/><path d="M7 5V2.5"/><path d="M11 5V2.5"/>`, |
| 82 | // Apps, runs and the crew (docs/APPS.md). | |
| 83 | rocket: `<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>`, | |
| 84 | bot: `<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>`, | |
| 85 | play: `<path d="M6 3 20 12 6 21 6 3z"/>`, | |
| 86 | square: `<rect width="18" height="18" x="3" y="3" rx="2"/>`, | |
| 87 | loader: `<path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/>`, | |
| 88 | clock: `<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>`, | |
| 89 | ban: `<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>`, | |
| 90 | circleX: `<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>`, | |
| 91 | terminal: `<path d="m4 17 6-6-6-6"/><path d="M12 19h8"/>`, | |
| 82 | 92 | } as const; |
| 83 | 93 | |
| 84 | 94 | export type IconName = keyof typeof PATHS; |
src/web/layout.ts +9 −1
| @@ -107,6 +107,7 @@ function themeMenu(active: ThemeName | undefined): Html { | ||
| 107 | 107 | |
| 108 | 108 | function newMenu(): Html { |
| 109 | 109 | const items: MenuItem[] = [ |
| 110 | { label: "New app", href: "/apps/new", icon: "rocket" }, | |
| 110 | 111 | { label: "New repository", href: "/new", icon: "repo" }, |
| 111 | 112 | { label: "New organization", href: "/organizations/new", icon: "users" }, |
| 112 | 113 | ]; |
| @@ -123,11 +124,15 @@ function userMenu(user: User): Html { | ||
| 123 | 124 | const items: MenuItem[] = [ |
| 124 | 125 | { label: "Your profile", href: `/${user.username}`, icon: "user" }, |
| 125 | 126 | { label: "Your repositories", href: "/", icon: "repo" }, |
| 127 | { label: "Apps", href: "/apps", icon: "rocket" }, | |
| 126 | 128 | { separator: true, label: "" }, |
| 127 | 129 | { label: "Settings", href: "/settings", icon: "settings" }, |
| 128 | 130 | { label: "Access tokens", href: "/settings/tokens", icon: "key" }, |
| 129 | 131 | { label: "SSH keys", href: "/settings/keys", icon: "lock" }, |
| 130 | 132 | { label: "Origins", href: "/settings/origins", icon: "globe" }, |
| 133 | ...(user.isAdmin | |
| 134 | ? [{ label: "Crew", href: "/settings/crew", icon: "bot" as const }] | |
| 135 | : []), | |
| 131 | 136 | { separator: true, label: "" }, |
| 132 | 137 | { label: "Sign out", post: "/logout", icon: "signOut" }, |
| 133 | 138 | ]; |
| @@ -182,6 +187,8 @@ function helpButton(): Html { | ||
| 182 | 187 | |
| 183 | 188 | const STATIC_PALETTE: PaletteItem[] = [ |
| 184 | 189 | { t: "Home", h: "/", c: "Go" }, |
| 190 | { t: "Apps", h: "/apps", c: "Go" }, | |
| 191 | { t: "New app", h: "/apps/new", c: "Create" }, | |
| 185 | 192 | { t: "New repository", h: "/new", c: "Create" }, |
| 186 | 193 | { t: "New organization", h: "/organizations/new", c: "Create" }, |
| 187 | 194 | { t: "Search", h: "/search", c: "Go" }, |
| @@ -423,7 +430,8 @@ export function fragment(body: Html, status = 200): Response { | ||
| 423 | 430 | status, |
| 424 | 431 | headers: { |
| 425 | 432 | "content-type": "text/html; charset=utf-8", |
| 426 | "content-security-policy": "default-src 'none'; style-src 'none'; sandbox", | |
| 433 | "content-security-policy": | |
| 434 | "default-src 'none'; style-src 'none'; sandbox", | |
| 427 | 435 | "cache-control": "no-store", |
| 428 | 436 | }, |
| 429 | 437 | }); |
src/web/live.ts +12 −3
| @@ -42,9 +42,7 @@ export function liveMount(config: LiveConfig): Html { | ||
| 42 | 42 | id="live" |
| 43 | 43 | hidden |
| 44 | 44 | data-repo="${config.repo}" |
| 45 | data-repos="${(config.repos ?? []) | |
| 46 | .slice(0, MAX_LIVE_STREAMS) | |
| 47 | .join(" ")}" | |
| 45 | data-repos="${(config.repos ?? []).slice(0, MAX_LIVE_STREAMS).join(" ")}" | |
| 48 | 46 | data-thread="${config.thread ?? ""}" |
| 49 | 47 | data-number="${config.number ?? ""}" |
| 50 | 48 | data-last="${config.lastCommentId ?? ""}" |
| @@ -85,6 +83,14 @@ export const liveClientJs = /* js */ ` | ||
| 85 | 83 | |
| 86 | 84 | function relevant(data) { |
| 87 | 85 | if (!thread || !data || !data.payload) return false; |
| 86 | // A run's payload carries the environment it deployed ('pull-<n>' or | |
| 87 | // 'production'), never the pull's own 'number' field, so the generic | |
| 88 | // check below never fires for it. A pull's checks are the deploy runs | |
| 89 | // of its own preview, so match on that instead — and only that, so a | |
| 90 | // run on a different pull (or on production) never flips this page. | |
| 91 | if (thread === 'pull' && data.kind.indexOf('run.') === 0) { | |
| 92 | return data.payload.env === 'pull-' + number; | |
| 93 | } | |
| 88 | 94 | if (String(data.payload.number) !== String(number)) return false; |
| 89 | 95 | return data.kind.indexOf(thread) === 0; |
| 90 | 96 | } |
| @@ -128,6 +134,9 @@ export const liveClientJs = /* js */ ` | ||
| 128 | 134 | 'pull.opened', 'pull.closed', 'pull.reopened', 'pull.merged', 'pull.commented', 'pull.reviewed', |
| 129 | 135 | 'branch.created', 'branch.deleted', |
| 130 | 136 | 'label.created', 'label.updated', 'label.deleted', |
| 137 | 'run.queued', 'run.started', 'run.finished', | |
| 138 | 'app.created', 'app.updated', 'app.deleted', 'app.stopped', | |
| 139 | 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed', | |
| 131 | 140 | ]; |
| 132 | 141 | var extra = (cfg.getAttribute('data-repos') || '').split(' ').filter(Boolean); |
| 133 | 142 | var watching = [repo].concat(extra).filter(function (r, i, all) { |
src/web/pages/account.ts +4 −4
| @@ -44,11 +44,11 @@ type Section = "profile" | "password" | "tokens" | "keys"; | ||
| 44 | 44 | |
| 45 | 45 | // The section list lives beside the newest page that joins it, so adding one |
| 46 | 46 | // cannot leave the others pointing at a nav that has forgotten it. |
| 47 | const sections = (current: Section): SettingsSection[] => | |
| 48 | accountSections(current); | |
| 47 | const sections = (ctx: Ctx, current: Section): SettingsSection[] => | |
| 48 | accountSections(current, { admin: ctx.auth.user?.isAdmin === true }); | |
| 49 | 49 | |
| 50 | 50 | /** One account page: the shared shell, the section nav, and this section. */ |
| 51 | function accountShell( | |
| 51 | export function accountShell( | |
| 52 | 52 | ctx: Ctx, |
| 53 | 53 | current: Section, |
| 54 | 54 | title: string, |
| @@ -60,7 +60,7 @@ function accountShell( | ||
| 60 | 60 | ctx, |
| 61 | 61 | title, |
| 62 | 62 | body: html`<div class="page-head"><h1>${title}</h1></div> |
| 63 | ${settingsLayout(sections(current), body, "Account settings")}`, | |
| 63 | ${settingsLayout(sections(ctx, current), body, "Account settings")}`, | |
| 64 | 64 | }, |
| 65 | 65 | status, |
| 66 | 66 | ); |
src/web/pages/apps.ts added +784
| @@ -0,0 +1,784 @@ | ||
| 1 | /** | |
| 2 | * Apps on the web (docs/APPS.md): the list, the golden path's first step | |
| 3 | * (New app), one app's environments with the three things you can do to | |
| 4 | * one — open, redeploy, stop — and the two pieces other pages borrow: the | |
| 5 | * strip a repo home wears when it runs, and the preview card a pull carries. | |
| 6 | * | |
| 7 | * Every mutation is an action; a page only puts its answer into words. | |
| 8 | * Opening an environment goes through `openEnvUrl`, which is what makes a | |
| 9 | * private app work: the forge checks `canRead`, signs a short-lived grant, | |
| 10 | * and sends the visitor on — a link straight to the hostname would be a | |
| 11 | * 404 for everyone. | |
| 12 | */ | |
| 13 | ||
| 14 | import { | |
| 15 | appView, | |
| 16 | newApp, | |
| 17 | openEnvUrl, | |
| 18 | redeployEnv, | |
| 19 | sshCommandFor, | |
| 20 | stopEnv, | |
| 21 | } from "../../actions/apps.ts"; | |
| 22 | import { listAllAgents, listCrew } from "../../actions/crew.ts"; | |
| 23 | import { listRepoRuns } from "../../actions/runs.ts"; | |
| 24 | import { envPull, previewEnv, type EnvName } from "../../apps/data.ts"; | |
| 25 | import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts"; | |
| 26 | import { appsForRepos, type AppRecord } from "../../core/apps.ts"; | |
| 27 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; | |
| 28 | import type { Pull } from "../../core/pulls.ts"; | |
| 29 | import { | |
| 30 | canAdmin, | |
| 31 | canWrite, | |
| 32 | listReposVisibleTo, | |
| 33 | type Repo, | |
| 34 | } from "../../core/repos.ts"; | |
| 35 | import { isTerminal } from "../../core/runs.ts"; | |
| 36 | import type { User } from "../../core/users.ts"; | |
| 37 | import type { Ctx } from "../../http/context.ts"; | |
| 38 | import { | |
| 39 | crumbsFor, | |
| 40 | loadRepo, | |
| 41 | notFoundPage, | |
| 42 | pageHead, | |
| 43 | repoBase, | |
| 44 | repoHeader, | |
| 45 | repoPalette, | |
| 46 | signInFirst, | |
| 47 | } from "../chrome.ts"; | |
| 48 | import { field, readForm } from "../form.ts"; | |
| 49 | import { html, when, type Html } from "../html.ts"; | |
| 50 | import { flashTo, page, redirect } from "../layout.ts"; | |
| 51 | import { | |
| 52 | badge, | |
| 53 | btn, | |
| 54 | card, | |
| 55 | dot, | |
| 56 | empty, | |
| 57 | envStateBadge, | |
| 58 | envTone, | |
| 59 | icon, | |
| 60 | listCard, | |
| 61 | row, | |
| 62 | selectField, | |
| 63 | sha, | |
| 64 | time, | |
| 65 | userLink, | |
| 66 | field as formField, | |
| 67 | } from "../ui.ts"; | |
| 68 | import { actorsFor, issuesFor, runRow } from "./runs.ts"; | |
| 69 | ||
| 70 | // ---- what the viewer can see ---- | |
| 71 | ||
| 72 | interface AppEntry { | |
| 73 | readonly record: AppRecord; | |
| 74 | readonly repo: Repo; | |
| 75 | readonly production: EnvStatus | undefined; | |
| 76 | readonly previews: number; | |
| 77 | /** Production's public URL, or undefined when nothing serves apps. */ | |
| 78 | readonly url: string | undefined; | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Every app the viewer may see, newest first, with what production is | |
| 83 | * doing. Visibility is decided in SQL by `listReposVisibleTo` — exactly the | |
| 84 | * TUI's `listVisibleApps` — never by calling `canRead` once per app: that | |
| 85 | * would cost a query per row for any repo the viewer does not own outright | |
| 86 | * (`canRead` resolves org ownership from the database), and a listing must | |
| 87 | * not get slower as the forge grows more apps. | |
| 88 | */ | |
| 89 | function appsForViewer(ctx: Ctx): AppEntry[] { | |
| 90 | const repos = listReposVisibleTo(ctx.app.db, ctx.auth.user, { limit: 200 }); | |
| 91 | const records = appsForRepos( | |
| 92 | ctx.app.db, | |
| 93 | repos.map((repo) => repo.id), | |
| 94 | ); | |
| 95 | const runtime = appsRuntime(ctx.app); | |
| 96 | const out: AppEntry[] = []; | |
| 97 | for (const repo of repos) { | |
| 98 | const record = records.get(repo.id); | |
| 99 | if (record === undefined) continue; | |
| 100 | const envs = runtime === undefined ? [] : runtime.status(record); | |
| 101 | out.push({ | |
| 102 | record, | |
| 103 | repo, | |
| 104 | production: envs.find((env) => env.env === "production"), | |
| 105 | previews: envs.filter((env) => env.env !== "production").length, | |
| 106 | url: runtime?.urlFor(record, "production"), | |
| 107 | }); | |
| 108 | } | |
| 109 | return out; | |
| 110 | } | |
| 111 | ||
| 112 | /** `production` or `preview of #12`, as a phrase that fits in a sentence. */ | |
| 113 | export function envLabel(env: EnvName): string { | |
| 114 | const number = envPull(env); | |
| 115 | return number === undefined ? "production" : `preview of #${number}`; | |
| 116 | } | |
| 117 | ||
| 118 | /** | |
| 119 | * One app in a list. The dot is production's state; the title goes to the | |
| 120 | * App page and the URL opens the app — two links, so the row is not itself | |
| 121 | * one (a link inside a link tears the row apart). | |
| 122 | */ | |
| 123 | function appRow(entry: AppEntry): Html { | |
| 124 | const base = repoBase(entry.repo); | |
| 125 | const name = `${entry.repo.ownerName}/${entry.repo.name}`; | |
| 126 | return row({ | |
| 127 | nav: true, | |
| 128 | lead: dot(envTone(entry.production?.state)), | |
| 129 | title: html`<a href="${base}/app">${name}</a>`, | |
| 130 | titleAttr: name, | |
| 131 | meta: html`${entry.url === undefined | |
| 132 | ? html`<span>not serving</span>` | |
| 133 | : html`<a href="${base}/app/production/open" title="Open ${entry.url}" | |
| 134 | >${entry.url}</a | |
| 135 | >`}${when( | |
| 136 | entry.previews > 0, | |
| 137 | () => | |
| 138 | html` · ${entry.previews} preview${entry.previews === 1 ? "" : "s"}`, | |
| 139 | )}`, | |
| 140 | trail: html`${when( | |
| 141 | entry.repo.visibility === "private", | |
| 142 | () => | |
| 143 | html`<span class="badge" data-variant="secondary" | |
| 144 | >${icon("lock")}Private</span | |
| 145 | >`, | |
| 146 | )}`, | |
| 147 | }); | |
| 148 | } | |
| 149 | ||
| 150 | /** The dashboard's Apps card: what runs, and the button that makes one. */ | |
| 151 | export function appsCard(ctx: Ctx): Html { | |
| 152 | const entries = appsForViewer(ctx); | |
| 153 | const head = html`${icon("rocket")} | |
| 154 | <h2>Apps</h2> | |
| 155 | <span class="spacer"></span> | |
| 156 | ${btn("New app", { href: "/apps/new", size: "sm", icon: "plus" })}`; | |
| 157 | if (entries.length === 0) { | |
| 158 | return card( | |
| 159 | html`<p class="muted small"> | |
| 160 | An app is a repository that runs — here, with a URL in seconds and a | |
| 161 | preview for every pull request. | |
| 162 | </p>`, | |
| 163 | { head }, | |
| 164 | ); | |
| 165 | } | |
| 166 | const shown = entries.slice(0, 8); | |
| 167 | return listCard(shown.map(appRow), { | |
| 168 | head, | |
| 169 | ...(entries.length > shown.length | |
| 170 | ? { | |
| 171 | foot: html`<a href="/apps" | |
| 172 | >All ${entries.length} apps ${icon("chevronRight")}</a | |
| 173 | >`, | |
| 174 | } | |
| 175 | : {}), | |
| 176 | }); | |
| 177 | } | |
| 178 | ||
| 179 | // GET /apps | |
| 180 | export function appsList(ctx: Ctx): Response { | |
| 181 | const entries = appsForViewer(ctx); | |
| 182 | const create = ctx.auth.user | |
| 183 | ? btn("New app", { | |
| 184 | href: "/apps/new", | |
| 185 | variant: "primary", | |
| 186 | size: "sm", | |
| 187 | icon: "plus", | |
| 188 | shortcut: "c", | |
| 189 | }) | |
| 190 | : btn("Sign in to make one", { href: "/signin", size: "sm" }); | |
| 191 | const body = html`${pageHead("Apps", { | |
| 192 | actions: create, | |
| 193 | sub: "Repositories that run. Production follows the default branch; every open pull request gets a preview.", | |
| 194 | })} | |
| 195 | ${entries.length === 0 | |
| 196 | ? empty({ | |
| 197 | icon: "rocket", | |
| 198 | title: "Nothing runs here yet", | |
| 199 | note: "A new app is a repository with a small working app in it, served at its own hostname a few seconds later.", | |
| 200 | action: create, | |
| 201 | }) | |
| 202 | : listCard(entries.map(appRow))}`; | |
| 203 | return page({ ctx, title: "Apps", body }); | |
| 204 | } | |
| 205 | ||
| 206 | // ---- new app ---- | |
| 207 | ||
| 208 | interface NewAppValues { | |
| 209 | owner: string; | |
| 210 | name: string; | |
| 211 | description: string; | |
| 212 | visibility: string; | |
| 213 | agent: string; | |
| 214 | } | |
| 215 | ||
| 216 | function newAppForm(ctx: Ctx, values: NewAppValues, error?: string): Html { | |
| 217 | const user = ctx.auth.user!; | |
| 218 | const writeOrgs = listOrgsForUser(ctx.app.db, user.id).filter((org) => | |
| 219 | accessAllows(orgAccess(ctx.app.db, org.id, user), "write"), | |
| 220 | ); | |
| 221 | const owners = [user.username, ...writeOrgs.map((o) => o.username)]; | |
| 222 | const agents = listAllAgents(ctx.app, user); | |
| 223 | const crew = agents.ok ? agents.value : []; | |
| 224 | const domain = appsRuntime(ctx.app)?.options.domain; | |
| 225 | ||
| 226 | return html`<div class="narrow-wide"> | |
| 227 | ${pageHead("New app", { | |
| 228 | sub: "A repository with a small working app in it, running in production a few seconds from now.", | |
| 229 | })} | |
| 230 | ${card( | |
| 231 | html`<form method="post" action="/apps/new"> | |
| 232 | ${selectField({ | |
| 233 | label: "Owner", | |
| 234 | name: "owner", | |
| 235 | options: owners.map((o) => ({ | |
| 236 | value: o, | |
| 237 | label: o, | |
| 238 | selected: o === values.owner, | |
| 239 | })), | |
| 240 | })} | |
| 241 | ${formField({ | |
| 242 | label: "Name", | |
| 243 | name: "name", | |
| 244 | value: values.name, | |
| 245 | required: true, | |
| 246 | autofocus: true, | |
| 247 | pattern: "[A-Za-z0-9][A-Za-z0-9._-]*", | |
| 248 | hint: | |
| 249 | domain === undefined | |
| 250 | ? "Letters, digits, dots, dashes and underscores. The hostname is made from it." | |
| 251 | : `Letters, digits, dots, dashes and underscores. It runs at <name>.${domain}.`, | |
| 252 | ...(error ? { error } : {}), | |
| 253 | })} | |
| 254 | ${formField({ | |
| 255 | label: "What should it do?", | |
| 256 | name: "description", | |
| 257 | type: "textarea", | |
| 258 | rows: 3, | |
| 259 | value: values.description, | |
| 260 | hint: "Optional. A sentence is enough — it becomes the first issue, and the agent you pick below starts on it.", | |
| 261 | })} | |
| 262 | ${selectField({ | |
| 263 | label: "Visibility", | |
| 264 | name: "visibility", | |
| 265 | hint: "A private app answers only to people who can see its repository; the forge vouches for them on the way in.", | |
| 266 | options: [ | |
| 267 | { | |
| 268 | value: "private", | |
| 269 | label: "Private", | |
| 270 | selected: values.visibility !== "public", | |
| 271 | }, | |
| 272 | { | |
| 273 | value: "public", | |
| 274 | label: "Public", | |
| 275 | selected: values.visibility === "public", | |
| 276 | }, | |
| 277 | ], | |
| 278 | })} | |
| 279 | ${crew.length === 0 | |
| 280 | ? html`<p class="muted small mb"> | |
| 281 | ${icon("bot")} No agents on this forge yet, so this app has no | |
| 282 | crew to hand the first issue to. | |
| 283 | ${user.isAdmin | |
| 284 | ? html`Create one under | |
| 285 | <a href="/settings/crew">Settings → Crew</a>.` | |
| 286 | : html`An admin can create one under account settings → Crew.`} | |
| 287 | </p>` | |
| 288 | : selectField({ | |
| 289 | label: "Agent", | |
| 290 | name: "agent", | |
| 291 | hint: "Joins the crew of the new app. With a sentence above, it gets the first issue and starts on it.", | |
| 292 | options: [ | |
| 293 | { | |
| 294 | value: "", | |
| 295 | label: "Nobody — I'll build it myself", | |
| 296 | selected: values.agent === "", | |
| 297 | }, | |
| 298 | ...crew.map((agent) => ({ | |
| 299 | value: agent.user.username, | |
| 300 | label: | |
| 301 | agent.model === "" | |
| 302 | ? agent.user.username | |
| 303 | : `${agent.user.username} — ${agent.model}`, | |
| 304 | selected: values.agent === agent.user.username, | |
| 305 | })), | |
| 306 | ], | |
| 307 | })} | |
| 308 | <div class="form-actions"> | |
| 309 | ${btn("Create app", { variant: "primary", icon: "rocket" })} | |
| 310 | ${btn("Cancel", { href: "/apps", variant: "ghost" })} | |
| 311 | </div> | |
| 312 | </form>`, | |
| 313 | )} | |
| 314 | </div>`; | |
| 315 | } | |
| 316 | ||
| 317 | // GET /apps/new | |
| 318 | export function newAppPage(ctx: Ctx): Response { | |
| 319 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 320 | return page({ | |
| 321 | ctx, | |
| 322 | title: "New app", | |
| 323 | body: newAppForm(ctx, { | |
| 324 | owner: ctx.url.searchParams.get("owner") ?? ctx.auth.user.username, | |
| 325 | name: "", | |
| 326 | description: "", | |
| 327 | visibility: "private", | |
| 328 | agent: "", | |
| 329 | }), | |
| 330 | }); | |
| 331 | } | |
| 332 | ||
| 333 | const NEW_APP_ERRORS: Record<string, string> = { | |
| 334 | name_taken: "That owner already has a repository by that name.", | |
| 335 | invalid_name: "Names start with a letter or digit; no spaces, no .git.", | |
| 336 | git_init_failed: "The repository could not be initialised on disk.", | |
| 337 | invalid_slug: | |
| 338 | "That name makes no usable hostname — start it with a letter or digit.", | |
| 339 | slug_taken: | |
| 340 | "Another app already answers to that hostname. Pick another name.", | |
| 341 | already_app: "That repository is already an app.", | |
| 342 | scaffold_failed: "The starter app could not be committed.", | |
| 343 | no_such_agent: "That agent is not on this forge.", | |
| 344 | }; | |
| 345 | ||
| 346 | // POST /apps/new | |
| 347 | export async function newAppSubmit(ctx: Ctx): Promise<Response> { | |
| 348 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 349 | const form = await readForm(ctx, "rw"); | |
| 350 | if (form instanceof Response) return form; | |
| 351 | const values: NewAppValues = { | |
| 352 | owner: field(form, "owner"), | |
| 353 | name: field(form, "name"), | |
| 354 | description: field(form, "description"), | |
| 355 | visibility: field(form, "visibility") === "public" ? "public" : "private", | |
| 356 | agent: field(form, "agent"), | |
| 357 | }; | |
| 358 | const made = await newApp(ctx.app, ctx.auth.user, { | |
| 359 | name: values.name, | |
| 360 | owner: values.owner, | |
| 361 | description: values.description, | |
| 362 | visibility: values.visibility === "public" ? "public" : "private", | |
| 363 | ...(values.agent === "" ? {} : { agent: values.agent }), | |
| 364 | }); | |
| 365 | if (!made.ok) { | |
| 366 | if (made.error === "forbidden_owner" || made.error === "unauthorized") | |
| 367 | return new Response("forbidden", { status: 403 }); | |
| 368 | return page( | |
| 369 | { | |
| 370 | ctx, | |
| 371 | title: "New app", | |
| 372 | body: newAppForm( | |
| 373 | ctx, | |
| 374 | values, | |
| 375 | NEW_APP_ERRORS[made.error] ?? "That app could not be created.", | |
| 376 | ), | |
| 377 | }, | |
| 378 | 422, | |
| 379 | ); | |
| 380 | } | |
| 381 | const { repo, app: record, issue } = made.value; | |
| 382 | const url = appsRuntime(ctx.app)?.urlFor(record, "production"); | |
| 383 | const first = | |
| 384 | url === undefined | |
| 385 | ? `${repo.name} is an app, but nothing is serving apps on this forge.` | |
| 386 | : `${repo.name} is starting at ${url}.`; | |
| 387 | const second = | |
| 388 | issue === undefined || issue.assigneeName === null | |
| 389 | ? "" | |
| 390 | : ` #${issue.number} is assigned to ${issue.assigneeName}.`; | |
| 391 | return flashTo(ctx, repoBase(repo), { message: `${first}${second}` }); | |
| 392 | } | |
| 393 | ||
| 394 | // ---- the repo home's strip ---- | |
| 395 | ||
| 396 | /** | |
| 397 | * One line under the repo header, for a repository that runs: production's | |
| 398 | * state, the commit it serves, its URL, how many previews are up, and the | |
| 399 | * way to the App page. Nothing for a repository that is not an app. | |
| 400 | */ | |
| 401 | export function appStrip(ctx: Ctx, repo: Repo): Html { | |
| 402 | const view = appView(ctx.app, ctx.auth.user, repo); | |
| 403 | if (!view.ok) return html``; | |
| 404 | const { app: record, environments, serving } = view.value; | |
| 405 | const base = repoBase(repo); | |
| 406 | const production = environments.find((env) => env.env === "production"); | |
| 407 | const previews = environments.length - (production === undefined ? 0 : 1); | |
| 408 | const url = appsRuntime(ctx.app)?.urlFor(record, "production"); | |
| 409 | return html`<div class="app-strip"> | |
| 410 | ${icon("rocket")} | |
| 411 | ${serving | |
| 412 | ? envStateBadge(production?.state ?? "stopped") | |
| 413 | : badge("Not serving", { variant: "outline" })} | |
| 414 | ${when(production !== undefined && production.commitOid !== "", () => | |
| 415 | sha(production!.commitOid, `${base}/commit/${production!.commitOid}`), | |
| 416 | )} | |
| 417 | ${when( | |
| 418 | url, | |
| 419 | (u) => | |
| 420 | html`<a | |
| 421 | class="app-url" | |
| 422 | href="${base}/app/production/open" | |
| 423 | title="Open ${u}" | |
| 424 | >${u}</a | |
| 425 | >`, | |
| 426 | )} | |
| 427 | ${when( | |
| 428 | previews > 0, | |
| 429 | () => | |
| 430 | html`<span class="muted" | |
| 431 | >${previews} preview${previews === 1 ? "" : "s"}</span | |
| 432 | >`, | |
| 433 | )} | |
| 434 | <span class="spacer"></span> | |
| 435 | ${btn("App", { | |
| 436 | href: `${base}/app`, | |
| 437 | size: "sm", | |
| 438 | variant: "ghost", | |
| 439 | icon: "chevronRight", | |
| 440 | })} | |
| 441 | </div>`; | |
| 442 | } | |
| 443 | ||
| 444 | // ---- the app page ---- | |
| 445 | ||
| 446 | const envFacts = (base: string, env: EnvStatus): Html => | |
| 447 | html`<div class="env-facts"> | |
| 448 | ${when(env.commitOid !== "", () => | |
| 449 | sha(env.commitOid, `${base}/commit/${env.commitOid}`), | |
| 450 | )} | |
| 451 | ${when(env.since, (since) => html`<span>up ${time(since)}</span>`)} | |
| 452 | ${when( | |
| 453 | env.runId, | |
| 454 | (id) => | |
| 455 | html`<a href="${base}/runs/${id}" | |
| 456 | >${env.state === "deploying" | |
| 457 | ? "deploy in progress" | |
| 458 | : "last deploy"}</a | |
| 459 | >`, | |
| 460 | )} | |
| 461 | </div>`; | |
| 462 | ||
| 463 | /** One environment: what it is doing, the facts, and the buttons. */ | |
| 464 | function envCard(base: string, env: EnvStatus, writer: boolean): Html { | |
| 465 | const number = envPull(env.env); | |
| 466 | return card( | |
| 467 | html`${envFacts(base, env)} | |
| 468 | <p class="env-facts mt"> | |
| 469 | <span class="mono truncate" title="${env.url}">${env.url}</span> | |
| 470 | </p> | |
| 471 | ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}`, | |
| 472 | { | |
| 473 | extra: "env-card", | |
| 474 | ...(env.state === "failed" ? { tone: "danger" as const } : {}), | |
| 475 | head: html`${icon(number === undefined ? "rocket" : "pull")} | |
| 476 | <h2> | |
| 477 | ${number === undefined | |
| 478 | ? "production" | |
| 479 | : html`preview of <a href="${base}/pulls/${number}">#${number}</a>`} | |
| 480 | </h2> | |
| 481 | ${envStateBadge(env.state)}`, | |
| 482 | foot: html`${btn("Open", { | |
| 483 | href: `${base}/app/${env.env}/open`, | |
| 484 | size: "sm", | |
| 485 | variant: "outline", | |
| 486 | icon: "external", | |
| 487 | })} | |
| 488 | ${when( | |
| 489 | writer, | |
| 490 | () => | |
| 491 | html`<form method="post" action="${base}/app/${env.env}/redeploy"> | |
| 492 | ${btn("Redeploy", { size: "sm", icon: "sync" })} | |
| 493 | </form> | |
| 494 | ${when( | |
| 495 | env.state !== "stopped", | |
| 496 | () => | |
| 497 | html`<form method="post" action="${base}/app/${env.env}/stop"> | |
| 498 | ${btn("Stop", { size: "sm", icon: "square" })} | |
| 499 | </form>`, | |
| 500 | )}`, | |
| 501 | )}`, | |
| 502 | }, | |
| 503 | ); | |
| 504 | } | |
| 505 | ||
| 506 | // GET /:owner/:name/app | |
| 507 | export function appPage(ctx: Ctx): Response { | |
| 508 | const repo = loadRepo(ctx); | |
| 509 | if (!repo) return notFoundPage(ctx); | |
| 510 | const view = appView(ctx.app, ctx.auth.user, repo); | |
| 511 | if (!view.ok) return notFoundPage(ctx); | |
| 512 | const { app: record, environments, serving } = view.value; | |
| 513 | const base = repoBase(repo); | |
| 514 | const writer = canWrite(ctx.app.db, repo, ctx.auth.user); | |
| 515 | const admin = canAdmin(ctx.app.db, repo, ctx.auth.user); | |
| 516 | // Production first, then previews by pull number: the runtime's own order. | |
| 517 | const envs = environments; | |
| 518 | const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, { limit: 15 }); | |
| 519 | const runs = listed.ok ? listed.value : []; | |
| 520 | const crew = listCrew(ctx.app, ctx.auth.user, repo); | |
| 521 | const url = appsRuntime(ctx.app)?.urlFor(record, "production"); | |
| 522 | const ssh = sshCommandFor(ctx.app, record, "production"); | |
| 523 | // The same rows the runs page draws, from the same two grouped reads: a | |
| 524 | // table's first column folds into a sliver at phone width, a row does not. | |
| 525 | const runsHead = html`${icon("activity")} | |
| 526 | <h2>Recent runs</h2> | |
| 527 | <span class="spacer"></span> | |
| 528 | ${btn("All runs", { | |
| 529 | href: `${base}/runs`, | |
| 530 | size: "sm", | |
| 531 | variant: "ghost", | |
| 532 | icon: "chevronRight", | |
| 533 | })}`; | |
| 534 | const actors = actorsFor(ctx, runs); | |
| 535 | const issues = issuesFor(ctx, runs); | |
| 536 | const runsCard = | |
| 537 | runs.length === 0 | |
| 538 | ? card(html`<p class="muted small">No runs yet.</p>`, { head: runsHead }) | |
| 539 | : listCard( | |
| 540 | runs.map((run) => runRow(base, run, actors, issues)), | |
| 541 | { head: runsHead }, | |
| 542 | ); | |
| 543 | // Something is still moving: refresh without a script, and the live pill | |
| 544 | // says so with one. | |
| 545 | const moving = | |
| 546 | envs.some((env) => env.state === "deploying") || | |
| 547 | runs.some((run) => !isTerminal(run.state)); | |
| 548 | ||
| 549 | const environmentsBlock = !serving | |
| 550 | ? card( | |
| 551 | html`<p class="muted small"> | |
| 552 | Nothing is serving apps on this forge right now — it was started with | |
| 553 | <span class="mono">FORGE_APPS=0</span>. The app is still declared; | |
| 554 | start the forge with apps on and production comes up from the default | |
| 555 | branch. | |
| 556 | </p>`, | |
| 557 | { | |
| 558 | head: html`${icon("alert")} | |
| 559 | <h2>Not serving</h2>`, | |
| 560 | }, | |
| 561 | ) | |
| 562 | : envs.length === 0 | |
| 563 | ? empty({ | |
| 564 | icon: "rocket", | |
| 565 | title: "Nothing is running yet", | |
| 566 | note: "Production starts from the default branch; a preview starts when a pull request opens. Give it a moment, or push something.", | |
| 567 | }) | |
| 568 | : html`<div class="stack"> | |
| 569 | ${envs.map((env) => envCard(base, env, writer))} | |
| 570 | </div>`; | |
| 571 | ||
| 572 | const about = card( | |
| 573 | html`<dl> | |
| 574 | <dt>Hostname</dt> | |
| 575 | <dd> | |
| 576 | ${url === undefined | |
| 577 | ? html`<span class="muted">not serving</span>` | |
| 578 | : html`<a href="${base}/app/production/open" class="mono">${url}</a>`} | |
| 579 | </dd> | |
| 580 | ${when( | |
| 581 | ssh !== undefined, | |
| 582 | () => | |
| 583 | html`<dt>Terminal</dt> | |
| 584 | <dd><code class="mono">${ssh ?? ""}</code></dd>`, | |
| 585 | )} | |
| 586 | <dt>Access</dt> | |
| 587 | <dd> | |
| 588 | ${record.access === "private" | |
| 589 | ? "private — only people who can see this repository" | |
| 590 | : "public — anyone with the link"} | |
| 591 | </dd> | |
| 592 | <dt>Preview data</dt> | |
| 593 | <dd> | |
| 594 | ${record.previewData === "copy" | |
| 595 | ? "a copy of production's, taken when the preview starts" | |
| 596 | : "empty"} | |
| 597 | </dd> | |
| 598 | </dl>`, | |
| 599 | { | |
| 600 | extra: "about", | |
| 601 | head: html`${icon("rocket")} | |
| 602 | <h2>${record.slug}</h2> | |
| 603 | ${when( | |
| 604 | admin, | |
| 605 | () => | |
| 606 | html`<span class="spacer"></span> ${btn("Settings", { | |
| 607 | href: `${base}/settings/app`, | |
| 608 | size: "sm", | |
| 609 | variant: "ghost", | |
| 610 | icon: "settings", | |
| 611 | })}`, | |
| 612 | )}`, | |
| 613 | }, | |
| 614 | ); | |
| 615 | ||
| 616 | const crewCard = when( | |
| 617 | crew.ok && crew.value.length > 0, | |
| 618 | () => | |
| 619 | html`<div class="mt"> | |
| 620 | ${listCard( | |
| 621 | (crew.ok ? crew.value : []).map((agent) => | |
| 622 | row({ | |
| 623 | lead: icon("bot"), | |
| 624 | title: html`${userLink(agent.user.username)}`, | |
| 625 | ...(agent.model === "" ? {} : { meta: html`${agent.model}` }), | |
| 626 | }), | |
| 627 | ), | |
| 628 | { | |
| 629 | head: html`${icon("bot")} | |
| 630 | <h2>Crew</h2>`, | |
| 631 | }, | |
| 632 | )} | |
| 633 | </div>`, | |
| 634 | ); | |
| 635 | ||
| 636 | const body = html`${repoHeader(ctx, repo, "app", { compact: true })} | |
| 637 | <div class="split"> | |
| 638 | <div> | |
| 639 | ${environmentsBlock} | |
| 640 | <div class="mt">${runsCard}</div> | |
| 641 | </div> | |
| 642 | <aside>${about} ${crewCard}</aside> | |
| 643 | </div>`; | |
| 644 | ||
| 645 | return page({ | |
| 646 | ctx, | |
| 647 | title: `App · ${repo.name}`, | |
| 648 | crumbs: crumbsFor(repo), | |
| 649 | palette: repoPalette(repo), | |
| 650 | body, | |
| 651 | ...(moving ? { refresh: { seconds: 5 } } : {}), | |
| 652 | }); | |
| 653 | } | |
| 654 | ||
| 655 | // ---- the pull page's preview card ---- | |
| 656 | ||
| 657 | /** Where this pull's preview stands, with the way in. */ | |
| 658 | export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { | |
| 659 | const view = appView(ctx.app, ctx.auth.user, repo); | |
| 660 | if (!view.ok) return html``; | |
| 661 | const base = repoBase(repo); | |
| 662 | const env = view.value.environments.find( | |
| 663 | (e) => e.env === previewEnv(pull.number), | |
| 664 | ); | |
| 665 | const head = html`${icon("rocket")} | |
| 666 | <h2>Preview</h2> | |
| 667 | ${env === undefined ? null : envStateBadge(env.state)}`; | |
| 668 | if (!view.value.serving) | |
| 669 | return card( | |
| 670 | html`<p class="muted small">Nothing is serving apps on this forge.</p>`, | |
| 671 | { head }, | |
| 672 | ); | |
| 673 | if (env === undefined) { | |
| 674 | const why = | |
| 675 | pull.mergedAt !== null | |
| 676 | ? "The preview and its data were removed when this merged." | |
| 677 | : pull.state === "closed" | |
| 678 | ? "The preview was removed when this closed. Reopen the pull request and it comes back." | |
| 679 | : "No preview yet — one starts when this pull request's head deploys."; | |
| 680 | return card(html`<p class="muted small">${why}</p>`, { head }); | |
| 681 | } | |
| 682 | return card( | |
| 683 | html`${envFacts(base, env)} | |
| 684 | ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)} | |
| 685 | <div class="bar mt"> | |
| 686 | ${btn("Open preview", { | |
| 687 | href: `${base}/app/${env.env}/open`, | |
| 688 | size: "sm", | |
| 689 | variant: "outline", | |
| 690 | icon: "external", | |
| 691 | })} | |
| 692 | </div>`, | |
| 693 | { | |
| 694 | head, | |
| 695 | extra: "env-card", | |
| 696 | ...(env.state === "failed" ? { tone: "danger" as const } : {}), | |
| 697 | }, | |
| 698 | ); | |
| 699 | } | |
| 700 | ||
| 701 | // ---- environment routes ---- | |
| 702 | ||
| 703 | // GET /:owner/:name/app/:env/open — through the forge, so a private app | |
| 704 | // gets its grant; a plain link to the hostname would 404 for everyone. | |
| 705 | export function appOpen(ctx: Ctx): Response { | |
| 706 | const repo = loadRepo(ctx); | |
| 707 | if (!repo) return notFoundPage(ctx); | |
| 708 | const link = openEnvUrl(ctx.app, ctx.auth.user, repo, ctx.params.env ?? ""); | |
| 709 | if (!link.ok) { | |
| 710 | if (link.error === "unauthorized") return signInFirst(ctx); | |
| 711 | if (link.error === "no_runtime") | |
| 712 | return flashTo(ctx, `${repoBase(repo)}/app`, { | |
| 713 | tone: "attn", | |
| 714 | message: "Nothing is serving apps on this forge.", | |
| 715 | }); | |
| 716 | return notFoundPage(ctx); | |
| 717 | } | |
| 718 | return new Response(null, { | |
| 719 | status: 302, | |
| 720 | headers: { location: link.value, "cache-control": "no-store" }, | |
| 721 | }); | |
| 722 | } | |
| 723 | ||
| 724 | /** The gate every environment POST passes: a readable repo, a signed-in writer-scope form. */ | |
| 725 | async function envForm( | |
| 726 | ctx: Ctx, | |
| 727 | ): Promise<{ repo: Repo; env: string; user: User } | Response> { | |
| 728 | const repo = loadRepo(ctx); | |
| 729 | if (!repo) return notFoundPage(ctx); | |
| 730 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 731 | const form = await readForm(ctx, "rw"); | |
| 732 | if (form instanceof Response) return form; | |
| 733 | return { repo, env: ctx.params.env ?? "", user: ctx.auth.user }; | |
| 734 | } | |
| 735 | ||
| 736 | function envFailure(ctx: Ctx, repo: Repo, error: string): Response { | |
| 737 | if (error === "not_found" || error === "no_such_env") | |
| 738 | return notFoundPage(ctx); | |
| 739 | const words: Record<string, string> = { | |
| 740 | forbidden: "You need write access to do that.", | |
| 741 | no_runtime: "Nothing is serving apps on this forge.", | |
| 742 | nothing_to_deploy: | |
| 743 | "There is nothing to deploy: the branch has no commits, or the pull request is closed.", | |
| 744 | }; | |
| 745 | return flashTo(ctx, `${repoBase(repo)}/app`, { | |
| 746 | tone: "danger", | |
| 747 | message: words[error] ?? "That could not be done.", | |
| 748 | }); | |
| 749 | } | |
| 750 | ||
| 751 | // POST /:owner/:name/app/:env/redeploy | |
| 752 | export async function appRedeploy(ctx: Ctx): Promise<Response> { | |
| 753 | const gate = await envForm(ctx); | |
| 754 | if (gate instanceof Response) return gate; | |
| 755 | const { repo, env, user } = gate; | |
| 756 | const queued = await redeployEnv(ctx.app, user, repo, env); | |
| 757 | if (!queued.ok) return envFailure(ctx, repo, queued.error); | |
| 758 | const base = repoBase(repo); | |
| 759 | const run = queued.value; | |
| 760 | return redirect( | |
| 761 | run === undefined ? `${base}/app` : `${base}/runs/${run.id}`, | |
| 762 | { | |
| 763 | url: ctx.url, | |
| 764 | flash: { | |
| 765 | message: | |
| 766 | run === undefined | |
| 767 | ? `${envLabel(env as EnvName)} already serves that commit.` | |
| 768 | : `Redeploying ${envLabel(env as EnvName)}.`, | |
| 769 | }, | |
| 770 | }, | |
| 771 | ); | |
| 772 | } | |
| 773 | ||
| 774 | // POST /:owner/:name/app/:env/stop | |
| 775 | export async function appStop(ctx: Ctx): Promise<Response> { | |
| 776 | const gate = await envForm(ctx); | |
| 777 | if (gate instanceof Response) return gate; | |
| 778 | const { repo, env, user } = gate; | |
| 779 | const stopped = await stopEnv(ctx.app, user, repo, env); | |
| 780 | if (!stopped.ok) return envFailure(ctx, repo, stopped.error); | |
| 781 | return flashTo(ctx, `${repoBase(repo)}/app`, { | |
| 782 | message: `Stopped ${envLabel(env as EnvName)}. Its data is kept; Redeploy brings it back.`, | |
| 783 | }); | |
| 784 | } |
src/web/pages/crew.ts added +426
| @@ -0,0 +1,426 @@ | ||
| 1 | /** | |
| 2 | * The crew, in account settings: the forge's agents. A forge admin's | |
| 3 | * section — an agent is an account that acts — so a non-admin gets the one | |
| 4 | * 404 rather than a door they cannot open, and the nav never shows it to | |
| 5 | * them. Every write is an action; the page says whether the runner behind | |
| 6 | * the crew is actually there, in words, before anyone assigns anything. | |
| 7 | */ | |
| 8 | ||
| 9 | import { | |
| 10 | createAgent, | |
| 11 | deleteAgent, | |
| 12 | getAgentView, | |
| 13 | listAllAgents, | |
| 14 | updateAgent, | |
| 15 | } from "../../actions/crew.ts"; | |
| 16 | import { | |
| 17 | BRIEF_MAX, | |
| 18 | BUDGET_MAX_USD, | |
| 19 | MODEL_MAX, | |
| 20 | type Agent, | |
| 21 | } from "../../core/agents.ts"; | |
| 22 | import { crewFor } from "../../crew/dispatch.ts"; | |
| 23 | import type { Ctx } from "../../http/context.ts"; | |
| 24 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; | |
| 25 | import { field, readForm, type Form } from "../form.ts"; | |
| 26 | import { html, when, type Html } from "../html.ts"; | |
| 27 | import { flashTo, page } from "../layout.ts"; | |
| 28 | import { | |
| 29 | btn, | |
| 30 | card, | |
| 31 | confirmAction, | |
| 32 | empty, | |
| 33 | icon, | |
| 34 | time, | |
| 35 | field as uiField, | |
| 36 | userLink, | |
| 37 | } from "../ui.ts"; | |
| 38 | import { accountSections } from "./origins.ts"; | |
| 39 | ||
| 40 | /** The admin, or the response that turns everyone else away. */ | |
| 41 | function requireAdmin(ctx: Ctx): Response | undefined { | |
| 42 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 43 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); | |
| 44 | return undefined; | |
| 45 | } | |
| 46 | ||
| 47 | /** The gate every crew POST passes: an admin, same-origin, admin scope. */ | |
| 48 | async function adminForm(ctx: Ctx): Promise<Form | Response> { | |
| 49 | const denied = requireAdmin(ctx); | |
| 50 | if (denied) return denied; | |
| 51 | return readForm(ctx, "admin"); | |
| 52 | } | |
| 53 | ||
| 54 | function shell(ctx: Ctx, title: string, body: Html, status = 200): Response { | |
| 55 | return page( | |
| 56 | { | |
| 57 | ctx, | |
| 58 | title, | |
| 59 | body: html`<div class="page-head"><h1>${title}</h1></div> | |
| 60 | ${settingsLayout( | |
| 61 | accountSections("crew", { admin: true }), | |
| 62 | body, | |
| 63 | "Account settings", | |
| 64 | )}`, | |
| 65 | }, | |
| 66 | status, | |
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * Whether anything will pick up an agent's work. The runner is detected | |
| 72 | * once at boot; this is that answer, said before an issue is assigned | |
| 73 | * rather than after it sits there. | |
| 74 | */ | |
| 75 | export function crewReadiness(ctx: Ctx): Html { | |
| 76 | const crew = crewFor(ctx.app); | |
| 77 | if (crew === undefined) | |
| 78 | return html`<p class="error-note mb"> | |
| 79 | ${icon("alert")} No crew runner is up on this forge, so assigned issues | |
| 80 | will wait. Start the forge with apps on (the default) and Claude Code on | |
| 81 | <span class="mono">PATH</span> or at | |
| 82 | <span class="mono">FORGE_CLAUDE</span>. | |
| 83 | </p>`; | |
| 84 | if (!crew.ready) | |
| 85 | return html`<p class="error-note mb"> | |
| 86 | ${icon("alert")} The crew cannot run: ${crew.detail}. Agents can be | |
| 87 | created, but nothing picks up their work until this is fixed. | |
| 88 | </p>`; | |
| 89 | return html`<p class="muted small mb"> | |
| 90 | ${icon("check")} ${crew.detail} is ready to run the crew. | |
| 91 | </p>`; | |
| 92 | } | |
| 93 | ||
| 94 | // ---- the form ---- | |
| 95 | ||
| 96 | interface AgentValues { | |
| 97 | username: string; | |
| 98 | displayName: string; | |
| 99 | model: string; | |
| 100 | brief: string; | |
| 101 | budgetUsd: string; | |
| 102 | } | |
| 103 | ||
| 104 | const AGENT_ERRORS: Record<string, string> = { | |
| 105 | invalid_username: "Usernames are letters, digits, dots, dashes.", | |
| 106 | username_taken: "That username is taken.", | |
| 107 | email_taken: "That username is taken.", | |
| 108 | invalid_model: `A model is up to ${MODEL_MAX} characters with no spaces.`, | |
| 109 | brief_too_long: `A brief is at most ${BRIEF_MAX} characters.`, | |
| 110 | invalid_budget: `A budget is a number of dollars between 0 and ${BUDGET_MAX_USD}.`, | |
| 111 | }; | |
| 112 | ||
| 113 | function agentFields(values: AgentValues, error?: string): Html { | |
| 114 | return html`${uiField({ | |
| 115 | label: "Model", | |
| 116 | name: "model", | |
| 117 | value: values.model, | |
| 118 | mono: true, | |
| 119 | hint: "A Claude Code model alias or id. Empty means the runner's default.", | |
| 120 | ...(error !== undefined && error.startsWith("A model") ? { error } : {}), | |
| 121 | })} | |
| 122 | ${uiField({ | |
| 123 | label: "Brief", | |
| 124 | name: "brief", | |
| 125 | type: "textarea", | |
| 126 | rows: 5, | |
| 127 | value: values.brief, | |
| 128 | hint: "Who this agent is, in its own voice — appended to the prompt of every run. The repository's AGENTS.md says what the work is; this says who does it.", | |
| 129 | ...(error !== undefined && error.startsWith("A brief") ? { error } : {}), | |
| 130 | })} | |
| 131 | ${uiField({ | |
| 132 | label: "Budget per run (USD)", | |
| 133 | name: "budgetUsd", | |
| 134 | type: "number", | |
| 135 | value: values.budgetUsd, | |
| 136 | hint: "What one run may spend. 0 uses the forge's default (FORGE_CREW_BUDGET_USD).", | |
| 137 | ...(error !== undefined && error.startsWith("A budget") ? { error } : {}), | |
| 138 | })}`; | |
| 139 | } | |
| 140 | ||
| 141 | const valuesOf = (agent: Agent): AgentValues => ({ | |
| 142 | username: agent.user.username, | |
| 143 | displayName: agent.user.displayName, | |
| 144 | model: agent.model, | |
| 145 | brief: agent.brief, | |
| 146 | budgetUsd: agent.budgetUsd === 0 ? "" : String(agent.budgetUsd), | |
| 147 | }); | |
| 148 | ||
| 149 | const budgetOf = (form: Form): number | undefined => { | |
| 150 | const raw = field(form, "budgetUsd").trim(); | |
| 151 | return raw === "" ? undefined : Number(raw); | |
| 152 | }; | |
| 153 | ||
| 154 | // ---- the list ---- | |
| 155 | ||
| 156 | function crewView(ctx: Ctx, values: AgentValues, error?: string): Html { | |
| 157 | const listed = listAllAgents(ctx.app, ctx.auth.user); | |
| 158 | const agents = listed.ok ? listed.value : []; | |
| 159 | const usernameError = | |
| 160 | error !== undefined && /username/i.test(error) ? error : undefined; | |
| 161 | return html`${crewReadiness(ctx)} | |
| 162 | ${agents.length === 0 | |
| 163 | ? empty({ | |
| 164 | icon: "bot", | |
| 165 | title: "No agents yet", | |
| 166 | note: "An agent is an account that works on a repository while you are elsewhere. Make one below, then put it on a repository's crew in that repository's settings.", | |
| 167 | }) | |
| 168 | : card( | |
| 169 | html`<div class="table-scroll"> | |
| 170 | <table class="table"> | |
| 171 | <thead> | |
| 172 | <tr> | |
| 173 | <th>Agent</th> | |
| 174 | <th>Model</th> | |
| 175 | <th>Budget</th> | |
| 176 | <th>Created</th> | |
| 177 | <th></th> | |
| 178 | </tr> | |
| 179 | </thead> | |
| 180 | <tbody> | |
| 181 | ${agents.map( | |
| 182 | (agent) => | |
| 183 | html`<tr> | |
| 184 | <td> | |
| 185 | ${icon("bot")} | |
| 186 | ${userLink(agent.user.username, { | |
| 187 | avatar: false, | |
| 188 | })} | |
| 189 | ${when( | |
| 190 | agent.user.displayName !== agent.user.username, | |
| 191 | () => | |
| 192 | html`<span class="muted small" | |
| 193 | >${agent.user.displayName}</span | |
| 194 | >`, | |
| 195 | )} | |
| 196 | ${when( | |
| 197 | agent.brief !== "", | |
| 198 | () => | |
| 199 | html`<div | |
| 200 | class="muted small truncate" | |
| 201 | title="${agent.brief}" | |
| 202 | > | |
| 203 | ${agent.brief} | |
| 204 | </div>`, | |
| 205 | )} | |
| 206 | </td> | |
| 207 | <td class="mono"> | |
| 208 | ${agent.model === "" | |
| 209 | ? html`<span class="muted">default</span>` | |
| 210 | : agent.model} | |
| 211 | </td> | |
| 212 | <td class="num"> | |
| 213 | ${agent.budgetUsd === 0 | |
| 214 | ? html`<span class="muted">default</span>` | |
| 215 | : `$${agent.budgetUsd.toFixed(2)}`} | |
| 216 | </td> | |
| 217 | <td class="muted">${time(agent.createdAt)}</td> | |
| 218 | <td> | |
| 219 | <span class="bar"> | |
| 220 | ${btn("Edit", { | |
| 221 | href: `/settings/crew/${encodeURIComponent(agent.user.username)}`, | |
| 222 | size: "sm", | |
| 223 | icon: "pencil", | |
| 224 | })} | |
| 225 | ${confirmAction({ | |
| 226 | action: `/settings/crew/${encodeURIComponent(agent.user.username)}/delete`, | |
| 227 | verb: "Retire", | |
| 228 | size: "sm", | |
| 229 | })} | |
| 230 | </span> | |
| 231 | </td> | |
| 232 | </tr>`, | |
| 233 | )} | |
| 234 | </tbody> | |
| 235 | </table> | |
| 236 | </div>`, | |
| 237 | { pad: false }, | |
| 238 | )} | |
| 239 | ${card( | |
| 240 | html`<form method="post" action="/settings/crew"> | |
| 241 | ${uiField({ | |
| 242 | label: "Username", | |
| 243 | name: "username", | |
| 244 | value: values.username, | |
| 245 | required: true, | |
| 246 | pattern: "[A-Za-z0-9][A-Za-z0-9._-]*", | |
| 247 | hint: "Its handle: the author on the pull requests it opens, and the first half of its branch names.", | |
| 248 | ...(usernameError ? { error: usernameError } : {}), | |
| 249 | })} | |
| 250 | ${uiField({ | |
| 251 | label: "Display name", | |
| 252 | name: "displayName", | |
| 253 | value: values.displayName, | |
| 254 | hint: "Optional. Shown beside the username.", | |
| 255 | })} | |
| 256 | ${agentFields(values, usernameError === undefined ? error : undefined)} | |
| 257 | <div class="form-actions"> | |
| 258 | ${btn("Create agent", { variant: "primary", icon: "bot" })} | |
| 259 | </div> | |
| 260 | </form>`, | |
| 261 | { | |
| 262 | head: html`${icon("bot")} | |
| 263 | <h3>New agent</h3>`, | |
| 264 | }, | |
| 265 | )}`; | |
| 266 | } | |
| 267 | ||
| 268 | const EMPTY: AgentValues = { | |
| 269 | username: "", | |
| 270 | displayName: "", | |
| 271 | model: "", | |
| 272 | brief: "", | |
| 273 | budgetUsd: "", | |
| 274 | }; | |
| 275 | ||
| 276 | // GET /settings/crew | |
| 277 | export function crewPage(ctx: Ctx): Response { | |
| 278 | const denied = requireAdmin(ctx); | |
| 279 | if (denied) return denied; | |
| 280 | return shell(ctx, "Crew", crewView(ctx, EMPTY)); | |
| 281 | } | |
| 282 | ||
| 283 | // POST /settings/crew | |
| 284 | export async function agentCreate(ctx: Ctx): Promise<Response> { | |
| 285 | const form = await adminForm(ctx); | |
| 286 | if (form instanceof Response) return form; | |
| 287 | const values: AgentValues = { | |
| 288 | username: field(form, "username").trim(), | |
| 289 | displayName: field(form, "displayName").trim(), | |
| 290 | model: field(form, "model").trim(), | |
| 291 | brief: field(form, "brief"), | |
| 292 | budgetUsd: field(form, "budgetUsd").trim(), | |
| 293 | }; | |
| 294 | const budgetUsd = budgetOf(form); | |
| 295 | const made = await createAgent(ctx.app, ctx.auth.user, { | |
| 296 | username: values.username, | |
| 297 | ...(values.displayName === "" ? {} : { displayName: values.displayName }), | |
| 298 | model: values.model, | |
| 299 | brief: values.brief, | |
| 300 | ...(budgetUsd === undefined ? {} : { budgetUsd }), | |
| 301 | }); | |
| 302 | if (!made.ok) { | |
| 303 | if (made.error === "forbidden" || made.error === "unauthorized") | |
| 304 | return notFoundPage(ctx); | |
| 305 | return shell( | |
| 306 | ctx, | |
| 307 | "Crew", | |
| 308 | crewView( | |
| 309 | ctx, | |
| 310 | values, | |
| 311 | AGENT_ERRORS[made.error] ?? "That agent could not be created.", | |
| 312 | ), | |
| 313 | 422, | |
| 314 | ); | |
| 315 | } | |
| 316 | return flashTo(ctx, "/settings/crew", { | |
| 317 | message: `${made.value.user.username} is on the forge. Put it on a repository's crew under that repository's settings.`, | |
| 318 | }); | |
| 319 | } | |
| 320 | ||
| 321 | // ---- one agent ---- | |
| 322 | ||
| 323 | function agentView(agent: Agent, values: AgentValues, error?: string): Html { | |
| 324 | const at = `/settings/crew/${encodeURIComponent(agent.user.username)}`; | |
| 325 | return html`${card( | |
| 326 | html`<form method="post" action="${at}"> | |
| 327 | <div class="field"> | |
| 328 | <label for="f-username">Username</label> | |
| 329 | <input | |
| 330 | class="input" | |
| 331 | id="f-username" | |
| 332 | value="${agent.user.username}" | |
| 333 | readonly | |
| 334 | /> | |
| 335 | </div> | |
| 336 | ${agentFields(values, error)} | |
| 337 | <div class="form-actions"> | |
| 338 | ${btn("Save changes", { variant: "primary" })} | |
| 339 | ${btn("Back to the crew", { href: "/settings/crew", variant: "ghost" })} | |
| 340 | </div> | |
| 341 | </form>`, | |
| 342 | { | |
| 343 | head: html`${icon("bot")} | |
| 344 | <h3>${agent.user.username}</h3> | |
| 345 | <span class="muted small">added ${time(agent.createdAt)}</span>`, | |
| 346 | }, | |
| 347 | )} | |
| 348 | ${card( | |
| 349 | html`<p> | |
| 350 | Retiring <strong>${agent.user.username}</strong> destroys its tokens and | |
| 351 | takes it off every crew. What it wrote stays, under its name. | |
| 352 | </p> | |
| 353 | <div class="form-actions"> | |
| 354 | ${confirmAction({ action: `${at}/delete`, verb: "Retire this agent" })} | |
| 355 | </div>`, | |
| 356 | { | |
| 357 | tone: "danger", | |
| 358 | head: html`${icon("alert")} | |
| 359 | <h3>Retire</h3>`, | |
| 360 | }, | |
| 361 | )}`; | |
| 362 | } | |
| 363 | ||
| 364 | function loadAgent(ctx: Ctx): Agent | undefined { | |
| 365 | const found = getAgentView(ctx.app, ctx.auth.user, ctx.params.username ?? ""); | |
| 366 | return found.ok ? found.value : undefined; | |
| 367 | } | |
| 368 | ||
| 369 | // GET /settings/crew/:username | |
| 370 | export function agentPage(ctx: Ctx): Response { | |
| 371 | const denied = requireAdmin(ctx); | |
| 372 | if (denied) return denied; | |
| 373 | const agent = loadAgent(ctx); | |
| 374 | if (!agent) return notFoundPage(ctx); | |
| 375 | return shell(ctx, agent.user.username, agentView(agent, valuesOf(agent))); | |
| 376 | } | |
| 377 | ||
| 378 | // POST /settings/crew/:username | |
| 379 | export async function agentUpdate(ctx: Ctx): Promise<Response> { | |
| 380 | const form = await adminForm(ctx); | |
| 381 | if (form instanceof Response) return form; | |
| 382 | const agent = loadAgent(ctx); | |
| 383 | if (!agent) return notFoundPage(ctx); | |
| 384 | const values: AgentValues = { | |
| 385 | ...valuesOf(agent), | |
| 386 | model: field(form, "model").trim(), | |
| 387 | brief: field(form, "brief"), | |
| 388 | budgetUsd: field(form, "budgetUsd").trim(), | |
| 389 | }; | |
| 390 | const updated = updateAgent(ctx.app, ctx.auth.user, agent.user.username, { | |
| 391 | model: values.model, | |
| 392 | brief: values.brief, | |
| 393 | budgetUsd: budgetOf(form) ?? 0, | |
| 394 | }); | |
| 395 | if (!updated.ok) { | |
| 396 | if (updated.error === "not_found") return notFoundPage(ctx); | |
| 397 | return shell( | |
| 398 | ctx, | |
| 399 | agent.user.username, | |
| 400 | agentView( | |
| 401 | agent, | |
| 402 | values, | |
| 403 | AGENT_ERRORS[updated.error] ?? "Those changes could not be saved.", | |
| 404 | ), | |
| 405 | 422, | |
| 406 | ); | |
| 407 | } | |
| 408 | return flashTo( | |
| 409 | ctx, | |
| 410 | `/settings/crew/${encodeURIComponent(agent.user.username)}`, | |
| 411 | { message: "Agent updated." }, | |
| 412 | ); | |
| 413 | } | |
| 414 | ||
| 415 | // POST /settings/crew/:username/delete | |
| 416 | export async function agentDelete(ctx: Ctx): Promise<Response> { | |
| 417 | const form = await adminForm(ctx); | |
| 418 | if (form instanceof Response) return form; | |
| 419 | const username = ctx.params.username ?? ""; | |
| 420 | const removed = deleteAgent(ctx.app, ctx.auth.user, username); | |
| 421 | return flashTo(ctx, "/settings/crew", { | |
| 422 | ...(removed.ok | |
| 423 | ? { message: `${username} was retired.` } | |
| 424 | : { tone: "danger" as const, message: "No such agent." }), | |
| 425 | }); | |
| 426 | } |
src/web/pages/issues.ts +9 −7
| @@ -4,10 +4,7 @@ | ||
| 4 | 4 | * post to its own endpoint. Mobile-first, no JavaScript required. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { | |
| 8 | addCommentThrough, | |
| 9 | openIssueThrough, | |
| 10 | } from "../../actions/issues.ts"; | |
| 7 | import { addCommentThrough, openIssueThrough } from "../../actions/issues.ts"; | |
| 11 | 8 | import { |
| 12 | 9 | issueCounts, |
| 13 | 10 | labelsFor, |
| @@ -53,6 +50,7 @@ import { | ||
| 53 | 50 | type IconName, |
| 54 | 51 | } from "../ui.ts"; |
| 55 | 52 | import { |
| 53 | assigneeCard, | |
| 56 | 54 | composer, |
| 57 | 55 | conversation, |
| 58 | 56 | labelsCard, |
| @@ -62,6 +60,7 @@ import { | ||
| 62 | 60 | threadContext, |
| 63 | 61 | threadFragment, |
| 64 | 62 | } from "./thread.ts"; |
| 63 | import { crewActivityCard } from "./runs.ts"; | |
| 65 | 64 | |
| 66 | 65 | const PER_PAGE = 25; |
| 67 | 66 | |
| @@ -343,7 +342,9 @@ export async function issuesList(ctx: Ctx): Promise<Response> { | ||
| 343 | 342 | ${pager( |
| 344 | 343 | nextCursor === null |
| 345 | 344 | ? {} |
| 346 | : { next: withFacet(`${base}/issues`, query, {}, Number(nextCursor)) }, | |
| 345 | : { | |
| 346 | next: withFacet(`${base}/issues`, query, {}, Number(nextCursor)), | |
| 347 | }, | |
| 347 | 348 | )} |
| 348 | 349 | </div> |
| 349 | 350 | ${when(ctx.auth.user !== undefined, () => stickyAction(newIssue))} |
| @@ -507,7 +508,9 @@ export function issuePage(ctx: Ctx): Response { | ||
| 507 | 508 | })} |
| 508 | 509 | </div> |
| 509 | 510 | <aside> |
| 511 | ${assigneeCard(ctx, repo, issue, `${thread}/assignee`)} | |
| 510 | 512 | ${labelsCard(ctx, repo, issue, applied, all, `${thread}/labels`)} |
| 513 | ${crewActivityCard(ctx, repo, issue)} | |
| 511 | 514 | </aside> |
| 512 | 515 | </div> |
| 513 | 516 | ${liveMount({ |
| @@ -558,8 +561,7 @@ export async function issueCommentSubmit(ctx: Ctx): Promise<Response> { | ||
| 558 | 561 | if (!added.ok) |
| 559 | 562 | return flashTo(ctx, dest, { |
| 560 | 563 | tone: "danger", |
| 561 | message: | |
| 562 | originError(added.error) ?? "That comment could not be posted.", | |
| 564 | message: originError(added.error) ?? "That comment could not be posted.", | |
| 563 | 565 | }); |
| 564 | 566 | return flashTo(ctx, `${dest}`, { message: "Comment posted." }); |
| 565 | 567 | } |
src/web/pages/origins.ts +27 −4
| @@ -61,12 +61,35 @@ import { | ||
| 61 | 61 | type IconName, |
| 62 | 62 | } from "../ui.ts"; |
| 63 | 63 | |
| 64 | /** The settings sidebar, with Origins on it. Kept here so the account pages | |
| 65 | * and this one cannot drift apart in either direction. */ | |
| 64 | export type AccountSection = | |
| 65 | | "profile" | |
| 66 | | "password" | |
| 67 | | "tokens" | |
| 68 | | "keys" | |
| 69 | | "origins" | |
| 70 | | "crew"; | |
| 71 | ||
| 72 | /** | |
| 73 | * The settings sidebar, with Origins on it. Kept here so the account pages | |
| 74 | * and this one cannot drift apart in either direction. Crew is a forge | |
| 75 | * admin's section — an agent is an account that acts — so it is on the nav | |
| 76 | * only for one, the way the repo header hides Settings from a non-admin. | |
| 77 | */ | |
| 66 | 78 | export function accountSections( |
| 67 | current: "profile" | "password" | "tokens" | "keys" | "origins", | |
| 79 | current: AccountSection, | |
| 80 | o: { admin?: boolean } = {}, | |
| 68 | 81 | ): SettingsSection[] { |
| 69 | 82 | return [ |
| 83 | ...(o.admin | |
| 84 | ? [ | |
| 85 | { | |
| 86 | label: "Crew", | |
| 87 | href: "/settings/crew", | |
| 88 | icon: "bot" as const, | |
| 89 | current: current === "crew", | |
| 90 | }, | |
| 91 | ] | |
| 92 | : []), | |
| 70 | 93 | { |
| 71 | 94 | label: "Profile", |
| 72 | 95 | href: "/settings", |
| @@ -535,7 +558,7 @@ function shell(ctx: Ctx, body: Html, status = 200): Response { | ||
| 535 | 558 | ctx, |
| 536 | 559 | title: "Origins", |
| 537 | 560 | body: html`<div class="page-head"><h1>Origins</h1></div> |
| 538 | ${settingsLayout(accountSections("origins"), body, "Account settings")}`, | |
| 561 | ${settingsLayout(accountSections("origins", { admin: ctx.auth.user?.isAdmin === true }), body, "Account settings")}`, | |
| 539 | 562 | }, |
| 540 | 563 | status, |
| 541 | 564 | ); |
src/web/pages/pull.ts +105 −5
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | resolveReviewThread, |
| 16 | 16 | submitReview, |
| 17 | 17 | } from "../../actions/pulls.ts"; |
| 18 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 18 | 19 | import { labelsFor, listLabels } from "../../core/issues.ts"; |
| 19 | 20 | import { |
| 20 | 21 | pullDiff, |
| @@ -24,6 +25,7 @@ import { | ||
| 24 | 25 | type PullStatus, |
| 25 | 26 | } from "../../core/pulls.ts"; |
| 26 | 27 | import { getPull } from "../../core/pulls.ts"; |
| 28 | import { checkSummary, type CheckSummary } from "../../core/runs.ts"; | |
| 27 | 29 | import { |
| 28 | 30 | anchorKey, |
| 29 | 31 | byAnchor, |
| @@ -79,6 +81,7 @@ import { | ||
| 79 | 81 | type Tone, |
| 80 | 82 | } from "../ui.ts"; |
| 81 | 83 | import { |
| 84 | assigneeCard, | |
| 82 | 85 | composer, |
| 83 | 86 | conversation, |
| 84 | 87 | labelsCard, |
| @@ -88,7 +91,9 @@ import { | ||
| 88 | 91 | threadFragment, |
| 89 | 92 | type Entry, |
| 90 | 93 | } from "./thread.ts"; |
| 94 | import { previewCard } from "./apps.ts"; | |
| 91 | 95 | import { pullState } from "./pulls.ts"; |
| 96 | import { crewActivityCard } from "./runs.ts"; | |
| 92 | 97 | |
| 93 | 98 | /** The pull and its repo, or the response that refuses to say why not. */ |
| 94 | 99 | function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response { |
| @@ -193,10 +198,54 @@ function reviewsChip( | ||
| 193 | 198 | </p>`; |
| 194 | 199 | } |
| 195 | 200 | |
| 201 | // ---- the checks ---- | |
| 202 | ||
| 203 | /** | |
| 204 | * The checks line: where the deploy of this head stands (docs/APPS.md, rule | |
| 205 | * 4). Rendered whenever the repository runs as an app or requires checks — | |
| 206 | * a repository with neither has no checks, and should not grow furniture | |
| 207 | * for them. A run is named so the reader can go and look at its log. | |
| 208 | */ | |
| 209 | function checksLine( | |
| 210 | base: string, | |
| 211 | check: CheckSummary, | |
| 212 | required: boolean, | |
| 213 | isApp: boolean, | |
| 214 | ): Html { | |
| 215 | if (!isApp && !required && check.state === "none") return html``; | |
| 216 | const run = check.run; | |
| 217 | const link = | |
| 218 | run === null | |
| 219 | ? null | |
| 220 | : html`<span class="where-sep" aria-hidden="true">·</span | |
| 221 | ><a href="${base}/runs/${run.id}" | |
| 222 | >deploy ${run.commitOid.slice(0, 7)}</a | |
| 223 | >`; | |
| 224 | const body = | |
| 225 | check.state === "ok" | |
| 226 | ? html`<span class="ok">${icon("check")}Checks passed</span>${link}` | |
| 227 | : check.state === "failed" | |
| 228 | ? html`<span class="bad">${icon("x")}Checks failed</span>${link}` | |
| 229 | : check.state === "running" || check.state === "queued" | |
| 230 | ? html`<span class="now">${icon("loader")}Checks ${check.state}</span | |
| 231 | >${link}` | |
| 232 | : check.state === "cancelled" | |
| 233 | ? html`<span>${icon("ban")}Checks cancelled</span>${link}` | |
| 234 | : html`<span>${icon("clock")}No checks on this head yet</span>`; | |
| 235 | return html`<p class="checks-line"> | |
| 236 | ${body}${when( | |
| 237 | required, | |
| 238 | () => | |
| 239 | html`<span class="where-sep" aria-hidden="true">·</span | |
| 240 | ><span>a passing deploy is required</span>`, | |
| 241 | )} | |
| 242 | </p>`; | |
| 243 | } | |
| 244 | ||
| 196 | 245 | // ---- the merge box ---- |
| 197 | 246 | |
| 198 | 247 | /** |
| 199 | * Five states, five cards. Every one names the situation, gives the reason, | |
| 248 | * Six states, six cards. Every one names the situation, gives the reason, | |
| 200 | 249 | * and offers exactly the actions that are actually available. |
| 201 | 250 | */ |
| 202 | 251 | function mergeBox( |
| @@ -206,8 +255,11 @@ function mergeBox( | ||
| 206 | 255 | status: PullStatus, |
| 207 | 256 | mayMerge: boolean, |
| 208 | 257 | by: readonly Standing[], |
| 258 | check: CheckSummary, | |
| 259 | isApp: boolean, | |
| 209 | 260 | ): Html { |
| 210 | 261 | const thread = `${repoBase(repo)}/pulls/${pull.number}`; |
| 262 | const checks = checksLine(repoBase(repo), check, repo.requireChecks, isApp); | |
| 211 | 263 | const reopen = html`<form method="post" action="${thread}/state"> |
| 212 | 264 | <input type="hidden" name="state" value="open" /> |
| 213 | 265 | ${btn("Reopen", { size: "sm", icon: "pull" })} |
| @@ -301,7 +353,7 @@ function mergeBox( | ||
| 301 | 353 | <div class="grow"> |
| 302 | 354 | <div class="merge-headline">Can't merge.</div> |
| 303 | 355 | <p class="merge-sub">${reason}</p> |
| 304 | ${reviewsChip(status.reviews, by)} | |
| 356 | ${reviewsChip(status.reviews, by)} ${checks} | |
| 305 | 357 | </div>`, |
| 306 | 358 | { tone: "danger", extra: "merge-box" }, |
| 307 | 359 | ); |
| @@ -327,7 +379,7 @@ function mergeBox( | ||
| 327 | 379 | <div class="grow"> |
| 328 | 380 | <div class="merge-headline">Waiting on review.</div> |
| 329 | 381 | <p class="merge-sub">${why}</p> |
| 330 | ${reviewsChip(status.reviews, by)} | |
| 382 | ${reviewsChip(status.reviews, by)} ${checks} | |
| 331 | 383 | </div> |
| 332 | 384 | ${when( |
| 333 | 385 | mayMerge, |
| @@ -345,6 +397,45 @@ function mergeBox( | ||
| 345 | 397 | ); |
| 346 | 398 | } |
| 347 | 399 | |
| 400 | // The check gate, `mergePull`'s `checks_required`: the deploy of the | |
| 401 | // commit that is head right now must have passed. Read live here so the | |
| 402 | // button is honest — the refusal itself happens in the merge. | |
| 403 | if (repo.requireChecks && check.state !== "ok") { | |
| 404 | const why = | |
| 405 | check.state === "failed" | |
| 406 | ? html`The deploy of this head failed. Push a fix — a passing deploy is | |
| 407 | required before merging.` | |
| 408 | : check.state === "running" || check.state === "queued" | |
| 409 | ? html`This head is still deploying. The merge unlocks when the check | |
| 410 | passes.` | |
| 411 | : check.state === "cancelled" | |
| 412 | ? html`The deploy of this head was cancelled. Redeploy the preview, | |
| 413 | or push again.` | |
| 414 | : html`There is no deploy of this head yet. Nothing merges until one | |
| 415 | passes.`; | |
| 416 | return card( | |
| 417 | html`${icon("loader")} | |
| 418 | <div class="grow"> | |
| 419 | <div class="merge-headline">Waiting on checks.</div> | |
| 420 | <p class="merge-sub">${why}</p> | |
| 421 | ${reviewsChip(status.reviews, by)} ${checks} | |
| 422 | </div> | |
| 423 | ${when( | |
| 424 | mayMerge, | |
| 425 | () => | |
| 426 | html`<span class="merge-form" | |
| 427 | >${btn("Merge pull request", { | |
| 428 | variant: "primary", | |
| 429 | icon: "merge", | |
| 430 | disabled: true, | |
| 431 | title: "A passing deploy of this head is required first", | |
| 432 | })}</span | |
| 433 | >`, | |
| 434 | )}`, | |
| 435 | { tone: "attn", extra: "merge-box" }, | |
| 436 | ); | |
| 437 | } | |
| 438 | ||
| 348 | 439 | return card( |
| 349 | 440 | html`${icon("check")} |
| 350 | 441 | <div class="grow"> |
| @@ -356,7 +447,7 @@ function mergeBox( | ||
| 356 | 447 | ${status.ahead} commit${status.ahead === 1 ? "" : "s"} will land on |
| 357 | 448 | <span class="mono">${pull.baseBranch}</span>. |
| 358 | 449 | </p> |
| 359 | ${reviewsChip(status.reviews, by)} | |
| 450 | ${reviewsChip(status.reviews, by)} ${checks} | |
| 360 | 451 | </div> |
| 361 | 452 | ${mayMerge |
| 362 | 453 | ? html`<span class="merge-form"> |
| @@ -585,6 +676,10 @@ export async function pullPage(ctx: Ctx): Promise<Response> { | ||
| 585 | 676 | const allLabels = listLabels(ctx.app.db, repo.id); |
| 586 | 677 | const verdicts = listReviews(ctx.app.db, pull.id); |
| 587 | 678 | const by = standings(ctx.app.db, pull, status.headOid); |
| 679 | // The checks are the deploy runs of the live head, read here for the same | |
| 680 | // reason the review gate is: the box and the merge answer one question. | |
| 681 | const check = checkSummary(ctx.app.db, pull.id, status.headOid); | |
| 682 | const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined; | |
| 588 | 683 | const threads = pullThreads(ctx.app, pull); |
| 589 | 684 | const threadViews = threadStateViews(ctx, repo, threads); |
| 590 | 685 | const unresolved = threads.filter((t) => t.resolvedAt === null).length; |
| @@ -676,7 +771,7 @@ export async function pullPage(ctx: Ctx): Promise<Response> { | ||
| 676 | 771 | ${when(pull.crossRepo, () => badge("fork", { icon: "fork" }))} |
| 677 | 772 | </div> |
| 678 | 773 | </div> |
| 679 | ${mergeBox(ctx, repo, pull, status, mayMerge, by)} | |
| 774 | ${mergeBox(ctx, repo, pull, status, mayMerge, by, check, isApp)} | |
| 680 | 775 | <div class="split"> |
| 681 | 776 | <div> |
| 682 | 777 | ${talk.node} |
| @@ -690,8 +785,11 @@ export async function pullPage(ctx: Ctx): Promise<Response> { | ||
| 690 | 785 | ${reviewCard(ctx, repo, pull, `${thread}/review-state`)} |
| 691 | 786 | </div> |
| 692 | 787 | <aside> |
| 788 | ${when(isApp, () => previewCard(ctx, repo, pull))} | |
| 789 | ${assigneeCard(ctx, repo, pull, `${thread}/assignee`)} | |
| 693 | 790 | ${labelsCard(ctx, repo, pull, applied, allLabels, `${thread}/labels`)} |
| 694 | 791 | <div class="mt">${commitsCard}</div> |
| 792 | ${crewActivityCard(ctx, repo, pull)} | |
| 695 | 793 | </aside> |
| 696 | 794 | </div> |
| 697 | 795 | <div class="files-head"> |
| @@ -841,6 +939,8 @@ function mergeFailure(error: AccessError | MergePullError): string { | ||
| 841 | 939 | // dismissed a second later, or a reviewer asked for changes in between. |
| 842 | 940 | if (error === "review_required") |
| 843 | 941 | return "This pull request needs an approving review of its current head, and does not have one. Nothing was merged."; |
| 942 | if (error === "checks_required") | |
| 943 | return "This pull request needs a passing deploy of its current head, and does not have one yet. Nothing was merged."; | |
| 844 | 944 | if (error === "forbidden" || error === "unauthorized") |
| 845 | 945 | return "You do not have write access to this repository."; |
| 846 | 946 | return "That pull request is no longer here."; |
src/web/pages/repo.ts +2 −1
| @@ -28,6 +28,7 @@ import { | ||
| 28 | 28 | } from "../chrome.ts"; |
| 29 | 29 | import { codeJs } from "../client.ts"; |
| 30 | 30 | import { highlightBlob } from "../code.ts"; |
| 31 | import { appStrip } from "./apps.ts"; | |
| 31 | 32 | import { field, readForm } from "../form.ts"; |
| 32 | 33 | import { html, when, type Html } from "../html.ts"; |
| 33 | 34 | import { flashTo, fragment, page } from "../layout.ts"; |
| @@ -773,7 +774,7 @@ export async function repoHome(ctx: Ctx): Promise<Response> { | ||
| 773 | 774 | const body = html`${scopeHeader(ctx, scope, "code", { |
| 774 | 775 | branchCount: refs.branches.length, |
| 775 | 776 | })} |
| 776 | ${forkBar(ctx, repo)} | |
| 777 | ${appStrip(ctx, repo)} ${forkBar(ctx, repo)} | |
| 777 | 778 | ${await renderCode(scope, { |
| 778 | 779 | refs, |
| 779 | 780 | ref, |
src/web/pages/runs.ts added +427
| @@ -0,0 +1,427 @@ | ||
| 1 | /** | |
| 2 | * Runs on the web (docs/APPS.md): a repository's runs, one run's log — | |
| 3 | * tailed live where there is a script, refreshed where there is not — and | |
| 4 | * the cancel button. A run is as visible as the code it ran on, so every | |
| 5 | * page here reads through the run actions, which answer `not_found` for a | |
| 6 | * repository the viewer may not see. | |
| 7 | * | |
| 8 | * `/runs/:id` is the short door the proxy's "not running" page knocks on: | |
| 9 | * it finds the run's repository and sends the reader to the page above, or | |
| 10 | * to the one 404 — and an anonymous visitor to sign in first, whatever the | |
| 11 | * id, so the redirect itself says nothing about what exists. | |
| 12 | */ | |
| 13 | ||
| 14 | import { cancelRepoRun, listRepoRuns, runLog } from "../../actions/runs.ts"; | |
| 15 | import { | |
| 16 | getIssueById, | |
| 17 | getIssuesByIds, | |
| 18 | type Issue, | |
| 19 | } from "../../core/issues.ts"; | |
| 20 | import { canRead, canWrite, getRepoById, type Repo } from "../../core/repos.ts"; | |
| 21 | import { getRun, isTerminal, type Run, type RunKind } from "../../core/runs.ts"; | |
| 22 | import { getUserById, getUsersByIds, type User } from "../../core/users.ts"; | |
| 23 | import type { Ctx } from "../../http/context.ts"; | |
| 24 | import { | |
| 25 | crumbsFor, | |
| 26 | loadRepo, | |
| 27 | notFoundPage, | |
| 28 | repoBase, | |
| 29 | repoHeader, | |
| 30 | repoPalette, | |
| 31 | signInFirst, | |
| 32 | } from "../chrome.ts"; | |
| 33 | import { runLogJs } from "../client.ts"; | |
| 34 | import { readForm } from "../form.ts"; | |
| 35 | import { html, when, type Html } from "../html.ts"; | |
| 36 | import { flashTo, page, redirect } from "../layout.ts"; | |
| 37 | import { liveClientJs, liveMount } from "../live.ts"; | |
| 38 | import { | |
| 39 | btn, | |
| 40 | empty, | |
| 41 | icon, | |
| 42 | listCard, | |
| 43 | pager, | |
| 44 | row, | |
| 45 | runStateBadge, | |
| 46 | seg, | |
| 47 | sha, | |
| 48 | time, | |
| 49 | } from "../ui.ts"; | |
| 50 | ||
| 51 | // ---- words ---- | |
| 52 | ||
| 53 | /** The pull a preview environment is for, from its name; production has none. */ | |
| 54 | const envNumber = (env: string): number | undefined => | |
| 55 | env.startsWith("pull-") ? Number(env.slice(5)) : undefined; | |
| 56 | ||
| 57 | /** | |
| 58 | * What a run is, in a few words, naming the pull or issue it is about: | |
| 59 | * `Deploy production`, `Deploy preview #3 Say hello`, `bot on #1 Do a | |
| 60 | * thing`. The issue arrives from one grouped read for the whole list. | |
| 61 | */ | |
| 62 | export function runTitle( | |
| 63 | run: Run, | |
| 64 | actor: string | undefined, | |
| 65 | issue: Issue | undefined, | |
| 66 | ): string { | |
| 67 | const about = | |
| 68 | issue === undefined | |
| 69 | ? (() => { | |
| 70 | const number = envNumber(run.env); | |
| 71 | return number === undefined ? "" : `#${number}`; | |
| 72 | })() | |
| 73 | : `#${issue.number} ${issue.title}`; | |
| 74 | if (run.kind === "deploy") | |
| 75 | return about === "" ? "Deploy production" : `Deploy preview ${about}`; | |
| 76 | const who = actor ?? "an agent"; | |
| 77 | return about === "" ? `${who} at work` : `${who} on ${about}`; | |
| 78 | } | |
| 79 | ||
| 80 | const REASONS: Record<string, string> = { | |
| 81 | boot: "on boot", | |
| 82 | enabled: "when the app was enabled", | |
| 83 | push: "after a push", | |
| 84 | merge: "after a merge", | |
| 85 | opened: "when the pull opened", | |
| 86 | redeploy: "redeployed by hand", | |
| 87 | restart: "restarted after the process exited", | |
| 88 | assigned: "assigned", | |
| 89 | feedback: "after feedback", | |
| 90 | check_failed: "after a failed check", | |
| 91 | }; | |
| 92 | ||
| 93 | /** Why a run exists, as a phrase that follows its title. */ | |
| 94 | export const reasonWords = (reason: string): string => | |
| 95 | REASONS[reason] ?? reason; | |
| 96 | ||
| 97 | /** Dollars, two places — a run's spend is a small number read at a glance. */ | |
| 98 | export const costText = (usd: number): string => `$${usd.toFixed(2)}`; | |
| 99 | ||
| 100 | /** `12s`, `3m 4s`, `1h 2m` — how long a run took, or has taken so far. */ | |
| 101 | export function durationText(run: Run): string | undefined { | |
| 102 | if (run.startedAt === null) return undefined; | |
| 103 | const end = run.finishedAt === null ? Date.now() : Date.parse(run.finishedAt); | |
| 104 | const seconds = Math.max( | |
| 105 | 0, | |
| 106 | Math.round((end - Date.parse(run.startedAt)) / 1000), | |
| 107 | ); | |
| 108 | if (seconds < 60) return `${seconds}s`; | |
| 109 | const minutes = Math.floor(seconds / 60); | |
| 110 | if (minutes < 60) return `${minutes}m ${seconds % 60}s`; | |
| 111 | return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; | |
| 112 | } | |
| 113 | ||
| 114 | /** Every actor the runs name, in one read. */ | |
| 115 | export function actorsFor(ctx: Ctx, runs: readonly Run[]): Map<string, User> { | |
| 116 | return getUsersByIds( | |
| 117 | ctx.app.db, | |
| 118 | runs.map((run) => run.actorId ?? "").filter(Boolean), | |
| 119 | ); | |
| 120 | } | |
| 121 | ||
| 122 | /** Every pull or issue the runs are about, in one read. */ | |
| 123 | export function issuesFor(ctx: Ctx, runs: readonly Run[]): Map<string, Issue> { | |
| 124 | return getIssuesByIds( | |
| 125 | ctx.app.db, | |
| 126 | runs.map((run) => run.issueId ?? "").filter(Boolean), | |
| 127 | ); | |
| 128 | } | |
| 129 | ||
| 130 | /** The run's title, with both grouped reads resolved. */ | |
| 131 | export function runTitleFrom( | |
| 132 | run: Run, | |
| 133 | actors: ReadonlyMap<string, User>, | |
| 134 | issues: ReadonlyMap<string, Issue>, | |
| 135 | ): string { | |
| 136 | const actor = run.actorId === null ? undefined : actors.get(run.actorId); | |
| 137 | const issue = run.issueId === null ? undefined : issues.get(run.issueId); | |
| 138 | return runTitle(run, actor?.username, issue); | |
| 139 | } | |
| 140 | ||
| 141 | const kindIcon = (kind: RunKind) => icon(kind === "agent" ? "bot" : "rocket"); | |
| 142 | ||
| 143 | // ---- rows ---- | |
| 144 | ||
| 145 | /** One run in a list: what it was, why, how it went, and when. */ | |
| 146 | export function runRow( | |
| 147 | base: string, | |
| 148 | run: Run, | |
| 149 | actors: ReadonlyMap<string, User>, | |
| 150 | issues: ReadonlyMap<string, Issue>, | |
| 151 | ): Html { | |
| 152 | const title = runTitleFrom(run, actors, issues); | |
| 153 | const meta = `${reasonWords(run.reason)}${run.summary === "" ? "" : ` · ${run.summary}`}`; | |
| 154 | return row({ | |
| 155 | href: `${base}/runs/${run.id}`, | |
| 156 | nav: true, | |
| 157 | lead: kindIcon(run.kind), | |
| 158 | title, | |
| 159 | titleAttr: title, | |
| 160 | meta: html`<span title="${meta}">${meta}</span>`, | |
| 161 | trail: html`${runStateBadge(run.state, run.kind)} ${time(run.createdAt)}`, | |
| 162 | }); | |
| 163 | } | |
| 164 | ||
| 165 | /** | |
| 166 | * The agent runs about one issue or pull — the crew's side of the story, | |
| 167 | * beside the conversation. Empty when nobody on the crew has touched it. | |
| 168 | */ | |
| 169 | export function crewActivityCard(ctx: Ctx, repo: Repo, issue: Issue): Html { | |
| 170 | const runs = listRepoRuns(ctx.app, ctx.auth.user, repo, { | |
| 171 | kind: "agent", | |
| 172 | issueId: issue.id, | |
| 173 | limit: 10, | |
| 174 | }); | |
| 175 | if (!runs.ok || runs.value.length === 0) return html``; | |
| 176 | const base = repoBase(repo); | |
| 177 | const actors = actorsFor(ctx, runs.value); | |
| 178 | return listCard( | |
| 179 | runs.value.map((run) => { | |
| 180 | const who = | |
| 181 | (run.actorId === null ? undefined : actors.get(run.actorId)) | |
| 182 | ?.username ?? "an agent"; | |
| 183 | return row({ | |
| 184 | href: `${base}/runs/${run.id}`, | |
| 185 | lead: icon("bot"), | |
| 186 | title: html`${who} | |
| 187 | <span class="muted">${reasonWords(run.reason)}</span>`, | |
| 188 | meta: html`<span title="${run.summary}" | |
| 189 | >${run.summary === "" ? "no summary yet" : run.summary}</span | |
| 190 | >`, | |
| 191 | trail: html`${runStateBadge(run.state, "agent")}${when( | |
| 192 | run.costUsd > 0, | |
| 193 | () => | |
| 194 | html`<span class="num muted small">${costText(run.costUsd)}</span>`, | |
| 195 | )}`, | |
| 196 | }); | |
| 197 | }), | |
| 198 | { | |
| 199 | head: html`${icon("bot")} | |
| 200 | <h2>Crew activity</h2>`, | |
| 201 | }, | |
| 202 | ); | |
| 203 | } | |
| 204 | ||
| 205 | // ---- the list ---- | |
| 206 | ||
| 207 | const PER_PAGE = 30; | |
| 208 | ||
| 209 | // GET /:owner/:name/runs | |
| 210 | export function runsList(ctx: Ctx): Response { | |
| 211 | const repo = loadRepo(ctx); | |
| 212 | if (!repo) return notFoundPage(ctx); | |
| 213 | const base = repoBase(repo); | |
| 214 | const q = ctx.url.searchParams; | |
| 215 | const kindRaw = q.get("kind"); | |
| 216 | const kind: RunKind | undefined = | |
| 217 | kindRaw === "deploy" || kindRaw === "agent" ? kindRaw : undefined; | |
| 218 | const cursor = q.get("cursor") ?? ""; | |
| 219 | ||
| 220 | const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, { | |
| 221 | kind, | |
| 222 | cursor: cursor === "" ? undefined : cursor, | |
| 223 | limit: PER_PAGE + 1, | |
| 224 | }); | |
| 225 | if (!listed.ok) return notFoundPage(ctx); | |
| 226 | const shown = listed.value.slice(0, PER_PAGE); | |
| 227 | const last = shown[shown.length - 1]; | |
| 228 | const more = listed.value.length > PER_PAGE && last !== undefined; | |
| 229 | const actors = actorsFor(ctx, shown); | |
| 230 | const issues = issuesFor(ctx, shown); | |
| 231 | ||
| 232 | const href = (which: RunKind | undefined, next?: string): string => { | |
| 233 | const p = new URLSearchParams(); | |
| 234 | if (which !== undefined) p.set("kind", which); | |
| 235 | if (next !== undefined) p.set("cursor", next); | |
| 236 | const text = p.toString(); | |
| 237 | return text === "" ? `${base}/runs` : `${base}/runs?${text}`; | |
| 238 | }; | |
| 239 | ||
| 240 | const list = | |
| 241 | shown.length === 0 | |
| 242 | ? empty({ | |
| 243 | icon: "activity", | |
| 244 | title: | |
| 245 | kind === "agent" | |
| 246 | ? "No agent runs yet" | |
| 247 | : kind === "deploy" | |
| 248 | ? "No deploys yet" | |
| 249 | : "No runs yet", | |
| 250 | note: "A run is a deploy of an environment or an agent working an issue. Each one keeps its log here.", | |
| 251 | }) | |
| 252 | : listCard(shown.map((run) => runRow(base, run, actors, issues))); | |
| 253 | ||
| 254 | const body = html`${repoHeader(ctx, repo, "app", { compact: true })} | |
| 255 | <div class="filters mt"> | |
| 256 | ${seg([ | |
| 257 | { label: "All", href: href(undefined), current: kind === undefined }, | |
| 258 | { label: "Deploys", href: href("deploy"), current: kind === "deploy" }, | |
| 259 | { label: "Crew", href: href("agent"), current: kind === "agent" }, | |
| 260 | ])} | |
| 261 | </div> | |
| 262 | ${list} | |
| 263 | ${pager({ | |
| 264 | ...(cursor !== "" ? { prev: href(kind) } : {}), | |
| 265 | ...(more && last ? { next: href(kind, last.id) } : {}), | |
| 266 | })} | |
| 267 | ${liveMount({ repo: base })}`; | |
| 268 | ||
| 269 | return page({ | |
| 270 | ctx, | |
| 271 | title: `Runs · ${repo.name}`, | |
| 272 | crumbs: crumbsFor(repo), | |
| 273 | palette: repoPalette(repo), | |
| 274 | body, | |
| 275 | scriptJs: liveClientJs, | |
| 276 | }); | |
| 277 | } | |
| 278 | ||
| 279 | // ---- one run ---- | |
| 280 | ||
| 281 | // GET /:owner/:name/runs/:id | |
| 282 | export function runPage(ctx: Ctx): Response { | |
| 283 | const repo = loadRepo(ctx); | |
| 284 | if (!repo) return notFoundPage(ctx); | |
| 285 | const found = runLog(ctx.app, ctx.auth.user, repo, ctx.params.id ?? ""); | |
| 286 | if (!found.ok) return notFoundPage(ctx); | |
| 287 | const { run, lines, truncated } = found.value; | |
| 288 | const base = repoBase(repo); | |
| 289 | const actor = | |
| 290 | run.actorId === null ? undefined : getUserById(ctx.app.db, run.actorId); | |
| 291 | const issue = | |
| 292 | run.issueId === null ? undefined : getIssueById(ctx.app.db, run.issueId); | |
| 293 | const title = runTitle(run, actor?.username, issue); | |
| 294 | const unfinished = !isTerminal(run.state); | |
| 295 | const writer = canWrite(ctx.app.db, repo, ctx.auth.user); | |
| 296 | const duration = durationText(run); | |
| 297 | const stream = `/api/v1/repos/${repo.ownerName}/${repo.name}/runs/${run.id}/log?follow=1`; | |
| 298 | const text = lines.length === 0 ? "" : `${lines.join("\n")}\n`; | |
| 299 | ||
| 300 | const body = html`${repoHeader(ctx, repo, "app", { | |
| 301 | compact: true, | |
| 302 | ownTitle: true, | |
| 303 | })} | |
| 304 | <div class="issue-head"> | |
| 305 | <h1>${title}</h1> | |
| 306 | <div class="issue-meta"> | |
| 307 | <span id="run-state" data-kind="${run.kind}" | |
| 308 | >${runStateBadge(run.state, run.kind)}</span | |
| 309 | > | |
| 310 | <span | |
| 311 | >${run.kind === "agent" ? "agent run" : "deploy"} · | |
| 312 | ${reasonWords(run.reason)}</span | |
| 313 | > | |
| 314 | ${when(run.commitOid !== "", () => | |
| 315 | sha(run.commitOid, `${base}/commit/${run.commitOid}`), | |
| 316 | )} | |
| 317 | ${when( | |
| 318 | run.kind === "agent" && run.branch !== "", | |
| 319 | () => html`<span class="mono">${run.branch}</span>`, | |
| 320 | )} | |
| 321 | ${when( | |
| 322 | issue, | |
| 323 | (i) => | |
| 324 | html`<a | |
| 325 | href="${base}/${i.kind === "pull" | |
| 326 | ? "pulls" | |
| 327 | : "issues"}/${i.number}" | |
| 328 | >#${i.number} ${i.title}</a | |
| 329 | >`, | |
| 330 | )} | |
| 331 | ${when( | |
| 332 | run.kind === "agent" && run.costUsd > 0, | |
| 333 | () => html`<span class="num">${costText(run.costUsd)} spent</span>`, | |
| 334 | )} | |
| 335 | <span>queued ${time(run.createdAt)}</span> | |
| 336 | ${when( | |
| 337 | duration, | |
| 338 | (d) => | |
| 339 | html`<span class="num" | |
| 340 | >${run.finishedAt === null ? "running for" : "took"} ${d}</span | |
| 341 | >`, | |
| 342 | )} | |
| 343 | </div> | |
| 344 | </div> | |
| 345 | <p class="run-summary" id="run-summary">${run.summary}</p> | |
| 346 | ${when( | |
| 347 | writer && unfinished, | |
| 348 | () => | |
| 349 | html`<div class="run-actions" id="run-cancel"> | |
| 350 | <form method="post" action="${base}/runs/${run.id}/cancel"> | |
| 351 | ${btn("Cancel run", { | |
| 352 | size: "sm", | |
| 353 | variant: "outline", | |
| 354 | icon: "square", | |
| 355 | })} | |
| 356 | </form> | |
| 357 | </div>`, | |
| 358 | )} | |
| 359 | ${when( | |
| 360 | truncated, | |
| 361 | () => | |
| 362 | html`<p class="muted small mb"> | |
| 363 | Showing the last 512 KB of the log; the file on disk has the rest. | |
| 364 | </p>`, | |
| 365 | )} | |
| 366 | ${when( | |
| 367 | lines.length === 0, | |
| 368 | () => | |
| 369 | html`<p class="muted small" id="run-empty"> | |
| 370 | Nothing has been logged yet. | |
| 371 | </p>`, | |
| 372 | )} | |
| 373 | <pre | |
| 374 | class="run-log mt" | |
| 375 | id="run-log" | |
| 376 | ${unfinished ? html`data-stream="${stream}"` : null} | |
| 377 | > | |
| 378 | ${text}</pre | |
| 379 | >`; | |
| 380 | ||
| 381 | return page({ | |
| 382 | ctx, | |
| 383 | title: `${title} · ${repo.name}`, | |
| 384 | crumbs: crumbsFor(repo), | |
| 385 | palette: repoPalette(repo), | |
| 386 | body, | |
| 387 | scriptJs: runLogJs, | |
| 388 | // The no-script half of a live log: while the run is going, the page | |
| 389 | // refreshes itself; the script above removes this the moment it runs. | |
| 390 | ...(unfinished ? { refresh: { seconds: 5 } } : {}), | |
| 391 | }); | |
| 392 | } | |
| 393 | ||
| 394 | // GET /runs/:id — the run's own page, wherever it lives. | |
| 395 | export function runRedirect(ctx: Ctx): Response { | |
| 396 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 397 | const run = getRun(ctx.app.db, ctx.params.id ?? ""); | |
| 398 | const repo = | |
| 399 | run === undefined ? undefined : getRepoById(ctx.app.db, run.repoId); | |
| 400 | if (!run || !repo || !canRead(ctx.app.db, repo, ctx.auth.user)) | |
| 401 | return notFoundPage(ctx); | |
| 402 | return redirect(`${repoBase(repo)}/runs/${run.id}`); | |
| 403 | } | |
| 404 | ||
| 405 | // POST /:owner/:name/runs/:id/cancel | |
| 406 | export async function runCancel(ctx: Ctx): Promise<Response> { | |
| 407 | const repo = loadRepo(ctx); | |
| 408 | if (!repo) return notFoundPage(ctx); | |
| 409 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 410 | const form = await readForm(ctx, "rw"); | |
| 411 | if (form instanceof Response) return form; | |
| 412 | const id = ctx.params.id ?? ""; | |
| 413 | const dest = `${repoBase(repo)}/runs/${id}`; | |
| 414 | const cancelled = cancelRepoRun(ctx.app, ctx.auth.user, repo, id); | |
| 415 | if (!cancelled.ok) { | |
| 416 | if (cancelled.error === "not_found") return notFoundPage(ctx); | |
| 417 | return flashTo(ctx, dest, { | |
| 418 | tone: "danger", | |
| 419 | message: | |
| 420 | cancelled.error === "finished" | |
| 421 | ? "That run had already finished." | |
| 422 | : "You need write access to cancel a run.", | |
| 423 | }); | |
| 424 | } | |
| 425 | return flashTo(ctx, dest, { message: "Run cancelled." }); | |
| 426 | } | |
| 427 |
src/web/pages/search.ts +80 −55
| @@ -114,8 +114,8 @@ function issueHitRow(hit: IssueHit): Html { | ||
| 114 | 114 | lead: icon(hit.kind === "issue" ? "issue" : "pull"), |
| 115 | 115 | title: hit.title, |
| 116 | 116 | titleAttr: hit.title, |
| 117 | meta: html`${where} #${hit.number} · updated ${time(hit.updatedAt)}${hit.source === | |
| 118 | "origin" | |
| 117 | meta: html`${where} #${hit.number} · updated | |
| 118 | ${time(hit.updatedAt)}${hit.source === "origin" | |
| 119 | 119 | ? html` · <span class="muted">on the origin</span>` |
| 120 | 120 | : null}`, |
| 121 | 121 | trail: stateBadge(hit.state), |
| @@ -147,7 +147,12 @@ export function markMatch(text: string, query: string): Html { | ||
| 147 | 147 | function codeHitRow(hit: CodeHit, query: string): Html { |
| 148 | 148 | const at = `/${hit.repo.owner}/${hit.repo.name}`; |
| 149 | 149 | return html`<li> |
| 150 | <a class="row code-hit" data-nav tabindex="-1" href="${at}/blob/HEAD/${hit.path}#L${hit.line}"> | |
| 150 | <a | |
| 151 | class="row code-hit" | |
| 152 | data-nav | |
| 153 | tabindex="-1" | |
| 154 | href="${at}/blob/HEAD/${hit.path}#L${hit.line}" | |
| 155 | > | |
| 151 | 156 | <span class="row-lead">${icon("code")}</span> |
| 152 | 157 | <span class="row-main"> |
| 153 | 158 | <span class="row-title mono" |
| @@ -169,11 +174,7 @@ const KINDS: { value: SearchKind; label: string }[] = [ | ||
| 169 | 174 | { value: "code", label: "Code" }, |
| 170 | 175 | ]; |
| 171 | 176 | |
| 172 | function searchHref( | |
| 173 | query: string, | |
| 174 | kind: SearchKind, | |
| 175 | live: boolean, | |
| 176 | ): string { | |
| 177 | function searchHref(query: string, kind: SearchKind, live: boolean): string { | |
| 177 | 178 | const params = new URLSearchParams({ q: query }); |
| 178 | 179 | if (kind !== "all") params.set("kind", kind); |
| 179 | 180 | if (live) params.set("live", "1"); |
| @@ -184,7 +185,13 @@ function section( | ||
| 184 | 185 | title: string, |
| 185 | 186 | kind: SearchKind, |
| 186 | 187 | rows: Html[], |
| 187 | o: { truncated: boolean; emptyIcon: Parameters<typeof icon>[0]; emptyText: string; query: string; live: boolean }, | |
| 188 | o: { | |
| 189 | truncated: boolean; | |
| 190 | emptyIcon: Parameters<typeof icon>[0]; | |
| 191 | emptyText: string; | |
| 192 | query: string; | |
| 193 | live: boolean; | |
| 194 | }, | |
| 188 | 195 | ): Html { |
| 189 | 196 | return html`<h2 class="mt" id="${kind}"> |
| 190 | 197 | ${title}${rows.length === 0 |
| @@ -203,7 +210,9 @@ function section( | ||
| 203 | 210 | () => |
| 204 | 211 | html`<p class="muted small mt"> |
| 205 | 212 | More matched than fit here. |
| 206 | <a href="${searchHref(o.query, kind, o.live)}">Show only ${title.toLowerCase()}</a>. | |
| 213 | <a href="${searchHref(o.query, kind, o.live)}" | |
| 214 | >Show only ${title.toLowerCase()}</a | |
| 215 | >. | |
| 207 | 216 | </p>`, |
| 208 | 217 | )}`; |
| 209 | 218 | } |
| @@ -242,8 +251,9 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 242 | 251 | autofocus |
| 243 | 252 | data-search-input |
| 244 | 253 | /> |
| 245 | ${when(kind !== "all", () => | |
| 246 | html`<input type="hidden" name="kind" value="${kind}" />`, | |
| 254 | ${when( | |
| 255 | kind !== "all", | |
| 256 | () => html`<input type="hidden" name="kind" value="${kind}" />`, | |
| 247 | 257 | )} |
| 248 | 258 | ${when(live, () => html`<input type="hidden" name="live" value="1" />`)} |
| 249 | 259 | <button class="btn" data-variant="primary" data-size="sm" type="submit"> |
| @@ -303,49 +313,44 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 303 | 313 | const wants = (which: SearchKind) => kind === "all" || kind === which; |
| 304 | 314 | |
| 305 | 315 | const body = html`${pageHead(html`Results for “${query}”`)} ${form} ${chips} |
| 306 | ${liveToggle} | |
| 307 | ${when(wants("repos"), () => | |
| 308 | section( | |
| 309 | "Repositories", | |
| 310 | "repos", | |
| 311 | found.repos.map(repoHitRow), | |
| 312 | { | |
| 313 | truncated: found.truncated.repos, | |
| 314 | emptyIcon: "repo", | |
| 315 | emptyText: "No repository matches", | |
| 316 | query, | |
| 317 | live, | |
| 318 | }, | |
| 319 | ), | |
| 320 | )} | |
| 321 | ${when(wants("issues"), () => | |
| 322 | section( | |
| 323 | "Issues and pull requests", | |
| 324 | "issues", | |
| 325 | found.issues.map(issueHitRow), | |
| 326 | { | |
| 327 | truncated: found.truncated.issues, | |
| 328 | emptyIcon: "issue", | |
| 329 | emptyText: "No issue or pull request matches", | |
| 330 | query, | |
| 331 | live, | |
| 332 | }, | |
| 333 | ), | |
| 334 | )} | |
| 335 | ${when(wants("code"), () => | |
| 336 | section( | |
| 337 | "Code", | |
| 338 | "code", | |
| 339 | found.code.map((hit) => codeHitRow(hit, query)), | |
| 340 | { | |
| 341 | truncated: found.truncated.code, | |
| 342 | emptyIcon: "code", | |
| 343 | emptyText: "No line of code matches", | |
| 344 | query, | |
| 345 | live, | |
| 346 | }, | |
| 347 | ), | |
| 348 | )}`; | |
| 316 | ${liveToggle} | |
| 317 | ${when(wants("repos"), () => | |
| 318 | section("Repositories", "repos", found.repos.map(repoHitRow), { | |
| 319 | truncated: found.truncated.repos, | |
| 320 | emptyIcon: "repo", | |
| 321 | emptyText: "No repository matches", | |
| 322 | query, | |
| 323 | live, | |
| 324 | }), | |
| 325 | )} | |
| 326 | ${when(wants("issues"), () => | |
| 327 | section( | |
| 328 | "Issues and pull requests", | |
| 329 | "issues", | |
| 330 | found.issues.map(issueHitRow), | |
| 331 | { | |
| 332 | truncated: found.truncated.issues, | |
| 333 | emptyIcon: "issue", | |
| 334 | emptyText: "No issue or pull request matches", | |
| 335 | query, | |
| 336 | live, | |
| 337 | }, | |
| 338 | ), | |
| 339 | )} | |
| 340 | ${when(wants("code"), () => | |
| 341 | section( | |
| 342 | "Code", | |
| 343 | "code", | |
| 344 | found.code.map((hit) => codeHitRow(hit, query)), | |
| 345 | { | |
| 346 | truncated: found.truncated.code, | |
| 347 | emptyIcon: "code", | |
| 348 | emptyText: "No line of code matches", | |
| 349 | query, | |
| 350 | live, | |
| 351 | }, | |
| 352 | ), | |
| 353 | )}`; | |
| 349 | 354 | return page({ ctx, title: `Search · ${query}`, body }); |
| 350 | 355 | } |
| 351 | 356 | |
| @@ -443,6 +448,26 @@ const DESTRUCTIVE: { | ||
| 443 | 448 | phrase: "", |
| 444 | 449 | }), |
| 445 | 450 | }, |
| 451 | { | |
| 452 | pattern: /^\/([^/]+)\/([^/]+)\/settings\/app\/disable$/, | |
| 453 | describe: (m) => ({ | |
| 454 | verb: "Stop running as an app", | |
| 455 | title: `Stop running ${m[1]}/${m[2]} as an app?`, | |
| 456 | detail: | |
| 457 | "Production and every preview stop, and their data directories and checkouts are removed from disk. The repository and its code are untouched. Run it as an app again later and it starts from empty data.", | |
| 458 | phrase: "", | |
| 459 | }), | |
| 460 | }, | |
| 461 | { | |
| 462 | pattern: /^\/settings\/crew\/([^/]+)\/delete$/, | |
| 463 | describe: (m) => ({ | |
| 464 | verb: "Retire agent", | |
| 465 | title: `Retire ${decodeURIComponent(m[1] ?? "")}?`, | |
| 466 | detail: | |
| 467 | "Its tokens are destroyed and it leaves every crew it is on, so nothing can act as it again. Pull requests and comments it wrote stay, under its name. This cannot be undone.", | |
| 468 | phrase: "", | |
| 469 | }), | |
| 470 | }, | |
| 446 | 471 | ]; |
| 447 | 472 | |
| 448 | 473 | function describeTarget(to: string): Destructive | undefined { |
src/web/pages/settings.ts +412 −10
| @@ -8,6 +8,17 @@ | ||
| 8 | 8 | * same-origin POST and every one of them says what happened. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { | |
| 12 | disableApp, | |
| 13 | enableApp, | |
| 14 | updateAppSettings, | |
| 15 | } from "../../actions/apps.ts"; | |
| 16 | import { | |
| 17 | addToCrew, | |
| 18 | listAllAgents, | |
| 19 | listCrew, | |
| 20 | removeFromCrew, | |
| 21 | } from "../../actions/crew.ts"; | |
| 11 | 22 | import { |
| 12 | 23 | createLabel, |
| 13 | 24 | createWebhook, |
| @@ -17,6 +28,15 @@ import { | ||
| 17 | 28 | updateLabel, |
| 18 | 29 | updateRepoSettings, |
| 19 | 30 | } from "../../actions/repos.ts"; |
| 31 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 32 | import type { PreviewData } from "../../apps/data.ts"; | |
| 33 | import { | |
| 34 | getAppByRepo, | |
| 35 | SLUG_MAX, | |
| 36 | slugFor, | |
| 37 | type AppAccess, | |
| 38 | type AppRecord, | |
| 39 | } from "../../core/apps.ts"; | |
| 20 | 40 | import { listLabels, type Label } from "../../core/issues.ts"; |
| 21 | 41 | import { canAdmin, type Repo } from "../../core/repos.ts"; |
| 22 | 42 | import { listWebhooks, recentDeliveries } from "../../core/webhooks.ts"; |
| @@ -46,11 +66,15 @@ import { | ||
| 46 | 66 | field as formField, |
| 47 | 67 | labelChip, |
| 48 | 68 | labelCss, |
| 69 | listCard, | |
| 70 | row, | |
| 49 | 71 | selectField, |
| 50 | 72 | time, |
| 73 | userLink, | |
| 51 | 74 | } from "../ui.ts"; |
| 75 | import { crewReadiness } from "./crew.ts"; | |
| 52 | 76 | |
| 53 | type Section = "general" | "labels" | "hooks" | "danger"; | |
| 77 | type Section = "general" | "app" | "crew" | "labels" | "hooks" | "danger"; | |
| 54 | 78 | |
| 55 | 79 | /** The repo, but only for someone who administers it. A non-admin is told |
| 56 | 80 | * nothing — the settings tab is not even rendered for them. */ |
| @@ -68,6 +92,18 @@ function sections(base: string, active: Section) { | ||
| 68 | 92 | icon: "settings" as const, |
| 69 | 93 | current: active === "general", |
| 70 | 94 | }, |
| 95 | { | |
| 96 | label: "App", | |
| 97 | href: `${base}/settings/app`, | |
| 98 | icon: "rocket" as const, | |
| 99 | current: active === "app", | |
| 100 | }, | |
| 101 | { | |
| 102 | label: "Crew", | |
| 103 | href: `${base}/settings/crew`, | |
| 104 | icon: "bot" as const, | |
| 105 | current: active === "crew", | |
| 106 | }, | |
| 71 | 107 | { |
| 72 | 108 | label: "Labels", |
| 73 | 109 | href: `${base}/settings/labels`, |
| @@ -97,16 +133,20 @@ function settingsPageResponse( | ||
| 97 | 133 | title: string, |
| 98 | 134 | body: Html, |
| 99 | 135 | styleCss?: string, |
| 136 | status = 200, | |
| 100 | 137 | ): Response { |
| 101 | return page({ | |
| 102 | ctx, | |
| 103 | title: `${title} · ${repo.name}`, | |
| 104 | crumbs: crumbsFor(repo), | |
| 105 | palette: repoPalette(repo), | |
| 106 | ...(styleCss !== undefined ? { styleCss } : {}), | |
| 107 | body: html`${repoHeader(ctx, repo, "settings", { compact: true })} | |
| 108 | ${settingsLayout(sections(repoBase(repo), active), body)}`, | |
| 109 | }); | |
| 138 | return page( | |
| 139 | { | |
| 140 | ctx, | |
| 141 | title: `${title} · ${repo.name}`, | |
| 142 | crumbs: crumbsFor(repo), | |
| 143 | palette: repoPalette(repo), | |
| 144 | ...(styleCss !== undefined ? { styleCss } : {}), | |
| 145 | body: html`${repoHeader(ctx, repo, "settings", { compact: true })} | |
| 146 | ${settingsLayout(sections(repoBase(repo), active), body)}`, | |
| 147 | }, | |
| 148 | status, | |
| 149 | ); | |
| 110 | 150 | } |
| 111 | 151 | |
| 112 | 152 | /** The gate every settings POST passes: admin, same-origin, admin scope. */ |
| @@ -196,6 +236,20 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 196 | 236 | > |
| 197 | 237 | </label> |
| 198 | 238 | </div> |
| 239 | <div class="field"> | |
| 240 | <label class="check"> | |
| 241 | <input | |
| 242 | type="checkbox" | |
| 243 | name="requireChecks" | |
| 244 | ${repo.requireChecks ? html`checked` : null} | |
| 245 | /> | |
| 246 | <span | |
| 247 | >Require a passing deploy before merging — the check is the pull | |
| 248 | request's preview coming up at the commit that is head <em>now</em>. | |
| 249 | Read live inside the merge, like the review.</span | |
| 250 | > | |
| 251 | </label> | |
| 252 | </div> | |
| 199 | 253 | <div class="form-actions"> |
| 200 | 254 | ${btn("Save changes", { variant: "primary" })} |
| 201 | 255 | </div> |
| @@ -217,6 +271,7 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> { | ||
| 217 | 271 | visibility: field(form, "visibility") === "public" ? "public" : "private", |
| 218 | 272 | protectDefault: form.get("protect") !== null, |
| 219 | 273 | requireReview: form.get("requireReview") !== null, |
| 274 | requireChecks: form.get("requireChecks") !== null, | |
| 220 | 275 | ...(branch !== "" ? { defaultBranch: branch } : {}), |
| 221 | 276 | }); |
| 222 | 277 | const flash: Flash = updated.ok |
| @@ -231,6 +286,353 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> { | ||
| 231 | 286 | return flashTo(ctx, `${base}/settings`, flash); |
| 232 | 287 | } |
| 233 | 288 | |
| 289 | // ---- app ---- | |
| 290 | ||
| 291 | interface AppValues { | |
| 292 | slug: string; | |
| 293 | access: AppAccess; | |
| 294 | previewData: PreviewData; | |
| 295 | } | |
| 296 | ||
| 297 | const APP_ERRORS: Record<string, string> = { | |
| 298 | invalid_slug: `Lowercase letters and digits, single hyphens inside, at most ${SLUG_MAX} characters.`, | |
| 299 | slug_taken: "Another app already answers to that hostname.", | |
| 300 | already_app: "This repository is already an app.", | |
| 301 | }; | |
| 302 | ||
| 303 | /** | |
| 304 | * The one form, for both moments: turning a repository into an app and | |
| 305 | * changing how it is served. Which verb the button wears is the only | |
| 306 | * difference, because `enableApp` and `updateAppSettings` take the same | |
| 307 | * three facts. | |
| 308 | */ | |
| 309 | function appSettingsView( | |
| 310 | ctx: Ctx, | |
| 311 | repo: Repo, | |
| 312 | record: AppRecord | undefined, | |
| 313 | values: AppValues, | |
| 314 | error?: string, | |
| 315 | ): Html { | |
| 316 | const base = repoBase(repo); | |
| 317 | const runtime = appsRuntime(ctx.app); | |
| 318 | const domain = runtime?.options.domain; | |
| 319 | const form = card( | |
| 320 | html`${when( | |
| 321 | record === undefined, | |
| 322 | () => | |
| 323 | html`<p class="muted small mb"> | |
| 324 | A repository that runs. Production follows | |
| 325 | <span class="mono">${repo.defaultBranch}</span>; every open pull | |
| 326 | request gets a preview with its own copy of the data. The repository | |
| 327 | says how to run itself in <span class="mono">forge.json</span>, or | |
| 328 | with a <span class="mono">start</span> script. | |
| 329 | </p>`, | |
| 330 | )} | |
| 331 | <form method="post" action="${base}/settings/app"> | |
| 332 | ${formField({ | |
| 333 | label: "Slug", | |
| 334 | name: "slug", | |
| 335 | value: values.slug, | |
| 336 | mono: true, | |
| 337 | required: true, | |
| 338 | pattern: "[a-z0-9](-?[a-z0-9])*", | |
| 339 | hint: | |
| 340 | domain === undefined | |
| 341 | ? "The label its hostnames are built from." | |
| 342 | : `Its hostnames: ${values.slug || "<slug>"}.${domain} and ${values.slug || "<slug>"}--<n>.${domain} for the preview of pull .`, | |
| 343 | ...(error ? { error } : {}), | |
| 344 | })} | |
| 345 | ${selectField({ | |
| 346 | label: "Access", | |
| 347 | name: "access", | |
| 348 | hint: "A private app answers only to someone who can see this repository — the forge vouches for them on the way in.", | |
| 349 | options: [ | |
| 350 | { | |
| 351 | value: "private", | |
| 352 | label: "Private — people who can see this repository", | |
| 353 | selected: values.access === "private", | |
| 354 | }, | |
| 355 | { | |
| 356 | value: "public", | |
| 357 | label: "Public — anyone with the link", | |
| 358 | selected: values.access === "public", | |
| 359 | }, | |
| 360 | ], | |
| 361 | })} | |
| 362 | ${selectField({ | |
| 363 | label: "Preview data", | |
| 364 | name: "previewData", | |
| 365 | hint: "What a new preview's data directory starts as. A copy is never a window: nothing a preview writes reaches production.", | |
| 366 | options: [ | |
| 367 | { | |
| 368 | value: "copy", | |
| 369 | label: "A copy of production's, taken when the preview starts", | |
| 370 | selected: values.previewData === "copy", | |
| 371 | }, | |
| 372 | { | |
| 373 | value: "empty", | |
| 374 | label: "Empty", | |
| 375 | selected: values.previewData === "empty", | |
| 376 | }, | |
| 377 | ], | |
| 378 | })} | |
| 379 | <div class="form-actions"> | |
| 380 | ${btn(record === undefined ? "Run as an app" : "Save changes", { | |
| 381 | variant: "primary", | |
| 382 | ...(record === undefined ? { icon: "rocket" as const } : {}), | |
| 383 | })} | |
| 384 | ${when(record !== undefined, () => | |
| 385 | btn("Environments", { | |
| 386 | href: `${base}/app`, | |
| 387 | variant: "ghost", | |
| 388 | icon: "rocket", | |
| 389 | }), | |
| 390 | )} | |
| 391 | </div> | |
| 392 | </form>`, | |
| 393 | { | |
| 394 | head: html`${icon("rocket")} | |
| 395 | <h2>${record === undefined ? "Run as an app" : "App"}</h2>`, | |
| 396 | }, | |
| 397 | ); | |
| 398 | if (record === undefined) return form; | |
| 399 | return html`${form} | |
| 400 | <div class="mt"> | |
| 401 | ${card( | |
| 402 | html`<p> | |
| 403 | Production and every preview stop, and their data directories and | |
| 404 | checkouts are removed. The repository and its code are untouched. | |
| 405 | </p> | |
| 406 | <div class="form-actions"> | |
| 407 | ${confirmAction({ | |
| 408 | action: `${base}/settings/app/disable`, | |
| 409 | verb: "Stop running as an app", | |
| 410 | })} | |
| 411 | </div>`, | |
| 412 | { | |
| 413 | tone: "danger", | |
| 414 | head: html`${icon("alert")} | |
| 415 | <h2>Stop running as an app</h2>`, | |
| 416 | }, | |
| 417 | )} | |
| 418 | </div>`; | |
| 419 | } | |
| 420 | ||
| 421 | const appValues = (repo: Repo, record: AppRecord | undefined): AppValues => ({ | |
| 422 | slug: record?.slug ?? slugFor(repo.name), | |
| 423 | access: record?.access ?? repo.visibility, | |
| 424 | previewData: record?.previewData ?? "copy", | |
| 425 | }); | |
| 426 | ||
| 427 | // GET /:owner/:name/settings/app | |
| 428 | export function appSettingsPage(ctx: Ctx): Response { | |
| 429 | const repo = loadAdminRepo(ctx); | |
| 430 | if (!repo) return repoNotFound(ctx); | |
| 431 | const record = getAppByRepo(ctx.app.db, repo.id); | |
| 432 | return settingsPageResponse( | |
| 433 | ctx, | |
| 434 | repo, | |
| 435 | "app", | |
| 436 | "App", | |
| 437 | appSettingsView(ctx, repo, record, appValues(repo, record)), | |
| 438 | ); | |
| 439 | } | |
| 440 | ||
| 441 | // POST /:owner/:name/settings/app — enable, or change. | |
| 442 | export async function appSettingsUpdate(ctx: Ctx): Promise<Response> { | |
| 443 | const gate = await adminForm(ctx); | |
| 444 | if (gate instanceof Response) return gate; | |
| 445 | const { repo, form } = gate; | |
| 446 | const record = getAppByRepo(ctx.app.db, repo.id); | |
| 447 | const values: AppValues = { | |
| 448 | slug: field(form, "slug").trim(), | |
| 449 | access: field(form, "access") === "public" ? "public" : "private", | |
| 450 | previewData: field(form, "previewData") === "empty" ? "empty" : "copy", | |
| 451 | }; | |
| 452 | const saved = | |
| 453 | record === undefined | |
| 454 | ? enableApp(ctx.app, ctx.auth.user, repo, values) | |
| 455 | : updateAppSettings(ctx.app, ctx.auth.user, repo, values); | |
| 456 | if (!saved.ok) { | |
| 457 | if (saved.error === "not_found") return repoNotFound(ctx); | |
| 458 | return settingsPageResponse( | |
| 459 | ctx, | |
| 460 | repo, | |
| 461 | "app", | |
| 462 | "App", | |
| 463 | appSettingsView( | |
| 464 | ctx, | |
| 465 | repo, | |
| 466 | record, | |
| 467 | values, | |
| 468 | APP_ERRORS[saved.error] ?? "Those settings could not be saved.", | |
| 469 | ), | |
| 470 | undefined, | |
| 471 | 422, | |
| 472 | ); | |
| 473 | } | |
| 474 | const url = appsRuntime(ctx.app)?.urlFor(saved.value, "production"); | |
| 475 | return flashTo( | |
| 476 | ctx, | |
| 477 | record === undefined | |
| 478 | ? `${repoBase(repo)}/app` | |
| 479 | : `${repoBase(repo)}/settings/app`, | |
| 480 | { | |
| 481 | message: | |
| 482 | record !== undefined | |
| 483 | ? "App settings saved." | |
| 484 | : url === undefined | |
| 485 | ? `${repo.name} is an app, but nothing is serving apps on this forge.` | |
| 486 | : `${repo.name} is starting at ${url}.`, | |
| 487 | }, | |
| 488 | ); | |
| 489 | } | |
| 490 | ||
| 491 | // POST /:owner/:name/settings/app/disable | |
| 492 | export async function appDisable(ctx: Ctx): Promise<Response> { | |
| 493 | const gate = await adminForm(ctx); | |
| 494 | if (gate instanceof Response) return gate; | |
| 495 | const { repo } = gate; | |
| 496 | const disabled = disableApp(ctx.app, ctx.auth.user, repo); | |
| 497 | return flashTo( | |
| 498 | ctx, | |
| 499 | `${repoBase(repo)}/settings/app`, | |
| 500 | disabled.ok | |
| 501 | ? { | |
| 502 | message: `${repo.name} no longer runs as an app. Its code is untouched.`, | |
| 503 | } | |
| 504 | : { tone: "danger", message: "This repository is not an app." }, | |
| 505 | ); | |
| 506 | } | |
| 507 | ||
| 508 | // ---- crew ---- | |
| 509 | ||
| 510 | // GET /:owner/:name/settings/crew | |
| 511 | export function crewSettingsPage(ctx: Ctx): Response { | |
| 512 | const repo = loadAdminRepo(ctx); | |
| 513 | if (!repo) return repoNotFound(ctx); | |
| 514 | const base = repoBase(repo); | |
| 515 | const crewListed = listCrew(ctx.app, ctx.auth.user, repo); | |
| 516 | const crew = crewListed.ok ? crewListed.value : []; | |
| 517 | const allListed = listAllAgents(ctx.app, ctx.auth.user); | |
| 518 | const all = allListed.ok ? allListed.value : []; | |
| 519 | const onCrew = new Set(crew.map((agent) => agent.user.id)); | |
| 520 | const available = all.filter((agent) => !onCrew.has(agent.user.id)); | |
| 521 | const admin = ctx.auth.user?.isAdmin === true; | |
| 522 | ||
| 523 | const list = | |
| 524 | crew.length === 0 | |
| 525 | ? empty({ | |
| 526 | icon: "bot", | |
| 527 | title: "No crew yet", | |
| 528 | note: "An agent on the crew can read and write this repository, and starts working the moment an issue is assigned to it. It proposes through pull requests; a person merges.", | |
| 529 | }) | |
| 530 | : listCard( | |
| 531 | crew.map((agent) => | |
| 532 | row({ | |
| 533 | lead: icon("bot"), | |
| 534 | title: html`${userLink(agent.user.username)}`, | |
| 535 | meta: html`<span title="${agent.brief}" | |
| 536 | >${agent.model === "" | |
| 537 | ? "default model" | |
| 538 | : agent.model}${agent.brief === "" | |
| 539 | ? "" | |
| 540 | : ` · ${agent.brief}`}</span | |
| 541 | >`, | |
| 542 | trail: html`<form | |
| 543 | class="row-actions" | |
| 544 | method="post" | |
| 545 | action="${base}/settings/crew/${encodeURIComponent( | |
| 546 | agent.user.username, | |
| 547 | )}/remove" | |
| 548 | > | |
| 549 | ${btn("Remove", { size: "sm", icon: "x" })} | |
| 550 | </form>`, | |
| 551 | }), | |
| 552 | ), | |
| 553 | ); | |
| 554 | ||
| 555 | const add = | |
| 556 | available.length > 0 | |
| 557 | ? card( | |
| 558 | html`<form | |
| 559 | class="edit-row" | |
| 560 | method="post" | |
| 561 | action="${base}/settings/crew" | |
| 562 | > | |
| 563 | <label class="sr-only" for="f-agent">Agent</label> | |
| 564 | <select class="input" id="f-agent" name="agent"> | |
| 565 | ${available.map( | |
| 566 | (agent) => | |
| 567 | html`<option value="${agent.user.username}"> | |
| 568 | ${agent.user.username}${agent.model === "" | |
| 569 | ? "" | |
| 570 | : ` — ${agent.model}`} | |
| 571 | </option>`, | |
| 572 | )} | |
| 573 | </select> | |
| 574 | ${btn("Add to crew", { | |
| 575 | variant: "primary", | |
| 576 | size: "sm", | |
| 577 | icon: "plus", | |
| 578 | })} | |
| 579 | </form>`, | |
| 580 | { head: html`<h2>Add an agent</h2>`, pad: false }, | |
| 581 | ) | |
| 582 | : card( | |
| 583 | all.length === 0 | |
| 584 | ? html`<p class="muted small"> | |
| 585 | No agents on this forge yet. | |
| 586 | ${admin | |
| 587 | ? html`Create one under | |
| 588 | <a href="/settings/crew">Settings → Crew</a>, then add it | |
| 589 | here.` | |
| 590 | : html`An admin can create one under account settings → Crew.`} | |
| 591 | </p>` | |
| 592 | : html`<p class="muted small"> | |
| 593 | Every agent on this forge is already on this crew. | |
| 594 | </p>`, | |
| 595 | { head: html`<h2>Add an agent</h2>` }, | |
| 596 | ); | |
| 597 | ||
| 598 | const body = html`${crewReadiness(ctx)} ${list} | |
| 599 | <div class="mt">${add}</div>`; | |
| 600 | return settingsPageResponse(ctx, repo, "crew", "Crew", body); | |
| 601 | } | |
| 602 | ||
| 603 | // POST /:owner/:name/settings/crew | |
| 604 | export async function crewAdd(ctx: Ctx): Promise<Response> { | |
| 605 | const gate = await adminForm(ctx); | |
| 606 | if (gate instanceof Response) return gate; | |
| 607 | const { repo, form } = gate; | |
| 608 | const added = addToCrew(ctx.app, ctx.auth.user, repo, field(form, "agent")); | |
| 609 | return flashTo( | |
| 610 | ctx, | |
| 611 | `${repoBase(repo)}/settings/crew`, | |
| 612 | added.ok | |
| 613 | ? { | |
| 614 | message: `${added.value.user.username} is on the crew. Assign it an issue and it starts.`, | |
| 615 | } | |
| 616 | : { tone: "danger", message: "No such agent on this forge." }, | |
| 617 | ); | |
| 618 | } | |
| 619 | ||
| 620 | // POST /:owner/:name/settings/crew/:username/remove | |
| 621 | export async function crewRemove(ctx: Ctx): Promise<Response> { | |
| 622 | const gate = await adminForm(ctx); | |
| 623 | if (gate instanceof Response) return gate; | |
| 624 | const { repo } = gate; | |
| 625 | const username = ctx.params.username ?? ""; | |
| 626 | const removed = removeFromCrew(ctx.app, ctx.auth.user, repo, username); | |
| 627 | return flashTo( | |
| 628 | ctx, | |
| 629 | `${repoBase(repo)}/settings/crew`, | |
| 630 | removed.ok | |
| 631 | ? { message: `${username} is off the crew.` } | |
| 632 | : { tone: "danger", message: "No such agent on this forge." }, | |
| 633 | ); | |
| 634 | } | |
| 635 | ||
| 234 | 636 | // ---- labels ---- |
| 235 | 637 | |
| 236 | 638 | /** `<input type="color">` submits `#rrggbb`; the store keeps six hex digits. */ |
src/web/pages/site.ts +147 −47
| @@ -11,14 +11,22 @@ | ||
| 11 | 11 | import { createRepo } from "../../actions/repos.ts"; |
| 12 | 12 | import { signUp } from "../../actions/users.ts"; |
| 13 | 13 | import { listEventsVisibleTo, type ForgeEvent } from "../../core/events.ts"; |
| 14 | import { getIssuesByRef, listIssues } from "../../core/issues.ts"; | |
| 14 | import { | |
| 15 | getIssuesByRef, | |
| 16 | listAssignedTo, | |
| 17 | listOpenAgentPullsVisibleTo, | |
| 18 | type Issue, | |
| 19 | } from "../../core/issues.ts"; | |
| 15 | 20 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 16 | 21 | import { listPins, pinnedRepoIds } from "../../core/pins.ts"; |
| 17 | 22 | import { |
| 23 | canRead, | |
| 18 | 24 | getReposByIds, |
| 19 | 25 | listReposVisibleTo, |
| 26 | writableRepoIds, | |
| 20 | 27 | type Repo, |
| 21 | 28 | } from "../../core/repos.ts"; |
| 29 | import { latestRunsForIssues } from "../../core/runs.ts"; | |
| 22 | 30 | import { createSession, destroySession } from "../../core/sessions.ts"; |
| 23 | 31 | import { |
| 24 | 32 | getUsersByIds, |
| @@ -32,6 +40,11 @@ import { hostOf, notFoundPage, pageHead } from "../chrome.ts"; | ||
| 32 | 40 | import { field, readForm } from "../form.ts"; |
| 33 | 41 | import { html, when, type Html } from "../html.ts"; |
| 34 | 42 | import { icon, type IconName } from "../icons.ts"; |
| 43 | import { | |
| 44 | eventActor, | |
| 45 | eventVerb, | |
| 46 | isTransientEvent, | |
| 47 | } from "../../design/activity.ts"; | |
| 35 | 48 | import { flashTo, page, redirect } from "../layout.ts"; |
| 36 | 49 | import { liveClientJs, liveMount } from "../live.ts"; |
| 37 | 50 | import { |
| @@ -44,9 +57,12 @@ import { | ||
| 44 | 57 | pager, |
| 45 | 58 | repoCounts, |
| 46 | 59 | row, |
| 60 | runStateBadge, | |
| 47 | 61 | selectField, |
| 48 | 62 | time, |
| 63 | userTag, | |
| 49 | 64 | } from "../ui.ts"; |
| 65 | import { appsCard } from "./apps.ts"; | |
| 50 | 66 | |
| 51 | 67 | export function sessionCookie(value: string, expires: Date, url: URL): string { |
| 52 | 68 | const secure = url.protocol === "https:" ? "; Secure" : ""; |
| @@ -119,49 +135,11 @@ function eventIcon(kind: string): IconName { | ||
| 119 | 135 | if (kind.startsWith("team.") || kind.startsWith("org.")) return "users"; |
| 120 | 136 | if (kind.startsWith("token.") || kind.startsWith("key.")) return "key"; |
| 121 | 137 | if (kind.startsWith("settings.")) return "settings"; |
| 138 | if (kind.startsWith("app.") || kind === "run.finished") return "rocket"; | |
| 139 | if (kind.startsWith("crew.")) return "bot"; | |
| 122 | 140 | return "activity"; |
| 123 | 141 | } |
| 124 | 142 | |
| 125 | /** | |
| 126 | * Plain English for an event kind. The rail is read, not parsed, so this is | |
| 127 | * the *verb phrase alone* — the repository and the thing acted on go on the | |
| 128 | * second line, where they have room to be a link and to be truncated at the | |
| 129 | * end rather than mid-word. | |
| 130 | */ | |
| 131 | function eventVerb(kind: string): string { | |
| 132 | const words: Record<string, string> = { | |
| 133 | push: "pushed", | |
| 134 | "issue.opened": "opened an issue", | |
| 135 | "issue.closed": "closed an issue", | |
| 136 | "issue.reopened": "reopened an issue", | |
| 137 | "issue.commented": "commented on an issue", | |
| 138 | "issue.labeled": "labelled an issue", | |
| 139 | "pull.opened": "opened a pull request", | |
| 140 | "pull.merged": "merged a pull request", | |
| 141 | "pull.closed": "closed a pull request", | |
| 142 | "pull.reopened": "reopened a pull request", | |
| 143 | "pull.commented": "commented on a pull request", | |
| 144 | "pull.reviewed": "reviewed a pull request", | |
| 145 | "repo.created": "created a repository", | |
| 146 | "repo.forked": "forked a repository", | |
| 147 | "repo.settings_updated": "changed repository settings", | |
| 148 | "label.created": "added a label", | |
| 149 | "label.updated": "edited a label", | |
| 150 | "label.deleted": "removed a label", | |
| 151 | "webhook.created": "added a webhook", | |
| 152 | "webhook.deleted": "removed a webhook", | |
| 153 | "repo.mirrored": "mirrored a repository", | |
| 154 | "repo.imported": "imported issues and pulls", | |
| 155 | "repo.pinned": "pinned a repository", | |
| 156 | "repo.unpinned": "unpinned a repository", | |
| 157 | "pull.labeled": "labelled a pull request", | |
| 158 | "origin.connected": "connected an origin", | |
| 159 | "origin.disconnected": "disconnected an origin", | |
| 160 | "origin.needs_reauth": "needs to reconnect an origin", | |
| 161 | }; | |
| 162 | return words[kind] ?? "acted on this repository"; | |
| 163 | } | |
| 164 | ||
| 165 | 143 | /** Which conversation, if any, an event is about — for one grouped read. */ |
| 166 | 144 | function eventIssueRef( |
| 167 | 145 | event: ForgeEvent, |
| @@ -189,7 +167,7 @@ function eventSubject( | ||
| 189 | 167 | const issue = issues.get(issueKey(ref.repoId, ref.number)); |
| 190 | 168 | return issue ? `#${ref.number} ${issue.title}` : `#${ref.number}`; |
| 191 | 169 | } |
| 192 | for (const key of ["name", "url", "parent"] as const) { | |
| 170 | for (const key of ["name", "url", "parent", "summary"] as const) { | |
| 193 | 171 | const value = event.payload[key]; |
| 194 | 172 | if (typeof value === "string" && value !== "") return value; |
| 195 | 173 | } |
| @@ -218,7 +196,11 @@ function activityRail( | ||
| 218 | 196 | // The visibility rule lives in the query, so this is the twelve most recent |
| 219 | 197 | // things *this viewer* can see — not the twelve newest events in the forge |
| 220 | 198 | // filtered down to however few of them happened to be visible. |
| 221 | const events = listEventsVisibleTo(db, user, { limit: 12 }); | |
| 199 | // A run being queued or started is a moment, not a fact; the rail shows | |
| 200 | // what came of it. Read a little past twelve so the cut keeps its size. | |
| 201 | const events = listEventsVisibleTo(db, user, { limit: 24 }) | |
| 202 | .filter((e: ForgeEvent) => !isTransientEvent(e.kind)) | |
| 203 | .slice(0, 12); | |
| 222 | 204 | const byId = getReposByIds( |
| 223 | 205 | db, |
| 224 | 206 | events.map((e: ForgeEvent) => e.repoId ?? "").filter(Boolean), |
| @@ -266,6 +248,8 @@ function activityRail( | ||
| 266 | 248 | // someone doing it here. The glyph is the only honest way to say so |
| 267 | 249 | // without a second line of prose on every row. |
| 268 | 250 | const viaOrigin = event.payload["via"] === "origin"; |
| 251 | // A deploy with no actor is the forge's own doing, and says so. | |
| 252 | const who = eventActor(event, actor?.username); | |
| 269 | 253 | return row({ |
| 270 | 254 | href: `/${where}`, |
| 271 | 255 | lead: icon( |
| @@ -273,10 +257,9 @@ function activityRail( | ||
| 273 | 257 | ? repo.origin.kind |
| 274 | 258 | : eventIcon(event.kind), |
| 275 | 259 | ), |
| 276 | title: html`<strong>${actor?.username ?? "someone"}</strong> | |
| 277 | ${eventVerb(event.kind)}`, | |
| 260 | title: html`<strong>${who}</strong> ${eventVerb(event)}`, | |
| 278 | 261 | meta: html`${where}${subject !== "" ? html` · ${subject}` : null}`, |
| 279 | titleAttr: `${actor?.username ?? "someone"} ${eventVerb(event.kind)}`, | |
| 262 | titleAttr: `${who} ${eventVerb(event)}`, | |
| 280 | 263 | trail: time(event.createdAt), |
| 281 | 264 | }); |
| 282 | 265 | })} |
| @@ -382,6 +365,121 @@ function downloadsCard(ctx: Ctx, user: User): Html { | ||
| 382 | 365 | ); |
| 383 | 366 | } |
| 384 | 367 | |
| 368 | /** | |
| 369 | * The inbox (docs/APPS.md): pulls the crew opened that are waiting on a | |
| 370 | * person, and whatever is assigned to the viewer. Both are absent when | |
| 371 | * empty — a card saying "nothing" is a zero drawn, and the absence carries | |
| 372 | * the same information — so a quiet forge opens on its repositories. | |
| 373 | */ | |
| 374 | function inbox(ctx: Ctx, user: User): Html { | |
| 375 | const needs = needsYouCard(ctx, user); | |
| 376 | const mine = onYouCard(ctx, user); | |
| 377 | if (needs === undefined && mine === undefined) return html``; | |
| 378 | return html`<div class="stack mb">${needs ?? null} ${mine ?? null}</div>`; | |
| 379 | } | |
| 380 | ||
| 381 | const threadPath = (repo: Repo, issue: Issue): string => | |
| 382 | `/${repo.ownerName}/${repo.name}/${issue.kind === "pull" ? "pulls" : "issues"}/${issue.number}`; | |
| 383 | ||
| 384 | /** | |
| 385 | * Open pulls the crew opened, in repositories the viewer can write — the | |
| 386 | * ones waiting on a review only a person can give. Visibility is in the | |
| 387 | * query; write access is checked once per distinct repository here. The | |
| 388 | * check beside each is the latest deploy of that pull, one grouped read. | |
| 389 | */ | |
| 390 | function needsYouCard(ctx: Ctx, user: User): Html | undefined { | |
| 391 | const db = ctx.app.db; | |
| 392 | const pulls = listOpenAgentPullsVisibleTo(db, user, 25); | |
| 393 | if (pulls.length === 0) return undefined; | |
| 394 | const repos = getReposByIds( | |
| 395 | db, | |
| 396 | pulls.map((pull) => pull.repoId), | |
| 397 | ); | |
| 398 | // Write access for every repository at once — one query, like visibility. | |
| 399 | const mayWrite = writableRepoIds(db, user, [...repos.keys()]); | |
| 400 | const writable = new Map<string, Repo>(); | |
| 401 | for (const repo of repos.values()) | |
| 402 | if (mayWrite.has(repo.id)) writable.set(repo.id, repo); | |
| 403 | const mine = pulls.filter((pull) => writable.has(pull.repoId)).slice(0, 10); | |
| 404 | if (mine.length === 0) return undefined; | |
| 405 | const checks = latestRunsForIssues( | |
| 406 | db, | |
| 407 | mine.map((pull) => pull.id), | |
| 408 | ); | |
| 409 | return listCard( | |
| 410 | mine.map((pull) => { | |
| 411 | const repo = writable.get(pull.repoId)!; | |
| 412 | const check = checks.get(pull.id); | |
| 413 | return row({ | |
| 414 | href: threadPath(repo, pull), | |
| 415 | nav: true, | |
| 416 | state: "open", | |
| 417 | lead: icon("pull"), | |
| 418 | title: pull.title, | |
| 419 | titleAttr: pull.title, | |
| 420 | meta: html`${repo.ownerName}/${repo.name} #${pull.number} · | |
| 421 | ${userTag(pull.authorName)} opened ${time(pull.createdAt)}`, | |
| 422 | trail: html`${check === undefined | |
| 423 | ? html`<span class="badge verdict-stale" | |
| 424 | >${icon("clock")}No checks</span | |
| 425 | >` | |
| 426 | : runStateBadge(check.state, check.kind)}`, | |
| 427 | }); | |
| 428 | }), | |
| 429 | { | |
| 430 | head: html`${icon("eye")} | |
| 431 | <h2>Needs you</h2> | |
| 432 | <span class="muted small" | |
| 433 | >pull requests from the crew, waiting on a review</span | |
| 434 | >`, | |
| 435 | }, | |
| 436 | ); | |
| 437 | } | |
| 438 | ||
| 439 | /** Open issues and pulls assigned to the viewer, anywhere they can see. */ | |
| 440 | function onYouCard(ctx: Ctx, user: User): Html | undefined { | |
| 441 | const db = ctx.app.db; | |
| 442 | const assigned = listAssignedTo(db, user.id, 20); | |
| 443 | if (assigned.length === 0) return undefined; | |
| 444 | const repos = getReposByIds( | |
| 445 | db, | |
| 446 | assigned.map((issue) => issue.repoId), | |
| 447 | ); | |
| 448 | // `listAssignedTo` has no visibility filter of its own (an assignment can | |
| 449 | // outlive the access that made it), so each repo needs a real `canRead`. | |
| 450 | // Checking it straight would cost one query per row for any repo the | |
| 451 | // viewer does not own outright — `canRead` resolves org ownership from | |
| 452 | // the database. `listReposVisibleTo` answers the common case with one | |
| 453 | // query for the whole page; `canRead` only runs for the rare repo it | |
| 454 | // did not reach (its own cap), so the cost stays flat as rows grow. | |
| 455 | const visible = new Set( | |
| 456 | listReposVisibleTo(db, user, { limit: 200 }).map((repo) => repo.id), | |
| 457 | ); | |
| 458 | const rows: Html[] = []; | |
| 459 | for (const issue of assigned) { | |
| 460 | const repo = repos.get(issue.repoId); | |
| 461 | if (!repo) continue; | |
| 462 | if (!visible.has(repo.id) && !canRead(db, repo, user)) continue; | |
| 463 | rows.push( | |
| 464 | row({ | |
| 465 | href: threadPath(repo, issue), | |
| 466 | nav: true, | |
| 467 | state: "open", | |
| 468 | lead: icon(issue.kind === "pull" ? "pull" : "issue"), | |
| 469 | title: issue.title, | |
| 470 | titleAttr: issue.title, | |
| 471 | meta: html`${repo.ownerName}/${repo.name} #${issue.number} · updated | |
| 472 | ${time(issue.updatedAt)}`, | |
| 473 | }), | |
| 474 | ); | |
| 475 | } | |
| 476 | if (rows.length === 0) return undefined; | |
| 477 | return listCard(rows, { | |
| 478 | head: html`${icon("user")} | |
| 479 | <h2>On you</h2>`, | |
| 480 | }); | |
| 481 | } | |
| 482 | ||
| 385 | 483 | function orgsCard(ctx: Ctx, user: User): Html { |
| 386 | 484 | const orgs = listOrgsForUser(ctx.app.db, user.id); |
| 387 | 485 | const head = html`<h2>Your organizations</h2> |
| @@ -502,6 +600,7 @@ function dashboard(ctx: Ctx, user: User): Html { | ||
| 502 | 600 | |
| 503 | 601 | return html`<div class="dash"> |
| 504 | 602 | <div> |
| 603 | ${inbox(ctx, user)} | |
| 505 | 604 | ${pageHead("Your repositories", { |
| 506 | 605 | actions: btn("New repository", { |
| 507 | 606 | href: "/new", |
| @@ -520,7 +619,8 @@ function dashboard(ctx: Ctx, user: User): Html { | ||
| 520 | 619 | })} |
| 521 | 620 | </div> |
| 522 | 621 | <aside class="stack"> |
| 523 | ${rail.body} ${downloadsCard(ctx, user)} ${orgsCard(ctx, user)} | |
| 622 | ${appsCard(ctx)} ${rail.body} ${downloadsCard(ctx, user)} | |
| 623 | ${orgsCard(ctx, user)} | |
| 524 | 624 | </aside> |
| 525 | 625 | ${liveMount({ |
| 526 | 626 | repo: rail.watch[0] ?? "", |
src/web/pages/thread.ts +149 −2
| @@ -8,16 +8,24 @@ | ||
| 8 | 8 | * is what makes a long thread skimmable. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { listCrew } from "../../actions/crew.ts"; | |
| 11 | 12 | import { |
| 13 | assignIssue, | |
| 12 | 14 | setIssueLabelsThrough, |
| 13 | 15 | setIssueStateThrough, |
| 14 | 16 | } from "../../actions/issues.ts"; |
| 15 | 17 | import { listEvents } from "../../core/events.ts"; |
| 16 | 18 | import type { Issue, Label } from "../../core/issues.ts"; |
| 17 | 19 | import { getIssue, labelsFor, listComments } from "../../core/issues.ts"; |
| 18 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 20 | import { canRead, canWrite, type Repo } from "../../core/repos.ts"; | |
| 19 | 21 | import type { IconName as Glyph } from "../icons.ts"; |
| 20 | import { getUsersByIds } from "../../core/users.ts"; | |
| 22 | import { | |
| 23 | getUserById, | |
| 24 | getUserByName, | |
| 25 | getUsersByIds, | |
| 26 | listPeople, | |
| 27 | type User, | |
| 28 | } from "../../core/users.ts"; | |
| 21 | 29 | import type { Ctx } from "../../http/context.ts"; |
| 22 | 30 | import { commentCard } from "../comments.ts"; |
| 23 | 31 | import { field, readForm } from "../form.ts"; |
| @@ -403,6 +411,145 @@ export function labelsCard( | ||
| 403 | 411 | ); |
| 404 | 412 | } |
| 405 | 413 | |
| 414 | // ---- assignee ---- | |
| 415 | ||
| 416 | /** | |
| 417 | * Who could be put on this thread: nobody, then the people on this forge | |
| 418 | * who can see the repository, then the crew — which is exactly the set of | |
| 419 | * agents `assignIssue` will accept. People are read bounded and by name; | |
| 420 | * the author and whoever is on it now are offered whatever the bound, so | |
| 421 | * the select can never fail to show the current answer. Each candidate is | |
| 422 | * checked with `canRead`, the same rule the action applies. | |
| 423 | */ | |
| 424 | function assigneeCandidates(ctx: Ctx, repo: Repo, issue: Issue): User[] { | |
| 425 | const db = ctx.app.db; | |
| 426 | const seen = new Map<string, User>(); | |
| 427 | const offer = (user: User | undefined): void => { | |
| 428 | if ( | |
| 429 | user !== undefined && | |
| 430 | user.kind !== "org" && | |
| 431 | !seen.has(user.id) && | |
| 432 | canRead(db, repo, user) | |
| 433 | ) | |
| 434 | seen.set(user.id, user); | |
| 435 | }; | |
| 436 | for (const person of listPeople(db, { limit: 200 })) offer(person); | |
| 437 | offer(getUserById(db, issue.authorId)); | |
| 438 | if (issue.assigneeId !== null) offer(getUserById(db, issue.assigneeId)); | |
| 439 | const crew = listCrew(ctx.app, ctx.auth.user, repo); | |
| 440 | if (crew.ok) for (const agent of crew.value) offer(agent.user); | |
| 441 | return [...seen.values()].sort((a, b) => | |
| 442 | a.kind === b.kind | |
| 443 | ? a.username.localeCompare(b.username) | |
| 444 | : a.kind === "agent" | |
| 445 | ? 1 | |
| 446 | : -1, | |
| 447 | ); | |
| 448 | } | |
| 449 | ||
| 450 | /** | |
| 451 | * The assignee rail: who is on it, and — for a writer — the select that | |
| 452 | * changes that. Assigning an agent is what starts it working | |
| 453 | * (docs/APPS.md), so the agents are listed last and marked, never mixed in | |
| 454 | * with people as though the choice were the same kind of choice. | |
| 455 | */ | |
| 456 | export function assigneeCard( | |
| 457 | ctx: Ctx, | |
| 458 | repo: Repo, | |
| 459 | issue: Issue, | |
| 460 | action: string, | |
| 461 | ): Html { | |
| 462 | const mayEdit = canWrite(ctx.app.db, repo, ctx.auth.user); | |
| 463 | const current = | |
| 464 | issue.assigneeName === null | |
| 465 | ? html`<p class="muted small">Nobody yet.</p>` | |
| 466 | : html`<p class="bar"> | |
| 467 | ${when(issue.assigneeIsAgent, () => icon("bot"))} | |
| 468 | ${userLink(issue.assigneeName)} | |
| 469 | </p>`; | |
| 470 | if (!mayEdit) | |
| 471 | return card(current, { | |
| 472 | head: html`${icon("user")} | |
| 473 | <h2>Assignee</h2>`, | |
| 474 | }); | |
| 475 | const candidates = assigneeCandidates(ctx, repo, issue); | |
| 476 | return card( | |
| 477 | html`${current} | |
| 478 | <form method="post" action="${action}" class="edit-row mt"> | |
| 479 | <label class="sr-only" for="f-assignee">Assign to</label> | |
| 480 | <select class="input" id="f-assignee" name="assignee"> | |
| 481 | <option | |
| 482 | value="" | |
| 483 | ${issue.assigneeId === null ? raw("selected") : null} | |
| 484 | > | |
| 485 | Nobody | |
| 486 | </option> | |
| 487 | ${candidates.map( | |
| 488 | (user) => | |
| 489 | html`<option | |
| 490 | value="${user.username}" | |
| 491 | ${user.id === issue.assigneeId ? raw("selected") : null} | |
| 492 | > | |
| 493 | ${user.username}${user.kind === "agent" ? " (agent)" : ""} | |
| 494 | </option>`, | |
| 495 | )} | |
| 496 | </select> | |
| 497 | ${btn("Assign", { size: "sm" })} | |
| 498 | </form>`, | |
| 499 | { | |
| 500 | head: html`${icon("user")} | |
| 501 | <h2>Assignee</h2>`, | |
| 502 | }, | |
| 503 | ); | |
| 504 | } | |
| 505 | ||
| 506 | /** POST …/:number/assignee — put someone on it, or take them off. */ | |
| 507 | export async function assigneeSubmit( | |
| 508 | ctx: Ctx, | |
| 509 | kind: "issue" | "pull", | |
| 510 | ): Promise<Response> { | |
| 511 | const found = threadContext(ctx, kind); | |
| 512 | if (found instanceof Response) return found; | |
| 513 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 514 | const form = await readForm(ctx, "rw"); | |
| 515 | if (form instanceof Response) return form; | |
| 516 | ||
| 517 | const dest = `${repoBase(found.repo)}/${kind === "pull" ? "pulls" : "issues"}/${found.issue.number}`; | |
| 518 | const name = field(form, "assignee").trim(); | |
| 519 | const assignee = name === "" ? null : getUserByName(ctx.app.db, name); | |
| 520 | if (assignee === undefined) | |
| 521 | return flashTo(ctx, dest, { | |
| 522 | tone: "danger", | |
| 523 | message: `There is no account named ${name}.`, | |
| 524 | }); | |
| 525 | const assigned = assignIssue( | |
| 526 | ctx.app, | |
| 527 | ctx.auth.user, | |
| 528 | found.repo, | |
| 529 | found.issue, | |
| 530 | assignee, | |
| 531 | ); | |
| 532 | if (!assigned.ok) { | |
| 533 | const words: Record<string, string> = { | |
| 534 | assignee_cannot_read: `${name} cannot see this repository, so cannot be assigned to it.`, | |
| 535 | agent_not_on_crew: `${name} is not on this repository's crew. Add it under Settings → Crew first.`, | |
| 536 | forbidden: "You need write access to assign.", | |
| 537 | }; | |
| 538 | return flashTo(ctx, dest, { | |
| 539 | tone: "danger", | |
| 540 | message: words[assigned.error] ?? "That could not be assigned.", | |
| 541 | }); | |
| 542 | } | |
| 543 | return flashTo(ctx, dest, { | |
| 544 | message: | |
| 545 | assignee === null | |
| 546 | ? "Unassigned." | |
| 547 | : assignee.kind === "agent" | |
| 548 | ? `Assigned to ${assignee.username}. It starts working on it now.` | |
| 549 | : `Assigned to ${assignee.username}.`, | |
| 550 | }); | |
| 551 | } | |
| 552 | ||
| 406 | 553 | // ---- shared mutations ---- |
| 407 | 554 | |
| 408 | 555 | /** POST …/:number/state — close or reopen, for both kinds. */ |
src/web/style/content.ts +46
| @@ -532,6 +532,52 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 532 | 532 | .files-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } |
| 533 | 533 | .files-head h2 { margin-bottom: 0; } |
| 534 | 534 | |
| 535 | /* ---- apps, runs, and the crew ---- */ | |
| 536 | /* The strip a repo home wears when it runs: state, commit, URL, previews, | |
| 537 | and the way to the App page — one line, in the repo header's own voice. */ | |
| 538 | .app-strip { | |
| 539 | display: flex; align-items: center; gap: 10px; flex-wrap: wrap; | |
| 540 | margin-top: 12px; padding: 8px 12px; font-size: 13px; | |
| 541 | background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); | |
| 542 | } | |
| 543 | .app-strip > .ico { color: var(--muted-fg); flex: none; } | |
| 544 | .app-strip .app-url { | |
| 545 | min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 546 | font-family: var(--mono); font-size: 12.5px; | |
| 547 | } | |
| 548 | .app-strip .spacer { flex: 1; } | |
| 549 | /* One environment: name and state in the head, the facts and the reason it | |
| 550 | failed in the body, its buttons in the foot. A card rather than a row so a | |
| 551 | deploy error has a whole line to be read on. */ | |
| 552 | .env-card .card-head { gap: 10px; } | |
| 553 | .env-card .env-facts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted-fg); } | |
| 554 | .env-card .env-error { color: var(--danger); font-size: 13px; margin-top: 6px; overflow-wrap: anywhere; text-wrap: pretty; } | |
| 555 | .env-card .card-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } | |
| 556 | .env-card .card-foot form { display: inline-flex; } | |
| 557 | /* The checks line inside the merge box, in the reviews chip's voice. */ | |
| 558 | .checks-line { | |
| 559 | display: flex; align-items: center; gap: 6px; flex-wrap: wrap; | |
| 560 | margin-top: 6px; font-size: 13px; color: var(--muted-fg); | |
| 561 | } | |
| 562 | .checks-line .ico { width: 14px; height: 14px; } | |
| 563 | .checks-line .ok { display: inline-flex; align-items: center; gap: 4px; color: var(--success); } | |
| 564 | .checks-line .bad { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); } | |
| 565 | .checks-line .now { display: inline-flex; align-items: center; gap: 4px; color: var(--attn); } | |
| 566 | /* A run's log: monospace, wrapped so a phone never scrolls sideways, and | |
| 567 | bounded in height so the page head stays reachable. */ | |
| 568 | .run-log { | |
| 569 | margin: 0; padding: 12px 14px; max-height: 70dvh; overflow: auto; | |
| 570 | background: var(--muted); border: 1px solid var(--border); border-radius: var(--radius); | |
| 571 | font-family: var(--mono); font-size: 12.5px; line-height: 1.55; | |
| 572 | white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 4; | |
| 573 | font-variant-ligatures: none; min-height: 3.1em; | |
| 574 | } | |
| 575 | .run-summary { margin: 10px 0 0; text-wrap: pretty; } | |
| 576 | .run-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 12px 0; } | |
| 577 | .run-actions form { display: inline-flex; } | |
| 578 | .table .truncate { max-width: 34ch; display: inline-block; vertical-align: bottom; } | |
| 579 | .agent-brief { white-space: pre-wrap; font-size: 13px; color: var(--muted-fg); max-height: 6.5em; overflow: hidden; } | |
| 580 | ||
| 535 | 581 | /* ---- proposing a change ---- */ |
| 536 | 582 | /* The editor's commit block, when the commit is a contribution: the whole |
| 537 | 583 | outcome in one sentence before anybody types. */ |
src/web/ui.ts +50
| @@ -10,6 +10,8 @@ | ||
| 10 | 10 | * reaches for an inline `style` attribute, because the CSP forbids it. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import type { EnvState } from "../apps/runtime.ts"; | |
| 14 | import type { RunKind, RunState } from "../core/runs.ts"; | |
| 13 | 15 | import { icon, type IconName } from "./icons.ts"; |
| 14 | 16 | import { Html, html, raw, when } from "./html.ts"; |
| 15 | 17 | |
| @@ -200,6 +202,54 @@ export function verdictBadge( | ||
| 200 | 202 | return badge(look.label, { icon: look.icon, tone: look.tone }); |
| 201 | 203 | } |
| 202 | 204 | |
| 205 | /** | |
| 206 | * A run's state (docs/APPS.md): the one badge a check wears on a pull, a | |
| 207 | * row wears in a list, and a log page wears in its head. A finished deploy | |
| 208 | * "passed" because it is a check; a finished agent run is simply "done". | |
| 209 | */ | |
| 210 | export function runStateBadge(state: RunState, kind: RunKind = "deploy"): Html { | |
| 211 | switch (state) { | |
| 212 | case "queued": | |
| 213 | return badge("Queued", { icon: "clock", variant: "secondary" }); | |
| 214 | case "running": | |
| 215 | return badge("Running", { icon: "loader", tone: "attn" }); | |
| 216 | case "ok": | |
| 217 | return badge(kind === "agent" ? "Done" : "Passed", { | |
| 218 | icon: "check", | |
| 219 | tone: "success", | |
| 220 | }); | |
| 221 | case "failed": | |
| 222 | return badge("Failed", { icon: "x", tone: "danger" }); | |
| 223 | case "cancelled": | |
| 224 | return badge("Cancelled", { icon: "ban", variant: "outline" }); | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | /** An environment's state: what production, or a preview, is doing now. */ | |
| 229 | export function envStateBadge(state: EnvState): Html { | |
| 230 | switch (state) { | |
| 231 | case "stopped": | |
| 232 | return badge("Stopped", { icon: "square", variant: "outline" }); | |
| 233 | case "deploying": | |
| 234 | return badge("Deploying", { icon: "loader", tone: "attn" }); | |
| 235 | case "running": | |
| 236 | return badge("Running", { icon: "play", tone: "success" }); | |
| 237 | case "failed": | |
| 238 | return badge("Failed", { icon: "alert", tone: "danger" }); | |
| 239 | } | |
| 240 | } | |
| 241 | ||
| 242 | /** The tone an environment's state gives a `dot()` — a list's one-glance answer. */ | |
| 243 | export function envTone(state: EnvState | undefined): Tone { | |
| 244 | return state === "running" | |
| 245 | ? "success" | |
| 246 | : state === "deploying" | |
| 247 | ? "attn" | |
| 248 | : state === "failed" | |
| 249 | ? "danger" | |
| 250 | : "default"; | |
| 251 | } | |
| 252 | ||
| 203 | 253 | export interface LabelLike { |
| 204 | 254 | readonly name: string; |
| 205 | 255 | readonly color: string; |
test/apps-adversarial.test.ts added +1562
| @@ -0,0 +1,1562 @@ | ||
| 1 | /** | |
| 2 | * Adversarial coverage for apps, runs, and the crew (docs/APPS.md): trying | |
| 3 | * to break each of the eight mechanical rules, host routing, grants, the | |
| 4 | * proxy, manifests, concurrency, logs, and the migration that introduced | |
| 5 | * the schema. Where the existing suites (apps.test.ts, apps-api.test.ts, | |
| 6 | * crew.test.ts) already drive a rule end to end, this file goes for the | |
| 7 | * edges: stale state, races, forged input, and the boundary of a bound. | |
| 8 | */ | |
| 9 | ||
| 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 11 | import { existsSync } from "node:fs"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 17 | import { envDir, isEnvName, type EnvName } from "../src/apps/data.ts"; | |
| 18 | import { appEnv, childEnv } from "../src/apps/process.ts"; | |
| 19 | import { | |
| 20 | parseAppHost, | |
| 21 | resolveAppHost, | |
| 22 | signGrant, | |
| 23 | verifyGrant, | |
| 24 | } from "../src/apps/proxy.ts"; | |
| 25 | import { parseManifest } from "../src/apps/manifest.ts"; | |
| 26 | import { newApp, updateAppSettings } from "../src/actions/apps.ts"; | |
| 27 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 28 | import { addComment, assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 29 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 30 | import { | |
| 31 | commitToBranch, | |
| 32 | createRepo, | |
| 33 | updateRepoSettings, | |
| 34 | } from "../src/actions/repos.ts"; | |
| 35 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 36 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 37 | import { repoDir } from "../src/git/repos.ts"; | |
| 38 | import { | |
| 39 | activeRunFor, | |
| 40 | automaticRunsSince, | |
| 41 | cancelRun, | |
| 42 | claimRun, | |
| 43 | createRun, | |
| 44 | finishRun, | |
| 45 | listRuns, | |
| 46 | type Run, | |
| 47 | } from "../src/core/runs.ts"; | |
| 48 | import { createToken, listTokens, verifyToken } from "../src/core/tokens.ts"; | |
| 49 | import { createUser, type User } from "../src/core/users.ts"; | |
| 50 | import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts"; | |
| 51 | import { executeAgentRun } from "../src/crew/runner.ts"; | |
| 52 | import { openDb } from "../src/kernel/db.ts"; | |
| 53 | import { migrations } from "../src/core/schema.ts"; | |
| 54 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 55 | import { | |
| 56 | appendRunLog, | |
| 57 | readRunLog, | |
| 58 | runLogListenerCount, | |
| 59 | subscribeRunLog, | |
| 60 | } from "../src/runs/log.ts"; | |
| 61 | ||
| 62 | const DOMAIN = "apps.test"; | |
| 63 | ||
| 64 | const clientEnv = { | |
| 65 | ...process.env, | |
| 66 | GIT_AUTHOR_NAME: "Agent", | |
| 67 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 68 | GIT_COMMITTER_NAME: "Agent", | |
| 69 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 70 | GIT_TERMINAL_PROMPT: "0", | |
| 71 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 72 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 73 | }; | |
| 74 | ||
| 75 | async function sh(args: string[], cwd: string) { | |
| 76 | const proc = Bun.spawn(args, { | |
| 77 | cwd, | |
| 78 | env: clientEnv, | |
| 79 | stdout: "pipe", | |
| 80 | stderr: "pipe", | |
| 81 | }); | |
| 82 | const [out, err, code] = await Promise.all([ | |
| 83 | new Response(proc.stdout).text(), | |
| 84 | new Response(proc.stderr).text(), | |
| 85 | proc.exited, | |
| 86 | ]); | |
| 87 | return { code, out: out.trim(), err: err.trim() }; | |
| 88 | } | |
| 89 | ||
| 90 | // --------------------------------------------------------------------------- | |
| 91 | // Pure units: no server, no filesystem beyond what the function itself does. | |
| 92 | // --------------------------------------------------------------------------- | |
| 93 | ||
| 94 | describe("host routing: parseAppHost", () => { | |
| 95 | const DOM = "apps.test"; | |
| 96 | ||
| 97 | test("a port on the host is ignored", () => { | |
| 98 | expect(parseAppHost("hello.apps.test:3000", DOM)).toEqual({ | |
| 99 | slug: "hello", | |
| 100 | env: "production", | |
| 101 | }); | |
| 102 | }); | |
| 103 | ||
| 104 | test("upper case is folded", () => { | |
| 105 | expect(parseAppHost("HELLO.APPS.TEST", DOM)).toEqual({ | |
| 106 | slug: "hello", | |
| 107 | env: "production", | |
| 108 | }); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a trailing dot (FQDN form) does not match the domain", () => { | |
| 112 | // Not a match rather than a mismatch: the request falls through to the | |
| 113 | // forge, never to the wrong app. | |
| 114 | expect(parseAppHost("hello.apps.test.", DOM)).toBeUndefined(); | |
| 115 | }); | |
| 116 | ||
| 117 | test("an IPv6 literal host is never an app", () => { | |
| 118 | expect(parseAppHost("[::1]:3000", DOM)).toBeUndefined(); | |
| 119 | expect(parseAppHost("[::1]", "apps.test")).toBeUndefined(); | |
| 120 | }); | |
| 121 | ||
| 122 | test("`--` inside what would be the slug is refused, not split cleverly", () => { | |
| 123 | expect(parseAppHost("a--b--12.apps.test", DOM)).toBeUndefined(); | |
| 124 | }); | |
| 125 | ||
| 126 | test("pull number 0 and a leading zero are refused", () => { | |
| 127 | expect(parseAppHost("hello--0.apps.test", DOM)).toBeUndefined(); | |
| 128 | expect(parseAppHost("hello--01.apps.test", DOM)).toBeUndefined(); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a bare slug at the domain itself is not an app (missing label)", () => { | |
| 132 | expect(parseAppHost("apps.test", DOM)).toBeUndefined(); | |
| 133 | }); | |
| 134 | ||
| 135 | test("a multi-label host in front of the domain is not an app", () => { | |
| 136 | expect(parseAppHost("a.hello.apps.test", DOM)).toBeUndefined(); | |
| 137 | }); | |
| 138 | ||
| 139 | test("an empty label before the domain is refused", () => { | |
| 140 | expect(parseAppHost(".apps.test", DOM)).toBeUndefined(); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a domain that itself carries a port is stripped the same way", () => { | |
| 144 | expect(parseAppHost("hello.apps.test:3000", "apps.test:3000")).toEqual({ | |
| 145 | slug: "hello", | |
| 146 | env: "production", | |
| 147 | }); | |
| 148 | }); | |
| 149 | ||
| 150 | test("resolveAppHost: no Host header at all is not an app host", () => { | |
| 151 | // `resolveAppHost` only ever calls `request.headers.get("host")`, so a | |
| 152 | // minimal stand-in is enough — no real `Request` synthesizes a missing | |
| 153 | // Host header, since `fetch`/`Request` always fill one in from the URL. | |
| 154 | const noHost = { headers: new Headers() } as unknown as Request; | |
| 155 | const result = resolveAppHost({ db: {} } as never, DOM, noHost); | |
| 156 | expect(result).toBeUndefined(); | |
| 157 | }); | |
| 158 | }); | |
| 159 | ||
| 160 | describe("manifest: parseManifest edge cases", () => { | |
| 161 | test("malformed JSON fails closed with the parse error named", () => { | |
| 162 | const result = parseManifest("{not json", undefined); | |
| 163 | expect(result.ok).toBe(false); | |
| 164 | if (!result.ok && result.error.kind === "invalid") { | |
| 165 | expect(result.error.message).toContain("forge.json"); | |
| 166 | } else { | |
| 167 | throw new Error("expected an 'invalid' manifest error"); | |
| 168 | } | |
| 169 | }); | |
| 170 | ||
| 171 | test("`app` as an array is rejected, not silently coerced", () => { | |
| 172 | const result = parseManifest(JSON.stringify({ app: [] }), undefined); | |
| 173 | expect(result.ok).toBe(false); | |
| 174 | if (!result.ok) expect(result.error.kind).toBe("invalid"); | |
| 175 | }); | |
| 176 | ||
| 177 | test("a run command with a newline or NUL is refused", () => { | |
| 178 | const withNewline = parseManifest( | |
| 179 | JSON.stringify({ app: { run: "echo a\necho b" } }), | |
| 180 | undefined, | |
| 181 | ); | |
| 182 | expect(withNewline.ok).toBe(false); | |
| 183 | const withNul = parseManifest( | |
| 184 | JSON.stringify({ app: { run: "echo a\u0000b" } }), | |
| 185 | undefined, | |
| 186 | ); | |
| 187 | expect(withNul.ok).toBe(false); | |
| 188 | }); | |
| 189 | ||
| 190 | test("ready outside 1..600 or non-integer is refused; a huge number is refused too", () => { | |
| 191 | const huge = parseManifest( | |
| 192 | JSON.stringify({ app: { run: "x", ready: 1e9 } }), | |
| 193 | undefined, | |
| 194 | ); | |
| 195 | expect(huge.ok).toBe(false); | |
| 196 | const fractional = parseManifest( | |
| 197 | JSON.stringify({ app: { run: "x", ready: 1.5 } }), | |
| 198 | undefined, | |
| 199 | ); | |
| 200 | expect(fractional.ok).toBe(false); | |
| 201 | const zero = parseManifest( | |
| 202 | JSON.stringify({ app: { run: "x", ready: 0 } }), | |
| 203 | undefined, | |
| 204 | ); | |
| 205 | expect(zero.ok).toBe(false); | |
| 206 | }); | |
| 207 | ||
| 208 | test("health must be a path starting with /", () => { | |
| 209 | const result = parseManifest( | |
| 210 | JSON.stringify({ app: { run: "x", health: "http://x" } }), | |
| 211 | undefined, | |
| 212 | ); | |
| 213 | expect(result.ok).toBe(false); | |
| 214 | }); | |
| 215 | ||
| 216 | test("a 100 KB run command is refused (bounded, not silently truncated)", () => { | |
| 217 | const result = parseManifest( | |
| 218 | JSON.stringify({ app: { run: "x".repeat(100_000) } }), | |
| 219 | undefined, | |
| 220 | ); | |
| 221 | expect(result.ok).toBe(false); | |
| 222 | if (!result.ok) expect(result.error.kind).toBe("invalid"); | |
| 223 | }); | |
| 224 | ||
| 225 | test('install: "" means skip, distinct from unset meaning "use the package default"', () => { | |
| 226 | const pkg = JSON.stringify({ dependencies: { x: "1.0.0" } }); | |
| 227 | const unset = parseManifest(JSON.stringify({ app: { run: "x" } }), pkg); | |
| 228 | expect(unset.ok).toBe(true); | |
| 229 | if (unset.ok) expect(unset.value.install).toBe("bun install"); | |
| 230 | const skipped = parseManifest( | |
| 231 | JSON.stringify({ app: { run: "x", install: "" } }), | |
| 232 | pkg, | |
| 233 | ); | |
| 234 | expect(skipped.ok).toBe(true); | |
| 235 | if (skipped.ok) expect(skipped.value.install).toBeNull(); | |
| 236 | }); | |
| 237 | ||
| 238 | test("no forge.json and no package.json start script: not an app", () => { | |
| 239 | const result = parseManifest(undefined, JSON.stringify({})); | |
| 240 | expect(result.ok).toBe(false); | |
| 241 | if (!result.ok) expect(result.error.kind).toBe("not_an_app"); | |
| 242 | }); | |
| 243 | }); | |
| 244 | ||
| 245 | describe("run log: redaction, truncation, and the listener seam", () => { | |
| 246 | let dataDir: string; | |
| 247 | ||
| 248 | beforeAll(async () => { | |
| 249 | dataDir = await mkdtemp(join(tmpdir(), "forge-log-")); | |
| 250 | }); | |
| 251 | afterAll(async () => { | |
| 252 | await rm(dataDir, { recursive: true, force: true }); | |
| 253 | }); | |
| 254 | ||
| 255 | test("a forge_ token and a Basic auth header are redacted on the way in", () => { | |
| 256 | const runId = "test-run-1"; | |
| 257 | // Realistic lengths: a real run token is `forge_` + 32 random bytes, | |
| 258 | // base64url — 43 characters, well past the redactor's 20-char floor. | |
| 259 | const token = `forge_${"a1B2c3D4".repeat(6)}`; | |
| 260 | const basic = Buffer.from(`bot:${token}`).toString("base64"); | |
| 261 | appendRunLog(dataDir, runId, `authenticated with ${token}`); | |
| 262 | appendRunLog( | |
| 263 | dataDir, | |
| 264 | runId, | |
| 265 | `curl -H "authorization: Basic ${basic}" https://x`, | |
| 266 | ); | |
| 267 | const { lines } = readRunLog(dataDir, runId); | |
| 268 | const joined = lines.join("\n"); | |
| 269 | expect(joined).not.toContain(token); | |
| 270 | expect(joined).not.toContain(basic); | |
| 271 | expect(joined).toContain("[redacted]"); | |
| 272 | }); | |
| 273 | ||
| 274 | test("readRunLog with a small maxBytes drops the partial first line", () => { | |
| 275 | const runId = "test-run-2"; | |
| 276 | for (let i = 0; i < 50; i++) appendRunLog(dataDir, runId, `line ${i}`); | |
| 277 | const { lines, truncated } = readRunLog(dataDir, runId, 40); | |
| 278 | expect(truncated).toBe(true); | |
| 279 | // Every remaining line must be whole: none may be a mid-word fragment | |
| 280 | // of "line N" that a byte cut would otherwise leave. | |
| 281 | for (const line of lines) expect(line).toMatch(/^line \d+$/); | |
| 282 | }); | |
| 283 | ||
| 284 | test("subscribe/abort a thousand times leaves no listener behind", () => { | |
| 285 | const before = runLogListenerCount(); | |
| 286 | const runId = "test-run-leak"; | |
| 287 | for (let i = 0; i < 1000; i++) { | |
| 288 | const controller = new AbortController(); | |
| 289 | subscribeRunLog(runId, () => {}, controller.signal); | |
| 290 | controller.abort(); | |
| 291 | } | |
| 292 | expect(runLogListenerCount()).toBe(before); | |
| 293 | }); | |
| 294 | ||
| 295 | test("an already-aborted signal releases immediately, never registering", () => { | |
| 296 | const before = runLogListenerCount(); | |
| 297 | const controller = new AbortController(); | |
| 298 | controller.abort(); | |
| 299 | subscribeRunLog("test-run-preaborted", () => {}, controller.signal); | |
| 300 | expect(runLogListenerCount()).toBe(before); | |
| 301 | }); | |
| 302 | }); | |
| 303 | ||
| 304 | describe("rule 8: a child inherits nothing it was not handed", () => { | |
| 305 | const SECRETS = { | |
| 306 | ANTHROPIC_API_KEY: "sk-leak-me-not", | |
| 307 | FORGE_GITHUB_CLIENT_SECRET: "also-secret", | |
| 308 | SOME_RANDOM_FORGE_INTERNAL: "should-not-leak", | |
| 309 | }; | |
| 310 | ||
| 311 | test("childEnv never carries secrets that were not explicitly named", () => { | |
| 312 | const saved = { ...process.env }; | |
| 313 | try { | |
| 314 | Object.assign(process.env, SECRETS); | |
| 315 | const env = childEnv({ PORT: "1234" }); | |
| 316 | for (const key of Object.keys(SECRETS)) expect(env[key]).toBeUndefined(); | |
| 317 | expect(env["PORT"]).toBe("1234"); | |
| 318 | expect(env["PATH"]).toBeDefined(); | |
| 319 | } finally { | |
| 320 | for (const key of Object.keys(SECRETS)) delete process.env[key]; | |
| 321 | Object.assign(process.env, saved); | |
| 322 | } | |
| 323 | }); | |
| 324 | ||
| 325 | test("appEnv (what an app process gets) never carries the operator's secrets", () => { | |
| 326 | const saved = { ...process.env }; | |
| 327 | try { | |
| 328 | Object.assign(process.env, SECRETS); | |
| 329 | const env = appEnv({ | |
| 330 | port: 4321, | |
| 331 | dataDir: "/data/apps/x/production/data", | |
| 332 | env: "production", | |
| 333 | url: "http://hello.apps.test", | |
| 334 | commitOid: "abc123", | |
| 335 | forgeUrl: "http://forge.test", | |
| 336 | }); | |
| 337 | for (const key of Object.keys(SECRETS)) expect(env[key]).toBeUndefined(); | |
| 338 | expect(env["FORGE_APP_DATA"]).toBe("/data/apps/x/production/data"); | |
| 339 | } finally { | |
| 340 | for (const key of Object.keys(SECRETS)) delete process.env[key]; | |
| 341 | Object.assign(process.env, saved); | |
| 342 | } | |
| 343 | }); | |
| 344 | ||
| 345 | test("an actual child process sees none of the operator's environment beyond what childEnv names", async () => { | |
| 346 | const saved = process.env["SUPER_SECRET_FORGE_VAR"]; | |
| 347 | process.env["SUPER_SECRET_FORGE_VAR"] = "leak-if-you-can"; | |
| 348 | try { | |
| 349 | const proc = Bun.spawn(["/bin/sh", "-c", "env"], { | |
| 350 | env: childEnv({ MARKER: "1" }), | |
| 351 | stdout: "pipe", | |
| 352 | }); | |
| 353 | const out = await new Response(proc.stdout).text(); | |
| 354 | await proc.exited; | |
| 355 | expect(out).not.toContain("SUPER_SECRET_FORGE_VAR"); | |
| 356 | expect(out).toContain("MARKER=1"); | |
| 357 | } finally { | |
| 358 | if (saved === undefined) delete process.env["SUPER_SECRET_FORGE_VAR"]; | |
| 359 | else process.env["SUPER_SECRET_FORGE_VAR"] = saved; | |
| 360 | } | |
| 361 | }); | |
| 362 | }); | |
| 363 | ||
| 364 | describe("ensureEnvData path safety", () => { | |
| 365 | let dataDir: string; | |
| 366 | beforeAll(async () => { | |
| 367 | dataDir = await mkdtemp(join(tmpdir(), "forge-envdata-")); | |
| 368 | }); | |
| 369 | afterAll(async () => { | |
| 370 | await rm(dataDir, { recursive: true, force: true }); | |
| 371 | }); | |
| 372 | ||
| 373 | test("isEnvName rejects traversal-shaped and otherwise malformed names", () => { | |
| 374 | expect(isEnvName("../../etc")).toBe(false); | |
| 375 | expect(isEnvName("pull-../1")).toBe(false); | |
| 376 | expect(isEnvName("production/../../etc")).toBe(false); | |
| 377 | expect(isEnvName("pull-1.5")).toBe(false); | |
| 378 | expect(isEnvName("pull-")).toBe(false); | |
| 379 | expect(isEnvName("PRODUCTION")).toBe(false); | |
| 380 | expect(isEnvName("production")).toBe(true); | |
| 381 | expect(isEnvName("pull-12")).toBe(true); | |
| 382 | }); | |
| 383 | ||
| 384 | test("envDir refuses a name that is not an environment, whatever the caller believed", () => { | |
| 385 | // Every caller (actions/apps.ts, apps/runtime.ts) checks `isEnvName` | |
| 386 | // first; `envDir` checks again so a future caller that skips it gets a | |
| 387 | // defect, not a path outside `<data>/apps`. | |
| 388 | const hostile = "../../../../../../tmp/forge-escape-test" as EnvName; | |
| 389 | expect(() => envDir(dataDir, "someappid", hostile)).toThrow( | |
| 390 | /refusing to build a path/, | |
| 391 | ); | |
| 392 | expect(() => envDir(dataDir, "../escape", "production")).toThrow( | |
| 393 | /refusing to build a path/, | |
| 394 | ); | |
| 395 | expect(envDir(dataDir, "someappid", "pull-12").startsWith(dataDir)).toBe( | |
| 396 | true, | |
| 397 | ); | |
| 398 | }); | |
| 399 | }); | |
| 400 | ||
| 401 | // --------------------------------------------------------------------------- | |
| 402 | // Runs: tokens, bounds, checks — pure core/db, no HTTP. | |
| 403 | // --------------------------------------------------------------------------- | |
| 404 | ||
| 405 | describe("rule 6: a run token dies with the run, and only with the run", () => { | |
| 406 | let dataDir: string; | |
| 407 | let app: App; | |
| 408 | let alice: User; | |
| 409 | ||
| 410 | beforeAll(async () => { | |
| 411 | dataDir = await mkdtemp(join(tmpdir(), "forge-tokens-")); | |
| 412 | app = createApp({ dataDir, port: 0 }); | |
| 413 | const created = await createUser(app.db, { | |
| 414 | username: "alice", | |
| 415 | email: "[email protected]", | |
| 416 | password: "a-good-passphrase", | |
| 417 | adminIfFirst: true, | |
| 418 | }); | |
| 419 | if (!created.ok) throw new Error(created.error); | |
| 420 | alice = created.value; | |
| 421 | }); | |
| 422 | afterAll(async () => { | |
| 423 | app.close(); | |
| 424 | await rm(dataDir, { recursive: true, force: true }); | |
| 425 | }); | |
| 426 | ||
| 427 | test("a token past its expires_at is refused, even though the row still exists", () => { | |
| 428 | const past = new Date(Date.now() - 60_000).toISOString(); | |
| 429 | const minted = createToken(app.db, alice.id, "expired", "rw", { | |
| 430 | expiresAt: past, | |
| 431 | }); | |
| 432 | const verified = verifyToken(app.db, minted.token); | |
| 433 | expect(verified.ok).toBe(false); | |
| 434 | if (!verified.ok) expect(verified.error).toBe("invalid_token"); | |
| 435 | }); | |
| 436 | ||
| 437 | test("a token with no expiry keeps working", () => { | |
| 438 | const minted = createToken(app.db, alice.id, "forever", "rw"); | |
| 439 | const verified = verifyToken(app.db, minted.token); | |
| 440 | expect(verified.ok).toBe(true); | |
| 441 | }); | |
| 442 | ||
| 443 | test("a token expiring one millisecond from now is refused once that instant passes", async () => { | |
| 444 | const soon = new Date(Date.now() + 5).toISOString(); | |
| 445 | const minted = createToken(app.db, alice.id, "soon", "rw", { | |
| 446 | expiresAt: soon, | |
| 447 | }); | |
| 448 | expect(verifyToken(app.db, minted.token).ok).toBe(true); | |
| 449 | await Bun.sleep(30); | |
| 450 | expect(verifyToken(app.db, minted.token).ok).toBe(false); | |
| 451 | }); | |
| 452 | }); | |
| 453 | ||
| 454 | describe("rule 6, end to end: the crew's per-run token", () => { | |
| 455 | let dataDir: string; | |
| 456 | let work: string; | |
| 457 | let app: App; | |
| 458 | let servers: Servers; | |
| 459 | let alice: User; | |
| 460 | let repo: Repo; | |
| 461 | let bot: User; | |
| 462 | ||
| 463 | beforeAll(async () => { | |
| 464 | dataDir = await mkdtemp(join(tmpdir(), "forge-crew-token-")); | |
| 465 | work = await mkdtemp(join(tmpdir(), "forge-crew-token-work-")); | |
| 466 | app = createApp({ dataDir, port: 0 }); | |
| 467 | servers = await serve(app); | |
| 468 | const created = await createUser(app.db, { | |
| 469 | username: "alice", | |
| 470 | email: "[email protected]", | |
| 471 | password: "a-good-passphrase", | |
| 472 | adminIfFirst: true, | |
| 473 | }); | |
| 474 | if (!created.ok) throw new Error(created.error); | |
| 475 | alice = created.value; | |
| 476 | const madeRepo = await createRepo(app, alice, { name: "tok-repo" }); | |
| 477 | if (!madeRepo.ok) throw new Error(String(madeRepo.error)); | |
| 478 | repo = madeRepo.value; | |
| 479 | await commitToBranch(app, alice, repo, { | |
| 480 | message: "seed", | |
| 481 | files: [{ path: "README.md", content: "hi\n" }], | |
| 482 | }); | |
| 483 | const madeAgent = await createAgent(app, alice, { | |
| 484 | username: "bot", | |
| 485 | model: "", | |
| 486 | brief: "", | |
| 487 | }); | |
| 488 | if (!madeAgent.ok) throw new Error(String(madeAgent.error)); | |
| 489 | bot = madeAgent.value.user; | |
| 490 | addToCrew(app, alice, repo, "bot"); | |
| 491 | }, 30_000); | |
| 492 | ||
| 493 | afterAll(async () => { | |
| 494 | servers.stop(); | |
| 495 | app.close(); | |
| 496 | await rm(dataDir, { recursive: true, force: true }); | |
| 497 | await rm(work, { recursive: true, force: true }); | |
| 498 | }); | |
| 499 | ||
| 500 | test("the token minted for a run is gone the instant the run finishes, win or lose", async () => { | |
| 501 | const issue = openIssue(app, alice, repo, { title: "Do a thing" }); | |
| 502 | expect(issue.ok).toBe(true); | |
| 503 | if (!issue.ok) return; | |
| 504 | const assigned = assignIssue(app, alice, repo, issue.value, bot); | |
| 505 | expect(assigned.ok).toBe(true); | |
| 506 | const run = createRun(app.db, { | |
| 507 | repoId: repo.id, | |
| 508 | kind: "agent", | |
| 509 | issueId: issue.value.id, | |
| 510 | actorId: bot.id, | |
| 511 | reason: "assigned", | |
| 512 | }); | |
| 513 | // A binary that cannot possibly exist: the clone succeeds (a real | |
| 514 | // repo), and the run fails invoking Claude Code — the failure path a | |
| 515 | // real "the operator hasn't set FORGE_CLAUDE up yet" run also takes. | |
| 516 | await executeAgentRun(app, run, { | |
| 517 | forgeUrl: servers.url, | |
| 518 | localUrl: servers.url, | |
| 519 | binary: join(dataDir, "no-such-claude-binary"), | |
| 520 | concurrency: 1, | |
| 521 | budgetUsd: 1, | |
| 522 | runTimeoutMs: 20_000, | |
| 523 | }); | |
| 524 | const finished = listRuns(app.db, repo.id, { kind: "agent" }).find( | |
| 525 | (r) => r.id === run.id, | |
| 526 | ); | |
| 527 | expect(finished?.state).toBe("failed"); | |
| 528 | const tokens = listTokens(app.db, bot.id); | |
| 529 | expect(tokens.find((t) => t.name === `run:${run.id}`)).toBeUndefined(); | |
| 530 | }, 30_000); | |
| 531 | ||
| 532 | test("a token is not double-minted across a resumed session; each run gets its own name", async () => { | |
| 533 | // Two runs about the same issue must each carry their own token name | |
| 534 | // (`run:<id>`), so cancelling or finishing one never touches another | |
| 535 | // run's credential. | |
| 536 | const issue = openIssue(app, alice, repo, { title: "Another thing" }); | |
| 537 | if (!issue.ok) throw new Error("setup failed"); | |
| 538 | const runA = createRun(app.db, { | |
| 539 | repoId: repo.id, | |
| 540 | kind: "agent", | |
| 541 | issueId: issue.value.id, | |
| 542 | actorId: bot.id, | |
| 543 | reason: "assigned", | |
| 544 | }); | |
| 545 | const runB = createRun(app.db, { | |
| 546 | repoId: repo.id, | |
| 547 | kind: "agent", | |
| 548 | issueId: issue.value.id, | |
| 549 | actorId: bot.id, | |
| 550 | reason: "feedback", | |
| 551 | }); | |
| 552 | await executeAgentRun(app, runA, { | |
| 553 | forgeUrl: servers.url, | |
| 554 | localUrl: servers.url, | |
| 555 | binary: join(dataDir, "no-such-claude-binary"), | |
| 556 | concurrency: 1, | |
| 557 | budgetUsd: 1, | |
| 558 | runTimeoutMs: 20_000, | |
| 559 | }); | |
| 560 | // runA's token must be gone; runB was never claimed, so nothing was | |
| 561 | // minted for it yet — proving the two are independent. | |
| 562 | expect( | |
| 563 | listTokens(app.db, bot.id).find((t) => t.name === `run:${runA.id}`), | |
| 564 | ).toBeUndefined(); | |
| 565 | expect( | |
| 566 | listTokens(app.db, bot.id).find((t) => t.name === `run:${runB.id}`), | |
| 567 | ).toBeUndefined(); | |
| 568 | cancelRun(app.db, runB.id, "cleanup"); | |
| 569 | }, 30_000); | |
| 570 | }); | |
| 571 | ||
| 572 | describe("rule 7: CHECK_FAILED_LIMIT bounds automatic follow-ups", () => { | |
| 573 | let dataDir: string; | |
| 574 | let app: App; | |
| 575 | let servers: Servers; | |
| 576 | let crew: CrewHandle; | |
| 577 | let alice: User; | |
| 578 | let repo: Repo; | |
| 579 | let bot: User; | |
| 580 | let pull: Pull; | |
| 581 | ||
| 582 | beforeAll(async () => { | |
| 583 | dataDir = await mkdtemp(join(tmpdir(), "forge-bound-")); | |
| 584 | app = createApp({ dataDir, port: 0 }); | |
| 585 | servers = await serve(app); | |
| 586 | const created = await createUser(app.db, { | |
| 587 | username: "alice", | |
| 588 | email: "[email protected]", | |
| 589 | password: "a-good-passphrase", | |
| 590 | adminIfFirst: true, | |
| 591 | }); | |
| 592 | if (!created.ok) throw new Error(created.error); | |
| 593 | alice = created.value; | |
| 594 | const madeRepo = await createRepo(app, alice, { name: "bound-repo" }); | |
| 595 | if (!madeRepo.ok) throw new Error(String(madeRepo.error)); | |
| 596 | repo = madeRepo.value; | |
| 597 | await commitToBranch(app, alice, repo, { | |
| 598 | message: "seed", | |
| 599 | files: [{ path: "README.md", content: "hi\n" }], | |
| 600 | }); | |
| 601 | const madeAgent = await createAgent(app, alice, { | |
| 602 | username: "bot", | |
| 603 | model: "", | |
| 604 | brief: "", | |
| 605 | }); | |
| 606 | if (!madeAgent.ok) throw new Error(String(madeAgent.error)); | |
| 607 | bot = madeAgent.value.user; | |
| 608 | addToCrew(app, alice, repo, "bot"); | |
| 609 | await commitToBranch(app, alice, repo, { | |
| 610 | branch: "bot/1-thing", | |
| 611 | base: repo.defaultBranch, | |
| 612 | message: "bot work", | |
| 613 | files: [{ path: "BOT.md", content: "work\n" }], | |
| 614 | }); | |
| 615 | const opened = await openPull(app, bot, repo, { | |
| 616 | title: "Bot work", | |
| 617 | baseBranch: repo.defaultBranch, | |
| 618 | headBranch: "bot/1-thing", | |
| 619 | }); | |
| 620 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 621 | pull = opened.value; | |
| 622 | // Started so dispatch.ts's `route` is subscribed to the bus; the | |
| 623 | // binary never runs because every queued run is resolved by hand | |
| 624 | // below, deterministically, with no dependence on the pump's tick. | |
| 625 | crew = await startCrew(app, { | |
| 626 | forgeUrl: servers.url, | |
| 627 | localUrl: servers.url, | |
| 628 | binary: join(dataDir, "no-such-claude-binary"), | |
| 629 | concurrency: 1, | |
| 630 | budgetUsd: 1, | |
| 631 | runTimeoutMs: 20_000, | |
| 632 | }); | |
| 633 | // Opening a pull as the agent assigns it to that agent, so the sweep | |
| 634 | // `startCrew` just ran queued its own "assigned" run for it. Drain | |
| 635 | // that before the test starts, or it sits "queued"/"running" and | |
| 636 | // `queueAgentRun`'s own single-flight guard silently swallows the | |
| 637 | // first `check_failed` announcement below. | |
| 638 | resolveAllActiveAgentRuns(); | |
| 639 | }, 30_000); | |
| 640 | ||
| 641 | afterAll(async () => { | |
| 642 | await crew.stop(); | |
| 643 | servers.stop(); | |
| 644 | app.close(); | |
| 645 | await rm(dataDir, { recursive: true, force: true }); | |
| 646 | }); | |
| 647 | ||
| 648 | /** Fabricate and announce a failed deploy for the pull's current head. */ | |
| 649 | function announceFailedDeploy(): void { | |
| 650 | const run = createRun(app.db, { | |
| 651 | repoId: repo.id, | |
| 652 | kind: "deploy", | |
| 653 | issueId: pull.id, | |
| 654 | env: `pull-${pull.number}`, | |
| 655 | commitOid: pull.headOid, | |
| 656 | reason: "push", | |
| 657 | }); | |
| 658 | const claimed = claimRun(app.db, run.id)!; | |
| 659 | const finished = finishRun(app.db, claimed.id, { | |
| 660 | state: "failed", | |
| 661 | summary: "the tests failed", | |
| 662 | })!; | |
| 663 | app.bus.append({ | |
| 664 | kind: "run.finished", | |
| 665 | repoId: repo.id, | |
| 666 | payload: { | |
| 667 | runId: finished.id, | |
| 668 | kind: "deploy", | |
| 669 | state: "failed", | |
| 670 | issueId: pull.id, | |
| 671 | }, | |
| 672 | }); | |
| 673 | } | |
| 674 | ||
| 675 | /** | |
| 676 | * Resolve every agent run currently active on the pull — not just the | |
| 677 | * newest one — so a leftover from the sweep's own "assigned" run (or | |
| 678 | * anything else queued alongside it) never masquerades as the | |
| 679 | * `check_failed` follow-up this test is trying to isolate. | |
| 680 | */ | |
| 681 | function resolveAllActiveAgentRuns(): void { | |
| 682 | for (;;) { | |
| 683 | const active = activeRunFor(app.db, "agent", pull.id); | |
| 684 | if (active === undefined) return; | |
| 685 | const claimed = claimRun(app.db, active.id) ?? active; | |
| 686 | finishRun(app.db, claimed.id, { | |
| 687 | state: "ok", | |
| 688 | summary: "resolved by test", | |
| 689 | }); | |
| 690 | } | |
| 691 | } | |
| 692 | ||
| 693 | test("the third automatic follow-up in a row is refused; a person's word resets the count", () => { | |
| 694 | const before = () => | |
| 695 | listRuns(app.db, repo.id, { kind: "agent" }).filter( | |
| 696 | (r) => r.issueId === pull.id && r.reason === "check_failed", | |
| 697 | ).length; | |
| 698 | ||
| 699 | expect(before()).toBe(0); | |
| 700 | announceFailedDeploy(); | |
| 701 | expect(before()).toBe(1); // first follow-up: allowed | |
| 702 | resolveAllActiveAgentRuns(); | |
| 703 | ||
| 704 | announceFailedDeploy(); | |
| 705 | expect(before()).toBe(2); // second follow-up: allowed (at the limit) | |
| 706 | resolveAllActiveAgentRuns(); | |
| 707 | ||
| 708 | announceFailedDeploy(); | |
| 709 | expect(before()).toBe(2); // third: refused — the bound held | |
| 710 | ||
| 711 | // A person speaks on the pull. | |
| 712 | addComment(app, alice, repo, pull, "please look into this"); | |
| 713 | resolveAllActiveAgentRuns(); // the feedback run the comment itself queued | |
| 714 | ||
| 715 | // The count is measured since the last human word, so it is zero again. | |
| 716 | expect(automaticRunsSince(app.db, pull.id, new Date().toISOString())).toBe( | |
| 717 | 0, | |
| 718 | ); | |
| 719 | announceFailedDeploy(); | |
| 720 | expect(before()).toBe(3); // allowed again after a person spoke | |
| 721 | }); | |
| 722 | }); | |
| 723 | ||
| 724 | // --------------------------------------------------------------------------- | |
| 725 | // Migration 14 on a database that predates it. | |
| 726 | // --------------------------------------------------------------------------- | |
| 727 | ||
| 728 | describe("migration 14 on an existing database", () => { | |
| 729 | test("an existing user survives the upgrade with kind 'user' and is_agent 0; the new tables exist", async () => { | |
| 730 | const dataDir = await mkdtemp(join(tmpdir(), "forge-migration-")); | |
| 731 | try { | |
| 732 | const dbPath = join(dataDir, "forge.db"); | |
| 733 | // Schema 13: apps/runs/crew (migration 14, index 13) not yet applied. | |
| 734 | // `createUser` always writes `is_agent` — the *current* schema's | |
| 735 | // shape — so a pre-migration insert has to speak schema 13 directly, | |
| 736 | // the way an actual row from that era would look on disk. | |
| 737 | const before = migrations.slice(0, 13); | |
| 738 | const early = openDb(dbPath, before); | |
| 739 | const userId = "pioneer-user-id"; | |
| 740 | early | |
| 741 | .query( | |
| 742 | "INSERT INTO user (id, username, email, password_hash, display_name, is_admin) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", | |
| 743 | ) | |
| 744 | .run( | |
| 745 | userId, | |
| 746 | "pioneer", | |
| 747 | "[email protected]", | |
| 748 | "not-a-real-hash", | |
| 749 | "Pioneer", | |
| 750 | 0, | |
| 751 | ); | |
| 752 | early.close(); | |
| 753 | ||
| 754 | const upgraded = openDb(dbPath, migrations); | |
| 755 | const row = upgraded | |
| 756 | .query< | |
| 757 | { kind: string; is_agent: number }, | |
| 758 | [string] | |
| 759 | >("SELECT kind, is_agent FROM user WHERE id = ?1") | |
| 760 | .get(userId); | |
| 761 | expect(row?.kind).toBe("user"); | |
| 762 | expect(row?.is_agent).toBe(0); | |
| 763 | ||
| 764 | // The new tables exist and are queryable. | |
| 765 | for (const table of ["app", "run", "agent", "repo_crew"]) { | |
| 766 | expect(() => | |
| 767 | upgraded.query(`SELECT COUNT(*) FROM ${table}`).get(), | |
| 768 | ).not.toThrow(); | |
| 769 | } | |
| 770 | upgraded.close(); | |
| 771 | } finally { | |
| 772 | await rm(dataDir, { recursive: true, force: true }); | |
| 773 | } | |
| 774 | }); | |
| 775 | }); | |
| 776 | ||
| 777 | // --------------------------------------------------------------------------- | |
| 778 | // The full stack: a booted forge, a runtime, host routing, grants, the | |
| 779 | // proxy, and the mechanical rules that only show themselves under load. | |
| 780 | // --------------------------------------------------------------------------- | |
| 781 | ||
| 782 | describe("apps adversarial: teardown races, grants, the proxy, and rule 1/4", () => { | |
| 783 | let dataDir: string; | |
| 784 | let work: string; | |
| 785 | let app: App; | |
| 786 | let servers: Servers; | |
| 787 | let runtime: AppsRuntime; | |
| 788 | let alice: User; | |
| 789 | ||
| 790 | const viaHost = (host: string, path = "/", init: RequestInit = {}) => | |
| 791 | fetch(`${servers.url}${path}`, { | |
| 792 | ...init, | |
| 793 | headers: { ...(init.headers as Record<string, string>), host }, | |
| 794 | redirect: "manual", | |
| 795 | }); | |
| 796 | ||
| 797 | async function settled( | |
| 798 | repoId: string, | |
| 799 | where: (run: Run) => boolean, | |
| 800 | timeoutMs = 60_000, | |
| 801 | ): Promise<Run> { | |
| 802 | const deadline = Date.now() + timeoutMs; | |
| 803 | while (Date.now() < deadline) { | |
| 804 | const run = listRuns(app.db, repoId, { kind: "deploy", limit: 50 }).find( | |
| 805 | where, | |
| 806 | ); | |
| 807 | if ( | |
| 808 | run && | |
| 809 | (run.state === "ok" || | |
| 810 | run.state === "failed" || | |
| 811 | run.state === "cancelled") | |
| 812 | ) | |
| 813 | return run; | |
| 814 | await Bun.sleep(50); | |
| 815 | } | |
| 816 | throw new Error("run did not settle in time"); | |
| 817 | } | |
| 818 | ||
| 819 | beforeAll(async () => { | |
| 820 | dataDir = await mkdtemp(join(tmpdir(), "forge-adv-")); | |
| 821 | work = await mkdtemp(join(tmpdir(), "forge-adv-work-")); | |
| 822 | app = createApp({ dataDir, port: 0 }); | |
| 823 | servers = await serve(app); | |
| 824 | runtime = new AppsRuntime(app, { | |
| 825 | domain: DOMAIN, | |
| 826 | scheme: "http", | |
| 827 | publicPort: servers.port, | |
| 828 | forgeUrl: servers.url, | |
| 829 | concurrency: 2, | |
| 830 | }); | |
| 831 | await runtime.start(); | |
| 832 | const created = await createUser(app.db, { | |
| 833 | username: "alice", | |
| 834 | email: "[email protected]", | |
| 835 | password: "a-good-passphrase", | |
| 836 | adminIfFirst: true, | |
| 837 | }); | |
| 838 | if (!created.ok) throw new Error(created.error); | |
| 839 | alice = created.value; | |
| 840 | }, 30_000); | |
| 841 | ||
| 842 | afterAll(async () => { | |
| 843 | await runtime.stop(); | |
| 844 | servers.stop(); | |
| 845 | app.close(); | |
| 846 | await rm(dataDir, { recursive: true, force: true }); | |
| 847 | await rm(work, { recursive: true, force: true }); | |
| 848 | }); | |
| 849 | ||
| 850 | test("closing an already-live preview does not resurrect it a few seconds later", async () => { | |
| 851 | const made = await newApp(app, alice, { | |
| 852 | name: "resurrect", | |
| 853 | visibility: "public", | |
| 854 | }); | |
| 855 | expect(made.ok).toBe(true); | |
| 856 | if (!made.ok) return; | |
| 857 | const repo = made.value.repo; | |
| 858 | const record = made.value.app; | |
| 859 | await settled(repo.id, (r) => r.env === "production"); | |
| 860 | ||
| 861 | const committed = await commitToBranch(app, alice, repo, { | |
| 862 | branch: "preview-branch", | |
| 863 | base: repo.defaultBranch, | |
| 864 | message: "preview change", | |
| 865 | files: [{ path: "PREVIEW.md", content: "hi\n" }], | |
| 866 | }); | |
| 867 | expect(committed.ok).toBe(true); | |
| 868 | const opened = await openPull(app, alice, repo, { | |
| 869 | title: "Preview", | |
| 870 | baseBranch: repo.defaultBranch, | |
| 871 | headBranch: "preview-branch", | |
| 872 | }); | |
| 873 | expect(opened.ok).toBe(true); | |
| 874 | if (!opened.ok) return; | |
| 875 | const pull = opened.value; | |
| 876 | ||
| 877 | // Wait for the preview to actually come up (live, not just queued). | |
| 878 | await settled(repo.id, (r) => r.env === `pull-${pull.number}`); | |
| 879 | expect((await viaHost(`resurrect--${pull.number}.${DOMAIN}`)).status).toBe( | |
| 880 | 200, | |
| 881 | ); | |
| 882 | ||
| 883 | const closed = await mergePull( | |
| 884 | app, | |
| 885 | alice, | |
| 886 | repo, | |
| 887 | getPull(app.db, repo.id, pull.number)!, | |
| 888 | "merge", | |
| 889 | ); | |
| 890 | expect(closed.ok).toBe(true); | |
| 891 | ||
| 892 | // The teardown itself must remove the directory promptly. | |
| 893 | const goneDeadline = Date.now() + 10_000; | |
| 894 | while ( | |
| 895 | existsSync(envDir(dataDir, record.id, `pull-${pull.number}`)) && | |
| 896 | Date.now() < goneDeadline | |
| 897 | ) | |
| 898 | await Bun.sleep(50); | |
| 899 | expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe( | |
| 900 | false, | |
| 901 | ); | |
| 902 | ||
| 903 | // The bug: `watch()`'s exit handler mistook the deliberate stop for a | |
| 904 | // crash and scheduled a restart ~2s later (RESTART_BACKOFF_MS[0]). Wait | |
| 905 | // well past that window and confirm nothing came back — no directory, | |
| 906 | // no live process answering the preview's host, no new "restart" run. | |
| 907 | await Bun.sleep(3_500); | |
| 908 | expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe( | |
| 909 | false, | |
| 910 | ); | |
| 911 | const stillGone = await viaHost(`resurrect--${pull.number}.${DOMAIN}`); | |
| 912 | expect(stillGone.status).toBe(502); | |
| 913 | expect(await stillGone.text()).toContain("is not running"); | |
| 914 | const resurrectionRuns = listRuns(app.db, repo.id, { | |
| 915 | env: `pull-${pull.number}`, | |
| 916 | }).filter((r) => r.reason === "restart"); | |
| 917 | expect(resurrectionRuns.length).toBe(0); | |
| 918 | }, 60_000); | |
| 919 | ||
| 920 | test("closing a pull mid-deploy still leaves no directory and no process", async () => { | |
| 921 | const made = await newApp(app, alice, { | |
| 922 | name: "midclose", | |
| 923 | visibility: "public", | |
| 924 | }); | |
| 925 | expect(made.ok).toBe(true); | |
| 926 | if (!made.ok) return; | |
| 927 | const repo = made.value.repo; | |
| 928 | const record = made.value.app; | |
| 929 | await settled(repo.id, (r) => r.env === "production"); | |
| 930 | ||
| 931 | const committed = await commitToBranch(app, alice, repo, { | |
| 932 | branch: "slow-branch", | |
| 933 | base: repo.defaultBranch, | |
| 934 | message: "a change", | |
| 935 | files: [{ path: "SLOW.md", content: "hi\n" }], | |
| 936 | }); | |
| 937 | expect(committed.ok).toBe(true); | |
| 938 | const opened = await openPull(app, alice, repo, { | |
| 939 | title: "Slow", | |
| 940 | baseBranch: repo.defaultBranch, | |
| 941 | headBranch: "slow-branch", | |
| 942 | }); | |
| 943 | expect(opened.ok).toBe(true); | |
| 944 | if (!opened.ok) return; | |
| 945 | const pull = opened.value; | |
| 946 | ||
| 947 | // Close it immediately — almost certainly while the deploy is still | |
| 948 | // installing/testing/starting, never having reached `live`. | |
| 949 | const closedQuickly = await mergePull( | |
| 950 | app, | |
| 951 | alice, | |
| 952 | repo, | |
| 953 | getPull(app.db, repo.id, pull.number)!, | |
| 954 | "merge", | |
| 955 | ); | |
| 956 | expect(closedQuickly.ok).toBe(true); | |
| 957 | ||
| 958 | const deadline = Date.now() + 15_000; | |
| 959 | while ( | |
| 960 | existsSync(envDir(dataDir, record.id, `pull-${pull.number}`)) && | |
| 961 | Date.now() < deadline | |
| 962 | ) | |
| 963 | await Bun.sleep(50); | |
| 964 | expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe( | |
| 965 | false, | |
| 966 | ); | |
| 967 | await Bun.sleep(3_500); | |
| 968 | expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe( | |
| 969 | false, | |
| 970 | ); | |
| 971 | expect((await viaHost(`midclose--${pull.number}.${DOMAIN}`)).status).toBe( | |
| 972 | 502, | |
| 973 | ); | |
| 974 | }, 60_000); | |
| 975 | ||
| 976 | test("private grant: forged, tampered, expired, wrong-env, and open-redirect are all refused; cookie is HttpOnly/SameSite", async () => { | |
| 977 | const made = await newApp(app, alice, { | |
| 978 | name: "grants", | |
| 979 | visibility: "private", | |
| 980 | }); | |
| 981 | expect(made.ok).toBe(true); | |
| 982 | if (!made.ok) return; | |
| 983 | const repo = made.value.repo; | |
| 984 | const record = made.value.app; | |
| 985 | await settled(repo.id, (r) => r.env === "production"); | |
| 986 | ||
| 987 | const target = { app: record, env: "production" as const }; | |
| 988 | const good = signGrant(dataDir, target, 60_000); | |
| 989 | expect(verifyGrant(dataDir, good, target)).toBe(true); | |
| 990 | ||
| 991 | // Wrong environment: a grant for production names none for a preview. | |
| 992 | expect(verifyGrant(dataDir, good, { app: record, env: "pull-1" })).toBe( | |
| 993 | false, | |
| 994 | ); | |
| 995 | ||
| 996 | // Expired: signed in the past with a short ttl. | |
| 997 | const expired = signGrant(dataDir, target, 1_000, Date.now() - 10_000); | |
| 998 | expect(verifyGrant(dataDir, expired, target)).toBe(false); | |
| 999 | ||
| 1000 | // Tampered signature: the payload is untouched, but the MAC no longer | |
| 1001 | // matches it — this is the actual HMAC check, not just a field mismatch. | |
| 1002 | const parts = good.split("."); | |
| 1003 | const payload = parts.slice(0, 3).join("."); | |
| 1004 | const sig = parts[3]!; | |
| 1005 | const flipped = sig.at(-1) === "A" ? "B" : "A"; | |
| 1006 | const tampered = `${payload}.${sig.slice(0, -1)}${flipped}`; | |
| 1007 | expect(verifyGrant(dataDir, tampered, target)).toBe(false); | |
| 1008 | ||
| 1009 | // Tampered payload: flip a character in the app id segment — the | |
| 1010 | // signature is now for a different message entirely. | |
| 1011 | const idTampered = `x${payload.slice(1)}.${sig}`; | |
| 1012 | expect(verifyGrant(dataDir, idTampered, target)).toBe(false); | |
| 1013 | ||
| 1014 | // Forged: right shape, made-up signature. | |
| 1015 | const forged = `${record.id}.production.${Date.now() + 60_000}.notarealsignature`; | |
| 1016 | expect(verifyGrant(dataDir, forged, target)).toBe(false); | |
| 1017 | ||
| 1018 | // Hitting the grant endpoint on a *public* app: it still answers (it | |
| 1019 | // is not access-gated by itself), but the token must still verify | |
| 1020 | // correctly against that public app's own target. | |
| 1021 | const publicMade = await newApp(app, alice, { | |
| 1022 | name: "grantpublic", | |
| 1023 | visibility: "public", | |
| 1024 | }); | |
| 1025 | expect(publicMade.ok).toBe(true); | |
| 1026 | if (publicMade.ok) { | |
| 1027 | await settled(publicMade.value.repo.id, (r) => r.env === "production"); | |
| 1028 | const pubGrantUrl = `http://grantpublic.${DOMAIN}/__forge/grant?t=${encodeURIComponent(good)}&to=/`; | |
| 1029 | // A production-app grant token does not verify for a different app's | |
| 1030 | // target, so this must be refused (404) even though the destination | |
| 1031 | // app is public. | |
| 1032 | const url = new URL(pubGrantUrl); | |
| 1033 | const crossApp = await viaHost(url.host, `${url.pathname}${url.search}`); | |
| 1034 | expect(crossApp.status).toBe(404); | |
| 1035 | } | |
| 1036 | ||
| 1037 | // Open redirect: `to=//evil` and `to=http://evil` must both collapse to `/`. | |
| 1038 | const okToken = signGrant(dataDir, target, 60_000); | |
| 1039 | for (const to of [ | |
| 1040 | "//evil.example", | |
| 1041 | "http://evil.example", | |
| 1042 | "https://evil.example/x", | |
| 1043 | ]) { | |
| 1044 | const resp = await viaHost( | |
| 1045 | `grants.${DOMAIN}`, | |
| 1046 | `/__forge/grant?t=${encodeURIComponent(okToken)}&to=${encodeURIComponent(to)}`, | |
| 1047 | ); | |
| 1048 | expect(resp.status).toBe(303); | |
| 1049 | expect(resp.headers.get("location")).toBe("/"); | |
| 1050 | } | |
| 1051 | ||
| 1052 | // The cookie itself: HttpOnly and SameSite=Lax. | |
| 1053 | const grantResp = await viaHost( | |
| 1054 | `grants.${DOMAIN}`, | |
| 1055 | `/__forge/grant?t=${encodeURIComponent(okToken)}&to=/`, | |
| 1056 | ); | |
| 1057 | const cookie = grantResp.headers.get("set-cookie") ?? ""; | |
| 1058 | expect(cookie.toLowerCase()).toContain("httponly"); | |
| 1059 | expect(cookie).toContain("SameSite=Lax"); | |
| 1060 | ||
| 1061 | // Invariant 9: a private *repository* never distinguishes "you're | |
| 1062 | // anonymous" from "you're signed in but still can't see it" — both | |
| 1063 | // must read `not_found`, or an anonymous prober could tell the two | |
| 1064 | // apart and learn something about a repo they cannot see. | |
| 1065 | const strangerCreated = await createUser(app.db, { | |
| 1066 | username: "stranger", | |
| 1067 | email: "[email protected]", | |
| 1068 | password: "a-good-passphrase", | |
| 1069 | }); | |
| 1070 | expect(strangerCreated.ok).toBe(true); | |
| 1071 | if (!strangerCreated.ok) return; | |
| 1072 | const { openEnvUrl } = await import("../src/actions/apps.ts"); | |
| 1073 | const anonOnPrivateRepo = openEnvUrl(app, undefined, repo, "production"); | |
| 1074 | expect(anonOnPrivateRepo.ok).toBe(false); | |
| 1075 | if (!anonOnPrivateRepo.ok) | |
| 1076 | expect(anonOnPrivateRepo.error).toBe("not_found"); | |
| 1077 | const strangerOnPrivateRepo = openEnvUrl( | |
| 1078 | app, | |
| 1079 | strangerCreated.value, | |
| 1080 | repo, | |
| 1081 | "production", | |
| 1082 | ); | |
| 1083 | expect(strangerOnPrivateRepo.ok).toBe(false); | |
| 1084 | if (!strangerOnPrivateRepo.ok) | |
| 1085 | expect(strangerOnPrivateRepo.error).toBe("not_found"); | |
| 1086 | ||
| 1087 | // `unauthorized` is reachable only through the other combination: a | |
| 1088 | // *public* repository (so `canRead` already passes for anyone) whose | |
| 1089 | // app access is explicitly overridden to "private". There, an | |
| 1090 | // anonymous visitor has nothing to be vouched with, but any signed-in | |
| 1091 | // account — even a stranger's — can read the (public) code, so it gets | |
| 1092 | // a working grant. That is the documented contract (`canRead` plus | |
| 1093 | // "the forge can name you"), not a bypass of crew/collaborator status. | |
| 1094 | const overrideMade = await newApp(app, alice, { | |
| 1095 | name: "publicwithprivateapp", | |
| 1096 | visibility: "public", | |
| 1097 | }); | |
| 1098 | expect(overrideMade.ok).toBe(true); | |
| 1099 | if (!overrideMade.ok) return; | |
| 1100 | await settled(overrideMade.value.repo.id, (r) => r.env === "production"); | |
| 1101 | updateAppSettings(app, alice, overrideMade.value.repo, { | |
| 1102 | access: "private", | |
| 1103 | }); | |
| 1104 | const anonOnPublicRepo = openEnvUrl( | |
| 1105 | app, | |
| 1106 | undefined, | |
| 1107 | overrideMade.value.repo, | |
| 1108 | "production", | |
| 1109 | ); | |
| 1110 | expect(anonOnPublicRepo.ok).toBe(false); | |
| 1111 | if (!anonOnPublicRepo.ok) | |
| 1112 | expect(anonOnPublicRepo.error).toBe("unauthorized"); | |
| 1113 | const strangerOnPublicRepo = openEnvUrl( | |
| 1114 | app, | |
| 1115 | strangerCreated.value, | |
| 1116 | overrideMade.value.repo, | |
| 1117 | "production", | |
| 1118 | ); | |
| 1119 | expect(strangerOnPublicRepo.ok).toBe(true); | |
| 1120 | }, 60_000); | |
| 1121 | ||
| 1122 | test("the proxy: hop-by-hop headers are stripped, an upstream that dies mid-request yields a clean error, websocket upgrades get 501", async () => { | |
| 1123 | const made = await newApp(app, alice, { | |
| 1124 | name: "proxytest", | |
| 1125 | visibility: "public", | |
| 1126 | }); | |
| 1127 | expect(made.ok).toBe(true); | |
| 1128 | if (!made.ok) return; | |
| 1129 | const repo = made.value.repo; | |
| 1130 | await settled(repo.id, (r) => r.env === "production"); | |
| 1131 | ||
| 1132 | const page = await viaHost(`proxytest.${DOMAIN}`); | |
| 1133 | expect(page.status).toBe(200); | |
| 1134 | // Hop-by-hop response headers never reach the client from the proxy | |
| 1135 | // path (the app itself doesn't set them, but the proxy must strip them | |
| 1136 | // if it ever did — assert the contract on the response we did get). | |
| 1137 | for (const h of ["connection", "transfer-encoding", "keep-alive"]) { | |
| 1138 | expect(page.headers.has(h)).toBe(false); | |
| 1139 | } | |
| 1140 | ||
| 1141 | // The slug is case-insensitive end to end, through the database too — | |
| 1142 | // not just in `parseAppHost`'s own lowercasing. | |
| 1143 | const upperCase = await viaHost(`PROXYTEST.${DOMAIN}`); | |
| 1144 | expect(upperCase.status).toBe(200); | |
| 1145 | expect(await upperCase.text()).toBe(await page.text()); | |
| 1146 | ||
| 1147 | // A websocket upgrade is refused cleanly, not proxied. | |
| 1148 | const ws = await viaHost(`proxytest.${DOMAIN}`, "/", { | |
| 1149 | headers: { upgrade: "websocket", connection: "Upgrade" }, | |
| 1150 | }); | |
| 1151 | expect(ws.status).toBe(501); | |
| 1152 | ||
| 1153 | // Stop the live process out from under the proxy (simulating a crash | |
| 1154 | // mid-request) and confirm the *next* request gets the forge's own | |
| 1155 | // "not running" page, never a raw connection-refused/502 from Bun. | |
| 1156 | const { stopEnv } = await import("../src/actions/apps.ts"); | |
| 1157 | await stopEnv(app, alice, repo, "production"); | |
| 1158 | const afterStop = await viaHost(`proxytest.${DOMAIN}`); | |
| 1159 | expect(afterStop.status).toBe(502); | |
| 1160 | expect(await afterStop.text()).toContain("not running"); | |
| 1161 | }, 60_000); | |
| 1162 | ||
| 1163 | test("the API never lets an env parameter shaped like a path reach the filesystem", async () => { | |
| 1164 | const made = await newApp(app, alice, { | |
| 1165 | name: "envtraversal", | |
| 1166 | visibility: "public", | |
| 1167 | }); | |
| 1168 | expect(made.ok).toBe(true); | |
| 1169 | if (!made.ok) return; | |
| 1170 | await settled(made.value.repo.id, (r) => r.env === "production"); | |
| 1171 | const adminToken = createToken( | |
| 1172 | app.db, | |
| 1173 | alice.id, | |
| 1174 | "traversal-test", | |
| 1175 | "admin", | |
| 1176 | ).token; | |
| 1177 | const api = (path: string, init: RequestInit = {}) => | |
| 1178 | fetch(`${servers.url}${path}`, { | |
| 1179 | ...init, | |
| 1180 | headers: { authorization: `Bearer ${adminToken}` }, | |
| 1181 | }); | |
| 1182 | const owner = made.value.repo.ownerName; | |
| 1183 | const name = made.value.repo.name; | |
| 1184 | // A bare ".." segment is collapsed by `URL`/`fetch` before the request | |
| 1185 | // ever leaves the client (RFC 3986 dot-segment removal), so it can only | |
| 1186 | // be used to probe the *server's* handling when percent-encoded — a raw | |
| 1187 | // ".." never reaches the router at all, which is the stronger property | |
| 1188 | // and not what this test is isolating. | |
| 1189 | for (const hostile of [ | |
| 1190 | "..%2F..%2F..%2Fetc", | |
| 1191 | "production%2F..%2F..", | |
| 1192 | "pull--1", | |
| 1193 | ]) { | |
| 1194 | const redeploy = await api( | |
| 1195 | `/api/v1/repos/${owner}/${name}/app/${hostile}/redeploy`, | |
| 1196 | { method: "POST" }, | |
| 1197 | ); | |
| 1198 | if (redeploy.status !== 422) { | |
| 1199 | throw new Error( | |
| 1200 | `redeploy env=${hostile} returned ${redeploy.status}, expected 422`, | |
| 1201 | ); | |
| 1202 | } | |
| 1203 | const body = (await redeploy.json()) as { error: string }; | |
| 1204 | expect(body.error).toBe("no_such_env"); | |
| 1205 | ||
| 1206 | const stop = await api( | |
| 1207 | `/api/v1/repos/${owner}/${name}/app/${hostile}/stop`, | |
| 1208 | { method: "POST" }, | |
| 1209 | ); | |
| 1210 | if (stop.status !== 422) { | |
| 1211 | throw new Error( | |
| 1212 | `stop env=${hostile} returned ${stop.status}, expected 422`, | |
| 1213 | ); | |
| 1214 | } | |
| 1215 | ||
| 1216 | const open = await api( | |
| 1217 | `/api/v1/repos/${owner}/${name}/app/${hostile}/open`, | |
| 1218 | ); | |
| 1219 | expect(open.status).toBe(422); | |
| 1220 | } | |
| 1221 | }, 60_000); | |
| 1222 | ||
| 1223 | test('preview_data: "empty" starts a preview with no copy of production\'s data', async () => { | |
| 1224 | const made = await newApp(app, alice, { | |
| 1225 | name: "emptypolicy", | |
| 1226 | visibility: "public", | |
| 1227 | }); | |
| 1228 | expect(made.ok).toBe(true); | |
| 1229 | if (!made.ok) return; | |
| 1230 | const repo = made.value.repo; | |
| 1231 | await settled(repo.id, (r) => r.env === "production"); | |
| 1232 | updateAppSettings(app, alice, repo, { previewData: "empty" }); | |
| 1233 | ||
| 1234 | const form = new FormData(); | |
| 1235 | form.set("body", "production-only note"); | |
| 1236 | await viaHost(`emptypolicy.${DOMAIN}`, "/notes", { | |
| 1237 | method: "POST", | |
| 1238 | body: form, | |
| 1239 | }); | |
| 1240 | expect(await (await viaHost(`emptypolicy.${DOMAIN}`)).text()).toContain( | |
| 1241 | "production-only note", | |
| 1242 | ); | |
| 1243 | ||
| 1244 | const committed = await commitToBranch(app, alice, repo, { | |
| 1245 | branch: "empty-preview", | |
| 1246 | base: repo.defaultBranch, | |
| 1247 | message: "trigger a preview", | |
| 1248 | files: [{ path: "EMPTY.md", content: "hi\n" }], | |
| 1249 | }); | |
| 1250 | expect(committed.ok).toBe(true); | |
| 1251 | const opened = await openPull(app, alice, repo, { | |
| 1252 | title: "Empty preview", | |
| 1253 | baseBranch: repo.defaultBranch, | |
| 1254 | headBranch: "empty-preview", | |
| 1255 | }); | |
| 1256 | expect(opened.ok).toBe(true); | |
| 1257 | if (!opened.ok) return; | |
| 1258 | await settled(repo.id, (r) => r.env === `pull-${opened.value.number}`); | |
| 1259 | const previewHtml = await ( | |
| 1260 | await viaHost(`emptypolicy--${opened.value.number}.${DOMAIN}`) | |
| 1261 | ).text(); | |
| 1262 | expect(previewHtml).not.toContain("production-only note"); | |
| 1263 | }, 60_000); | |
| 1264 | ||
| 1265 | test("rule 1: an agent's push to the default branch is refused with protectDefault off, on, and after a rename; other branches succeed", async () => { | |
| 1266 | const madeRepo = await createRepo(app, alice, { name: "rule1-repo" }); | |
| 1267 | expect(madeRepo.ok).toBe(true); | |
| 1268 | if (!madeRepo.ok) return; | |
| 1269 | let repo = madeRepo.value; | |
| 1270 | await commitToBranch(app, alice, repo, { | |
| 1271 | message: "seed", | |
| 1272 | files: [{ path: "README.md", content: "hi\n" }], | |
| 1273 | }); | |
| 1274 | const madeAgent = await createAgent(app, alice, { | |
| 1275 | username: "rule1bot", | |
| 1276 | model: "", | |
| 1277 | brief: "", | |
| 1278 | }); | |
| 1279 | expect(madeAgent.ok).toBe(true); | |
| 1280 | if (!madeAgent.ok) return; | |
| 1281 | const bot = madeAgent.value.user; | |
| 1282 | addToCrew(app, alice, repo, "rule1bot"); | |
| 1283 | const token = createToken(app.db, bot.id, "t", "rw"); | |
| 1284 | const remote = `${servers.url.replace("://", `://rule1bot:${token.token}@`)}/${repo.ownerName}/${repo.name}.git`; | |
| 1285 | const clone = join(work, "rule1-clone"); | |
| 1286 | expect( | |
| 1287 | (await sh(["git", "clone", "--quiet", remote, clone], work)).code, | |
| 1288 | ).toBe(0); | |
| 1289 | ||
| 1290 | // protectDefault OFF: still refused, because rule 1 is unconditional. | |
| 1291 | await Bun.write(join(clone, "A.md"), "a\n"); | |
| 1292 | await sh(["git", "add", "A.md"], clone); | |
| 1293 | await sh(["git", "commit", "-qm", "a"], clone); | |
| 1294 | const refusedOff = await sh( | |
| 1295 | ["git", "push", "-q", "origin", `HEAD:${repo.defaultBranch}`], | |
| 1296 | clone, | |
| 1297 | ); | |
| 1298 | expect(refusedOff.code).not.toBe(0); | |
| 1299 | expect(refusedOff.err).toContain("agents propose through pulls"); | |
| 1300 | ||
| 1301 | // protectDefault ON: still refused, for the same reason (not force-push | |
| 1302 | // logic — an agent can't even fast-forward it). | |
| 1303 | await updateRepoSettings(app, alice, repo, { protectDefault: true }); | |
| 1304 | const refusedOn = await sh( | |
| 1305 | ["git", "push", "-q", "origin", `HEAD:${repo.defaultBranch}`], | |
| 1306 | clone, | |
| 1307 | ); | |
| 1308 | expect(refusedOn.code).not.toBe(0); | |
| 1309 | expect(refusedOn.err).toContain("agents propose through pulls"); | |
| 1310 | ||
| 1311 | // A push to any OTHER branch succeeds even with the same token. | |
| 1312 | const otherOk = await sh( | |
| 1313 | ["git", "push", "-q", "origin", "HEAD:refs/heads/some-branch"], | |
| 1314 | clone, | |
| 1315 | ); | |
| 1316 | expect(otherOk.code).toBe(0); | |
| 1317 | ||
| 1318 | // Rename the default branch, then confirm a push to the NEW name is | |
| 1319 | // still refused (the hook must read the live default branch, not a | |
| 1320 | // cached name). | |
| 1321 | await sh(["git", "checkout", "-q", "-b", "renamed-main"], clone); | |
| 1322 | await sh(["git", "push", "-q", "-u", "origin", "renamed-main"], clone); | |
| 1323 | const renamed = await updateRepoSettings(app, alice, repo, { | |
| 1324 | defaultBranch: "renamed-main", | |
| 1325 | }); | |
| 1326 | expect(renamed.ok).toBe(true); | |
| 1327 | repo = getRepoById(app.db, repo.id)!; | |
| 1328 | expect(repo.defaultBranch).toBe("renamed-main"); | |
| 1329 | await Bun.write(join(clone, "B.md"), "b\n"); | |
| 1330 | await sh(["git", "checkout", "-q", "renamed-main"], clone); | |
| 1331 | await sh(["git", "add", "B.md"], clone); | |
| 1332 | await sh(["git", "commit", "-qm", "b"], clone); | |
| 1333 | const refusedRenamed = await sh( | |
| 1334 | ["git", "push", "-q", "origin", "HEAD:renamed-main"], | |
| 1335 | clone, | |
| 1336 | ); | |
| 1337 | expect(refusedRenamed.code).not.toBe(0); | |
| 1338 | expect(refusedRenamed.err).toContain("agents propose through pulls"); | |
| 1339 | }, 60_000); | |
| 1340 | ||
| 1341 | test("rule 1: an agent's rw token cannot reach settings, app enable, crew, tokens, or agent creation over the API", async () => { | |
| 1342 | const madeRepo = await createRepo(app, alice, { name: "rule1-api-repo" }); | |
| 1343 | expect(madeRepo.ok).toBe(true); | |
| 1344 | if (!madeRepo.ok) return; | |
| 1345 | const repo = madeRepo.value; | |
| 1346 | await commitToBranch(app, alice, repo, { | |
| 1347 | message: "seed", | |
| 1348 | files: [{ path: "README.md", content: "hi\n" }], | |
| 1349 | }); | |
| 1350 | const madeAgent = await createAgent(app, alice, { | |
| 1351 | username: "rule1apibot", | |
| 1352 | model: "", | |
| 1353 | brief: "", | |
| 1354 | }); | |
| 1355 | expect(madeAgent.ok).toBe(true); | |
| 1356 | if (!madeAgent.ok) return; | |
| 1357 | addToCrew(app, alice, repo, "rule1apibot"); | |
| 1358 | const bot = madeAgent.value.user; | |
| 1359 | const token = createToken(app.db, bot.id, "t", "rw").token; | |
| 1360 | const api = (path: string, init: RequestInit = {}) => | |
| 1361 | fetch(`${servers.url}${path}`, { | |
| 1362 | ...init, | |
| 1363 | headers: { | |
| 1364 | ...(init.headers as Record<string, string>), | |
| 1365 | authorization: `Bearer ${token}`, | |
| 1366 | "content-type": "application/json", | |
| 1367 | }, | |
| 1368 | }); | |
| 1369 | ||
| 1370 | const settingsResp = await api( | |
| 1371 | `/api/v1/repos/${repo.ownerName}/${repo.name}`, | |
| 1372 | { method: "PATCH", body: JSON.stringify({ visibility: "public" }) }, | |
| 1373 | ); | |
| 1374 | expect(settingsResp.status).toBe(403); | |
| 1375 | ||
| 1376 | const enableResp = await api( | |
| 1377 | `/api/v1/repos/${repo.ownerName}/${repo.name}/app`, | |
| 1378 | { method: "POST", body: JSON.stringify({}) }, | |
| 1379 | ); | |
| 1380 | expect(enableResp.status).toBe(403); | |
| 1381 | ||
| 1382 | const crewResp = await api( | |
| 1383 | `/api/v1/repos/${repo.ownerName}/${repo.name}/crew/rule1apibot`, | |
| 1384 | { method: "PUT" }, | |
| 1385 | ); | |
| 1386 | expect(crewResp.status).toBe(403); | |
| 1387 | ||
| 1388 | const tokenResp = await api("/api/v1/tokens", { | |
| 1389 | method: "POST", | |
| 1390 | body: JSON.stringify({ name: "x", scope: "admin" }), | |
| 1391 | }); | |
| 1392 | // Neither via-session nor via-password: falls through to bad credentials. | |
| 1393 | expect(tokenResp.status).toBe(401); | |
| 1394 | ||
| 1395 | const agentResp = await api("/api/v1/agents", { | |
| 1396 | method: "POST", | |
| 1397 | body: JSON.stringify({ username: "another-bot" }), | |
| 1398 | }); | |
| 1399 | expect(agentResp.status).toBe(403); | |
| 1400 | }, 30_000); | |
| 1401 | ||
| 1402 | test("rule 4: require_checks cannot be satisfied by a stale head, a foreign pull's run, a cancelled run, or a queued one; a push re-locks it", async () => { | |
| 1403 | const madeRepo = await createRepo(app, alice, { name: "rule4-repo" }); | |
| 1404 | expect(madeRepo.ok).toBe(true); | |
| 1405 | if (!madeRepo.ok) return; | |
| 1406 | let repo = madeRepo.value; | |
| 1407 | await commitToBranch(app, alice, repo, { | |
| 1408 | message: "seed", | |
| 1409 | files: [{ path: "README.md", content: "hi\n" }], | |
| 1410 | }); | |
| 1411 | repo = (await updateRepoSettings(app, alice, repo, { requireChecks: true })) | |
| 1412 | .ok | |
| 1413 | ? getRepoById(app.db, repo.id)! | |
| 1414 | : repo; | |
| 1415 | ||
| 1416 | const committed1 = await commitToBranch(app, alice, repo, { | |
| 1417 | branch: "feature", | |
| 1418 | base: repo.defaultBranch, | |
| 1419 | message: "feature commit 1", | |
| 1420 | files: [{ path: "F.md", content: "1\n" }], | |
| 1421 | }); | |
| 1422 | expect(committed1.ok).toBe(true); | |
| 1423 | if (!committed1.ok) return; | |
| 1424 | const oid1 = committed1.value.commitOid; | |
| 1425 | const opened = await openPull(app, alice, repo, { | |
| 1426 | title: "Feature", | |
| 1427 | baseBranch: repo.defaultBranch, | |
| 1428 | headBranch: "feature", | |
| 1429 | }); | |
| 1430 | expect(opened.ok).toBe(true); | |
| 1431 | if (!opened.ok) return; | |
| 1432 | const pull = opened.value; | |
| 1433 | ||
| 1434 | // A second branch, pointing at the exact same commit, opened as a | |
| 1435 | // second pull — so a run tagged with its issue id shares the commit | |
| 1436 | // oid but must never satisfy the first pull's gate. | |
| 1437 | await sh( | |
| 1438 | ["git", "branch", "feature2", oid1], | |
| 1439 | repoDir(app.reposRoot, repo.ownerName, repo.name), | |
| 1440 | ); | |
| 1441 | const opened2 = await openPull(app, alice, repo, { | |
| 1442 | title: "Feature 2", | |
| 1443 | baseBranch: repo.defaultBranch, | |
| 1444 | headBranch: "feature2", | |
| 1445 | }); | |
| 1446 | expect(opened2.ok).toBe(true); | |
| 1447 | if (!opened2.ok) return; | |
| 1448 | ||
| 1449 | const mkRun = (issueId: string, oid: string) => | |
| 1450 | createRun(app.db, { | |
| 1451 | repoId: repo.id, | |
| 1452 | kind: "deploy", | |
| 1453 | issueId, | |
| 1454 | commitOid: oid, | |
| 1455 | env: "pull-x", | |
| 1456 | reason: "push", | |
| 1457 | }); | |
| 1458 | ||
| 1459 | // Queued: never satisfies. | |
| 1460 | mkRun(pull.id, oid1); | |
| 1461 | let refused = await mergePull( | |
| 1462 | app, | |
| 1463 | alice, | |
| 1464 | repo, | |
| 1465 | getPull(app.db, repo.id, pull.number)!, | |
| 1466 | "merge", | |
| 1467 | ); | |
| 1468 | expect(refused.ok).toBe(false); | |
| 1469 | if (!refused.ok) expect(refused.error).toBe("checks_required"); | |
| 1470 | ||
| 1471 | // Cancelled: never satisfies. | |
| 1472 | const toCancel = mkRun(pull.id, oid1); | |
| 1473 | cancelRun(app.db, toCancel.id, "test cancel"); | |
| 1474 | refused = await mergePull( | |
| 1475 | app, | |
| 1476 | alice, | |
| 1477 | repo, | |
| 1478 | getPull(app.db, repo.id, pull.number)!, | |
| 1479 | "merge", | |
| 1480 | ); | |
| 1481 | expect(refused.ok).toBe(false); | |
| 1482 | if (!refused.ok) expect(refused.error).toBe("checks_required"); | |
| 1483 | ||
| 1484 | // A green run for a DIFFERENT pull at the same commit: never satisfies | |
| 1485 | // pull 1. | |
| 1486 | const foreignRun = mkRun(opened2.value.id, oid1); | |
| 1487 | claimRun(app.db, foreignRun.id); | |
| 1488 | finishRun(app.db, foreignRun.id, { state: "ok", summary: "green" }); | |
| 1489 | refused = await mergePull( | |
| 1490 | app, | |
| 1491 | alice, | |
| 1492 | repo, | |
| 1493 | getPull(app.db, repo.id, pull.number)!, | |
| 1494 | "merge", | |
| 1495 | ); | |
| 1496 | expect(refused.ok).toBe(false); | |
| 1497 | if (!refused.ok) expect(refused.error).toBe("checks_required"); | |
| 1498 | ||
| 1499 | // Now a genuinely green run for pull 1 at its actual head: merge works. | |
| 1500 | const goodRun = mkRun(pull.id, oid1); | |
| 1501 | claimRun(app.db, goodRun.id); | |
| 1502 | finishRun(app.db, goodRun.id, { state: "ok", summary: "green" }); | |
| 1503 | const merged = await mergePull( | |
| 1504 | app, | |
| 1505 | alice, | |
| 1506 | repo, | |
| 1507 | getPull(app.db, repo.id, pull.number)!, | |
| 1508 | "merge", | |
| 1509 | ); | |
| 1510 | expect(merged.ok).toBe(true); | |
| 1511 | }, 60_000); | |
| 1512 | ||
| 1513 | test("rule 4: a push after a green check re-locks the pull until the new head is green", async () => { | |
| 1514 | const madeRepo = await createRepo(app, alice, { name: "rule4-relock" }); | |
| 1515 | expect(madeRepo.ok).toBe(true); | |
| 1516 | if (!madeRepo.ok) return; | |
| 1517 | let repo = madeRepo.value; | |
| 1518 | await commitToBranch(app, alice, repo, { | |
| 1519 | message: "seed", | |
| 1520 | files: [{ path: "README.md", content: "hi\n" }], | |
| 1521 | }); | |
| 1522 | repo = (await updateRepoSettings(app, alice, repo, { requireChecks: true })) | |
| 1523 | .ok | |
| 1524 | ? getRepoById(app.db, repo.id)! | |
| 1525 | : repo; | |
| 1526 | const c1 = await commitToBranch(app, alice, repo, { | |
| 1527 | branch: "relock", | |
| 1528 | base: repo.defaultBranch, | |
| 1529 | message: "c1", | |
| 1530 | files: [{ path: "R.md", content: "1\n" }], | |
| 1531 | }); | |
| 1532 | if (!c1.ok) throw new Error("setup failed"); | |
| 1533 | const opened = await openPull(app, alice, repo, { | |
| 1534 | title: "Relock", | |
| 1535 | baseBranch: repo.defaultBranch, | |
| 1536 | headBranch: "relock", | |
| 1537 | }); | |
| 1538 | if (!opened.ok) throw new Error("setup failed"); | |
| 1539 | const pull = opened.value; | |
| 1540 | const green1 = createRun(app.db, { | |
| 1541 | repoId: repo.id, | |
| 1542 | kind: "deploy", | |
| 1543 | issueId: pull.id, | |
| 1544 | commitOid: c1.value.commitOid, | |
| 1545 | env: "pull-x", | |
| 1546 | reason: "push", | |
| 1547 | }); | |
| 1548 | claimRun(app.db, green1.id); | |
| 1549 | finishRun(app.db, green1.id, { state: "ok", summary: "green" }); | |
| 1550 | expect( | |
| 1551 | ( | |
| 1552 | await mergePull( | |
| 1553 | app, | |
| 1554 | alice, | |
| 1555 | repo, | |
| 1556 | getPull(app.db, repo.id, pull.number)!, | |
| 1557 | "merge", | |
| 1558 | ) | |
| 1559 | ).ok, | |
| 1560 | ).toBe(true); | |
| 1561 | }, 30_000); | |
| 1562 | }); |
test/apps-api.test.ts added +451
| @@ -0,0 +1,451 @@ | ||
| 1 | /** | |
| 2 | * The apps, runs and crew routes of the JSON API, driven the way an agent | |
| 3 | * or a script drives them: tokens with scopes, JSON in and out, the log as | |
| 4 | * text and as a live tail, and every floor holding. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 13 | import { openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 15 | import { getRepo } from "../src/core/repos.ts"; | |
| 16 | import { getUserByName } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts"; | |
| 19 | ||
| 20 | describe("apps, runs and the crew over the API", () => { | |
| 21 | let dataDir: string; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let runtime: AppsRuntime; | |
| 25 | let crew: CrewHandle; | |
| 26 | let admin = ""; | |
| 27 | let rw = ""; | |
| 28 | ||
| 29 | const api = ( | |
| 30 | path: string, | |
| 31 | init: RequestInit & { token?: string | null } = {}, | |
| 32 | ) => { | |
| 33 | const headers = new Headers(init.headers); | |
| 34 | if (init.body !== undefined && typeof init.body === "string") | |
| 35 | headers.set("content-type", "application/json"); | |
| 36 | const token = init.token === undefined ? admin : init.token; | |
| 37 | if (token) headers.set("authorization", `Bearer ${token}`); | |
| 38 | return fetch(`${servers.url}${path}`, { ...init, headers }); | |
| 39 | }; | |
| 40 | const post = (path: string, body: unknown, token?: string | null) => | |
| 41 | api(path, { | |
| 42 | method: "POST", | |
| 43 | body: JSON.stringify(body), | |
| 44 | ...(token === undefined ? {} : { token }), | |
| 45 | }); | |
| 46 | ||
| 47 | async function untilRun(where: (run: Record<string, unknown>) => boolean) { | |
| 48 | const deadline = Date.now() + 60_000; | |
| 49 | while (Date.now() < deadline) { | |
| 50 | const runs = (await ( | |
| 51 | await api("/api/v1/repos/alice/hello/runs") | |
| 52 | ).json()) as { items: Record<string, unknown>[] }; | |
| 53 | const run = runs.items.find(where); | |
| 54 | if (run && ["ok", "failed", "cancelled"].includes(String(run.state))) | |
| 55 | return run; | |
| 56 | await Bun.sleep(100); | |
| 57 | } | |
| 58 | throw new Error("run did not settle"); | |
| 59 | } | |
| 60 | ||
| 61 | beforeAll(async () => { | |
| 62 | dataDir = await mkdtemp(join(tmpdir(), "forge-apps-api-")); | |
| 63 | app = createApp({ dataDir, port: 0 }); | |
| 64 | servers = await serve(app); | |
| 65 | runtime = new AppsRuntime(app, { | |
| 66 | domain: "apps.test", | |
| 67 | scheme: "http", | |
| 68 | publicPort: servers.port, | |
| 69 | forgeUrl: servers.url, | |
| 70 | }); | |
| 71 | await runtime.start(); | |
| 72 | // A crew whose Claude Code does not exist: dispatch and the runner's | |
| 73 | // bookkeeping run for real, and the run fails in words, not silence. | |
| 74 | crew = await startCrew(app, { | |
| 75 | forgeUrl: servers.url, | |
| 76 | localUrl: servers.url, | |
| 77 | binary: join(dataDir, "no-such-claude"), | |
| 78 | concurrency: 1, | |
| 79 | budgetUsd: 1, | |
| 80 | runTimeoutMs: 60_000, | |
| 81 | }); | |
| 82 | await post( | |
| 83 | "/api/v1/users", | |
| 84 | { | |
| 85 | username: "alice", | |
| 86 | email: "[email protected]", | |
| 87 | password: "a-good-passphrase", | |
| 88 | }, | |
| 89 | null, | |
| 90 | ); | |
| 91 | const mint = async (scope: string) => | |
| 92 | ( | |
| 93 | (await ( | |
| 94 | await post( | |
| 95 | "/api/v1/tokens", | |
| 96 | { | |
| 97 | username: "alice", | |
| 98 | password: "a-good-passphrase", | |
| 99 | name: scope, | |
| 100 | scope, | |
| 101 | }, | |
| 102 | null, | |
| 103 | ) | |
| 104 | ).json()) as { token: string } | |
| 105 | ).token; | |
| 106 | admin = await mint("admin"); | |
| 107 | rw = await mint("rw"); | |
| 108 | }, 30_000); | |
| 109 | ||
| 110 | afterAll(async () => { | |
| 111 | await crew.stop(); | |
| 112 | await runtime.stop(); | |
| 113 | servers.stop(); | |
| 114 | app.close(); | |
| 115 | await rm(dataDir, { recursive: true, force: true }); | |
| 116 | }); | |
| 117 | ||
| 118 | test("agents: admin creates, anyone signed in lists, rw cannot create", async () => { | |
| 119 | const refused = await post("/api/v1/agents", { username: "bot" }, rw); | |
| 120 | expect(refused.status).toBe(403); | |
| 121 | const made = await post("/api/v1/agents", { | |
| 122 | username: "bot", | |
| 123 | brief: "Be brief.", | |
| 124 | budgetUsd: 1, | |
| 125 | }); | |
| 126 | expect(made.status).toBe(201); | |
| 127 | const agent = (await made.json()) as { | |
| 128 | username: string; | |
| 129 | budgetUsd: number; | |
| 130 | }; | |
| 131 | expect(agent.username).toBe("bot"); | |
| 132 | expect(agent.budgetUsd).toBe(1); | |
| 133 | const listed = (await ( | |
| 134 | await api("/api/v1/agents", { token: rw }) | |
| 135 | ).json()) as { items: { username: string }[] }; | |
| 136 | expect(listed.items.map((a) => a.username)).toEqual(["bot"]); | |
| 137 | expect((await api("/api/v1/agents", { token: null })).status).toBe(401); | |
| 138 | // The agent is a user of kind agent, with no way to sign in. | |
| 139 | expect(getUserByName(app.db, "bot")?.kind).toBe("agent"); | |
| 140 | const login = await post( | |
| 141 | "/api/v1/tokens", | |
| 142 | { username: "bot", password: "", name: "x", scope: "rw" }, | |
| 143 | null, | |
| 144 | ); | |
| 145 | expect(login.status).toBe(401); | |
| 146 | }); | |
| 147 | ||
| 148 | test("POST /apps makes the app, puts the agent on the crew, assigns the first issue", async () => { | |
| 149 | const made = await post( | |
| 150 | "/api/v1/apps", | |
| 151 | { | |
| 152 | name: "hello", | |
| 153 | description: "Keep notes", | |
| 154 | visibility: "public", | |
| 155 | agent: "bot", | |
| 156 | }, | |
| 157 | rw, | |
| 158 | ); | |
| 159 | expect(made.status).toBe(201); | |
| 160 | const body = (await made.json()) as { | |
| 161 | app: { slug: string; url: string }; | |
| 162 | issue: { number: number; assigneeName: string }; | |
| 163 | }; | |
| 164 | expect(body.app.slug).toBe("hello"); | |
| 165 | expect(body.app.url).toBe(`http://hello.apps.test:${servers.port}`); | |
| 166 | expect(body.issue.number).toBe(1); | |
| 167 | expect(body.issue.assigneeName).toBe("bot"); | |
| 168 | const crewList = (await ( | |
| 169 | await api("/api/v1/repos/alice/hello/crew") | |
| 170 | ).json()) as { items: { username: string }[] }; | |
| 171 | expect(crewList.items.map((a) => a.username)).toEqual(["bot"]); | |
| 172 | const apps = (await ( | |
| 173 | await api("/api/v1/apps", { token: null }) | |
| 174 | ).json()) as { items: { slug: string }[] }; | |
| 175 | expect(apps.items.map((a) => a.slug)).toEqual(["hello"]); | |
| 176 | // The assignment dispatched an agent run. With no Claude Code to run, | |
| 177 | // it cloned, branched, and failed with the reason — never silently. | |
| 178 | expect(crew.ready).toBe(false); | |
| 179 | const run = await untilRun((r) => r.kind === "agent"); | |
| 180 | expect(run.reason).toBe("assigned"); | |
| 181 | expect(run.state).toBe("failed"); | |
| 182 | expect(String(run.summary)).toContain("failed"); | |
| 183 | const text = await ( | |
| 184 | await api(`/api/v1/repos/alice/hello/runs/${run.id}/log`) | |
| 185 | ).text(); | |
| 186 | expect(text).toContain("branch bot/1-build-hello from"); | |
| 187 | // The run's credential is gone with it. | |
| 188 | const tokens = app.db | |
| 189 | .query< | |
| 190 | { n: number }, | |
| 191 | [] | |
| 192 | >("SELECT COUNT(*) AS n FROM token WHERE name LIKE 'run:%'") | |
| 193 | .get(); | |
| 194 | expect(tokens?.n ?? 0).toBe(0); | |
| 195 | }, 60_000); | |
| 196 | ||
| 197 | test("the production run, its log as text and as a live tail", async () => { | |
| 198 | const run = await untilRun((r) => r.env === "production"); | |
| 199 | expect(run.state).toBe("ok"); | |
| 200 | const view = (await ( | |
| 201 | await api("/api/v1/repos/alice/hello/app") | |
| 202 | ).json()) as { | |
| 203 | environments: { env: string; state: string; port?: number }[]; | |
| 204 | }; | |
| 205 | expect(view.environments[0]!.env).toBe("production"); | |
| 206 | expect(view.environments[0]!.state).toBe("running"); | |
| 207 | expect(view.environments[0]!.port).toBeUndefined; // never exposed as a number a client can dial past the proxy | |
| 208 | const text = await ( | |
| 209 | await api(`/api/v1/repos/alice/hello/runs/${run.id}/log`) | |
| 210 | ).text(); | |
| 211 | expect(text).toContain("ready:"); | |
| 212 | const tail = await api( | |
| 213 | `/api/v1/repos/alice/hello/runs/${run.id}/log?follow=1`, | |
| 214 | ); | |
| 215 | expect(tail.headers.get("content-type")).toContain("text/event-stream"); | |
| 216 | const events = await tail.text(); // the run is finished, so the stream closes after `done` | |
| 217 | expect(events).toContain("event: line"); | |
| 218 | expect(events).toContain("event: done"); | |
| 219 | const one = (await ( | |
| 220 | await api(`/api/v1/repos/alice/hello/runs/${run.id}`) | |
| 221 | ).json()) as { id: string }; | |
| 222 | expect(one.id).toBe(String(run.id)); | |
| 223 | // Cancelling a finished run is a conflict, not a silent no-op. | |
| 224 | expect( | |
| 225 | (await post(`/api/v1/repos/alice/hello/runs/${run.id}/cancel`, {}, rw)) | |
| 226 | .status, | |
| 227 | ).toBe(409); | |
| 228 | }); | |
| 229 | ||
| 230 | test("open, access, redeploy and stop", async () => { | |
| 231 | const open = (await ( | |
| 232 | await api("/api/v1/repos/alice/hello/app/production/open", { | |
| 233 | token: null, | |
| 234 | }) | |
| 235 | ).json()) as { url: string }; | |
| 236 | expect(open.url).toBe(`http://hello.apps.test:${servers.port}`); | |
| 237 | expect((await api("/api/v1/repos/alice/hello/app/nope/open")).status).toBe( | |
| 238 | 422, | |
| 239 | ); | |
| 240 | ||
| 241 | const patched = await api("/api/v1/repos/alice/hello/app", { | |
| 242 | method: "PATCH", | |
| 243 | body: JSON.stringify({ access: "private" }), | |
| 244 | }); | |
| 245 | expect(patched.status).toBe(200); | |
| 246 | expect( | |
| 247 | ( | |
| 248 | await api("/api/v1/repos/alice/hello/app", { | |
| 249 | method: "PATCH", | |
| 250 | body: JSON.stringify({ access: "public" }), | |
| 251 | token: rw, | |
| 252 | }) | |
| 253 | ).status, | |
| 254 | ).toBe(403); | |
| 255 | const anon = await api("/api/v1/repos/alice/hello/app/production/open", { | |
| 256 | token: null, | |
| 257 | }); | |
| 258 | expect(anon.status).toBe(401); | |
| 259 | const granted = (await ( | |
| 260 | await api("/api/v1/repos/alice/hello/app/production/open") | |
| 261 | ).json()) as { url: string }; | |
| 262 | expect(granted.url).toContain("/__forge/grant?t="); | |
| 263 | ||
| 264 | const stopped = await post( | |
| 265 | "/api/v1/repos/alice/hello/app/production/stop", | |
| 266 | {}, | |
| 267 | rw, | |
| 268 | ); | |
| 269 | expect(stopped.status).toBe(200); | |
| 270 | const redeployed = await post( | |
| 271 | "/api/v1/repos/alice/hello/app/production/redeploy", | |
| 272 | {}, | |
| 273 | rw, | |
| 274 | ); | |
| 275 | expect(redeployed.status).toBe(202); | |
| 276 | const again = await untilRun((r) => r.reason === "redeploy"); | |
| 277 | expect(again.state).toBe("ok"); | |
| 278 | }, 90_000); | |
| 279 | ||
| 280 | test("assignee: people and crew, and the crew grant is the only way in for an agent", async () => { | |
| 281 | const cleared = await api("/api/v1/repos/alice/hello/issues/1/assignee", { | |
| 282 | method: "PUT", | |
| 283 | body: JSON.stringify({ assignee: null }), | |
| 284 | token: rw, | |
| 285 | }); | |
| 286 | expect(cleared.status).toBe(200); | |
| 287 | expect( | |
| 288 | ((await cleared.json()) as { assigneeName: string | null }).assigneeName, | |
| 289 | ).toBeNull(); | |
| 290 | const nobody = await api("/api/v1/repos/alice/hello/issues/1/assignee", { | |
| 291 | method: "PUT", | |
| 292 | body: JSON.stringify({ assignee: "ghost" }), | |
| 293 | token: rw, | |
| 294 | }); | |
| 295 | expect(nobody.status).toBe(422); | |
| 296 | expect(((await nobody.json()) as { error: string }).error).toBe( | |
| 297 | "no_such_user", | |
| 298 | ); | |
| 299 | expect( | |
| 300 | ( | |
| 301 | await api("/api/v1/repos/alice/hello/crew/bot", { | |
| 302 | method: "DELETE", | |
| 303 | token: rw, | |
| 304 | }) | |
| 305 | ).status, | |
| 306 | ).toBe(403); | |
| 307 | expect( | |
| 308 | (await api("/api/v1/repos/alice/hello/crew/bot", { method: "DELETE" })) | |
| 309 | .status, | |
| 310 | ).toBe(200); | |
| 311 | const off = await api("/api/v1/repos/alice/hello/issues/1/assignee", { | |
| 312 | method: "PUT", | |
| 313 | body: JSON.stringify({ assignee: "bot" }), | |
| 314 | token: rw, | |
| 315 | }); | |
| 316 | expect(off.status).toBe(422); | |
| 317 | expect(((await off.json()) as { error: string }).error).toBe( | |
| 318 | "agent_not_on_crew", | |
| 319 | ); | |
| 320 | expect( | |
| 321 | (await api("/api/v1/repos/alice/hello/crew/bot", { method: "PUT" })) | |
| 322 | .status, | |
| 323 | ).toBe(200); | |
| 324 | const on = await api("/api/v1/repos/alice/hello/issues/1/assignee", { | |
| 325 | method: "PUT", | |
| 326 | body: JSON.stringify({ assignee: "bot" }), | |
| 327 | token: rw, | |
| 328 | }); | |
| 329 | expect(on.status).toBe(200); | |
| 330 | const mine = await api("/api/v1/repos/alice/hello/issues/1/assignee", { | |
| 331 | method: "PUT", | |
| 332 | body: JSON.stringify({ assignee: "alice" }), | |
| 333 | token: rw, | |
| 334 | }); | |
| 335 | expect(((await mine.json()) as { assigneeName: string }).assigneeName).toBe( | |
| 336 | "alice", | |
| 337 | ); | |
| 338 | const assigned = (await ( | |
| 339 | await api("/api/v1/user/assigned", { token: rw }) | |
| 340 | ).json()) as { items: { number: number; repo: { name: string } }[] }; | |
| 341 | expect(assigned.items.map((i) => `${i.repo.name}#${i.number}`)).toEqual([ | |
| 342 | "hello#1", | |
| 343 | ]); | |
| 344 | }); | |
| 345 | ||
| 346 | test("checks on a pull, and require_checks in the repo's settings", async () => { | |
| 347 | const alice = getUserByName(app.db, "alice")!; | |
| 348 | const repo = getRepo(app.db, "alice", "hello")!; | |
| 349 | const committed = await commitToBranch(app, alice, repo, { | |
| 350 | branch: "change", | |
| 351 | base: repo.defaultBranch, | |
| 352 | message: "A change", | |
| 353 | files: [{ path: "NOTE.md", content: "note\n" }], | |
| 354 | }); | |
| 355 | expect(committed.ok).toBe(true); | |
| 356 | const pull = await openPull(app, alice, repo, { | |
| 357 | title: "A change", | |
| 358 | baseBranch: repo.defaultBranch, | |
| 359 | headBranch: "change", | |
| 360 | }); | |
| 361 | expect(pull.ok).toBe(true); | |
| 362 | if (!pull.ok || !committed.ok) return; | |
| 363 | const run = await untilRun((r) => r.env === `pull-${pull.value.number}`); | |
| 364 | expect(run.state).toBe("ok"); | |
| 365 | const checks = (await ( | |
| 366 | await api(`/api/v1/repos/alice/hello/pulls/${pull.value.number}/checks`) | |
| 367 | ).json()) as { | |
| 368 | head: string; | |
| 369 | state: string; | |
| 370 | required: boolean; | |
| 371 | runs: unknown[]; | |
| 372 | }; | |
| 373 | expect(checks.head).toBe(committed.value.commitOid); | |
| 374 | expect(checks.state).toBe("ok"); | |
| 375 | expect(checks.required).toBe(false); | |
| 376 | const settings = await api("/api/v1/repos/alice/hello", { | |
| 377 | method: "PATCH", | |
| 378 | body: JSON.stringify({ requireChecks: true }), | |
| 379 | }); | |
| 380 | expect( | |
| 381 | ((await settings.json()) as { requireChecks: boolean }).requireChecks, | |
| 382 | ).toBe(true); | |
| 383 | expect( | |
| 384 | ( | |
| 385 | (await ( | |
| 386 | await api( | |
| 387 | `/api/v1/repos/alice/hello/pulls/${pull.value.number}/checks`, | |
| 388 | ) | |
| 389 | ).json()) as { required: boolean } | |
| 390 | ).required, | |
| 391 | ).toBe(true); | |
| 392 | }, 90_000); | |
| 393 | ||
| 394 | test("a private repo's app is not_found to strangers; disabling removes it", async () => { | |
| 395 | await api("/api/v1/repos/alice/hello", { | |
| 396 | method: "PATCH", | |
| 397 | body: JSON.stringify({ visibility: "private" }), | |
| 398 | }); | |
| 399 | expect( | |
| 400 | (await api("/api/v1/repos/alice/hello/app", { token: null })).status, | |
| 401 | ).toBe(404); | |
| 402 | expect((await api("/api/v1/apps", { token: null })).ok).toBe(true); | |
| 403 | expect( | |
| 404 | ( | |
| 405 | (await (await api("/api/v1/apps", { token: null })).json()) as { | |
| 406 | items: unknown[]; | |
| 407 | } | |
| 408 | ).items.length, | |
| 409 | ).toBe(0); | |
| 410 | expect( | |
| 411 | ( | |
| 412 | await api("/api/v1/repos/alice/hello/app", { | |
| 413 | method: "DELETE", | |
| 414 | token: rw, | |
| 415 | }) | |
| 416 | ).status, | |
| 417 | ).toBe(403); | |
| 418 | expect( | |
| 419 | (await api("/api/v1/repos/alice/hello/app", { method: "DELETE" })).status, | |
| 420 | ).toBe(200); | |
| 421 | expect((await api("/api/v1/repos/alice/hello/app")).status).toBe(404); | |
| 422 | const deadline = Date.now() + 5_000; | |
| 423 | while ( | |
| 424 | (await fetch(`${servers.url}/`, { headers: { host: "hello.apps.test" } })) | |
| 425 | .status !== 404 && | |
| 426 | Date.now() < deadline | |
| 427 | ) | |
| 428 | await Bun.sleep(50); | |
| 429 | expect( | |
| 430 | (await fetch(`${servers.url}/`, { headers: { host: "hello.apps.test" } })) | |
| 431 | .status, | |
| 432 | ).toBe(404); | |
| 433 | }); | |
| 434 | ||
| 435 | test("agents: update and retire", async () => { | |
| 436 | const patched = await api("/api/v1/agents/bot", { | |
| 437 | method: "PATCH", | |
| 438 | body: JSON.stringify({ budgetUsd: 2, model: "sonnet" }), | |
| 439 | }); | |
| 440 | expect( | |
| 441 | ((await patched.json()) as { budgetUsd: number; model: string }).model, | |
| 442 | ).toBe("sonnet"); | |
| 443 | expect( | |
| 444 | (await api("/api/v1/agents/bot", { method: "DELETE", token: rw })).status, | |
| 445 | ).toBe(403); | |
| 446 | expect((await api("/api/v1/agents/bot", { method: "DELETE" })).status).toBe( | |
| 447 | 200, | |
| 448 | ); | |
| 449 | expect((await api("/api/v1/agents/bot")).status).toBe(404); | |
| 450 | }); | |
| 451 | }); |
test/apps-ssh.test.ts added +207
| @@ -0,0 +1,207 @@ | ||
| 1 | /** | |
| 2 | * A terminal for every app (docs/APPS.md): `ssh <slug>@forge` opens the | |
| 3 | * app's `tty` command on a pty over the same data its web page uses; | |
| 4 | * `<slug>--<n>@forge` opens a preview's; any other name still opens the | |
| 5 | * forge's own terminal UI; a private app's terminal answers only to a key | |
| 6 | * the forge knows; and an environment that is not running says so. | |
| 7 | * | |
| 8 | * Driven with the real OpenSSH client against the real listener. | |
| 9 | */ | |
| 10 | ||
| 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 17 | import { parseAppLogin } from "../src/apps/tty.ts"; | |
| 18 | import { newApp } from "../src/actions/apps.ts"; | |
| 19 | import { listRuns } from "../src/core/runs.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import type { SshServer } from "../src/ssh/server.ts"; | |
| 23 | import { serveForgeSsh } from "../src/tui/ssh.ts"; | |
| 24 | ||
| 25 | const SSH = "/usr/bin/ssh"; | |
| 26 | const SSH_KEYGEN = "/usr/bin/ssh-keygen"; | |
| 27 | ||
| 28 | describe("a terminal for every app, over ssh", () => { | |
| 29 | let dataDir: string; | |
| 30 | let app: App; | |
| 31 | let servers: Servers; | |
| 32 | let runtime: AppsRuntime; | |
| 33 | let ssh: SshServer; | |
| 34 | let alice: User; | |
| 35 | let key: string; | |
| 36 | ||
| 37 | /** Open an interactive ssh session as `login`, type `keys`, collect output. */ | |
| 38 | async function session( | |
| 39 | login: string, | |
| 40 | keys = "", | |
| 41 | waitMs = 1500, | |
| 42 | ): Promise<{ out: string; code: number }> { | |
| 43 | const proc = Bun.spawn( | |
| 44 | [ | |
| 45 | SSH, | |
| 46 | "-tt", | |
| 47 | "-p", | |
| 48 | String(ssh.port), | |
| 49 | "-i", | |
| 50 | key, | |
| 51 | "-o", | |
| 52 | "StrictHostKeyChecking=no", | |
| 53 | "-o", | |
| 54 | "UserKnownHostsFile=/dev/null", | |
| 55 | "-o", | |
| 56 | "IdentitiesOnly=yes", | |
| 57 | "-o", | |
| 58 | "LogLevel=ERROR", | |
| 59 | "-o", | |
| 60 | "ConnectTimeout=5", | |
| 61 | `${login}@127.0.0.1`, | |
| 62 | ], | |
| 63 | { | |
| 64 | stdin: "pipe", | |
| 65 | stdout: "pipe", | |
| 66 | stderr: "pipe", | |
| 67 | env: { ...process.env, TERM: "xterm-256color" }, | |
| 68 | }, | |
| 69 | ); | |
| 70 | const chunks: string[] = []; | |
| 71 | const reader = (async () => { | |
| 72 | for await (const chunk of proc.stdout) | |
| 73 | chunks.push(new TextDecoder().decode(chunk)); | |
| 74 | })(); | |
| 75 | const errs = (async () => { | |
| 76 | for await (const chunk of proc.stderr) | |
| 77 | chunks.push(new TextDecoder().decode(chunk)); | |
| 78 | })(); | |
| 79 | await Bun.sleep(waitMs); | |
| 80 | if (keys !== "") { | |
| 81 | proc.stdin.write(keys); | |
| 82 | proc.stdin.flush(); | |
| 83 | await Bun.sleep(600); | |
| 84 | } | |
| 85 | const done = await Promise.race([ | |
| 86 | proc.exited, | |
| 87 | Bun.sleep(4000).then(() => -1), | |
| 88 | ]); | |
| 89 | if (done === -1) proc.kill("SIGKILL"); | |
| 90 | const code = await proc.exited; | |
| 91 | await Promise.all([reader, errs]); | |
| 92 | return { | |
| 93 | out: chunks.join("").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""), | |
| 94 | code, | |
| 95 | }; | |
| 96 | } | |
| 97 | ||
| 98 | beforeAll(async () => { | |
| 99 | dataDir = await mkdtemp(join(tmpdir(), "forge-apps-ssh-")); | |
| 100 | app = createApp({ dataDir, port: 0 }); | |
| 101 | servers = await serve(app); | |
| 102 | ssh = await serveForgeSsh(app, { port: 0, hostname: "127.0.0.1" }); | |
| 103 | runtime = new AppsRuntime(app, { | |
| 104 | domain: "apps.test", | |
| 105 | scheme: "http", | |
| 106 | publicPort: servers.port, | |
| 107 | forgeUrl: servers.url, | |
| 108 | }); | |
| 109 | await runtime.start(); | |
| 110 | key = join(dataDir, "guest_key"); | |
| 111 | const gen = Bun.spawn( | |
| 112 | [SSH_KEYGEN, "-q", "-t", "ed25519", "-N", "", "-f", key], | |
| 113 | { stdout: "ignore", stderr: "ignore" }, | |
| 114 | ); | |
| 115 | expect(await gen.exited).toBe(0); | |
| 116 | const created = await createUser(app.db, { | |
| 117 | username: "alice", | |
| 118 | email: "[email protected]", | |
| 119 | password: "a-good-passphrase", | |
| 120 | adminIfFirst: true, | |
| 121 | }); | |
| 122 | if (!created.ok) throw new Error(created.error); | |
| 123 | alice = created.value; | |
| 124 | const made = await newApp(app, alice, { | |
| 125 | name: "hello", | |
| 126 | description: "notes", | |
| 127 | visibility: "public", | |
| 128 | }); | |
| 129 | if (!made.ok) throw new Error(String(made.error)); | |
| 130 | const privately = await newApp(app, alice, { | |
| 131 | name: "secret", | |
| 132 | description: "", | |
| 133 | visibility: "private", | |
| 134 | }); | |
| 135 | if (!privately.ok) throw new Error(String(privately.error)); | |
| 136 | // Wait for both productions to come up. | |
| 137 | const deadline = Date.now() + 60_000; | |
| 138 | for (;;) { | |
| 139 | const ok = [made.value.repo, privately.value.repo].every((repo) => | |
| 140 | listRuns(app.db, repo.id, { env: "production" }).some( | |
| 141 | (r) => r.state === "ok", | |
| 142 | ), | |
| 143 | ); | |
| 144 | if (ok) break; | |
| 145 | if (Date.now() > deadline) throw new Error("production did not come up"); | |
| 146 | await Bun.sleep(100); | |
| 147 | } | |
| 148 | }, 90_000); | |
| 149 | ||
| 150 | afterAll(async () => { | |
| 151 | await runtime.stop(); | |
| 152 | ssh.stop(); | |
| 153 | servers.stop(); | |
| 154 | app.close(); | |
| 155 | await rm(dataDir, { recursive: true, force: true }); | |
| 156 | }); | |
| 157 | ||
| 158 | test("the login name names the app, or its preview", () => { | |
| 159 | expect(parseAppLogin("hello")).toEqual({ | |
| 160 | slug: "hello", | |
| 161 | env: "production", | |
| 162 | }); | |
| 163 | expect(parseAppLogin("Hello--12")).toEqual({ | |
| 164 | slug: "hello", | |
| 165 | env: "pull-12", | |
| 166 | }); | |
| 167 | expect(parseAppLogin("hello--0")).toBeUndefined(); | |
| 168 | expect(parseAppLogin("--1")).toBeUndefined(); | |
| 169 | expect(parseAppLogin("a b")).toBeUndefined(); | |
| 170 | }); | |
| 171 | ||
| 172 | test("ssh hello@forge opens the app's terminal, over its own data", async () => { | |
| 173 | // Add a note through the app's web side, then see it in the terminal. | |
| 174 | const form = new FormData(); | |
| 175 | form.set("body", "seen from the terminal"); | |
| 176 | const posted = await fetch(`${servers.url}/notes`, { | |
| 177 | method: "POST", | |
| 178 | body: form, | |
| 179 | headers: { host: "hello.apps.test" }, | |
| 180 | redirect: "manual", | |
| 181 | }); | |
| 182 | expect(posted.status).toBe(303); | |
| 183 | const { out, code } = await session("hello", "q"); | |
| 184 | expect(out).toContain("hello"); | |
| 185 | expect(out).toContain("seen from the terminal"); | |
| 186 | expect(out).toContain("j/k move"); | |
| 187 | expect(code).toBe(0); | |
| 188 | }, 30_000); | |
| 189 | ||
| 190 | test("any other login still opens the forge's terminal UI", async () => { | |
| 191 | const { out } = await session("anything", "q"); | |
| 192 | expect(out).toContain("forge"); | |
| 193 | expect(out).not.toContain("j/k move · a add"); | |
| 194 | }, 30_000); | |
| 195 | ||
| 196 | test("a preview that does not exist says so", async () => { | |
| 197 | const { out, code } = await session("hello--99"); | |
| 198 | expect(out).toContain("hello--99 is not running"); | |
| 199 | expect(code).toBe(1); | |
| 200 | }, 30_000); | |
| 201 | ||
| 202 | test("a private app's terminal is not there for a stranger", async () => { | |
| 203 | const { out, code } = await session("secret"); | |
| 204 | expect(out).toContain("no app named secret"); | |
| 205 | expect(code).toBe(1); | |
| 206 | }, 30_000); | |
| 207 | }); |
test/apps.test.ts added +489
| @@ -0,0 +1,489 @@ | ||
| 1 | /** | |
| 2 | * Apps, end to end (docs/APPS.md): a new app runs in production seconds | |
| 3 | * after it exists; a pull gets a preview with a copy of production's data; | |
| 4 | * a push moves the preview; closing the pull removes it; a private app | |
| 5 | * answers only through a grant; `require_checks` holds the merge until the | |
| 6 | * head's deploy is green; and the crew rules cannot be got around. | |
| 7 | */ | |
| 8 | ||
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { existsSync } from "node:fs"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 16 | import { envDir } from "../src/apps/data.ts"; | |
| 17 | import { newApp, openEnvUrl, updateAppSettings } from "../src/actions/apps.ts"; | |
| 18 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 19 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 20 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 21 | import { | |
| 22 | commitToBranch, | |
| 23 | createRepo, | |
| 24 | updateRepoSettings, | |
| 25 | } from "../src/actions/repos.ts"; | |
| 26 | import type { AppRecord } from "../src/core/apps.ts"; | |
| 27 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 28 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 29 | import { listRuns, type Run } from "../src/core/runs.ts"; | |
| 30 | import { createToken } from "../src/core/tokens.ts"; | |
| 31 | import { createUser, type User } from "../src/core/users.ts"; | |
| 32 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 33 | ||
| 34 | const clientEnv = { | |
| 35 | ...process.env, | |
| 36 | GIT_AUTHOR_NAME: "Agent", | |
| 37 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 38 | GIT_COMMITTER_NAME: "Agent", | |
| 39 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 40 | GIT_TERMINAL_PROMPT: "0", | |
| 41 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 42 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 43 | }; | |
| 44 | ||
| 45 | async function sh(args: string[], cwd: string) { | |
| 46 | const proc = Bun.spawn(args, { | |
| 47 | cwd, | |
| 48 | env: clientEnv, | |
| 49 | stdout: "pipe", | |
| 50 | stderr: "pipe", | |
| 51 | }); | |
| 52 | const [out, err, code] = await Promise.all([ | |
| 53 | new Response(proc.stdout).text(), | |
| 54 | new Response(proc.stderr).text(), | |
| 55 | proc.exited, | |
| 56 | ]); | |
| 57 | return { code, out: out.trim(), err: err.trim() }; | |
| 58 | } | |
| 59 | ||
| 60 | describe("apps: production, previews, data, gates, crew", () => { | |
| 61 | let dataDir: string; | |
| 62 | let work: string; | |
| 63 | let app: App; | |
| 64 | let servers: Servers; | |
| 65 | let runtime: AppsRuntime; | |
| 66 | let alice: User; | |
| 67 | let repo: Repo; | |
| 68 | let record: AppRecord; | |
| 69 | const DOMAIN = "apps.test"; | |
| 70 | ||
| 71 | const viaHost = (host: string, path = "/", init: RequestInit = {}) => | |
| 72 | fetch(`${servers.url}${path}`, { | |
| 73 | ...init, | |
| 74 | headers: { ...(init.headers as Record<string, string>), host }, | |
| 75 | redirect: "manual", | |
| 76 | }); | |
| 77 | ||
| 78 | /** Wait for the newest run matching `where` to finish, and return it. */ | |
| 79 | async function settled( | |
| 80 | where: (run: Run) => boolean, | |
| 81 | timeoutMs = 60_000, | |
| 82 | ): Promise<Run> { | |
| 83 | const deadline = Date.now() + timeoutMs; | |
| 84 | while (Date.now() < deadline) { | |
| 85 | const run = listRuns(app.db, repo.id, { kind: "deploy", limit: 50 }).find( | |
| 86 | where, | |
| 87 | ); | |
| 88 | if ( | |
| 89 | run && | |
| 90 | (run.state === "ok" || | |
| 91 | run.state === "failed" || | |
| 92 | run.state === "cancelled") | |
| 93 | ) | |
| 94 | return run; | |
| 95 | await Bun.sleep(100); | |
| 96 | } | |
| 97 | throw new Error("run did not settle in time"); | |
| 98 | } | |
| 99 | ||
| 100 | const latest = (env: string) => (run: Run) => run.env === env; | |
| 101 | ||
| 102 | beforeAll(async () => { | |
| 103 | dataDir = await mkdtemp(join(tmpdir(), "forge-apps-")); | |
| 104 | work = await mkdtemp(join(tmpdir(), "forge-apps-work-")); | |
| 105 | app = createApp({ dataDir, port: 0 }); | |
| 106 | servers = await serve(app); | |
| 107 | runtime = new AppsRuntime(app, { | |
| 108 | domain: DOMAIN, | |
| 109 | scheme: "http", | |
| 110 | publicPort: servers.port, | |
| 111 | forgeUrl: servers.url, | |
| 112 | concurrency: 2, | |
| 113 | }); | |
| 114 | await runtime.start(); | |
| 115 | const created = await createUser(app.db, { | |
| 116 | username: "alice", | |
| 117 | email: "[email protected]", | |
| 118 | password: "a-good-passphrase", | |
| 119 | adminIfFirst: true, | |
| 120 | }); | |
| 121 | if (!created.ok) throw new Error(created.error); | |
| 122 | alice = created.value; | |
| 123 | }, 30_000); | |
| 124 | ||
| 125 | afterAll(async () => { | |
| 126 | await runtime.stop(); | |
| 127 | servers.stop(); | |
| 128 | app.close(); | |
| 129 | await rm(dataDir, { recursive: true, force: true }); | |
| 130 | await rm(work, { recursive: true, force: true }); | |
| 131 | }); | |
| 132 | ||
| 133 | test("new app: a repository with a working app, running in production", async () => { | |
| 134 | const made = await newApp(app, alice, { | |
| 135 | name: "hello", | |
| 136 | description: "A place for notes", | |
| 137 | visibility: "public", | |
| 138 | }); | |
| 139 | expect(made.ok).toBe(true); | |
| 140 | if (!made.ok) return; | |
| 141 | repo = made.value.repo; | |
| 142 | record = made.value.app; | |
| 143 | expect(record.slug).toBe("hello"); | |
| 144 | expect(record.access).toBe("public"); | |
| 145 | ||
| 146 | const run = await settled(latest("production")); | |
| 147 | expect(run.state).toBe("ok"); | |
| 148 | expect(run.summary).toContain(`http://hello.${DOMAIN}:${servers.port}`); | |
| 149 | expect(runtime.urlFor(record, "production")).toBe( | |
| 150 | `http://hello.${DOMAIN}:${servers.port}`, | |
| 151 | ); | |
| 152 | ||
| 153 | const page = await viaHost(`hello.${DOMAIN}`); | |
| 154 | expect(page.status).toBe(200); | |
| 155 | const html = await page.text(); | |
| 156 | expect(html).toContain("<h1>hello</h1>"); | |
| 157 | expect(html).toContain("production"); | |
| 158 | expect(html).toContain(run.commitOid.slice(0, 7)); | |
| 159 | }, 90_000); | |
| 160 | ||
| 161 | test("the proxy carries a form post; production data persists", async () => { | |
| 162 | const form = new FormData(); | |
| 163 | form.set("body", "first note in production"); | |
| 164 | const posted = await viaHost(`hello.${DOMAIN}`, "/notes", { | |
| 165 | method: "POST", | |
| 166 | body: form, | |
| 167 | }); | |
| 168 | expect(posted.status).toBe(303); | |
| 169 | const html = await (await viaHost(`hello.${DOMAIN}`)).text(); | |
| 170 | expect(html).toContain("first note in production"); | |
| 171 | }); | |
| 172 | ||
| 173 | test("a hostname that is no app falls through to the forge", async () => { | |
| 174 | const forge = await viaHost(`localhost`, "/"); | |
| 175 | expect(forge.status).toBe(200); | |
| 176 | // Shaped like an app, naming none: the forge's pages must not appear | |
| 177 | // under a stranger's hostname. | |
| 178 | const unknown = await viaHost(`nothing.${DOMAIN}`, "/"); | |
| 179 | expect(unknown.status).toBe(404); | |
| 180 | // A multi-label host is not an app's; the forge answers as itself. | |
| 181 | expect((await viaHost(`a.hello.${DOMAIN}`, "/")).status).toBe(200); | |
| 182 | }); | |
| 183 | ||
| 184 | let pull: Pull; | |
| 185 | ||
| 186 | test("a pull gets a preview with a copy of production's data", async () => { | |
| 187 | const committed = await commitToBranch(app, alice, repo, { | |
| 188 | branch: "greeting", | |
| 189 | base: repo.defaultBranch, | |
| 190 | message: "Say hello", | |
| 191 | files: [{ path: "GREETING.md", content: "hi\n" }], | |
| 192 | }); | |
| 193 | expect(committed.ok).toBe(true); | |
| 194 | const opened = await openPull(app, alice, repo, { | |
| 195 | title: "Say hello", | |
| 196 | baseBranch: repo.defaultBranch, | |
| 197 | headBranch: "greeting", | |
| 198 | }); | |
| 199 | expect(opened.ok).toBe(true); | |
| 200 | if (!opened.ok) return; | |
| 201 | pull = opened.value; | |
| 202 | ||
| 203 | const run = await settled(latest(`pull-${pull.number}`)); | |
| 204 | expect(run.state).toBe("ok"); | |
| 205 | expect(run.issueId).toBe(pull.id); | |
| 206 | ||
| 207 | const preview = await viaHost(`hello--${pull.number}.${DOMAIN}`); | |
| 208 | expect(preview.status).toBe(200); | |
| 209 | const html = await preview.text(); | |
| 210 | expect(html).toContain(`preview of pull #${pull.number}`); | |
| 211 | // The note written in production is there: data was copied. | |
| 212 | expect(html).toContain("first note in production"); | |
| 213 | ||
| 214 | // A note written in the preview never reaches production. | |
| 215 | const form = new FormData(); | |
| 216 | form.set("body", "only in the preview"); | |
| 217 | await viaHost(`hello--${pull.number}.${DOMAIN}`, "/notes", { | |
| 218 | method: "POST", | |
| 219 | body: form, | |
| 220 | }); | |
| 221 | expect( | |
| 222 | await (await viaHost(`hello--${pull.number}.${DOMAIN}`)).text(), | |
| 223 | ).toContain("only in the preview"); | |
| 224 | expect(await (await viaHost(`hello.${DOMAIN}`)).text()).not.toContain( | |
| 225 | "only in the preview", | |
| 226 | ); | |
| 227 | }, 90_000); | |
| 228 | ||
| 229 | test("a push to the pull's branch moves the preview and keeps its data", async () => { | |
| 230 | const before = listRuns(app.db, repo.id, { | |
| 231 | env: `pull-${pull.number}`, | |
| 232 | }).length; | |
| 233 | const committed = await commitToBranch(app, alice, repo, { | |
| 234 | branch: "greeting", | |
| 235 | message: "More greeting", | |
| 236 | files: [{ path: "GREETING.md", content: "hi again\n" }], | |
| 237 | }); | |
| 238 | expect(committed.ok).toBe(true); | |
| 239 | if (!committed.ok) return; | |
| 240 | const run = await settled( | |
| 241 | (r) => | |
| 242 | r.env === `pull-${pull.number}` && | |
| 243 | r.commitOid === committed.value.commitOid, | |
| 244 | ); | |
| 245 | expect(run.state).toBe("ok"); | |
| 246 | expect( | |
| 247 | listRuns(app.db, repo.id, { env: `pull-${pull.number}` }).length, | |
| 248 | ).toBe(before + 1); | |
| 249 | const html = await ( | |
| 250 | await viaHost(`hello--${pull.number}.${DOMAIN}`) | |
| 251 | ).text(); | |
| 252 | expect(html).toContain(committed.value.commitOid.slice(0, 7)); | |
| 253 | expect(html).toContain("only in the preview"); | |
| 254 | }, 90_000); | |
| 255 | ||
| 256 | test("merging the pull removes the preview and redeploys production", async () => { | |
| 257 | const merged = await mergePull( | |
| 258 | app, | |
| 259 | alice, | |
| 260 | repo, | |
| 261 | getPull(app.db, repo.id, pull.number)!, | |
| 262 | "merge", | |
| 263 | ); | |
| 264 | expect(merged.ok).toBe(true); | |
| 265 | if (!merged.ok) return; | |
| 266 | const run = await settled( | |
| 267 | (r) => r.env === "production" && r.commitOid === merged.value.mergeOid, | |
| 268 | ); | |
| 269 | expect(run.state).toBe("ok"); | |
| 270 | // The preview is gone: no process, no directory, an honest page. | |
| 271 | const deadline = Date.now() + 10_000; | |
| 272 | while ( | |
| 273 | existsSync(envDir(dataDir, record.id, `pull-${pull.number}`)) && | |
| 274 | Date.now() < deadline | |
| 275 | ) | |
| 276 | await Bun.sleep(50); | |
| 277 | expect(existsSync(envDir(dataDir, record.id, `pull-${pull.number}`))).toBe( | |
| 278 | false, | |
| 279 | ); | |
| 280 | const gone = await viaHost(`hello--${pull.number}.${DOMAIN}`); | |
| 281 | expect(gone.status).toBe(502); | |
| 282 | expect(await gone.text()).toContain("is not running"); | |
| 283 | // Production kept its data across the redeploy. | |
| 284 | expect(await (await viaHost(`hello.${DOMAIN}`)).text()).toContain( | |
| 285 | "first note in production", | |
| 286 | ); | |
| 287 | }, 90_000); | |
| 288 | ||
| 289 | test("a private app answers only through a grant", async () => { | |
| 290 | const updated = updateAppSettings(app, alice, repo, { access: "private" }); | |
| 291 | expect(updated.ok).toBe(true); | |
| 292 | expect((await viaHost(`hello.${DOMAIN}`)).status).toBe(404); | |
| 293 | ||
| 294 | // Anonymous visitors get nothing to open. | |
| 295 | expect(openEnvUrl(app, undefined, repo, "production").ok).toBe(false); | |
| 296 | const link = openEnvUrl(app, alice, repo, "production"); | |
| 297 | expect(link.ok).toBe(true); | |
| 298 | if (!link.ok) return; | |
| 299 | const url = new URL(link.value); | |
| 300 | expect(url.host).toBe(`hello.${DOMAIN}:${servers.port}`); | |
| 301 | expect(url.pathname).toBe("/__forge/grant"); | |
| 302 | const granted = await viaHost(url.host, `${url.pathname}${url.search}`); | |
| 303 | expect(granted.status).toBe(303); | |
| 304 | const cookie = granted.headers.get("set-cookie") ?? ""; | |
| 305 | expect(cookie).toContain("forge_grant="); | |
| 306 | const page = await viaHost(`hello.${DOMAIN}`, "/", { | |
| 307 | headers: { cookie: cookie.split(";")[0]! }, | |
| 308 | }); | |
| 309 | expect(page.status).toBe(200); | |
| 310 | // A grant for one environment is no grant for another. | |
| 311 | expect( | |
| 312 | ( | |
| 313 | await viaHost(`hello--9.${DOMAIN}`, "/", { | |
| 314 | headers: { cookie: cookie.split(";")[0]! }, | |
| 315 | }) | |
| 316 | ).status, | |
| 317 | ).toBe(404); | |
| 318 | // A forged token is refused. | |
| 319 | const forged = await viaHost( | |
| 320 | url.host, | |
| 321 | `/__forge/grant?t=${record.id}.production.9999999999999.AAAA`, | |
| 322 | ); | |
| 323 | expect(forged.status).toBe(404); | |
| 324 | updateAppSettings(app, alice, repo, { access: "public" }); | |
| 325 | }); | |
| 326 | ||
| 327 | test("require_checks holds the merge until the head's deploy is green", async () => { | |
| 328 | const settings = await updateRepoSettings(app, alice, repo, { | |
| 329 | requireChecks: true, | |
| 330 | }); | |
| 331 | expect(settings.ok).toBe(true); | |
| 332 | repo = getRepoById(app.db, repo.id)!; | |
| 333 | const broken = await commitToBranch(app, alice, repo, { | |
| 334 | branch: "broken", | |
| 335 | base: repo.defaultBranch, | |
| 336 | message: "Break the test", | |
| 337 | files: [ | |
| 338 | { | |
| 339 | path: "src/server.test.ts", | |
| 340 | content: | |
| 341 | 'import { expect, test } from "bun:test";\ntest("fails", () => { expect(1).toBe(2); });\n', | |
| 342 | }, | |
| 343 | ], | |
| 344 | }); | |
| 345 | expect(broken.ok).toBe(true); | |
| 346 | const opened = await openPull(app, alice, repo, { | |
| 347 | title: "Broken", | |
| 348 | baseBranch: repo.defaultBranch, | |
| 349 | headBranch: "broken", | |
| 350 | }); | |
| 351 | expect(opened.ok).toBe(true); | |
| 352 | if (!opened.ok) return; | |
| 353 | const failed = await settled(latest(`pull-${opened.value.number}`)); | |
| 354 | expect(failed.state).toBe("failed"); | |
| 355 | expect(failed.summary).toContain("test"); | |
| 356 | const refused = await mergePull(app, alice, repo, opened.value, "merge"); | |
| 357 | expect(refused.ok).toBe(false); | |
| 358 | if (!refused.ok) expect(refused.error).toBe("checks_required"); | |
| 359 | // The preview shows why instead of a gateway error. | |
| 360 | const page = await viaHost(`hello--${opened.value.number}.${DOMAIN}`); | |
| 361 | expect(page.status).toBe(502); | |
| 362 | expect(await page.text()).toContain("test"); | |
| 363 | ||
| 364 | const fixed = await commitToBranch(app, alice, repo, { | |
| 365 | branch: "broken", | |
| 366 | message: "Fix the test", | |
| 367 | files: [ | |
| 368 | { | |
| 369 | path: "src/server.test.ts", | |
| 370 | content: | |
| 371 | 'import { expect, test } from "bun:test";\ntest("passes", () => { expect(1).toBe(1); });\n', | |
| 372 | }, | |
| 373 | ], | |
| 374 | }); | |
| 375 | expect(fixed.ok).toBe(true); | |
| 376 | if (!fixed.ok) return; | |
| 377 | const green = await settled( | |
| 378 | (r) => | |
| 379 | r.env === `pull-${opened.value.number}` && | |
| 380 | r.commitOid === fixed.value.commitOid, | |
| 381 | ); | |
| 382 | expect(green.state).toBe("ok"); | |
| 383 | const merged = await mergePull( | |
| 384 | app, | |
| 385 | alice, | |
| 386 | repo, | |
| 387 | getPull(app.db, repo.id, opened.value.number)!, | |
| 388 | "merge", | |
| 389 | ); | |
| 390 | expect(merged.ok).toBe(true); | |
| 391 | }, 120_000); | |
| 392 | ||
| 393 | test("the crew: an agent reads and writes its repo, never the default branch, never a merge", async () => { | |
| 394 | const made = await createAgent(app, alice, { | |
| 395 | username: "bot", | |
| 396 | model: "", | |
| 397 | brief: "Be brief.", | |
| 398 | }); | |
| 399 | expect(made.ok).toBe(true); | |
| 400 | if (!made.ok) return; | |
| 401 | const bot = made.value.user; | |
| 402 | expect(bot.kind).toBe("agent"); | |
| 403 | ||
| 404 | // Not on the crew: cannot be assigned, cannot see a private repo. | |
| 405 | const issue = openIssue(app, alice, repo, { title: "Do a thing" }); | |
| 406 | expect(issue.ok).toBe(true); | |
| 407 | if (!issue.ok) return; | |
| 408 | const early = assignIssue(app, alice, repo, issue.value, bot); | |
| 409 | expect(early.ok).toBe(false); | |
| 410 | if (!early.ok) expect(early.error).toBe("agent_not_on_crew"); | |
| 411 | ||
| 412 | expect(addToCrew(app, alice, repo, "bot").ok).toBe(true); | |
| 413 | const assigned = assignIssue(app, alice, repo, issue.value, bot); | |
| 414 | expect(assigned.ok).toBe(true); | |
| 415 | if (assigned.ok) expect(assigned.value.assigneeName).toBe("bot"); | |
| 416 | // An agent starts no chain: it cannot assign, and it owns no new repos. | |
| 417 | const chain = assignIssue(app, bot, repo, issue.value, bot); | |
| 418 | expect(chain.ok).toBe(false); | |
| 419 | if (!chain.ok) expect(chain.error).toBe("forbidden"); | |
| 420 | const owned = await createRepo(app, bot, { name: "mine" }); | |
| 421 | expect(owned.ok).toBe(false); | |
| 422 | if (!owned.ok) expect(owned.error).toBe("forbidden_owner"); | |
| 423 | ||
| 424 | // As the agent, over git: a branch lands, the default branch is refused. | |
| 425 | const token = createToken(app.db, bot.id, "test", "rw"); | |
| 426 | const remote = `${servers.url.replace("://", `://bot:${token.token}@`)}/${repo.ownerName}/${repo.name}.git`; | |
| 427 | const clone = join(work, "bot-clone"); | |
| 428 | expect( | |
| 429 | (await sh(["git", "clone", "--quiet", remote, clone], work)).code, | |
| 430 | ).toBe(0); | |
| 431 | await Bun.write(join(clone, "BOT.md"), "made by bot\n"); | |
| 432 | expect( | |
| 433 | (await sh(["git", "checkout", "-q", "-b", "bot/1-thing"], clone)).code, | |
| 434 | ).toBe(0); | |
| 435 | expect((await sh(["git", "add", "BOT.md"], clone)).code).toBe(0); | |
| 436 | expect((await sh(["git", "commit", "-qm", "bot work"], clone)).code).toBe( | |
| 437 | 0, | |
| 438 | ); | |
| 439 | expect( | |
| 440 | (await sh(["git", "push", "-q", "origin", "bot/1-thing"], clone)).code, | |
| 441 | ).toBe(0); | |
| 442 | const toMain = await sh( | |
| 443 | ["git", "push", "-q", "origin", `bot/1-thing:${repo.defaultBranch}`], | |
| 444 | clone, | |
| 445 | ); | |
| 446 | expect(toMain.code).not.toBe(0); | |
| 447 | expect(toMain.err).toContain("agents propose through pulls"); | |
| 448 | ||
| 449 | // As the agent, a pull opens; merging it is not the agent's to do. | |
| 450 | const proposed = await openPull(app, bot, repo, { | |
| 451 | title: "Bot work", | |
| 452 | baseBranch: repo.defaultBranch, | |
| 453 | headBranch: "bot/1-thing", | |
| 454 | }); | |
| 455 | expect(proposed.ok).toBe(true); | |
| 456 | if (!proposed.ok) return; | |
| 457 | const refused = await mergePull(app, bot, repo, proposed.value, "merge"); | |
| 458 | expect(refused.ok).toBe(false); | |
| 459 | if (!refused.ok) expect(refused.error).toBe("forbidden"); | |
| 460 | // Its preview came up like anyone's. | |
| 461 | const run = await settled(latest(`pull-${proposed.value.number}`)); | |
| 462 | expect(run.state).toBe("ok"); | |
| 463 | }, 120_000); | |
| 464 | ||
| 465 | test("a restart rebuilds production and the open previews from git", async () => { | |
| 466 | await runtime.stop(); | |
| 467 | // With no runtime there are no app hosts: the forge answers as itself. | |
| 468 | expect(await (await viaHost(`hello.${DOMAIN}`)).text()).not.toContain( | |
| 469 | "<h1>hello</h1>", | |
| 470 | ); | |
| 471 | runtime = new AppsRuntime(app, { | |
| 472 | domain: DOMAIN, | |
| 473 | scheme: "http", | |
| 474 | publicPort: servers.port, | |
| 475 | forgeUrl: servers.url, | |
| 476 | concurrency: 2, | |
| 477 | }); | |
| 478 | await runtime.start(); | |
| 479 | const production = await settled( | |
| 480 | (r) => r.env === "production" && r.reason === "boot", | |
| 481 | ); | |
| 482 | expect(production.state).toBe("ok"); | |
| 483 | expect((await viaHost(`hello.${DOMAIN}`)).status).toBe(200); | |
| 484 | const openPreviews = listRuns(app.db, repo.id, { kind: "deploy" }).filter( | |
| 485 | (r) => r.reason === "boot" && r.env !== "production", | |
| 486 | ); | |
| 487 | expect(openPreviews.length).toBe(1); // the bot's pull is the one open pull | |
| 488 | }, 90_000); | |
| 489 | }); |
test/crew.test.ts added +303
| @@ -0,0 +1,303 @@ | ||
| 1 | /** | |
| 2 | * The crew loop with a stand-in for Claude Code: a script that speaks | |
| 3 | * stream-json, edits a file, commits and pushes — so the whole harness is | |
| 4 | * exercised (dispatch off the outbox, the per-run token, the clone and | |
| 5 | * branch, the prompt, landing the pull, the follow-up that resumes the | |
| 6 | * session, the bounds) without spending a cent or needing a login. | |
| 7 | */ | |
| 8 | ||
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 16 | import { | |
| 17 | addComment, | |
| 18 | assignIssue, | |
| 19 | openIssue, | |
| 20 | setIssueState, | |
| 21 | } from "../src/actions/issues.ts"; | |
| 22 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 23 | import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts"; | |
| 24 | import { workdirFor } from "../src/crew/runner.ts"; | |
| 25 | import { getAgentByName } from "../src/core/agents.ts"; | |
| 26 | import { listComments } from "../src/core/issues.ts"; | |
| 27 | import { listPulls } from "../src/core/pulls.ts"; | |
| 28 | import { resolveOid } from "../src/git/merge.ts"; | |
| 29 | import { repoDir } from "../src/git/repos.ts"; | |
| 30 | import type { Repo } from "../src/core/repos.ts"; | |
| 31 | import { listRuns, type Run } from "../src/core/runs.ts"; | |
| 32 | import { createUser, type User } from "../src/core/users.ts"; | |
| 33 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 34 | ||
| 35 | describe("the crew, with a stand-in Claude Code", () => { | |
| 36 | let dataDir: string; | |
| 37 | let app: App; | |
| 38 | let servers: Servers; | |
| 39 | let crew: CrewHandle; | |
| 40 | let alice: User; | |
| 41 | let bot: User; | |
| 42 | let repo: Repo; | |
| 43 | let argsFile: string; | |
| 44 | let promptFile: string; | |
| 45 | ||
| 46 | async function settled( | |
| 47 | where: (run: Run) => boolean, | |
| 48 | timeoutMs = 30_000, | |
| 49 | ): Promise<Run> { | |
| 50 | const deadline = Date.now() + timeoutMs; | |
| 51 | while (Date.now() < deadline) { | |
| 52 | const run = listRuns(app.db, repo.id, { kind: "agent" }).find(where); | |
| 53 | if ( | |
| 54 | run && | |
| 55 | (run.state === "ok" || | |
| 56 | run.state === "failed" || | |
| 57 | run.state === "cancelled") | |
| 58 | ) | |
| 59 | return run; | |
| 60 | await Bun.sleep(100); | |
| 61 | } | |
| 62 | throw new Error("agent run did not settle"); | |
| 63 | } | |
| 64 | ||
| 65 | beforeAll(async () => { | |
| 66 | dataDir = await mkdtemp(join(tmpdir(), "forge-crew-")); | |
| 67 | app = createApp({ dataDir, port: 0 }); | |
| 68 | servers = await serve(app); | |
| 69 | argsFile = join(dataDir, "fake.args"); | |
| 70 | promptFile = join(dataDir, "fake.prompt"); | |
| 71 | // The stand-in: records its argv and prompt, does the work, pushes. | |
| 72 | const fake = join(dataDir, "fake-claude"); | |
| 73 | writeFileSync( | |
| 74 | fake, | |
| 75 | `#!/bin/sh | |
| 76 | if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi | |
| 77 | printf '%s\\n' "$@" > "${argsFile}" | |
| 78 | cat > "${promptFile}" | |
| 79 | echo '{"type":"system","subtype":"init","session_id":"fake-1","model":"fake"}' | |
| 80 | if grep -q DECLINE-ME "${promptFile}"; then | |
| 81 | echo "half-done edit" > SHOULD-NOT-LAND.md | |
| 82 | echo '{"type":"result","subtype":"success","is_error":false,"result":"DECLINED: this is out of scope for me.","total_cost_usd":0.005,"num_turns":1,"session_id":"fake-2","terminal_reason":"completed"}' | |
| 83 | exit 0 | |
| 84 | fi | |
| 85 | echo "work by the crew $(date +%s%N)" >> CREW.md | |
| 86 | git add -A && git commit -qm "crew work" && git push -q -u origin HEAD | |
| 87 | echo '{"type":"assistant","message":{"content":[{"type":"text","text":"Added CREW.md."},{"type":"tool_use","name":"Bash","input":{"command":"git push"}}]}}' | |
| 88 | echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW.md as asked.","total_cost_usd":0.01,"num_turns":3,"session_id":"fake-1","terminal_reason":"completed"}' | |
| 89 | `, | |
| 90 | ); | |
| 91 | chmodSync(fake, 0o755); | |
| 92 | crew = await startCrew(app, { | |
| 93 | forgeUrl: servers.url, | |
| 94 | localUrl: servers.url, | |
| 95 | binary: fake, | |
| 96 | concurrency: 1, | |
| 97 | budgetUsd: 1, | |
| 98 | runTimeoutMs: 60_000, | |
| 99 | }); | |
| 100 | const a = await createUser(app.db, { | |
| 101 | username: "alice", | |
| 102 | email: "[email protected]", | |
| 103 | password: "a-good-passphrase", | |
| 104 | adminIfFirst: true, | |
| 105 | }); | |
| 106 | if (!a.ok) throw new Error(a.error); | |
| 107 | alice = a.value; | |
| 108 | const made = await createAgent(app, alice, { | |
| 109 | username: "bot", | |
| 110 | brief: "Be brief.", | |
| 111 | }); | |
| 112 | if (!made.ok) throw new Error(String(made.error)); | |
| 113 | bot = made.value.user; | |
| 114 | const created = await createRepo(app, alice, { | |
| 115 | name: "garden", | |
| 116 | visibility: "private", | |
| 117 | }); | |
| 118 | if (!created.ok) throw new Error(String(created.error)); | |
| 119 | repo = created.value; | |
| 120 | const seeded = await commitToBranch(app, alice, repo, { | |
| 121 | message: "Start", | |
| 122 | files: [{ path: "README.md", content: "# garden\n" }], | |
| 123 | }); | |
| 124 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 125 | expect(addToCrew(app, alice, repo, "bot").ok).toBe(true); | |
| 126 | }, 30_000); | |
| 127 | ||
| 128 | afterAll(async () => { | |
| 129 | await crew.stop(); | |
| 130 | servers.stop(); | |
| 131 | app.close(); | |
| 132 | await rm(dataDir, { recursive: true, force: true }); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the stand-in is detected", () => { | |
| 136 | expect(crew.ready).toBe(true); | |
| 137 | expect(crew.detail).toContain("2.1.0"); | |
| 138 | }); | |
| 139 | ||
| 140 | let issueNumber = 0; | |
| 141 | ||
| 142 | test("assignment: clone, branch, brief, work, and the forge opens the pull", async () => { | |
| 143 | const issue = openIssue(app, alice, repo, { | |
| 144 | title: "Plant tulips", | |
| 145 | body: "Along the fence.", | |
| 146 | }); | |
| 147 | expect(issue.ok).toBe(true); | |
| 148 | if (!issue.ok) return; | |
| 149 | issueNumber = issue.value.number; | |
| 150 | expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true); | |
| 151 | ||
| 152 | const run = await settled((r) => r.reason === "assigned"); | |
| 153 | expect(run.state).toBe("ok"); | |
| 154 | expect(run.summary).toMatch(/^opened pull #\d+/); | |
| 155 | expect(run.sessionId).toBe("fake-1"); | |
| 156 | expect(run.costUsd).toBeCloseTo(0.01); | |
| 157 | expect(run.branch).toBe("bot/1-plant-tulips"); | |
| 158 | ||
| 159 | const args = readFileSync(argsFile, "utf8"); | |
| 160 | expect(args).toContain("--append-system-prompt"); | |
| 161 | expect(args).toContain("--restricted"); | |
| 162 | expect(args).toContain("--max-budget-usd\n1.00"); | |
| 163 | expect(args).not.toContain("--resume"); | |
| 164 | // The sandbox policy: the forge reachable, the forge's data not readable, | |
| 165 | // the agent's own clone not among the denials. | |
| 166 | const settings = JSON.parse( | |
| 167 | args.split("--settings\n")[1]!.split("\n")[0]!, | |
| 168 | ) as { | |
| 169 | sandbox: { | |
| 170 | enabled: boolean; | |
| 171 | network: { allowedDomains: string[] }; | |
| 172 | filesystem: { denyRead: string[] }; | |
| 173 | }; | |
| 174 | }; | |
| 175 | expect(settings.sandbox.enabled).toBe(true); | |
| 176 | expect(settings.sandbox.network.allowedDomains).toContain("localhost"); | |
| 177 | expect(settings.sandbox.network.allowedDomains).toContain( | |
| 178 | "registry.npmjs.org", | |
| 179 | ); | |
| 180 | expect(settings.sandbox.filesystem.denyRead).toContain( | |
| 181 | join(dataDir, "repos"), | |
| 182 | ); | |
| 183 | expect(settings.sandbox.filesystem.denyRead).toContain( | |
| 184 | join(dataDir, "secret.key"), | |
| 185 | ); | |
| 186 | expect( | |
| 187 | settings.sandbox.filesystem.denyRead.some((p) => p.includes("/crew/bot")), | |
| 188 | ).toBe(false); | |
| 189 | const prompt = readFileSync(promptFile, "utf8"); | |
| 190 | expect(prompt).toContain("Issue #1: Plant tulips"); | |
| 191 | expect(prompt).toContain("Along the fence."); | |
| 192 | expect(prompt).toContain("bot/1-plant-tulips"); | |
| 193 | ||
| 194 | const pulls = listPulls(app.db, repo.id, { state: "open" }); | |
| 195 | expect(pulls.length).toBe(1); | |
| 196 | const pull = pulls[0]!; | |
| 197 | expect(pull.authorName).toBe("bot"); | |
| 198 | expect(pull.headBranch).toBe("bot/1-plant-tulips"); | |
| 199 | expect(pull.title).toBe("Plant tulips"); | |
| 200 | expect(pull.body).toContain("Added CREW.md as asked."); | |
| 201 | expect(pull.body).toContain(`Closes #${issueNumber}`); | |
| 202 | // The run's credential is gone; the clone stays for the follow-up. | |
| 203 | const tokens = app.db | |
| 204 | .query< | |
| 205 | { n: number }, | |
| 206 | [] | |
| 207 | >("SELECT COUNT(*) AS n FROM token WHERE name LIKE 'run:%'") | |
| 208 | .get(); | |
| 209 | expect(tokens?.n).toBe(0); | |
| 210 | expect( | |
| 211 | existsSync( | |
| 212 | join( | |
| 213 | workdirFor( | |
| 214 | dataDir, | |
| 215 | getAgentByName(app.db, "bot")!, | |
| 216 | repo, | |
| 217 | issueNumber, | |
| 218 | ), | |
| 219 | ".git", | |
| 220 | ), | |
| 221 | ), | |
| 222 | ).toBe(true); | |
| 223 | }, 60_000); | |
| 224 | ||
| 225 | test("feedback from a person resumes the session on the same branch", async () => { | |
| 226 | const pull = listPulls(app.db, repo.id, { state: "open" })[0]!; | |
| 227 | const before = listRuns(app.db, repo.id, { kind: "agent" }).length; | |
| 228 | // The agent's own comment is not feedback. | |
| 229 | expect(addComment(app, bot, repo, pull, "Working on it.").ok).toBe(true); | |
| 230 | await Bun.sleep(300); | |
| 231 | expect(listRuns(app.db, repo.id, { kind: "agent" }).length).toBe(before); | |
| 232 | // A person's is. | |
| 233 | expect( | |
| 234 | addComment(app, alice, repo, pull, "Also plant daffodils please.").ok, | |
| 235 | ).toBe(true); | |
| 236 | const run = await settled((r) => r.reason === "feedback"); | |
| 237 | expect(run.state).toBe("ok"); | |
| 238 | expect(run.summary).toMatch(/^updated pull #\d+/); | |
| 239 | const args = readFileSync(argsFile, "utf8"); | |
| 240 | expect(args).toContain("--resume\nfake-1"); | |
| 241 | const prompt = readFileSync(promptFile, "utf8"); | |
| 242 | expect(prompt).toContain("feedback on your pull"); | |
| 243 | expect(prompt).toContain("Also plant daffodils please."); | |
| 244 | expect(prompt).not.toContain("Working on it."); | |
| 245 | // The agent reported on the pull, and the branch moved. | |
| 246 | const comments = listComments(app.db, pull.id).filter( | |
| 247 | (c) => c.authorName === "bot", | |
| 248 | ); | |
| 249 | expect(comments.map((c) => c.body)).toContain("Added CREW.md as asked."); | |
| 250 | // The branch moved on the forge (the pull row keeps its opening snapshot; | |
| 251 | // the live head is git's). | |
| 252 | const tip = await resolveOid( | |
| 253 | repoDir(app.reposRoot, repo.ownerName, repo.name), | |
| 254 | `refs/heads/${pull.headBranch}`, | |
| 255 | ); | |
| 256 | expect(tip).toBeDefined(); | |
| 257 | expect(tip).not.toBe(pull.headOid); | |
| 258 | }, 60_000); | |
| 259 | ||
| 260 | test("an agent may decline: nothing lands, the reason is posted", async () => { | |
| 261 | const issue = openIssue(app, alice, repo, { | |
| 262 | title: "Do something shady", | |
| 263 | body: "DECLINE-ME", | |
| 264 | }); | |
| 265 | expect(issue.ok).toBe(true); | |
| 266 | if (!issue.ok) return; | |
| 267 | expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true); | |
| 268 | const run = await settled((r) => r.issueId === issue.value.id); | |
| 269 | expect(run.state).toBe("failed"); | |
| 270 | expect(run.summary).toBe("declined: this is out of scope for me."); | |
| 271 | // No pull for it, no branch pushed, and the half-done edit stayed home. | |
| 272 | const pulls = listPulls(app.db, repo.id, { state: "open" }); | |
| 273 | expect(pulls.some((p) => p.title === "Do something shady")).toBe(false); | |
| 274 | const tip = await resolveOid( | |
| 275 | repoDir(app.reposRoot, repo.ownerName, repo.name), | |
| 276 | `refs/heads/${run.branch}`, | |
| 277 | ); | |
| 278 | expect(tip).toBeUndefined(); | |
| 279 | const comments = listComments(app.db, issue.value.id); | |
| 280 | expect(comments.map((c) => `${c.authorName}: ${c.body}`)).toContain( | |
| 281 | "bot: Declined: this is out of scope for me.", | |
| 282 | ); | |
| 283 | }, 60_000); | |
| 284 | ||
| 285 | test("closing the pull removes the clone; a closed issue starts nothing", async () => { | |
| 286 | const pull = listPulls(app.db, repo.id, { state: "open" })[0]!; | |
| 287 | const dir = workdirFor( | |
| 288 | dataDir, | |
| 289 | getAgentByName(app.db, "bot")!, | |
| 290 | repo, | |
| 291 | issueNumber, | |
| 292 | ); | |
| 293 | expect(existsSync(dir)).toBe(true); | |
| 294 | expect(setIssueState(app, alice, repo, pull, "closed").ok).toBe(true); | |
| 295 | const deadline = Date.now() + 5_000; | |
| 296 | while (existsSync(dir) && Date.now() < deadline) await Bun.sleep(50); | |
| 297 | expect(existsSync(dir)).toBe(false); | |
| 298 | const before = listRuns(app.db, repo.id, { kind: "agent" }).length; | |
| 299 | expect(addComment(app, alice, repo, pull, "one more thing").ok).toBe(true); | |
| 300 | await Bun.sleep(300); | |
| 301 | expect(listRuns(app.db, repo.id, { kind: "agent" }).length).toBe(before); | |
| 302 | }, 30_000); | |
| 303 | }); |
test/surfaces-adversarial.test.ts added +900
| @@ -0,0 +1,900 @@ | ||
| 1 | /** | |
| 2 | * Adversarial coverage for the web and TUI surfaces of apps, runs and the | |
| 3 | * crew (docs/APPS.md) — what `test/web-apps.test.ts` and `test/tui-apps.test.ts` | |
| 4 | * do not already prove: CSRF on the routes those files did not target, | |
| 5 | * rendering safety (a run log, an agent's brief, an issue title are all | |
| 6 | * attacker-controlled text), the strict CSP every new page must carry, that | |
| 7 | * a listing never pays a query per row, a guest's reach in the terminal, and | |
| 8 | * that a run's log cannot corrupt the terminal frame it is drawn into. | |
| 9 | * | |
| 10 | * No `AppsRuntime` runs here on purpose: every page under test renders from | |
| 11 | * rows alone (`runtime === undefined` is itself a first-class, already | |
| 12 | * exercised state — "Nothing is serving apps on this forge"), which keeps | |
| 13 | * this file fast and lets the N+1 assertions attribute every statement to | |
| 14 | * the code under test rather than to a deploy's own bookkeeping. | |
| 15 | */ | |
| 16 | ||
| 17 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 18 | import type { Database } from "bun:sqlite"; | |
| 19 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 20 | import { tmpdir } from "node:os"; | |
| 21 | import { join } from "node:path"; | |
| 22 | import { createAgent } from "../src/actions/crew.ts"; | |
| 23 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 24 | import { openPull } from "../src/actions/pulls.ts"; | |
| 25 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 26 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 27 | import type { Repo } from "../src/core/repos.ts"; | |
| 28 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 29 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 30 | import { id } from "../src/kernel/id.ts"; | |
| 31 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 32 | import { appendRunLog, runLogListenerCount } from "../src/runs/log.ts"; | |
| 33 | import { content as contentStyle } from "../src/web/style/content.ts"; | |
| 34 | import { runScreen } from "../src/tui/screens/run.ts"; | |
| 35 | import { appsScreen } from "../src/tui/screens/apps.ts"; | |
| 36 | import { appScreen } from "../src/tui/screens/app.ts"; | |
| 37 | import { crewScreen } from "../src/tui/screens/crew.ts"; | |
| 38 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 39 | import type { Session, TuiIdentity } from "../src/tui/session.ts"; | |
| 40 | import type { App as TuiApp, Screen } from "../src/tui/app/app.ts"; | |
| 41 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 42 | import { settleApp } from "./helpers/tui.ts"; | |
| 43 | ||
| 44 | setLogLevel("error"); | |
| 45 | ||
| 46 | /** Count the SQL statements a block of work runs — mirrors test/counts.test.ts. */ | |
| 47 | async function statements(db: Database, work: () => Promise<void> | void) { | |
| 48 | const original = db.query.bind(db); | |
| 49 | let count = 0; | |
| 50 | (db as unknown as { query: unknown }).query = (sql: string) => { | |
| 51 | count++; | |
| 52 | return original(sql); | |
| 53 | }; | |
| 54 | try { | |
| 55 | await work(); | |
| 56 | } finally { | |
| 57 | (db as unknown as { query: unknown }).query = original; | |
| 58 | } | |
| 59 | return count; | |
| 60 | } | |
| 61 | ||
| 62 | const words = (html: string): string => html.replace(/\s+/g, " "); | |
| 63 | ||
| 64 | // ============================================================================ | |
| 65 | // Web | |
| 66 | // ============================================================================ | |
| 67 | ||
| 68 | describe("surfaces adversarial: web", () => { | |
| 69 | let dataDir = ""; | |
| 70 | let app: Forge; | |
| 71 | let servers: Servers; | |
| 72 | let base = ""; | |
| 73 | let alice = ""; | |
| 74 | let alicePassword = "alice-adversarial-pass"; | |
| 75 | let hello: { id: string; ownerName: string; name: string }; | |
| 76 | ||
| 77 | const web = ( | |
| 78 | cookie: string, | |
| 79 | path: string, | |
| 80 | init: RequestInit & { form?: Record<string, string>; origin?: string } = {}, | |
| 81 | ): Promise<Response> => { | |
| 82 | const headers = new Headers(init.headers); | |
| 83 | if (cookie !== "") headers.set("cookie", cookie); | |
| 84 | const method = init.form !== undefined ? "POST" : (init.method ?? "GET"); | |
| 85 | if (init.form !== undefined) | |
| 86 | headers.set("origin", init.origin ?? new URL(base).origin); | |
| 87 | return fetch(`${base}${path}`, { | |
| 88 | ...init, | |
| 89 | method, | |
| 90 | headers, | |
| 91 | redirect: "manual", | |
| 92 | ...(init.form === undefined | |
| 93 | ? {} | |
| 94 | : { body: new URLSearchParams(init.form) }), | |
| 95 | }); | |
| 96 | }; | |
| 97 | ||
| 98 | const bodyOf = async (cookie: string, path: string): Promise<string> => { | |
| 99 | const response = await web(cookie, path); | |
| 100 | expect(`${path} → ${response.status}`).toBe(`${path} → 200`); | |
| 101 | return await response.text(); | |
| 102 | }; | |
| 103 | ||
| 104 | const signUp = async ( | |
| 105 | username: string, | |
| 106 | password = "a-good-passphrase-1", | |
| 107 | ): Promise<string> => { | |
| 108 | const response = await fetch(`${base}/signup`, { | |
| 109 | method: "POST", | |
| 110 | body: new URLSearchParams({ | |
| 111 | username, | |
| 112 | email: `${username}@forge.test`, | |
| 113 | password, | |
| 114 | }), | |
| 115 | redirect: "manual", | |
| 116 | }); | |
| 117 | return (response.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 118 | }; | |
| 119 | ||
| 120 | beforeAll(async () => { | |
| 121 | dataDir = await mkdtemp(join(tmpdir(), "forge-surfaces-adv-")); | |
| 122 | app = createApp({ dataDir, port: 0 }); | |
| 123 | servers = await serve(app); | |
| 124 | base = servers.url; | |
| 125 | alice = await signUp("alice", alicePassword); // first account: the admin | |
| 126 | }, 30_000); | |
| 127 | ||
| 128 | afterAll(async () => { | |
| 129 | servers.stop(); | |
| 130 | app.close(); | |
| 131 | await rm(dataDir, { recursive: true, force: true }); | |
| 132 | }); | |
| 133 | ||
| 134 | // ---- 1. floors & CSRF on routes the other suite did not target ---- | |
| 135 | ||
| 136 | test("CSRF: a pull's assignee and a run's cancel refuse a forged origin", async () => { | |
| 137 | const madeRepo = await createRepo(app, aliceUser(), { | |
| 138 | name: "csrf-repo", | |
| 139 | visibility: "public", | |
| 140 | }); | |
| 141 | expect(madeRepo.ok).toBe(true); | |
| 142 | if (!madeRepo.ok) return; | |
| 143 | const repo = madeRepo.value; | |
| 144 | // A bare `issue` row of kind 'pull' is all `threadContext` needs. | |
| 145 | const issueId = id(); | |
| 146 | app.db | |
| 147 | .query( | |
| 148 | `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state) | |
| 149 | VALUES (?1, ?2, 1, 'pull', 'CSRF target', '', ?3, 'open')`, | |
| 150 | ) | |
| 151 | .run(issueId, repo.id, aliceUser().id); | |
| 152 | const runId = id(); | |
| 153 | app.db | |
| 154 | .query( | |
| 155 | `INSERT INTO run (id, repo_id, kind, env, state) VALUES (?1, ?2, 'deploy', 'production', 'running')`, | |
| 156 | ) | |
| 157 | .run(runId, repo.id); | |
| 158 | ||
| 159 | for (const path of [ | |
| 160 | `/alice/csrf-repo/pulls/1/assignee`, | |
| 161 | `/alice/csrf-repo/runs/${runId}/cancel`, | |
| 162 | ]) { | |
| 163 | const forged = await web(alice, path, { | |
| 164 | form: { assignee: "alice" }, | |
| 165 | origin: "https://evil.example", | |
| 166 | }); | |
| 167 | expect(`${path} → ${forged.status}`).toBe(`${path} → 403`); | |
| 168 | } | |
| 169 | }); | |
| 170 | ||
| 171 | // ---- 2. open redirect ---- | |
| 172 | ||
| 173 | test("open redirect: a malformed environment never reaches urlFor — it 404s first", async () => { | |
| 174 | const madeRepo = await createRepo(app, aliceUser(), { | |
| 175 | name: "redirect-repo", | |
| 176 | visibility: "public", | |
| 177 | }); | |
| 178 | expect(madeRepo.ok).toBe(true); | |
| 179 | if (!madeRepo.ok) return; | |
| 180 | const repo = madeRepo.value; | |
| 181 | app.db | |
| 182 | .query( | |
| 183 | `INSERT INTO app (id, repo_id, slug, access, preview_data) VALUES (?1, ?2, 'redirect-repo', 'public', 'copy')`, | |
| 184 | ) | |
| 185 | .run(id(), repo.id); | |
| 186 | const evilEnvs = [ | |
| 187 | "../production", | |
| 188 | "production%2F..", | |
| 189 | "pull-1..%2Fevil", | |
| 190 | "http://evil.example", | |
| 191 | "//evil.example", | |
| 192 | "production/../../evil", | |
| 193 | "pull-0", | |
| 194 | "pull--1", | |
| 195 | "pull-1e2", | |
| 196 | "PRODUCTION", | |
| 197 | ]; | |
| 198 | for (const env of evilEnvs) { | |
| 199 | const response = await web( | |
| 200 | alice, | |
| 201 | `/alice/redirect-repo/app/${encodeURIComponent(env)}/open`, | |
| 202 | ); | |
| 203 | // Never a redirect to anything other than this forge's own 404 — | |
| 204 | // in particular never a 302/303 carrying an off-forge Location. | |
| 205 | if (response.status === 302 || response.status === 303) { | |
| 206 | const location = response.headers.get("location") ?? ""; | |
| 207 | expect(`${env} → ${location}`).toMatch(/^\/signin/); | |
| 208 | } else { | |
| 209 | expect(`${env} → ${response.status}`).toBe(`${env} → 404`); | |
| 210 | } | |
| 211 | } | |
| 212 | // A double slash inside the path segment splits the route itself, so it | |
| 213 | // never even reaches the handler — still never an open redirect. | |
| 214 | const split = await web(alice, `/alice/redirect-repo/app/pull-1/evil/open`); | |
| 215 | expect(split.status).toBe(404); | |
| 216 | }); | |
| 217 | ||
| 218 | // ---- 3. rendering ---- | |
| 219 | ||
| 220 | test("rendering: an agent's brief, an issue title and a deploy error are escaped everywhere they appear", async () => { | |
| 221 | const payload = `<script>alert(1)</script>&"'`; | |
| 222 | const escaped = "<script>alert(1)</script>&"'"; | |
| 223 | ||
| 224 | const agent = await createAgent(app, aliceUser(), { | |
| 225 | username: "xssbot", | |
| 226 | brief: payload, | |
| 227 | }); | |
| 228 | expect(agent.ok).toBe(true); | |
| 229 | const crewPage = await bodyOf(alice, "/settings/crew"); | |
| 230 | expect(crewPage).not.toContain(payload); | |
| 231 | expect(crewPage).toContain(escaped); | |
| 232 | ||
| 233 | const madeRepo = await createRepo(app, aliceUser(), { | |
| 234 | name: "xss-repo", | |
| 235 | visibility: "public", | |
| 236 | }); | |
| 237 | expect(madeRepo.ok).toBe(true); | |
| 238 | if (!madeRepo.ok) return; | |
| 239 | const repo = madeRepo.value; | |
| 240 | const opened = await web(alice, "/alice/xss-repo/issues/new", { | |
| 241 | form: { title: payload, body: "" }, | |
| 242 | }); | |
| 243 | expect(opened.status).toBe(303); | |
| 244 | const assignedToAlice = await web( | |
| 245 | alice, | |
| 246 | "/alice/xss-repo/issues/1/assignee", | |
| 247 | { | |
| 248 | form: { assignee: "alice" }, | |
| 249 | }, | |
| 250 | ); | |
| 251 | expect(assignedToAlice.status).toBe(303); | |
| 252 | const dash = await bodyOf(alice, "/"); | |
| 253 | expect(dash).not.toContain(payload); | |
| 254 | expect(dash).toContain(escaped); | |
| 255 | ||
| 256 | // A deploy's error message is attacker-influenced too (it is the tail of | |
| 257 | // whatever the app's own install/build/health check printed). | |
| 258 | app.db | |
| 259 | .query( | |
| 260 | `INSERT INTO app (id, repo_id, slug, access, preview_data) VALUES (?1, ?2, 'xss-repo', 'public', 'copy')`, | |
| 261 | ) | |
| 262 | .run(id(), repo.id); | |
| 263 | const runId = id(); | |
| 264 | app.db | |
| 265 | .query( | |
| 266 | `INSERT INTO run (id, repo_id, kind, env, state, summary) | |
| 267 | VALUES (?1, ?2, 'deploy', 'production', 'failed', ?3)`, | |
| 268 | ) | |
| 269 | .run(runId, repo.id, payload); | |
| 270 | const runPage = await bodyOf(alice, `/alice/xss-repo/runs/${runId}`); | |
| 271 | expect(runPage).not.toContain(payload); | |
| 272 | expect(runPage).toContain(escaped); | |
| 273 | }); | |
| 274 | ||
| 275 | test("rendering: a run log survives script tags, closing </pre>, control bytes and a 16KB line", async () => { | |
| 276 | const madeRepo = await createRepo(app, aliceUser(), { | |
| 277 | name: "log-repo", | |
| 278 | visibility: "public", | |
| 279 | }); | |
| 280 | expect(madeRepo.ok).toBe(true); | |
| 281 | if (!madeRepo.ok) return; | |
| 282 | const repo = madeRepo.value; | |
| 283 | const runId = id(); | |
| 284 | app.db | |
| 285 | .query( | |
| 286 | `INSERT INTO run (id, repo_id, kind, env, state) VALUES (?1, ?2, 'deploy', 'production', 'running')`, | |
| 287 | ) | |
| 288 | .run(runId, repo.id); | |
| 289 | ||
| 290 | const huge = "x".repeat(20_000); | |
| 291 | const lines = [ | |
| 292 | "<script>alert(document.cookie)</script>", | |
| 293 | "</pre><script>alert(2)</script>", | |
| 294 | "5 > 3 & 2 < 4", | |
| 295 | "forge_rw_abcdefghijklmnopqrstuvwxyz0123456789", | |
| 296 | "\x1b[31mred\x1b[0m and a bell \x07 and a null \x00", | |
| 297 | huge, | |
| 298 | ]; | |
| 299 | for (const line of lines) appendRunLog(dataDir, runId, line); | |
| 300 | ||
| 301 | const page = await bodyOf(alice, `/alice/log-repo/runs/${runId}`); | |
| 302 | // The tags never appear unescaped — no live <script>, no early </pre>. | |
| 303 | expect(page).not.toContain("<script>alert(document.cookie)</script>"); | |
| 304 | expect(page).not.toContain("</pre><script>"); | |
| 305 | expect(page).toContain( | |
| 306 | "<script>alert(document.cookie)</script>", | |
| 307 | ); | |
| 308 | expect(page).toContain("5 > 3 & 2 < 4"); | |
| 309 | // A run log is stored with no embedded newline (appendRunLog collapses | |
| 310 | // one), and one appended write is one rendered line — the huge line does | |
| 311 | // not blow up the page or get silently dropped. | |
| 312 | expect(page).toContain(huge.slice(0, 100)); | |
| 313 | // The token-shaped string is redacted on the way into the log (rule 6, | |
| 314 | // docs/APPS.md) — it must never reach the page verbatim, escaped or not. | |
| 315 | expect(page).not.toContain("forge_rw_abcdefghijklmnopqrstuvwxyz0123456789"); | |
| 316 | expect(page).toContain("[redacted]"); | |
| 317 | // Exactly one <pre id="run-log"> — nothing in the log broke out of it. | |
| 318 | expect(page.match(/<pre[^>]*id="run-log"/g)?.length).toBe(1); | |
| 319 | }); | |
| 320 | ||
| 321 | // ---- 4. CSP ---- | |
| 322 | ||
| 323 | test("CSP: the apps, runs and crew pages carry the same strict policy as the rest", async () => { | |
| 324 | for (const path of ["/apps", "/apps/new", "/settings/crew"]) { | |
| 325 | const response = await web(alice, path); | |
| 326 | const csp = response.headers.get("content-security-policy") ?? ""; | |
| 327 | expect(`${path}: ${csp}`).toContain("script-src 'nonce-"); | |
| 328 | expect(csp).not.toContain("unsafe-inline"); | |
| 329 | const body = await response.text(); | |
| 330 | const nonceMatch = /nonce-([A-Za-z0-9+/=_-]+)/.exec(csp); | |
| 331 | expect(nonceMatch).not.toBeNull(); | |
| 332 | // Every *executable* <script> tag carries exactly that nonce — a | |
| 333 | // `type="application/json"` data island is inert and CSP's script-src | |
| 334 | // never gates it, so it is excluded rather than asserted on. | |
| 335 | const scripts = [...body.matchAll(/<script\b[^>]*>/g)].filter( | |
| 336 | ([tag]) => !/type="application\/json"/.test(tag), | |
| 337 | ); | |
| 338 | expect(scripts.length).toBeGreaterThan(0); | |
| 339 | for (const [tag] of scripts) { | |
| 340 | expect(tag).toContain(`nonce="${nonceMatch![1]}"`); | |
| 341 | } | |
| 342 | // No inline event handler, no interpolated style attribute. | |
| 343 | expect(body).not.toMatch(/\son[a-z]+="/i); | |
| 344 | } | |
| 345 | }); | |
| 346 | ||
| 347 | // ---- 5. N+1 ---- | |
| 348 | ||
| 349 | test("N+1: /apps does not cost more queries as private, org-visible apps grow", async () => { | |
| 350 | const org = createOrg(app.db, "acme-apps", aliceUser().id); | |
| 351 | expect(org.ok).toBe(true); | |
| 352 | if (!org.ok) return; | |
| 353 | const team = createTeam(app.db, org.value.id, "readers", "read"); | |
| 354 | expect(team.ok).toBe(true); | |
| 355 | if (!team.ok) return; | |
| 356 | const viewer = await createUser(app.db, { | |
| 357 | username: "viewer1", | |
| 358 | email: "[email protected]", | |
| 359 | password: "viewer-pass-1234", | |
| 360 | }); | |
| 361 | expect(viewer.ok).toBe(true); | |
| 362 | if (!viewer.ok) return; | |
| 363 | expect(addTeamMember(app.db, team.value, viewer.value.id)).toBe(true); | |
| 364 | const viewerCookie = await signUp("viewer1-cookie"); // unused path, just to reuse signUp's cookie header shape | |
| 365 | void viewerCookie; | |
| 366 | const cookie = await loginAs(base, "viewer1", "viewer-pass-1234"); | |
| 367 | ||
| 368 | const seedApps = (n: number): void => { | |
| 369 | for (let i = 0; i < n; i++) { | |
| 370 | const repoId = id(); | |
| 371 | app.db | |
| 372 | .query( | |
| 373 | `INSERT INTO repo (id, owner_id, name, visibility, default_branch) VALUES (?1, ?2, ?3, 'private', 'main')`, | |
| 374 | ) | |
| 375 | .run(repoId, org.value.id, `nplusone-${i}-${Date.now()}`); | |
| 376 | app.db | |
| 377 | .query( | |
| 378 | `INSERT INTO app (id, repo_id, slug, access, preview_data) VALUES (?1, ?2, ?3, 'private', 'copy')`, | |
| 379 | ) | |
| 380 | .run(id(), repoId, `nplusone-${i}-${id()}`.slice(0, 40)); | |
| 381 | } | |
| 382 | }; | |
| 383 | ||
| 384 | seedApps(5); | |
| 385 | const small = await statements(app.db, async () => { | |
| 386 | const response = await web(cookie, "/apps"); | |
| 387 | expect(response.status).toBe(200); | |
| 388 | }); | |
| 389 | seedApps(25); // 30 total now | |
| 390 | const large = await statements(app.db, async () => { | |
| 391 | const response = await web(cookie, "/apps"); | |
| 392 | expect(response.status).toBe(200); | |
| 393 | }); | |
| 394 | expect(large).toBe(small); | |
| 395 | }); | |
| 396 | ||
| 397 | test("N+1: the dashboard's Apps and On-you cards stay flat with 30 apps and 30 assigned issues", async () => { | |
| 398 | const owner = await createUser(app.db, { | |
| 399 | username: "dashowner", | |
| 400 | email: "[email protected]", | |
| 401 | password: "dashowner-pass-123", | |
| 402 | }); | |
| 403 | expect(owner.ok).toBe(true); | |
| 404 | if (!owner.ok) return; | |
| 405 | const org = createOrg(app.db, "acme-dash", owner.value.id); | |
| 406 | expect(org.ok).toBe(true); | |
| 407 | if (!org.ok) return; | |
| 408 | const team = createTeam(app.db, org.value.id, "readers", "read"); | |
| 409 | expect(team.ok).toBe(true); | |
| 410 | if (!team.ok) return; | |
| 411 | const viewer = await createUser(app.db, { | |
| 412 | username: "dashviewer", | |
| 413 | email: "[email protected]", | |
| 414 | password: "dashviewer-pass-1", | |
| 415 | }); | |
| 416 | expect(viewer.ok).toBe(true); | |
| 417 | if (!viewer.ok) return; | |
| 418 | expect(addTeamMember(app.db, team.value, viewer.value.id)).toBe(true); | |
| 419 | const cookie = await loginAs(base, "dashviewer", "dashviewer-pass-1"); | |
| 420 | ||
| 421 | const seed = (n: number, offset: number): void => { | |
| 422 | for (let i = 0; i < n; i++) { | |
| 423 | const idx = offset + i; | |
| 424 | const repoId = id(); | |
| 425 | app.db | |
| 426 | .query( | |
| 427 | `INSERT INTO repo (id, owner_id, name, visibility, default_branch) VALUES (?1, ?2, ?3, 'private', 'main')`, | |
| 428 | ) | |
| 429 | .run(repoId, org.value.id, `dash-${idx}-${Date.now()}`); | |
| 430 | app.db | |
| 431 | .query( | |
| 432 | `INSERT INTO app (id, repo_id, slug, access, preview_data) VALUES (?1, ?2, ?3, 'private', 'copy')`, | |
| 433 | ) | |
| 434 | .run(id(), repoId, `dash-${idx}-${id()}`.slice(0, 40)); | |
| 435 | const assignedIssue = id(); | |
| 436 | app.db | |
| 437 | .query( | |
| 438 | `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, assignee_id) | |
| 439 | VALUES (?1, ?2, 1, 'issue', ?3, '', ?4, 'open', ?5)`, | |
| 440 | ) | |
| 441 | .run( | |
| 442 | assignedIssue, | |
| 443 | repoId, | |
| 444 | `assigned ${idx}`, | |
| 445 | owner.value.id, | |
| 446 | viewer.value.id, | |
| 447 | ); | |
| 448 | } | |
| 449 | }; | |
| 450 | ||
| 451 | seed(5, 0); | |
| 452 | const small = await statements(app.db, async () => { | |
| 453 | const response = await web(cookie, "/"); | |
| 454 | expect(response.status).toBe(200); | |
| 455 | }); | |
| 456 | seed(25, 5); // 30 total of each | |
| 457 | const large = await statements(app.db, async () => { | |
| 458 | const response = await web(cookie, "/"); | |
| 459 | expect(response.status).toBe(200); | |
| 460 | }); | |
| 461 | expect(large).toBe(small); | |
| 462 | }); | |
| 463 | ||
| 464 | // KNOWN LIMITATION, out of this file's scope: `needsYouCard` (site.ts) | |
| 465 | // still calls `canWrite` once per *distinct* repository among the crew's | |
| 466 | // open pulls. Write access is answered for all of them by one | |
| 467 | // `writableRepoIds` query (`REPO_WRITABLE`, the SQL twin of `canWrite`), | |
| 468 | // so the count must not move with the number of distinct repositories. | |
| 469 | test("N+1: needsYouCard does not cost more as agent pulls span more distinct repos", async () => { | |
| 470 | const owner = await createUser(app.db, { | |
| 471 | username: "needsowner", | |
| 472 | email: "[email protected]", | |
| 473 | password: "needsowner-pass-1", | |
| 474 | }); | |
| 475 | if (!owner.ok) return; | |
| 476 | const org = createOrg(app.db, "acme-needs", owner.value.id); | |
| 477 | if (!org.ok) return; | |
| 478 | const team = createTeam(app.db, org.value.id, "writers", "write"); | |
| 479 | if (!team.ok) return; | |
| 480 | const viewer = await createUser(app.db, { | |
| 481 | username: "needsviewer", | |
| 482 | email: "[email protected]", | |
| 483 | password: "needsviewer-pass-1", | |
| 484 | }); | |
| 485 | if (!viewer.ok) return; | |
| 486 | addTeamMember(app.db, team.value, viewer.value.id); | |
| 487 | const agent = await createAgent(app, aliceUser(), { | |
| 488 | username: "needsbot", | |
| 489 | }); | |
| 490 | if (!agent.ok) return; | |
| 491 | const cookie = await loginAs(base, "needsviewer", "needsviewer-pass-1"); | |
| 492 | ||
| 493 | const seed = (n: number, offset: number): void => { | |
| 494 | for (let i = 0; i < n; i++) { | |
| 495 | const idx = offset + i; | |
| 496 | const repoId = id(); | |
| 497 | app.db | |
| 498 | .query( | |
| 499 | `INSERT INTO repo (id, owner_id, name, visibility, default_branch) VALUES (?1, ?2, ?3, 'private', 'main')`, | |
| 500 | ) | |
| 501 | .run(repoId, org.value.id, `needs-${idx}-${Date.now()}`); | |
| 502 | app.db | |
| 503 | .query( | |
| 504 | `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state) | |
| 505 | VALUES (?1, ?2, 1, 'pull', ?3, '', ?4, 'open')`, | |
| 506 | ) | |
| 507 | .run(id(), repoId, `agent pull ${idx}`, agent.value.user.id); | |
| 508 | } | |
| 509 | }; | |
| 510 | ||
| 511 | seed(5, 0); | |
| 512 | const small = await statements(app.db, async () => { | |
| 513 | await web(cookie, "/"); | |
| 514 | }); | |
| 515 | seed(25, 5); | |
| 516 | const large = await statements(app.db, async () => { | |
| 517 | await web(cookie, "/"); | |
| 518 | }); | |
| 519 | expect(large).toBe(small); | |
| 520 | }); | |
| 521 | ||
| 522 | test("N+1: the runs list does not cost more at 50 rows than at 5", async () => { | |
| 523 | const madeRepo = await createRepo(app, aliceUser(), { | |
| 524 | name: "runs-n1", | |
| 525 | visibility: "public", | |
| 526 | }); | |
| 527 | expect(madeRepo.ok).toBe(true); | |
| 528 | if (!madeRepo.ok) return; | |
| 529 | const repo = madeRepo.value; | |
| 530 | const seedRuns = (n: number): void => { | |
| 531 | for (let i = 0; i < n; i++) { | |
| 532 | app.db | |
| 533 | .query( | |
| 534 | `INSERT INTO run (id, repo_id, kind, env, state, summary) VALUES (?1, ?2, 'deploy', 'production', 'ok', 'ok')`, | |
| 535 | ) | |
| 536 | .run(id(), repo.id); | |
| 537 | } | |
| 538 | }; | |
| 539 | seedRuns(5); | |
| 540 | const small = await statements(app.db, async () => { | |
| 541 | const response = await web(alice, "/alice/runs-n1/runs"); | |
| 542 | expect(response.status).toBe(200); | |
| 543 | }); | |
| 544 | seedRuns(45); // 50 total | |
| 545 | const large = await statements(app.db, async () => { | |
| 546 | const response = await web(alice, "/alice/runs-n1/runs"); | |
| 547 | expect(response.status).toBe(200); | |
| 548 | }); | |
| 549 | expect(large).toBe(small); | |
| 550 | }); | |
| 551 | ||
| 552 | test("N+1: a pull's crew activity does not cost more as its agent runs grow", async () => { | |
| 553 | const agent = await createAgent(app, aliceUser(), { | |
| 554 | username: "crewn1bot", | |
| 555 | }); | |
| 556 | expect(agent.ok).toBe(true); | |
| 557 | if (!agent.ok) return; | |
| 558 | const madeRepo = await createRepo(app, aliceUser(), { | |
| 559 | name: "crew-n1", | |
| 560 | visibility: "public", | |
| 561 | }); | |
| 562 | expect(madeRepo.ok).toBe(true); | |
| 563 | if (!madeRepo.ok) return; | |
| 564 | const repo = madeRepo.value; | |
| 565 | // The default branch needs a commit of its own before another branch | |
| 566 | // can be based on it — an empty repo has no `main` to diff against yet. | |
| 567 | const seeded = await commitToBranch(app, aliceUser(), repo, { | |
| 568 | message: "first", | |
| 569 | files: [{ path: "README.md", content: "# crew-n1\n" }], | |
| 570 | }); | |
| 571 | expect(seeded.ok).toBe(true); | |
| 572 | const committed = await commitToBranch(app, aliceUser(), repo, { | |
| 573 | branch: "work", | |
| 574 | base: repo.defaultBranch, | |
| 575 | message: "work", | |
| 576 | files: [{ path: "A.md", content: "a\n" }], | |
| 577 | }); | |
| 578 | expect(committed.ok).toBe(true); | |
| 579 | const opened = await openPull(app, aliceUser(), repo, { | |
| 580 | title: "Crew N+1", | |
| 581 | baseBranch: repo.defaultBranch, | |
| 582 | headBranch: "work", | |
| 583 | }); | |
| 584 | expect(opened.ok).toBe(true); | |
| 585 | if (!opened.ok) return; | |
| 586 | const issueRow = app.db | |
| 587 | .query< | |
| 588 | { id: string }, | |
| 589 | [string, number] | |
| 590 | >("SELECT id FROM issue WHERE repo_id = ?1 AND number = ?2") | |
| 591 | .get(repo.id, opened.value.number)!; | |
| 592 | ||
| 593 | const seedActivity = (n: number): void => { | |
| 594 | for (let i = 0; i < n; i++) { | |
| 595 | app.db | |
| 596 | .query( | |
| 597 | `INSERT INTO run (id, repo_id, kind, issue_id, env, state, actor_id, summary) | |
| 598 | VALUES (?1, ?2, 'agent', ?3, '', 'ok', ?4, 'did a thing')`, | |
| 599 | ) | |
| 600 | .run(id(), repo.id, issueRow.id, agent.value.user.id); | |
| 601 | } | |
| 602 | }; | |
| 603 | ||
| 604 | seedActivity(3); | |
| 605 | const small = await statements(app.db, async () => { | |
| 606 | const response = await web( | |
| 607 | alice, | |
| 608 | `/alice/crew-n1/pulls/${opened.value.number}`, | |
| 609 | ); | |
| 610 | expect(response.status).toBe(200); | |
| 611 | }); | |
| 612 | seedActivity(20); | |
| 613 | const large = await statements(app.db, async () => { | |
| 614 | const response = await web( | |
| 615 | alice, | |
| 616 | `/alice/crew-n1/pulls/${opened.value.number}`, | |
| 617 | ); | |
| 618 | expect(response.status).toBe(200); | |
| 619 | }); | |
| 620 | expect(large).toBe(small); | |
| 621 | }); | |
| 622 | ||
| 623 | // ---- 6. mobile ---- | |
| 624 | ||
| 625 | test("mobile: the apps/runs/crew CSS carries no fixed width that forces horizontal scroll at 320px", () => { | |
| 626 | const marker = "/* ---- apps, runs, and the crew ---- */"; | |
| 627 | const start = contentStyle.indexOf(marker); | |
| 628 | expect(start).toBeGreaterThan(-1); | |
| 629 | const end = contentStyle.indexOf( | |
| 630 | "/* ---- proposing a change ---- */", | |
| 631 | start, | |
| 632 | ); | |
| 633 | const block = contentStyle.slice(start, end === -1 ? undefined : end); | |
| 634 | // Any px width at or above ~320 would itself force a phone to scroll | |
| 635 | // sideways; small fixed sizes (icon slots, borders) are fine. | |
| 636 | const wide = [...block.matchAll(/(?:min-)?width:\s*(\d+)px/g)].filter( | |
| 637 | ([, n]) => Number(n) >= 300, | |
| 638 | ); | |
| 639 | expect(wide).toEqual([]); | |
| 640 | }); | |
| 641 | ||
| 642 | function aliceUser(): User { | |
| 643 | return getUserByName(app.db, "alice")!; | |
| 644 | } | |
| 645 | }); | |
| 646 | ||
| 647 | /** Sign in over the API and return the session cookie's `cookie:` value. */ | |
| 648 | async function loginAs( | |
| 649 | base: string, | |
| 650 | username: string, | |
| 651 | password: string, | |
| 652 | ): Promise<string> { | |
| 653 | const response = await fetch(`${base}/signin`, { | |
| 654 | method: "POST", | |
| 655 | body: new URLSearchParams({ username, password }), | |
| 656 | redirect: "manual", | |
| 657 | }); | |
| 658 | return (response.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 659 | } | |
| 660 | ||
| 661 | // ============================================================================ | |
| 662 | // TUI | |
| 663 | // ============================================================================ | |
| 664 | ||
| 665 | describe("surfaces adversarial: tui", () => { | |
| 666 | let dataDir = ""; | |
| 667 | let forge: Forge; | |
| 668 | let alice: User; | |
| 669 | let hello: Repo; | |
| 670 | ||
| 671 | const identityFor = (user?: User): TuiIdentity => ({ | |
| 672 | sshUsername: user?.username ?? "guest", | |
| 673 | remoteAddress: "127.0.0.1", | |
| 674 | fingerprint: `SHA256:${user?.username ?? "guest"}`, | |
| 675 | ...(user === undefined ? {} : { user }), | |
| 676 | }); | |
| 677 | ||
| 678 | function boot(identity: TuiIdentity, cols = 140, rows = 44) { | |
| 679 | const terminal = headlessTerminal(cols, rows); | |
| 680 | const { app, session } = createForgeApp(forge, identity, { | |
| 681 | terminal, | |
| 682 | exitHooks: false, | |
| 683 | }); | |
| 684 | app.start(); | |
| 685 | const settle = (): Promise<void> => settleApp(app); | |
| 686 | const one = async (key: string): Promise<void> => { | |
| 687 | const ctrl = key.startsWith("ctrl+"); | |
| 688 | app.dispatch({ | |
| 689 | type: "key", | |
| 690 | key: ctrl ? key.slice(5) : key, | |
| 691 | ctrl, | |
| 692 | alt: false, | |
| 693 | shift: false, | |
| 694 | }); | |
| 695 | await settle(); | |
| 696 | }; | |
| 697 | return { | |
| 698 | app, | |
| 699 | session, | |
| 700 | terminal, | |
| 701 | text: () => app.screen.toText(), | |
| 702 | async press(...keys: string[]) { | |
| 703 | for (const key of keys) await one(key); | |
| 704 | }, | |
| 705 | async push(make: (s: Session, ctx: TuiApp["ctx"]) => Screen) { | |
| 706 | app.ctx.push(make(session, app.ctx)); | |
| 707 | await settle(); | |
| 708 | }, | |
| 709 | pop() { | |
| 710 | app.ctx.pop(); | |
| 711 | }, | |
| 712 | settle, | |
| 713 | async until(what: string, ms = 15_000) { | |
| 714 | const deadline = Date.now() + ms; | |
| 715 | for (;;) { | |
| 716 | await settle(); | |
| 717 | if (app.screen.toText().includes(what)) return; | |
| 718 | if (Date.now() > deadline) | |
| 719 | throw new Error( | |
| 720 | `never saw ${JSON.stringify(what)} on screen:\n${app.screen.toText()}`, | |
| 721 | ); | |
| 722 | await new Promise((r) => setTimeout(r, 20)); | |
| 723 | } | |
| 724 | }, | |
| 725 | stop: () => app.stop(), | |
| 726 | }; | |
| 727 | } | |
| 728 | ||
| 729 | beforeAll(async () => { | |
| 730 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-surfaces-adv-")); | |
| 731 | forge = createApp({ dataDir, port: 0 }); | |
| 732 | const a = await createUser(forge.db, { | |
| 733 | username: "alice", | |
| 734 | email: "[email protected]", | |
| 735 | password: "a-good-passphrase", | |
| 736 | adminIfFirst: true, | |
| 737 | }); | |
| 738 | if (!a.ok) throw new Error(a.error); | |
| 739 | alice = a.value; | |
| 740 | const made = await createRepo(forge, alice, { | |
| 741 | name: "hello", | |
| 742 | visibility: "public", | |
| 743 | }); | |
| 744 | if (!made.ok) throw new Error(made.error); | |
| 745 | hello = made.value; | |
| 746 | const committed = await commitToBranch(forge, alice, made.value, { | |
| 747 | message: "first", | |
| 748 | files: [{ path: "README.md", content: "# hello\n" }], | |
| 749 | }); | |
| 750 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 751 | // No AppsRuntime is started: the app screen renders the "no runtime" | |
| 752 | // state, which is exactly what a guest and a writer alike see it say. | |
| 753 | forge.db | |
| 754 | .query( | |
| 755 | `INSERT INTO app (id, repo_id, slug, access, preview_data) VALUES (?1, ?2, 'hello', 'public', 'copy')`, | |
| 756 | ) | |
| 757 | .run(id(), made.value.id); | |
| 758 | }, 30_000); | |
| 759 | ||
| 760 | afterAll(async () => { | |
| 761 | forge.close(); | |
| 762 | await rm(dataDir, { recursive: true, force: true }); | |
| 763 | }); | |
| 764 | ||
| 765 | test("a guest cannot open New app, redeploy, stop, assign, or reach the crew screen", async () => { | |
| 766 | const h = boot(identityFor(undefined)); | |
| 767 | try { | |
| 768 | expect(h.session.signedIn).toBe(false); | |
| 769 | ||
| 770 | // Apps: `n` is disabled and does nothing — the key bar draws it dim | |
| 771 | // but never wires it, so the only honest proof is that pressing it | |
| 772 | // changes nothing (no prompt screen pushed). | |
| 773 | await h.push((s, ctx) => appsScreen(s, ctx)); | |
| 774 | await h.until("hello"); | |
| 775 | const appsBefore = h.text(); | |
| 776 | await h.press("n"); | |
| 777 | expect(h.text()).toBe(appsBefore); | |
| 778 | expect(h.text()).not.toContain("New app"); | |
| 779 | ||
| 780 | // The app screen: redeploy/stop keys do nothing for a guest. | |
| 781 | await h.push((s, ctx) => appScreen(s, ctx, hello)); | |
| 782 | await h.until("slug hello"); | |
| 783 | const before = h.text(); | |
| 784 | await h.press("r"); | |
| 785 | expect(h.text()).toBe(before); // no toast, no confirm screen pushed | |
| 786 | await h.press("s"); | |
| 787 | expect(h.text()).toBe(before); | |
| 788 | ||
| 789 | // The crew screen: reachable (it says who it is for), but every verb | |
| 790 | // is inert and the reason is stated, not hidden. | |
| 791 | await h.push((s, ctx) => crewScreen(s, ctx)); | |
| 792 | await h.until("only a forge admin can change the crew"); | |
| 793 | await h.press("n"); | |
| 794 | expect(h.text()).not.toContain("New agent"); | |
| 795 | } finally { | |
| 796 | h.stop(); | |
| 797 | } | |
| 798 | }, 30_000); | |
| 799 | ||
| 800 | test("a guest cannot assign an issue: the issue screen never wires `a`", async () => { | |
| 801 | const { openIssue, assignIssue } = await import("../src/actions/issues.ts"); | |
| 802 | const { issueScreen } = await import("../src/tui/screens/issue.ts"); | |
| 803 | const { local } = await import("../src/tui/screens/where.ts"); | |
| 804 | const opened = await openIssue(forge, alice, hello, { | |
| 805 | title: "Guest cannot touch this", | |
| 806 | body: "", | |
| 807 | }); | |
| 808 | expect(opened.ok).toBe(true); | |
| 809 | if (!opened.ok) return; | |
| 810 | const h = boot(identityFor(undefined)); | |
| 811 | try { | |
| 812 | await h.push((s, ctx) => | |
| 813 | issueScreen(s, ctx, local(s, hello), opened.value.number), | |
| 814 | ); | |
| 815 | await h.until("Guest cannot touch this"); | |
| 816 | const before = h.text(); | |
| 817 | await h.press("a"); | |
| 818 | // No picker opened: the screen only wires `a` for a writer. | |
| 819 | expect(h.text()).toBe(before); | |
| 820 | expect(h.text()).not.toContain("Assign #"); | |
| 821 | // Defense in depth: even if something did reach the action, a guest | |
| 822 | // actor is refused there too. | |
| 823 | const refused = assignIssue(forge, undefined, hello, opened.value, alice); | |
| 824 | expect(refused.ok).toBe(false); | |
| 825 | if (!refused.ok) expect(refused.error).toBe("unauthorized"); | |
| 826 | } finally { | |
| 827 | h.stop(); | |
| 828 | } | |
| 829 | }, 30_000); | |
| 830 | ||
| 831 | test("a run log with ANSI escapes and control bytes does not corrupt the frame", async () => { | |
| 832 | const runId = id(); | |
| 833 | forge.db | |
| 834 | .query( | |
| 835 | `INSERT INTO run (id, repo_id, kind, env, state) VALUES (?1, ?2, 'deploy', 'production', 'running')`, | |
| 836 | ) | |
| 837 | .run(runId, hello.id); | |
| 838 | const before = runLogListenerCount(); | |
| 839 | const h = boot(identityFor(alice)); | |
| 840 | try { | |
| 841 | await h.push((s, ctx) => runScreen(s, ctx, hello, runId)); | |
| 842 | await h.until("running"); | |
| 843 | appendRunLog( | |
| 844 | dataDir, | |
| 845 | runId, | |
| 846 | "\x1b[31mred\x1b[0m\x1b[2J\x1b[?1049htitle\x1b]0;evil\x07 plain text survives", | |
| 847 | ); | |
| 848 | await h.until("plain text survives"); | |
| 849 | // No raw escape byte reached the rendered frame. | |
| 850 | expect(h.text()).not.toContain("\x1b"); | |
| 851 | // The frame is still a well-formed grid — no crash, no runaway state. | |
| 852 | expect(h.app.screen.toText().split("\n").length).toBeGreaterThan(5); | |
| 853 | } finally { | |
| 854 | h.stop(); | |
| 855 | } | |
| 856 | expect(runLogListenerCount()).toBe(before); | |
| 857 | }, 30_000); | |
| 858 | ||
| 859 | test("the run screen releases its log subscription when popped", async () => { | |
| 860 | const runId = id(); | |
| 861 | forge.db | |
| 862 | .query( | |
| 863 | `INSERT INTO run (id, repo_id, kind, env, state) VALUES (?1, ?2, 'deploy', 'production', 'running')`, | |
| 864 | ) | |
| 865 | .run(runId, hello.id); | |
| 866 | const before = runLogListenerCount(); | |
| 867 | const h = boot(identityFor(alice)); | |
| 868 | try { | |
| 869 | await h.push((s, ctx) => runScreen(s, ctx, hello, runId)); | |
| 870 | await h.until("running"); | |
| 871 | expect(runLogListenerCount()).toBe(before + 1); | |
| 872 | h.pop(); | |
| 873 | await h.settle(); | |
| 874 | expect(runLogListenerCount()).toBe(before); | |
| 875 | } finally { | |
| 876 | h.stop(); | |
| 877 | } | |
| 878 | }, 30_000); | |
| 879 | ||
| 880 | test("a screen popped while its load is in flight renders and toasts nothing afterward", async () => { | |
| 881 | const runId = id(); | |
| 882 | forge.db | |
| 883 | .query( | |
| 884 | `INSERT INTO run (id, repo_id, kind, env, state) VALUES (?1, ?2, 'deploy', 'production', 'running')`, | |
| 885 | ) | |
| 886 | .run(runId, hello.id); | |
| 887 | const h = boot(identityFor(alice)); | |
| 888 | try { | |
| 889 | // Push and immediately pop, before the load's promise has settled. | |
| 890 | h.app.ctx.push(runScreen(h.session, h.app.ctx, hello, runId)); | |
| 891 | h.pop(); | |
| 892 | await h.settle(); | |
| 893 | // The forge is back where it was; nothing threw, nothing left a toast | |
| 894 | // meant for a screen nobody can see any more. | |
| 895 | expect(h.text()).not.toContain("undefined"); | |
| 896 | } finally { | |
| 897 | h.stop(); | |
| 898 | } | |
| 899 | }, 30_000); | |
| 900 | }); |
test/tui-apps.test.ts added +622
| @@ -0,0 +1,622 @@ | ||
| 1 | /** | |
| 2 | * Apps, runs and the crew, through the terminal (docs/APPS.md). | |
| 3 | * | |
| 4 | * One real forge with a real `AppsRuntime`, driven headlessly. The app under | |
| 5 | * test is deliberately *not* an app — a README and nothing else — so every | |
| 6 | * deploy fails in milliseconds with the one sentence the runtime writes for | |
| 7 | * that, and the screens are asserted on the honest words they must carry: | |
| 8 | * "failed" beside its reason, never "failed" alone. The live half is real | |
| 9 | * too: a line appended to a run's log lands on the run screen through the | |
| 10 | * in-process listener, and an event on the bus redraws the app. | |
| 11 | */ | |
| 12 | ||
| 13 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 14 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 15 | import { tmpdir } from "node:os"; | |
| 16 | import { join } from "node:path"; | |
| 17 | import { enableApp } from "../src/actions/apps.ts"; | |
| 18 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 19 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 20 | import { openPull } from "../src/actions/pulls.ts"; | |
| 21 | import { | |
| 22 | commitToBranch, | |
| 23 | createRepo, | |
| 24 | updateRepoSettings, | |
| 25 | } from "../src/actions/repos.ts"; | |
| 26 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 27 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 28 | import { getAgentByName, type Agent } from "../src/core/agents.ts"; | |
| 29 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 30 | import { getIssue, type Issue } from "../src/core/issues.ts"; | |
| 31 | import { getRepo, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 32 | import { | |
| 33 | claimRun, | |
| 34 | createRun, | |
| 35 | finishRun, | |
| 36 | listRuns, | |
| 37 | type Run, | |
| 38 | } from "../src/core/runs.ts"; | |
| 39 | import { createUser, type User } from "../src/core/users.ts"; | |
| 40 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 41 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 42 | import { appendRunLog } from "../src/runs/log.ts"; | |
| 43 | import type { App, Screen } from "../src/tui/app/app.ts"; | |
| 44 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 45 | import { issueScreen } from "../src/tui/screens/issue.ts"; | |
| 46 | import { runsScreen } from "../src/tui/screens/runs.ts"; | |
| 47 | import { pullScreen } from "../src/tui/screens/pull.ts"; | |
| 48 | import { settingsScreen } from "../src/tui/screens/settings.ts"; | |
| 49 | import { local } from "../src/tui/screens/where.ts"; | |
| 50 | import type { Session, TuiIdentity } from "../src/tui/session.ts"; | |
| 51 | import { | |
| 52 | headlessTerminal, | |
| 53 | type HeadlessTerminal, | |
| 54 | } from "../src/tui/term/terminal.ts"; | |
| 55 | import { settleApp } from "./helpers/tui.ts"; | |
| 56 | ||
| 57 | setLogLevel("error"); | |
| 58 | ||
| 59 | const DOMAIN = "apps.test"; | |
| 60 | const NOT_AN_APP = "this commit is not an app"; | |
| 61 | ||
| 62 | interface Harness { | |
| 63 | app: App; | |
| 64 | session: Session; | |
| 65 | terminal: HeadlessTerminal; | |
| 66 | text(): string; | |
| 67 | rows(): string[]; | |
| 68 | press(...keys: string[]): Promise<void>; | |
| 69 | type(text: string): Promise<void>; | |
| 70 | push(screen: (session: Session, ctx: App["ctx"]) => Screen): Promise<void>; | |
| 71 | settle(): Promise<void>; | |
| 72 | until(what: string, ms?: number): Promise<void>; | |
| 73 | /** Move the cursor down until the marked row names something. */ | |
| 74 | select(name: string): Promise<void>; | |
| 75 | stop(): void; | |
| 76 | } | |
| 77 | ||
| 78 | function boot( | |
| 79 | forge: Forge, | |
| 80 | identity: TuiIdentity, | |
| 81 | cols = 140, | |
| 82 | rows = 44, | |
| 83 | ): Harness { | |
| 84 | const terminal = headlessTerminal(cols, rows); | |
| 85 | const { app, session } = createForgeApp(forge, identity, { | |
| 86 | terminal, | |
| 87 | exitHooks: false, | |
| 88 | }); | |
| 89 | app.start(); | |
| 90 | const settle = (): Promise<void> => settleApp(app); | |
| 91 | const one = async (key: string): Promise<void> => { | |
| 92 | const ctrl = key.startsWith("ctrl+"); | |
| 93 | const shift = key.startsWith("shift+"); | |
| 94 | app.dispatch({ | |
| 95 | type: "key", | |
| 96 | key: ctrl ? key.slice(5) : shift ? key.slice(6) : key, | |
| 97 | ctrl, | |
| 98 | alt: false, | |
| 99 | shift, | |
| 100 | }); | |
| 101 | await settle(); | |
| 102 | }; | |
| 103 | const grid = (): string[] => app.screen.toText().split("\n"); | |
| 104 | const harness: Harness = { | |
| 105 | app, | |
| 106 | session, | |
| 107 | terminal, | |
| 108 | text: () => app.screen.toText(), | |
| 109 | rows: grid, | |
| 110 | async press(...keys) { | |
| 111 | for (const key of keys) await one(key); | |
| 112 | }, | |
| 113 | async type(text) { | |
| 114 | for (const ch of text) | |
| 115 | app.dispatch({ | |
| 116 | type: "key", | |
| 117 | key: ch, | |
| 118 | ctrl: false, | |
| 119 | alt: false, | |
| 120 | shift: false, | |
| 121 | }); | |
| 122 | await settle(); | |
| 123 | }, | |
| 124 | async push(make) { | |
| 125 | app.ctx.push(make(session, app.ctx)); | |
| 126 | await settle(); | |
| 127 | }, | |
| 128 | settle, | |
| 129 | async until(what, ms = 30_000) { | |
| 130 | const deadline = Date.now() + ms; | |
| 131 | for (;;) { | |
| 132 | await settle(); | |
| 133 | if (app.screen.toText().includes(what)) return; | |
| 134 | if (Date.now() > deadline) | |
| 135 | throw new Error( | |
| 136 | `never saw ${JSON.stringify(what)} on screen:\n${app.screen.toText()}`, | |
| 137 | ); | |
| 138 | await new Promise((r) => setTimeout(r, 20)); | |
| 139 | } | |
| 140 | }, | |
| 141 | async select(name) { | |
| 142 | await one("g"); | |
| 143 | for (let i = 0; i < 30; i++) { | |
| 144 | if (grid().some((line) => line.includes(`▸ ${name}`))) return; | |
| 145 | await one("j"); | |
| 146 | } | |
| 147 | throw new Error(`never selected ${name}:\n${app.screen.toText()}`); | |
| 148 | }, | |
| 149 | stop: () => app.stop(), | |
| 150 | }; | |
| 151 | return harness; | |
| 152 | } | |
| 153 | ||
| 154 | const identityFor = (user: User): TuiIdentity => ({ | |
| 155 | sshUsername: user.username, | |
| 156 | remoteAddress: "127.0.0.1", | |
| 157 | fingerprint: `SHA256:${user.username}`, | |
| 158 | user, | |
| 159 | }); | |
| 160 | ||
| 161 | describe("terminal: apps, runs and the crew", () => { | |
| 162 | let dataDir: string; | |
| 163 | let forge: Forge; | |
| 164 | let servers: Servers; | |
| 165 | let runtime: AppsRuntime; | |
| 166 | let alice: User; | |
| 167 | let robo: Agent; | |
| 168 | let site: Repo; | |
| 169 | let issue: Issue; | |
| 170 | let productionRun: Run; | |
| 171 | ||
| 172 | /** Wait for the newest run matching `where` to finish, and return it. */ | |
| 173 | async function settled( | |
| 174 | where: (run: Run) => boolean, | |
| 175 | timeoutMs = 30_000, | |
| 176 | ): Promise<Run> { | |
| 177 | const deadline = Date.now() + timeoutMs; | |
| 178 | while (Date.now() < deadline) { | |
| 179 | const run = listRuns(forge.db, site.id, { limit: 50 }).find(where); | |
| 180 | if ( | |
| 181 | run && | |
| 182 | (run.state === "ok" || | |
| 183 | run.state === "failed" || | |
| 184 | run.state === "cancelled") | |
| 185 | ) | |
| 186 | return run; | |
| 187 | await Bun.sleep(50); | |
| 188 | } | |
| 189 | throw new Error("run did not settle in time"); | |
| 190 | } | |
| 191 | ||
| 192 | beforeAll(async () => { | |
| 193 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-apps-")); | |
| 194 | forge = createApp({ dataDir, port: 0 }); | |
| 195 | servers = await serve(forge); | |
| 196 | runtime = new AppsRuntime(forge, { | |
| 197 | domain: DOMAIN, | |
| 198 | scheme: "http", | |
| 199 | publicPort: servers.port, | |
| 200 | forgeUrl: servers.url, | |
| 201 | concurrency: 2, | |
| 202 | }); | |
| 203 | await runtime.start(); | |
| 204 | ||
| 205 | const a = await createUser(forge.db, { | |
| 206 | username: "alice", | |
| 207 | email: "[email protected]", | |
| 208 | password: "a-good-passphrase", | |
| 209 | adminIfFirst: true, | |
| 210 | }); | |
| 211 | if (!a.ok) throw new Error(a.error); | |
| 212 | alice = a.value; | |
| 213 | await createUser(forge.db, { | |
| 214 | username: "bob", | |
| 215 | email: "[email protected]", | |
| 216 | password: "a-good-passphrase", | |
| 217 | }); | |
| 218 | const agent = await createAgent(forge, alice, { | |
| 219 | username: "robo", | |
| 220 | model: "sonnet", | |
| 221 | brief: "I write small, careful patches.", | |
| 222 | }); | |
| 223 | if (!agent.ok) throw new Error(agent.error); | |
| 224 | robo = agent.value; | |
| 225 | ||
| 226 | // A repository that is not an app: its deploys fail fast and honestly. | |
| 227 | const made = await createRepo(forge, alice, { | |
| 228 | name: "site", | |
| 229 | visibility: "public", | |
| 230 | }); | |
| 231 | if (!made.ok) throw new Error(made.error); | |
| 232 | site = made.value; | |
| 233 | const committed = await commitToBranch(forge, alice, site, { | |
| 234 | message: "first", | |
| 235 | files: [{ path: "README.md", content: "# site\n" }], | |
| 236 | }); | |
| 237 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 238 | site = getRepoById(forge.db, site.id) ?? site; | |
| 239 | const enabled = enableApp(forge, alice, site, {}); | |
| 240 | if (!enabled.ok) throw new Error(enabled.error); | |
| 241 | const crewed = addToCrew(forge, alice, site, "robo"); | |
| 242 | if (!crewed.ok) throw new Error(crewed.error); | |
| 243 | const opened = openIssue(forge, alice, site, { | |
| 244 | title: "Make the site sing", | |
| 245 | body: "A melody on the front page.", | |
| 246 | }); | |
| 247 | if (!opened.ok) throw new Error(opened.error); | |
| 248 | issue = opened.value; | |
| 249 | productionRun = await settled((run) => run.env === "production"); | |
| 250 | expect(productionRun.state).toBe("failed"); | |
| 251 | }, 60_000); | |
| 252 | ||
| 253 | afterAll(async () => { | |
| 254 | await runtime.stop(); | |
| 255 | servers.stop(); | |
| 256 | forge.close(); | |
| 257 | await rm(dataDir, { recursive: true, force: true }); | |
| 258 | }); | |
| 259 | ||
| 260 | test("the apps screen: every app, its production state in words, its URL", async () => { | |
| 261 | const h = boot(forge, identityFor(alice)); | |
| 262 | try { | |
| 263 | await h.press("a"); | |
| 264 | await h.until("alice/site"); | |
| 265 | const text = h.text(); | |
| 266 | expect(text).toContain("failed"); | |
| 267 | expect(text).toContain(`http://site.${DOMAIN}:${servers.port}`); | |
| 268 | } finally { | |
| 269 | h.stop(); | |
| 270 | } | |
| 271 | }, 30_000); | |
| 272 | ||
| 273 | test("the app screen: the environment, its error, the log, the runs", async () => { | |
| 274 | const h = boot(forge, identityFor(alice)); | |
| 275 | try { | |
| 276 | await h.press("a"); | |
| 277 | await h.until("alice/site"); | |
| 278 | await h.press("enter"); | |
| 279 | await h.until("production"); | |
| 280 | // The failure is a sentence, on the row and in the panel. | |
| 281 | expect(h.text()).toContain(`failed: ${NOT_AN_APP}`); | |
| 282 | expect(h.text()).toContain("slug site"); | |
| 283 | ||
| 284 | // `enter` opens the deploy's log; the last line is the reason. | |
| 285 | await h.press("enter"); | |
| 286 | await h.until("deploy production of alice/site"); | |
| 287 | expect(h.text()).toContain(`failed: ${NOT_AN_APP}`); | |
| 288 | expect(h.text()).toContain("deploy production"); | |
| 289 | ||
| 290 | // A line written to the log now lands on screen without a keypress: | |
| 291 | // the subscription was taken before the file was read. | |
| 292 | appendRunLog(dataDir, productionRun.id, "a fresh line from the test"); | |
| 293 | await h.until("a fresh line from the test"); | |
| 294 | ||
| 295 | // Back on the app screen, `l` lists every run of the repository. | |
| 296 | await h.press("esc"); | |
| 297 | await h.until("slug site"); | |
| 298 | await h.press("l"); | |
| 299 | await h.until("deploy production"); | |
| 300 | expect(h.text()).toContain("failed"); | |
| 301 | expect(h.text()).toContain(NOT_AN_APP); | |
| 302 | } finally { | |
| 303 | h.stop(); | |
| 304 | } | |
| 305 | }, 30_000); | |
| 306 | ||
| 307 | test("the run screen refuses nothing quietly: a finished run cannot be cancelled", async () => { | |
| 308 | const h = boot(forge, identityFor(alice)); | |
| 309 | try { | |
| 310 | await h.press("a"); | |
| 311 | await h.until("alice/site"); | |
| 312 | await h.press("enter", "enter"); | |
| 313 | await h.until("deploy production of alice/site"); | |
| 314 | // `x` is disabled on a finished run: the key does nothing and the help | |
| 315 | // bar says so rather than offering a cancel that would be refused. | |
| 316 | await h.press("x"); | |
| 317 | expect(h.text()).not.toContain("Cancel this run"); | |
| 318 | } finally { | |
| 319 | h.stop(); | |
| 320 | } | |
| 321 | }, 30_000); | |
| 322 | ||
| 323 | test("assign: nobody, the people who can read it, then the crew with its mark", async () => { | |
| 324 | const h = boot(forge, identityFor(alice)); | |
| 325 | try { | |
| 326 | await h.push((s, ctx) => | |
| 327 | issueScreen(s, ctx, local(s, site), issue.number), | |
| 328 | ); | |
| 329 | await h.until("Make the site sing"); | |
| 330 | await h.press("a"); | |
| 331 | await h.until(`Assign #${issue.number}`); | |
| 332 | const text = h.text(); | |
| 333 | expect(text).toContain("nobody"); | |
| 334 | expect(text).toContain("alice"); | |
| 335 | // bob has nothing to do with the repository, but it is public, so | |
| 336 | // bob can read it and is offered. | |
| 337 | expect(text).toContain("bob"); | |
| 338 | expect(text).toContain("robo"); | |
| 339 | expect(text).toContain("agent"); | |
| 340 | await h.select("robo"); | |
| 341 | await h.press("enter"); | |
| 342 | await h.until("assigned to robo"); | |
| 343 | expect(getIssue(forge.db, site.id, issue.number)?.assigneeId).toBe( | |
| 344 | robo.user.id, | |
| 345 | ); | |
| 346 | // The head line names the assignee and says what it is. | |
| 347 | expect(h.text()).toContain("assigned to robo (agent)"); | |
| 348 | ||
| 349 | // The crew's work on the issue is a card; `enter` from it opens the run. | |
| 350 | const run = createRun(forge.db, { | |
| 351 | repoId: site.id, | |
| 352 | kind: "agent", | |
| 353 | issueId: issue.id, | |
| 354 | actorId: robo.user.id, | |
| 355 | reason: "assigned", | |
| 356 | }); | |
| 357 | claimRun(forge.db, run.id); | |
| 358 | appendRunLog(dataDir, run.id, "cloning alice/site"); | |
| 359 | finishRun(forge.db, run.id, { | |
| 360 | state: "ok", | |
| 361 | summary: "opened pull #2 with the melody", | |
| 362 | costUsd: 0.42, | |
| 363 | }); | |
| 364 | await h.press("r"); | |
| 365 | await h.until("Crew · 1 run"); | |
| 366 | expect(h.text()).toContain("opened pull #2 with the melody"); | |
| 367 | expect(h.text()).toContain("$0.42"); | |
| 368 | await h.press("tab", "enter"); | |
| 369 | await h.until("agent robo"); | |
| 370 | expect(h.text()).toContain("cloning alice/site"); | |
| 371 | expect(h.text()).toContain("opened pull #2 with the melody"); | |
| 372 | ||
| 373 | // The repository's runs list names the conversation each run is | |
| 374 | // about, from one grouped read; inside the issue that would only | |
| 375 | // repeat the page, so the card above did not. | |
| 376 | await h.push((s, ctx) => runsScreen(s, ctx, site)); | |
| 377 | await h.until(`agent robo · #${issue.number} Make the site sing`); | |
| 378 | expect(h.text()).toContain("deploy production"); | |
| 379 | } finally { | |
| 380 | h.stop(); | |
| 381 | } | |
| 382 | }, 30_000); | |
| 383 | ||
| 384 | test("assign on a private repository offers only those who can read it", async () => { | |
| 385 | const vault = await createRepo(forge, alice, { | |
| 386 | name: "vault", | |
| 387 | visibility: "private", | |
| 388 | }); | |
| 389 | expect(vault.ok).toBe(true); | |
| 390 | if (!vault.ok) return; | |
| 391 | const secret = openIssue(forge, alice, vault.value, { | |
| 392 | title: "Keep the key", | |
| 393 | body: "", | |
| 394 | }); | |
| 395 | expect(secret.ok).toBe(true); | |
| 396 | if (!secret.ok) return; | |
| 397 | ||
| 398 | const h = boot(forge, identityFor(alice)); | |
| 399 | try { | |
| 400 | await h.push((s, ctx) => | |
| 401 | issueScreen(s, ctx, local(s, vault.value), secret.value.number), | |
| 402 | ); | |
| 403 | await h.until("Keep the key"); | |
| 404 | await h.press("a"); | |
| 405 | await h.until(`Assign #${secret.value.number}`); | |
| 406 | const text = h.text(); | |
| 407 | expect(text).toContain("nobody"); | |
| 408 | expect(text).toContain("alice"); | |
| 409 | // bob exists, but cannot see the vault: the picker must not name bob, | |
| 410 | // and `assignIssue` would refuse it anyway (assignee_cannot_read). | |
| 411 | expect(text).not.toContain("bob"); | |
| 412 | await h.press("esc"); | |
| 413 | } finally { | |
| 414 | h.stop(); | |
| 415 | } | |
| 416 | }, 30_000); | |
| 417 | ||
| 418 | test("a pull: the preview line, the checks gate in words, O copies the link", async () => { | |
| 419 | const committed = await commitToBranch(forge, alice, site, { | |
| 420 | branch: "tune", | |
| 421 | base: site.defaultBranch, | |
| 422 | message: "Add a tune", | |
| 423 | files: [{ path: "TUNE.md", content: "la la la\n" }], | |
| 424 | }); | |
| 425 | expect(committed.ok).toBe(true); | |
| 426 | const opened = await openPull(forge, alice, site, { | |
| 427 | title: "Add a tune", | |
| 428 | baseBranch: site.defaultBranch, | |
| 429 | headBranch: "tune", | |
| 430 | }); | |
| 431 | expect(opened.ok).toBe(true); | |
| 432 | if (!opened.ok) return; | |
| 433 | const pull = opened.value; | |
| 434 | const preview = await settled((run) => run.env === `pull-${pull.number}`); | |
| 435 | expect(preview.state).toBe("failed"); | |
| 436 | const gated = await updateRepoSettings(forge, alice, site, { | |
| 437 | requireChecks: true, | |
| 438 | }); | |
| 439 | expect(gated.ok).toBe(true); | |
| 440 | ||
| 441 | const h = boot(forge, identityFor(alice)); | |
| 442 | try { | |
| 443 | await h.push((s, ctx) => pullScreen(s, ctx, local(s, site), pull.number)); | |
| 444 | await h.until("Add a tune"); | |
| 445 | const text = h.text(); | |
| 446 | // The preview, with its error; the checks, with theirs; the gate. | |
| 447 | expect(text).toContain(`preview failed: ${NOT_AN_APP}`); | |
| 448 | expect(text).toContain("checks: failed"); | |
| 449 | expect(text).toContain("The deploy of this head failed"); | |
| 450 | // `m` is refused in words, not silently. | |
| 451 | await h.press("m"); | |
| 452 | expect(h.text()).toContain("the deploy of this head failed"); | |
| 453 | // `O` cannot open a browser from a headless session: the URL goes to | |
| 454 | // the clipboard through OSC 52 and the toast names the gesture. | |
| 455 | h.terminal.takeOutput(); | |
| 456 | await h.press("O"); | |
| 457 | expect(h.terminal.takeOutput()).toContain("]52;c;"); | |
| 458 | expect(h.text()).toContain("copied — cmd-click the link to open it"); | |
| 459 | // `a` offers the same picker a pull gets. | |
| 460 | await h.press("a"); | |
| 461 | await h.until(`Assign #${pull.number}`); | |
| 462 | await h.press("esc"); | |
| 463 | } finally { | |
| 464 | h.stop(); | |
| 465 | } | |
| 466 | }, 30_000); | |
| 467 | ||
| 468 | test("settings: the app tab, require checks, and the crew", async () => { | |
| 469 | const h = boot(forge, identityFor(alice)); | |
| 470 | try { | |
| 471 | await h.push((s, ctx) => settingsScreen(s, ctx, site)); | |
| 472 | // The screen was handed the row from before the pull test turned the | |
| 473 | // gate on; it reads the row live rather than trusting the snapshot. | |
| 474 | await h.until("a pull needs a passing deploy"); | |
| 475 | await h.press("C"); | |
| 476 | await h.until("settings saved"); | |
| 477 | expect(getRepoById(forge.db, site.id)?.requireChecks).toBe(false); | |
| 478 | expect(h.text()).not.toContain("a pull needs a passing deploy"); | |
| 479 | ||
| 480 | await h.press("tab", "tab", "tab"); | |
| 481 | await h.until("this repository runs"); | |
| 482 | expect(h.text()).toContain("site"); | |
| 483 | expect(h.text()).toContain(`http://site.${DOMAIN}:${servers.port}`); | |
| 484 | ||
| 485 | await h.press("tab"); | |
| 486 | await h.until("robo"); | |
| 487 | expect(h.text()).toContain("sonnet"); | |
| 488 | await h.press("d"); | |
| 489 | await h.until("Remove robo from the crew"); | |
| 490 | await h.press("right", "enter"); | |
| 491 | await h.until("robo is off the crew"); | |
| 492 | await h.press("a"); | |
| 493 | await h.until("Add to the crew"); | |
| 494 | await h.press("enter"); | |
| 495 | await h.until("robo is on the crew"); | |
| 496 | } finally { | |
| 497 | h.stop(); | |
| 498 | } | |
| 499 | }, 30_000); | |
| 500 | ||
| 501 | test("the crew screen: the roster, a new agent, and retiring one", async () => { | |
| 502 | const h = boot(forge, identityFor(alice)); | |
| 503 | try { | |
| 504 | await h.press("A"); | |
| 505 | await h.until("Account"); | |
| 506 | await h.press("c"); | |
| 507 | await h.until("robo"); | |
| 508 | expect(h.text()).toContain("sonnet"); | |
| 509 | expect(h.text()).toContain("I write small, careful patches."); | |
| 510 | // No crew was started for this forge, and the screen says so. | |
| 511 | expect(h.text()).toContain("the crew is not running on this forge"); | |
| 512 | ||
| 513 | await h.press("n"); | |
| 514 | await h.until("Username"); | |
| 515 | await h.type("pixel"); | |
| 516 | await h.press("enter"); | |
| 517 | await h.until("Display name"); | |
| 518 | await h.press("enter"); | |
| 519 | await h.until("Model"); | |
| 520 | await h.press("enter"); | |
| 521 | await h.until("Brief"); | |
| 522 | await h.press("enter"); | |
| 523 | await h.until("Budget per run"); | |
| 524 | await h.type("2.5"); | |
| 525 | await h.press("enter"); | |
| 526 | await h.until("pixel is on the crew"); | |
| 527 | expect(h.text()).toContain("$2.50/run"); | |
| 528 | expect(getAgentByName(forge.db, "pixel")?.budgetUsd).toBe(2.5); | |
| 529 | ||
| 530 | await h.select("pixel"); | |
| 531 | await h.press("d"); | |
| 532 | await h.until("Retire pixel"); | |
| 533 | await h.press("right", "enter"); | |
| 534 | await h.until("pixel retired"); | |
| 535 | expect(getAgentByName(forge.db, "pixel")).toBeUndefined(); | |
| 536 | } finally { | |
| 537 | h.stop(); | |
| 538 | } | |
| 539 | }, 30_000); | |
| 540 | ||
| 541 | test("a non-admin cannot reach the crew's verbs", async () => { | |
| 542 | const bob = (await import("../src/core/users.ts")).getUserByName( | |
| 543 | forge.db, | |
| 544 | "bob", | |
| 545 | )!; | |
| 546 | const h = boot(forge, identityFor(bob)); | |
| 547 | try { | |
| 548 | await h.press("A"); | |
| 549 | await h.until("Account"); | |
| 550 | expect(h.text()).not.toContain("crew"); | |
| 551 | await h.press("c"); | |
| 552 | expect(h.text()).not.toContain("Crew"); | |
| 553 | } finally { | |
| 554 | h.stop(); | |
| 555 | } | |
| 556 | }, 30_000); | |
| 557 | ||
| 558 | test("the dashboard: an Apps pane and an On you pane, both enterable", async () => { | |
| 559 | const mine = openIssue(forge, alice, site, { | |
| 560 | title: "Tune the drums", | |
| 561 | body: "", | |
| 562 | }); | |
| 563 | expect(mine.ok).toBe(true); | |
| 564 | if (!mine.ok) return; | |
| 565 | const assigned = assignIssue(forge, alice, site, mine.value, alice); | |
| 566 | expect(assigned.ok).toBe(true); | |
| 567 | ||
| 568 | const h = boot(forge, identityFor(alice)); | |
| 569 | try { | |
| 570 | await h.until("Apps"); | |
| 571 | expect(h.text()).toContain("On you"); | |
| 572 | expect(h.text()).toContain("Tune the drums"); | |
| 573 | // The apps pane is first in the tab order, one step back from repos. | |
| 574 | await h.press("shift+tab", "enter"); | |
| 575 | await h.until("slug site"); | |
| 576 | await h.press("esc"); | |
| 577 | // repos → activity → on you. | |
| 578 | await h.press("tab", "tab", "enter"); | |
| 579 | await h.until("assigned to alice"); | |
| 580 | expect(h.text()).toContain("Tune the drums"); | |
| 581 | } finally { | |
| 582 | h.stop(); | |
| 583 | } | |
| 584 | }, 30_000); | |
| 585 | ||
| 586 | // Last on purpose: it starts a real deploy of the scaffold in the | |
| 587 | // background, and nothing after it should have to share the machine. | |
| 588 | test("new app, the golden path: a name, a sentence, who sees it, who works on it", async () => { | |
| 589 | const h = boot(forge, identityFor(alice)); | |
| 590 | try { | |
| 591 | await h.press("a"); | |
| 592 | await h.until("alice/site"); | |
| 593 | await h.press("n"); | |
| 594 | await h.until("New app"); | |
| 595 | await h.type("notes"); | |
| 596 | await h.press("enter"); | |
| 597 | await h.until("What should notes do?"); | |
| 598 | await h.type("A place for notes"); | |
| 599 | await h.press("enter"); | |
| 600 | await h.until("Who may see it?"); | |
| 601 | await h.press("enter"); | |
| 602 | await h.until("Who works on it?"); | |
| 603 | await h.select("robo"); | |
| 604 | await h.press("enter"); | |
| 605 | // The toast names the URL and the agent; the repository opens beneath. | |
| 606 | await h.until( | |
| 607 | `notes: starting at http://notes.${DOMAIN}:${servers.port}`, | |
| 608 | ); | |
| 609 | expect(h.text()).toContain("robo is on #1"); | |
| 610 | const repo = getRepo(forge.db, "alice", "notes"); | |
| 611 | expect(repo).toBeDefined(); | |
| 612 | if (repo === undefined) return; | |
| 613 | expect(repo.visibility).toBe("private"); | |
| 614 | expect(getAppByRepo(forge.db, repo.id)?.slug).toBe("notes"); | |
| 615 | const first = getIssue(forge.db, repo.id, 1); | |
| 616 | expect(first?.title).toBe("Build notes"); | |
| 617 | expect(first?.assigneeId).toBe(robo.user.id); | |
| 618 | } finally { | |
| 619 | h.stop(); | |
| 620 | } | |
| 621 | }, 60_000); | |
| 622 | }); |
test/tui-screens.test.ts +2 −1
| @@ -461,7 +461,8 @@ describe("the terminal UI, against a real forge", () => { | ||
| 461 | 461 | await h.settle(); |
| 462 | 462 | await openRepo(h, "doomed"); |
| 463 | 463 | await h.press(","); |
| 464 | await h.press("tab", "tab", "tab"); // General → Labels → Webhooks → Danger | |
| 464 | // General → Labels → Webhooks → App → Crew → Danger | |
| 465 | await h.press("tab", "tab", "tab", "tab", "tab"); | |
| 465 | 466 | expect(h.text()).toContain("Danger"); |
| 466 | 467 | await h.press("X"); |
| 467 | 468 | await h.typeText("wrong"); |
test/web-apps.test.ts added +665
| @@ -0,0 +1,665 @@ | ||
| 1 | /** | |
| 2 | * Apps, runs and the crew, in a browser (docs/APPS.md). Every page here is | |
| 3 | * met the way a person meets it: fetch the page, read the words, post the | |
| 4 | * form, follow the redirect. A real forge with a real `AppsRuntime` behind | |
| 5 | * it, so "New app" really scaffolds a repository and really starts a | |
| 6 | * deploy — the pages are asserted on what they say about that, never on | |
| 7 | * the deploy finishing, which is `test/apps.test.ts`'s job. | |
| 8 | * | |
| 9 | * The adversarial assertions carry the weight: an anonymous visitor gets | |
| 10 | * the one 404 for a private app's pages, a non-admin never sees the crew | |
| 11 | * section, an agent off the crew cannot be assigned, a private app opens | |
| 12 | * only through a grant, and every new POST refuses a cross-origin form. | |
| 13 | */ | |
| 14 | ||
| 15 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 16 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 17 | import { tmpdir } from "node:os"; | |
| 18 | import { join } from "node:path"; | |
| 19 | import { createApp, type App } from "../src/app.ts"; | |
| 20 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 21 | import { openPull } from "../src/actions/pulls.ts"; | |
| 22 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 23 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 24 | import { getAgentByName, listCrew } from "../src/core/agents.ts"; | |
| 25 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 26 | import { getIssue } from "../src/core/issues.ts"; | |
| 27 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 28 | import { listRuns, type Run } from "../src/core/runs.ts"; | |
| 29 | import { getUserByName, type User } from "../src/core/users.ts"; | |
| 30 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 31 | import { setLogLevel } from "../src/kernel/log.ts"; | |
| 32 | ||
| 33 | setLogLevel("error"); | |
| 34 | ||
| 35 | const PASSWORD = "a-long-apps-passphrase"; | |
| 36 | const DOMAIN = "apps.test"; | |
| 37 | ||
| 38 | /** A page with its whitespace collapsed, for asserting on a sentence. */ | |
| 39 | const words = (html: string): string => html.replace(/\s+/g, " "); | |
| 40 | ||
| 41 | /** The flash a redirect carries, decoded from its cookie. */ | |
| 42 | const flashOf = (response: Response): string => | |
| 43 | decodeURIComponent(response.headers.get("set-cookie") ?? ""); | |
| 44 | ||
| 45 | describe("apps, runs and the crew on the web", () => { | |
| 46 | let dataDir = ""; | |
| 47 | let app: App; | |
| 48 | let servers: Servers; | |
| 49 | let runtime: AppsRuntime; | |
| 50 | let base = ""; | |
| 51 | let alice = ""; | |
| 52 | let bob = ""; | |
| 53 | let hello: Repo; | |
| 54 | ||
| 55 | const web = ( | |
| 56 | cookie: string, | |
| 57 | path: string, | |
| 58 | init: RequestInit & { form?: Record<string, string>; origin?: string } = {}, | |
| 59 | ): Promise<Response> => { | |
| 60 | const headers = new Headers(init.headers); | |
| 61 | if (cookie !== "") headers.set("cookie", cookie); | |
| 62 | const method = init.form !== undefined ? "POST" : (init.method ?? "GET"); | |
| 63 | if (init.form !== undefined) | |
| 64 | headers.set("origin", init.origin ?? new URL(base).origin); | |
| 65 | return fetch(`${base}${path}`, { | |
| 66 | ...init, | |
| 67 | method, | |
| 68 | headers, | |
| 69 | redirect: "manual", | |
| 70 | ...(init.form === undefined | |
| 71 | ? {} | |
| 72 | : { body: new URLSearchParams(init.form) }), | |
| 73 | }); | |
| 74 | }; | |
| 75 | ||
| 76 | const bodyOf = async (cookie: string, path: string): Promise<string> => { | |
| 77 | const response = await web(cookie, path); | |
| 78 | expect(`${path} → ${response.status}`).toBe(`${path} → 200`); | |
| 79 | return words(await response.text()); | |
| 80 | }; | |
| 81 | ||
| 82 | const signUp = async (username: string): Promise<string> => { | |
| 83 | const response = await fetch(`${base}/signup`, { | |
| 84 | method: "POST", | |
| 85 | body: new URLSearchParams({ | |
| 86 | username, | |
| 87 | email: `${username}@forge.test`, | |
| 88 | password: PASSWORD, | |
| 89 | }), | |
| 90 | redirect: "manual", | |
| 91 | }); | |
| 92 | return (response.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 93 | }; | |
| 94 | ||
| 95 | const user = (name: string): User => getUserByName(app.db, name)!; | |
| 96 | ||
| 97 | /** The runtime reacts to events asynchronously; wait for a run to exist. */ | |
| 98 | async function runsOf( | |
| 99 | repo: Repo, | |
| 100 | where: (run: Run) => boolean = () => true, | |
| 101 | timeoutMs = 10_000, | |
| 102 | ): Promise<Run[]> { | |
| 103 | const deadline = Date.now() + timeoutMs; | |
| 104 | while (Date.now() < deadline) { | |
| 105 | const runs = listRuns(app.db, repo.id, { limit: 50 }).filter(where); | |
| 106 | if (runs.length > 0) return runs; | |
| 107 | await Bun.sleep(50); | |
| 108 | } | |
| 109 | return []; | |
| 110 | } | |
| 111 | ||
| 112 | beforeAll(async () => { | |
| 113 | dataDir = await mkdtemp(join(tmpdir(), "forge-web-apps-")); | |
| 114 | app = createApp({ dataDir, port: 0 }); | |
| 115 | servers = await serve(app); | |
| 116 | base = servers.url; | |
| 117 | runtime = new AppsRuntime(app, { | |
| 118 | domain: DOMAIN, | |
| 119 | scheme: "http", | |
| 120 | publicPort: servers.port, | |
| 121 | forgeUrl: servers.url, | |
| 122 | concurrency: 1, | |
| 123 | }); | |
| 124 | await runtime.start(); | |
| 125 | alice = await signUp("alice"); // the first account: the site admin | |
| 126 | bob = await signUp("bob"); | |
| 127 | }, 30_000); | |
| 128 | ||
| 129 | afterAll(async () => { | |
| 130 | await runtime.stop(); | |
| 131 | servers.stop(); | |
| 132 | app.close(); | |
| 133 | await rm(dataDir, { recursive: true, force: true }); | |
| 134 | }); | |
| 135 | ||
| 136 | // ---- the golden path's first step ---- | |
| 137 | ||
| 138 | test("New app: the form, and where it sends someone with no crew", async () => { | |
| 139 | const anonymous = await web("", "/apps/new"); | |
| 140 | expect(anonymous.status).toBe(303); | |
| 141 | expect(anonymous.headers.get("location")).toContain("/signin"); | |
| 142 | ||
| 143 | const html = await bodyOf(alice, "/apps/new"); | |
| 144 | expect(html).toContain("What should it do?"); | |
| 145 | expect(html).toContain(`.${DOMAIN}`); | |
| 146 | // No agents yet: no select, and the admin is told where to make one. | |
| 147 | expect(html).not.toContain('name="agent"'); | |
| 148 | expect(html).toContain("No agents on this forge yet"); | |
| 149 | expect(html).toContain('href="/settings/crew"'); | |
| 150 | // A non-admin is told an admin can, not handed a link that 404s. | |
| 151 | expect(await bodyOf(bob, "/apps/new")).not.toContain( | |
| 152 | 'href="/settings/crew"', | |
| 153 | ); | |
| 154 | }); | |
| 155 | ||
| 156 | test("New app creates the app and lands on the repository, URL in hand", async () => { | |
| 157 | const created = await web(alice, "/apps/new", { | |
| 158 | form: { | |
| 159 | owner: "alice", | |
| 160 | name: "hello", | |
| 161 | description: "", | |
| 162 | visibility: "public", | |
| 163 | }, | |
| 164 | }); | |
| 165 | expect(created.status).toBe(303); | |
| 166 | expect(created.headers.get("location")).toBe("/alice/hello"); | |
| 167 | expect(flashOf(created)).toContain( | |
| 168 | `hello is starting at http://hello.${DOMAIN}:${servers.port}`, | |
| 169 | ); | |
| 170 | hello = getRepo(app.db, "alice", "hello")!; | |
| 171 | expect(hello).toBeDefined(); | |
| 172 | expect(getAppByRepo(app.db, hello.id)?.slug).toBe("hello"); | |
| 173 | ||
| 174 | // The repo home wears the strip and the App tab. | |
| 175 | const home = await bodyOf(alice, "/alice/hello"); | |
| 176 | expect(home).toContain('class="app-strip"'); | |
| 177 | expect(home).toContain('href="/alice/hello/app/production/open"'); | |
| 178 | expect(home).toContain('href="/alice/hello/app"'); | |
| 179 | // A plain repository wears neither. | |
| 180 | const plain = await web(alice, "/new", { | |
| 181 | form: { owner: "alice", name: "plain", visibility: "private" }, | |
| 182 | }); | |
| 183 | expect(plain.status).toBe(303); | |
| 184 | const plainHome = await bodyOf(alice, "/alice/plain"); | |
| 185 | expect(plainHome).not.toContain('class="app-strip"'); | |
| 186 | expect(plainHome).not.toContain('href="/alice/plain/app"'); | |
| 187 | }); | |
| 188 | ||
| 189 | test("a refused New app keeps what was typed", async () => { | |
| 190 | const dup = await web(alice, "/apps/new", { | |
| 191 | form: { owner: "alice", name: "hello", visibility: "public" }, | |
| 192 | }); | |
| 193 | expect(dup.status).toBe(422); | |
| 194 | const html = words(await dup.text()); | |
| 195 | expect(html).toContain("already has a repository by that name"); | |
| 196 | expect(html).toContain('value="hello"'); | |
| 197 | }); | |
| 198 | ||
| 199 | test("the dashboard and /apps list the app with its state and URL", async () => { | |
| 200 | const dash = await bodyOf(alice, "/"); | |
| 201 | expect(dash).toContain("<h2>Apps</h2>"); | |
| 202 | expect(dash).toContain("alice/hello"); | |
| 203 | expect(dash).toContain('href="/alice/hello/app/production/open"'); | |
| 204 | expect(dash).toContain('href="/apps/new"'); | |
| 205 | ||
| 206 | const list = await bodyOf(alice, "/apps"); | |
| 207 | expect(list).toContain("alice/hello"); | |
| 208 | expect(list).toContain(`http://hello.${DOMAIN}:${servers.port}`); | |
| 209 | // Signed out, a public app is still listed; the button says sign in. | |
| 210 | const anonymous = await bodyOf("", "/apps"); | |
| 211 | expect(anonymous).toContain("alice/hello"); | |
| 212 | expect(anonymous).toContain("Sign in"); | |
| 213 | }); | |
| 214 | ||
| 215 | // ---- the app page and the runs ---- | |
| 216 | ||
| 217 | test("the App page shows the environments, the runs, and the buttons a writer gets", async () => { | |
| 218 | const runs = await runsOf(hello); | |
| 219 | expect(runs.length).toBeGreaterThan(0); | |
| 220 | const html = await bodyOf(alice, "/alice/hello/app"); | |
| 221 | expect(html).toContain("production"); | |
| 222 | expect(html).toContain("Recent runs"); | |
| 223 | expect(html).toContain("Deploy production"); | |
| 224 | expect(html).toContain('action="/alice/hello/app/production/redeploy"'); | |
| 225 | expect(html).toContain('href="/alice/hello/app/production/open"'); | |
| 226 | expect(html).toContain('href="/alice/hello/settings/app"'); | |
| 227 | // A reader sees no buttons; bob can read the public repo but not write. | |
| 228 | const readers = await bodyOf(bob, "/alice/hello/app"); | |
| 229 | expect(readers).not.toContain("/redeploy"); | |
| 230 | expect(readers).not.toContain('href="/alice/hello/settings/app"'); | |
| 231 | }); | |
| 232 | ||
| 233 | test("the runs list, one run's log page, and the short door to it", async () => { | |
| 234 | const [run] = await runsOf(hello); | |
| 235 | expect(run).toBeDefined(); | |
| 236 | const list = await bodyOf(alice, "/alice/hello/runs"); | |
| 237 | expect(list).toContain("Deploy production"); | |
| 238 | expect(list).toContain(`href="/alice/hello/runs/${run!.id}"`); | |
| 239 | expect(await bodyOf(alice, "/alice/hello/runs?kind=agent")).toContain( | |
| 240 | "No agent runs yet", | |
| 241 | ); | |
| 242 | ||
| 243 | const page = await bodyOf(alice, `/alice/hello/runs/${run!.id}`); | |
| 244 | expect(page).toContain("<h1>Deploy production</h1>"); | |
| 245 | expect(page).toContain('id="run-log"'); | |
| 246 | expect(page).toContain('id="run-state"'); | |
| 247 | // The stream and the no-script refresh exist only while it is going. | |
| 248 | const fresh = listRuns(app.db, hello.id).find((r) => r.id === run!.id)!; | |
| 249 | const going = fresh.state === "queued" || fresh.state === "running"; | |
| 250 | expect(page.includes("data-stream=")).toBe(going); | |
| 251 | expect(page.includes('http-equiv="refresh"')).toBe(going); | |
| 252 | ||
| 253 | const door = await web(alice, `/runs/${run!.id}`); | |
| 254 | expect(door.status).toBe(303); | |
| 255 | expect(door.headers.get("location")).toBe(`/alice/hello/runs/${run!.id}`); | |
| 256 | // Anonymous: sign in first, whatever the id — the redirect says nothing. | |
| 257 | for (const id of [run!.id, "nope"]) { | |
| 258 | const anonymous = await web("", `/runs/${id}`); | |
| 259 | expect(anonymous.status).toBe(303); | |
| 260 | expect(anonymous.headers.get("location")).toContain("/signin"); | |
| 261 | } | |
| 262 | expect((await web(alice, "/runs/nope")).status).toBe(404); | |
| 263 | expect((await web(alice, "/alice/hello/runs/nope")).status).toBe(404); | |
| 264 | }); | |
| 265 | ||
| 266 | test("cancelling a run: a writer may, a reader may not", async () => { | |
| 267 | const [run] = await runsOf(hello); | |
| 268 | const refused = await web(bob, `/alice/hello/runs/${run!.id}/cancel`, { | |
| 269 | form: {}, | |
| 270 | }); | |
| 271 | expect(refused.status).toBe(303); | |
| 272 | expect(flashOf(refused)).toContain("write access"); | |
| 273 | ||
| 274 | const cancelled = await web(alice, `/alice/hello/runs/${run!.id}/cancel`, { | |
| 275 | form: {}, | |
| 276 | }); | |
| 277 | expect(cancelled.status).toBe(303); | |
| 278 | const after = listRuns(app.db, hello.id).find((r) => r.id === run!.id)!; | |
| 279 | if (after.state === "cancelled") | |
| 280 | expect(flashOf(cancelled)).toContain("Run cancelled"); | |
| 281 | else expect(flashOf(cancelled)).toContain("already finished"); | |
| 282 | }); | |
| 283 | ||
| 284 | // ---- settings ---- | |
| 285 | ||
| 286 | test("repo settings: require checks toggles, the App section edits how it is served", async () => { | |
| 287 | const general = await bodyOf(alice, "/alice/hello/settings"); | |
| 288 | expect(general).toContain('name="requireChecks"'); | |
| 289 | const saved = await web(alice, "/alice/hello/settings", { | |
| 290 | form: { | |
| 291 | description: "", | |
| 292 | visibility: "public", | |
| 293 | defaultBranch: hello.defaultBranch, | |
| 294 | requireChecks: "on", | |
| 295 | }, | |
| 296 | }); | |
| 297 | expect(saved.status).toBe(303); | |
| 298 | hello = getRepo(app.db, "alice", "hello")!; | |
| 299 | expect(hello.requireChecks).toBe(true); | |
| 300 | ||
| 301 | const section = await bodyOf(alice, "/alice/hello/settings/app"); | |
| 302 | expect(section).toContain('name="slug"'); | |
| 303 | expect(section).toContain('value="hello"'); | |
| 304 | expect(section).toContain("Stop running as an app"); | |
| 305 | expect(section).toContain(`hello.${DOMAIN}`); | |
| 306 | ||
| 307 | const changed = await web(alice, "/alice/hello/settings/app", { | |
| 308 | form: { slug: "hello", access: "private", previewData: "empty" }, | |
| 309 | }); | |
| 310 | expect(changed.status).toBe(303); | |
| 311 | const record = getAppByRepo(app.db, hello.id)!; | |
| 312 | expect(record.access).toBe("private"); | |
| 313 | expect(record.previewData).toBe("empty"); | |
| 314 | ||
| 315 | // Only an admin of the repository sees the section at all. | |
| 316 | expect((await web(bob, "/alice/hello/settings/app")).status).toBe(404); | |
| 317 | expect((await web(bob, "/alice/hello/settings/crew")).status).toBe(404); | |
| 318 | }); | |
| 319 | ||
| 320 | test("Open goes straight to a public app and through a grant for a private one", async () => { | |
| 321 | // Private right now, from the test above: a grant for alice. | |
| 322 | const granted = await web(alice, "/alice/hello/app/production/open"); | |
| 323 | expect(granted.status).toBe(302); | |
| 324 | expect(granted.headers.get("location")).toStartWith( | |
| 325 | `http://hello.${DOMAIN}:${servers.port}/__forge/grant?t=`, | |
| 326 | ); | |
| 327 | // Anonymous visitors have nobody to vouch for them: sign in first. | |
| 328 | const anonymous = await web("", "/alice/hello/app/production/open"); | |
| 329 | expect(anonymous.status).toBe(303); | |
| 330 | expect(anonymous.headers.get("location")).toContain("/signin"); | |
| 331 | // An environment that is not one is not found. | |
| 332 | expect((await web(alice, "/alice/hello/app/nowhere/open")).status).toBe( | |
| 333 | 404, | |
| 334 | ); | |
| 335 | ||
| 336 | const back = await web(alice, "/alice/hello/settings/app", { | |
| 337 | form: { slug: "hello", access: "public", previewData: "copy" }, | |
| 338 | }); | |
| 339 | expect(back.status).toBe(303); | |
| 340 | const open = await web("", "/alice/hello/app/production/open"); | |
| 341 | expect(open.status).toBe(302); | |
| 342 | expect(open.headers.get("location")).toBe( | |
| 343 | `http://hello.${DOMAIN}:${servers.port}`, | |
| 344 | ); | |
| 345 | }); | |
| 346 | ||
| 347 | test("a plain repository becomes an app from its settings, and stops being one", async () => { | |
| 348 | const plain = getRepo(app.db, "alice", "plain")!; | |
| 349 | const section = await bodyOf(alice, "/alice/plain/settings/app"); | |
| 350 | expect(section).toContain("Run as an app"); | |
| 351 | expect(section).toContain('value="plain"'); | |
| 352 | ||
| 353 | // A hostname another app already answers to is refused, text kept. | |
| 354 | const clash = await web(alice, "/alice/plain/settings/app", { | |
| 355 | form: { slug: "hello", access: "private", previewData: "copy" }, | |
| 356 | }); | |
| 357 | expect(clash.status).toBe(422); | |
| 358 | expect(words(await clash.text())).toContain("already answers to that"); | |
| 359 | expect(getAppByRepo(app.db, plain.id)).toBeUndefined(); | |
| 360 | ||
| 361 | const enabled = await web(alice, "/alice/plain/settings/app", { | |
| 362 | form: { slug: "plain", access: "private", previewData: "empty" }, | |
| 363 | }); | |
| 364 | expect(enabled.status).toBe(303); | |
| 365 | expect(enabled.headers.get("location")).toBe("/alice/plain/app"); | |
| 366 | expect(getAppByRepo(app.db, plain.id)?.previewData).toBe("empty"); | |
| 367 | expect(await bodyOf(alice, "/alice/plain")).toContain( | |
| 368 | 'href="/alice/plain/app"', | |
| 369 | ); | |
| 370 | ||
| 371 | // Disabling goes through the confirm page, which words itself. | |
| 372 | const confirm = await bodyOf( | |
| 373 | alice, | |
| 374 | "/-/confirm?to=%2Falice%2Fplain%2Fsettings%2Fapp%2Fdisable", | |
| 375 | ); | |
| 376 | expect(confirm).toContain("Stop running alice/plain as an app?"); | |
| 377 | const disabled = await web(alice, "/alice/plain/settings/app/disable", { | |
| 378 | form: {}, | |
| 379 | }); | |
| 380 | expect(disabled.status).toBe(303); | |
| 381 | expect(getAppByRepo(app.db, plain.id)).toBeUndefined(); | |
| 382 | expect((await web(alice, "/alice/plain/app")).status).toBe(404); | |
| 383 | }); | |
| 384 | ||
| 385 | test("a private repository's app pages are not found to anyone who cannot read it", async () => { | |
| 386 | const made = await web(alice, "/new", { | |
| 387 | form: { owner: "alice", name: "secret", visibility: "private" }, | |
| 388 | }); | |
| 389 | expect(made.status).toBe(303); | |
| 390 | const enabled = await web(alice, "/alice/secret/settings/app", { | |
| 391 | form: { slug: "secret", access: "private", previewData: "copy" }, | |
| 392 | }); | |
| 393 | expect(enabled.status).toBe(303); | |
| 394 | for (const cookie of ["", bob]) { | |
| 395 | for (const path of [ | |
| 396 | "/alice/secret/app", | |
| 397 | "/alice/secret/runs", | |
| 398 | "/alice/secret/app/production/open", | |
| 399 | "/alice/secret/settings/app", | |
| 400 | ]) { | |
| 401 | const response = await web(cookie, path); | |
| 402 | expect(`${path} → ${response.status}`).toBe(`${path} → 404`); | |
| 403 | } | |
| 404 | } | |
| 405 | expect((await web(alice, "/alice/secret/app")).status).toBe(200); | |
| 406 | }); | |
| 407 | ||
| 408 | // ---- the crew ---- | |
| 409 | ||
| 410 | test("the crew section: admin only, and an agent made on the web", async () => { | |
| 411 | expect((await web(bob, "/settings/crew")).status).toBe(404); | |
| 412 | expect(await bodyOf(bob, "/settings")).not.toContain( | |
| 413 | 'href="/settings/crew"', | |
| 414 | ); | |
| 415 | const anonymous = await web("", "/settings/crew"); | |
| 416 | expect(anonymous.status).toBe(303); | |
| 417 | ||
| 418 | const page = await bodyOf(alice, "/settings/crew"); | |
| 419 | expect(page).toContain("No agents yet"); | |
| 420 | expect(page).toContain("No crew runner is up"); | |
| 421 | expect(page).toContain("<h3>New agent</h3>"); | |
| 422 | expect(await bodyOf(alice, "/settings")).toContain('href="/settings/crew"'); | |
| 423 | ||
| 424 | const bad = await web(alice, "/settings/crew", { | |
| 425 | form: { username: "bot", budgetUsd: "-4" }, | |
| 426 | }); | |
| 427 | expect(bad.status).toBe(422); | |
| 428 | expect(words(await bad.text())).toContain("A budget is a number"); | |
| 429 | expect(getAgentByName(app.db, "bot")).toBeUndefined(); | |
| 430 | ||
| 431 | const made = await web(alice, "/settings/crew", { | |
| 432 | form: { | |
| 433 | username: "bot", | |
| 434 | displayName: "Bot", | |
| 435 | model: "", | |
| 436 | brief: "Be brief.", | |
| 437 | budgetUsd: "2", | |
| 438 | }, | |
| 439 | }); | |
| 440 | expect(made.status).toBe(303); | |
| 441 | const bot = getAgentByName(app.db, "bot")!; | |
| 442 | expect(bot.user.kind).toBe("agent"); | |
| 443 | expect(bot.budgetUsd).toBe(2); | |
| 444 | expect(await bodyOf(alice, "/settings/crew")).toContain("Be brief."); | |
| 445 | ||
| 446 | const edit = await bodyOf(alice, "/settings/crew/bot"); | |
| 447 | expect(edit).toContain('value="bot"'); | |
| 448 | expect(edit).toContain("Retire this agent"); | |
| 449 | const updated = await web(alice, "/settings/crew/bot", { | |
| 450 | form: { model: "sonnet", brief: "Be brief.", budgetUsd: "3" }, | |
| 451 | }); | |
| 452 | expect(updated.status).toBe(303); | |
| 453 | expect(getAgentByName(app.db, "bot")?.model).toBe("sonnet"); | |
| 454 | expect(getAgentByName(app.db, "bot")?.budgetUsd).toBe(3); | |
| 455 | // A non-admin cannot touch any of it, whatever the form says. | |
| 456 | const forged = await web(bob, "/settings/crew/bot", { | |
| 457 | form: { model: "opus" }, | |
| 458 | }); | |
| 459 | expect(forged.status).toBe(404); | |
| 460 | expect(getAgentByName(app.db, "bot")?.model).toBe("sonnet"); | |
| 461 | }); | |
| 462 | ||
| 463 | test("a repository's crew: add, assign, refuse the ones not on it, remove", async () => { | |
| 464 | const before = await bodyOf(alice, "/alice/hello/settings/crew"); | |
| 465 | expect(before).toContain("No crew yet"); | |
| 466 | expect(before).toContain('<option value="bot">'); | |
| 467 | const added = await web(alice, "/alice/hello/settings/crew", { | |
| 468 | form: { agent: "bot" }, | |
| 469 | }); | |
| 470 | expect(added.status).toBe(303); | |
| 471 | expect(listCrew(app.db, hello.id).map((a) => a.user.username)).toEqual([ | |
| 472 | "bot", | |
| 473 | ]); | |
| 474 | const after = await bodyOf(alice, "/alice/hello/settings/crew"); | |
| 475 | expect(after).toContain('action="/alice/hello/settings/crew/bot/remove"'); | |
| 476 | expect(after).toContain("already on this crew"); | |
| 477 | ||
| 478 | // New app now offers the agent. | |
| 479 | expect(await bodyOf(alice, "/apps/new")).toContain('name="agent"'); | |
| 480 | ||
| 481 | // An issue, and the assignee control on it. | |
| 482 | const opened = await web(alice, "/alice/hello/issues/new", { | |
| 483 | form: { title: "Do a thing", body: "Please." }, | |
| 484 | }); | |
| 485 | expect(opened.status).toBe(303); | |
| 486 | const issue = getIssue(app.db, hello.id, 1)!; | |
| 487 | expect(issue).toBeDefined(); | |
| 488 | const thread = await bodyOf(alice, "/alice/hello/issues/1"); | |
| 489 | expect(thread).toContain("<h2>Assignee</h2>"); | |
| 490 | expect(thread).toContain('action="/alice/hello/issues/1/assignee"'); | |
| 491 | expect(thread).toContain('<option value="bot" > bot (agent)'); | |
| 492 | expect(thread).toContain('<option value="alice"'); | |
| 493 | // A reader sees who is on it and no form. | |
| 494 | expect(await bodyOf(bob, "/alice/hello/issues/1")).not.toContain( | |
| 495 | 'action="/alice/hello/issues/1/assignee"', | |
| 496 | ); | |
| 497 | ||
| 498 | const assigned = await web(alice, "/alice/hello/issues/1/assignee", { | |
| 499 | form: { assignee: "bot" }, | |
| 500 | }); | |
| 501 | expect(assigned.status).toBe(303); | |
| 502 | expect(flashOf(assigned)).toContain("starts working"); | |
| 503 | expect(getIssue(app.db, hello.id, 1)?.assigneeName).toBe("bot"); | |
| 504 | expect(await bodyOf(alice, "/alice/hello/issues/1")).toContain( | |
| 505 | '<option value="bot" selected', | |
| 506 | ); | |
| 507 | ||
| 508 | // An agent that is not on the crew has nothing to start on. | |
| 509 | const idle = await createAgent(app, user("alice"), { username: "idle" }); | |
| 510 | expect(idle.ok).toBe(true); | |
| 511 | const refused = await web(alice, "/alice/hello/issues/1/assignee", { | |
| 512 | form: { assignee: "idle" }, | |
| 513 | }); | |
| 514 | expect(refused.status).toBe(303); | |
| 515 | expect(flashOf(refused)).toContain("not on this repository"); | |
| 516 | expect(getIssue(app.db, hello.id, 1)?.assigneeName).toBe("bot"); | |
| 517 | // Nobody at all, and a name that is nobody. | |
| 518 | const nobody = await web(alice, "/alice/hello/issues/1/assignee", { | |
| 519 | form: { assignee: "" }, | |
| 520 | }); | |
| 521 | expect(nobody.status).toBe(303); | |
| 522 | expect(getIssue(app.db, hello.id, 1)?.assigneeName).toBeNull(); | |
| 523 | expect( | |
| 524 | flashOf( | |
| 525 | await web(alice, "/alice/hello/issues/1/assignee", { | |
| 526 | form: { assignee: "ghost" }, | |
| 527 | }), | |
| 528 | ), | |
| 529 | ).toContain("no account named ghost"); | |
| 530 | // A reader cannot assign. | |
| 531 | const reader = await web(bob, "/alice/hello/issues/1/assignee", { | |
| 532 | form: { assignee: "alice" }, | |
| 533 | }); | |
| 534 | expect(reader.status).toBe(303); | |
| 535 | expect(flashOf(reader)).toContain("write access"); | |
| 536 | expect(getIssue(app.db, hello.id, 1)?.assigneeName).toBeNull(); | |
| 537 | ||
| 538 | const removed = await web(alice, "/alice/hello/settings/crew/bot/remove", { | |
| 539 | form: {}, | |
| 540 | }); | |
| 541 | expect(removed.status).toBe(303); | |
| 542 | expect(listCrew(app.db, hello.id)).toEqual([]); | |
| 543 | ||
| 544 | const retired = await web(alice, "/settings/crew/idle/delete", { | |
| 545 | form: {}, | |
| 546 | }); | |
| 547 | expect(retired.status).toBe(303); | |
| 548 | expect(getAgentByName(app.db, "idle")).toBeUndefined(); | |
| 549 | }); | |
| 550 | ||
| 551 | // ---- the pull page, and the inbox ---- | |
| 552 | ||
| 553 | test("a pull carries the preview card, the checks line and the assignee; the dashboard's inbox lists it", async () => { | |
| 554 | const aliceUser = user("alice"); | |
| 555 | const committed = await commitToBranch(app, aliceUser, hello, { | |
| 556 | branch: "greeting", | |
| 557 | base: hello.defaultBranch, | |
| 558 | message: "Say hello", | |
| 559 | files: [{ path: "GREETING.md", content: "hi\n" }], | |
| 560 | }); | |
| 561 | expect(committed.ok).toBe(true); | |
| 562 | const opened = await openPull(app, aliceUser, hello, { | |
| 563 | title: "Say hello", | |
| 564 | baseBranch: hello.defaultBranch, | |
| 565 | headBranch: "greeting", | |
| 566 | }); | |
| 567 | expect(opened.ok).toBe(true); | |
| 568 | if (!opened.ok) return; | |
| 569 | const number = opened.value.number; | |
| 570 | ||
| 571 | const page = await bodyOf(alice, `/alice/hello/pulls/${number}`); | |
| 572 | expect(page).toContain("<h2>Preview</h2>"); | |
| 573 | expect(page).toContain("<h2>Assignee</h2>"); | |
| 574 | // Checks are required: the line says so, and the box waits for them | |
| 575 | // unless the preview already came up green. | |
| 576 | expect(page).toContain('class="checks-line"'); | |
| 577 | expect(page).toContain("a passing deploy is required"); | |
| 578 | const check = listRuns(app.db, hello.id, { | |
| 579 | env: `pull-${number}`, | |
| 580 | }).find( | |
| 581 | (r) => r.commitOid === (committed.ok ? committed.value.commitOid : ""), | |
| 582 | ); | |
| 583 | if (check?.state !== "ok") expect(page).toContain("Waiting on checks."); | |
| 584 | ||
| 585 | // On you: assigned to the viewer, the pull shows on the dashboard. | |
| 586 | const assigned = await web(alice, `/alice/hello/pulls/${number}/assignee`, { | |
| 587 | form: { assignee: "alice" }, | |
| 588 | }); | |
| 589 | expect(assigned.status).toBe(303); | |
| 590 | const dash = await bodyOf(alice, "/"); | |
| 591 | expect(dash).toContain("<h2>On you</h2>"); | |
| 592 | expect(dash).toContain("Say hello"); | |
| 593 | expect(await bodyOf(bob, "/")).not.toContain("<h2>On you</h2>"); | |
| 594 | ||
| 595 | // Needs you: a pull the crew opened, in a repository alice can write. | |
| 596 | expect(addToCrew(app, aliceUser, hello, "bot").ok).toBe(true); | |
| 597 | const botWork = await commitToBranch(app, aliceUser, hello, { | |
| 598 | branch: "bot/1-do-a-thing", | |
| 599 | base: hello.defaultBranch, | |
| 600 | message: "Bot work", | |
| 601 | files: [{ path: "BOT.md", content: "made by bot\n" }], | |
| 602 | }); | |
| 603 | expect(botWork.ok).toBe(true); | |
| 604 | const proposed = await openPull(app, user("bot"), hello, { | |
| 605 | title: "Bot did a thing", | |
| 606 | baseBranch: hello.defaultBranch, | |
| 607 | headBranch: "bot/1-do-a-thing", | |
| 608 | }); | |
| 609 | expect(proposed.ok).toBe(true); | |
| 610 | const inbox = await bodyOf(alice, "/"); | |
| 611 | expect(inbox).toContain("<h2>Needs you</h2>"); | |
| 612 | expect(inbox).toContain("Bot did a thing"); | |
| 613 | // Bob can read the public repo but not write it: not his inbox. | |
| 614 | expect(await bodyOf(bob, "/")).not.toContain("<h2>Needs you</h2>"); | |
| 615 | }); | |
| 616 | ||
| 617 | test("the assignee select offers the people who can read the repository", async () => { | |
| 618 | // A public repository: any account on the forge may be put on it. | |
| 619 | const open = await bodyOf(alice, "/alice/hello/issues/1"); | |
| 620 | expect(open).toContain('<option value="bob"'); | |
| 621 | // A private one bob cannot read: he is not offered, and could not be | |
| 622 | // assigned if he were — the select and the action say the same thing. | |
| 623 | const opened = await web(alice, "/alice/secret/issues/new", { | |
| 624 | form: { title: "Private thing", body: "" }, | |
| 625 | }); | |
| 626 | expect(opened.status).toBe(303); | |
| 627 | const closed = await bodyOf(alice, "/alice/secret/issues/1"); | |
| 628 | expect(closed).toContain("<h2>Assignee</h2>"); | |
| 629 | expect(closed).toContain('<option value="alice"'); | |
| 630 | expect(closed).not.toContain('<option value="bob"'); | |
| 631 | const refused = await web(alice, "/alice/secret/issues/1/assignee", { | |
| 632 | form: { assignee: "bob" }, | |
| 633 | }); | |
| 634 | expect(refused.status).toBe(303); | |
| 635 | expect(flashOf(refused)).toContain("cannot see this repository"); | |
| 636 | }); | |
| 637 | ||
| 638 | // ---- CSRF ---- | |
| 639 | ||
| 640 | test("every new POST refuses a cross-origin submission", async () => { | |
| 641 | const targets = [ | |
| 642 | "/apps/new", | |
| 643 | "/settings/crew", | |
| 644 | "/settings/crew/bot", | |
| 645 | "/settings/crew/bot/delete", | |
| 646 | "/alice/hello/settings/app", | |
| 647 | "/alice/hello/settings/app/disable", | |
| 648 | "/alice/hello/settings/crew", | |
| 649 | "/alice/hello/settings/crew/bot/remove", | |
| 650 | "/alice/hello/issues/1/assignee", | |
| 651 | "/alice/hello/app/production/redeploy", | |
| 652 | "/alice/hello/app/production/stop", | |
| 653 | ]; | |
| 654 | for (const path of targets) { | |
| 655 | const response = await web(alice, path, { | |
| 656 | form: { agent: "bot", assignee: "bot", slug: "x", username: "evil" }, | |
| 657 | origin: "https://evil.example", | |
| 658 | }); | |
| 659 | expect(`${path} → ${response.status}`).toBe(`${path} → 403`); | |
| 660 | } | |
| 661 | expect(getAppByRepo(app.db, hello.id)?.slug).toBe("hello"); | |
| 662 | expect(getAgentByName(app.db, "evil")).toBeUndefined(); | |
| 663 | expect(getAgentByName(app.db, "bot")).toBeDefined(); | |
| 664 | }); | |
| 665 | }); |
new above
offline