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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6392-a-repository-has-a-flow-only-switch-one → main 8 commits fork 5 threads · 5 unresolved
README.md +1
| @@ -359,6 +359,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 359 | 359 | - [](docs/WEBSPEC-PREVIEWS.md) — the website specification run over every preview, report-only. |
| 360 | 360 | - [](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. |
| 361 | 361 | - [](docs/SENTINEL.md) — the forge hunting its own faults; [](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. | |
| 362 | 363 | - [](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. |
| 363 | 364 | - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [](docs/FLOW.md). |
| 364 | 365 | - 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. | ||
| 122 | 122 | | `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 | | |
| 123 | 123 | | `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` | | |
| 124 | 124 | | `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 | | |
| 126 | 126 | | `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 | |
| 127 | 127 | | `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 | |
| 128 | 128 | | `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 @@ | ||
| 123 | 123 | - **The crew** — agents as accounts with nothing but a run-scoped token, |
| 124 | 124 | working issues in the background with Claude Code and proposing through |
| 125 | 125 | 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. | |
| 126 | 130 | |
| 127 | 131 | Pages render without a line of JavaScript and are designed thumb-first. Six |
| 128 | 132 | 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 | ||
| 18 | 18 | import { |
| 19 | 19 | createLabel as insertLabel, |
| 20 | 20 | deleteLabel as removeLabel, |
| 21 | getLabel, | |
| 21 | 22 | updateLabel as writeLabel, |
| 22 | 23 | type Label, |
| 23 | 24 | } from "../core/issues.ts"; |
| @@ -333,6 +334,9 @@ function moveArchive(app: App, repoId: string, archived: boolean): Promise<boole | ||
| 333 | 334 | }); |
| 334 | 335 | } |
| 335 | 336 | |
| 337 | /** The label FLOW ONLY puts in a repository. */ | |
| 338 | const FLOW_LABEL = "flow"; | |
| 339 | ||
| 336 | 340 | /** |
| 337 | 341 | * Change settings. A new default branch must exist; git HEAD is moved first |
| 338 | 342 | * and the row follows (git is truth, the index is derived from it). |
| @@ -352,6 +356,7 @@ export async function updateRepoSettings( | ||
| 352 | 356 | (settings.crewOutsiders !== undefined || |
| 353 | 357 | settings.decisionsBy !== undefined || |
| 354 | 358 | settings.plansNeedNod !== undefined || |
| 359 | settings.flowOnly !== undefined || | |
| 355 | 360 | settings.archived !== undefined) && |
| 356 | 361 | admin.value.kind === "agent" |
| 357 | 362 | ) |
| @@ -370,10 +375,15 @@ export async function updateRepoSettings( | ||
| 370 | 375 | } |
| 371 | 376 | const { archived, ...rest } = settings; |
| 372 | 377 | 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 |
||
| 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. |
||
| 373 | 381 | const moved = |
| 374 | 382 | archived === undefined ? false : await moveArchive(app, repo.id, archived); |
| 375 | 383 | const updated = getRepoById(app.db, repo.id); |
| 376 | 384 | 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. | |
| 377 | 387 | const changed: string[] = ( |
| 378 | 388 | [ |
| 379 | 389 | "visibility", |
| @@ -385,6 +395,7 @@ export async function updateRepoSettings( | ||
| 385 | 395 | "crewOutsiders", |
| 386 | 396 | "decisionsBy", |
| 387 | 397 | "plansNeedNod", |
| 398 | "flowOnly", | |
| 388 | 399 | ] as const |
| 389 | 400 | ).filter((k) => settings[k] !== undefined && settings[k] !== repo[k]); |
| 390 | 401 | if (moved) changed.push("archived"); |
src/contract/openapi/routes/patchApiV1ReposOwnerName.ts +2
| @@ -19,10 +19,12 @@ export const route: Route = { | ||
| 19 | 19 | crewOutsiders: "boolean", |
| 20 | 20 | decisionsBy: "string", |
| 21 | 21 | plansNeedNod: "boolean", |
| 22 | flowOnly: "boolean", | |
| 22 | 23 | }, |
| 23 | 24 | refusals: { |
| 24 | 25 | bad_archived: "422: archived is true or false", |
| 25 | 26 | bad_decisions_by: "422: decisionsBy is me, decider or decider_tell", |
| 27 | bad_flow_only: "422: flowOnly is true or false", | |
| 26 | 28 | bad_plans_need_nod: "422: plansNeedNod is true or false", |
| 27 | 29 | bad_show_provenance: "422: showProvenance is true or false", |
| 28 | 30 | 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 { | ||
| 121 | 121 | readonly decisionsBy: DecisionsBy; |
| 122 | 122 | /** "Plans need a nod": a plan is stored as a proposal, not opened. */ |
| 123 | 123 | readonly plansNeedNod: boolean; |
| 124 | /** FLOW ONLY: an admin's switch; nothing obeys it yet. */ | |
| 125 | readonly flowOnly: boolean; | |
| 124 | 126 | /** The issue the planner is given, when the map has run dry. */ |
| 125 | 127 | readonly directionIssueId: string | null; |
| 126 | 128 | /** The repo this was forked from, or null. */ |
| @@ -169,6 +171,7 @@ interface RepoRow { | ||
| 169 | 171 | crew_outsiders: number; |
| 170 | 172 | decisions_by: string; |
| 171 | 173 | plans_need_nod: number; |
| 174 | flow_only: number; | |
| 172 | 175 | direction_issue_id: string | null; |
| 173 | 176 | fork_parent_id: string | null; |
| 174 | 177 | sealed_for: string | null; |
| @@ -209,6 +212,7 @@ function toRepo(row: RepoRow): Repo { | ||
| 209 | 212 | crewOutsiders: row.crew_outsiders === 1, |
| 210 | 213 | decisionsBy: isDecisionsBy(row.decisions_by) ? row.decisions_by : "me", |
| 211 | 214 | plansNeedNod: row.plans_need_nod === 1, |
| 215 | flowOnly: row.flow_only === 1, | |
| 212 | 216 | directionIssueId: row.direction_issue_id, |
| 213 | 217 | forkParentId: row.fork_parent_id, |
| 214 | 218 | sealedFor: row.sealed_for, |
| @@ -1155,6 +1159,7 @@ export interface RepoSettings { | ||
| 1155 | 1159 | /** Archive (true) or wake (false); the time is stamped here. */ |
| 1156 | 1160 | archived?: boolean; |
| 1157 | 1161 | plansNeedNod?: boolean; |
| 1162 | flowOnly?: boolean; | |
| 1158 | 1163 | } |
| 1159 | 1164 | |
| 1160 | 1165 | /** The repositories still awake: what Yours lists before anyone types a filter. */ |
| @@ -1178,7 +1183,7 @@ export function updateRepoSettings( | ||
| 1178 | 1183 | default_branch = ?4, require_review = ?5, require_checks = ?7, |
| 1179 | 1184 | autonomy = ?8, autonomy_direction = ?9, autonomy_ceiling_usd = ?10, |
| 1180 | 1185 | 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 | |
| 1182 | 1187 | WHERE id = ?6`, |
| 1183 | 1188 | ).run( |
| 1184 | 1189 | settings.visibility ?? repo.visibility, |
| @@ -1196,6 +1201,7 @@ export function updateRepoSettings( | ||
| 1196 | 1201 | (settings.crewOutsiders ?? repo.crewOutsiders) ? 1 : 0, |
| 1197 | 1202 | settings.decisionsBy ?? repo.decisionsBy, |
| 1198 | 1203 | (settings.plansNeedNod ?? repo.plansNeedNod) ? 1 : 0, |
| 1204 | (settings.flowOnly ?? repo.flowOnly) ? 1 : 0, | |
| 1199 | 1205 | ); |
| 1200 | 1206 | forgetListings(db); |
| 1201 | 1207 | return getRepoById(db, repo.id)!; |
src/http/api.ts +4
| @@ -2509,6 +2509,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2509 | 2509 | ); |
| 2510 | 2510 | if (input.plansNeedNod !== undefined && typeof input.plansNeedNod !== "boolean") |
| 2511 | 2511 | 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"); | |
| 2512 | 2514 | if (input.showProvenance !== undefined) { |
| 2513 | 2515 | if (typeof input.showProvenance !== "boolean") |
| 2514 | 2516 | return apiError("bad_show_provenance", 422, "showProvenance is true or false"); |
| @@ -2556,6 +2558,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2556 | 2558 | ...(typeof input.plansNeedNod === "boolean" |
| 2557 | 2559 | ? { plansNeedNod: input.plansNeedNod } |
| 2558 | 2560 | : {}), |
| 2561 | // FLOW ONLY: an admin's switch, never an agent's. | |
| 2562 | ...(typeof input.flowOnly === "boolean" ? { flowOnly: input.flowOnly } : {}), | |
| 2559 | 2563 | }); |
| 2560 | 2564 | if (!updated.ok) return respondAccess(updated.error, 422); |
| 2561 | 2565 | // "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"; | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | 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`", | |
| 8 | 8 | 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).", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
| 13 | 13 | repo: REPO_ARG, |
| 14 | 14 | description: { type: "string", maxLength: 2000 }, |
| 15 | 15 | visibility: { type: "string", enum: ["public", "private"] }, |
| 16 | flowOnly: { type: "boolean" }, | |
| 16 | 17 | }, |
| 17 | 18 | required: ["repo"], |
| 18 | 19 | }, |
| @@ -21,7 +22,9 @@ export const tool: Tool = { | ||
| 21 | 22 | async run(ctx, user, args) { |
| 22 | 23 | const repo = repoArg(ctx, user, args); |
| 23 | 24 | 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"); | |
| 25 | 28 | if (description !== undefined && typeof description !== "string") |
| 26 | 29 | return bad("invalid", "description is text"); |
| 27 | 30 | if (visibility !== undefined && visibility !== "public" && visibility !== "private") |
| @@ -29,6 +32,7 @@ export const tool: Tool = { | ||
| 29 | 32 | const updated = await updateRepoSettings(ctx.app, user, repo, { |
| 30 | 33 | ...(visibility !== undefined ? { visibility } : {}), |
| 31 | 34 | ...(description !== undefined ? { description } : {}), |
| 35 | ...(flowOnly !== undefined ? { flowOnly } : {}), | |
| 32 | 36 | }); |
| 33 | 37 | if (!updated.ok) |
| 34 | 38 | return bad(updated.error === "not_found" ? "not_found" : "forbidden", updated.error); |
| @@ -36,6 +40,7 @@ export const tool: Tool = { | ||
| 36 | 40 | repo: `${updated.value.ownerName}/${updated.value.name}`, |
| 37 | 41 | description: updated.value.description, |
| 38 | 42 | visibility: updated.value.visibility, |
| 43 | flowOnly: updated.value.flowOnly, | |
| 39 | 44 | }); |
| 40 | 45 | }, |
| 41 | 46 | }; |
src/http/server.ts +2
| @@ -375,6 +375,7 @@ import { | ||
| 375 | 375 | autonomySettingsUpdate, |
| 376 | 376 | settingsPage, |
| 377 | 377 | settingsUpdate, |
| 378 | flowOnlyUpdate, | |
| 378 | 379 | waveCut, |
| 379 | 380 | archiveSubmit, |
| 380 | 381 | provenanceSubmit, |
| @@ -1444,6 +1445,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1444 | 1445 | ); |
| 1445 | 1446 | router.add("GET", "/:owner/:name/settings/autonomy", autonomySettingsPage); |
| 1446 | 1447 | router.add("POST", "/:owner/:name/settings/autonomy", autonomySettingsUpdate); |
| 1448 | router.add("POST", "/:owner/:name/settings/flow-only", flowOnlyUpdate); | |
| 1447 | 1449 | router.add("POST", "/:owner/:name/settings/holds", fileHoldUpdate); |
| 1448 | 1450 | router.add("POST", "/:owner/:name/settings/upstream", followUpstreamUpdate); |
| 1449 | 1451 | router.add("POST", "/:owner/:name/settings/upstream/check", followUpstreamCheck); |
src/tui/screens/about.ts +24
| @@ -89,6 +89,8 @@ export function aboutScreen( | ||
| 89 | 89 | f.user?.kind !== "agent" && |
| 90 | 90 | repo !== undefined && |
| 91 | 91 | 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"; | |
| 92 | 94 | // The following card's rule: an admin's, and only while it is on or to be turned on. |
| 93 | 95 | const mayFollow = (): boolean => |
| 94 | 96 | may() && repo !== undefined && repo.origin === null; |
| @@ -96,6 +98,7 @@ export function aboutScreen( | ||
| 96 | 98 | repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true; |
| 97 | 99 | const factRows = (): [string, string][] => [ |
| 98 | 100 | ...archivedRow(repo), |
| 101 | ...flowRow(repo), | |
| 99 | 102 | ...aboutFacts(f, src, facts, ref), |
| 100 | 103 | ...trailRows(f, repo), |
| 101 | 104 | ...upstreamRows(f, repo), |
| @@ -154,6 +157,9 @@ export function aboutScreen( | ||
| 154 | 157 | ), |
| 155 | 158 | ] |
| 156 | 159 | : []), |
| 160 | ...(mayFlow() | |
| 161 | ? [binding(["w"], repo?.flowOnly === true ? "flow only off" : "flow only on")] | |
| 162 | : []), | |
| 157 | 163 | ...(mayTrail() |
| 158 | 164 | ? [ |
| 159 | 165 | binding( |
| @@ -298,6 +304,18 @@ export function aboutScreen( | ||
| 298 | 304 | }); |
| 299 | 305 | return true; |
| 300 | 306 | } |
| 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 | } | |
| 301 | 319 | if (e.key === "t" && repo !== undefined && mayTrail()) { |
| 302 | 320 | const shown = getProvenance(f.db, repo.id)?.shown === true; |
| 303 | 321 | const done = showProvenance(f.app, f.user, repo, !shown); |
| @@ -417,6 +435,12 @@ export function upstreamRows( | ||
| 417 | 435 | ]; |
| 418 | 436 | } |
| 419 | 437 | |
| 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 | ||
| 420 | 444 | /** One line when an admin has put the repository away. */ |
| 421 | 445 | function archivedRow(repo: Repo | undefined): [string, string][] { |
| 422 | 446 | if (repo === undefined || repo.archivedAt === null) return []; |
src/web/pages/settings.ts +22
| @@ -1446,6 +1446,28 @@ export function autonomySettingsPage(ctx: Ctx): Response { | ||
| 1446 | 1446 | return redirect(`${repoBase(repo)}/settings#autonomy`); |
| 1447 | 1447 | } |
| 1448 | 1448 | |
| 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 | ||
| 1449 | 1471 | // POST /:owner/:name/settings/autonomy |
| 1450 | 1472 | export async function autonomySettingsUpdate(ctx: Ctx): Promise<Response> { |
| 1451 | 1473 | const gate = await adminForm(ctx); |
src/web/studio/crew.ts +11 −1
| @@ -373,7 +373,17 @@ ${repo.autonomyDirection}</textarea | ||
| 373 | 373 | crew under Crew to have them reviewed and land on their own. |
| 374 | 374 | </p>` |
| 375 | 375 | : 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 |
||
| 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}`, { | |
| 377 | 387 | id: "autonomy", |
| 378 | 388 | head: html`${icon("play")} |
| 379 | 389 | <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 | }); |
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.