Public: anyone can read this; the crew can change it.

Settings › Crew, the list of the forge's agents, speaks the home page's language #9845

Merged opened this

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

Merged

mason/forge:mason/9826-settings-crew-the-list-of-the-forge-s-ag → main 7 commits fork 4 threads · 4 unresolved

  • Merged. mason/9826-settings-crew-the-list-of-the-forge-s-ag was merged into main as a2065d8.

Review

Sign in to review this change.
4 files changed +165 −84
docs/design/areas/settings.md +7
@@ -126,6 +126,13 @@ title, each with a glyph (Trusted with a check, Ends soon with a clock, Lapsed w
126126 `.row-fields` forms. With no entry, one plain sentence: no one is trusted, so every pull waits for review. The forms post to the same routes as
127127 before. `test/trusted-sources.test.ts` holds the shape.
128128
129 Settings › Crew (`crewPage()` and `crewReadiness()` in `src/web/pages/crew.ts`, #9826) adds no class and has no card. The
130 readiness words come first: a danger notice (no runner, runner cannot run, unsandboxed) is an existing
131 `.toast[data-tone="danger"]` with `role="alert"`. Spending, Agents, Scorecard, Model review, the per-model, per-prompt and
132 per-day tables, Handed to the planner and Restore drill are each a `<section class="settings-block">` with an `<h2>` (an
133 optional muted note under it) and keep their `.table-scroll` tables. With no agent, one plain sentence under "No agents yet".
134 New agent is the last block, its form posting to `/settings/crew`. Pinned by `test/crew-language.test.ts`.
135
129136 Settings › Prompts (`promptsView()` in `src/web/pages/prompts.ts`, #9825) adds no class: two `<section class="settings-block">` blocks, Instagram (the connected account as a `row()` in a `.setting-list` with its Fetch now and Remove forms, or one plain sentence and the add form) and Getting a token. Pinned by `test/prompts-language.test.ts`.
130137
131138 Settings › Hooks (`hooksPage()` in `src/web/pages/hooks.ts`, #9762) adds no component class and has no card or table: Webhooks and Add a webhook are each a `<section class="settings-block">` with an `<h2>`. Each hook is a `row()` in `<ul class="list setting-list">` (URL as title; events, state, added date and "unsigned" as meta; Send ping and a confirmed Delete in the trail), with its recent deliveries as nested child rows, Redeliver in the trail of a failed one. With no hook, one plain sentence. The secret is never drawn. Pinned by `test/hooks-rows.test.ts`.
docs/features/crew-list.md added +8
@@ -0,0 +1,8 @@
1 # The crew list
2
3 A forge admin sees who is on the crew, and whether anything will run their work, in two places.
4
5 - **Web: Settings → Crew** (`/settings/crew`, `crewPage()` in `src/web/pages/crew.ts`). One heading, then the readiness words first: "… is ready to run the crew", or a red notice saying no runner is up, the runner cannot run, or agents' shells are unsandboxed. Under it the agents (model, budget, runs, landed, spent, with Edit and Retire), the scorecards, and the New agent form, which posts to the same route as before. With no agent, one sentence says so. A non-admin gets 404.
6 - **Terminal: the Crew agents screen** (`src/tui/screens/agents.ts`). Each agent's roles, model, budget and the first line of its brief; `n` creates one, `m`, `b`, `B` and `r` change it, `d` removes it, each after a confirm.
7
8 Readiness is the web page's alone: the runner is detected once at boot, and the page says so before an issue is assigned rather than after it waits. `test/crew-language.test.ts` holds the page's shape.
src/web/pages/crew.ts +64 −84
@@ -55,9 +55,7 @@ import { html, raw, when, type Html } from "../html.ts";
5555 import { flashTo, page } from "../layout.ts";
5656 import {
5757 btn,
58 card,
5958 confirmAction,
60 empty,
6159 icon,
6260 time,
6361 field as uiField,
@@ -100,6 +98,28 @@ function shell(ctx: Ctx, title: string, body: Html, status = 200): Response {
10098 );
10199 }
102100
101 interface PanelOptions {
102 title: string;
103 /** One muted sentence under the heading. */
104 note?: Html;
105 id?: string;
106 /** Extra attributes on the section (the restore drill's verdict). */
107 attrs?: Html;
108 }
outdated

pad, attrs and tone are options the callers do not need; this is card() renamed. Trim to what is used, no dead options.

109
110 /** A titled block of the crew list: heading, a quiet note, then the body, no box. */
111 function panel(body: Html, o: PanelOptions): Html {
112 return html`<section
113 class="settings-block"
114 ${o.id ? html`id="${o.id}"` : null}
115 ${o.attrs ?? null}
116 >
117 <h2>${o.title}</h2>
118 ${o.note ? html`<p class="muted small">${o.note}</p>` : null}
119 ${body}
120 </section>`;
121 }
122
103123 /**
104124 * Whether anything will pick up an agent's work. The runner is detected
105125 * once at boot; this is that answer, said before an issue is assigned
@@ -108,46 +128,26 @@ function shell(ctx: Ctx, title: string, body: Html, status = 200): Response {
108128 export function crewReadiness(ctx: Ctx): Html {
109129 const crew = crewFor(ctx.app);
110130 if (crew === undefined && isHostedChild(ctx.app))
111 return card(
112 html`<p>
113 ${icon("alert")} This forge is hosted inside another forge's process,
131 return html`<div class="toast" data-tone="danger" role="alert">${icon("alert")}<div class="toast-body">This forge is hosted inside another forge's process,
114132 which has no crew runner for it: assigned issues will wait. The crew
115 runs once this forge has a process of its own.
116 </p>`,
117 { tone: "danger", extra: "mb" },
118 );
133 runs once this forge has a process of its own.</div></div>`;
outdated

Principle 5/8: class="error" has no style outside .field, so this danger notice (and the three below, including #crew-unsandboxed) loses its colour. Use a styled notice or add the class to docs/design/areas/settings.md.

119134 if (crew === undefined)
120 return card(
121 html`<p>
122 ${icon("alert")} No crew runner is up on this forge, so assigned issues
135 return html`<div class="toast" data-tone="danger" role="alert">${icon("alert")}<div class="toast-body">No crew runner is up on this forge, so assigned issues
123136 will wait. Start the forge with apps on (the default) and Claude Code
124137 on <span class="mono">PATH</span> or at
125 <span class="mono">FORGE_CLAUDE</span>.
126 </p>`,
127 { tone: "danger", extra: "mb" },
128 );
138 <span class="mono">FORGE_CLAUDE</span>.</div></div>`;
129139 if (!crew.ready)
130 return card(
131 html`<p>
132 ${icon("alert")} The crew cannot run: ${crew.detail}. Agents can be
133 created, but nothing picks up their work until this is fixed.
134 </p>`,
135 { tone: "danger", extra: "mb" },
136 );
140 return html`<div class="toast" data-tone="danger" role="alert">${icon("alert")}<div class="toast-body">The crew cannot run: ${crew.detail}. Agents can be
141 created, but nothing picks up their work until this is fixed.</div></div>`;
137142 return html`<p class="muted small mb">
138143 ${icon("check")} ${crew.detail} is ready to run the crew.
139144 </p>
140145 ${when(
141146 !crew.sandbox.enforced,
142147 () =>
143 card(
144 html`<p>
145 ${icon("alert")} Unsandboxed: agents' shells run unrestricted here
148 html`<div class="toast" data-tone="danger" role="alert" id="crew-unsandboxed">${icon("alert")}<div class="toast-body">Unsandboxed: agents' shells run unrestricted here
146149 (${crew.sandbox.reason}). They can read this forge's data and reach
147 any host.
148 </p>`,
149 { tone: "danger", extra: "mb", id: "crew-unsandboxed" },
150 ),
150 any host.</div></div>`,
151151 )}`;
152152 }
153153
@@ -327,7 +327,7 @@ function scorecardCard(
327327 ${s.meanLandMs === null
328328 ? dash
329329 : html`<td class="num">${spanText(s.meanLandMs)}</td>`}`}`;
330 return card(
330 return panel(
331331 html`<div class="table-scroll">
332332 <table class="table" id="crew-scorecard" data-sticky-lead>
333333 <thead>
@@ -378,18 +378,13 @@ function scorecardCard(
378378 </table>

Non-blocking, principle 1: this Scorecard note is a five-line paragraph that explains what the table columns already show. One sentence.

379379 </div>`,
380380 {
381 pad: false,
382 extra: "mt mb",
383 head: html`${icon("activity")}
384 <h2>Scorecard</h2>
385 <span class="muted small"
386 >Over the pulls each agent opened, and each team's members pooled (an
381 title: "Scorecard",
382 note: html`Over the pulls each agent opened, and each team's members pooled (an
387383 agent on two teams counts in both), in the last ${RECENT_DAYS} days
388384 and over all time: review rounds are change requests per landed pull, red checks
389385 the share of finished checks that failed; reverts (the landed pulls
390386 a merged pull took back) and time to land (the mean from opening to
391 merge) are all time.</span
392 >`,
387 merge) are all time.`,
393388 },
394389 );
395390 }
@@ -401,7 +396,7 @@ function spendingCard(
401396 ): Html {
402397 const total = spending.reduce((n, s) => n + s.status.spentTodayUsd, 0);
403398 const cap = spending.reduce((n, s) => n + s.status.ceilingUsd, 0);
404 return card(
399 return panel(
405400 html`<ul class="list">
406401 ${spending.map(
407402 ({ repo, status }) =>
@@ -436,18 +431,13 @@ function spendingCard(
436431 )}
437432 </ul>`,
438433 {
439 pad: false,
440 extra: "mb",
441 head: html`${icon("activity")}
442 <h2>Spending today</h2>
443 <span class="muted small"
444 >$${total.toFixed(0)} of $${cap.toFixed(0)} across ${spending.length}
434 title: "Spending today",
435 note: html`$${total.toFixed(0)} of $${cap.toFixed(0)} across ${spending.length}
445436 ${spending.length === 1 ? "app" : "apps"} running on their
446437 own${forgeCap === undefined
447438 ? "; no forge-wide ceiling is set (FORGE_CREW_CEILING_USD)"
448439 : `, under the forge's $${forgeCap.toFixed(0)} a day`};
449 the ceilings reset at 00:00 UTC</span
450 >`,
440 the ceilings reset at 00:00 UTC`,
451441 },
452442 );
453443 }
@@ -490,7 +480,7 @@ function modelReviewCard(ctx: Ctx): Html {
490480 const cell = (n: number | null, f: (n: number) => string) =>
491481 n === null ? html`<td class="num muted">—</td>` : html`<td class="num">${f(n)}</td>`;
492482 const asked = r.changes.filter((c) => c.state === "asked");
493 return card(
483 return panel(
494484 html`<p class="muted small">At most one change of model per agent a week, to the cheapest that holds its quality.
495485 A step up always asks; a step down ${r.limits.auto ? "applies on its own" : "asks too"} (Settings → Limits).
496486 Under ${r.limits.minLanded} landed changes nothing. A week after a change the result is reported, and the
@@ -573,10 +563,8 @@ function modelReviewCard(ctx: Ctx): Html {
573563 </table>
574564 </div>`,
575565 {
576 extra: "mt mb",
577 head: html`${icon("activity")}
578 <h2>Model review</h2>
579 <span class="muted small">Weekly, from the last ${r.windowDays} days of the ledger</span>`,
566 title: "Model review",
567 note: html`Weekly, from the last ${r.windowDays} days of the ledger`,
580568 },
581569 );
582570 }
@@ -618,12 +606,11 @@ function crewView(
618606 spendingCard(spending, forgeCeiling(ctx.app.config) ?? undefined),
619607 )}
620608 ${agents.length === 0
621 ? empty({
622 icon: "bot",
623 title: "No agents yet",
624 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.",
625 })
626 : card(
609 ? html`<section class="mb" id="crew-empty">
610 <h2 class="mb">No agents yet</h2>
611 <p class="muted">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.</p>
612 </section>`
613 : panel(
627614 html`<div class="table-scroll">
628615 <table class="table">
629616 <thead>
@@ -699,7 +686,7 @@ function crewView(
699686 </tbody>
700687 </table>
701688 </div>`,
702 { pad: false },
689 { title: "Agents", id: "crew-agents" },
703690 )}
704691 ${when(agents.length > 0, () =>
705692 scorecardCard(
@@ -730,8 +717,9 @@ function crewView(
730717 () => html`<span class="muted">not recorded</span>`,
731718 )}
732719 ${handedCard(handover.parked)}${drillCard(handover.drill)}
733 ${card(
734 html`<form method="post" action="/settings/crew">
720 <section class="mt" id="crew-new">
721 <h2>New agent</h2>
722 <form method="post" action="/settings/crew">
735723 ${uiField({
736724 label: "Username",
737725 name: "username",
@@ -752,18 +740,14 @@ function crewView(
752740 ${btn("Create agent", { variant: "primary", icon: "bot" })}
753741 ${btn("Teams", { href: "/settings/teams", variant: "ghost" })}
754742 </div>
755 </form>`,
756 {
757 head: html`${icon("bot")}
758 <h2>New agent</h2>`,
759 },
760 )}`;
743 </form>
744 </section>`;
761745 }
762746
763747 /** Issues the crew parked twice and the planner now holds; nothing when there are none. */
764748 function handedCard(parked: CrewHandover["parked"]): Html {
765749 if (parked.length === 0) return html``;
766 return card(
750 return panel(
767751 html`<div class="table-scroll"><table class="table">
768752 <thead>
769753 <tr>
@@ -788,10 +772,8 @@ function handedCard(parked: CrewHandover["parked"]): Html {
788772 </tbody>
789773 </table></div>`,
790774 {
791 pad: false,
792775 id: "crew-handed",
793 head: html`${icon("bot")}
794 <h2>Handed to the planner</h2>`,
776 title: "Handed to the planner",
795777 },
796778 );
797779 }
@@ -799,21 +781,19 @@ function handedCard(parked: CrewHandover["parked"]): Html {
799781 /** The last restore drill's verdict; nothing before a first drill. */

Non-blocking, principle 6: a failed drill should still read as failed without a box. Put the danger colour and the alert icon on the Failed word.

800782 function drillCard(drill: CrewHandover["drill"]): Html {
801783 if (drill === undefined) return html``;
802 return card(
784 return panel(
803785 html`<p>${drill.verdict === "green" ? "Passed" : "Failed"} ${time(drill.at)}.</p>`,
804786 {
805787 id: "crew-drill",
806 tone: drill.verdict === "green" ? "default" : "danger",
807788 attrs: html`data-drill="${drill.verdict}"`,
808 head: html`${icon("clock")}
809 <h2>Restore drill</h2>`,
789 title: "Restore drill",
810790 },
811791 );
812792 }
813793
814794 /** Hours the forge ran and pulls it landed, per day of the last week (#7645). */
815795 function keeperDaysCard(rows: readonly KeeperDay[]): Html {
816 return card(
796 return panel(
817797 html`<div class="table-scroll">
818798 <table class="table" id="crew-keeper-days">
819799 <thead>
@@ -834,14 +814,14 @@ function keeperDaysCard(rows: readonly KeeperDay[]): Html {
834814 </tbody>
835815 </table>
836816 </div>`,
837 { pad: false, head: html`<h2>Forge per day</h2>` },
817 { title: "Forge per day" },
838818 );
839819 }
840820
841821 /** Spend and landed pulls per model per day: the escalation, measured. */
842822 function modelDaysCard(rows: readonly ModelDay[]): Html {
843823 if (rows.length === 0) return html``;
844 return card(
824 return panel(
845825 html`<div class="table-scroll">
846826 <table class="table" id="crew-model-days">
847827 <thead>
@@ -866,14 +846,14 @@ function modelDaysCard(rows: readonly ModelDay[]): Html {
866846 </tbody>
867847 </table>
868848 </div>`,
869 { pad: false, head: html`<h2>Per model per day</h2>` },
849 { title: "Per model per day" },
870850 );
871851 }
872852
873853 /** Runs that ended on work not theirs to do, per day: the pull closed, nothing pushed, already done. */
874854 function wastedDaysCard(rows: readonly WastedDay[]): Html {
875855 if (rows.length === 0) return html``;
876 return card(
856 return panel(
877857 html`<div class="table-scroll">
878858 <table class="table" id="crew-wasted-days">
879859 <thead>
@@ -896,7 +876,7 @@ function wastedDaysCard(rows: readonly WastedDay[]): Html {
896876 </tbody>
897877 </table>
898878 </div>`,
899 { pad: false, head: html`<h2>Wasted runs per day</h2>` },
879 { title: "Wasted runs per day" },
900880 );
901881 }
902882
@@ -908,7 +888,7 @@ function splitsCard(
908888 blank: (key: string) => Html,
909889 ): Html {
910890 if (rows.length === 0) return html``;
911 return card(
891 return panel(
912892 html`<div class="table-scroll">
913893 <table class="table">
914894 <thead>
@@ -935,7 +915,7 @@ function splitsCard(
935915 </tbody>
936916 </table>
937917 </div>`,
938 { pad: false, head: html`<h2>${title}</h2>` },
918 { title },
939919 );
940920 }
941921
test/crew-language.test.ts added +86
@@ -0,0 +1,86 @@
1 /** Settings › Crew speaks the home page's language (#9826): one heading, the readiness words, a plain list, the form; no card. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { components } from "../src/web/style/components.ts";
7 import { createApp } from "../src/app.ts";
8 import { createAgent } from "../src/actions/crew.ts";
9 import { createSession } from "../src/core/sessions.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { serve } from "../src/http/server.ts";
12 import { promote } from "./helpers/members.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 let dir = "";
18 let app: ReturnType<typeof createApp>;
19 let servers: Awaited<ReturnType<typeof serve>>;
20 let admin: User;
21 let cookie = "";
22 let other = "";
23 const person = async (username: string, adminIfFirst = false): Promise<User> => {
24 const made = await createUser(app.db, {
25 origin: "seed",
26 trusted: true,
27 username,
28 email: `${username}@forge.test`,
29 adminIfFirst,
30 });
31 if (!made.ok) throw new Error(String(made.error));
32 return made.value;
33 };
34
35 beforeAll(async () => {
36 dir = await mkdtemp(join(tmpdir(), "forge-crew-lang-"));
37 app = createApp({ dataDir: dir, port: 0 });
38 servers = await serve(app);
39 admin = await person("alice", true);
40 promote(app.db, admin);
41 cookie = `forge_session=${createSession(app.db, admin.id).token}`;
42 const bob = await person("bob");
43 other = `forge_session=${createSession(app.db, bob.id).token}`;
44 });
45 afterAll(async () => {
46 servers.stop();
47 app.close();
48 await rm(dir, { recursive: true, force: true });
49 });
50
51 const crewMain = async (): Promise<string> => {
52 const res = await fetch(`${servers.url}/settings/crew`, { headers: { cookie }, redirect: "manual" });
53 expect(res.status).toBe(200);
54 const page = await res.text();
55 return page.slice(page.indexOf("<main"), page.indexOf("</main>"));
56 };
57 const speaksPlainly = (main: string): void => {
58 expect(main.match(/<h1[\s>]/g)?.length).toBe(1);
59 expect(main).not.toContain('class="card');
60 expect(main).not.toContain("card-body");
61 expect(main).toMatch(/runner is up|cannot run|ready to run the crew|hosted inside/);
62 expect(main).toContain('action="/settings/crew"');
63 // The danger notice (no runner in this test) wears a class the stylesheet really styles.
64 expect(main).toContain('class="toast" data-tone="danger" role="alert"');
65 expect(components).toContain('.toast[data-tone="danger"]');
66 expect(main).not.toContain('class="error');
67 };
68
69 test("with no agent: one heading, the readiness sentence, the empty sentence, the form", async () => {
70 const main = await crewMain();
71 speaksPlainly(main);
72 expect(main).toContain("No agents yet");
73 });
74
75 test("with an agent: its link is on the page, still no card", async () => {
76 const made = await createAgent(app, admin, { username: "bot", brief: "Be brief." });
77 expect(made.ok).toBe(true);
78 const main = await crewMain();
79 speaksPlainly(main);
80 expect(main).toContain('href="/settings/crew/bot"');
81 });
82
83 test("a non-admin still gets 404", async () => {
84 const res = await fetch(`${servers.url}/settings/crew`, { headers: { cookie: other }, redirect: "manual" });
85 expect(res.status).toBe(404);
86 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs