Public: anyone can read this; the crew can change it.
How this app runs itself: the crew and the ceiling, set from the studio #103
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/61-how-this-app-runs-itself-the-crew-and-th → main 1 commit fork
5 files
changed +370
−7
docs/STUDIO.md +21 −3
| @@ -48,11 +48,25 @@ An app's home in the studio (`/:owner/:name`, when the repository is an | ||
| 48 | 48 | app) shows, top to bottom: what it serves and where; the box; what asks |
| 49 | 49 | you; what is happening now. Beside it, who is on the crew and what each is |
| 50 | 50 | for, this week's numbers (shipped, runs, spend, spend per shipped change), |
| 51 | and how the repository runs itself, when it does. The page keeps itself | |
| 51 | and how the repository runs itself, when it does — each **Change** button | |
| 52 | leading to the one place both are set. The page keeps itself | |
| 52 | 53 | current about deployments: a deploy run for the app being viewed refetches |
| 53 | 54 | the head card and previews list in place, so a change serving in its |
| 54 | 55 | preview shows up without a reload. |
| 55 | 56 | |
| 57 | **How this app runs itself** (`/:owner/:name/settings`, in studio, on an | |
| 58 | app) is that one place: the crew, each with their role in the studio's | |
| 59 | words (builds, reviews, plans) and their brief, with add, change-role and | |
| 60 | remove, and beside it whether the app is assisted or autonomous, the day's | |
| 61 | ceiling, how many may work at once, and whether it is paused and why. It is | |
| 62 | a studio page over the very same actions dev's Crew and Autonomy settings | |
| 63 | already use (`src/actions/crew.ts`, `updateRepoSettings`) — same | |
| 64 | same-origin check, same authorization, same errors; nothing here decides a | |
| 65 | mutation a second way. *The rest of the settings* — visibility, the | |
| 66 | default branch, branch protection, labels, webhooks, environment | |
| 67 | variables, deploy keys, danger — stays dev's, one link away | |
| 68 | (`?mode=dev`), where the tab strip still has Crew and Autonomy of its own. | |
| 69 | ||
| 56 | 70 | **The owner's facts and the public's.** What an app *is* — its description, |
| 57 | 71 | whether it is live, its address, the look, *Open the app*, and how many |
| 58 | 72 | changes it has shipped — is the point of a public app and stays on the |
| @@ -63,8 +77,12 @@ book-keeping, not the app's, and is computed at all only for a viewer who | ||
| 63 | 77 | can write to the repository: `canSeeOwnerFacts` (`src/core/repos.ts`, |
| 64 | 78 | today exactly `canWrite`) is the one predicate every surface asks before |
| 65 | 79 | it renders or even queries one of these — the studio page, its live |
| 66 | `/app/state` fragment, a run's own page and its JSON and live-tail | |
| 67 | answers over the API, and the board. What is happening now still reads as | |
| 80 | `/app/state` fragment, its own crew-and-autonomy settings page, a run's | |
| 81 | own page and its JSON and live-tail answers over the API, and the board. | |
| 82 | The forms that change either render only for who the underlying actions | |
| 83 | would let post — an admin, exactly as dev's pages already require — so a | |
| 84 | writer who is not an admin sees the same facts with no controls that would | |
| 85 | just come back refused. What is happening now still reads as | |
| 68 | 86 | the app's own story for such a viewer — a change being built, a preview |
| 69 | 87 | coming up — but by "the crew" rather than an agent's own name, and never |
| 70 | 88 | with a run's cost, turns or steer box. A private repository is unaffected: |
src/web/pages/settings.ts +12
| @@ -68,6 +68,7 @@ import { | ||
| 68 | 68 | repoPalette, |
| 69 | 69 | settingsLayout, |
| 70 | 70 | } from "../chrome.ts"; |
| 71 | import { studioCrewSettings } from "../studio/crew.ts"; | |
| 71 | 72 | import { field, readForm, type Form } from "../form.ts"; |
| 72 | 73 | import { html, raw, when, type Html } from "../html.ts"; |
| 73 | 74 | import { icon } from "../icons.ts"; |
| @@ -192,6 +193,17 @@ async function adminForm( | ||
| 192 | 193 | // ---- general ---- |
| 193 | 194 | |
| 194 | 195 | export async function settingsPage(ctx: Ctx): Promise<Response> { |
| 196 | // In the studio, the app's head leads to one page for the crew and the | |
| 197 | // ceiling — the two decisions that are the studio's own story — rather | |
| 198 | // than dev's full settings shell. `?mode=dev` is the way to the rest. | |
| 199 | const studio = | |
| 200 | ctx.mode === "studio" && ctx.url.searchParams.get("mode") !== "dev"; | |
| 201 | if (studio) { | |
| 202 | const readable = loadRepo(ctx); | |
| 203 | if (!readable) return repoNotFound(ctx); | |
| 204 | const record = getAppByRepo(ctx.app.db, readable.id); | |
| 205 | if (record !== undefined) return studioCrewSettings(ctx, readable, record); | |
| 206 | } | |
| 195 | 207 | const repo = loadAdminRepo(ctx); |
| 196 | 208 | if (!repo) return repoNotFound(ctx); |
| 197 | 209 | const base = repoBase(repo); |
src/web/studio/app.ts +4 −4
| @@ -268,7 +268,7 @@ export async function studioApp( | ||
| 268 | 268 | : crew.length === 0 |
| 269 | 269 | ? card( |
| 270 | 270 | html`<p class="muted small"> |
| 271 | Nobody yet. <a href="${base}/settings/crew">Add someone.</a> | |
| 271 | Nobody yet. <a href="${base}/settings">Add someone.</a> | |
| 272 | 272 | </p>`, |
| 273 | 273 | { |
| 274 | 274 | head: html`${icon("bot")} |
| @@ -291,7 +291,7 @@ export async function studioApp( | ||
| 291 | 291 | head: html`${icon("bot")} |
| 292 | 292 | <h2>Crew</h2> |
| 293 | 293 | <span class="spacer"></span> |
| 294 | ${btn("Change", { href: `${base}/settings/crew`, size: "sm" })}`, | |
| 294 | ${btn("Change", { href: `${base}/settings`, size: "sm" })}`, | |
| 295 | 295 | }, |
| 296 | 296 | ); |
| 297 | 297 | const weekCard = card( |
| @@ -331,7 +331,7 @@ export async function studioApp( | ||
| 331 | 331 | : card( |
| 332 | 332 | autonomy === undefined |
| 333 | 333 | ? html`<p class="muted small"> |
| 334 | Off. <a href="${base}/settings/autonomy">Turn it on</a> and the | |
| 334 | Off. <a href="${base}/settings">Turn it on</a> and the | |
| 335 | 335 | crew works what is ready without being asked. |
| 336 | 336 | </p>` |
| 337 | 337 | : html`<dl> |
| @@ -359,7 +359,7 @@ export async function studioApp( | ||
| 359 | 359 | head: html`${icon("play")} |
| 360 | 360 | <h2>On its own</h2> |
| 361 | 361 | <span class="spacer"></span> |
| 362 | ${btn("Change", { href: `${base}/settings/autonomy`, size: "sm" })}`, | |
| 362 | ${btn("Change", { href: `${base}/settings`, size: "sm" })}`, | |
| 363 | 363 | extra: "about", |
| 364 | 364 | }, |
| 365 | 365 | ); |
src/web/studio/crew.ts added +259
| @@ -0,0 +1,259 @@ | ||
| 1 | /** | |
| 2 | * The studio's own settings: who is on the crew, and how far it may go on | |
| 3 | * its own — the two decisions that belong to the studio's story, told from | |
| 4 | * one page reached from its head. Everything here posts to the very same | |
| 5 | * routes dev's Crew and Autonomy pages already use (`crewAdd`, `crewRemove`, | |
| 6 | * `crewRoleUpdate`, `autonomySettingsUpdate`) — same same-origin check, same | |
| 7 | * authorization, same errors. This module only draws the page; it never | |
| 8 | * decides anything a mutation already decides. | |
| 9 | * | |
| 10 | * Crew and autonomy are the owner's own book-keeping, same as the app page's | |
| 11 | * rail (`canSeeOwnerFacts`): a viewer who cannot write here is told that, | |
| 12 | * not shown the roster or the day's spend. The forms that change either are | |
| 13 | * drawn only for who the actions themselves would let post — an admin — | |
| 14 | * so nothing here offers a control that would just come back refused. | |
| 15 | * Everything else about the repository — visibility, branches, labels, | |
| 16 | * webhooks, deletion — stays dev's, one link away. | |
| 17 | */ | |
| 18 | ||
| 19 | import { listAllAgents } from "../../actions/crew.ts"; | |
| 20 | import { CREW_ROLES, listCrewRoles, type CrewRole } from "../../core/agents.ts"; | |
| 21 | import type { AppRecord } from "../../core/apps.ts"; | |
| 22 | import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | |
| 23 | import { autonomyStatus } from "../../crew/autonomy.ts"; | |
| 24 | import type { Ctx } from "../../http/context.ts"; | |
| 25 | import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; | |
| 26 | import { html, raw, type Html } from "../html.ts"; | |
| 27 | import { icon } from "../icons.ts"; | |
| 28 | import { page } from "../layout.ts"; | |
| 29 | import { | |
| 30 | btn, | |
| 31 | card, | |
| 32 | empty, | |
| 33 | field, | |
| 34 | listCard, | |
| 35 | row, | |
| 36 | selectField, | |
| 37 | userLink, | |
| 38 | } from "../ui.ts"; | |
| 39 | ||
| 40 | const ROLE_WORD: Record<CrewRole, string> = { | |
| 41 | builder: "builds", | |
| 42 | reviewer: "reviews", | |
| 43 | planner: "plans", | |
| 44 | }; | |
| 45 | ||
| 46 | function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { | |
| 47 | const db = ctx.app.db; | |
| 48 | const crew = listCrewRoles(db, repo.id); | |
| 49 | const onCrew = new Set(crew.map((c) => c.agent.user.id)); | |
| 50 | const allListed = listAllAgents(ctx.app, ctx.auth.user); | |
| 51 | const available = (allListed.ok ? allListed.value : []).filter( | |
| 52 | (agent) => !onCrew.has(agent.user.id), | |
| 53 | ); | |
| 54 | ||
| 55 | const rows = | |
| 56 | crew.length === 0 | |
| 57 | ? empty({ | |
| 58 | icon: "bot", | |
| 59 | title: "Nobody yet", | |
| 60 | note: "Add an agent below and tell it what you want.", | |
| 61 | }) | |
| 62 | : listCard( | |
| 63 | crew.map((c) => | |
| 64 | row({ | |
| 65 | lead: icon("bot"), | |
| 66 | title: html`${userLink(c.agent.user.username)}`, | |
| 67 | meta: html`<span title="${c.agent.brief}" | |
| 68 | >${ROLE_WORD[c.role]}${c.agent.brief === "" | |
| 69 | ? "" | |
| 70 | : ` · ${c.agent.brief}`}</span | |
| 71 | >`, | |
| 72 | ...(admin | |
| 73 | ? { | |
| 74 | trail: html`<form | |
| 75 | class="row-actions" | |
| 76 | method="post" | |
| 77 | action="${base}/settings/crew/${encodeURIComponent( | |
| 78 | c.agent.user.username, | |
| 79 | )}/role" | |
| 80 | > | |
| 81 | <label | |
| 82 | class="sr-only" | |
| 83 | for="f-role-${c.agent.user.username}" | |
| 84 | >Role of ${c.agent.user.username}</label | |
| 85 | > | |
| 86 | <select | |
| 87 | class="input input-sm" | |
| 88 | id="f-role-${c.agent.user.username}" | |
| 89 | name="role" | |
| 90 | > | |
| 91 | ${CREW_ROLES.map( | |
| 92 | (r) => | |
| 93 | html`<option | |
| 94 | value="${r}" | |
| 95 | ${c.role === r ? raw("selected") : null} | |
| 96 | > | |
| 97 | ${ROLE_WORD[r]} | |
| 98 | </option>`, | |
| 99 | )} | |
| 100 | </select> | |
| 101 | ${btn("Set", { size: "sm" })} | |
| 102 | </form> | |
| 103 | <form | |
| 104 | class="row-actions" | |
| 105 | method="post" | |
| 106 | action="${base}/settings/crew/${encodeURIComponent( | |
| 107 | c.agent.user.username, | |
| 108 | )}/remove" | |
| 109 | > | |
| 110 | ${btn("Remove", { size: "sm", icon: "x" })} | |
| 111 | </form>`, | |
| 112 | } | |
| 113 | : {}), | |
| 114 | }), | |
| 115 | ), | |
| 116 | ); | |
| 117 | ||
| 118 | const add = | |
| 119 | !admin || available.length === 0 | |
| 120 | ? null | |
| 121 | : card( | |
| 122 | html`<form class="edit-row" method="post" action="${base}/settings/crew"> | |
| 123 | <label class="sr-only" for="f-agent">Agent</label> | |
| 124 | <select class="input" id="f-agent" name="agent"> | |
| 125 | ${available.map( | |
| 126 | (agent) => | |
| 127 | html`<option value="${agent.user.username}"> | |
| 128 | ${agent.user.username} | |
| 129 | </option>`, | |
| 130 | )} | |
| 131 | </select> | |
| 132 | ${btn("Add to crew", { | |
| 133 | variant: "primary", | |
| 134 | size: "sm", | |
| 135 | icon: "plus", | |
| 136 | })} | |
| 137 | </form>`, | |
| 138 | { head: html`<h2>Add an agent</h2>`, pad: false }, | |
| 139 | ); | |
| 140 | ||
| 141 | return html`${card(html`${rows}`, { | |
| 142 | head: html`${icon("bot")} | |
| 143 | <h2>Crew</h2>`, | |
| 144 | pad: false, | |
| 145 | })} | |
| 146 | ${add}`; | |
| 147 | } | |
| 148 | ||
| 149 | function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { | |
| 150 | const status = repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo); | |
| 151 | const state = | |
| 152 | status === undefined | |
| 153 | ? html`<p class="muted small"> | |
| 154 | Off. Every run starts because a person assigned, commented, or a | |
| 155 | check failed. | |
| 156 | </p>` | |
| 157 | : html`<dl> | |
| 158 | <dt>Mode</dt> | |
| 159 | <dd>${repo.autonomy === "autonomous" ? "Autonomous" : "Assisted"}</dd> | |
| 160 | <dt>Today</dt> | |
| 161 | <dd> | |
| 162 | $${status.spentTodayUsd.toFixed(2)} of | |
| 163 | $${status.ceilingUsd.toFixed(0)} | |
| 164 | </dd> | |
| 165 | <dt>Slots</dt> | |
| 166 | <dd>${status.active} of ${status.slots} in use</dd> | |
| 167 | ${status.paused | |
| 168 | ? html`<dt>Paused</dt> | |
| 169 | <dd> | |
| 170 | ${status.paused === "ceiling" | |
| 171 | ? "at the ceiling, until 00:00 UTC" | |
| 172 | : status.paused} | |
| 173 | </dd>` | |
| 174 | : null} | |
| 175 | </dl>`; | |
| 176 | const form = !admin | |
| 177 | ? null | |
| 178 | : html`<form method="post" action="${base}/settings/autonomy"> | |
| 179 | ${selectField({ | |
| 180 | label: "Mode", | |
| 181 | name: "autonomy", | |
| 182 | hint: "Assisted: the crew works what is ready and every green change waits for your look. Autonomous: green ships on its own.", | |
| 183 | options: [ | |
| 184 | { value: "off", label: "Off", selected: repo.autonomy === "off" }, | |
| 185 | { | |
| 186 | value: "assisted", | |
| 187 | label: "Assisted", | |
| 188 | selected: repo.autonomy === "assisted", | |
| 189 | }, | |
| 190 | { | |
| 191 | value: "autonomous", | |
| 192 | label: "Autonomous", | |
| 193 | selected: repo.autonomy === "autonomous", | |
| 194 | }, | |
| 195 | ], | |
| 196 | })} | |
| 197 | <input | |
| 198 | type="hidden" | |
| 199 | name="direction" | |
| 200 | value="${repo.autonomyDirection}" | |
| 201 | /> | |
| 202 | ${field({ | |
| 203 | label: "Ceiling per day", | |
| 204 | name: "ceiling", | |
| 205 | value: String(repo.autonomyCeilingUsd), | |
| 206 | type: "number", | |
| 207 | hint: "Dollars of agent spend on this repository per UTC day. At the ceiling the crew stops picking until midnight; nothing in flight is cut.", | |
| 208 | })} | |
| 209 | ${field({ | |
| 210 | label: "Slots", | |
| 211 | name: "slots", | |
| 212 | value: String(repo.autonomySlots), | |
| 213 | type: "number", | |
| 214 | hint: "Agent runs at once here. This is the WIP limit.", | |
| 215 | })} | |
| 216 | <div class="form-actions">${btn("Save", { variant: "primary" })}</div> | |
| 217 | </form>`; | |
| 218 | return card(html`${state}${form}`, { | |
| 219 | head: html`${icon("play")} | |
| 220 | <h2>On its own</h2>`, | |
| 221 | }); | |
| 222 | } | |
| 223 | ||
| 224 | /** | |
| 225 | * GET /:owner/:name/settings in studio mode, for an app: the crew and the | |
| 226 | * ceiling, over the same actions dev's Crew and Autonomy pages use. | |
| 227 | */ | |
| 228 | export function studioCrewSettings( | |
| 229 | ctx: Ctx, | |
| 230 | repo: Repo, | |
| 231 | _record: AppRecord, | |
| 232 | ): Response { | |
| 233 | const base = repoBase(repo); | |
| 234 | const ownerFacts = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user); | |
| 235 | const admin = ownerFacts && canAdmin(ctx.app.db, repo, ctx.auth.user); | |
| 236 | const body = !ownerFacts | |
| 237 | ? html`${repoHeader(ctx, repo, "settings", { compact: true })} | |
| 238 | <div class="card"> | |
| 239 | <p class="muted small"> | |
| 240 | The crew and how this app runs itself are visible to people who can | |
| 241 | write here. | |
| 242 | </p> | |
| 243 | </div>` | |
| 244 | : html`${repoHeader(ctx, repo, "settings", { compact: true })} | |
| 245 | <div class="stack"> | |
| 246 | ${crewCard(ctx, repo, base, admin)} | |
| 247 | ${autonomyCard(ctx, repo, base, admin)} | |
| 248 | <p class="muted small"> | |
| 249 | <a href="${base}/settings?mode=dev">The rest of the settings</a> | |
| 250 | </p> | |
| 251 | </div>`; | |
| 252 | return page({ | |
| 253 | ctx, | |
| 254 | title: `Settings · ${repo.name}`, | |
| 255 | crumbs: crumbsFor(repo), | |
| 256 | palette: repoPalette(repo), | |
| 257 | body, | |
| 258 | }); | |
| 259 | } |
test/studio.test.ts +74
| @@ -2589,6 +2589,80 @@ describe("a stranger sees the app, not the owner's books", () => { | ||
| 2589 | 2589 | expect(asOwner).toContain("keeper"); |
| 2590 | 2590 | expect(asOwner).toContain("beacon"); |
| 2591 | 2591 | }); |
| 2592 | ||
| 2593 | const post = (path: string, body: Record<string, string>, cookie: string) => | |
| 2594 | fetch(`${base}${path}`, { | |
| 2595 | method: "POST", | |
| 2596 | headers: { | |
| 2597 | cookie: `${cookie}; forge_mode=studio`, | |
| 2598 | origin: base, | |
| 2599 | }, | |
| 2600 | body: new URLSearchParams(body), | |
| 2601 | redirect: "manual", | |
| 2602 | }); | |
| 2603 | ||
| 2604 | test("the studio's settings page renders the crew and the ceiling for a writer, with the same numbers the app page shows", async () => { | |
| 2605 | const owner = await (await get("/alice/beacon/settings", ownerCookie)).text(); | |
| 2606 | expect(owner).toContain("keeper"); | |
| 2607 | expect(owner).toContain(">Crew<"); | |
| 2608 | expect(owner).toContain(">On its own<"); | |
| 2609 | expect(owner).toMatch(/\$42\.50\s+of\s+\$25/); | |
| 2610 | expect(owner).toContain("Remove"); | |
| 2611 | expect(owner).toContain("Save"); | |
| 2612 | expect(owner).toContain("The rest of the settings"); | |
| 2613 | }); | |
| 2614 | ||
| 2615 | test("a reader without write sees no crew and no controls on the settings page; one who cannot read gets not-found", async () => { | |
| 2616 | const stranger = await ( | |
| 2617 | await get("/alice/beacon/settings", strangerCookie) | |
| 2618 | ).text(); | |
| 2619 | expect(stranger).not.toContain("keeper"); | |
| 2620 | expect(stranger).not.toMatch(/\$\d/); | |
| 2621 | expect(stranger).not.toContain("Add to crew"); | |
| 2622 | expect(stranger).not.toContain("<select"); | |
| 2623 | ||
| 2624 | const anon = await get("/alice/beacon/settings"); | |
| 2625 | expect(anon.status).toBe(200); | |
| 2626 | const anonText = await anon.text(); | |
| 2627 | expect(anonText).not.toContain("keeper"); | |
| 2628 | ||
| 2629 | const noSuchRepo = await get("/alice/no-such-app/settings", strangerCookie); | |
| 2630 | expect(noSuchRepo.status).toBe(404); | |
| 2631 | }); | |
| 2632 | ||
| 2633 | test("a reader's post to the crew action is refused, exactly as dev's page refuses it", async () => { | |
| 2634 | const refused = await post( | |
| 2635 | "/alice/beacon/settings/crew", | |
| 2636 | { agent: "keeper" }, | |
| 2637 | strangerCookie, | |
| 2638 | ); | |
| 2639 | expect(refused.status).toBe(404); | |
| 2640 | }); | |
| 2641 | ||
| 2642 | test("a role change made from the studio settings page lands exactly as dev's page does", async () => { | |
| 2643 | const roleChange = await post( | |
| 2644 | "/alice/beacon/settings/crew/keeper/role", | |
| 2645 | { role: "reviewer" }, | |
| 2646 | ownerCookie, | |
| 2647 | ); | |
| 2648 | expect(roleChange.status).toBe(303); | |
| 2649 | expect(roleChange.headers.get("location")).toBe( | |
| 2650 | "/alice/beacon/settings/crew", | |
| 2651 | ); | |
| 2652 | const after = await ( | |
| 2653 | await get("/alice/beacon/settings", ownerCookie) | |
| 2654 | ).text(); | |
| 2655 | expect(after).toContain("reviews"); | |
| 2656 | }); | |
| 2657 | ||
| 2658 | test("?mode=dev on the settings route still renders dev's settings pages", async () => { | |
| 2659 | const dev = await ( | |
| 2660 | await get("/alice/beacon/settings?mode=dev", ownerCookie) | |
| 2661 | ).text(); | |
| 2662 | expect(dev).toContain(">General<"); | |
| 2663 | expect(dev).toContain("Description"); | |
| 2664 | expect(dev).not.toContain("Add to crew"); | |
| 2665 | }); | |
| 2592 | 2666 | }); |
| 2593 | 2667 | |
| 2594 | 2668 | /** |
new above
offline