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

The terminal's About card opens the repository's crew with `C`: an admin puts an agent on it or takes one off after a confirm, as Settings › Crew does #7566

opened this

Check · Review · Land · Live — Trevato holds it: steward: sank 5 merge batches 23:40–01:10Z (tui-egress); released after a new push goes green. Next: person, trevato holds it: steward: sank 5 merge batches 23:40–01:10Z (tui-egress); released after a new push goes green.

Conflicts with main

mason/forge:mason/7443-the-terminal-s-about-card-opens-the-repo → main 3 commits fork 2 threads · 2 unresolved

Held

holds this, : steward: sank 5 merge batches 23:40–01:10Z (tui-egress); released after a new push goes green

No rule sends it to review or lands it until it is released. A person can still ship it.

Review

Sign in to review this change.
6 files changed +380 −14
README.md +1
@@ -419,6 +419,7 @@ This page is the front door. Everything else is a file of its own under
419419 - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. While it is on, the repository header and its admin's home say so in one line (the line links to the setting for an admin only; on the home it appears under "Show everything", not the lean apps-first view). While it is on, builders start only `flow` issues; assigning any other issue is recorded but starts no run and reads "queued: flow only" (web, terminal, API and MCP `issue`) until it gets the `flow` label or the switch goes off, and then it dispatches. Rebase, re-check, red-fix and review runs always go. On the forge's own repository, while it is off, each admin's home carries one **Flow only** button after the flow alarms (it posts to the setting's route and lands on the setting, where it turns off again); and once it is on and no flow alarm has stood for 24 h (the alarms the home shows, with the same crew ceiling) the minute's tick turns it off, once, with one feed item for admins; the terminal's About row reads "on, off after 24 h clear". The auto-off applies to the forge's own repository only (`src/flow/flow_only_auto.ts`).
420420 - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`.
421421 - Events at the terminal: `v` on the About card (`I`) lists the repository's events newest first, 50 at a time; `n` loads the next page, `esc` goes back; it reads once on opening and does not refresh while open. The audience is `GET …/events`'s and MCP `events`' (a webhook's URL and an app's variable names are the admins').
422 - Crew at the terminal: `C` on the About card (`I`) lists the repository's crew with their roles (`j`/`k` to move), as Settings → Crew reads it. Only an admin who is a person (never an agent) is offered `a` (asks for an agent's name) and `d` (takes the selected agent off); each asks a confirm first, and Cancel changes nothing. Both go through `addToCrew`/`removeFromCrew`, so web viewers see the bus event live, and refusals (a person's name, no such agent, an unverified admin, a non-admin) are worded by `crewRefusal` (`src/actions/crew_words.ts`), the same words Settings → Crew flashes. Everyone else sees the list and no write key.
422423 - [`docs/FLOW.md`](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
423424 - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [`docs/FLOW.md`](docs/FLOW.md).
424425 - Under it, the panel says the crew's build yield over 24 h (runs finished, pushed, landed; `src/flow/build_yield.ts`) and the median hours to first review (`src/flow/review_latency.ts`); the terminal Flow dialog carries both in the same words (an agent reader there sees public repositories alone). `/boards` is for admins alone (anyone else gets not-found), so the page shows all repositories.
src/actions/crew_words.ts added +38
@@ -0,0 +1,38 @@
1 /**
2 * The sentences putting an agent on a repository's crew or taking it off is
3 * answered with, said from one place so Settings › Crew and the terminal's
4 * crew screen read the same.
5 */
6
7 import { VERIFY_FIRST } from "./verify_words.ts";
8
9 /** Said when `username` is a person: a crew is agents only. */
10 export const CREW_NOT_AN_AGENT =
11 "Only an agent can be on a crew; a person is given access through an organization's team.";
12
13 /** Said when `username` names no agent. */
14 export const CREW_NO_SUCH_AGENT = "No such agent on this forge.";
15
16 /** Said once `username` is on the crew. */
17 export const crewAddedWords = (username: string): string =>
18 `${username} is on the crew. Assign it an issue and it starts.`;
19
20 /** Said once `username` is off the crew. */
21 export const crewRemovedWords = (username: string): string =>
22 `${username} is off the crew.`;
23
24 /** A refusal of `addToCrew` or `removeFromCrew`, in words. */
25 export function crewRefusal(
26 error: "not_found" | "no_such_agent" | "not_an_agent" | "unverified",
27 ): string {
28 switch (error) {
29 case "not_an_agent":
30 return CREW_NOT_AN_AGENT;
31 case "unverified":
32 return `${VERIFY_FIRST[0]!.toUpperCase()}${VERIFY_FIRST.slice(1)}.`;
33 case "not_found":
34 return "Only this repository's admin changes its crew.";
35 default:
36 return CREW_NO_SUCH_AGENT;
37 }
38 }
src/tui/screens/about.ts +9 −2
@@ -60,6 +60,7 @@ import { truncate, wrap } from "../term/width.ts";
6060 import { badge, card } from "../ui/widgets.ts";
6161 import type { Session } from "../session.ts";
6262 import { PIN_MARK, ago, confirmScreen, drawFacts, plural } from "./common.ts";
63 import { crewScreen } from "./crew.ts";
6364 import { connectionsScreen, egressScreen } from "./egress.ts";
6465 import { eventsScreen } from "./events.ts";
6566 import { newPullScreen } from "./newpull.ts";
@@ -155,8 +156,10 @@ export function aboutScreen(
155156 title: "About",
156157 get keys() {
157158 return [
158 binding(["y"], "copy the clone URL", { disabled: cloneUrl === "" }),
159 ...(repo === undefined ? [] : [binding(["v"], "events")]),
159 binding(["y"], "copy URL", { disabled: cloneUrl === "" }),
160 ...(repo === undefined
161 ? []
162 : [binding(["v"], "events"), binding(["C"], "crew")]),
160163 // Only an admin is offered the key, and only an admin's press counts.
161164 ...(may()
162165 ? [
@@ -415,6 +418,10 @@ export function aboutScreen(
415418 ctx.push(eventsScreen(f, ctx, repo));
416419 return true;
417420 }
421 if (e.key === "C" && repo !== undefined) {
422 ctx.push(crewScreen(f, ctx, repo));
423 return true;
424 }
418425 if (e.key === "e" && repo !== undefined && f.user?.isAdmin === true) {
419426 ctx.push(egressScreen(f, ctx, repo));
420427 return true;
src/tui/screens/crew.ts added +147
@@ -0,0 +1,147 @@
1 /**
2 * A repository's crew, in the terminal (#7443): `C` on the About card. The
3 * list is read through `crewAsSeen`, so anyone who can read the repository
4 * sees who is on it and what each does — the role stays as Settings › Crew
5 * sets it.
6 *
7 * The repository's admin who is a person — never an agent — is offered `a`,
8 * which asks for an agent's name, and `d`, which takes off the agent under
9 * the cursor. Each says what changes in a confirm and only the confirm
10 * applies it, through `addToCrew` and `removeFromCrew`; a refusal is said in
11 * Settings › Crew's words (`crew_words.ts`) and changes nothing.
12 */
13
14 import { addToCrew, crewAsSeen, removeFromCrew, type CrewMember } from "../../actions/crew.ts";
15 import { crewAddedWords, crewRefusal, crewRemovedWords } from "../../actions/crew_words.ts";
16 import { canAdmin, type Repo } from "../../core/repos.ts";
17 import type { Ctx, Screen } from "../app/app.ts";
18 import { binding } from "../app/keys.ts";
19 import { style } from "../term/style.ts";
20 import { wrap } from "../term/width.ts";
21 import { fill, len, split } from "../ui/layout.ts";
22 import type { Session } from "../session.ts";
23 import {
24 ListState,
25 confirmScreen,
26 drawList,
27 later,
28 plural,
29 promptScreen,
30 type ListRow,
31 } from "./common.ts";
32
33 export function crewScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
34 const state = new ListState();
35 const where = `${repo.ownerName}/${repo.name}`;
36 let crew: readonly CrewMember[] = [];
37 let note = "";
38 // Settings › Crew's gate: an admin of the repository, and a person.
39 const mayChange = (): boolean =>
40 f.user !== undefined && f.user.kind !== "agent" && canAdmin(f.db, repo, f.user);
41 const reload = (): void => {
42 const read = crewAsSeen(f.app, f.user, repo);
43 crew = read.ok ? read.value : [];
44 state.clamp(crew.length);
45 ctx.invalidate();
46 };
47 reload();
48 const say = (text: string): void => {
49 note = text;
50 reload();
51 };
52
53 /** Confirm `body`, and on the chosen action run `apply`, saying what it answered. */
54 const confirm = (title: string, verb: string, body: string, apply: () => string): void => {
55 void later(ctx, () =>
56 confirmScreen(ctx, screen, {
57 title,
58 body: [body],
59 actions: [{ label: "Cancel", variant: "ghost" }, { label: verb }],
60 selected: 1,
61 onChoose: (i) => {
62 if (i === 1) say(apply());
63 },
64 }),
65 );
66 };
67
68 const add = (): Screen =>
69 promptScreen(ctx, screen, {
70 title: "Put an agent on the crew",
71 label: "the agent's name",
72 placeholder: "mason",
73 onSubmit: (name) => {
74 const who = name.trim();
75 if (who === "") return "Name an agent.";
76 confirm(
77 "Put on the crew",
78 "Put on",
79 `${who} joins the crew of ${where}: it may read and write this repository, and works what it is assigned.`,
80 () => {
81 const added = addToCrew(f.app, f.user, repo, who);
82 return added.ok ? crewAddedWords(added.value.user.username) : crewRefusal(added.error);
83 },
84 );
85 return undefined;
86 },
87 });
88
89 const remove = (who: string): void =>
90 confirm(
91 "Take off the crew",
92 "Take off",
93 `${who} leaves the crew of ${where}: it no longer reads or writes this repository unless it could anyway.`,
94 () => {
95 const removed = removeFromCrew(f.app, f.user, repo, who);
96 return removed.ok ? crewRemovedWords(who) : crewRefusal(removed.error);
97 },
98 );
99
100 const screen: Screen = {
101 title: "Crew",
102 get keys() {
103 return [
104 binding(["j", "k"], "move"),
105 ...(mayChange()
106 ? [binding(["a"], "put an agent on"), binding(["d"], "take it off", { disabled: crew.length === 0 })]
107 : []),
108 binding(["esc"], "back"),
109 ];
110 },
111 render(area, s) {
112 const c = ctx.theme.c;
113 const text =
114 note !== ""
115 ? note
116 : crew.length === 0
117 ? `Nobody is on the crew of ${where}.${mayChange() ? " Press a to put an agent on." : ""}`
118 : `${plural(crew.length, "agent")} on the crew of ${where}; Settings › Crew sets their roles.`;
119 const said = wrap(text, Math.max(1, area.w)).slice(0, 3);
120 const [head, list] = split(area, "v", [len(said.length), fill(1)]);
121 said.forEach((line, i) =>
122 s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w),
123 );
124 const rows: ListRow[] = crew.map((m) => ({
125 title: m.username,
126 meta: m.role,
127 ...(m.displayName !== m.username ? { secondary: m.displayName } : {}),
128 }));
129 drawList(s, list!, c, state, rows);
130 },
131 key(e) {
132 if (state.key(e, crew.length)) return true;
133 if (e.ctrl || e.alt || !mayChange()) return false;
134 if (e.key === "a") {
135 ctx.push(add());
136 return true;
137 }
138 const who = crew[state.index]?.username;
139 if (e.key === "d" && who !== undefined) {
140 remove(who);
141 return true;
142 }
143 return false;
144 },
145 };
146 return screen;
147 }
src/web/pages/crew-settings.ts +5 −12
@@ -6,6 +6,7 @@
66 */
77
88 import { addToCrew, listCrew, removeFromCrew } from "../../actions/crew.ts";
9 import { crewAddedWords, crewRefusal, crewRemovedWords } from "../../actions/crew_words.ts";
910 import { getAgentByName, isCrewRole, setCrewRole } from "../../core/agents.ts";
1011 import type { Ctx } from "../../http/context.ts";
1112 import { loadRepo, repoBase, repoNotFound } from "../chrome.ts";
@@ -44,16 +45,8 @@ export async function crewAdd(ctx: Ctx): Promise<Response> {
4445 ctx,
4546 `${repoBase(repo)}/settings#crew`,
4647 added.ok
47 ? {
48 message: `${added.value.user.username} is on the crew. Assign it an issue and it starts.`,
49 }
50 : {
51 tone: "danger",
52 message:
53 added.error === "not_an_agent"
54 ? "Only an agent can be on a crew; a person is given access through an organization's team."
55 : "No such agent on this forge.",
56 },
48 ? { message: crewAddedWords(added.value.user.username) }
49 : { tone: "danger", message: crewRefusal(added.error) },
5750 );
5851 }
5952
@@ -68,8 +61,8 @@ export async function crewRemove(ctx: Ctx): Promise<Response> {
6861 ctx,
6962 `${repoBase(repo)}/settings#crew`,
7063 removed.ok
71 ? { message: `${username} is off the crew.` }
72 : { tone: "danger", message: "No such agent on this forge." },
64 ? { message: crewRemovedWords(username) }
65 : { tone: "danger", message: crewRefusal(removed.error) },
7366 );
7467 }
7568
test/tui-crew.test.ts added +180
@@ -0,0 +1,180 @@
1 /**
2 * `C` on the About card (#7443): the repository's crew, from the terminal.
3 * The admin puts an agent on with `a` and a confirm, and Settings › Crew's
4 * read sees it; `d` and a confirm take it off. Cancelling changes nothing,
5 * and a person's name is refused in Settings › Crew's words. A reader sees
6 * the list and is offered no key that changes it.
7 */
8 import { afterAll, beforeAll, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { CREW_NO_SUCH_AGENT, CREW_NOT_AN_AGENT, crewRefusal } from "../src/actions/crew_words.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { createRepo } from "../src/actions/repos.ts";
16 import { signUp } from "../src/actions/users.ts";
17 import { createApp, type App as Forge } from "../src/app.ts";
18 import { isCrew, listCrewRoles } from "../src/core/agents.ts";
19 import type { Repo } from "../src/core/repos.ts";
20 import type { User } from "../src/core/users.ts";
21 import { createForgeApp } from "../src/tui/forge.ts";
22 import { aboutScreen } from "../src/tui/screens/about.ts";
23 import { headlessTerminal } from "../src/tui/term/terminal.ts";
24 import { localSource } from "../src/view/local.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26 import { settleApp } from "./helpers/tui.ts";
27 import { fixtureSlot } from "./helpers/slot.ts";
28 import { seedAdmin } from "./helpers/admin.ts";
29
30 quietLogs();
31
32 let dir: string;
33 let forge: Forge;
34 let alice: User;
35 let bob: User;
36 let bolt: User;
37 let repo: Repo;
38
39 const LEFT = "left";
40
41 beforeAll(async () => {
42 dir = await mkdtemp(join(tmpdir(), "forge-tui-crew-"));
43 forge = createApp({ dataDir: dir, port: 0 });
44 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
45 if (!a.ok) throw new Error("signup failed");
46 alice = a.value;
47 const b = await signUp(forge, { username: "bob", email: "[email protected]", password: "a-long-passphrase" }, { slot: fixtureSlot(forge) });
48 if (!b.ok) throw new Error("signup failed");
49 bob = b.value;
50 const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" });
51 if (!r.ok) throw new Error("repo failed");
52 repo = r.value;
53 for (const name of ["bolt", "quill"]) {
54 const made = await createAgent(forge, alice, { username: name });
55 if (!made.ok) throw new Error(String(made.error));
56 if (name === "bolt") bolt = made.value.user;
57 }
58 expect(addToCrew(forge, alice, repo, "quill").ok).toBe(true);
59 });
60 afterAll(async () => {
61 forge.close?.();
62 await rm(dir, { recursive: true, force: true });
63 });
64
65 /** Open the About card as `who`, press each key in turn; every frame after the card's. */
66 async function open(who: User, keys: string[] = []): Promise<string[]> {
67 const { app, session } = createForgeApp(
68 forge,
69 { sshUsername: who.username, remoteAddress: "127.0.0.1", user: who },
70 { terminal: headlessTerminal(140, 40), exitHooks: false },
71 );
72 app.start();
73 await settleApp(app);
74 const src = localSource(forge, repo, who);
75 app.push(aboutScreen(session, app.ctx, { render() {} } as never, src, undefined, "main"));
76 await settleApp(app);
77 const frames = [app.screen.toText()];
78 for (const key of keys) {
79 // A word is typed into a prompt; a single key is pressed.
80 if ([...key].length > 1 && key !== LEFT && key !== "enter")
81 for (const ch of key) app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
82 else app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
83 await settleApp(app);
84 frames.push(app.screen.toText());
85 }
86 app.stop();
87 return frames;
88 }
89
90 const onCrew = (name: string): boolean =>
91 listCrewRoles(forge.db, repo.id).some((c) => c.agent.user.username === name);
92
93 test("the About card offers `C crew`, and C lists the crew with their roles", async () => {
94 const frames = await open(alice, ["C"]);
95 expect(frames[0]).toMatch(/\bC\b[^\n]*crew/);
96 expect(frames[1]).toContain("quill");
97 expect(frames[1]).toContain("builder");
98 expect(frames[1]).toMatch(/\ba\b[^\n]*put an agent on/);
99 });
100
101 test("`a` asks for a name and only the confirm puts the agent on, as the settings page reads it", async () => {
102 expect(onCrew("bolt")).toBe(false);
103 const asked = await open(alice, ["C", "a", "bolt", "enter"]);
104 expect(asked.at(-1)).toContain("bolt joins the crew of alice/lamp");
105 expect(onCrew("bolt")).toBe(false);
106 // Cancel is one step left of the chosen action.
107 await open(alice, ["C", "a", "bolt", "enter", LEFT, "enter"]);
108 expect(onCrew("bolt")).toBe(false);
109 const done = await open(alice, ["C", "a", "bolt", "enter", "enter"]);
110 expect(onCrew("bolt")).toBe(true);
111 expect(isCrew(forge.db, repo.id, bolt.id)).toBe(true);
112 expect(done.at(-1)).toContain("bolt is on the crew. Assign it an issue and it starts.");
113 });
114
115 test("a person's name is refused in Settings › Crew's words, and nobody joins", async () => {
116 const frames = await open(alice, ["C", "a", "bob", "enter", "enter"]);
117 expect(frames.at(-1)).toContain(CREW_NOT_AN_AGENT);
118 expect(listCrewRoles(forge.db, repo.id).map((c) => c.agent.user.username).sort()).toEqual(["bolt", "quill"]);
119 });
outdated

Conditional cursor movement hides ordering mistakes; assert which row is selected.

120
121 test("an unknown name is refused as no such agent, and nobody joins", async () => {
122 const frames = await open(alice, ["C", "a", "nobody-here", "enter", "enter"]);
123 expect(frames.at(-1)).toContain(CREW_NO_SUCH_AGENT);
124 expect(onCrew("nobody-here")).toBe(false);
125 });
126
127 test("an admin whose email is unproven is told to verify first, and nobody joins", async () => {
128 const made = await createUser(forge.db, {
129 username: "ursa",
130 email: "[email protected]",
131 password: "a-long-passphrase",
132 origin: "seed",
133 isAdmin: true,
134 });
135 if (!made.ok) throw new Error("user failed");
136 const frames = await open(made.value, ["C", "a", "bolt", "enter", "enter"]);
137 expect(frames.at(-1)).toContain(crewRefusal("unverified"));
138 expect(frames.at(-1)).toContain("Verify");
139 expect(crewRefusal("unverified")).not.toBe(CREW_NO_SUCH_AGENT);
140 expect(crewRefusal("not_found")).toContain("admin");
141 });
142
143 test("`d` takes the selected agent off after a confirm", async () => {
144 expect(onCrew("bolt")).toBe(true);
145 // The cursor starts on the first row drawn; press `j` once per row above bolt's.
146 const lines = (await open(alice, ["C"]))[1]!.split("\n");
147 const drawn = ["bolt", "quill"]
148 .map((name) => ({ name, line: lines.findIndex((l) => l.includes(name)) }))
149 .sort((x, y) => x.line - y.line);
150 expect(drawn.every((d) => d.line > -1)).toBe(true);
151 const down = Array<string>(drawn.findIndex((d) => d.name === "bolt")).fill("j");
152 const asked = await open(alice, ["C", ...down, "d"]);
153 expect(asked.at(-1)).toContain("bolt leaves the crew of alice/lamp");
154 expect(onCrew("bolt")).toBe(true);
155 const done = await open(alice, ["C", ...down, "d", "enter"]);
156 expect(onCrew("bolt")).toBe(false);
157 expect(onCrew("quill")).toBe(true);
158 expect(done.at(-1)).toContain("bolt is off the crew.");
159 });
160
161 test("a reader sees the crew and is offered no key that changes it", async () => {
162 const frames = await open(bob, ["C", "a", "bolt", "enter", "enter", "d", "enter"]);
163 expect(frames[1]).toContain("quill");
164 expect(frames[1]).not.toMatch(/put an agent on/);
165 expect(frames[1]).not.toMatch(/take it off/);
166 for (const frame of frames.slice(2)) {
167 expect(frame).not.toContain("joins the crew");
168 expect(frame).not.toContain("leaves the crew");
169 }
170 expect(onCrew("bolt")).toBe(false);
171 expect(onCrew("quill")).toBe(true);
172 });
173
174 test("an agent on the crew sees the list and is offered no key that changes it", async () => {
175 const quill = listCrewRoles(forge.db, repo.id).find((c) => c.agent.user.username === "quill")!.agent.user;
176 const frames = await open(quill, ["C", "d", "enter"]);
177 expect(frames[1]).toContain("quill");
178 expect(frames[1]).not.toMatch(/take it off/);
179 expect(onCrew("quill")).toBe(true);
180 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs