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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/61-how-this-app-runs-itself-the-crew-and-th → main 1 commit fork

  • Merged. scout/61-how-this-app-runs-itself-the-crew-and-th was merged into main as 015c82c.

Review

Sign in to review this change.
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
4848 app) shows, top to bottom: what it serves and where; the box; what asks
4949 you; what is happening now. Beside it, who is on the crew and what each is
5050 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
5253 current about deployments: a deploy run for the app being viewed refetches
5354 the head card and previews list in place, so a change serving in its
5455 preview shows up without a reload.
5556
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
5670 **The owner's facts and the public's.** What an app *is* — its description,
5771 whether it is live, its address, the look, *Open the app*, and how many
5872 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
6377 can write to the repository: `canSeeOwnerFacts` (`src/core/repos.ts`,
6478 today exactly `canWrite`) is the one predicate every surface asks before
6579 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
6886 the app's own story for such a viewer — a change being built, a preview
6987 coming up — but by "the crew" rather than an agent's own name, and never
7088 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 {
6868 repoPalette,
6969 settingsLayout,
7070 } from "../chrome.ts";
71 import { studioCrewSettings } from "../studio/crew.ts";
7172 import { field, readForm, type Form } from "../form.ts";
7273 import { html, raw, when, type Html } from "../html.ts";
7374 import { icon } from "../icons.ts";
@@ -192,6 +193,17 @@ async function adminForm(
192193 // ---- general ----
193194
194195 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 }
195207 const repo = loadAdminRepo(ctx);
196208 if (!repo) return repoNotFound(ctx);
197209 const base = repoBase(repo);
src/web/studio/app.ts +4 −4
@@ -268,7 +268,7 @@ export async function studioApp(
268268 : crew.length === 0
269269 ? card(
270270 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>
272272 </p>`,
273273 {
274274 head: html`${icon("bot")}
@@ -291,7 +291,7 @@ export async function studioApp(
291291 head: html`${icon("bot")}
292292 <h2>Crew</h2>
293293 <span class="spacer"></span>
294 ${btn("Change", { href: `${base}/settings/crew`, size: "sm" })}`,
294 ${btn("Change", { href: `${base}/settings`, size: "sm" })}`,
295295 },
296296 );
297297 const weekCard = card(
@@ -331,7 +331,7 @@ export async function studioApp(
331331 : card(
332332 autonomy === undefined
333333 ? 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
335335 crew works what is ready without being asked.
336336 </p>`
337337 : html`<dl>
@@ -359,7 +359,7 @@ export async function studioApp(
359359 head: html`${icon("play")}
360360 <h2>On its own</h2>
361361 <span class="spacer"></span>
362 ${btn("Change", { href: `${base}/settings/autonomy`, size: "sm" })}`,
362 ${btn("Change", { href: `${base}/settings`, size: "sm" })}`,
363363 extra: "about",
364364 },
365365 );
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", () => {
25892589 expect(asOwner).toContain("keeper");
25902590 expect(asOwner).toContain("beacon");
25912591 });
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 });
25922666 });
25932667
25942668 /**

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs