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

Merged opened this

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

Merged

mason/forge:mason/8132-an-admin-sets-the-crew-s-autonomy-from-t → main 7 commits fork 2 threads · 2 unresolved

  • Merged. mason/8132-an-admin-sets-the-crew-s-autonomy-from-t was merged into main as 0b92259.

Review

Sign in to review this change.
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'
7575
7676 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.
7777
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
7880 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.
7981
8082 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"];
8282 export const isAutonomy = (value: unknown): value is Autonomy =>
8383 (AUTONOMY as readonly unknown[]).includes(value);
8484
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
85114 export interface Repo {
86115 readonly id: string;
87116 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
outdated

The palette entry is offered whenever a repo is open. Only offer it to people who may administer the repo, as Settings is.

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";
134134 import { visionCommands } from "./vision.ts";
135135 import { labelsCommands } from "./labels.ts";
136136 import { appEnvCommands } from "./appenv.ts";
137 import { autonomyCommands } from "./autonomy.ts";
137138 import { externalEditor, editorHint, hasExternalEditor } from "./external.ts";
138139 import { finderScreen } from "./finder.ts";
139140 import { issuesScreen } from "./issues.ts";
@@ -1448,6 +1449,7 @@ export function workspaceScreen(
14481449 ...visionCommands(f, ctx, repo),
14491450 ...labelsCommands(f, ctx, repo),
14501451 ...appEnvCommands(f, ctx, repo),
1452 ...autonomyCommands(f, ctx, repo),
14511453 ],
14521454 get title() {
14531455 return sourceName(src);
src/web/pages/settings.ts +6 −20
@@ -49,7 +49,7 @@ import {
4949 APP_ENV_NOT_SET,
5050 SCOPE_WORDS,
5151 } from "../../actions/app_env_words.ts";
52 import { isAutonomy, isDecisionsBy } from "../../core/repos.ts";
52 import { AUTONOMY_SAID, checkAutonomy, isDecisionsBy } from "../../core/repos.ts";
5353 import { updateRepoSettings } from "../../actions/repos.ts";
5454 import { appsRuntime } from "../../apps/runtime.ts";
5555 import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
@@ -1429,22 +1429,15 @@ export async function autonomySettingsUpdate(ctx: Ctx): Promise<Response> {
14291429 if (gate instanceof Response) return gate;
14301430 const { repo, form } = gate;
14311431 const base = repoBase(repo);
1432 const mode = field(form, "autonomy");
14331432 const ceiling = Number(field(form, "ceiling"));
14341433 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)
14431436 return flashTo(ctx, `${base}/settings#autonomy`, {
14441437 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,
14471439 });
1440 const { mode } = checked;
14481441 const rule = field(form, "decisions");
14491442 const decisionsBy = rule === "" ? repo.decisionsBy : rule;
14501443 if (!isDecisionsBy(decisionsBy))
@@ -1463,14 +1456,7 @@ export async function autonomySettingsUpdate(ctx: Ctx): Promise<Response> {
14631456 });
14641457 return flashTo(ctx, `${base}/settings#autonomy`, {
14651458 ...(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] }
14741460 : {
14751461 tone: "danger" as const,
14761462 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs