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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2679-crew-teams-a-named-group-of-crew-members → main 6 commits fork 7 threads · 7 unresolved
README.md +2
Diff not shown — a line 4,879 characters wide (limit 2,000). +2
View filedocs/design/audit.md +2 −1
| @@ -33,7 +33,7 @@ their own vocabulary instead of composing the named one. | ||
| 33 | 33 | |
| 34 | 34 | ## Finding 2 — the pages, and what each is for |
| 35 | 35 | |
| 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` | |
| 37 | 37 | that returns a page (`test/design-audit.test.ts` holds this list to the router, |
| 38 | 38 | both ways). Each entry reads `route` — module · what it is for · what |
| 39 | 39 | 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`, | ||
| 118 | 118 | - `/settings/accounts` — `pages/accounts.ts` · every account on the forge · `/settings/people` · none seen. |
| 119 | 119 | - `/settings/people` — `pages/people.ts` · people and their roles · `/settings/accounts`, `/settings/crew` · none seen. |
| 120 | 120 | - `/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. | |
| 121 | 122 | - `/settings/origins` — `pages/origins.ts` (1,497 lines) · other forges this one reads and mirrors from · `/migrate` · none seen. |
| 122 | 123 | |
| 123 | 124 | ## 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 | ||
| 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"; | ||
| 27 | 27 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 28 | 28 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 29 | 29 | import { changeLimit, listLimits } from "../actions/limits.ts"; |
| 30 | import { changeTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts"; | |
| 30 | 31 | import { changeQuotas, readQuotas } from "../actions/quotas.ts"; |
| 31 | 32 | import { changeSignup, readSignup } from "../actions/signup.ts"; |
| 32 | 33 | import { |
| @@ -1567,6 +1568,36 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1567 | 1568 | ); |
| 1568 | 1569 | }); |
| 1569 | 1570 | |
| 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 | ||
| 1570 | 1601 | // Per-account quotas (`core/quotas.ts`): null is no limit. Nothing |
| 1571 | 1602 | // enforces them yet. |
| 1572 | 1603 | 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"; | ||
| 24 | 24 | import { listAccess } from "../actions/access_log.ts"; |
| 25 | 25 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 26 | 26 | import { changeLimit, listLimits } from "../actions/limits.ts"; |
| 27 | import { changeTeam, planTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts"; | |
| 27 | 28 | import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../actions/cloud.ts"; |
| 28 | 29 | import { usesByVendor, type VendorUse } from "../cloud/declared.ts"; |
| 29 | 30 | import { cloudOverview } from "../cloud/overview.ts"; |
| @@ -2705,6 +2706,56 @@ const TOOLS: readonly Tool[] = [ | ||
| 2705 | 2706 | }); |
| 2706 | 2707 | }, |
| 2707 | 2708 | }, |
| 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 |
||
| 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
|
||
| 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 | }, | |
| 2708 | 2759 | { |
| 2709 | 2760 | name: "cloud_accounts", |
| 2710 | 2761 | description: |
src/http/server.ts +4
| @@ -16,6 +16,7 @@ import { accessPage } from "../web/pages/access.ts"; | ||
| 16 | 16 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 17 | 17 | import { flowPage } from "../web/pages/flow.ts"; |
| 18 | 18 | import { systemPage, systemStream } from "../web/pages/system.ts"; |
| 19 | import { teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts"; | |
| 19 | 20 | import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; |
| 20 | 21 | import { contributePage } from "../web/pages/contribute.ts"; |
| 21 | 22 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| @@ -882,6 +883,9 @@ export function buildRouter(): Router<Handler> { | ||
| 882 | 883 | router.add("POST", "/settings/accounts/:username/verify", accountAct("verify")); |
| 883 | 884 | router.add("GET", "/settings/crew", crewPage); |
| 884 | 885 | 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); | |
| 885 | 889 | router.add("GET", "/settings/crew/:username", agentPage); |
| 886 | 890 | router.add("POST", "/settings/crew/:username", agentUpdate); |
| 887 | 891 | router.add("POST", "/settings/crew/:username/delete", agentDelete); |
src/tui/forge.ts +9
| @@ -55,6 +55,7 @@ import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/mod | ||
| 55 | 55 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 56 | 56 | import { invitesScreen } from "./screens/invites.ts"; |
| 57 | 57 | import { quotasScreen } from "./screens/quotas.ts"; |
| 58 | import { teamsScreen } from "./screens/teams.ts"; | |
| 58 | 59 | import { domainsScreen } from "./screens/domains.ts"; |
| 59 | 60 | import { ownerScreen } from "./screens/owner.ts"; |
| 60 | 61 | import { resilienceLines, resilienceScreen } from "./screens/resilience.ts"; |
| @@ -439,6 +440,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 439 | 440 | hint: "what one account may own, and who changed it", |
| 440 | 441 | run: () => ctx.push(quotasScreen(session, ctx)), |
| 441 | 442 | }); |
| 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 | }); | |
| 442 | 451 | // Invite links, as /admin/limits has them: an admin who is a person (#2337). |
| 443 | 452 | if (session.user?.isAdmin === true && session.user.kind === "user") |
| 444 | 453 | 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
|
||
| 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 |
||
| 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 { | ||
| 493 | 493 | ${agentFields(values, usernameError === undefined ? error : undefined)} |
| 494 | 494 | <div class="form-actions"> |
| 495 | 495 | ${btn("Create agent", { variant: "primary", icon: "bot" })} |
| 496 | ${btn("Teams", { href: "/settings/teams", variant: "ghost" })} | |
| 496 | 497 | </div> |
| 497 | 498 | </form>`, |
| 498 | 499 | { |
src/web/pages/teams.ts added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | /** | |
This page has no terminal counterpart. Add |
||
| 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. |
||
| 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 <b>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"); | ||
| 11 | 11 | |
| 12 | 12 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 13 | 13 | 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\/)/; | |
| 15 | 15 | |
| 16 | 16 | const getRoutes = [ |
| 17 | 17 | ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g), |
test/mcp.test.ts +1
| @@ -231,6 +231,7 @@ describe("the forge over MCP", () => { | ||
| 231 | 231 | "system", |
| 232 | 232 | "contribute", |
| 233 | 233 | "limits", |
| 234 | "teams", | |
| 234 | 235 | "cloud_accounts", |
| 235 | 236 | "cloud_account", |
| 236 | 237 | "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 | }); |
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/teamsand the audit see a write made from any door.