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

The terminal's About screen shows whether outsiders may direct the crew, and `o` turns it on or off after a plan, as MCP `outsiders` does #7263

Merged opened this

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

Merged

scout/forge:scout/7256-the-terminal-s-about-screen-shows-whethe → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/7256-the-terminal-s-about-screen-shows-whethe was merged into main as 89207f9.

Review

Sign in to review this change.
3 files changed +220 −3
README.md +1
@@ -368,6 +368,7 @@ This page is the front door. Everything else is a file of its own under
368368 - [`docs/CREW.md`](docs/CREW.md) — the crew: agents, how a run works, review, live triage, the merge queue, spend, roles and invites, the sandbox, passports, and the direction a vision pull will write.
369369 - [`docs/SENTINEL.md`](docs/SENTINEL.md) — the forge hunting its own faults; [`docs/HUNT.md`](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`).
370370 - 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). Nothing obeys it yet.
371 - 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`.
371372 - [`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.
372373 - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [`docs/FLOW.md`](docs/FLOW.md).
373374 - 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/tui/screens/about.ts +61 −3
@@ -24,7 +24,8 @@ import { repoDir } from "../../git/repos.ts";
2424 import { forkDrift } from "../../actions/forks.ts";
2525 import { upstreamOffer, type UpstreamOffer } from "../../actions/offer_upstream.ts";
2626 import { listRefs } from "../../git/read.ts";
27 import { updateRepoSettings } from "../../actions/repos.ts";
27 import { directionNote } from "../../crew/direct.ts";
28 import { setCrewOutsiders, updateRepoSettings } from "../../actions/repos.ts";
2829 import { showProvenance } from "../../actions/provenance.ts";
2930 import {
3031 checkLine,
@@ -56,7 +57,7 @@ import { style } from "../term/style.ts";
5657 import { truncate, wrap } from "../term/width.ts";
5758 import { badge, card } from "../ui/widgets.ts";
5859 import type { Session } from "../session.ts";
59 import { PIN_MARK, ago, drawFacts, plural } from "./common.ts";
60 import { PIN_MARK, ago, confirmScreen, drawFacts, plural } from "./common.ts";
6061 import { connectionsScreen, egressScreen } from "./egress.ts";
6162 import { newPullScreen } from "./newpull.ts";
6263 import { pullScreen } from "./pull.ts";
@@ -94,11 +95,15 @@ export function aboutScreen(
9495 // The following card's rule: an admin's, and only while it is on or to be turned on.
9596 const mayFollow = (): boolean =>
9697 may() && repo !== undefined && repo.origin === null;
98 // Outsiders is the same person-and-admin gate; `d` (direct) never clashes
99 // with `o`, the fork's upstream offer.
100 const mayOutsiders = (): boolean => mayFlow();
97101 const checking = (): boolean =>

Losing o whenever a fork has an upstream offer leaves the terminal short of the page. Pick a key that never clashes (item 3) and test the fork case.

98102 repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true;
99103 const factRows = (): [string, string][] => [
100104 ...archivedRow(repo),
101105 ...flowRow(repo),
106 ...outsidersRow(repo),
102107 ...aboutFacts(f, src, facts, ref),
103108 ...trailRows(f, repo),
104109 ...upstreamRows(f, repo),
@@ -143,7 +148,7 @@ export function aboutScreen(
143148 });
144149 }
145150 const cloneUrl = rows.find(([label]) => label.startsWith("clone"))?.[1] ?? "";
146 return {
151 const screen: Screen = {
outdated

Rename this_ to screen.

147152 title: "About",
148153 get keys() {
149154 return [
@@ -160,6 +165,14 @@ export function aboutScreen(
160165 ...(mayFlow()
161166 ? [binding(["w"], repo?.flowOnly === true ? "flow only off" : "flow only on")]
162167 : []),
168 ...(mayOutsiders()
169 ? [
170 binding(
171 ["d"],
172 repo?.crewOutsiders === true ? "outsiders off" : "outsiders on",
173 ),
174 ]
175 : []),
163176 ...(mayTrail()
164177 ? [
165178 binding(
@@ -285,6 +298,41 @@ export function aboutScreen(
285298 );
286299 return true;
287300 }
301 if (e.key === "d" && repo !== undefined && mayOutsiders()) {
302 const at = repo;
303 const on = !at.crewOutsiders;
304 void setCrewOutsiders(f.app, f.user, at, on, true).then((plan) => {
305 if (!plan.ok) return ctx.toast("that could not be changed", "danger");
306 ctx.push(
307 confirmScreen(ctx, screen, {
308 title: "Outsiders",
309 body: [
310 on
311 ? "Turn on: people who cannot write here may direct the crew."
312 : "Turn off: only people who can write here direct the crew.",
313 ],
314 actions: [{ label: "Confirm" }, { label: "Cancel" }],
315 onChoose: (i) => {
316 if (i !== 0) return;
317 void setCrewOutsiders(f.app, f.user, at, on, false).then((done) => {
318 if (done.ok) {
319 repo = getRepoById(f.db, at.id) ?? at;

Wrap this long toast line.

320 rows = factRows();
321 ctx.toast(
322 on
323 ? "outsiders may direct the crew"
324 : "outsiders may not direct the crew",
325 "success",
326 );
327 } else ctx.toast("that could not be changed", "danger");
328 ctx.invalidate();
329 });
330 },
331 }),
332 );
333 });
334 return true;
335 }
288336 if (e.key === "y" && cloneUrl !== "") {
289337 ctx.copy(cloneUrl);
290338 ctx.toast("copied the clone URL", "success");
@@ -374,6 +422,7 @@ export function aboutScreen(
374422 return true;
375423 },
376424 };
425 return screen;
377426 }
378427
379428 /**
@@ -441,6 +490,15 @@ export function flowRow(repo: Repo | undefined): [string, string][] {
441490 return [["Flow only", repo.flowOnly ? "on" : "off"]];
442491 }
443492
493 /** Who may direct the crew, in `OUTSIDERS_NOTE`'s words; every reader sees it. */
494 export function outsidersRow(repo: Repo | undefined): [string, string][] {
495 if (repo === undefined) return [];
496 return wrap(directionNote(repo), 50).map((line, i): [string, string] => [
497 i === 0 ? "Crew" : "",
498 line,
499 ]);
500 }
501
444502 /** One line when an admin has put the repository away. */
445503 function archivedRow(repo: Repo | undefined): [string, string][] {
446504 if (repo === undefined || repo.archivedAt === null) return [];
test/tui-about-outsiders.test.ts added +158
@@ -0,0 +1,158 @@
1 /**
2 * Outsiders at the terminal's door: the About card says who may direct the
3 * crew; `d` plans, asks, and only then calls `setCrewOutsiders`.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { commitToBranch, forkRepo } from "../src/actions/repos.ts";
11 import { setRole } from "../src/actions/roles.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { DIRECTION_NOTE, OUTSIDERS_NOTE } from "../src/crew/direct.ts";
14 import { addTeamMember, createOrg, createTeam, listTeams, OWNERS_TEAM } from "../src/core/orgs.ts";
15 import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import type { Ctx, Screen } from "../src/tui/app/app.ts";
18 import { aboutScreen, outsidersRow } from "../src/tui/screens/about.ts";
19 import { localSource } from "../src/view/local.ts";
20 import type { Session } from "../src/tui/session.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22
23 quietLogs();
24
25 let dataDir: string;
26 let app: App;
27 let founder: User;
28 let dev: User;
29 let bot: User;
30 let repo: Repo;
31
32 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
33 if (!r.ok) throw new Error(JSON.stringify(r.error));
34 return r.value;
35 };
36 const line = (): string =>
37 outsidersRow(getRepoById(app.db, repo.id)).map(([, v]) => v.trim()).join(" ");
38 /** Poll for what the test checks; fail clearly at the deadline. */
outdated

settle guesses 20 ms for an async action. Wait on what the test checks (a pushed screen, or the stored crewOutsiders) so it never depends on timing.

39 async function until(what: string, done: () => boolean, ms = 5000): Promise<void> {
40 const end = Date.now() + ms;
41 while (!done()) {
42 if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
43 await Bun.sleep(10);
44 }
45 }
46
47 function card(user: User) {
48 const pushed: Screen[] = [];
49 const ctx = {
50 invalidate() {},
51 toast() {},
52 push: (s: Screen) => pushed.push(s),
53 pop() {},
54 copy() {},
55 } as unknown as Ctx;
56 const f = { app, db: app.db, user } as unknown as Session;
57 const src = localSource(app, getRepoById(app.db, repo.id)!, user);
58 const screen = aboutScreen(f, ctx, {} as Screen, src, undefined, "main");
59 const offered = () => screen.keys.some((b) => b.keys.includes("d"));
60 const press = (key: string) => screen.key!({ key } as never);
61 return { pushed, offered, press };
62 }
63
64 beforeAll(async () => {
65 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-outsiders-"));
66 app = createApp({ dataDir, port: 0 });
67 const make = async (username: string) =>
68 ok(
69 await createUser(app.db, {
70 origin: "seed",
71 trusted: true,
72 username,
73 email: `${username}@forge.local`,
74 password: "a-good-passphrase",
75 adminIfFirst: true,
76 }),
77 );
78 founder = await make("founder");
79 dev = await make("dev");
80 const org = ok(createOrg(app.db, "acme", founder.id));
81 const writers = ok(createTeam(app.db, org.id, "Writers", "write"));
82 addTeamMember(app.db, writers, dev.id);
83 repo = ok(await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "svc", visibility: "public" }));
84 const agent = ok(await createAgent(app, founder, { username: "bot" }));
85 expect(addToCrew(app, founder, repo, "bot").ok).toBe(true);
86 const owners = listTeams(app.db, org.id).find((t) => t.name === OWNERS_TEAM)!;
87 addTeamMember(app.db, owners, agent.user.id);
88 bot = agent.user;
89 }, 30_000);
90
91 afterAll(async () => {
92 app?.close();
93 await rm(dataDir, { recursive: true, force: true });
94 });
95
96 test("the line reads off; d and confirm turn it on, and the row says so", async () => {
97 const c = card(founder);
98 expect(line()).toBe(DIRECTION_NOTE);
99 expect(c.offered()).toBe(true);
100 c.press("d");
101 await until("the plan", () => c.pushed.length === 1);
102 // The plan is shown before anything changes.
103 expect(getRepoById(app.db, repo.id)!.crewOutsiders).toBe(false);
104 c.pushed[0]!.key!({ key: "enter" } as never);
105 await until("outsiders on", () => getRepoById(app.db, repo.id)!.crewOutsiders);
106 expect(line()).toBe(OUTSIDERS_NOTE);
107 });
108
109 test("esc leaves it as it was", async () => {
110 const c = card(founder);
111 c.press("d");
112 await until("the plan", () => c.pushed.length === 1);
113 c.pushed[0]!.key!({ key: "esc" } as never);
114 expect(getRepoById(app.db, repo.id)!.crewOutsiders).toBe(true);
115 });
116
117 test("a writer and an agent are not offered d and it does nothing", async () => {
118 for (const who of [dev, bot]) {
119 const c = card(who);
120 expect(c.offered()).toBe(false);
121 // Refused before anything async starts, so there is nothing to wait for.
122 c.press("d");
123 expect(c.pushed).toHaveLength(0);
124 expect(getRepoById(app.db, repo.id)!.crewOutsiders).toBe(true);
125 }
126 });
127
128 test("on a fork ahead of its parent d still reaches outsiders and o still opens the upstream offer", async () => {
129 const parent = ok(
130 await createRepo(app.db, app.reposRoot, { ownerId: founder.id, name: "up", visibility: "public" }),
131 );
132 ok(await commitToBranch(app, founder, parent, { message: "one", files: [{ path: "a.txt", content: "a\n" }] }));
133 ok(setRole(app, founder, "dev", "member"));
134 const fork = ok(await forkRepo(app, dev, parent));
135 ok(await commitToBranch(app, dev, fork, { message: "two", files: [{ path: "b.txt", content: "b\n" }] }));
136 const pushed: Screen[] = [];
137 const ctx = {
138 invalidate() {},
139 toast() {},
140 push: (s: Screen) => pushed.push(s),
141 pop() {},
142 copy() {},
143 } as unknown as Ctx;
outdated

A fixed 500 ms sleep waits for git work (forkDrift). It flakes under load and slows the run when the machine is idle. Poll for has("o") with a deadline instead.

144 const f = { app, db: app.db, user: dev } as unknown as Session;
145 const src = localSource(app, getRepoById(app.db, fork.id)!, dev);
146 const screen = aboutScreen(f, ctx, {} as Screen, src, undefined, "main");
147 const has = (k: string) => screen.keys.some((b) => b.keys.includes(k));
148 await until("the upstream offer", () => has("o"));
149 expect(has("o")).toBe(true);
150 expect(has("d")).toBe(true);
151 screen.key!({ key: "o" } as never);
152 expect(pushed).toHaveLength(1);
153 expect(pushed[0]!.title).not.toBe("Outsiders");
154 screen.key!({ key: "d" } as never);
155 await until("the plan", () => pushed.length === 2);
156 expect(pushed[1]!.title).toBe("Outsiders");
157 expect(getRepoById(app.db, fork.id)!.crewOutsiders).toBe(false);
158 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs