Public: anyone can read this; nobody can change it.

WIP: preview — apps worktree #2

Closed opened this view on origin
Closed without merging

apps-preview → main 4 commits

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
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
3939 9. **`not_found` is deliberately ambiguous.** A missing resource and a resource
4040 you can't see both return `not_found`, so private repos never leak their
4141 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.
4252
4353 ## How to work
4454
@@ -116,8 +126,29 @@ src/
116126 vocabulary; screens/* are adapters over actions/; forge.ts is the
117127 root + palette; ssh.ts is the front door (identity, git exec,
118128 `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)
119144 ```
120145
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
121152 Two rules the TUI must keep. `tui/term/` is the transport boundary — no screen,
122153 widget or loop code reaches past it to `process`, which is what lets one runtime
123154 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.
176207 is the same rule for a commit — objects written, ref held back until the
177208 origin agrees.
178209
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
179223 ## Not yet built (deliberately)
180224
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:
8383 the code on whatever device has a browser, and the forge does the polling.
8484 This is the flow that works over SSH with no browser on the server. Setting
8585 it up is three steps, once, for everyone on the forge:
86
8786 1. Create an OAuth App at <https://github.com/settings/developers>. Name it
8887 anything, set the homepage to your forge's URL, set the authorization
8988 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:
9392 disconnecting revokes the grant on GitHub too.
9493 3. The GitHub button appears. Until it does, both surfaces print these three
9594 steps rather than naming an environment variable and leaving you to it.
95
9696 - **Forgejo / Gitea** — authorization code with PKCE. They implement no device
9797 grant, and every instance needs its own OAuth application, so you paste a
9898 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:
109109 Both connect screens hand the job to whatever device has a browser: a **QR
110110 code** to point a camera at, a **short link** (`https://your.forge/go/<10
111111 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
113113 your own terminal (OSC 52, so it works over SSH; iTerm2, Ghostty, kitty and
114114 WezTerm honour it, Apple Terminal does not). `o` opens a browser only when the
115115 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.
123123 A mirrored repo wears its origin in the header (`mirrored from owner/name @
124124 host`), shows its last fetch and any fetch error in the About rail, and syncs
125125 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.
127127 Merging a pull happens on the origin: the merge box says so and links there,
128128 and the next sync brings back the merge commit it actually made. Pushes go
129129 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
188188 committed). Until you build it, `bun run src/cli/main.ts …` is the same
189189 program.
190190
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
191246 ## Develop
192247
193248 ```sh
@@ -212,6 +267,11 @@ src/
212267 tui/ the terminal UI: runtime, widgets, screens, the ssh front door,
213268 and local/ — the same runtime over your own working trees
214269 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
215275 ```
216276
217277 Read `docs/ARCHITECTURE.md` for the principles and the reasoning behind them.
@@ -252,10 +312,16 @@ work here.
252312 - **Automation** — a JSON API covering repos, issues, forks, and orgs (the
253313 label-as-queue surface), a durable event outbox, and **webhooks** (HMAC-signed
254314 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.
255321
256322 Pages render without a line of JavaScript and are designed thumb-first. Six
257323 premade themes (from warm paper to polar night) switch instantly and persist in
258324 a cookie — even that works script-free. Still **zero runtime dependencies**.
259325
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:
186186 blob line _content hash_ alongside file/line, so a force-push either
187187 re-anchors it exactly or marks it outdated explicitly — never silently lands
188188 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_
190190 code", so `review` stores the `commit_oid` it was passed on and the merge
191191 gate counts only verdicts on the current head. A push therefore dismisses a
192192 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
374374 hash above). A **verdict** — `approved`, `changes_requested`, `commented` —
375375 is anchored to a commit, and it is what decides whether a change may land.
376376 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_",
378378 which only a row carrying a commit id can answer.
379379
380380 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
412412 the dialect details, including the one that bites — GitHub takes the verb
413413 `APPROVE` and answers with the noun `APPROVED`, and Forgejo takes the noun.
414414
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
415483 ## Search, and where semantic search would go
416484
417485 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
463531 queries, and one search across local repos, cached origin repos, issues and
464532 code.
465533
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
126126 component takes in a TUI — a note for the terminal thread to fill in, not a
127127 specification.
128128
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 |
187195
188196 Two things are deliberately **not** in the vocabulary: skeleton loaders (the
189197 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
286294 resolve — so a button that is absent and a call that is refused are the same
287295 fact said twice, and never two different facts.
288296
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
289339 ---
290340
291341 ## 6. Keyboard
@@ -556,9 +606,11 @@ the binding itself so a label can never drift from the action. A list with no
556606 `hitList` is a bug, not a design decision: "everything you can see is
557607 something you can click" is the whole claim.
558608
559 ### 6·0a. The dashboard's four panes
609 ### 6·0a. The dashboard's panes
560610
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
562614 a pane with its own cursor: `tab` / `shift+tab` cycles, the focused one wears
563615 the accent frame, `j`/`k` move inside it and `enter` opens what it is pointing
564616 at — an activity row opens **the issue, pull or repository it is about**, a
@@ -714,7 +766,6 @@ arrangement got wrong:
714766 | `u` | — | discard this tab's changes |
715767 | `v` | — | the file's other view (below) |
716768
717
718769 And the keys the whole workspace answers, from either pane: `]` `[` and `1`–`9`
719770 move between tabs, `x` / `X` close one or the others, `c` commits, `b` lists
720771 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.
737788 **Two views, one key.** Some files have two honest ways of being seen, and `v`
738789 is the toggle for all of them:
739790
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) |
745796
746797 Prose opens **rendered**, because that is what a README is for and it is what
747798 the repo card always did — but the rendered view is a view, not a mode: `j`/`k`
@@ -852,7 +903,7 @@ your fork:
852903 ```
853904
854905 `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,
856907 push it there, and open the pull upstream with the `owner:branch` head form
857908 both forges take. The toast reads `opened pull #12 on octo/widgets`, and when
858909 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
863914 `proposing to octo/widgets via your fork`, on every frame, so nobody has to
864915 press `c` to find out where their change is going.
865916
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
867918 dialog: on a mirror that is `editFiles` plus push-through, which is
868919 `contribute`'s own `via: "direct"` path by another name.
869920
@@ -978,6 +1029,41 @@ there is no lifecycle to get wrong. A burst is coalesced into one refresh.
9781029
9791030 ---
9801031
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
9811067 ## 7. Two rules that are security boundaries
9821068
9831069 **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 {
2626 getLabel,
2727 labelsFor,
2828 listComments,
29 setAssignee,
2930 setIssueLabels as writeLabels,
3031 setIssueState as writeState,
3132 type Comment,
@@ -36,7 +37,7 @@ import {
3637 type TitleError,
3738 } from "../core/issues.ts";
3839 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";
4041 import type { User } from "../core/users.ts";
4142 import {
4243 applyLabelNames,
@@ -88,6 +89,46 @@ export function openIssue(
8889 return created;
8990 }
9091
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
91132 /** Comment on an issue or a pull; anyone who can see it may speak. */
92133 export function addComment(
93134 app: App,
@@ -212,8 +253,7 @@ export async function openIssueThrough(
212253 if (!user.ok) return user;
213254 const through = writeThroughFor(app, user.value, repo);
214255 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);
217257
218258 const title = input.title.trim();
219259 if (title === "") return err("empty_title");
src/actions/pulls.ts +33 −4
@@ -5,7 +5,12 @@
55 */
66
77 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";
914 import {
1015 createPull,
1116 getPull,
@@ -138,15 +143,32 @@ export async function openPull(
138143 },
139144 );
140145 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;
141153 app.bus.append({
142154 kind: "pull.opened",
143155 repoId: baseRepo.id,
144156 actorId: user.value.id,
145 payload: { number: created.value.number },
157 payload: {
158 number: created.value.number,
159 ...(assigned ? { assignee: user.value.username } : {}),
160 },
146161 });
147 return created;
162 return assigned ? ok({ ...created.value, ...assigneeOf(assigned) }) : created;
148163 }
149164
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
150172 /**
151173 * Bring `refs/pull/N/head` up to date with the fork it came from.
152174 *
@@ -261,6 +283,9 @@ export async function mergePull(
261283 const writer = requireWriter(app, actor, repo);
262284 if (!writer.ok) return writer;
263285 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");
264289 // Merging on a mirror is not offered. A local merge would rewrite the base
265290 // branch here and then be overwritten by the next fetch; a merge through
266291 // the API would have to reproduce the origin's merge strategy, protected
@@ -276,7 +301,11 @@ export async function mergePull(
276301 repoDir(app.reposRoot, repo.ownerName, repo.name),
277302 pull,
278303 {
279 gate: { db: app.db, requireReview: repo.requireReview },
304 gate: {
305 db: app.db,
306 requireReview: repo.requireReview,
307 requireChecks: repo.requireChecks,
308 },
280309 strategy,
281310 message:
282311 strategy === "squash"
src/actions/repos.ts +4
@@ -83,6 +83,9 @@ export async function createRepo(
8383 ): Promise<Result<Repo, CreateRepoActionError>> {
8484 const user = requireUser(actor);
8585 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");
8689 const ownerId = resolveOwner(app, user.value, input.owner ?? "");
8790 if (ownerId === undefined) return err("forbidden_owner");
8891 const created = await initRepo(app.db, app.reposRoot, {
@@ -155,6 +158,7 @@ export async function updateRepoSettings(
155158 "protectDefault",
156159 "defaultBranch",
157160 "requireReview",
161 "requireChecks",
158162 ] as const
159163 ).filter((k) => settings[k] !== undefined && settings[k] !== repo[k]);
160164 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 ? "&amp;"
298 : c === "<"
299 ? "&lt;"
300 : c === ">"
301 ? "&gt;"
302 : c === '"'
303 ? "&quot;"
304 : "&#39;",
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 {
3636 readonly originAuthor: string | null;
3737 /** The canonical URL at the origin, for "view at source". */
3838 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;
3948 }
4049
4150 interface IssueRow {
@@ -53,11 +62,16 @@ interface IssueRow {
5362 closed_at: string | null;
5463 origin_author: string | null;
5564 origin_html_url: string | null;
65 assignee_id: string | null;
66 assignee_name: string | null;
67 assignee_is_agent: number | null;
5668 }
5769
5870 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`;
6175
6276 function toIssue(row: IssueRow): Issue {
6377 return {
@@ -75,9 +89,95 @@ function toIssue(row: IssueRow): Issue {
7589 closedAt: row.closed_at,
7690 originAuthor: row.origin_author,
7791 originUrl: row.origin_html_url,
92 assigneeId: row.assignee_id,
93 assigneeName: row.assignee_name,
94 assigneeIsAgent: row.assignee_is_agent === 1,
7895 };
7996 }
8097
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
81181 /** Atomically hand out the next issue number for a repo. */
82182 export function nextNumber(db: Database, repoId: string): number {
83183 const row = db
@@ -184,6 +284,8 @@ export interface ListIssuesOptions {
184284 label?: string;
185285 /** Only issues opened by this username. */
186286 author?: string;
287 /** Only issues assigned to this username. */
288 assignee?: string;
187289 /** Case-insensitive substring of the title — the list page's search box. */
188290 query?: string;
189291 /** Page size. @default LIST_LIMIT (max LIST_LIMIT_MAX) */
@@ -213,6 +315,10 @@ export function listIssues(
213315 params.push(options.author);
214316 where += ` AND user.username = ?${params.length} COLLATE NOCASE`;
215317 }
318 if (options.assignee !== undefined) {
319 params.push(options.assignee);
320 where += ` AND assignee.username = ?${params.length} COLLATE NOCASE`;
321 }
216322 if (options.label !== undefined) {
217323 params.push(options.label);
218324 const p = params.length;
@@ -606,8 +712,6 @@ const chunked = <T>(items: readonly T[]): T[][] => {
606712 const placeholders = (n: number): string =>
607713 Array.from({ length: n }, (_, i) => `?${i + 1}`).join(",");
608714
609
610
611715 /**
612716 * Open and closed counts for one repo and kind, in a single pass over the
613717 * covering index — the pair every list page shows above its tabs, which was
src/core/pulls.ts +17 −3
@@ -32,6 +32,7 @@ import {
3232 type ListIssuesOptions,
3333 } from "./issues.ts";
3434 import { reviewSummary } from "./reviews.ts";
35 import { checkSummary } from "./runs.ts";
3536
3637 export interface Pull extends Issue {
3738 readonly baseBranch: string;
@@ -303,6 +304,11 @@ export interface PullReviewStatus {
303304 export interface ReviewGate {
304305 readonly db: Database;
305306 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;
306312 }
307313
308314 export interface PullStatus {
@@ -351,8 +357,7 @@ export async function pullStatus(
351357 ? `refs/pull/${pull.number}/head`
352358 : `refs/heads/${pull.headBranch}`;
353359 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);
356361 const ahead = await aheadCount(dir, baseOid, headOid);
357362 if (ahead === 0) {
358363 return {
@@ -390,7 +395,9 @@ export type MergePullError =
390395 | "closed"
391396 | "mirrored"
392397 /** 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";
394401
395402 export async function mergePull(
396403 db: Database,
@@ -414,6 +421,13 @@ export async function mergePull(
414421 const status = await pullStatus(dir, pull, input.gate);
415422 if (status.reviews !== null && !status.reviews.mergeable)
416423 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");
417431 const landed = await merge(dir, {
418432 baseBranch: pull.baseBranch,
419433 baseOid: status.baseOid,
src/core/repos.ts +58 −3
@@ -20,7 +20,7 @@ import { id } from "../kernel/id.ts";
2020 import { createLog } from "../kernel/log.ts";
2121 import { err, ok, type Result } from "../kernel/result.ts";
2222 import { likeEscape, pageLimit } from "./issues.ts";
23 import { REPO_VISIBLE } from "./visibility.ts";
23 import { REPO_VISIBLE, REPO_WRITABLE } from "./visibility.ts";
2424 import { accessAllows, orgAccess, type Access } from "./orgs.ts";
2525 import { getUserById, type User } from "./users.ts";
2626
@@ -65,6 +65,11 @@ export interface Repo {
6565 * Mechanical, not advisory: `mergePull` refuses `review_required`.
6666 */
6767 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;
6873 /** The repo this was forked from, or null. */
6974 readonly forkParentId: string | null;
7075 /** The origin this mirrors, or null for a native repo. */
@@ -87,6 +92,7 @@ interface RepoRow {
8792 default_branch: string;
8893 protect_default: number;
8994 require_review: number;
95 require_checks: number;
9096 fork_parent_id: string | null;
9197 origin_id: string | null;
9298 origin_kind: "github" | "forgejo" | "gitea" | null;
@@ -114,6 +120,7 @@ function toRepo(row: RepoRow): Repo {
114120 defaultBranch: row.default_branch,
115121 protectDefault: row.protect_default === 1,
116122 requireReview: row.require_review === 1,
123 requireChecks: row.require_checks === 1,
117124 forkParentId: row.fork_parent_id,
118125 origin:
119126 row.origin_id !== null && row.origin_kind !== null
@@ -480,12 +487,26 @@ export async function deleteRepo(
480487 }
481488
482489 // 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.
484493
485494 function personalOrDirect(repo: Repo, user: User | undefined): boolean {
486495 return user !== undefined && (user.id === repo.ownerId || user.isAdmin);
487496 }
488497
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
489510 /** Resolve org access for an org-owned repo, or "none" for a personal repo. */
490511 function orgAccessFor(
491512 db: Database,
@@ -504,6 +525,7 @@ export function canRead(
504525 ): boolean {
505526 if (repo.visibility === "public") return true;
506527 if (personalOrDirect(repo, user)) return true;
528 if (onCrew(db, repo, user)) return true;
507529 return accessAllows(orgAccessFor(db, repo, user), "read");
508530 }
509531
@@ -513,9 +535,39 @@ export function canWrite(
513535 user: User | undefined,
514536 ): boolean {
515537 if (personalOrDirect(repo, user)) return true;
538 if (onCrew(db, repo, user)) return true;
516539 return accessAllows(orgAccessFor(db, repo, user), "write");
517540 }
518541
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
519571 /** Admin over a repo (settings, delete, webhooks, protection). */
520572 export function canAdmin(
521573 db: Database,
@@ -532,6 +584,8 @@ export interface RepoSettings {
532584 protectDefault?: boolean;
533585 /** Require an approval of a pull's current head before it may merge. */
534586 requireReview?: boolean;
587 /** Require a passing deploy of a pull's current head before it may merge. */
588 requireChecks?: boolean;
535589 /** The caller must have pointed git HEAD at this branch first. */
536590 defaultBranch?: string;
537591 }
@@ -544,7 +598,7 @@ export function updateRepoSettings(
544598 ): Repo {
545599 db.query(
546600 `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`,
548602 ).run(
549603 settings.visibility ?? repo.visibility,
550604 settings.description ?? repo.description,
@@ -552,6 +606,7 @@ export function updateRepoSettings(
552606 settings.defaultBranch ?? repo.defaultBranch,
553607 (settings.requireReview ?? repo.requireReview) ? 1 : 0,
554608 repo.id,
609 (settings.requireChecks ?? repo.requireChecks) ? 1 : 0,
555610 );
556611 return getRepoById(db, repo.id)!;
557612 }
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[] = [
506506 ALTER TABLE repo ADD COLUMN origin_fork_of TEXT;
507507 ALTER TABLE origin_repo ADD COLUMN fork_of TEXT;
508508 `,
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 `,
509602 ];
src/core/tokens.ts +28 −5
@@ -24,20 +24,27 @@ export interface TokenInfo {
2424 readonly scope: TokenScope;
2525 readonly createdAt: string;
2626 readonly lastUsedAt: string | null;
27 /** When the token stops working, or null for a token that does not. */
28 readonly expiresAt: string | null;
2729 }
2830
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 */
3036 export function createToken(
3137 db: Database,
3238 userId: string,
3339 name: string,
3440 scope: TokenScope,
41 options: { expiresAt?: string } = {},
3542 ): { id: string; token: string } {
3643 const tokenId = id();
3744 const token = `forge_${secret()}`;
3845 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);
4148 return { id: tokenId, token };
4249 }
4350
@@ -66,13 +73,18 @@ export function verifyToken(
6673 user_id: string;
6774 scope: TokenScope;
6875 last_used_at: string | null;
76 expires_at: string | null;
6977 },
7078 [string]
7179 >(
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",
7381 )
7482 .get(sha256(presented));
7583 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");
7688 const user = getUserById(db, row.user_id);
7789 if (!user) return err("invalid_token");
7890 const now = Date.now();
@@ -96,10 +108,11 @@ export function listTokens(db: Database, userId: string): TokenInfo[] {
96108 scope: TokenScope;
97109 created_at: string;
98110 last_used_at: string | null;
111 expires_at: string | null;
99112 },
100113 [string]
101114 >(
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",
103116 )
104117 .all(userId);
105118 return rows.map((r) => ({
@@ -108,9 +121,19 @@ export function listTokens(db: Database, userId: string): TokenInfo[] {
108121 scope: r.scope,
109122 createdAt: r.created_at,
110123 lastUsedAt: r.last_used_at,
124 expiresAt: r.expires_at,
111125 }));
112126 }
113127
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
114137 export function deleteToken(
115138 db: Database,
116139 userId: string,
src/core/users.ts +59 −10
@@ -14,8 +14,12 @@ export interface User {
1414 readonly email: string;
1515 readonly displayName: string;
1616 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";
1923 readonly createdAt: string;
2024 }
2125
@@ -27,6 +31,7 @@ interface UserRow {
2731 is_admin: number;
2832 password_hash: string;
2933 kind: "user" | "org";
34 is_agent: number;
3035 created_at: string;
3136 }
3237
@@ -37,7 +42,9 @@ function toUser(row: UserRow): User {
3742 email: row.email,
3843 displayName: row.display_name,
3944 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,
4148 createdAt: row.created_at,
4249 };
4350 }
@@ -66,6 +73,12 @@ export interface CreateUserInput {
6673 * @default false
6774 */
6875 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;
6982 }
7083
7184 export async function createUser(
@@ -73,7 +86,9 @@ export async function createUser(
7386 input: CreateUserInput,
7487 ): Promise<Result<User, CreateUserError>> {
7588 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");
7792
7893 const existing = db
7994 .query<
@@ -90,17 +105,21 @@ export async function createUser(
90105 }
91106
92107 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");
94111 // The hash above yielded the event loop, so another sign-up may have landed
95112 // meanwhile. Uniqueness is settled by the constraint, and "first user" by a
96113 // subquery in the same statement — neither is a check-then-act.
97114 try {
98115 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)
100117 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
102120 WHEN ?7 = 1 AND (SELECT COUNT(*) FROM user) = 0 THEN 1
103 ELSE 0 END)`,
121 ELSE 0 END,
122 ?8)`,
104123 ).run(
105124 userId,
106125 input.username,
@@ -109,6 +128,7 @@ export async function createUser(
109128 input.displayName ?? "",
110129 input.isAdmin === true ? 1 : 0,
111130 input.adminIfFirst === true ? 1 : 0,
131 agent ? 1 : 0,
112132 );
113133 } catch (error) {
114134 if (!isUniqueViolation(error)) throw error;
@@ -218,11 +238,40 @@ export async function verifyPassword(
218238 row?.password_hash ??
219239 "$argon2id$v=19$m=65536,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
220240 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");
223245 return ok(toUser(row));
224246 }
225247
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
226275 export function countUsers(db: Database): number {
227276 const row = db
228277 .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM user")
src/core/visibility.ts +17
@@ -21,6 +21,23 @@ export const REPO_VISIBLE = `(
2121 SELECT team.org_id FROM team
2222 JOIN team_member ON team_member.team_id = team.id
2323 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)
2441 OR EXISTS (SELECT 1 FROM user AS me WHERE me.id = ?1 AND me.is_admin = 1)
2542 )`;
2643
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";
133133 import { runGit } from "../git/env.ts";
134134 import { isSafeRef, isSafeTreePath } from "../git/guard.ts";
135135 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";
136172 import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts";
137173 import { repoDir } from "../git/repos.ts";
138174 import type { Result } from "../kernel/result.ts";
@@ -550,6 +586,52 @@ function repoQuery(ctx: Ctx) {
550586 };
551587 }
552588
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
553635 function positiveInt(raw: string | undefined): number | undefined {
554636 const n = Number(raw);
555637 return Number.isInteger(n) && n > 0 ? n : undefined;
@@ -963,6 +1045,9 @@ export function apiRoutes(router: Router<Handler>): void {
9631045 ...(typeof input.requireReview === "boolean"
9641046 ? { requireReview: input.requireReview }
9651047 : {}),
1048 ...(typeof input.requireChecks === "boolean"
1049 ? { requireChecks: input.requireChecks }
1050 : {}),
9661051 ...(typeof input.description === "string"
9671052 ? { description: input.description }
9681053 : {}),
@@ -2242,6 +2327,407 @@ export function apiRoutes(router: Router<Handler>): void {
22422327 return json({ pinned: true });
22432328 });
22442329
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
22452731 router.add("DELETE", "/api/v1/user/pins/:owner/:name", (ctx) => {
22462732 const denied = requireScope(ctx, "rw");
22472733 if (denied) return denied;
src/http/server.ts +98 −1
@@ -71,7 +71,33 @@ import {
7171 newIssuePage,
7272 newIssueSubmit,
7373 } 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";
75101 import { newPullPage, newPullSubmit, pullsList } from "../web/pages/pulls.ts";
76102 import {
77103 pullCommentSubmit,
@@ -83,6 +109,12 @@ import {
83109 threadResolveSubmit,
84110 } from "../web/pages/pull.ts";
85111 import {
112 appDisable,
113 appSettingsPage,
114 appSettingsUpdate,
115 crewAdd,
116 crewRemove,
117 crewSettingsPage,
86118 dangerPage,
87119 hooksPage,
88120 labelCreate,
@@ -175,6 +207,9 @@ import {
175207 } from "../core/idempotency.ts";
176208 import { sseStream } from "./sse.ts";
177209 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";
178213
179214 // ---- auth ----
180215
@@ -288,6 +323,12 @@ export function buildRouter(): Router<Handler> {
288323 router.add("POST", "/new", newRepoSubmit);
289324 router.add("GET", "/organizations/new", newOrgPage);
290325 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);
291332 router.add("GET", "/settings", accountPage);
292333 router.add("POST", "/settings", accountUpdate);
293334 router.add("GET", "/settings/password", passwordPage);
@@ -295,6 +336,12 @@ export function buildRouter(): Router<Handler> {
295336 router.add("GET", "/settings/tokens", tokensPage);
296337 router.add("POST", "/settings/tokens", tokenCreate);
297338 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);
298345 // Where an OAuth authorization comes back to. Deliberately a plain route
299346 // and not an API one: it is a browser redirect, possibly from a phone, and
300347 // the only thing it carries is a `state` this forge issued minutes ago.
@@ -455,6 +502,9 @@ export function buildRouter(): Router<Handler> {
455502 router.add("POST", "/:owner/:name/issues/:number/labels", (ctx) =>
456503 labelsSubmit(ctx, "issue"),
457504 );
505 router.add("POST", "/:owner/:name/issues/:number/assignee", (ctx) =>
506 assigneeSubmit(ctx, "issue"),
507 );
458508
459509 // Pull requests. Same /new-before-/:number ordering discipline.
460510 router.add("GET", "/:owner/:name/pulls", pullsList);
@@ -487,6 +537,19 @@ export function buildRouter(): Router<Handler> {
487537 router.add("POST", "/:owner/:name/pulls/:number/labels", (ctx) =>
488538 labelsSubmit(ctx, "pull"),
489539 );
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);
490553
491554 // Live event stream for a repo the viewer can read.
492555 router.add("GET", "/:owner/:name/stream", (ctx) => {
@@ -545,6 +608,16 @@ export function buildRouter(): Router<Handler> {
545608 // Settings (owner-gated inside the handlers).
546609 router.add("GET", "/:owner/:name/settings", settingsPage);
547610 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 );
548621 router.add("GET", "/:owner/:name/settings/labels", labelsPage);
549622 router.add("POST", "/:owner/:name/settings/labels", labelCreate);
550623 router.add("POST", "/:owner/:name/settings/labels/:id", labelUpdate);
@@ -764,6 +837,9 @@ async function handleHook(app: App, request: Request): Promise<Response> {
764837 if (!repo) return new Response("unknown repository", { status: 400 });
765838
766839 if (input.hook === "pre-receive") {
840 const actor = input.actorId
841 ? getUserById(app.db, input.actorId)
842 : undefined;
767843 for (const update of input.updates) {
768844 // The pull namespace belongs to the forge, never to pushers.
769845 if (update.ref.startsWith("refs/pull/")) {
@@ -771,6 +847,18 @@ async function handleHook(app: App, request: Request): Promise<Response> {
771847 status: 403,
772848 });
773849 }
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 }
774862 if (
775863 repo.protectDefault &&
776864 update.ref === `refs/heads/${repo.defaultBranch}`
@@ -877,6 +965,15 @@ export async function serve(app: App): Promise<Servers> {
877965 port: app.config.port ?? 3000,
878966 idleTimeout: 120,
879967 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 }
880977 const url = publicUrl(request, app.config.url);
881978 const matched = router.match(request.method, url.pathname);
882979 if (!matched) return noRoute(request.method, url.pathname);
src/main.ts +77 −5
@@ -12,6 +12,9 @@ import { isHookName, runHookClient } from "./git/hooks.ts";
1212 import { serve } from "./http/server.ts";
1313 import { runSync } from "./origins/sync.ts";
1414 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";
1518
1619 // `forge hook <name>` re-enters here from a git hook stub. It relays to the
1720 // running forge over its socket and exits — it must never boot a server.
@@ -46,16 +49,67 @@ const servers = await serve(app);
4649 const ssh =
4750 sshPort > 0 ? await serveForgeSsh(app, { port: sshPort }) : undefined;
4851
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
4995 // The webhook delivery worker: poll the outbox for due deliveries. The event
5096 // row is the source of truth, so a missed tick only delays delivery, never
5197 // 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.
53100 const deliveryTimer = setInterval(() => {
54101 runDelivery(app.db, app.log);
55102 pruneSessions(app.db);
103 pruneTokens(app.db);
56104 }, 5_000);
57105 runDelivery(app.db, app.log); // sweep anything queued while we were down
58106 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 }
59113
60114 // The origin sync worker, on its own slower tick. Mirrors are polled, not
61115 // 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);
65119 runSync(app); // one pass on boot: a forge that was down has fetching to do
66120
67121 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 });
68134 if (ssh)
69135 // The host key fingerprint on boot is what an operator compares against the
70136 // one their client prints on first connect.
@@ -80,8 +146,14 @@ function shutdown(): void {
80146 app.log.info("forge shutting down");
81147 clearInterval(deliveryTimer);
82148 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 });
87159 }
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 @@
1111 */
1212
1313 import { defect } from "../../kernel/error.ts";
14 import { MOUSE_OFF, MOUSE_ON } from "../term/ansi.ts";
1415 import type { InputEvent, KeyEvent, MouseEvent } from "../term/input.ts";
1516 import { InputDecoder } from "../term/input.ts";
1617 import { Renderer, type Cursor } from "../term/render.ts";
@@ -146,6 +147,12 @@ export interface Ctx {
146147 * say it was sent and never that it arrived.
147148 */
148149 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;
149156 /**
150157 * Open a URL in a browser on the machine this terminal runs on. False when
151158 * there is no such machine to reach — every SSH session — which a screen
@@ -371,6 +378,10 @@ export class App {
371378 setTheme: (name) => app.applyTheme(name),
372379 quit: () => app.stop(),
373380 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 ),
374385 openUrl: (url) => openInBrowser(app.opts.terminal, url),
375386 suspend: (run) => app.suspend(run),
376387 hit: (rect, on) => {
@@ -882,7 +893,11 @@ export class App {
882893 );
883894 else if (e.key === "esc" || e.key === "q" || (e.ctrl && e.key === "t"))
884895 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 )
886901 this.close();
887902 else if (e.key === "q") this.close();
888903 this.invalidate();
src/tui/forge.ts +41 −7
@@ -19,6 +19,8 @@ import { fileIcon, icon, type IconName } from "./ui/icons.ts";
1919 import type { SessionOpts, Terminal } from "./term/terminal.ts";
2020 import { Session, type TuiIdentity } from "./session.ts";
2121 import { accountScreen } from "./screens/account.ts";
22 import { appScreen } from "./screens/app.ts";
23 import { appsScreen, newAppFlow } from "./screens/apps.ts";
2224 import {
2325 ISSUE_MARK,
2426 ORIGIN_MARK,
@@ -26,7 +28,9 @@ import {
2628 errorText,
2729 promptScreen,
2830 } from "./screens/common.ts";
31 import { crewScreen } from "./screens/crew.ts";
2932 import { issueScreen } from "./screens/issue.ts";
33 import { runsScreen } from "./screens/runs.ts";
3034 import { pullScreen } from "./screens/pull.ts";
3135 import { blobScreen } from "./screens/repo.ts";
3236 import { dashboardScreen } from "./screens/dashboard.ts";
@@ -70,6 +74,16 @@ const REPO_VERBS: readonly {
7074 },
7175 { id: "new-issue", title: "New issue", open: newIssueScreen },
7276 { 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 },
7387 { id: "settings", title: "Repository settings", open: settingsScreen },
7488 ];
7589
@@ -92,6 +106,12 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
92106 title: "Explore repositories",
93107 run: () => ctx.push(exploreScreen(session, ctx)),
94108 },
109 {
110 id: "apps",
111 title: "Apps",
112 hint: "what is running",
113 run: () => ctx.push(appsScreen(session, ctx)),
114 },
95115 {
96116 id: "profile",
97117 title: "Open a profile",
@@ -144,6 +164,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
144164 );
145165 if (session.signedIn)
146166 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 },
147175 {
148176 id: "account",
149177 title: "Account settings",
@@ -156,6 +184,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
156184 run: () => ctx.push(originsScreen(session, ctx)),
157185 },
158186 );
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 });
159194 commands.push({
160195 id: "quit",
161196 title: "Quit",
@@ -320,6 +355,11 @@ function countsHint(
320355 .join(" ");
321356 }
322357
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
323363 /** A prompt with no parent to draw behind it — the palette closed already. */
324364 function askFor(
325365 ctx: Ctx,
@@ -327,13 +367,7 @@ function askFor(
327367 label: string,
328368 onSubmit: (value: string) => string | undefined,
329369 ): 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 });
337371 }
338372
339373 export interface ForgeAppOptions extends SessionOpts {
src/tui/screens/account.ts +12 −1
@@ -38,6 +38,7 @@ import {
3838 ago,
3939 runAsync,
4040 } from "./common.ts";
41 import { crewScreen } from "./crew.ts";
4142 import { originsScreen } from "./origins.ts";
4243
4344 const TABS = ["Profile", "Tokens", "SSH keys"] as const;
@@ -58,6 +59,9 @@ export function accountScreen(f: Session, ctx: Ctx): Screen {
5859 const screen: Screen = {
5960 title: "Account",
6061 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")] : [];
6165 if (tab === 0)
6266 return [
6367 binding(["tab"], "next tab"),
@@ -65,6 +69,7 @@ export function accountScreen(f: Session, ctx: Ctx): Screen {
6569 binding(["e"], "email"),
6670 binding(["p"], "password"),
6771 binding(["o"], "origins"),
72 ...crew,
6873 ];
6974 if (tab === 1)
7075 return [
@@ -72,12 +77,14 @@ export function accountScreen(f: Session, ctx: Ctx): Screen {
7277 binding(["n"], "new token"),
7378 binding(["D"], "revoke"),
7479 binding(["o"], "origins"),
80 ...crew,
7581 ];
7682 return [
7783 binding(["tab"], "next tab"),
7884 binding(["n"], "add key"),
7985 binding(["D"], "delete key"),
8086 binding(["o"], "origins"),
87 ...crew,
8188 ];
8289 },
8390 load() {
@@ -97,7 +104,7 @@ export function accountScreen(f: Session, ctx: Ctx): Screen {
97104 ["username", f.user.username],
98105 ["display name", f.user.displayName || "(none)"],
99106 ["email", f.user.email],
100 ["role", f.user.isAdmin ? "site admin" : "user"],
107 ["role", f.user.isAdmin ? "site admin — c manages the crew" : "user"],
101108 ["joined", ago(f.user.createdAt)],
102109 ]);
103110 return;
@@ -147,6 +154,10 @@ export function accountScreen(f: Session, ctx: Ctx): Screen {
147154 ctx.push(originsScreen(f, ctx));
148155 return true;
149156 }
157 if (e.key === "c" && f.user?.isAdmin === true) {
158 ctx.push(crewScreen(f, ctx));
159 return true;
160 }
150161 if (tab === 0) return profileKey(e);
151162 if (tab === 1) return tokenKey(e);
152163 return keyKey(e);
src/tui/screens/activity.ts +26 −1
@@ -8,6 +8,7 @@
88 */
99
1010 import type { ForgeEvent } from "../../core/events.ts";
11 import { eventVerb, isTransientEvent } from "../../design/activity.ts";
1112
1213 export interface EventSubject {
1314 /** The actor's username, or a stand-in when the actor is gone. */
@@ -37,9 +38,20 @@ export interface FeedLine {
3738 * not something anyone reads a dashboard to find out.
3839 */
3940 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 );
4146 }
4247
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
4355 const THING: Record<string, string> = { issue: "issue", pull: "pull" };
4456
4557 function short(repo: string | undefined): string {
@@ -89,6 +101,19 @@ export function describeEvent(
89101 .filter((part) => part !== "")
90102 .join(" · ");
91103
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
92117 const [concept, verb] = split(event.kind);
93118 const noun = THING[concept];
94119 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 {
201201 export interface ListRow {
202202 readonly title: string;
203203 readonly state?: "open" | "closed" | "merged" | "draft";
204 /** A status row's lead dot — see `RowOpts.tone`. */
205 readonly tone?: Tone;
206 readonly hollow?: boolean;
204207 readonly secondary?: string;
205208 readonly meta?: string;
206209 }
@@ -515,6 +518,20 @@ export function promptScreen(
515518 };
516519 }
517520
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
518535 export interface PickItem {
519536 readonly id: string;
520537 readonly label: string;
@@ -697,8 +714,70 @@ export function openLink(ctx: Ctx, url: string): void {
697714 ctx.toast("opening in your browser", "success");
698715 return;
699716 }
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.
700720 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 };
702781 }
703782
704783 /** A branch-safe slug of a path: `src/main.ts` → `src-main-ts`. */
@@ -779,6 +858,23 @@ const MESSAGES: Record<string, string> = {
779858 rate_limited: "too many connect attempts; wait a few minutes",
780859 no_app: "register an OAuth application on that instance first",
781860 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",
782878 };
783879
784880 /**
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 @@
1515 */
1616
1717 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";
1923 import { listPins, pinnedRepoIds, type Pin } from "../../core/pins.ts";
2024 import { listOrgsForUser } from "../../core/orgs.ts";
2125 import { listEventsVisibleTo, type ForgeEvent } from "../../core/events.ts";
26 import { eventActor } from "../../design/activity.ts";
2227 import {
2328 canRead,
2429 getRepo,
@@ -39,8 +44,10 @@ import { card, emptyState } from "../ui/widgets.ts";
3944 import type { Session } from "../session.ts";
4045 import {
4146 Filter,
47 ISSUE_MARK,
4248 ListState,
4349 PIN_MARK,
50 PULL_MARK,
4451 confirmScreen,
4552 countsColumn,
4653 metaOf,
@@ -54,6 +61,8 @@ import {
5461 } from "./common.ts";
5562 import { describeEvent, type FeedLine } from "./activity.ts";
5663 import { accountScreen } from "./account.ts";
64 import { appScreen } from "./app.ts";
65 import { appRow, appsScreen, listVisibleApps, type AppItem } from "./apps.ts";
5766 import { exploreScreen } from "./explore.ts";
5867 import { issueScreen } from "./issue.ts";
5968 import { newRepoScreen } from "./newrepo.ts";
@@ -63,27 +72,46 @@ import { repoScreen } from "./repo.ts";
6372 import { local } from "./where.ts";
6473 import { signInScreen } from "./signin.ts";
6574
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";
6881
6982 const TITLES: Readonly<Record<PaneId, string>> = {
83 apps: "Apps",
7084 repos: "Repositories",
7185 activity: "Recent activity",
86 onyou: "On you",
7287 machine: "On your machine",
7388 orgs: "Organizations",
7489 };
7590
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
76100 export function dashboardScreen(f: Session, ctx: Ctx): Screen {
77101 let repos: Repo[] = [];
78102 let feed: FeedItem[] = [];
79103 let orgs: string[] = [];
80104 let pins: Pin[] = [];
81105 let pinned = new Set<string>();
106 let apps: AppItem[] = [];
107 let onYou: OnYouItem[] = [];
82108 const filter = new Filter();
83109 /** One cursor per pane: moving in one must never move another. */
84110 const cursors: Record<PaneId, ListState> = {
111 apps: new ListState(),
85112 repos: new ListState(),
86113 activity: new ListState(),
114 onyou: new ListState(),
87115 machine: new ListState(),
88116 orgs: new ListState(),
89117 };
@@ -95,6 +123,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
95123 pins = f.user ? listPins(f.db, f.user.id) : [];
96124 pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>();
97125 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();
98131 // "Yours" is ownership plus the orgs you are on a team of; everything
99132 // else lives in Explore, one key away.
100133 orgs = f.user
@@ -115,6 +148,27 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
115148 ctx.invalidate();
116149 };
117150
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
118172 const shown = (): Repo[] =>
119173 repos.filter((repo) =>
120174 filter.matches(`${repo.ownerName}/${repo.name} ${repo.description}`),
@@ -123,24 +177,31 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
123177 /**
124178 * The panes that exist right now. Below two columns only the repo list is
125179 * 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.
127183 */
128184 const panes = (): PaneId[] => {
129185 if (!ctx.twoPane) return ["repos"];
186 const withApps: PaneId[] = apps.length > 0 ? ["apps"] : [];
130187 return f.signedIn
131 ? ["repos", "activity", "machine", "orgs"]
132 : ["repos", "activity"];
188 ? [...withApps, "repos", "activity", "onyou", "machine", "orgs"]
189 : [...withApps, "repos", "activity"];
133190 };
134191
135192 const at = (pane: PaneId): ListState => cursors[pane];
136193 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;
144205
145206 const cycle = (step: number): void => {
146207 const list = panes();
@@ -211,10 +272,26 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
211272 );
212273 };
213274
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
214289 /** `enter` on whatever the focused pane's cursor is on. */
215290 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]);
217293 else if (focus === "activity") openEvent(feed[at("activity").index]);
294 else if (focus === "onyou") openAssigned(onYou[at("onyou").index]);
218295 else if (focus === "machine") openPin(pins[at("machine").index]);
219296 else {
220297 const org = orgs[at("orgs").index];
@@ -230,6 +307,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
230307 binding(["j", "k"], "move"),
231308 binding(["enter"], "open"),
232309 binding(["/"], "filter"),
310 binding(["a"], "apps"),
233311 binding(["e"], "explore"),
234312 binding(["p"], "pin", { disabled: !f.signedIn }),
235313 binding(["d"], "unpin", {
@@ -243,8 +321,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
243321 },
244322 load(signal) {
245323 // 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.)
248328 live(f, signal, {}, reload);
249329 reload();
250330 return Promise.resolve();
@@ -256,17 +336,28 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
256336 const [left, right] = ctx.twoPane
257337 ? split(area, "h", [fill(3), fill(2)])
258338 : [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);
260349 ctx.setCursor(filter.render(s, box!, c, "filter repositories"));
261350 drawRepos(s, list!, c, set);
262351 if (right === undefined) return;
263352
264 const [activity, machine, orgsArea] = split(right, "v", [
353 const [activity, onYouArea, machine, orgsArea] = split(right, "v", [
265354 fill(1),
355 len(f.signedIn ? 6 : 0),
266356 len(6),
267357 len(5),
268358 ]);
269359 drawActivity(s, activity!, c, set);
360 if (f.signedIn) drawOnYou(s, onYouArea!, c, set);
270361 drawMachine(s, machine!, c, set);
271362 drawOrgs(s, orgsArea!, c, set);
272363 },
@@ -289,6 +380,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
289380 ctx.push(exploreScreen(f, ctx));
290381 return true;
291382 }
383 if (e.key === "a") {
384 ctx.push(appsScreen(f, ctx));
385 return true;
386 }
292387 if (e.key === "d" && focus === "machine") {
293388 unpin(pins[at("machine").index]);
294389 return true;
@@ -333,6 +428,73 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
333428
334429 // ---- the panes ----
335430
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
336498 function drawRepos(s: Surface, r: Rect, c: Tokens, set: IconSet): void {
337499 const rows = shown();
338500 const on = focus === "repos";
@@ -562,10 +724,10 @@ function feedItems(f: Session, events: readonly ForgeEvent[]): FeedItem[] {
562724 ),
563725 );
564726 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 );
569731 const repo = event.repoId === null ? undefined : repos.get(event.repoId);
570732 const number = event.payload["number"];
571733 const found =
src/tui/screens/issue.ts +77 −6
@@ -19,7 +19,8 @@ import {
1919 setIssueLabelsThrough,
2020 setIssueStateThrough,
2121 } 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";
2324 import type { Repo } from "../../core/repos.ts";
2425 import type { IssueDetail, RepoSource } from "../../view/source.ts";
2526 import type { Ctx, Screen } from "../app/app.ts";
@@ -28,7 +29,7 @@ import type { Span } from "../term/screen.ts";
2829 import { BOLD, style } from "../term/style.ts";
2930 import { truncate } from "../term/width.ts";
3031 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";
3233 import type { Session } from "../session.ts";
3334 import {
3435 ORIGIN_MARK,
@@ -42,10 +43,16 @@ import {
4243 plural,
4344 runAsync,
4445 } from "./common.ts";
46 import { assignPicker, assignedText } from "./assign.ts";
4547 import { liveBindings, liveKey } from "./mirror.ts";
48 import { runScreen } from "./run.ts";
49 import { RunList } from "./runs.ts";
4650 import { Composer, threadLines, type ThreadEntry } from "./thread.ts";
4751 import { local, localRepo, whereRows, whereStrip } from "./where.ts";
4852
53 /** The crew card shows this many runs before it scrolls. */
54 const CREW_ROWS = 4;
55
4956 export function issueScreen(
5057 f: Session,
5158 ctx: Ctx,
@@ -60,6 +67,12 @@ export function issueScreen(
6067 const scroll = new Scroll();
6168 const composer = new Composer();
6269 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";
6376 const mayWrite = (): boolean => src.capabilities.write && repo !== undefined;
6477 scroll.top = keep.top ?? 0;
6578
@@ -71,12 +84,35 @@ export function issueScreen(
7184 return;
7285 }
7386 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";
7498 width = 0;
7599 ctx.invalidate();
76100 const listed = await src.labels();
77101 labels = listed.ok ? listed.value : [];
78102 };
79103
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
80116 /** The core row a write needs. Only ever reached on a local source. */
81117 const row = ():
82118 | { repo: Repo; issue: ReturnType<typeof getIssue> }
@@ -155,10 +191,16 @@ export function issueScreen(
155191 title: `#${number}`,
156192 get keys() {
157193 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"),
159200 ...(mayWrite()
160201 ? [
161202 binding(["c"], "comment"),
203 binding(["a"], "assign"),
162204 binding(["x"], "close", {
163205 disabled: detail?.issue.state === "closed",
164206 }),
@@ -184,11 +226,15 @@ export function issueScreen(
184226 const foot = composer.height();
185227 // The where-strip, then three rows of head: the title, the summary
186228 // 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", [
188233 len(whereRows(src)),
189234 len(3),
235 len(crewH),
190236 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)))),
192238 ]);
193239 whereStrip(s, strip!, c, f, src);
194240 const right = head!.x + head!.w;
@@ -214,7 +260,7 @@ export function issueScreen(
214260 x,
215261 head!.y + 1,
216262 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)}`,
218264 Math.max(0, right - x),
219265 ),
220266 style(c.mutedFg, c.bg),
@@ -229,6 +275,16 @@ export function issueScreen(
229275 { text: label.name, color: label.color },
230276 right,
231277 );
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 }
232288
233289 if (width !== body!.w) {
234290 width = body!.w;
@@ -264,6 +320,21 @@ export function issueScreen(
264320 else if (out === "cancel") composer.close();
265321 return true;
266322 }
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 }
267338 if (e.key === "c" && mayWrite()) {
268339 composer.start("Comment");
269340 return true;
src/tui/screens/origins.ts +1 −6
@@ -43,6 +43,7 @@ import {
4343 confirmScreen,
4444 drawList,
4545 errorText,
46 later,
4647 openLink,
4748 promptScreen,
4849 runAsync,
@@ -62,12 +63,6 @@ import { local } from "./where.ts";
6263 * push by a microtask, which is exactly the ordering wanted: prompt closes,
6364 * next screen opens.
6465 */
65 function later(ctx: Ctx, next: () => Screen): Promise<void> {
66 return Promise.resolve().then(() => {
67 ctx.push(next());
68 });
69 }
70
7166 /** `codeberg.org` from `https://codeberg.org` — what a person calls a forge. */
7267 export function hostLabel(url: string): string {
7368 return URL.parse(url)?.host ?? url;
src/tui/screens/pull.ts +368 −41
@@ -37,9 +37,15 @@ import {
3737 resolveReviewThread,
3838 submitReview,
3939 } 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";
4146 import { getPull, type Pull } from "../../core/pulls.ts";
4247 import { getRepoById, type Repo } from "../../core/repos.ts";
48 import { checkSummary, type CheckSummary } from "../../core/runs.ts";
4349 import { err, ok } from "../../kernel/result.ts";
4450 import { getUsersByIds } from "../../core/users.ts";
4551 import {
@@ -59,11 +65,17 @@ import type {
5965 } from "../../view/source.ts";
6066 import type { Ctx, Screen } from "../app/app.ts";
6167 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";
6475 import { truncate } from "../term/width.ts";
6576 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";
6779 import type { Session } from "../session.ts";
6880 import {
6981 ORIGIN_MARK,
@@ -74,13 +86,19 @@ import {
7486 hitScroll,
7587 errorText,
7688 live,
89 metaOf,
90 openLink,
7791 plural,
7892 relTime,
7993 runAsync,
8094 } from "./common.ts";
95 import { envTone } from "./apps.ts";
96 import { assignPicker, assignedText } from "./assign.ts";
8197 import { DiffView, type DiffAnchor, type DiffNote } from "./commits.ts";
8298 import { liveBindings, liveKey } from "./mirror.ts";
8399 import { reviewScreen } from "./review.ts";
100 import { runScreen } from "./run.ts";
101 import { RunList, runTone } from "./runs.ts";
84102 import { Composer, threadLines, type ThreadEntry } from "./thread.ts";
85103 import { local, localRepo, whereRows, whereStrip } from "./where.ts";
86104
@@ -130,10 +148,7 @@ export function standingOf(reviews: readonly ReviewFact[]): Standing {
130148 }
131149
132150 /** `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 {
137152 const parts = [
138153 standing.approvals === 0
139154 ? required
@@ -148,7 +163,11 @@ export function standingText(
148163 return parts.join(" · ");
149164 }
150165
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 };
152171
153172 /** What each verdict is called on screen — one wording, every surface. */
154173 const VERDICT_WORDS: Record<ReviewFact["state"], string> = {
@@ -186,6 +205,17 @@ export function pullScreen(
186205 * the merge box says without anybody re-entering the screen.
187206 */
188207 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);
189219 const mayWrite = (): boolean => src.capabilities.write && repo !== undefined;
190220 scroll.top = keep.top ?? 0;
191221
@@ -202,13 +232,38 @@ export function pullScreen(
202232 // Review comments are anchored rows in *our* database; a live browse has
203233 // none of them, and neither forge exposes an equivalent we could read.
204234 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;
207239 reviews =
208240 row === undefined
209241 ? []
210242 : withOutdated(listReviewComments(f.db, row.id), diff);
211243 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;
212267 view.setFiles(diff.files);
213268 view.setNotes(threadNotes());
214269 width = 0;
@@ -334,7 +389,10 @@ export function pullScreen(
334389 kind: "comment" as const,
335390 actor: review.author,
336391 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,
338396 badge: {
339397 text: VERDICT_WORDS[review.state],
340398 // A verdict a push has dismissed is muted, not green and not red:
@@ -370,6 +428,39 @@ export function pullScreen(
370428 return now.blocking.length > 0 || (requireReview && now.approvals === 0);
371429 };
372430
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
373464 const submit = (): void => {
374465 if (repo === undefined) return;
375466 const target = repo;
@@ -433,7 +524,10 @@ export function pullScreen(
433524 });
434525 if (!sent.ok) return err(errorText(String(sent.error)));
435526 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 );
437531 return ok(sent.value);
438532 },
439533 }),
@@ -481,18 +575,26 @@ export function pullScreen(
481575
482576 const mirrored = (): boolean => repo === undefined || repo.origin !== null;
483577 const state = (): MergeState =>
484 mergeState(detail?.issue, facts, mirrored(), gated());
578 mergeState(detail?.issue, facts, mirrored(), gated(), checksGated());
485579
486580 const screen: Screen = {
487581 title: `#${number}`,
488582 get keys() {
489583 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 : []),
492593 ...(mayWrite()
493594 ? [
494595 binding(["c"], tab === 1 ? "review line" : "comment"),
495596 binding(["R"], "review"),
597 binding(["a"], "assign"),
496598 binding(["m"], "merge", { disabled: state() !== "ready" }),
497599 ...(tab === 1 && view.threadHere() !== null
498600 ? [
@@ -519,21 +621,30 @@ export function pullScreen(
519621 if (!detail) return;
520622 const issue = detail.issue;
521623 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 );
535645 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.
537648 const edge = head!.x + head!.w;
538649 const after = s.text(
539650 head!.x,
@@ -549,8 +660,28 @@ export function pullScreen(
549660 style(c.fg, c.bg, BOLD),
550661 edge,
551662 );
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);
552680 drawMergeBox(s, boxRow!, c, issue, facts, view.summary(), mirrored(), {
553681 gated: gated(),
682 checksGated: checksGated(),
683 check,
684 checksLine,
554685 standing: standing(),
555686 required: requireReview,
556687 });
@@ -561,10 +692,17 @@ export function pullScreen(
561692 count: conversation().length + reviews.length + verdicts().length,
562693 },
563694 { label: "Files", count: diff.files.length },
695 ...(crew.runs.length > 0
696 ? [{ label: "Crew", count: crew.runs.length }]
697 : []),
564698 ],
565699 active: tab,
566700 });
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) {
568706 // The thread count is a section header, not a badge on the tab: how
569707 // many conversations are still open is the question a reviewer opens
570708 // this tab to answer, and it deserves a line.
@@ -612,13 +750,31 @@ export function pullScreen(
612750 return true;
613751 }
614752 if (e.key === "tab") {
615 tab = tab === 0 ? 1 : 0;
753 tab = ((tab + 1) % tabCount()) as Tab;
616754 return true;
617755 }
618756 if (e.key === "R" && mayWrite()) {
619757 review();
620758 return true;
621759 }
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 }
622778 // A thread the cursor is standing in owns `x` and `enter` before the
623779 // screen does: settling a conversation is the act on offer there, and
624780 // closing the whole pull from inside one would be a surprise.
@@ -664,6 +820,10 @@ export function pullScreen(
664820 );
665821 return true;
666822 }
823 if (e.key === "m" && mayWrite() && state() === "checks_required") {
824 ctx.toast(checksRefusal(check), "attn");
825 return true;
826 }
667827 if (
668828 e.key === "m" &&
669829 mayWrite() &&
@@ -694,6 +854,18 @@ export function pullScreen(
694854 );
695855 return true;
696856 }
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 }
697869 if (
698870 (e.key === "x" || e.key === "o") &&
699871 mayWrite() &&
@@ -733,6 +905,7 @@ export function pullScreen(
733905 })
734906 )
735907 return true;
908 if (tab === 2) return true;
736909 return tab === 1 ? view.key(e, ctx) : scroll.key(e, lines.length);
737910 },
738911 };
@@ -747,9 +920,120 @@ export type MergeState =
747920 | "empty"
748921 /** The verdicts do not let this land yet — `mergePull` refuses it too. */
749922 | "review_required"
923 /** The head's deploy is not green yet — `mergePull` refuses it too. */
924 | "checks_required"
750925 | "ready"
751926 | "unknown";
752927
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
7531037 /**
7541038 * What the merge box says, from what the source could tell us.
7551039 *
@@ -761,6 +1045,7 @@ export function mergeState(
7611045 pull: PullFacts | null | undefined,
7621046 mirrored = false,
7631047 gated = false,
1048 checksGated = false,
7641049 ): MergeState {
7651050 if (issue === undefined || pull === null || pull === undefined)
7661051 return "unknown";
@@ -774,7 +1059,9 @@ export function mergeState(
7741059 if (!pull.mergeable) return "conflicts";
7751060 // Git could take it; the reviewers have not. Said after the conflict check
7761061 // 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";
7781065 }
7791066
7801067 function drawMergeBox(
@@ -785,9 +1072,17 @@ function drawMergeBox(
7851072 pull: PullFacts | null,
7861073 summary: string,
7871074 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 },
7891084 ): void {
790 const state = mergeState(issue, pull, mirrored, o.gated);
1085 const state = mergeState(issue, pull, mirrored, o.gated, o.checksGated);
7911086 const tone =
7921087 state === "merged"
7931088 ? "merged"
@@ -795,7 +1090,9 @@ function drawMergeBox(
7951090 ? "success"
7961091 : state === "conflicts" || state === "closed"
7971092 ? "danger"
798 : state === "mirrored" || state === "review_required"
1093 : state === "mirrored" ||
1094 state === "review_required" ||
1095 state === "checks_required"
7991096 ? "attn"
8001097 : "default";
8011098 // Where it is going belongs on the frame, not on a body row: the box is
@@ -824,9 +1121,20 @@ function drawMergeBox(
8241121 ? o.standing.blocking.length > 0
8251122 ? `Changes requested by ${o.standing.blocking.join(", ")}`
8261123 : "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}`;
8281131 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,
8301138 variant: tone as "success",
8311139 });
8321140 s.text(
@@ -840,13 +1148,32 @@ function drawMergeBox(
8401148 // something to say — a box with an empty second line reads as a box that
8411149 // lost something.
8421150 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) {
8441153 s.text(
8451154 inner.x,
846 inner.y + 1,
1155 y,
8471156 truncate(verdicts, inner.w),
8481157 style(o.standing.blocking.length > 0 ? c.danger : c.mutedFg, c.card),
8491158 inner.x + inner.w,
8501159 );
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 }
8511179 }
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 @@
77 * confirmation is deliberately more work than pressing Enter.
88 */
99
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";
1021 import {
1122 createLabel,
1223 createWebhook,
@@ -15,8 +26,17 @@ import {
1526 deleteWebhook,
1627 updateRepoSettings,
1728 } 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";
1838 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";
2040 import {
2141 listWebhooks,
2242 recentDeliveries,
@@ -31,6 +51,7 @@ import { truncate } from "../term/width.ts";
3151 import { fill, len, split } from "../ui/layout.ts";
3252 import { labelChip, tabs } from "../ui/widgets.ts";
3353 import type { Session } from "../session.ts";
54 import { agentPick } from "./apps.ts";
3455 import {
3556 ListState,
3657 confirmScreen,
@@ -38,28 +59,48 @@ import {
3859 drawList,
3960 hitList,
4061 errorText,
62 later,
63 metaOf,
64 pickerScreen,
4165 promptScreen,
4266 runAsync,
4367 } from "./common.ts";
68 import { budgetText } from "./crew.ts";
4469 import { refPickerScreen } from "./refs.ts";
4570 import { local } from "./where.ts";
4671
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;
4880
4981 export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5082 let current = repo;
5183 let labels: Label[] = [];
5284 let hooks: Webhook[] = [];
5385 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[] = [];
5489 let tab = 0;
5590 const state = new ListState();
5691
5792 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;
5896 labels = listLabels(f.db, current.id);
5997 hooks = listWebhooks(f.db, current.id);
6098 deliveries = new Map(
6199 hooks.map((hook) => [hook.id, recentDeliveries(f.db, hook.id, 3)]),
62100 );
101 app = getAppByRepo(f.db, current.id);
102 const agents = listCrew(f.app, f.user, current);
103 crew = agents.ok ? agents.value : [];
63104 ctx.invalidate();
64105 };
65106
@@ -87,6 +128,7 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
87128 binding(["b"], "default branch"),
88129 binding(["p"], "protection"),
89130 binding(["R"], "require a review"),
131 binding(["C"], "require checks"),
90132 ];
91133 if (tab === 1)
92134 return [
@@ -100,6 +142,22 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
100142 binding(["n"], "new webhook"),
101143 binding(["D"], "delete webhook"),
102144 ];
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 ];
103161 return [binding(["tab"], "next tab"), binding(["X"], "delete repo")];
104162 },
105163 load() {
@@ -128,6 +186,12 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
128186 ? "on — a pull needs an approval of its current head"
129187 : "off",
130188 ],
189 [
190 "require checks",
191 current.requireChecks
192 ? "on — a pull needs a passing deploy of its current head"
193 : "off",
194 ],
131195 ]);
132196 return;
133197 }
@@ -167,6 +231,14 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
167231 hitList(ctx, body!, state, hooks.length, {});
168232 return;
169233 }
234 if (tab === 3) {
235 drawAppTab(s, body!, c);
236 return;
237 }
238 if (tab === 4) {
239 drawCrewTab(s, body!, c);
240 return;
241 }
170242 s.text(
171243 body!.x,
172244 body!.y,
@@ -194,6 +266,8 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
194266 if (tab === 0) return generalKey(e);
195267 if (tab === 1) return labelsKey(e);
196268 if (tab === 2) return hooksKey(e);
269 if (tab === 3) return appKey(e);
270 if (tab === 4) return crewKey(e);
197271 if (e.key === "X") {
198272 ctx.push(
199273 promptScreen(ctx, screen, {
@@ -251,6 +325,12 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
251325 save({ requireReview: !current.requireReview });
252326 return true;
253327 }
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 }
254334 if (key === "b") {
255335 ctx.push(
256336 refPickerScreen(
@@ -367,6 +447,328 @@ export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
367447 return false;
368448 }
369449
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
370772 return screen;
371773 }
372774
src/tui/ssh.ts +17 −1
@@ -21,11 +21,18 @@ import { repoDir } from "../git/repos.ts";
2121 import { parseGitCommand, runGitService } from "../git/smart-ssh.ts";
2222 import { serveSsh, type SshServer, type SshSession } from "../ssh/server.ts";
2323 import { createForgeApp } from "./forge.ts";
24 import { appForLogin, attachAppTty } from "../apps/tty.ts";
2425 import type { Terminal } from "./term/terminal.ts";
2526 import type { TuiIdentity } from "./session.ts";
2627
2728 const CRLF = "\r\n";
2829
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
2936 /** A `Terminal` over an SSH channel — the whole SSH-specific surface. */
3037 export function sshTerminal(
3138 session: SshSession,
@@ -33,6 +40,10 @@ export function sshTerminal(
3340 ): Terminal {
3441 let closed = false;
3542 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),
3647 // Getters, not a snapshot: `pty-req` may arrive after this object is
3748 // built, and `window-change` moves both of these mid-session.
3849 get term() {
@@ -149,7 +160,12 @@ export function forgeSshSession(forge: Forge): (session: SshSession) => void {
149160 try {
150161 const identity = identityFor(forge, session);
151162 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);
153169 return;
154170 }
155171 void serveGitCommand(forge, session, identity.user).catch(
src/tui/ui/icons.ts +5
@@ -48,6 +48,7 @@ export type IconName =
4848 | "milestone"
4949 // people and places
5050 | "person"
51 | "bot"
5152 | "org"
5253 | "star"
5354 | "pin"
@@ -99,6 +100,10 @@ const ICONS: Readonly<Record<IconName, Pair>> = {
99100 diff: { plain: G.comment, nerd: "\u{f440}" }, // nf-oct-diff
100101 milestone: { plain: G.diamond, nerd: "\u{f45d}" }, // nf-oct-milestone
101102 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
102107 org: { plain: G.diamond, nerd: "\u{f42b}" }, // nf-oct-organization
103108 star: { plain: G.diamond, nerd: "\u{f41e}" }, // nf-oct-star
104109 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 {
409409 export interface RowOpts {
410410 readonly title: string;
411411 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;
412420 readonly secondary?: string;
413421 readonly meta?: string;
414422 readonly selected?: boolean;
@@ -441,8 +449,21 @@ export function row(s: Screen, r: Rect, c: Tokens, o: RowOpts): void {
441449 style(c.accent, bg),
442450 right,
443451 );
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;
446467
447468 const meta = o.meta ?? "";
448469 const metaW = meta === "" ? 0 : displayWidth(meta) + 1;
src/web/chrome.ts +13
@@ -8,6 +8,7 @@
88 * `ui.ts` may not. That is the line between the two files.
99 */
1010
11 import { getAppByRepo } from "../core/apps.ts";
1112 import { canAdmin, canRead, getRepo, getRepoById } from "../core/repos.ts";
1213 import type { Repo } from "../core/repos.ts";
1314 import type { Ctx } from "../http/context.ts";
@@ -83,6 +84,7 @@ export type RepoTab =
8384 | "pulls"
8485 | "commits"
8586 | "branches"
87 | "app"
8688 | "settings";
8789
8890 export interface RepoHeaderOptions {
@@ -180,6 +182,16 @@ export function scopeHeader(
180182 shortcut: "g b",
181183 },
182184 ];
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 });
183195 if (admin)
184196 items.push({
185197 label: "Settings",
@@ -255,6 +267,7 @@ export function repoPalette(repo: Repo): PaletteItem[] {
255267 { t: `${name} — Pull requests`, h: `${base}/pulls`, c: "This repo" },
256268 { t: `${name} — Commits`, h: `${base}/commits`, c: "This repo" },
257269 { t: `${name} — New issue`, h: `${base}/issues/new`, c: "This repo" },
270 { t: `${name} — Runs`, h: `${base}/runs`, c: "This repo" },
258271 { t: `${name} — Settings`, h: `${base}/settings`, c: "This repo" },
259272 ];
260273 }
src/web/client.ts +61
@@ -102,6 +102,67 @@ export const mirrorJs = /* js */ `
102102 })();
103103 `;
104104
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
105166 /**
106167 * The code browser: the explorer column, the preview it paints beside itself,
107168 * and the fuzzy file finder.
src/web/icons.ts +10
@@ -79,6 +79,16 @@ const PATHS = {
7979 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"/>`,
8080 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"/>`,
8181 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"/>`,
8292 } as const;
8393
8494 export type IconName = keyof typeof PATHS;
src/web/layout.ts +9 −1
@@ -107,6 +107,7 @@ function themeMenu(active: ThemeName | undefined): Html {
107107
108108 function newMenu(): Html {
109109 const items: MenuItem[] = [
110 { label: "New app", href: "/apps/new", icon: "rocket" },
110111 { label: "New repository", href: "/new", icon: "repo" },
111112 { label: "New organization", href: "/organizations/new", icon: "users" },
112113 ];
@@ -123,11 +124,15 @@ function userMenu(user: User): Html {
123124 const items: MenuItem[] = [
124125 { label: "Your profile", href: `/${user.username}`, icon: "user" },
125126 { label: "Your repositories", href: "/", icon: "repo" },
127 { label: "Apps", href: "/apps", icon: "rocket" },
126128 { separator: true, label: "" },
127129 { label: "Settings", href: "/settings", icon: "settings" },
128130 { label: "Access tokens", href: "/settings/tokens", icon: "key" },
129131 { label: "SSH keys", href: "/settings/keys", icon: "lock" },
130132 { label: "Origins", href: "/settings/origins", icon: "globe" },
133 ...(user.isAdmin
134 ? [{ label: "Crew", href: "/settings/crew", icon: "bot" as const }]
135 : []),
131136 { separator: true, label: "" },
132137 { label: "Sign out", post: "/logout", icon: "signOut" },
133138 ];
@@ -182,6 +187,8 @@ function helpButton(): Html {
182187
183188 const STATIC_PALETTE: PaletteItem[] = [
184189 { t: "Home", h: "/", c: "Go" },
190 { t: "Apps", h: "/apps", c: "Go" },
191 { t: "New app", h: "/apps/new", c: "Create" },
185192 { t: "New repository", h: "/new", c: "Create" },
186193 { t: "New organization", h: "/organizations/new", c: "Create" },
187194 { t: "Search", h: "/search", c: "Go" },
@@ -423,7 +430,8 @@ export function fragment(body: Html, status = 200): Response {
423430 status,
424431 headers: {
425432 "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",
427435 "cache-control": "no-store",
428436 },
429437 });
src/web/live.ts +12 −3
@@ -42,9 +42,7 @@ export function liveMount(config: LiveConfig): Html {
4242 id="live"
4343 hidden
4444 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(" ")}"
4846 data-thread="${config.thread ?? ""}"
4947 data-number="${config.number ?? ""}"
5048 data-last="${config.lastCommentId ?? ""}"
@@ -85,6 +83,14 @@ export const liveClientJs = /* js */ `
8583
8684 function relevant(data) {
8785 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 }
8894 if (String(data.payload.number) !== String(number)) return false;
8995 return data.kind.indexOf(thread) === 0;
9096 }
@@ -128,6 +134,9 @@ export const liveClientJs = /* js */ `
128134 'pull.opened', 'pull.closed', 'pull.reopened', 'pull.merged', 'pull.commented', 'pull.reviewed',
129135 'branch.created', 'branch.deleted',
130136 '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',
131140 ];
132141 var extra = (cfg.getAttribute('data-repos') || '').split(' ').filter(Boolean);
133142 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";
4444
4545 // The section list lives beside the newest page that joins it, so adding one
4646 // 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 });
4949
5050 /** One account page: the shared shell, the section nav, and this section. */
51 function accountShell(
51 export function accountShell(
5252 ctx: Ctx,
5353 current: Section,
5454 title: string,
@@ -60,7 +60,7 @@ function accountShell(
6060 ctx,
6161 title,
6262 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")}`,
6464 },
6565 status,
6666 );
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 @@
44 * post to its own endpoint. Mobile-first, no JavaScript required.
55 */
66
7 import {
8 addCommentThrough,
9 openIssueThrough,
10 } from "../../actions/issues.ts";
7 import { addCommentThrough, openIssueThrough } from "../../actions/issues.ts";
118 import {
129 issueCounts,
1310 labelsFor,
@@ -53,6 +50,7 @@ import {
5350 type IconName,
5451 } from "../ui.ts";
5552 import {
53 assigneeCard,
5654 composer,
5755 conversation,
5856 labelsCard,
@@ -62,6 +60,7 @@ import {
6260 threadContext,
6361 threadFragment,
6462 } from "./thread.ts";
63 import { crewActivityCard } from "./runs.ts";
6564
6665 const PER_PAGE = 25;
6766
@@ -343,7 +342,9 @@ export async function issuesList(ctx: Ctx): Promise<Response> {
343342 ${pager(
344343 nextCursor === null
345344 ? {}
346 : { next: withFacet(`${base}/issues`, query, {}, Number(nextCursor)) },
345 : {
346 next: withFacet(`${base}/issues`, query, {}, Number(nextCursor)),
347 },
347348 )}
348349 </div>
349350 ${when(ctx.auth.user !== undefined, () => stickyAction(newIssue))}
@@ -507,7 +508,9 @@ export function issuePage(ctx: Ctx): Response {
507508 })}
508509 </div>
509510 <aside>
511 ${assigneeCard(ctx, repo, issue, `${thread}/assignee`)}
510512 ${labelsCard(ctx, repo, issue, applied, all, `${thread}/labels`)}
513 ${crewActivityCard(ctx, repo, issue)}
511514 </aside>
512515 </div>
513516 ${liveMount({
@@ -558,8 +561,7 @@ export async function issueCommentSubmit(ctx: Ctx): Promise<Response> {
558561 if (!added.ok)
559562 return flashTo(ctx, dest, {
560563 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.",
563565 });
564566 return flashTo(ctx, `${dest}#conversation`, { message: "Comment posted." });
565567 }
src/web/pages/origins.ts +27 −4
@@ -61,12 +61,35 @@ import {
6161 type IconName,
6262 } from "../ui.ts";
6363
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 */
6678 export function accountSections(
67 current: "profile" | "password" | "tokens" | "keys" | "origins",
79 current: AccountSection,
80 o: { admin?: boolean } = {},
6881 ): SettingsSection[] {
6982 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 : []),
7093 {
7194 label: "Profile",
7295 href: "/settings",
@@ -535,7 +558,7 @@ function shell(ctx: Ctx, body: Html, status = 200): Response {
535558 ctx,
536559 title: "Origins",
537560 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")}`,
539562 },
540563 status,
541564 );
src/web/pages/pull.ts +105 −5
@@ -15,6 +15,7 @@ import {
1515 resolveReviewThread,
1616 submitReview,
1717 } from "../../actions/pulls.ts";
18 import { getAppByRepo } from "../../core/apps.ts";
1819 import { labelsFor, listLabels } from "../../core/issues.ts";
1920 import {
2021 pullDiff,
@@ -24,6 +25,7 @@ import {
2425 type PullStatus,
2526 } from "../../core/pulls.ts";
2627 import { getPull } from "../../core/pulls.ts";
28 import { checkSummary, type CheckSummary } from "../../core/runs.ts";
2729 import {
2830 anchorKey,
2931 byAnchor,
@@ -79,6 +81,7 @@ import {
7981 type Tone,
8082 } from "../ui.ts";
8183 import {
84 assigneeCard,
8285 composer,
8386 conversation,
8487 labelsCard,
@@ -88,7 +91,9 @@ import {
8891 threadFragment,
8992 type Entry,
9093 } from "./thread.ts";
94 import { previewCard } from "./apps.ts";
9195 import { pullState } from "./pulls.ts";
96 import { crewActivityCard } from "./runs.ts";
9297
9398 /** The pull and its repo, or the response that refuses to say why not. */
9499 function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response {
@@ -193,10 +198,54 @@ function reviewsChip(
193198 </p>`;
194199 }
195200
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
196245 // ---- the merge box ----
197246
198247 /**
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,
200249 * and offers exactly the actions that are actually available.
201250 */
202251 function mergeBox(
@@ -206,8 +255,11 @@ function mergeBox(
206255 status: PullStatus,
207256 mayMerge: boolean,
208257 by: readonly Standing[],
258 check: CheckSummary,
259 isApp: boolean,
209260 ): Html {
210261 const thread = `${repoBase(repo)}/pulls/${pull.number}`;
262 const checks = checksLine(repoBase(repo), check, repo.requireChecks, isApp);
211263 const reopen = html`<form method="post" action="${thread}/state">
212264 <input type="hidden" name="state" value="open" />
213265 ${btn("Reopen", { size: "sm", icon: "pull" })}
@@ -301,7 +353,7 @@ function mergeBox(
301353 <div class="grow">
302354 <div class="merge-headline">Can't merge.</div>
303355 <p class="merge-sub">${reason}</p>
304 ${reviewsChip(status.reviews, by)}
356 ${reviewsChip(status.reviews, by)} ${checks}
305357 </div>`,
306358 { tone: "danger", extra: "merge-box" },
307359 );
@@ -327,7 +379,7 @@ function mergeBox(
327379 <div class="grow">
328380 <div class="merge-headline">Waiting on review.</div>
329381 <p class="merge-sub">${why}</p>
330 ${reviewsChip(status.reviews, by)}
382 ${reviewsChip(status.reviews, by)} ${checks}
331383 </div>
332384 ${when(
333385 mayMerge,
@@ -345,6 +397,45 @@ function mergeBox(
345397 );
346398 }
347399
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
348439 return card(
349440 html`${icon("check")}
350441 <div class="grow">
@@ -356,7 +447,7 @@ function mergeBox(
356447 ${status.ahead} commit${status.ahead === 1 ? "" : "s"} will land on
357448 <span class="mono">${pull.baseBranch}</span>.
358449 </p>
359 ${reviewsChip(status.reviews, by)}
450 ${reviewsChip(status.reviews, by)} ${checks}
360451 </div>
361452 ${mayMerge
362453 ? html`<span class="merge-form">
@@ -585,6 +676,10 @@ export async function pullPage(ctx: Ctx): Promise<Response> {
585676 const allLabels = listLabels(ctx.app.db, repo.id);
586677 const verdicts = listReviews(ctx.app.db, pull.id);
587678 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;
588683 const threads = pullThreads(ctx.app, pull);
589684 const threadViews = threadStateViews(ctx, repo, threads);
590685 const unresolved = threads.filter((t) => t.resolvedAt === null).length;
@@ -676,7 +771,7 @@ export async function pullPage(ctx: Ctx): Promise<Response> {
676771 ${when(pull.crossRepo, () => badge("fork", { icon: "fork" }))}
677772 </div>
678773 </div>
679 ${mergeBox(ctx, repo, pull, status, mayMerge, by)}
774 ${mergeBox(ctx, repo, pull, status, mayMerge, by, check, isApp)}
680775 <div class="split">
681776 <div>
682777 ${talk.node}
@@ -690,8 +785,11 @@ export async function pullPage(ctx: Ctx): Promise<Response> {
690785 ${reviewCard(ctx, repo, pull, `${thread}/review-state`)}
691786 </div>
692787 <aside>
788 ${when(isApp, () => previewCard(ctx, repo, pull))}
789 ${assigneeCard(ctx, repo, pull, `${thread}/assignee`)}
693790 ${labelsCard(ctx, repo, pull, applied, allLabels, `${thread}/labels`)}
694791 <div class="mt">${commitsCard}</div>
792 ${crewActivityCard(ctx, repo, pull)}
695793 </aside>
696794 </div>
697795 <div class="files-head">
@@ -841,6 +939,8 @@ function mergeFailure(error: AccessError | MergePullError): string {
841939 // dismissed a second later, or a reviewer asked for changes in between.
842940 if (error === "review_required")
843941 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.";
844944 if (error === "forbidden" || error === "unauthorized")
845945 return "You do not have write access to this repository.";
846946 return "That pull request is no longer here.";
src/web/pages/repo.ts +2 −1
@@ -28,6 +28,7 @@ import {
2828 } from "../chrome.ts";
2929 import { codeJs } from "../client.ts";
3030 import { highlightBlob } from "../code.ts";
31 import { appStrip } from "./apps.ts";
3132 import { field, readForm } from "../form.ts";
3233 import { html, when, type Html } from "../html.ts";
3334 import { flashTo, fragment, page } from "../layout.ts";
@@ -773,7 +774,7 @@ export async function repoHome(ctx: Ctx): Promise<Response> {
773774 const body = html`${scopeHeader(ctx, scope, "code", {
774775 branchCount: refs.branches.length,
775776 })}
776 ${forkBar(ctx, repo)}
777 ${appStrip(ctx, repo)} ${forkBar(ctx, repo)}
777778 ${await renderCode(scope, {
778779 refs,
779780 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 {
114114 lead: icon(hit.kind === "issue" ? "issue" : "pull"),
115115 title: hit.title,
116116 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"
119119 ? html` · <span class="muted">on the origin</span>`
120120 : null}`,
121121 trail: stateBadge(hit.state),
@@ -147,7 +147,12 @@ export function markMatch(text: string, query: string): Html {
147147 function codeHitRow(hit: CodeHit, query: string): Html {
148148 const at = `/${hit.repo.owner}/${hit.repo.name}`;
149149 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 >
151156 <span class="row-lead">${icon("code")}</span>
152157 <span class="row-main">
153158 <span class="row-title mono"
@@ -169,11 +174,7 @@ const KINDS: { value: SearchKind; label: string }[] = [
169174 { value: "code", label: "Code" },
170175 ];
171176
172 function searchHref(
173 query: string,
174 kind: SearchKind,
175 live: boolean,
176 ): string {
177 function searchHref(query: string, kind: SearchKind, live: boolean): string {
177178 const params = new URLSearchParams({ q: query });
178179 if (kind !== "all") params.set("kind", kind);
179180 if (live) params.set("live", "1");
@@ -184,7 +185,13 @@ function section(
184185 title: string,
185186 kind: SearchKind,
186187 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 },
188195 ): Html {
189196 return html`<h2 class="mt" id="${kind}">
190197 ${title}${rows.length === 0
@@ -203,7 +210,9 @@ function section(
203210 () =>
204211 html`<p class="muted small mt">
205212 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 >.
207216 </p>`,
208217 )}`;
209218 }
@@ -242,8 +251,9 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
242251 autofocus
243252 data-search-input
244253 />
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}" />`,
247257 )}
248258 ${when(live, () => html`<input type="hidden" name="live" value="1" />`)}
249259 <button class="btn" data-variant="primary" data-size="sm" type="submit">
@@ -303,49 +313,44 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
303313 const wants = (which: SearchKind) => kind === "all" || kind === which;
304314
305315 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 )}`;
349354 return page({ ctx, title: `Search · ${query}`, body });
350355 }
351356
@@ -443,6 +448,26 @@ const DESTRUCTIVE: {
443448 phrase: "",
444449 }),
445450 },
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 },
446471 ];
447472
448473 function describeTarget(to: string): Destructive | undefined {
src/web/pages/settings.ts +412 −10
@@ -8,6 +8,17 @@
88 * same-origin POST and every one of them says what happened.
99 */
1010
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";
1122 import {
1223 createLabel,
1324 createWebhook,
@@ -17,6 +28,15 @@ import {
1728 updateLabel,
1829 updateRepoSettings,
1930 } 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";
2040 import { listLabels, type Label } from "../../core/issues.ts";
2141 import { canAdmin, type Repo } from "../../core/repos.ts";
2242 import { listWebhooks, recentDeliveries } from "../../core/webhooks.ts";
@@ -46,11 +66,15 @@ import {
4666 field as formField,
4767 labelChip,
4868 labelCss,
69 listCard,
70 row,
4971 selectField,
5072 time,
73 userLink,
5174 } from "../ui.ts";
75 import { crewReadiness } from "./crew.ts";
5276
53 type Section = "general" | "labels" | "hooks" | "danger";
77 type Section = "general" | "app" | "crew" | "labels" | "hooks" | "danger";
5478
5579 /** The repo, but only for someone who administers it. A non-admin is told
5680 * nothing — the settings tab is not even rendered for them. */
@@ -68,6 +92,18 @@ function sections(base: string, active: Section) {
6892 icon: "settings" as const,
6993 current: active === "general",
7094 },
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 },
71107 {
72108 label: "Labels",
73109 href: `${base}/settings/labels`,
@@ -97,16 +133,20 @@ function settingsPageResponse(
97133 title: string,
98134 body: Html,
99135 styleCss?: string,
136 status = 200,
100137 ): 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 );
110150 }
111151
112152 /** The gate every settings POST passes: admin, same-origin, admin scope. */
@@ -196,6 +236,20 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
196236 >
197237 </label>
198238 </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>
199253 <div class="form-actions">
200254 ${btn("Save changes", { variant: "primary" })}
201255 </div>
@@ -217,6 +271,7 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> {
217271 visibility: field(form, "visibility") === "public" ? "public" : "private",
218272 protectDefault: form.get("protect") !== null,
219273 requireReview: form.get("requireReview") !== null,
274 requireChecks: form.get("requireChecks") !== null,
220275 ...(branch !== "" ? { defaultBranch: branch } : {}),
221276 });
222277 const flash: Flash = updated.ok
@@ -231,6 +286,353 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> {
231286 return flashTo(ctx, `${base}/settings`, flash);
232287 }
233288
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 #n.`,
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
234636 // ---- labels ----
235637
236638 /** `<input type="color">` submits `#rrggbb`; the store keeps six hex digits. */
src/web/pages/site.ts +147 −47
@@ -11,14 +11,22 @@
1111 import { createRepo } from "../../actions/repos.ts";
1212 import { signUp } from "../../actions/users.ts";
1313 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";
1520 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
1621 import { listPins, pinnedRepoIds } from "../../core/pins.ts";
1722 import {
23 canRead,
1824 getReposByIds,
1925 listReposVisibleTo,
26 writableRepoIds,
2027 type Repo,
2128 } from "../../core/repos.ts";
29 import { latestRunsForIssues } from "../../core/runs.ts";
2230 import { createSession, destroySession } from "../../core/sessions.ts";
2331 import {
2432 getUsersByIds,
@@ -32,6 +40,11 @@ import { hostOf, notFoundPage, pageHead } from "../chrome.ts";
3240 import { field, readForm } from "../form.ts";
3341 import { html, when, type Html } from "../html.ts";
3442 import { icon, type IconName } from "../icons.ts";
43 import {
44 eventActor,
45 eventVerb,
46 isTransientEvent,
47 } from "../../design/activity.ts";
3548 import { flashTo, page, redirect } from "../layout.ts";
3649 import { liveClientJs, liveMount } from "../live.ts";
3750 import {
@@ -44,9 +57,12 @@ import {
4457 pager,
4558 repoCounts,
4659 row,
60 runStateBadge,
4761 selectField,
4862 time,
63 userTag,
4964 } from "../ui.ts";
65 import { appsCard } from "./apps.ts";
5066
5167 export function sessionCookie(value: string, expires: Date, url: URL): string {
5268 const secure = url.protocol === "https:" ? "; Secure" : "";
@@ -119,49 +135,11 @@ function eventIcon(kind: string): IconName {
119135 if (kind.startsWith("team.") || kind.startsWith("org.")) return "users";
120136 if (kind.startsWith("token.") || kind.startsWith("key.")) return "key";
121137 if (kind.startsWith("settings.")) return "settings";
138 if (kind.startsWith("app.") || kind === "run.finished") return "rocket";
139 if (kind.startsWith("crew.")) return "bot";
122140 return "activity";
123141 }
124142
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
165143 /** Which conversation, if any, an event is about — for one grouped read. */
166144 function eventIssueRef(
167145 event: ForgeEvent,
@@ -189,7 +167,7 @@ function eventSubject(
189167 const issue = issues.get(issueKey(ref.repoId, ref.number));
190168 return issue ? `#${ref.number} ${issue.title}` : `#${ref.number}`;
191169 }
192 for (const key of ["name", "url", "parent"] as const) {
170 for (const key of ["name", "url", "parent", "summary"] as const) {
193171 const value = event.payload[key];
194172 if (typeof value === "string" && value !== "") return value;
195173 }
@@ -218,7 +196,11 @@ function activityRail(
218196 // The visibility rule lives in the query, so this is the twelve most recent
219197 // things *this viewer* can see — not the twelve newest events in the forge
220198 // 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);
222204 const byId = getReposByIds(
223205 db,
224206 events.map((e: ForgeEvent) => e.repoId ?? "").filter(Boolean),
@@ -266,6 +248,8 @@ function activityRail(
266248 // someone doing it here. The glyph is the only honest way to say so
267249 // without a second line of prose on every row.
268250 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);
269253 return row({
270254 href: `/${where}`,
271255 lead: icon(
@@ -273,10 +257,9 @@ function activityRail(
273257 ? repo.origin.kind
274258 : eventIcon(event.kind),
275259 ),
276 title: html`<strong>${actor?.username ?? "someone"}</strong>
277 ${eventVerb(event.kind)}`,
260 title: html`<strong>${who}</strong> ${eventVerb(event)}`,
278261 meta: html`${where}${subject !== "" ? html` · ${subject}` : null}`,
279 titleAttr: `${actor?.username ?? "someone"} ${eventVerb(event.kind)}`,
262 titleAttr: `${who} ${eventVerb(event)}`,
280263 trail: time(event.createdAt),
281264 });
282265 })}
@@ -382,6 +365,121 @@ function downloadsCard(ctx: Ctx, user: User): Html {
382365 );
383366 }
384367
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
385483 function orgsCard(ctx: Ctx, user: User): Html {
386484 const orgs = listOrgsForUser(ctx.app.db, user.id);
387485 const head = html`<h2>Your organizations</h2>
@@ -502,6 +600,7 @@ function dashboard(ctx: Ctx, user: User): Html {
502600
503601 return html`<div class="dash">
504602 <div>
603 ${inbox(ctx, user)}
505604 ${pageHead("Your repositories", {
506605 actions: btn("New repository", {
507606 href: "/new",
@@ -520,7 +619,8 @@ function dashboard(ctx: Ctx, user: User): Html {
520619 })}
521620 </div>
522621 <aside class="stack">
523 ${rail.body} ${downloadsCard(ctx, user)} ${orgsCard(ctx, user)}
622 ${appsCard(ctx)} ${rail.body} ${downloadsCard(ctx, user)}
623 ${orgsCard(ctx, user)}
524624 </aside>
525625 ${liveMount({
526626 repo: rail.watch[0] ?? "",
src/web/pages/thread.ts +149 −2
@@ -8,16 +8,24 @@
88 * is what makes a long thread skimmable.
99 */
1010
11 import { listCrew } from "../../actions/crew.ts";
1112 import {
13 assignIssue,
1214 setIssueLabelsThrough,
1315 setIssueStateThrough,
1416 } from "../../actions/issues.ts";
1517 import { listEvents } from "../../core/events.ts";
1618 import type { Issue, Label } from "../../core/issues.ts";
1719 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";
1921 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";
2129 import type { Ctx } from "../../http/context.ts";
2230 import { commentCard } from "../comments.ts";
2331 import { field, readForm } from "../form.ts";
@@ -403,6 +411,145 @@ export function labelsCard(
403411 );
404412 }
405413
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
406553 // ---- shared mutations ----
407554
408555 /** 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); }
532532 .files-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
533533 .files-head h2 { margin-bottom: 0; }
534534
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
535581 /* ---- proposing a change ---- */
536582 /* The editor's commit block, when the commit is a contribution: the whole
537583 outcome in one sentence before anybody types. */
src/web/ui.ts +50
@@ -10,6 +10,8 @@
1010 * reaches for an inline `style` attribute, because the CSP forbids it.
1111 */
1212
13 import type { EnvState } from "../apps/runtime.ts";
14 import type { RunKind, RunState } from "../core/runs.ts";
1315 import { icon, type IconName } from "./icons.ts";
1416 import { Html, html, raw, when } from "./html.ts";
1517
@@ -200,6 +202,54 @@ export function verdictBadge(
200202 return badge(look.label, { icon: look.icon, tone: look.tone });
201203 }
202204
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
203253 export interface LabelLike {
204254 readonly name: string;
205255 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 = "&lt;script&gt;alert(1)&lt;/script&gt;&amp;&quot;&#39;";
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 "&lt;script&gt;alert(document.cookie)&lt;/script&gt;",
307 );
308 expect(page).toContain("5 &gt; 3 &amp; 2 &lt; 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", () => {
461461 await h.settle();
462462 await openRepo(h, "doomed");
463463 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");
465466 expect(h.text()).toContain("Danger");
466467 await h.press("X");
467468 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs