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

Crew teams: a named group of crew members with a charter and a dial the owner sets on one settings page #2820

Merged opened this

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

Merged

scout/forge:scout/2679-crew-teams-a-named-group-of-crew-members → main 6 commits fork 7 threads · 7 unresolved

  • Merged. scout/2679-crew-teams-a-named-group-of-crew-members was merged into main as 0cd02d6.

Review

Sign in to review this change.
16 files changed +972 −2
README.md +2

Diff not shown — a line 4,879 characters wide (limit 2,000). +2

View file
docs/design/audit.md +2 −1
@@ -33,7 +33,7 @@ their own vocabulary instead of composing the named one.
3333
3434 ## Finding 2 — the pages, and what each is for
3535
36 There are 60 top-level pages, one entry each: every static `GET` route in `src/http/server.ts`
36 There are 61 top-level pages, one entry each: every static `GET` route in `src/http/server.ts`
3737 that returns a page (`test/design-audit.test.ts` holds this list to the router,
3838 both ways). Each entry reads `route` — module · what it is for · what
3939 duplicates it · what breaks `docs/DESIGN.md`. "Breaks" counts colour literals
@@ -118,6 +118,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
118118 - `/settings/accounts` — `pages/accounts.ts` · every account on the forge · `/settings/people` · none seen.
119119 - `/settings/people` — `pages/people.ts` · people and their roles · `/settings/accounts`, `/settings/crew` · none seen.
120120 - `/settings/crew` — `pages/crew.ts` · the named agents and what each may do · `/settings/model`, `/settings/telemetry` · none seen.
121 - `/settings/teams` — `pages/teams.ts` · the named crew teams, each with a charter, a dial and members · `/settings/crew` · none seen.
121122 - `/settings/origins` — `pages/origins.ts` (1,497 lines) · other forges this one reads and mirrors from · `/migrate` · none seen.
122123
123124 ## Operator views — why each is a page (#2696)
src/actions/teams.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * Crew teams (`core/teams.ts`), for a forge admin who is a person. Everyone
3 * else, an agent included, is told `not_found`.
4 */
5
6 import type { App } from "../app.ts";
7 import { listAgents, type Agent } from "../core/agents.ts";
8 import { CHARTER_MAX, DIAL_MAX, getTeam, listTeams, parseDial, refusal, saveTeam, type Team, type TeamError } from "../core/teams.ts";
9 import type { User } from "../core/users.ts";
10 import { err, ok, type Result } from "../kernel/result.ts";
11 import { writeSetting } from "../settings/limits.ts";
12
13 /** One sentence for each refusal, said the same by the page, API, MCP tool and terminal. */
14 export const TEAM_SAYS: Record<TeamError, string> = {
15 not_found: "No such team.",
16 bad_dial: `A dial is a whole number from 0 to ${DIAL_MAX}.`,
17 bad_charter: `A charter is at most ${CHARTER_MAX} characters.`,
18 over_100: `The dials together may not pass ${DIAL_MAX}%.`,
19 bad_member: "A member must be an agent on this forge.",
20 };
21
22 /** What a team is, in the words kept for a change: dial, member count, charter length. */
23 const summary = (t: Team): string =>
24 `${t.dial}% · ${t.members.length} member${t.members.length === 1 ? "" : "s"} · charter ${t.charter.length}`;
25
26 function admin(actor: User | undefined): Result<User, "not_found"> {
27 return actor?.isAdmin === true && actor.kind !== "agent" ? ok(actor) : err("not_found");
28 }
29
30 export function readTeams(
31 app: App,
32 actor: User | undefined,
33 ): Result<{ teams: Team[]; agents: Agent[] }, "not_found"> {
34 const who = admin(actor);
35 return who.ok ? ok({ teams: listTeams(app.db), agents: listAgents(app.db) }) : who;
36 }
37
outdated

The save isn't recorded and no event is emitted. Limits and quotas keep each change with who made it, and this one should too: record actor, slug, before and after, and emit, so /settings/teams and the audit see a write made from any door.

38 /** What a change would make of a team, or why it is refused; nothing is written. */
39 export function planTeam(
40 app: App,
41 actor: User | undefined,
42 slug: string,
43 raw: { charter?: string; dial?: unknown; members?: string[] },
44 ): Result<{ now: Team; input: { charter: string; dial: number; members: string[] } }, TeamError> {
45 const who = admin(actor);
46 if (!who.ok) return who;
47 const now = getTeam(app.db, slug);
48 if (!now) return err("not_found");
49 const dial = parseDial(raw.dial === undefined ? String(now.dial) : raw.dial);
50 if (!dial.ok) return dial;
51 const input = {
52 charter: raw.charter ?? now.charter,
53 dial: dial.value,
54 members: [...new Set(raw.members ?? now.members)],
55 };
56 const refused = refusal(app.db, slug, input);
57 return refused ? err(refused) : ok({ now, input });
58 }
59
60 /** A field left out keeps what the team has; the page always sends all three. */
61 export function changeTeam(
62 app: App,
63 actor: User | undefined,
64 slug: string,
65 raw: { charter?: string; dial?: unknown; members?: string[] },
66 ): Result<Team, TeamError> {
67 const who = admin(actor);
68 if (!who.ok) return who;
69 const plan = planTeam(app, actor, slug, raw);
70 if (!plan.ok) return plan;
71 const { now, input } = plan.value;
72 const saved = saveTeam(app.db, slug, input);
73 if (!saved.ok) return saved;
74 const next = saved.value;
75 const same =
76 next.charter === now.charter &&
77 next.dial === now.dial &&
78 next.members.join() === now.members.join();
79 if (!same) {
80 // Kept with who made it, as limits and quotas are; the audit reads the event.
81 writeSetting(app.db, `team:${slug}`, null, summary(now), summary(next), who.value.id);
82 app.bus.append({
83 kind: "team.changed",
84 actorId: who.value.id,
85 payload: { slug, dial: next.dial, from: now.dial, members: next.members.length },
86 });
87 }
88 return saved;
89 }
90
91 export interface TeamChange {
92 readonly slug: string;
93 readonly from: string;
94 readonly to: string;
95 readonly actorName: string;
96 readonly at: string;
97 }
98
99 /** The most recent team changes, newest first. */
100 export function teamChanges(app: App, limit = 20): TeamChange[] {
101 return app.db
102 .query<{ key: string; from_value: string; to_value: string; username: string | null; at: string }, [number]>(
103 `SELECT c.key, c.from_value, c.to_value, u.username, c.at
104 FROM forge_setting_change c LEFT JOIN user u ON u.id = c.actor_id
105 WHERE c.key LIKE 'team:%' ORDER BY c.at DESC, c.rowid DESC LIMIT ?1`,
106 )
107 .all(limit)
108 .map((r) => ({ slug: r.key.slice(5), from: r.from_value, to: r.to_value, actorName: r.username ?? "", at: r.at }));
109 }
src/core/migrations/20261019130000-crew-teams.ts added +23
@@ -0,0 +1,23 @@
1 // Crew teams (#2679): a named group of agents with a charter and a dial, the
2 // team's share (0-100) of builder slots and budget. An agent may be on many
3 // teams. A forge starts with four, dials at 0 until the owner sets them.
4 export default `
5 CREATE TABLE crew_team (
6 slug TEXT PRIMARY KEY,
7 name TEXT NOT NULL,
8 charter TEXT NOT NULL DEFAULT '',
9 dial INTEGER NOT NULL DEFAULT 0 CHECK (dial BETWEEN 0 AND 100),
10 at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
11 );
12 CREATE TABLE crew_team_member (
13 team_slug TEXT NOT NULL REFERENCES crew_team(slug) ON DELETE CASCADE,
14 user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
15 PRIMARY KEY (team_slug, user_id)
16 );
17 CREATE INDEX crew_team_member_user ON crew_team_member(user_id);
18 INSERT INTO crew_team (slug, name, charter) VALUES
19 ('design', 'Design', 'Owns the look and feel of the pages and the words on them; reviews changes to views and styles.'),
20 ('data-science', 'Data science', 'Owns measurement, benchmarks and what the numbers say; reviews changes to queries and reports.'),
21 ('security', 'Security', 'Owns the security floor, sessions and input handling; reviews changes at any boundary.'),
22 ('platform', 'Platform', 'Owns the runtime, storage, deploys and the check; reviews changes to the kernel and infrastructure.');
23 `;
src/core/teams.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * Crew teams: a named group of agents with a charter (what it owns, what it
3 * reviews) and a dial (0-100, its share of builder slots and budget). The
4 * dials of all teams never sum past 100. Pure data: no authz, no events.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { err, ok, type Result } from "../kernel/result.ts";
9
10 export const CHARTER_MAX = 2_000;
11 export const DIAL_MAX = 100;
12
13 export interface Team {
14 readonly slug: string;
15 readonly name: string;
16 readonly charter: string;
17 readonly dial: number;
18 /** User ids of the agents on the team. */
19 readonly members: string[];
20 }
21
22 export type TeamError = "not_found" | "bad_dial" | "bad_charter" | "over_100" | "bad_member";
23
24 export function listTeams(db: Database): Team[] {
25 const members = new Map<string, string[]>();
26 for (const m of db
27 .query<{ team_slug: string; user_id: string }, []>(
28 "SELECT team_slug, user_id FROM crew_team_member ORDER BY user_id",
29 )
30 .all()) {
31 const list = members.get(m.team_slug) ?? [];
32 list.push(m.user_id);
33 members.set(m.team_slug, list);
34 }
35 return db
36 .query<{ slug: string; name: string; charter: string; dial: number }, []>(
37 "SELECT slug, name, charter, dial FROM crew_team ORDER BY rowid",
38 )
39 .all()
40 .map((t) => ({ ...t, members: members.get(t.slug) ?? [] }));
41 }
42
43 export function getTeam(db: Database, slug: string): Team | undefined {
44 return listTeams(db).find((t) => t.slug === slug);
45 }
46
47 /** A dial: a whole number from 0 to 100, nothing else. */
48 export function parseDial(raw: unknown): Result<number, "bad_dial"> {
49 if (typeof raw !== "string" || !/^\d{1,3}$/.test(raw.trim())) return err("bad_dial");
50 const n = Number(raw.trim());
51 return n <= DIAL_MAX ? ok(n) : err("bad_dial");
52 }
53
54 /** Why `input` would be refused for `slug`, or undefined: what a save checks, without writing. */
55 export function refusal(
56 db: Database,
57 slug: string,
58 input: { charter: string; dial: number; members: string[] },
59 ): TeamError | undefined {
60 if (input.charter.length > CHARTER_MAX) return "bad_charter";
61 if (!Number.isInteger(input.dial) || input.dial < 0 || input.dial > DIAL_MAX) return "bad_dial";
62 if (!db.query("SELECT 1 FROM crew_team WHERE slug = ?1").get(slug)) return "not_found";
63 const others = db
64 .query<{ s: number }, [string]>(
65 "SELECT COALESCE(SUM(dial), 0) AS s FROM crew_team WHERE slug <> ?1",
66 )
67 .get(slug)!.s;
68 if (others + input.dial > DIAL_MAX) return "over_100";
69 for (const id of input.members)
70 if (!db.query("SELECT 1 FROM agent WHERE user_id = ?1").get(id)) return "bad_member";
71 return undefined;
72 }
73
74 /**
75 * Set a team's charter, dial and members in one transaction. The dials of
76 * the other teams plus this one must not pass 100; members must be agents.
77 */
78 export function saveTeam(
79 db: Database,
80 slug: string,
81 input: { charter: string; dial: number; members: string[] },
82 ): Result<Team, TeamError> {
83 const members = [...new Set(input.members)];
84 const save = db.transaction((): TeamError | undefined => {
85 const refused = refusal(db, slug, { ...input, members });
86 if (refused) return refused;
87 db.query("UPDATE crew_team SET charter = ?2, dial = ?3 WHERE slug = ?1").run(
88 slug,
89 input.charter,
90 input.dial,
91 );
92 db.query("DELETE FROM crew_team_member WHERE team_slug = ?1").run(slug);
93 for (const id of members)
94 db.query("INSERT INTO crew_team_member (team_slug, user_id) VALUES (?1, ?2)").run(slug, id);
95 return undefined;
96 });
97 const failed = save();
98 if (failed) return err(failed);
99 return ok(getTeam(db, slug)!);
100 }
src/http/api.ts +31
@@ -27,6 +27,7 @@ import { askCheck } from "../checks/again.ts";
2727 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
2828 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
2929 import { changeLimit, listLimits } from "../actions/limits.ts";
30 import { changeTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts";
3031 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
3132 import { changeSignup, readSignup } from "../actions/signup.ts";
3233 import {
@@ -1567,6 +1568,36 @@ export function apiRoutes(router: Router<Handler>): void {
15671568 );
15681569 });
15691570
1571 // Crew teams (`core/teams.ts`): a forge admin who is a person; the page's door.
1572 router.add("GET", "/api/v1/admin/teams", (ctx) => {
1573 const denied = accountAdmin(ctx, "ro");
1574 if (denied) return denied;
1575 const read = readTeams(ctx.app, ctx.auth.user);
1576 return read.ok ? json({ teams: read.value.teams }) : notFound();
1577 });
1578
1579 router.add("PUT", "/api/v1/admin/teams/:slug", async (ctx) => {
1580 const denied = accountAdmin(ctx, "admin");
1581 if (denied) return denied;
1582 const input = await body(ctx.request);
1583 const { charter, dial, members } = input;
1584 if (
1585 (charter !== undefined && typeof charter !== "string") ||
1586 (dial !== undefined && typeof dial !== "number" && typeof dial !== "string") ||
1587 (members !== undefined &&
1588 (!Array.isArray(members) || !members.every((m) => typeof m === "string")))
1589 )
1590 return apiError("invalid_body", 422, "send charter (text), dial (a whole number) and members (agent ids)");
1591 const changed = changeTeam(ctx.app, ctx.auth.user, ctx.params.slug ?? "", {
1592 ...(charter === undefined ? {} : { charter: charter as string }),
1593 ...(dial === undefined ? {} : { dial: String(dial) }),
1594 ...(members === undefined ? {} : { members: members as string[] }),
1595 });
1596 if (changed.ok) return json(changed.value);
1597 if (changed.error === "not_found") return notFound();
1598 return apiError(changed.error, 422, TEAM_SAYS[changed.error]);
1599 });
1600
15701601 // Per-account quotas (`core/quotas.ts`): null is no limit. Nothing
15711602 // enforces them yet.
15721603 router.add("GET", "/api/v1/admin/settings", (ctx) => {
src/http/mcp.ts +51
@@ -24,6 +24,7 @@ import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
2424 import { listAccess } from "../actions/access_log.ts";
2525 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
2626 import { changeLimit, listLimits } from "../actions/limits.ts";
27 import { changeTeam, planTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts";
2728 import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../actions/cloud.ts";
2829 import { usesByVendor, type VendorUse } from "../cloud/declared.ts";
2930 import { cloudOverview } from "../cloud/overview.ts";
@@ -2705,6 +2706,56 @@ const TOOLS: readonly Tool[] = [
27052706 });
27062707 },
27072708 },
2709 {
2710 name: "teams",
2711 description:
2712 "Crew teams, for a forge admin who is a person: each team's charter, dial (its share, a whole number from 0 to 100; all dials together may not pass 100) and member agents. Without `slug`, the teams. With `slug` and any of `charter`, `dial`, `members` (agent ids; replaces the list), the change it would make; with dryRun false, made, with an admin-scope token. Nothing reads the dials yet. The same as /settings/teams and /api/v1/admin/teams.",
2713 inputSchema: {
2714 type: "object",
2715 properties: {
2716 slug: { type: "string", maxLength: 64 },
2717 charter: { type: "string", maxLength: 2000 },
2718 dial: { type: "integer", minimum: 0, maximum: 100 },
2719 members: { type: "array", maxItems: 500, items: { type: "string", maxLength: 64 } },
2720 dryRun: { type: "boolean", description: "Plan only (the default). Send false to change it." },
2721 },
2722 },
2723 writes: true,
2724 dryByDefault: true,
2725 run(ctx, user, args) {
2726 const read = readTeams(ctx.app, user);
2727 if (!read.ok) return bad("not_found", "no such tool here");
2728 const slug = args["slug"];
2729 if (slug === undefined) return good({ teams: read.value.teams });
2730 const team = typeof slug === "string" ? read.value.teams.find((t) => t.slug === slug) : undefined;
2731 if (!team) return bad("not_found", `no team ${String(slug)}`);
2732 const { charter, dial, members } = args;
2733 if (
2734 (charter !== undefined && typeof charter !== "string") ||
2735 (dial !== undefined && typeof dial !== "number") ||
2736 (members !== undefined && (!Array.isArray(members) || !members.every((m) => typeof m === "string")))
2737 )
2738 return bad("invalid_body", "charter is text, dial a whole number, members a list of agent ids");
2739 const change = {
2740 ...(charter === undefined ? {} : { charter: charter as string }),
2741 ...(dial === undefined ? {} : { dial: String(dial) }),
2742 ...(members === undefined ? {} : { members: members as string[] }),
2743 };
2744 if (args["dryRun"] !== false) {
outdated

The dry run returns "Would change" without validating anything. A dial of 101, a dial that pushes the sum past 100, or a person's id in members all get planned as if they would succeed. Run the same checks before answering, and say the refusal from TEAM_SAYS instead.

2745 // The plan is the real call's answer: the same checks, the same refusal.
2746 const plan = planTeam(ctx.app, user, team.slug, change);
2747 if (!plan.ok) return bad(plan.error, TEAM_SAYS[plan.error]);
2748 return good({
2749 says: `Would change ${team.name}${dial === undefined ? "" : ` from ${team.dial}% to ${String(dial)}%`}. Send dryRun: false to change it.`,
2750 });
2751 }
outdated

bad(changed.error, changed.error) makes the message the bare code. Share the API's sentence map (move it into actions/teams.ts) so every door says the same thing.

2752 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
2753 return bad("forbidden", "changing a team needs an admin-scope token");
2754 const changed = changeTeam(ctx.app, user, team.slug, change);
2755 if (!changed.ok) return bad(changed.error, TEAM_SAYS[changed.error]);
2756 return good({ says: `${changed.value.name} saved at ${changed.value.dial}%.`, ...changed.value });
2757 },
2758 },
27082759 {
27092760 name: "cloud_accounts",
27102761 description:
src/http/server.ts +4
@@ -16,6 +16,7 @@ import { accessPage } from "../web/pages/access.ts";
1616 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1717 import { flowPage } from "../web/pages/flow.ts";
1818 import { systemPage, systemStream } from "../web/pages/system.ts";
19 import { teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts";
1920 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
2021 import { contributePage } from "../web/pages/contribute.ts";
2122 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
@@ -882,6 +883,9 @@ export function buildRouter(): Router<Handler> {
882883 router.add("POST", "/settings/accounts/:username/verify", accountAct("verify"));
883884 router.add("GET", "/settings/crew", crewPage);
884885 router.add("POST", "/settings/crew", agentCreate);
886 router.add("GET", "/settings/teams", teamsPage);
887 router.add("GET", "/settings/teams/stream", teamsStream);
888 router.add("POST", "/settings/teams/:slug", teamSet);
885889 router.add("GET", "/settings/crew/:username", agentPage);
886890 router.add("POST", "/settings/crew/:username", agentUpdate);
887891 router.add("POST", "/settings/crew/:username/delete", agentDelete);
src/tui/forge.ts +9
@@ -55,6 +55,7 @@ import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/mod
5555 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
5656 import { invitesScreen } from "./screens/invites.ts";
5757 import { quotasScreen } from "./screens/quotas.ts";
58 import { teamsScreen } from "./screens/teams.ts";
5859 import { domainsScreen } from "./screens/domains.ts";
5960 import { ownerScreen } from "./screens/owner.ts";
6061 import { resilienceLines, resilienceScreen } from "./screens/resilience.ts";
@@ -439,6 +440,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
439440 hint: "what one account may own, and who changed it",
440441 run: () => ctx.push(quotasScreen(session, ctx)),
441442 });
443 // Crew teams, as /settings/teams has them: an admin who is not an agent (#2679).
444 if (session.user?.isAdmin === true && session.user.kind !== "agent")
445 commands.push({
446 id: "teams",
447 title: "Crew teams",
448 hint: "each team's charter, dial and members",
449 run: () => ctx.push(teamsScreen(session, ctx)),
450 });
442451 // Invite links, as /admin/limits has them: an admin who is a person (#2337).
443452 if (session.user?.isAdmin === true && session.user.kind === "user")
444453 commands.push({
src/tui/screens/teams.ts added +97
@@ -0,0 +1,97 @@
1 /**
2 * Crew teams in the terminal, a forge admin's (#2679): the teams with their
3 * dial and members, as /settings/teams shows them. Enter sets the dial, `c`
4 * the charter, `m` the members (agent names, comma-separated). All go through
5 * `changeTeam`, so the same checks, refusal sentences and kept change apply.
6 * The gate is the action's: an admin who is not an agent; anyone else sees
7 * nothing and sets nothing.
8 */
9
10 import { changeTeam, readTeams, teamChanges, TEAM_SAYS } from "../../actions/teams.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import { style } from "../term/style.ts";
14 import { truncate } from "../term/width.ts";
15 import type { Session } from "../session.ts";
16 import { fill, len, split } from "../ui/layout.ts";
17 import { ListState, drawList, promptScreen, type ListRow } from "./common.ts";
18
19 export function teamsScreen(f: Session, ctx: Ctx): Screen {
20 const state = new ListState();
21 let note = "";
22 const read = () => readTeams(f.app, f.user);
23
outdated

allowed is never called. Remove it.

24 const save = (slug: string, raw: Parameters<typeof changeTeam>[3]): string | undefined => {
25 const made = changeTeam(f.app, f.user, slug, raw);
26 if (!made.ok) return TEAM_SAYS[made.error];
outdated

This saves, then only invalidates the terminal's own screen. An open /settings/teams in a browser never sees the change until it reloads. The bar asks for terminal writes to reach web viewers live. team.changed is already appended, so the page needs to listen for it.

27 note = `${made.value.name} saved at ${made.value.dial}%.`;
28 ctx.invalidate();
29 return undefined;
30 };
31
32 const rows = (): ListRow[] => {
33 const r = read();
34 if (!r.ok) return [];
35 const names = new Map(r.value.agents.map((a) => [a.user.id, a.user.username]));
36 return r.value.teams.map((t) => ({
37 title: t.name,
38 secondary: `${t.dial}%`,
39 meta: t.members.map((id) => names.get(id) ?? id).join(", ") || "no members",
40 }));
41 };
42
43 const screen: Screen = {
44 title: "Crew teams",
45 keys: [
46 binding(["j", "k"], "move cursor"),
47 binding(["enter"], "dial"),
48 binding(["c"], "charter"),
49 binding(["m"], "members"),
50 ],
51 render(area, s) {
52 const c = ctx.theme.c;
53 const r = read();
54 const last = r.ok ? teamChanges(f.app, 1)[0] : undefined;
55 const [head, list] = split(area, "v", [len(1), fill(1)]);
56 const text =
57 note !== ""
58 ? note
59 : !r.ok
60 ? "Teams are set by a forge admin."
61 : last === undefined
62 ? "Dials together may not pass 100%. No change yet."
63 : `${last.at.slice(0, 16)} ${last.slug} ${last.from} → ${last.to} ${last.actorName}`;
64 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
65 drawList(s, list!, c, state, rows());
66 },
67 key(e) {
68 const r = read();
69 if (state.key(e, r.ok ? r.value.teams.length : 0)) return true;
70 if (e.ctrl || e.alt || !r.ok) return false;
71 const team = r.value.teams[state.index];
72 if (team === undefined) return false;
73 const ask = (label: string, value: string, onSubmit: (v: string) => string | undefined) =>
74 ctx.push(promptScreen(ctx, screen, { title: team.name, label, value, onSubmit }));
75 if (e.key === "enter")
76 ask("a whole number from 0 to 100", String(team.dial), (v) => save(team.slug, { dial: v }));
77 else if (e.key === "c")
78 ask("what it owns, what it reviews", team.charter, (v) => save(team.slug, { charter: v.trim() }));
79 else if (e.key === "m") {
80 const byName = new Map(r.value.agents.map((a) => [a.user.username, a.user.id]));
81 const names = new Map(r.value.agents.map((a) => [a.user.id, a.user.username]));
82 ask(
83 "agent names, comma-separated",
84 team.members.map((id) => names.get(id) ?? id).join(", "),
85 (v) => {
86 const wanted = v.split(",").map((n) => n.trim()).filter((n) => n !== "");
87 const ids = wanted.map((n) => byName.get(n));
88 if (ids.some((id) => id === undefined)) return TEAM_SAYS.bad_member;
89 return save(team.slug, { members: ids as string[] });
90 },
91 );
92 } else return false;
93 return true;
94 },
95 };
96 return screen;
97 }
src/web/pages/crew.ts +1
@@ -493,6 +493,7 @@ function crewView(ctx: Ctx, values: AgentValues, error?: string): Html {
493493 ${agentFields(values, usernameError === undefined ? error : undefined)}
494494 <div class="form-actions">
495495 ${btn("Create agent", { variant: "primary", icon: "bot" })}
496 ${btn("Teams", { href: "/settings/teams", variant: "ghost" })}
496497 </div>
497498 </form>`,
498499 {
src/web/pages/teams.ts added +159
@@ -0,0 +1,159 @@
1 /**

This page has no terminal counterpart. Add src/tui/screens/teams.ts, wired in src/tui/forge.ts like quotasScreen (admin who is a person only) and calling changeTeam.

2 * Crew teams, for a forge admin (`core/teams.ts`): each team's charter, its
3 * dial and who is on it, set on one page linked from the crew page. Anyone
4 * else gets the not-found page, before a body is read.
5 */
6
7 import { changeTeam, readTeams, teamChanges, TEAM_SAYS } from "../../actions/teams.ts";
8 import { CHARTER_MAX, DIAL_MAX } from "../../core/teams.ts";
9 import type { Ctx } from "../../http/context.ts";
10 import { notFoundPage, settingsLayout } from "../chrome.ts";
11 import { field, readForm } from "../form.ts";
12 import { eventStream } from "../../http/sse.ts";
13 import { html, raw, type Html } from "../html.ts";
14 import { flashTo, page } from "../layout.ts";
15 import { btn, card, icon } from "../ui.ts";
16 import { accountSections } from "./origins.ts";
17
18 const isAdminPerson = (ctx: Ctx): boolean =>
19 ctx.auth.user?.isAdmin === true && ctx.auth.user.kind !== "agent";
20
21 /** The page's three regions, as HTML text: what the first paint and the stream both send. */
22 function regions(ctx: Ctx): Record<string, string> | undefined {
23 const read = readTeams(ctx.app, ctx.auth.user);
24 if (!read.ok) return undefined;
25 const { teams, agents } = read.value;
26 const changes = teamChanges(ctx.app, 10);
27 const total = teams.reduce((n, t) => n + t.dial, 0);
28 const cards = teams.map((t) =>
29 card(
30 html`<form method="post" action="/settings/teams/${t.slug}">
31 <div class="field">
32 <label for="charter-${t.slug}">Charter</label>
33 <textarea class="input" id="charter-${t.slug}" name="charter" rows="3" maxlength="${String(CHARTER_MAX)}">${t.charter}</textarea>
34 <span class="hint">What it owns, what it reviews.</span>
35 </div>
36 <div class="field">
37 <label for="dial-${t.slug}">Dial (%)</label>
38 <input class="input mono" id="dial-${t.slug}" type="number" name="dial" min="0" max="${String(DIAL_MAX)}" step="1" value="${String(t.dial)}" />
39 <span class="hint">Its share of builder slots and budget.</span>
40 </div>
41 <fieldset class="field">
42 <legend>Members</legend>
43 ${agents.length === 0
44 ? html`<span class="muted small">No agents yet.</span>`
45 : agents.map(
46 (a) => html`<label class="small">
47 <input type="checkbox" name="member" value="${a.user.id}" ${t.members.includes(a.user.id) ? "checked" : ""} />
48 ${a.user.username}
49 <span class="muted mono">${a.model === "" ? "default" : a.model}</span>
50 </label><br />`,
51 )}
52 </fieldset>
53 <div class="form-actions">
54 ${btn("Save", { variant: "primary", label: `Save ${t.name}` })}
55 </div>
56 </form>`,
57 { head: html`${icon("bot")}<h2>${t.name}</h2>` },
58 ),
59 );
60 const out: Record<string, Html> = {
61 "teams-total": html`${String(total)}%`,
62 "teams-cards": html`${cards}`,
63 "teams-changes":
64 changes.length === 0
65 ? html``
66 : card(
67 html`<ul class="small">${changes.map(
68 (c) => html`<li><span class="mono">${c.at.slice(0, 16)}</span> ${c.slug}: ${c.from} → ${c.to} <span class="muted">${c.actorName}</span></li>`,
69 )}</ul>`,
70 { head: html`<h2>Recent changes</h2>` },
71 ),
72 };
73 return Object.fromEntries(Object.entries(out).map(([k, h]) => [k, h.text]));
74 }
75
76 /**
77 * The client: one EventSource; a pushed region replaces the element of its
78 * id. The cards are left alone while one of their fields has focus, so a
79 * viewer's half-typed edit is never lost. Our own server's escaped markup.
80 */
81 export const teamsLiveJs = /* js */ `
82 (function () {
83 if (typeof EventSource === 'undefined') return;
84 var es = new EventSource('/settings/teams/stream');
85 es.addEventListener('teams', function (e) {
86 var data;
87 try { data = JSON.parse(e.data); } catch (_) { return; }
88 var regions = data && data.regions;
89 if (!regions) return;
90 for (var id in regions) {
91 var el = document.getElementById(id);
92 if (!el || el.innerHTML === regions[id]) continue;
93 if (id === 'teams-cards' && el.contains(document.activeElement)) continue;
94 el.innerHTML = regions[id];
95 }
96 });
97 })();
98 `;
99
100 // GET /settings/teams/stream: the page's regions again, each time a team changes.
101 export function teamsStream(ctx: Ctx): Response {
102 if (!isAdminPerson(ctx)) return notFoundPage(ctx);
103 return eventStream(ctx.request, (emit, _close, ready) => {
104 const send = () => {
105 const r = regions(ctx);

Low: the stream checks who you are only once, when it opens. regions(ctx) keeps reading ctx.auth.user as it was then. If an admin is demoted or signs out while a tab is open, that tab keeps receiving the team cards, the agent ids and the change list until it reconnects. /system/stream has the same shape, so this is a follow-up, not a blocker. Fix: re-read the user from the session (or check isAdmin against the db) before each send, and close the stream when the check fails.

106 if (r && ready()) emit("teams", { regions: r });
107 };
108 return ctx.app.bus.subscribe((event) => {
109 if (event.kind === "team.changed") send();
110 });
111 });
112 }
113
114 // GET /settings/teams
115 export function teamsPage(ctx: Ctx): Response {
116 const r = regions(ctx);
117 if (!r) return notFoundPage(ctx);
118 const at = (id: string): Html => raw(r[id]!);
119 return page({
120 ctx,
121 title: "Teams",
122 scriptJs: teamsLiveJs,
123 body: html`<div class="page-head"><h1>Teams</h1></div>
124 ${settingsLayout(
125 accountSections("crew", { admin: true }),
126 html`<p class="muted small">
127 A team is a named group of agents with a charter and a dial: its
128 share of builder slots and budget. The dials together may not pass
129 ${String(DIAL_MAX)}%; now <span class="mono" id="teams-total">${at("teams-total")}</span>.
130 An agent can be on more than one team. ${btn("Back to the crew", { href: "/settings/crew", variant: "ghost" })}
131 </p>
132 <div id="teams-cards">${at("teams-cards")}</div>
133 <div id="teams-changes">${at("teams-changes")}</div>`,
134 "Account settings",
135 )}`,
136 });
137 }
138
139 // POST /settings/teams/:slug
140 export async function teamSet(ctx: Ctx): Promise<Response> {
141 // Checked before the body is read: a non-admin learns nothing of the door.
142 if (!isAdminPerson(ctx)) return notFoundPage(ctx);
143 const form = await readForm(ctx, "admin");
144 if (form instanceof Response) return form;
145 const slug = ctx.params.slug ?? "";
146 const changed = changeTeam(ctx.app, ctx.auth.user, slug, {
147 charter: field(form, "charter").replace(/\r\n/g, "\n").trim(),
148 dial: field(form, "dial"),
149 members: form.getAll("member").filter((v): v is string => typeof v === "string"),
150 });
151 if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx);
152 return flashTo(
153 ctx,
154 "/settings/teams",
155 changed.ok
156 ? { message: `${changed.value.name} saved at ${changed.value.dial}%.` }
157 : { tone: "danger", message: TEAM_SAYS[changed.error] },
158 );
159 }
test/crew-teams.test.ts added +197
@@ -0,0 +1,197 @@
1 /**
2 * Crew teams (#2679): four to start, dials at 0; an admin sets design to 30%
3 * and reads it back; a non-admin gets the 404; the dials never sum past 100;
4 * an agent may be on two teams, whatever its model.
5 */
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 { createAgentRow } from "../src/core/agents.ts";
13 import { createToken } from "../src/core/tokens.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { teamChanges } from "../src/actions/teams.ts";
16 import { listEvents } from "../src/core/events.ts";
17 import { listTeams } from "../src/core/teams.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20
21 describe("crew teams", () => {
22 let dataDir: string;
23 let app: App;
24 let servers: Servers;
25 let alice: User;
26 let bob: User;
27 let bots: User[] = [];
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-teams-"));
31 app = createApp({ dataDir, port: 0 });
32 servers = await serve(app);
33 for (const username of ["alice", "bob", "bot1", "bot2"]) {
34 const made = await createUser(app.db, {
35 origin: "seed",
36 trusted: true,
37 username,
38 email: `${username}@forge.local`,
39 password: "a-good-passphrase",
40 adminIfFirst: username === "alice",
41 });
42 if (!made.ok) throw new Error(made.error);
43 if (username === "alice") alice = made.value;
44 else if (username === "bob") bob = made.value;
45 else {
46 bots.push(made.value);
47 createAgentRow(app.db, { userId: made.value.id, model: username === "bot1" ? "opus" : "haiku" });
48 }
49 }
50 });
51 afterAll(async () => {
52 await servers.stop();
53 await rm(dataDir, { recursive: true, force: true });
54 });
55
56 const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`;
57 const post = (u: User, slug: string, body: URLSearchParams, origin = servers.url) =>
58 fetch(`${servers.url}/settings/teams/${slug}`, {
59 method: "POST",
60 redirect: "manual",
61 headers: { cookie: cookie(u), "content-type": "application/x-www-form-urlencoded", origin },
62 body,
63 });
64 const dialOf = (slug: string) => listTeams(app.db).find((t) => t.slug === slug)!.dial;
65
66 test("a forge starts with four teams, dials at 0", () => {
67 const teams = listTeams(app.db);
68 expect(teams.map((t) => t.slug)).toEqual(["design", "data-science", "security", "platform"]);
69 expect(teams.every((t) => t.dial === 0 && t.charter !== "")).toBe(true);
70 });
71
72 test("an admin sets design to 30% and reads it back on the page", async () => {
73 const res = await post(alice, "design", new URLSearchParams({ charter: "Owns <b>looks</b>", dial: "30" }));
74 expect(res.status).toBe(303);
75 expect(dialOf("design")).toBe(30);
76 const text = await (await fetch(`${servers.url}/settings/teams`, { headers: { cookie: cookie(alice) } })).text();
77 expect(text).toContain('value="30"');
78 expect(text).toContain("Owns &lt;b&gt;looks");
79 expect(text).not.toContain("Owns <b>looks");
80 });
81
82 test("a non-admin gets the 404 on read and write, and a cross-origin post is refused", async () => {
83 const page = await fetch(`${servers.url}/settings/teams`, { headers: { cookie: cookie(bob) } });
84 expect(page.status).toBe(404);
85 expect((await post(bob, "design", new URLSearchParams({ dial: "90" }))).status).toBe(404);
86 expect(dialOf("design")).toBe(30);
87 expect((await post(alice, "design", new URLSearchParams({ dial: "5" }), "http://evil.example")).status).toBe(403);
88 expect(dialOf("design")).toBe(30);
89 });
90
91 test("the dials cannot sum past 100", async () => {
92 await post(alice, "security", new URLSearchParams({ dial: "70" }));
93 expect(dialOf("security")).toBe(70);
94 await post(alice, "platform", new URLSearchParams({ dial: "1" }));
95 expect(dialOf("platform")).toBe(0);
96 await post(alice, "design", new URLSearchParams({ dial: "31" }));
97 expect(dialOf("design")).toBe(30);
98 for (const bad of ["101", "-1", "abc", "", "1.5"]) {
99 await post(alice, "platform", new URLSearchParams({ dial: bad }));
100 expect(dialOf("platform")).toBe(0);
101 }
102 });
103
104 test("an agent can be on two teams, and a team holds agents on different models", async () => {
105 const [b1, b2] = bots as [User, User];
106 const members = (ids: string[]) => {
107 const p = new URLSearchParams({ dial: "0" });
108 for (const id of ids) p.append("member", id);
109 return p;
110 };
111 await post(alice, "platform", members([b1.id, b2.id]));
112 await post(alice, "data-science", members([b1.id]));
113 const byTeam = Object.fromEntries(listTeams(app.db).map((t) => [t.slug, t.members]));
114 expect(byTeam["platform"]!.sort()).toEqual([b1.id, b2.id].sort());
115 expect(byTeam["data-science"]).toEqual([b1.id]);
116 // A person is not an agent.
117 const refused = await post(alice, "platform", members([bob.id]));
118 const told = decodeURIComponent(
119 `${refused.headers.get("location") ?? ""} ${refused.headers.get("set-cookie") ?? ""}`,
120 ).replace(/\+/g, " ");
121 expect(told).toContain("must be an agent");
122 expect(listTeams(app.db).find((t) => t.slug === "platform")!.members.length).toBe(2);
123 });
124
125 const api = (u: User, method: string, path: string, body?: unknown, scope: "ro" | "admin" = "admin") =>
126 fetch(`${servers.url}${path}`, {
127 method,
128 headers: {
129 "content-type": "application/json",
130 authorization: `Bearer ${createToken(app.db, u.id, "t", scope).token}`,
131 },
132 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
133 });
134 const mcp = async (u: User, name: string, args: unknown, scope: "ro" | "admin" = "admin") => {
135 const res = await fetch(`${servers.url}/mcp`, {
136 method: "POST",
137 headers: {
138 "content-type": "application/json",
139 accept: "application/json, text/event-stream",
140 authorization: `Bearer ${createToken(app.db, u.id, "t", scope).token}`,
141 },
142 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
143 });
144 const body = (await res.json()) as any;
145 const text = body.result.content[0].text as string;
146 try {
147 return JSON.parse(text);
148 } catch {
149 return { error: text };
150 }
151 };
152
153 test("the JSON API reads and sets a team, keeps what is left out, and refuses the rest", async () => {
154 const [b1] = bots as [User];
155 expect((await api(alice, "GET", "/api/v1/admin/teams")).status).toBe(200);
156 const set = await api(alice, "PUT", "/api/v1/admin/teams/design", { dial: 25, members: [b1.id] });
157 expect(set.status).toBe(200);
158 expect(await set.json()).toMatchObject({ slug: "design", dial: 25, members: [b1.id] });
159 await api(alice, "PUT", "/api/v1/admin/teams/design", { charter: "Owns the look." });
160 const kept = listTeams(app.db).find((t) => t.slug === "design")!;
161 expect([kept.charter, kept.dial, kept.members]).toEqual(["Owns the look.", 25, [b1.id]]);
162 expect((await api(alice, "PUT", "/api/v1/admin/teams/design", { dial: 101 })).status).toBe(422);
163 expect((await api(alice, "PUT", "/api/v1/admin/teams/design", { dial: 1.5 })).status).toBe(422);
164 expect((await api(alice, "PUT", "/api/v1/admin/teams/design", { members: [bob.id] })).status).toBe(422);
165 expect((await api(alice, "PUT", "/api/v1/admin/teams/nope", { dial: 1 })).status).toBe(404);
166 expect((await api(alice, "PUT", "/api/v1/admin/teams/design", { dial: 5 }, "ro")).status).toBe(403);
167 expect((await api(bob, "GET", "/api/v1/admin/teams")).status).toBe(403);
168 expect((await api(bob, "PUT", "/api/v1/admin/teams/design", { dial: 1 })).status).toBe(403);
169 expect(listTeams(app.db).find((t) => t.slug === "design")!.dial).toBe(25);
170 });
171
172 test("the teams tool plans by default, changes with dryRun false, and is not for a non-admin", async () => {
173 expect(JSON.stringify(await mcp(alice, "teams", { slug: "security", dial: 101 }))).toContain(
174 "A dial is a whole number from 0 to 100.",
175 );
176 const dryOver = JSON.stringify(await mcp(alice, "teams", { slug: "security", dial: 90 }));
177 expect(dryOver).toContain("The dials together may not pass 100%.");
178 expect(dryOver).not.toContain("Would change");
179 expect(JSON.stringify(await mcp(alice, "teams", { slug: "security", members: [bob.id] }))).toContain(
180 "A member must be an agent",
181 );
182 const planned = await mcp(alice, "teams", { slug: "security", dial: 10 });
183 expect(planned.says).toContain("Would change");
184 expect(listTeams(app.db).find((t) => t.slug === "security")!.dial).toBe(70);
185 const done = await mcp(alice, "teams", { slug: "security", dial: 10, dryRun: false });
186 expect(done).toMatchObject({ slug: "security", dial: 10 });
187 expect(listTeams(app.db).find((t) => t.slug === "security")!.dial).toBe(10);
188 const over = await mcp(alice, "teams", { slug: "security", dial: 90, dryRun: false });
189 expect(JSON.stringify(over)).toContain("The dials together may not pass 100%.");
190 const kept = teamChanges(app).filter((c) => c.slug === "security");
191 expect(kept[0]).toMatchObject({ actorName: "alice" });
192 expect(kept[0]!.to).toStartWith("10%");
193 expect(listEvents(app.db, { kind: "team.changed" }).some((e) => e.payload["slug"] === "security")).toBe(true);
194 expect(JSON.stringify(await mcp(bob, "teams", {}))).toContain("not_found");
195 expect(listTeams(app.db).find((t) => t.slug === "security")!.dial).toBe(10);
196 });
197 });
test/design-audit.test.ts +1 −1
@@ -11,7 +11,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8");
1111
1212 /** Routes that answer a file, a fragment, a redirect hop or a callback. */
1313 const NOT_PAGES =
14 /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|sw\.js$|robots\.txt$|sitemap\.xml$|manifest\.webmanifest$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/)/;
14 /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|sitemap\.xml$|manifest\.webmanifest$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/)/;
1515
1616 const getRoutes = [
1717 ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g),
test/mcp.test.ts +1
@@ -231,6 +231,7 @@ describe("the forge over MCP", () => {
231231 "system",
232232 "contribute",
233233 "limits",
234 "teams",
234235 "cloud_accounts",
235236 "cloud_account",
236237 "quotas",
test/tui-teams.test.ts added +185
@@ -0,0 +1,185 @@
1 /**
2 * Crew teams in the terminal (#2679): an admin who is not an agent lists the
3 * teams, sets a dial, a charter and the members through `changeTeam` (so the
4 * same refusals apply and the change is kept and put on the bus); anyone else
5 * has no command and, driving the screen directly, can neither read nor write.
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 as Forge } from "../src/app.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { serve } from "../src/http/server.ts";
14 import { teamChanges } from "../src/actions/teams.ts";
15 import { createAgentRow } from "../src/core/agents.ts";
16 import { listEvents } from "../src/core/events.ts";
17 import { getTeam } from "../src/core/teams.ts";
18 import { createUser, getUserByName } from "../src/core/users.ts";
19 import { createForgeApp } from "../src/tui/forge.ts";
20 import { teamsScreen } from "../src/tui/screens/teams.ts";
21 import { Session } from "../src/tui/session.ts";
22 import { headlessTerminal } from "../src/tui/term/terminal.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { settleApp } from "./helpers/tui.ts";
25
26 quietLogs();
27
28 describe("teams in the terminal", () => {
29 let dataDir: string;
30 let forge: Forge;
31
32 function boot(username: string) {
33 const terminal = headlessTerminal(140, 32);
34 const user = getUserByName(forge.db, username)!;
35 const { app } = createForgeApp(
36 forge,
37 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
38 { terminal, exitHooks: false },
39 );
40 app.start();
41 const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
42 return {
43 text: () => app.screen.toText(),
44 press: async (...keys: string[]) => {
45 keys.forEach(key);
46 await settleApp(app);
47 },
48 ctrl: async (k: string) => {
49 app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false });
50 await settleApp(app);
51 },
52 type: async (t: string) => {
53 for (const ch of t) key(ch);
54 await settleApp(app);
55 },
56 stop: () => app.stop(),
57 };
58 }
59
60 beforeAll(async () => {
61 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-teams-"));
62 forge = createApp({ dataDir, port: 0 });
63 for (const username of ["alice", "bob", "carol", "bot1"]) {
64 const made = await createUser(forge.db, {
65 origin: "seed",
66 trusted: true,
67 username,
68 email: `${username}@forge.local`,
69 password: "a-good-passphrase",
70 adminIfFirst: username === "alice",
71 });
72 if (!made.ok) throw new Error(made.error);
73 if (username === "bot1") createAgentRow(forge.db, { userId: made.value.id, model: "opus" });
74 }
75 forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run();
76 });
77 afterAll(async () => {
78 forge.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 const open = async (h: ReturnType<typeof boot>) => {
83 await h.ctrl("k");
84 await h.type("Crew teams");
85 await h.press("enter");
86 };
87
88 test("an admin sets the dial, charter and members; it is kept with who and emitted", async () => {
89 const h = boot("alice");
90 await open(h);
91 expect(h.text()).toContain("Design");
92 expect(h.text()).toContain("Platform");
93
94 await h.press("enter");
95 for (let i = 0; i < 4; i++) await h.press("backspace");
96 await h.type("30");
97 await h.press("enter");
98 expect(getTeam(forge.db, "design")!.dial).toBe(30);
99 expect(h.text()).toContain("Design saved at 30%.");
100
101 await h.press("m");
102 await h.type("bot1");
103 await h.press("enter");
104 expect(getTeam(forge.db, "design")!.members).toHaveLength(1);
105 expect(h.text()).toContain("bot1");
106
107 await h.press("c");
108 await h.type(" Also owns icons.");
109 await h.press("enter");
110 expect(getTeam(forge.db, "design")!.charter).toContain("Also owns icons.");
111
112 const kept = teamChanges(forge).find((c) => c.from.startsWith("0% · 0 members"));
113 expect(kept).toMatchObject({ slug: "design", actorName: "alice" });
114 expect(kept!.to).toStartWith("30% · 0 members");
115 expect(listEvents(forge.db, { kind: "team.changed" }).length).toBe(3);
116 h.stop();
117 });
118
119 test("a save made in the terminal arrives on an open /settings/teams stream", async () => {
120 const servers = await serve(forge);
121 const alice = getUserByName(forge.db, "alice")!;
122 const bob = getUserByName(forge.db, "bob")!;
123 const cookie = (id: string) => `forge_session=${createSession(forge.db, id).token}`;
124 const denied = await fetch(`${servers.url}/settings/teams/stream`, { headers: { cookie: cookie(bob.id) } });
125 expect(denied.status).toBe(404);
126 await denied.body?.cancel();
127 const res = await fetch(`${servers.url}/settings/teams/stream`, { headers: { cookie: cookie(alice.id) } });
128 expect(res.headers.get("content-type")).toContain("text/event-stream");
129 const reader = res.body!.getReader();
130 const h = boot("alice");
131 await open(h);
132 await h.press("j", "j", "enter");
133 for (let i = 0; i < 4; i++) await h.press("backspace");
134 await h.type("7");
135 await h.press("enter");
136 const decoder = new TextDecoder();
137 let text = "";
138 const deadline = Date.now() + 3000;
139 while (!text.includes("7%") && Date.now() < deadline) {
140 const { value, done } = await reader.read();
141 if (done) break;
142 text += decoder.decode(value);
143 }
144 await reader.cancel();
145 h.stop();
146 servers.stop();
147 expect(text).toContain("event: teams");
148 expect(text).toContain("teams-total");
149 expect(text).toContain("security: ");
150 });
151
152 test("a refusal says the shared sentence and changes nothing", async () => {
153 const h = boot("alice");
154 await open(h);
155 await h.press("j", "enter");
156 for (let i = 0; i < 4; i++) await h.press("backspace");
157 await h.type("80");
158 await h.press("enter");
159 expect(h.text()).toContain("may not pass 100%");
160 expect(getTeam(forge.db, "data-science")!.dial).toBe(0);
161 h.stop();
162 });
163
164 test("a non-admin and an admin agent have no command", async () => {
165 for (const who of ["bob", "carol"]) {
166 const h = boot(who);
167 await h.ctrl("k");
168 await h.type("Crew teams");
169 expect(h.text()).not.toContain("charter, dial and members");
170 h.stop();
171 }
172 });
173
174 test("a non-admin driving the screen reads nothing and writes nothing", () => {
175 for (const who of ["bob", "carol"]) {
176 const user = getUserByName(forge.db, who)!;
177 const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user });
178 const pushed: unknown[] = [];
179 const screen = teamsScreen(session, { push: (s: unknown) => pushed.push(s), invalidate() {} } as never);
180 for (const k of ["enter", "c", "m"])
181 screen.key?.({ type: "key", key: k, ctrl: false, alt: false, shift: false } as never);
182 expect(pushed).toEqual([]);
183 }
184 });
185 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs