Public: anyone can read this; the crew can change it.
An admin sets the crew's autonomy from the terminal: mode off, assisted or autonomous, the dollar ceiling and the slots, as Settings › Autonomy does #8137
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8132-an-admin-sets-the-crew-s-autonomy-from-t → main 7 commits fork 2 threads · 2 unresolved
6 files
changed +467
−20
README.md +2
| @@ -75,6 +75,8 @@ In the terminal, `T` on a shipped pull's screen takes it back, as the pull page' | ||
| 75 | 75 | |
| 76 | 76 | In the terminal, `m` on a local repository's issue list opens the plan screen (`src/tui/screens/plan.ts`): Working, Ready now, Waiting and Parked twice, each headed with its count. It reads what MCP `map` reads (`repoGraph` under the reader's sealed scope, `pillarView`, `mapHeldFor`, the runs in flight), so a sealed issue the reader may not see is left out. Enter opens the issue, `r` reads again, and the headings are skipped over. It is not offered on the pulls list or on a live browse of another forge. |
| 77 | 77 | |
| 78 | In the terminal, open a repository and pick **Autonomy** from the palette (`:`): mode, dollar ceiling a day and slots, as Settings › Autonomy has them. It is offered only to whoever administers the repository (`canAdmin`, an agent included, as the page's API allows); anyone else gets no entry and sees no value. `m` opens a confirm naming each mode and the ceiling, with Off, Assisted and Autonomous as its buttons and Cancel selected, so any mode is one pick away; `c` sets the ceiling, `s` the slots; bounds are the page's (`checkAutonomy`), and each saves through `updateRepoSettings` and says the page's flash (`AUTONOMY_SAID`). | |
| 79 | ||
| 78 | 80 | In the terminal, a repository screen's `:` palette offers **Delete this repository** (`<owner>/<name> — Delete this repository`, "cannot be undone") to a person who administers it, never to an agent, a writer who is not an admin or a reader. It asks for the full `<owner>/<name>`; anything else is refused with "Type <owner>/<name> to confirm." and nothing changes. The exact name calls `deleteRepo` (`src/actions/repos.ts`, as Settings' danger zone does), toasts the result and unwinds the stack to the screen beneath the repository's (`leaveRepo`), so no screen reads the deleted row. |
| 79 | 81 | |
| 80 | 82 | In the terminal, the palette's **Organization teams** (offered to anyone on a team of an organization) lists each organization's teams you may see, as `/<org>/-/teams` does (an org admin sees every team, a member only their own), each with its member count and permission; Enter lists the team's members. On that list an admin of the organization adds a person with `a` (type a username) and removes the member under the cursor with `d` after a y/n, in the Teams page's words; a member who is not an org admin is offered neither key. |
src/core/repos.ts +29
| @@ -82,6 +82,35 @@ export const AUTONOMY: readonly Autonomy[] = ["off", "assisted", "autonomous"]; | ||
| 82 | 82 | export const isAutonomy = (value: unknown): value is Autonomy => |
| 83 | 83 | (AUTONOMY as readonly unknown[]).includes(value); |
| 84 | 84 | |
| 85 | /** What the settings page and the terminal say when autonomy input is out of bounds. */ | |
| 86 | export const AUTONOMY_BOUNDS = | |
| 87 | "Mode is off, assisted or autonomous; the ceiling is a number of dollars; slots are 1 to 8."; | |
| 88 | ||
| 89 | /** What each mode does, in one sentence: the settings page's flash and the terminal's toast. */ | |
| 90 | export const AUTONOMY_SAID: Readonly<Record<Autonomy, string>> = { | |
| 91 | off: "Autonomy is off.", | |
| 92 | assisted: "Assisted: the crew works what is ready; green waits for you.", | |
| 93 | autonomous: "Autonomous: the crew works what is ready and ships on green, as you.", | |
| 94 | }; | |
| 95 | ||
| 96 | /** The mode, dollar ceiling and slots, checked once for every surface; the sentence is the refusal. */ | |
| 97 | export function checkAutonomy( | |
| 98 | mode: unknown, | |
| 99 | ceiling: number, | |
| 100 | slots: number, | |
| 101 | ): { ok: true; mode: Autonomy } | { ok: false; message: string } { | |
| 102 | if ( | |
| 103 | !isAutonomy(mode) || | |
| 104 | !Number.isFinite(ceiling) || | |
| 105 | ceiling < 0 || | |
| 106 | !Number.isInteger(slots) || | |
| 107 | slots < 1 || | |
| 108 | slots > 8 | |
| 109 | ) | |
| 110 | return { ok: false, message: AUTONOMY_BOUNDS }; | |
| 111 | return { ok: true, mode }; | |
| 112 | } | |
| 113 | ||
| 85 | 114 | export interface Repo { |
| 86 | 115 | readonly id: string; |
| 87 | 116 | readonly ownerId: string; |
src/tui/screens/autonomy.ts added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | /** | |
| 2 | * A repository's autonomy in the terminal: mode, dollar ceiling and slots, as | |
| 3 | * Settings › Autonomy has them. Like that card it is for whoever administers | |
| 4 | * the repository (`canAdmin`), an agent included, as the page's API lets one: | |
| 5 | * they pick the mode with `m` (a confirm offering Off, Assisted and Autonomous | |
| 6 | * outright, naming what each does and the ceiling that will apply, Cancel | |
| 7 | * selected), the ceiling with `c` and the slots with `s`; each saves through | |
| 8 | * `updateRepoSettings` with the fields the page writes and toasts the page's | |
| 9 | * flash (`AUTONOMY_SAID`), and a value out of bounds is refused with the | |
| 10 | * page's sentence (`checkAutonomy`). | |
| 11 | * Anyone else gets no palette entry and, if the screen is reached, no values. | |
| 12 | */ | |
| 13 | ||
| 14 | import { updateRepoSettings } from "../../actions/repos.ts"; | |
| 15 | import { | |
| 16 | AUTONOMY, | |
| 17 | AUTONOMY_SAID, | |
| 18 | canAdmin, | |
| 19 | checkAutonomy, | |
| 20 | getRepoById, | |
| 21 | type Autonomy, | |
| 22 | type Repo, | |
| 23 | } from "../../core/repos.ts"; | |
| 24 | import type { Command } from "../app/palette.ts"; | |
| 25 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 26 | import { binding } from "../app/keys.ts"; | |
| 27 | import { style } from "../term/style.ts"; | |
| 28 | import { truncate } from "../term/width.ts"; | |
| 29 | import type { Session } from "../session.ts"; | |
| 30 | import { confirmScreen, promptScreen } from "./common.ts"; | |
| 31 | ||
| 32 | const MODE_LABEL: Record<Autonomy, string> = { off: "Off", assisted: "Assisted", autonomous: "Autonomous" }; | |
| 33 | ||
| 34 | const dollars = (n: number): string => `$${n}`; | |
| 35 | ||
| 36 | const mayAdminister = (f: Session, repo: Repo): boolean => | |
| 37 | f.user !== undefined && canAdmin(f.db, repo, f.user); | |
| 38 | ||
| 39 | /** The palette's "Autonomy", while a repository is open, to its admins. */ | |
| 40 | export function autonomyCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { | |
| 41 | if (repo === undefined || !mayAdminister(f, repo)) return []; | |
| 42 | return [ | |
| 43 | { | |
| 44 | id: "autonomy", | |
| 45 | title: `${repo.ownerName}/${repo.name} — Autonomy`, | |
| 46 | hint: "mode, dollar ceiling and slots of the crew", | |
| 47 | run: () => ctx.push(autonomyScreen(f, ctx, repo)), | |
| 48 | }, | |
| 49 | ]; | |
| 50 | } | |
| 51 | ||
| 52 | export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 53 | const current = (): Repo => getRepoById(f.db, repo.id) ?? repo; | |
| 54 | // Whoever administers the repository; the action decides again on save. | |
| 55 | const mayChange = (): boolean => mayAdminister(f, current()); | |
| 56 | ||
| 57 | const save = (patch: { mode?: Autonomy; ceiling?: number; slots?: number }, said: string): void => { | |
| 58 | const now = current(); | |
| 59 | const mode = patch.mode ?? now.autonomy; | |
| 60 | const ceiling = patch.ceiling ?? now.autonomyCeilingUsd; | |
| 61 | const slots = patch.slots ?? now.autonomySlots; | |
| 62 | const checked = checkAutonomy(mode, ceiling, slots); | |
| 63 | if (!checked.ok) return void ctx.toast(checked.message, "danger"); | |
| 64 | void updateRepoSettings(f.app, f.user, now, { | |
| 65 | autonomy: mode, | |
| 66 | autonomyCeilingUsd: ceiling, | |
| 67 | autonomySlots: slots, | |
| 68 | autonomyBy: mode === "off" ? null : (f.user?.id ?? null), | |
| 69 | }).then( | |
| 70 | (done) => { | |
| 71 | if (done.ok) ctx.toast(said, "success"); | |
| 72 | else if (done.error === "not_found") ctx.toast("Only an admin of this repository sets its autonomy.", "danger"); | |
| 73 | else ctx.toast("Those settings could not be saved.", "danger"); | |
| 74 | ctx.invalidate(); | |
| 75 | }, | |
| 76 | () => { | |
| 77 | ctx.toast("Those settings could not be saved.", "danger"); | |
| 78 | ctx.invalidate(); | |
outdated
render shows ceiling and slots to any viewer, including guests. Gate the read with the same canAdmin check as the settings page, and show nothing private otherwise. |
||
| 79 | }, | |
| 80 | ); | |
| 81 | }; | |
| 82 | ||
| 83 | const ask = (title: string, label: string, hint: string, value: string, onValue: (raw: string) => string | undefined): void => { | |
| 84 | ctx.push( | |
| 85 | promptScreen(ctx, screen, { | |
| 86 | title, | |
| 87 | label, | |
| 88 | value, | |
| 89 | hint, | |
| 90 | onSubmit: (raw) => onValue(raw.trim()), | |
| 91 | }), | |
| 92 | ); | |
| 93 | }; | |
| 94 | ||
| 95 | const screen: Screen = { | |
| 96 | title: "Autonomy", | |
| 97 | get keys() { | |
| 98 | return mayChange() | |
| 99 | ? [binding(["m"], "mode"), binding(["c"], "ceiling"), binding(["s"], "slots")] | |
| 100 | : []; | |
| 101 | }, | |
| 102 | render(area, s) { | |
| 103 | const c = ctx.theme.c; | |
| 104 | const r = current(); | |
| 105 | const lines = !mayChange() | |
| 106 | ? [`${r.ownerName}/${r.name}`, "", "Only an admin of this repository sets its autonomy."] | |
| 107 | : [ | |
| 108 | `${r.ownerName}/${r.name}`, | |
| 109 | "", | |
| 110 | `Mode ${r.autonomy}`, | |
| 111 | ` ${AUTONOMY_SAID[r.autonomy]}`, | |
| 112 | `Ceiling ${dollars(r.autonomyCeilingUsd)} a day`, | |
| 113 | `Slots ${r.autonomySlots}`, | |
| 114 | ]; | |
| 115 | lines.forEach((line, i) => { | |
| 116 | if (i < area.h) | |
| 117 | s.text(area.x, area.y + i, truncate(line, area.w), style(c.fg, c.bg), area.x + area.w); | |
| 118 | }); | |
| 119 | }, | |
| 120 | key(e) { | |
| 121 | if (!mayChange()) return false; | |
| 122 | if (e.key === "m") { | |
| 123 | const now = current(); | |
| 124 | // Any mode is one pick away, so leaving autonomous never passes | |
| 125 | // through another live mode; Cancel is what Enter does unmoved. | |
| 126 | ctx.push( | |
| 127 | confirmScreen(ctx, screen, { | |
| 128 | title: `Set autonomy (now ${now.autonomy})`, | |
| 129 | body: [ | |
| 130 | ...AUTONOMY.map((mode) => AUTONOMY_SAID[mode]), | |
| 131 | `The ceiling that applies is ${dollars(now.autonomyCeilingUsd)} a day.`, | |
| 132 | ], | |
| 133 | actions: [ | |
| 134 | ...AUTONOMY.map((mode) => ({ label: MODE_LABEL[mode] })), | |
| 135 | { label: "Cancel", variant: "ghost" as const }, | |
| 136 | ], | |
| 137 | selected: AUTONOMY.length, | |
| 138 | width: 78, | |
| 139 | onChoose: (choice) => { | |
| 140 | const next = AUTONOMY[choice]; | |
| 141 | // The same mode again would only move who it ships as. | |
| 142 | if (next !== undefined && next !== current().autonomy) save({ mode: next }, AUTONOMY_SAID[next]); | |
| 143 | }, | |
| 144 | }), | |
| 145 | ); | |
| 146 | return true; | |
| 147 | } | |
| 148 | if (e.key === "c") { | |
| 149 | ask("Ceiling", "dollars a day, 0 or more", "0 or more", String(current().autonomyCeilingUsd), (raw) => { | |
| 150 | const n = /^\d+(\.\d+)?$/.test(raw) ? Number(raw) : Number.NaN; | |
| 151 | save({ ceiling: n }, `Ceiling is ${dollars(n)} a day.`); | |
| 152 | return undefined; | |
| 153 | }); | |
| 154 | return true; | |
| 155 | } | |
| 156 | if (e.key === "s") { | |
| 157 | ask("Slots", "a whole number, 1 to 8", "1 to 8", String(current().autonomySlots), (raw) => { | |
| 158 | const n = /^\d+$/.test(raw) ? Number(raw) : Number.NaN; | |
| 159 | save({ slots: n }, `Slots are ${n}.`); | |
| 160 | return undefined; | |
| 161 | }); | |
| 162 | return true; | |
| 163 | } | |
| 164 | return false; | |
| 165 | }, | |
| 166 | }; | |
| 167 | return screen; | |
| 168 | } |
src/tui/screens/workspace.ts +2
| @@ -134,6 +134,7 @@ import { deleteRepoCommands } from "./deleterepo.ts"; | ||
| 134 | 134 | import { visionCommands } from "./vision.ts"; |
| 135 | 135 | import { labelsCommands } from "./labels.ts"; |
| 136 | 136 | import { appEnvCommands } from "./appenv.ts"; |
| 137 | import { autonomyCommands } from "./autonomy.ts"; | |
| 137 | 138 | import { externalEditor, editorHint, hasExternalEditor } from "./external.ts"; |
| 138 | 139 | import { finderScreen } from "./finder.ts"; |
| 139 | 140 | import { issuesScreen } from "./issues.ts"; |
| @@ -1448,6 +1449,7 @@ export function workspaceScreen( | ||
| 1448 | 1449 | ...visionCommands(f, ctx, repo), |
| 1449 | 1450 | ...labelsCommands(f, ctx, repo), |
| 1450 | 1451 | ...appEnvCommands(f, ctx, repo), |
| 1452 | ...autonomyCommands(f, ctx, repo), | |
| 1451 | 1453 | ], |
| 1452 | 1454 | get title() { |
| 1453 | 1455 | return sourceName(src); |
src/web/pages/settings.ts +6 −20
| @@ -49,7 +49,7 @@ import { | ||
| 49 | 49 | APP_ENV_NOT_SET, |
| 50 | 50 | SCOPE_WORDS, |
| 51 | 51 | } from "../../actions/app_env_words.ts"; |
| 52 | import { isAutonomy, isDecisionsBy } from "../../core/repos.ts"; | |
| 52 | import { AUTONOMY_SAID, checkAutonomy, isDecisionsBy } from "../../core/repos.ts"; | |
| 53 | 53 | import { updateRepoSettings } from "../../actions/repos.ts"; |
| 54 | 54 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 55 | 55 | import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| @@ -1429,22 +1429,15 @@ export async function autonomySettingsUpdate(ctx: Ctx): Promise<Response> { | ||
| 1429 | 1429 | if (gate instanceof Response) return gate; |
| 1430 | 1430 | const { repo, form } = gate; |
| 1431 | 1431 | const base = repoBase(repo); |
| 1432 | const mode = field(form, "autonomy"); | |
| 1433 | 1432 | const ceiling = Number(field(form, "ceiling")); |
| 1434 | 1433 | const slots = Number(field(form, "slots")); |
| 1435 | if ( | |
| 1436 | !isAutonomy(mode) || | |
| 1437 | !Number.isFinite(ceiling) || | |
| 1438 | ceiling < 0 || | |
| 1439 | !Number.isInteger(slots) || | |
| 1440 | slots < 1 || | |
| 1441 | slots > 8 | |
| 1442 | ) | |
| 1434 | const checked = checkAutonomy(field(form, "autonomy"), ceiling, slots); | |
| 1435 | if (!checked.ok) | |
| 1443 | 1436 | return flashTo(ctx, `${base}/settings#autonomy`, { |
| 1444 | 1437 | tone: "danger", |
| 1445 | message: | |
| 1446 | "Mode is off, assisted or autonomous; the ceiling is a number of dollars; slots are 1 to 8.", | |
| 1438 | message: checked.message, | |
| 1447 | 1439 | }); |
| 1440 | const { mode } = checked; | |
| 1448 | 1441 | const rule = field(form, "decisions"); |
| 1449 | 1442 | const decisionsBy = rule === "" ? repo.decisionsBy : rule; |
| 1450 | 1443 | if (!isDecisionsBy(decisionsBy)) |
| @@ -1463,14 +1456,7 @@ export async function autonomySettingsUpdate(ctx: Ctx): Promise<Response> { | ||
| 1463 | 1456 | }); |
| 1464 | 1457 | return flashTo(ctx, `${base}/settings#autonomy`, { |
| 1465 | 1458 | ...(updated.ok |
| 1466 | ? { | |
| 1467 | message: | |
| 1468 | mode === "off" | |
| 1469 | ? "Autonomy is off." | |
| 1470 | : mode === "assisted" | |
| 1471 | ? "Assisted: the crew works what is ready; green waits for you." | |
| 1472 | : "Autonomous: the crew works what is ready and ships on green, as you.", | |
| 1473 | } | |
| 1459 | ? { message: AUTONOMY_SAID[mode] } | |
| 1474 | 1460 | : { |
| 1475 | 1461 | tone: "danger" as const, |
| 1476 | 1462 | message: "Those settings could not be saved.", |
test/tui-autonomy.test.ts added +260
| @@ -0,0 +1,260 @@ | ||
| 1 | /** | |
| 2 | * A repository's autonomy in the terminal (issue #8132, pull #8137): an admin who is a person | |
| 3 | * picks any mode outright, and changes ceiling and slots through `updateRepoSettings`; out-of-range | |
| 4 | * input is refused with the page's sentence; an agent who administers is let | |
| 5 | * in as the page's API lets one; a reader and a writer who is not an admin get | |
| 6 | * no palette entry and see no value. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, 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 { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 14 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 15 | import { AUTONOMY_BOUNDS, AUTONOMY_SAID, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 16 | import { createUser, getUserById, type User } from "../src/core/users.ts"; | |
| 17 | import { serve } from "../src/http/server.ts"; | |
| 18 | import { FLASH_COOKIE } from "../src/web/flash.ts"; | |
| 19 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 20 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | import { promote } from "./helpers/members.ts"; | |
| 24 | import { settleApp } from "./helpers/tui.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | describe("autonomy in the terminal", () => { | |
| 29 | let dataDir: string; | |
| 30 | let forge: Forge; | |
| 31 | let ada: User; // site admin, owns the repository | |
| 32 | let erin: User; // administers it through a team | |
| 33 | let dave: User; // writes | |
| 34 | let bot: User; // an agent, an admin | |
| 35 | let repo: Repo; | |
| 36 | ||
| 37 | const saved = () => getRepoById(forge.db, repo.id)!; | |
| 38 | ||
| 39 | function boot(user: User | undefined) { | |
| 40 | const terminal = headlessTerminal(120, 32); | |
| 41 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 42 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 43 | app.start(); | |
| 44 | const key = (k: string) => { | |
| 45 | const ctrl = k.startsWith("ctrl+"); | |
| 46 | app.dispatch({ type: "key", key: ctrl ? k.slice(5) : k, ctrl, alt: false, shift: false }); | |
| 47 | }; | |
| 48 | const press = async (...keys: string[]) => { | |
| 49 | for (const k of keys) key(k); | |
| 50 | await settleApp(app); | |
| 51 | }; | |
| 52 | const type = async (text: string) => { | |
| 53 | for (const ch of text) key(ch); | |
| 54 | await settleApp(app); | |
| 55 | }; | |
| 56 | const open = async () => { | |
| 57 | await settleApp(app); | |
| 58 | await press("ctrl+k"); | |
| 59 | await type("Open a repository"); | |
| 60 | await press("enter"); | |
| 61 | await type("acme/arm"); | |
| 62 | await press("enter"); | |
| 63 | await press("ctrl+k"); | |
| 64 | await type("Autonomy"); | |
| 65 | await press("enter"); | |
| 66 | }; | |
| 67 | return { text: () => app.screen.toText(), press, type, open, stop: () => app.stop() }; | |
| 68 | } | |
| 69 | ||
| 70 | beforeAll(async () => { | |
| 71 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-autonomy-")); | |
| 72 | forge = createApp({ dataDir, port: 0 }); | |
| 73 | const make = async (username: string, adminIfFirst: boolean): Promise<User> => { | |
| 74 | const made = await createUser(forge.db, { | |
| 75 | origin: "seed", | |
| 76 | trusted: true, | |
| 77 | username, | |
| 78 | email: `${username}@forge.local`, | |
| 79 | password: "a-good-passphrase", | |
| 80 | adminIfFirst, | |
| 81 | }); | |
| 82 | if (!made.ok) throw new Error(made.error); | |
| 83 | return made.value; | |
| 84 | }; | |
| 85 | ada = await make("ada", true); | |
| 86 | dave = await make("dave", false); | |
| 87 | erin = await make("erin", false); | |
| 88 | const robot = await make("robo", false); | |
| 89 | promote(forge.db, dave, erin); | |
| 90 | forge.db.query("UPDATE user SET is_agent = 1, is_admin = 1 WHERE id = ?").run(robot.id); | |
| 91 | bot = getUserById(forge.db, robot.id)!; | |
| 92 | expect(bot.kind).toBe("agent"); | |
| 93 | const org = createOrg(forge.db, "acme", ada.id); | |
| 94 | if (!org.ok) throw new Error("org"); | |
| 95 | const writers = createTeam(forge.db, org.value.id, "writers", "write"); | |
| 96 | if (!writers.ok) throw new Error("team"); | |
| 97 | addTeamMember(forge.db, writers.value, dave.id); | |
| 98 | const stewards = createTeam(forge.db, org.value.id, "stewards", "admin"); | |
| 99 | if (!stewards.ok) throw new Error("team"); | |
| 100 | addTeamMember(forge.db, stewards.value, erin.id); | |
| 101 | const made = await createRepo(forge, ada, { name: "arm", owner: "acme", visibility: "public", description: "" }); | |
| 102 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 103 | repo = made.value; | |
| 104 | }, 60_000); | |
| 105 | ||
| 106 | afterAll(async () => { | |
| 107 | forge?.close(); | |
| 108 | if (dataDir) await rm(dataDir, { recursive: true, force: true }); | |
| 109 | }); | |
| 110 | ||
| 111 | // The confirm opens on Cancel; each `left` moves one mode back: Autonomous, Assisted, Off. | |
| 112 | const pick = { autonomous: ["left"], assisted: ["left", "left"], off: ["left", "left", "left"] } as const; | |
| 113 | ||
| 114 | test("an admin picks the mode after a confirm that names the modes and the ceiling; Cancel is the default", async () => { | |
| 115 | const h = boot(erin); | |
| 116 | await h.open(); | |
| 117 | expect(h.text()).toContain("Autonomy is off."); | |
| 118 | await h.press("m"); | |
| 119 | expect(h.text()).toContain("Set autonomy (now off)"); | |
| 120 | expect(h.text()).toContain(AUTONOMY_SAID.autonomous); | |
| 121 | expect(h.text()).toContain(`The ceiling that applies is $${saved().autonomyCeilingUsd} a day.`); | |
| 122 | await h.press("enter"); | |
| 123 | expect(saved().autonomy).toBe("off"); | |
| 124 | await h.press("m", "esc"); | |
| 125 | expect(saved().autonomy).toBe("off"); | |
| 126 | await h.press("m", ...pick.assisted, "enter"); | |
| 127 | expect(saved().autonomy).toBe("assisted"); | |
| 128 | expect(saved().autonomyBy).toBe(erin.id); | |
| 129 | h.stop(); | |
| 130 | }); | |
| 131 | ||
| 132 | for (const from of ["autonomous", "assisted"] as const) { | |
| 133 | test(`an admin goes from ${from} straight to off, with no save in between`, async () => { | |
| 134 | forge.db.query("UPDATE repo SET autonomy = ?, autonomy_by = ? WHERE id = ?").run(from, erin.id, repo.id); | |
| 135 | const h = boot(erin); | |
| 136 | await h.open(); | |
| 137 | await h.press("m"); | |
| 138 | expect(h.text()).toContain(`Set autonomy (now ${from})`); | |
| 139 | await h.press(...pick.off); | |
| 140 | expect(saved().autonomy).toBe(from); | |
| 141 | await h.press("enter"); | |
| 142 | expect(saved().autonomy).toBe("off"); | |
| 143 | expect(saved().autonomyBy).toBeNull(); | |
| 144 | expect(h.text()).toContain(AUTONOMY_SAID.off); | |
| 145 | h.stop(); | |
| 146 | }); | |
| 147 | } | |
| 148 | ||
| 149 | test("the terminal's toast for each mode is the settings page's flash", async () => { | |
| 150 | const servers = await serve(forge); | |
| 151 | try { | |
| 152 | const cookie = ( | |
| 153 | ( | |
| 154 | await fetch(`${servers.url}/signin`, { | |
| 155 | method: "POST", | |
| 156 | body: new URLSearchParams({ username: "erin", password: "a-good-passphrase" }), | |
| 157 | redirect: "manual", | |
| 158 | }) | |
| 159 | ).headers.get("set-cookie") ?? "" | |
| 160 | ).split(";")[0]!; | |
| 161 | const flashOf = async (mode: string): Promise<string> => { | |
| 162 | const res = await fetch(`${servers.url}/acme/arm/settings/autonomy`, { | |
| 163 | method: "POST", | |
| 164 | headers: { cookie, origin: servers.url }, | |
| 165 | body: new URLSearchParams({ autonomy: mode, ceiling: "5", slots: "1" }), | |
| 166 | redirect: "manual", | |
| 167 | }); | |
| 168 | const set = res.headers.getSetCookie().find((c) => c.startsWith(`${FLASH_COOKIE}=`)) ?? ""; | |
| 169 | return JSON.parse(decodeURIComponent(set.slice(FLASH_COOKIE.length + 1).split(";")[0]!)).message; | |
| 170 | }; | |
| 171 | // Each mode is reached from a different one, so every pick saves. | |
| 172 | for (const [mode, before] of [["off", "assisted"], ["assisted", "off"], ["autonomous", "off"]] as const) { | |
| 173 | const page = await flashOf(mode); | |
| 174 | expect(page).toBe(AUTONOMY_SAID[mode]); | |
| 175 | await flashOf(before); | |
| 176 | const h = boot(erin); | |
| 177 | await h.open(); | |
| 178 | await h.press("m", ...pick[mode], "enter"); | |
| 179 | expect(saved().autonomy).toBe(mode); | |
| 180 | expect(h.text()).toContain(page); | |
| 181 | h.stop(); | |
| 182 | } | |
| 183 | } finally { | |
| 184 | servers.stop(); | |
| 185 | } | |
| 186 | }); | |
| 187 | ||
| 188 | test("the ceiling and the slots are saved", async () => { | |
| 189 | const h = boot(ada); | |
| 190 | await h.open(); | |
| 191 | await h.press("c"); | |
| 192 | for (let i = 0; i < 10; i++) await h.press("backspace"); | |
| 193 | await h.type("12.5"); | |
| 194 | await h.press("enter"); | |
| 195 | expect(saved().autonomyCeilingUsd).toBe(12.5); | |
| 196 | await h.press("s"); | |
| 197 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 198 | await h.type("3"); | |
| 199 | await h.press("enter"); | |
| 200 | expect(saved().autonomySlots).toBe(3); | |
| 201 | expect(h.text()).toContain("$12.5"); | |
| 202 | h.stop(); | |
| 203 | }); | |
| 204 | ||
| 205 | test("a ceiling or slots out of range are refused with the page's sentence and nothing is saved", async () => { | |
| 206 | const h = boot(ada); | |
| 207 | await h.open(); | |
| 208 | const before = saved(); | |
| 209 | for (const bad of ["-1", "abc", ""]) { | |
| 210 | await h.press("c"); | |
| 211 | for (let i = 0; i < 10; i++) await h.press("backspace"); | |
| 212 | await h.type(bad); | |
| 213 | await h.press("enter"); | |
| 214 | expect(h.text()).toContain(AUTONOMY_BOUNDS); | |
| 215 | expect(saved().autonomyCeilingUsd).toBe(before.autonomyCeilingUsd); | |
| 216 | } | |
| 217 | for (const bad of ["0", "9", "2.5", "x"]) { | |
| 218 | await h.press("s"); | |
| 219 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 220 | await h.type(bad); | |
| 221 | await h.press("enter"); | |
| 222 | expect(h.text()).toContain(AUTONOMY_BOUNDS); | |
| 223 | expect(saved().autonomySlots).toBe(before.autonomySlots); | |
| 224 | } | |
| 225 | h.stop(); | |
| 226 | }); | |
| 227 | ||
| 228 | for (const [who, get] of [ | |
| 229 | ["a writer who is not an admin", () => dave], | |
| 230 | ["a reader", () => undefined], | |
| 231 | ] as const) { | |
| 232 | test(`${who} is offered no Autonomy entry and sees no value`, async () => { | |
| 233 | const before = saved(); | |
| 234 | const h = boot(get()); | |
| 235 | await h.open(); | |
| 236 | expect(h.text()).not.toContain("Slots"); | |
| 237 | expect(h.text()).not.toContain("Ceiling"); | |
| 238 | expect(h.text()).not.toContain("Mode "); | |
| 239 | await h.press("m", "left", "enter", "c", "s"); | |
| 240 | expect(h.text()).not.toContain("Set autonomy"); | |
| 241 | const after = saved(); | |
| 242 | expect(after.autonomy).toBe(before.autonomy); | |
| 243 | expect(after.autonomyCeilingUsd).toBe(before.autonomyCeilingUsd); | |
| 244 | expect(after.autonomySlots).toBe(before.autonomySlots); | |
| 245 | h.stop(); | |
| 246 | }); | |
| 247 | } | |
| 248 | ||
| 249 | test("an agent who administers the repository changes it, as the page's API lets it", async () => { | |
| 250 | const h = boot(bot); | |
| 251 | await h.open(); | |
| 252 | expect(h.text()).toContain("Slots"); | |
| 253 | await h.press("s"); | |
| 254 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 255 | await h.type("2"); | |
| 256 | await h.press("enter"); | |
| 257 | expect(saved().autonomySlots).toBe(2); | |
| 258 | h.stop(); | |
| 259 | }); | |
| 260 | }); |
new above
offline
The palette entry is offered whenever a repo is open. Only offer it to people who may administer the repo, as Settings is.