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

A repository has a "flow only" switch: one setting, readable and writable by its admin from Settings, the API and MCP #6940

Merged opened this

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

Merged

scout/forge:scout/6392-a-repository-has-a-flow-only-switch-one → main 8 commits fork 5 threads · 5 unresolved

  • Merged. scout/6392-a-repository-has-a-flow-only-switch-one was merged into main as 160ba9a.

Review

Sign in to review this change.
16 files changed +411 −6
README.md +1
@@ -359,6 +359,7 @@ This page is the front door. Everything else is a file of its own under
359359 - [`docs/WEBSPEC-PREVIEWS.md`](docs/WEBSPEC-PREVIEWS.md) — the website specification run over every preview, report-only.
360360 - [`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.
361361 - [`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>`).
362 - 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. Nothing obeys it yet.
362363 - [`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.
363364 - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [`docs/FLOW.md`](docs/FLOW.md).
364365 - 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.
docs/MCP.md +1 −1
@@ -122,7 +122,7 @@ for a tunnel and a forge that sleeps.
122122 | `releases` | `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page | |
123123 | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | |
124124 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default; a read-only token may), create (`create`, `name`, `color` six hex digits) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create and remove need an admin token and a repository admin | yes |
125 | `repo_settings` | `{ repo, description?, visibility? }` (admin): change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }`; a visibility other than public or private is `invalid` | yes |
125 | `repo_settings` | `{ repo, description?, visibility?, flowOnly? }` (admin): change a repository's description, visibility and FLOW ONLY switch as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility, flowOnly }` (with none to change it only reads); a visibility other than public or private is `invalid` | yes |
126126 | `reports` | `{ repo, id?, action? }`: the waiting reports from outsiders, each `from: "outside"` (their words are unchecked); with `id` and `action` (`accept` opens it as an issue marked as from outside, `dismiss` closes it). A person who writes the repository only; needs an `rw` token | yes |
127127 | `restore_drill` | no input: restore the newest snapshot as a drill and answer `{ verdict, failedStep?, says }`, worded as the Backups page's button; no store, a failure or a red drill is an answer, not an error; a forge admin's, with an admin token | yes |
128128 | `returns` | `{ repo?, days? }`: what the crew's tokens bought over the last `days` (1–90, default 7): dollars, runs, changes landed, cost per change; `repo` narrows it to one app of yours | |
docs/STATUS.md +4
@@ -123,6 +123,10 @@
123123 - **The crew** — agents as accounts with nothing but a run-scoped token,
124124 working issues in the background with Claude Code and proposing through
125125 pulls a person merges.
126 - **Flow only** — a repository's admin (a person, never an agent) sets one
127 switch from Settings, `PATCH /api/v1/repos/:owner/:name` or MCP
128 `repo_settings`, or the terminal's About card (`w`); it makes the `flow`
129 label. Nothing obeys it yet.
outdated

Both doors: the terminal is a door like the page, and it already sets repository settings (src/tui/screens/about.ts, archive). Give Flow only a row and an admin-only toggle there, then drop this clause.

126130
127131 Pages render without a line of JavaScript and are designed thumb-first. Six
128132 premade themes (from warm paper to polar night) switch instantly and persist in
src/actions/repos.ts +11
@@ -18,6 +18,7 @@ import { storageChanged, storageRefusal, storageSentence } from "../core/storage
1818 import {
1919 createLabel as insertLabel,
2020 deleteLabel as removeLabel,
21 getLabel,
2122 updateLabel as writeLabel,
2223 type Label,
2324 } from "../core/issues.ts";
@@ -333,6 +334,9 @@ function moveArchive(app: App, repoId: string, archived: boolean): Promise<boole
333334 });
334335 }
335336
337 /** The label FLOW ONLY puts in a repository. */
338 const FLOW_LABEL = "flow";
339
336340 /**
337341 * Change settings. A new default branch must exist; git HEAD is moved first
338342 * and the row follows (git is truth, the index is derived from it).
@@ -352,6 +356,7 @@ export async function updateRepoSettings(
352356 (settings.crewOutsiders !== undefined ||
353357 settings.decisionsBy !== undefined ||
354358 settings.plansNeedNod !== undefined ||
359 settings.flowOnly !== undefined ||
355360 settings.archived !== undefined) &&
356361 admin.value.kind === "agent"
357362 )
@@ -370,10 +375,15 @@ export async function updateRepoSettings(
370375 }
371376 const { archived, ...rest } = settings;
372377 writeSettings(app.db, repo, rest);
378 // FLOW ONLY names its label (the label it makes is not in `changed`, below): on, the repository has a `flow` to put on work.
379 if (settings.flowOnly === true && getLabel(app.db, repo.id, FLOW_LABEL) === undefined)

This calls the core insertLabel directly, so the label is created with no event of its own on the bus. A labels list someone already has open will not show flow until they reload. If actions/repos.ts (or the labels module) has a label-create action that emits an event, use that here, or say in the comment that skipping the event is deliberate.

380 insertLabel(app.db, repo.id, FLOW_LABEL, "d29922");

Fine: it's idempotent and the test pins a single label. The terminal toggle should reach this same function, so the label follows from every door.

373381 const moved =
374382 archived === undefined ? false : await moveArchive(app, repo.id, archived);
375383 const updated = getRepoById(app.db, repo.id);
376384 if (updated === undefined) return err("not_found");
385 // `changed` is the audit line: it names settings, not the `flow` label
386 // that turning flowOnly on may have made.
377387 const changed: string[] = (
378388 [
379389 "visibility",
@@ -385,6 +395,7 @@ export async function updateRepoSettings(
385395 "crewOutsiders",
386396 "decisionsBy",
387397 "plansNeedNod",
398 "flowOnly",
388399 ] as const
389400 ).filter((k) => settings[k] !== undefined && settings[k] !== repo[k]);
390401 if (moved) changed.push("archived");
src/contract/openapi/routes/patchApiV1ReposOwnerName.ts +2
@@ -19,10 +19,12 @@ export const route: Route = {
1919 crewOutsiders: "boolean",
2020 decisionsBy: "string",
2121 plansNeedNod: "boolean",
22 flowOnly: "boolean",
2223 },
2324 refusals: {
2425 bad_archived: "422: archived is true or false",
2526 bad_decisions_by: "422: decisionsBy is me, decider or decider_tell",
27 bad_flow_only: "422: flowOnly is true or false",
2628 bad_plans_need_nod: "422: plansNeedNod is true or false",
2729 bad_show_provenance: "422: showProvenance is true or false",
2830 no_provenance: `422: ${FAMILY_WORDS.no_provenance}`,
src/core/migration-pins/20261027110000-flow-only.pin added +1
@@ -0,0 +1,1 @@
1 76c51289fbefaf8b3f8325328548a34f6e6fab8e6a55a64fac1d4224178d11a5
src/core/migrations/20261027110000-flow-only.ts added +5
@@ -0,0 +1,5 @@
1 // A repository can be FLOW ONLY (#6392): one switch its admin sets. Nothing
2 // obeys it yet; dispatch reads it in a later change.
3 export default `
4 ALTER TABLE repo ADD COLUMN flow_only INTEGER NOT NULL DEFAULT 0;
5 `;
src/core/repos.ts +7 −1
@@ -121,6 +121,8 @@ export interface Repo {
121121 readonly decisionsBy: DecisionsBy;
122122 /** "Plans need a nod": a plan is stored as a proposal, not opened. */
123123 readonly plansNeedNod: boolean;
124 /** FLOW ONLY: an admin's switch; nothing obeys it yet. */
125 readonly flowOnly: boolean;
124126 /** The issue the planner is given, when the map has run dry. */
125127 readonly directionIssueId: string | null;
126128 /** The repo this was forked from, or null. */
@@ -169,6 +171,7 @@ interface RepoRow {
169171 crew_outsiders: number;
170172 decisions_by: string;
171173 plans_need_nod: number;
174 flow_only: number;
172175 direction_issue_id: string | null;
173176 fork_parent_id: string | null;
174177 sealed_for: string | null;
@@ -209,6 +212,7 @@ function toRepo(row: RepoRow): Repo {
209212 crewOutsiders: row.crew_outsiders === 1,
210213 decisionsBy: isDecisionsBy(row.decisions_by) ? row.decisions_by : "me",
211214 plansNeedNod: row.plans_need_nod === 1,
215 flowOnly: row.flow_only === 1,
212216 directionIssueId: row.direction_issue_id,
213217 forkParentId: row.fork_parent_id,
214218 sealedFor: row.sealed_for,
@@ -1155,6 +1159,7 @@ export interface RepoSettings {
11551159 /** Archive (true) or wake (false); the time is stamped here. */
11561160 archived?: boolean;
11571161 plansNeedNod?: boolean;
1162 flowOnly?: boolean;
11581163 }
11591164
11601165 /** The repositories still awake: what Yours lists before anyone types a filter. */
@@ -1178,7 +1183,7 @@ export function updateRepoSettings(
11781183 default_branch = ?4, require_review = ?5, require_checks = ?7,
11791184 autonomy = ?8, autonomy_direction = ?9, autonomy_ceiling_usd = ?10,
11801185 autonomy_slots = ?11, autonomy_by = ?12, crew_outsiders = ?13, decisions_by = ?14,
1181 plans_need_nod = ?15
1186 plans_need_nod = ?15, flow_only = ?16
11821187 WHERE id = ?6`,
11831188 ).run(
11841189 settings.visibility ?? repo.visibility,
@@ -1196,6 +1201,7 @@ export function updateRepoSettings(
11961201 (settings.crewOutsiders ?? repo.crewOutsiders) ? 1 : 0,
11971202 settings.decisionsBy ?? repo.decisionsBy,
11981203 (settings.plansNeedNod ?? repo.plansNeedNod) ? 1 : 0,
1204 (settings.flowOnly ?? repo.flowOnly) ? 1 : 0,
11991205 );
12001206 forgetListings(db);
12011207 return getRepoById(db, repo.id)!;
src/http/api.ts +4
@@ -2509,6 +2509,8 @@ export function apiRoutes(router: Router<Handler>): void {
25092509 );
25102510 if (input.plansNeedNod !== undefined && typeof input.plansNeedNod !== "boolean")
25112511 return apiError("bad_plans_need_nod", 422, "plansNeedNod is true or false");
2512 if (input.flowOnly !== undefined && typeof input.flowOnly !== "boolean")
2513 return apiError("bad_flow_only", 422, "flowOnly is true or false");
25122514 if (input.showProvenance !== undefined) {
25132515 if (typeof input.showProvenance !== "boolean")
25142516 return apiError("bad_show_provenance", 422, "showProvenance is true or false");
@@ -2556,6 +2558,8 @@ export function apiRoutes(router: Router<Handler>): void {
25562558 ...(typeof input.plansNeedNod === "boolean"
25572559 ? { plansNeedNod: input.plansNeedNod }
25582560 : {}),
2561 // FLOW ONLY: an admin's switch, never an agent's.
2562 ...(typeof input.flowOnly === "boolean" ? { flowOnly: input.flowOnly } : {}),
25592563 });
25602564 if (!updated.ok) return respondAccess(updated.error, 422);
25612565 // "Show where this came from" (#3293): an admin's, never an agent's.
src/http/mcp/tools/repo_settings.ts +8 −3
@@ -4,15 +4,16 @@ import { VISIBILITY_WORDS } from "../../../core/arg_words.ts";
44
55 export const tool: Tool = {
66 name: "repo_settings",
7 doc: "`{ repo, description?, visibility? }` (admin): change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }`; a visibility other than public or private is `invalid`",
7 doc: "`{ repo, description?, visibility?, flowOnly? }` (admin): change a repository's description, visibility and FLOW ONLY switch as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility, flowOnly }` (with none to change it only reads); a visibility other than public or private is `invalid`",
88 description:
9 "Change a repo's description or visibility: PATCH /api/v1/repos/:owner/:name (admin).",
9 "Read or change a repo's description, visibility or flowOnly: PATCH /api/v1/repos/:owner/:name (admin).",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
1313 repo: REPO_ARG,
1414 description: { type: "string", maxLength: 2000 },
1515 visibility: { type: "string", enum: ["public", "private"] },
16 flowOnly: { type: "boolean" },
1617 },
1718 required: ["repo"],
1819 },
@@ -21,7 +22,9 @@ export const tool: Tool = {
2122 async run(ctx, user, args) {
2223 const repo = repoArg(ctx, user, args);
2324 if (isAnswer(repo)) return repo;
24 const { description, visibility } = args;
25 const { description, visibility, flowOnly } = args;
26 if (flowOnly !== undefined && typeof flowOnly !== "boolean")
27 return bad("invalid", "flowOnly is true or false");
2528 if (description !== undefined && typeof description !== "string")
2629 return bad("invalid", "description is text");
2730 if (visibility !== undefined && visibility !== "public" && visibility !== "private")
@@ -29,6 +32,7 @@ export const tool: Tool = {
2932 const updated = await updateRepoSettings(ctx.app, user, repo, {
3033 ...(visibility !== undefined ? { visibility } : {}),
3134 ...(description !== undefined ? { description } : {}),
35 ...(flowOnly !== undefined ? { flowOnly } : {}),
3236 });
3337 if (!updated.ok)
3438 return bad(updated.error === "not_found" ? "not_found" : "forbidden", updated.error);
@@ -36,6 +40,7 @@ export const tool: Tool = {
3640 repo: `${updated.value.ownerName}/${updated.value.name}`,
3741 description: updated.value.description,
3842 visibility: updated.value.visibility,
43 flowOnly: updated.value.flowOnly,
3944 });
4045 },
4146 };
src/http/server.ts +2
@@ -375,6 +375,7 @@ import {
375375 autonomySettingsUpdate,
376376 settingsPage,
377377 settingsUpdate,
378 flowOnlyUpdate,
378379 waveCut,
379380 archiveSubmit,
380381 provenanceSubmit,
@@ -1444,6 +1445,7 @@ export function buildRouter(): Router<Handler> {
14441445 );
14451446 router.add("GET", "/:owner/:name/settings/autonomy", autonomySettingsPage);
14461447 router.add("POST", "/:owner/:name/settings/autonomy", autonomySettingsUpdate);
1448 router.add("POST", "/:owner/:name/settings/flow-only", flowOnlyUpdate);
14471449 router.add("POST", "/:owner/:name/settings/holds", fileHoldUpdate);
14481450 router.add("POST", "/:owner/:name/settings/upstream", followUpstreamUpdate);
14491451 router.add("POST", "/:owner/:name/settings/upstream/check", followUpstreamCheck);
src/tui/screens/about.ts +24
@@ -89,6 +89,8 @@ export function aboutScreen(
8989 f.user?.kind !== "agent" &&
9090 repo !== undefined &&
9191 getProvenance(f.db, repo.id) !== undefined;
92 // FLOW ONLY is a person's, an admin's: the settings page's gate.
93 const mayFlow = (): boolean => may() && f.user?.kind !== "agent";
9294 // The following card's rule: an admin's, and only while it is on or to be turned on.
9395 const mayFollow = (): boolean =>
9496 may() && repo !== undefined && repo.origin === null;
@@ -96,6 +98,7 @@ export function aboutScreen(
9698 repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true;
9799 const factRows = (): [string, string][] => [
98100 ...archivedRow(repo),
101 ...flowRow(repo),
99102 ...aboutFacts(f, src, facts, ref),
100103 ...trailRows(f, repo),
101104 ...upstreamRows(f, repo),
@@ -154,6 +157,9 @@ export function aboutScreen(
154157 ),
155158 ]
156159 : []),
160 ...(mayFlow()
161 ? [binding(["w"], repo?.flowOnly === true ? "flow only off" : "flow only on")]
162 : []),
157163 ...(mayTrail()
158164 ? [
159165 binding(
@@ -298,6 +304,18 @@ export function aboutScreen(
298304 });
299305 return true;
300306 }
307 if (e.key === "w" && repo !== undefined && mayFlow()) {
308 const on = !repo.flowOnly;
309 void updateRepoSettings(f.app, f.user, repo, { flowOnly: on }).then((done) => {
310 if (done.ok) {
311 repo = getRepoById(f.db, done.value.id) ?? done.value;
312 rows = factRows();
313 ctx.toast(on ? "flow only is on" : "flow only is off", "success");
314 } else ctx.toast("that could not be changed", "danger");
315 ctx.invalidate();
316 });
317 return true;
318 }
301319 if (e.key === "t" && repo !== undefined && mayTrail()) {
302320 const shown = getProvenance(f.db, repo.id)?.shown === true;
303321 const done = showProvenance(f.app, f.user, repo, !shown);
@@ -417,6 +435,12 @@ export function upstreamRows(
417435 ];
418436 }
419437
438 /** The FLOW ONLY switch, in the settings page's words; every reader sees it. */
439 export function flowRow(repo: Repo | undefined): [string, string][] {
440 if (repo === undefined) return [];
441 return [["Flow only", repo.flowOnly ? "on" : "off"]];
442 }
443
420444 /** One line when an admin has put the repository away. */
421445 function archivedRow(repo: Repo | undefined): [string, string][] {
422446 if (repo === undefined || repo.archivedAt === null) return [];
src/web/pages/settings.ts +22
@@ -1446,6 +1446,28 @@ export function autonomySettingsPage(ctx: Ctx): Response {
14461446 return redirect(`${repoBase(repo)}/settings#autonomy`);
14471447 }
14481448
1449 // POST /:owner/:name/settings/flow-only — the one switch; the state asked
1450 // for travels in the form, so a double submit is idempotent.
1451 export async function flowOnlyUpdate(ctx: Ctx): Promise<Response> {
1452 const gate = await adminForm(ctx);
1453 if (gate instanceof Response) return gate;
1454 const { repo, form } = gate;
1455 const asked = field(form, "flowOnly");
1456 const back = `${repoBase(repo)}/settings#autonomy`;
1457 if (asked !== "0" && asked !== "1")
1458 return flashTo(ctx, back, { tone: "danger", message: NOT_CHANGED });
1459 const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, {
1460 flowOnly: asked === "1",
1461 });
1462 return flashTo(
1463 ctx,
1464 back,
1465 updated.ok
1466 ? { message: asked === "1" ? "Flow only is on." : "Flow only is off." }
1467 : { tone: "danger", message: "Those settings could not be saved." },
1468 );
1469 }
1470
14491471 // POST /:owner/:name/settings/autonomy
14501472 export async function autonomySettingsUpdate(ctx: Ctx): Promise<Response> {
14511473 const gate = await adminForm(ctx);
src/web/studio/crew.ts +11 −1
@@ -373,7 +373,17 @@ ${repo.autonomyDirection}</textarea
373373 crew under Crew to have them reviewed and land on their own.
374374 </p>`
375375 : null;
376 return card(html`${state}${noReviewer}${form}`, {
376 const flowOnly = !admin
377 ? null
378 : html`<form method="post" action="${base}/settings/flow-only" id="flow-only">

The page door is right: it posts a <button> to a same-origin form, works with JavaScript off, and the wanted state rides a hidden field. Mirror it in the terminal: the same words (Flow only: on/off) and the same gate (admin and a person).

379 <input type="hidden" name="flowOnly" value="${repo.flowOnly ? "0" : "1"}" />

Copy: "the direction is to finish what is open, not start features" reads as if the switch already enforces something, but nothing obeys it yet (the README and STATUS say so). Until dispatch reads it, consider adding "(nothing enforces it yet)". Separately, the terminal's About row shows the switch to every reader while this page shows it to admins only. Pick one rule when dispatch lands.

380 <p class="small">
381 <b>Flow only:</b> ${repo.flowOnly ? "on" : "off"} — the direction is
382 to finish what is open, not start features.
383 ${btn(repo.flowOnly ? "Turn off" : "Turn on", { size: "sm" })}
384 </p>
385 </form>`;
386 return card(html`${state}${noReviewer}${form}${flowOnly}`, {
377387 id: "autonomy",
378388 head: html`${icon("play")}
379389 <h2>On its own</h2>`,
test/flow-only.test.ts added +200
@@ -0,0 +1,200 @@
1 /**
2 * A repository's FLOW ONLY switch (#6392): one setting its admin reads and
3 * sets from Settings, the API and the MCP. A writer who is not admin is
4 * refused at every door; turning it on makes the `flow` label. Nothing obeys
5 * it yet.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { listLabels } from "../src/core/issues.ts";
13 import { addToCrew, createAgent } from "../src/actions/crew.ts";
14 import type { Agent } from "../src/core/agents.ts";
15 import { addTeamMember, createOrg, createTeam, listTeams, OWNERS_TEAM } from "../src/core/orgs.ts";
16 import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts";
17 import { createSession } from "../src/core/sessions.ts";
18 import { createToken } from "../src/core/tokens.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22
23 quietLogs();
24
25 let dataDir: string;
26 let app: App;
27 let servers: Servers;
28 let founder: User;
29 let dev: User;
30 let repo: Repo;
31 let bot: Agent;
32 const token: Record<string, string> = {};
33
34 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
35 if (!r.ok) throw new Error(JSON.stringify(r.error));
36 return r.value;
37 };
38 const flowOnly = (): boolean => getRepoById(app.db, repo.id)!.flowOnly;
39 const hasLabel = (): boolean =>
40 listLabels(app.db, repo.id).some((l) => l.name === "flow");
41
42 const patch = (who: string, body: unknown) =>
43 fetch(`${servers.url}/api/v1/repos/acme/svc`, {
44 method: "PATCH",
45 headers: { authorization: `Bearer ${token[who]}`, "content-type": "application/json" },
46 body: JSON.stringify(body),
47 });
48
49 async function mcp(who: string, args: unknown): Promise<{ error?: string; value?: any }> {
50 const res = await fetch(`${servers.url}/mcp`, {
51 method: "POST",
52 headers: {
53 "content-type": "application/json",
54 accept: "application/json, text/event-stream",
55 authorization: `Bearer ${token[who]}`,
56 },
57 body: JSON.stringify({
58 jsonrpc: "2.0",
59 id: 1,
60 method: "tools/call",
61 params: { name: "repo_settings", arguments: args },
62 }),
63 });
64 const body = (await res.json()) as any;
65 const text: string = body.result.content[0].text;
66 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
67 }
68
69 const web = (who: User, value: string, headers: Record<string, string> = {}) =>
70 fetch(`${servers.url}/acme/svc/settings/flow-only`, {
71 method: "POST",
72 headers: {
73 cookie: `forge_session=${createSession(app.db, who.id).token}`,
74 "content-type": "application/x-www-form-urlencoded",
75 ...headers,
76 },
77 body: new URLSearchParams({ flowOnly: value }),
78 redirect: "manual",
79 });
80
81 beforeAll(async () => {
82 dataDir = await mkdtemp(join(tmpdir(), "forge-flow-only-"));
83 app = createApp({ dataDir, port: 0 });
84 servers = await serve(app);
85 const make = async (username: string) =>
86 ok(
87 await createUser(app.db, {
88 origin: "seed",
89 trusted: true,
90 username,
91 email: `${username}@forge.local`,
92 password: "a-good-passphrase",
93 adminIfFirst: true,
94 }),
95 );
96 founder = await make("founder");
97 dev = await make("dev");
98 const org = ok(createOrg(app.db, "acme", founder.id));
99 const writers = ok(createTeam(app.db, org.id, "Writers", "write"));
100 addTeamMember(app.db, writers, dev.id);
101 repo = ok(
102 await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "svc", visibility: "public" }),
103 );
104 bot = ok(await createAgent(app, founder, { username: "bot" }));
105 expect(addToCrew(app, founder, repo, "bot").ok).toBe(true);
106 // The hardest agent to refuse: on the team that administers the repository.
107 const owners = listTeams(app.db, org.id).find((t) => t.name === OWNERS_TEAM)!;
108 addTeamMember(app.db, owners, bot.user.id);
109 for (const u of [founder, dev, bot.user]) token[u.username] = createToken(app.db, u.id, "admin", "admin").token;
110 }, 30_000);
111
112 afterAll(async () => {
113 servers?.stop();
114 app?.close();
115 await rm(dataDir, { recursive: true, force: true });
116 });
117
118 describe("FLOW ONLY", () => {
119 test("it starts off, and the API says so", async () => {
120 expect(flowOnly()).toBe(false);
121 const res = await fetch(`${servers.url}/api/v1/repos/acme/svc`);
122 expect(((await res.json()) as { flowOnly: boolean }).flowOnly).toBe(false);
123 });
124
125 test("a writer who is not admin is refused at every door", async () => {
126 expect((await patch("dev", { flowOnly: true })).status).toBe(404);
127 expect((await mcp("dev", { repo: "acme/svc", flowOnly: true })).error).toBeDefined();
128 const refused = await web(dev, "1");
129 expect(refused.status).toBe(404);
130 expect(flowOnly()).toBe(false);
131 expect(hasLabel()).toBe(false);
132 });
133
134 test("an agent that administers the repository cannot switch it, on or off", async () => {
135 expect((await patch("bot", { flowOnly: true })).status).toBe(404);
136 expect((await mcp("bot", { repo: "acme/svc", flowOnly: true })).error).toBeDefined();
137 expect(flowOnly()).toBe(false);
138 await patch("founder", { flowOnly: true });
139 expect((await patch("bot", { flowOnly: false })).status).toBe(404);
140 expect((await mcp("bot", { repo: "acme/svc", flowOnly: false })).error).toBeDefined();
141 expect(flowOnly()).toBe(true);
142 await patch("founder", { flowOnly: false });
143 });
144
145 test("Settings draws the form for an admin only, not a writer or a guest", async () => {
146 const seen = async (headers: Record<string, string>) =>
147 (await fetch(`${servers.url}/acme/svc/settings`, { headers })).text();
148 const asDev = `forge_session=${createSession(app.db, dev.id).token}`;
149 expect(await seen({ cookie: asDev })).not.toContain('id="flow-only"');
150 expect(await seen({})).not.toContain('id="flow-only"');
151 const asFounder = `forge_session=${createSession(app.db, founder.id).token}`;
152 expect(await seen({ cookie: asFounder })).toContain('id="flow-only"');
153 });
154
155 test("the API's admin turns it on, with its label, and off", async () => {
156 const on = await patch("founder", { flowOnly: true });
157 expect(on.status).toBe(200);
158 expect(((await on.json()) as { flowOnly: boolean }).flowOnly).toBe(true);
159 expect(flowOnly()).toBe(true);
160 expect(hasLabel()).toBe(true);
161 expect((await patch("founder", { flowOnly: false })).status).toBe(200);
162 expect(flowOnly()).toBe(false);
163 // Turning it on again does not make a second label.
164 await patch("founder", { flowOnly: true });
165 expect(listLabels(app.db, repo.id).filter((l) => l.name === "flow").length).toBe(1);
166 await patch("founder", { flowOnly: false });
167 });
168
169 test("a non-boolean is a 422 and nothing changes", async () => {
170 expect((await patch("founder", { flowOnly: "yes" })).status).toBe(422);
171 expect(flowOnly()).toBe(false);
172 });
173
174 test("the MCP's admin reads, turns it on and off", async () => {
175 expect((await mcp("founder", { repo: "acme/svc" })).value.flowOnly).toBe(false);
176 expect((await mcp("founder", { repo: "acme/svc", flowOnly: true })).value.flowOnly).toBe(true);
177 expect(flowOnly()).toBe(true);
178 expect((await mcp("founder", { repo: "acme/svc", flowOnly: false })).value.flowOnly).toBe(false);
179 expect(flowOnly()).toBe(false);
180 expect((await mcp("founder", { repo: "acme/svc", flowOnly: "no" })).error).toBeDefined();
181 });
182
183 test("Settings shows it to the admin, and its form turns it on and off", async () => {
184 const cookie = `forge_session=${createSession(app.db, founder.id).token}`;
185 const page = await (await fetch(`${servers.url}/acme/svc/settings`, { headers: { cookie } })).text();
186 expect(page).toContain("Flow only:");
187 expect((await web(founder, "1")).status).toBe(303);
188 expect(flowOnly()).toBe(true);
189 expect((await web(founder, "0")).status).toBe(303);
190 expect(flowOnly()).toBe(false);
191 expect((await web(founder, "2")).status).toBe(303);
192 expect(flowOnly()).toBe(false);
193 });
194
195 test("the form refuses a foreign Origin before it writes", async () => {
196 const res = await web(founder, "1", { origin: "http://evil.example" });
197 expect(res.status).toBe(403);
198 expect(flowOnly()).toBe(false);
199 });
200 });
test/tui-flow-only.test.ts added +108
@@ -0,0 +1,108 @@
1 /**
2 * FLOW ONLY at the terminal's door (#6392): the About card shows the switch
3 * to every reader; only a repository admin who is a person gets the `w` key.
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 { addToCrew, createAgent } from "../src/actions/crew.ts";
11 import { listLabels } from "../src/core/issues.ts";
12 import { addTeamMember, createOrg, createTeam, listTeams, OWNERS_TEAM } from "../src/core/orgs.ts";
13 import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import type { Ctx, Screen } from "../src/tui/app/app.ts";
16 import { flowRow, aboutScreen } from "../src/tui/screens/about.ts";
17 import { localSource } from "../src/view/local.ts";
18 import type { Session } from "../src/tui/session.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 let dataDir: string;
24 let app: App;
25 let founder: User;
26 let dev: User;
27 let bot: User;
28 let repo: Repo;
29
30 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
31 if (!r.ok) throw new Error(JSON.stringify(r.error));
32 return r.value;
33 };
34 const row = (): string => flowRow(getRepoById(app.db, repo.id)).map(([, v]) => v)[0]!;
35
36 function card(user: User) {
37 const toasts: string[] = [];
38 const ctx = {
39 invalidate() {},
40 toast: (t: string) => toasts.push(t),
41 push() {},
42 pop() {},
43 copy() {},
44 } as unknown as Ctx;
45 const f = { app, db: app.db, user } as unknown as Session;
46 const src = localSource(app, getRepoById(app.db, repo.id)!, user);
47 const screen = aboutScreen(f, ctx, {} as Screen, src, undefined, "main");
48 const offered = () => screen.keys.some((b) => b.keys.includes("w"));
49 const press = async (key: string) => {
50 screen.key!({ key } as never);
51 await new Promise((r) => setTimeout(r, 20));
52 };
53 return { toasts, offered, press };
54 }
55
56 beforeAll(async () => {
57 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-flow-"));
58 app = createApp({ dataDir, port: 0 });
59 const make = async (username: string) =>
60 ok(
61 await createUser(app.db, {
62 origin: "seed",
63 trusted: true,
64 username,
65 email: `${username}@forge.local`,
66 password: "a-good-passphrase",
67 adminIfFirst: true,
68 }),
69 );
70 founder = await make("founder");
71 dev = await make("dev");
72 const org = ok(createOrg(app.db, "acme", founder.id));
73 const writers = ok(createTeam(app.db, org.id, "Writers", "write"));
74 addTeamMember(app.db, writers, dev.id);
75 repo = ok(await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "svc", visibility: "public" }));
76 const agent = ok(await createAgent(app, founder, { username: "bot" }));
77 expect(addToCrew(app, founder, repo, "bot").ok).toBe(true);
78 const owners = listTeams(app.db, org.id).find((t) => t.name === OWNERS_TEAM)!;
79 addTeamMember(app.db, owners, agent.user.id);
80 bot = agent.user;
81 }, 30_000);
82
83 afterAll(async () => {
84 app?.close();
85 await rm(dataDir, { recursive: true, force: true });
86 });
87
88 test("an admin turns it on and off from About", async () => {
89 const c = card(founder);
90 expect(row()).toBe("off");
91 expect(c.offered()).toBe(true);
92 await c.press("w");
93 expect(row()).toBe("on");
94 expect(listLabels(app.db, repo.id).filter((l) => l.name === "flow")).toHaveLength(1);
95 expect(c.toasts.at(-1)).toBe("flow only is on");
96 await c.press("w");
97 expect(row()).toBe("off");
98 expect(c.toasts.at(-1)).toBe("flow only is off");
99 });
100
101 test("a writer and an agent see the line but cannot toggle it", async () => {
102 for (const who of [dev, bot]) {
103 const c = card(who);
104 expect(c.offered()).toBe(false);
105 await c.press("w");
106 expect(row()).toBe("off");
107 }
108 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs