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

Boards through MCP and the terminal: list, read, write and delete a board from both doors #1910

Merged opened this

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

Merged

scout/forge:scout/1817-boards-through-mcp-and-the-terminal-list → main 1 commit fork

  • Merged. scout/1817-boards-through-mcp-and-the-terminal-list was merged into main as e4041cd.

Review

Sign in to review this change.
7 files changed +352 −2
README.md +2 −2
@@ -848,7 +848,7 @@ work here.
848848 default branches (force-push and deletion refused, honest fast-forwards
849849 allowed).
850850 - **Boards** — `/boards/data`, for a forge admin who is a person (anyone else
851 gets a 404): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`),
851 gets a 404 on the page): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`),
852852 drawn server-side as the chart chosen beside it (`chart=line|bar|area|number|table|heat`)
853853 with its points in a table; a point opens the rows behind it, keeping your filter.
854854 `series=<column>` splits each group by a second column: stacked area draws one band per
@@ -862,7 +862,7 @@ work here.
862862 duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`.
863863 A board with a refresh redraws its grid on that beat through the live-region swap
864864 (`/boards/:slug/live`, etag'd). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug`
865 (an agent is 403, anyone but an admin 404; a panel whose query cannot run is refused with 422). No dependency was added.
865 (an agent or anyone but an admin is 403; a panel whose query cannot run is refused with 422). The same four doors are MCP tools (`boards`, `board_write`, `board_delete`; `boards` with a `slug` reads one) and, for the same person, a **Boards** screen in the terminal that lists boards and shows a board's panels as text. No dependency was added.
866866 - **Issues** — labels, threaded comments, close/reopen, markdown.
867867 - **Pull requests** — open from a branch, a **beautiful diff viewer** (syntax
868868 highlighting composed with intra-line word highlighting, mobile-first,
scripts/gate.ts +1
@@ -411,6 +411,7 @@ export const EXCLUDED: readonly Exclusion[] = [
411411 },
412412 { file: "test/tui-app.test.ts", reason: "not yet admitted", issue: 261 },
413413 { file: "test/tui-asked.test.ts", reason: "not yet admitted", issue: 261 },
414 { file: "test/tui-boards.test.ts", reason: "not yet admitted", issue: 261 },
414415 { file: "test/tui-apps.test.ts", reason: "not yet admitted", issue: 261 },
415416 { file: "test/tui-code.test.ts", reason: "not yet admitted", issue: 261 },
416417 { file: "test/tui-editor.test.ts", reason: "not yet admitted", issue: 261 },
src/http/mcp.ts +66
@@ -151,6 +151,7 @@ import type { Router } from "./router.ts";
151151 import { mayReadRuns } from "../actions/runs.ts";
152152 import { manifestFor } from "../actions/here.ts";
153153 import { clientBucket } from "./peer.ts";
154 import { deleteBoard, listAllBoards, showBoard, writeBoard, type BoardError } from "../actions/boards.ts";
154155 import { fileReport } from "../actions/reports.ts";
155156 import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts";
156157 import { placementWords } from "../checks/placement.ts";
@@ -252,6 +253,15 @@ function issueArg(ctx: Ctx, repo: Repo, args: Args, key: string) {
252253
253254 const isAnswer = (v: Repo | Answer): v is Answer => "ok" in v;
254255
256 /** Boards are a forge admin's who is a person, as the API gates them: anyone else is forbidden, not told what is there. */
257 const boardRefused = (): Answer => bad("forbidden", "boards are for a forge admin who is a person");
258 const mayBoards = (user: User): boolean => user.isAdmin && user.kind !== "agent";
259 function boardFailed(e: BoardError): Answer {
260 if (e === "not_found") return bad("not_found", "no such board");
261 if (typeof e === "object") return bad("refused", e.refused);
262 return bad(e, "that board could not be saved");
263 }
264
255265 const TOOLS: readonly Tool[] = [
256266 {
257267 name: "apps",
@@ -2077,6 +2087,62 @@ const TOOLS: readonly Tool[] = [
20772087 });
20782088 },
20792089 },
2090 {
2091 name: "boards",
2092 description:
2093 "The forge's boards, for a forge admin who is a person (an agent is refused): each board's slug, title, range, refresh and panels. The same as /boards and GET /api/v1/boards. With `slug`, that one board.",
2094 inputSchema: {
2095 type: "object",
2096 properties: { slug: { type: "string", maxLength: 40, description: "One board, by slug." } },
2097 },
2098 writes: false,
2099 async run(ctx, user, args) {
2100 if (!mayBoards(user)) return boardRefused();
2101 if (args["slug"] === undefined) {
2102 const listed = await listAllBoards(ctx.app, user);
2103 return listed.ok
2104 ? good({ boards: listed.value.map((b) => ({ slug: b.slug, ...b.board })) })
2105 : boardFailed(listed.error);
2106 }
2107 const shown = await showBoard(ctx.app, user, str(args["slug"]));
2108 return shown.ok ? good({ slug: str(args["slug"]), ...shown.value }) : boardFailed(shown.error);
2109 },
2110 },
2111 {
2112 name: "board_write",
2113 description:
2114 "Write a whole board under a slug (a-z, 0-9 and -): made if new, replaced if there. `board` is {title, range: 1h|24h|7d|30d|all, refresh: off|10s|1m, panels: [{title, chart: line|bar|area|number|table|heat, query, w, h}]}. A panel whose query cannot run is refused. For a forge admin who is a person; an agent is refused. The same as PUT /api/v1/boards/:slug.",
2115 inputSchema: {
2116 type: "object",
2117 properties: {
2118 slug: { type: "string", maxLength: 40 },
2119 board: { type: "object", description: "The whole board." },
2120 },
2121 required: ["slug", "board"],
2122 },
2123 writes: true,
2124 async run(ctx, user, args) {
2125 if (!mayBoards(user)) return boardRefused();
2126 const done = await writeBoard(ctx.app, user, str(args["slug"]), args["board"]);
2127 return done.ok ? good({ slug: str(args["slug"]), ...done.value }) : boardFailed(done.error);
2128 },
2129 },
2130 {
2131 name: "board_delete",
2132 description:
2133 "Delete a board by slug (the commit stays in the boards repo's history). For a forge admin who is a person; an agent is refused. The same as DELETE /api/v1/boards/:slug.",
2134 inputSchema: {
2135 type: "object",
2136 properties: { slug: { type: "string", maxLength: 40 } },
2137 required: ["slug"],
2138 },
2139 writes: true,
2140 async run(ctx, user, args) {
2141 if (!mayBoards(user)) return boardRefused();
2142 const done = await deleteBoard(ctx.app, user, str(args["slug"]));
2143 return done.ok ? good({ deleted: str(args["slug"]) }) : boardFailed(done.error);
2144 },
2145 },
20802146 ];
20812147
20822148 /**
src/tui/forge.ts +9
@@ -28,6 +28,7 @@ import {
2828 openOnWeb,
2929 runAsync,
3030 } from "./screens/common.ts";
31 import { boardsScreen } from "./screens/boards.ts";
3132 import { askedScreen } from "./screens/asked.ts";
3233 import { familyScreen } from "./screens/family.ts";
3334 import { familyLines, familyView } from "../spawn/family.ts";
@@ -196,6 +197,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
196197 "default",
197198 ),
198199 });
200 // The boards, as the page gates them: a site admin who is a person, never an agent (#1817).
201 if (session.user?.isAdmin === true && session.user.kind !== "agent")
202 commands.push({
203 id: "boards",
204 title: "Boards",
205 hint: "the forge's boards and their panels",
206 run: () => ctx.push(boardsScreen(session, ctx)),
207 });
199208 // The pipeline's bottleneck and throughput, as /flow says them: a site admin's (#1554, #1786).
200209 if (session.user?.isAdmin === true)
201210 commands.push({
src/tui/screens/boards.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * `/boards` in the terminal (#1817): the boards a forge admin who is a person
3 * keeps, then one board's panels as text. It reads through the same
4 * `listAllBoards` and `showBoard` the page and the API do, so the gate is the
5 * action's (an agent or anyone else is told nothing); the palette offers the
6 * door only to that person. Read-only: a chart is not drawn, a panel is said
7 * as its title, chart, size and the query that feeds it.
8 */
9
10 import type { Board } from "../../boards/store.ts";
11 import { listAllBoards, showBoard } from "../../actions/boards.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
14 import type { Session } from "../session.ts";
15 import { split, fill, len } from "../ui/layout.ts";
16 import { emptyState } from "../ui/widgets.ts";
17 import { style } from "../term/style.ts";
18 import { truncate } from "../term/width.ts";
19 import { ListState, Scroll, drawList, drawLines, hitList, hitScroll, metaOf, plural, runAsync, type ListRow } from "./common.ts";
20
21 /** One line of plain text: a title or a query value may carry escapes the terminal must not obey. */
22 const plain = (text: string): string =>
23 // eslint-disable-next-line no-control-regex
24 text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
25
26 export interface BoardRow extends ListRow {
27 readonly slug: string;
28 }
29
30 /** The boards as rows; nothing for anyone the action refuses. */
31 export async function boardRows(session: Session): Promise<BoardRow[]> {
32 const listed = await listAllBoards(session.app, session.user);
33 if (!listed.ok) return [];
34 return listed.value.map((b) => ({
35 slug: b.slug,
36 title: plain(b.board.title),
37 secondary: b.slug,
38 meta: metaOf(plural(b.board.panels.length, "panel"), b.board.range),
39 }));
40 }
41
42 /** A board, panel by panel, as lines of text. */
43 export function boardLines(board: Board): string[] {
44 const lines = [
45 plain(board.title),
46 `range ${board.range} · refresh ${board.refresh} · ${plural(board.panels.length, "panel")}`,
47 ];
48 for (const p of board.panels) {
49 const query = Object.entries(p.query)
50 .map(([k, v]) => `${plain(k)}=${plain(typeof v === "string" ? v : JSON.stringify(v))}`)
51 .join(" ");
52 lines.push("", plain(p.title), ` ${p.chart} · ${p.w}×${p.h}`, ` ${query === "" ? "no query" : query}`);
53 }
54 return lines;
55 }
56
57 function boardScreen(ctx: Ctx, board: Board): Screen {
58 const scroll = new Scroll();
59 const lines = boardLines(board);
60 return {
61 title: plain(board.title),
62 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
63 render(area, s) {
64 const c = ctx.theme.c;
65 const spans = lines.map((text, i) => [{ text, style: style(i === 0 ? c.fg : c.mutedFg, c.bg) }]);
66 drawLines(s, area, c, scroll, spans);
67 hitScroll(ctx, area, scroll, lines.length);
68 },
69 key(e) {
70 if (scroll.key(e, lines.length)) return true;
71 if (e.key === "esc" || e.key === "q") {
72 ctx.pop();
73 return true;
74 }
75 return false;
76 },
77 };
78 }
79
80 export function boardsScreen(f: Session, ctx: Ctx): Screen {
81 const state = new ListState();
82 let rows: BoardRow[] = [];
83 const read = async (): Promise<void> => {
84 rows = await boardRows(f);
85 state.clamp(rows.length);
86 };
87 const open = (index: number): void => {
88 const row = rows[index];
89 if (row === undefined) return;
90 runAsync(ctx, async () => {
91 const shown = await showBoard(f.app, f.user, row.slug);
92 if (!shown.ok) ctx.toast("That board is gone", "default");
93 else ctx.push(boardScreen(ctx, shown.value));
94 });
95 };
96 return {
97 title: "Boards",
98 keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["r"], "refresh")],
99 load: read,
100 render(area, s) {
101 const c = ctx.theme.c;
102 const [head, body] = split(area, "v", [len(1), fill(1)]);
103 s.text(head!.x, head!.y, truncate(plural(rows.length, "board"), head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
104 if (rows.length === 0) {
105 emptyState(s, body!, c, {
106 title: "No boards yet",
107 body: "Make one on the web at /boards; it shows up here.",
108 });
109 return;
110 }
111 drawList(s, body!, c, state, rows);
112 hitList(ctx, body!, state, rows.length, { open });
113 },
114 key(e) {
115 if (state.key(e, rows.length)) return true;
116 if (e.key === "enter") {
117 open(state.index);
118 return true;
119 }
120 if (e.key === "r") {
121 runAsync(ctx, async () => {
122 await read();
123 ctx.invalidate();
124 });
125 return true;
126 }
127 return false;
128 },
129 };
130 }
test/mcp.test.ts +36
@@ -208,6 +208,9 @@ describe("the forge over MCP", () => {
208208 "capacity",
209209 "flow",
210210 "limits",
211 "boards",
212 "board_write",
213 "board_delete",
211214 "report",
212215 ]);
213216 const byName = new Map<string, any>(
@@ -621,6 +624,39 @@ describe("the forge over MCP", () => {
621624 expect(limitChanges(app.db)[0]).toMatchObject({ key });
622625 });
623626
627 test("boards: list, read, write and delete for an admin person; an agent and a non-admin are refused", async () => {
628 const board = { title: "Runs", range: "24h", refresh: "off", panels: [{ title: "Failed", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }] };
629 const made = await createAgent(app, alice, { username: "boardbot", brief: "x" });
630 if (!made.ok) throw new Error(String(made.error));
631 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id);
632 const botToken = createToken(app.db, made.value.user.id, "bot", "rw").token;
633 for (const who of [bobRw, botToken]) {
634 expect((await tool(who, "boards")).error).toContain("forbidden");
635 expect((await tool(who, "board_write", { slug: "runs", board })).error).toContain("forbidden");
636 expect((await tool(who, "board_delete", { slug: "runs" })).error).toContain("forbidden");
637 }
638
639 // A read-only token may not write.
640 expect((await tool(ro, "board_write", { slug: "runs", board })).error).toContain("forbidden");
641 expect((await tool(rw, "boards")).value.boards).toEqual([]);
642
643 const wrote = await tool(rw, "board_write", { slug: "runs", board });
644 expect(wrote.value).toMatchObject({ slug: "runs", title: "Runs" });
645 expect((await tool(rw, "boards")).value.boards.map((b: any) => b.slug)).toEqual(["runs"]);
646 expect((await tool(ro, "boards", { slug: "runs" })).value.panels[0].title).toBe("Failed");
647 expect((await tool(rw, "boards", { slug: "nope" })).error).toContain("not_found");
648
649 // A panel that cannot run, or a board that does not validate, is refused.
650 const broken = { ...board, panels: [{ ...board.panels[0], query: { source: "nothing" } }] };
651 expect((await tool(rw, "board_write", { slug: "broken", board: broken })).error).toContain("refused");
652 expect((await tool(rw, "board_write", { slug: "Bad Slug", board })).error).toContain("invalid");
653
654 expect((await tool(ro, "board_delete", { slug: "runs" })).error).toContain("forbidden");
655 expect((await tool(rw, "board_delete", { slug: "runs" })).value).toEqual({ deleted: "runs" });
656 expect((await tool(rw, "boards")).value.boards).toEqual([]);
657 expect((await tool(rw, "board_delete", { slug: "runs" })).error).toContain("not_found");
658 });
659
624660 test("ui:// card resources are listed, read, and tied to their tools", async () => {
625661 const list = await call(rw, "resources/list");
626662 const uris = list.body.result.resources.map((r: any) => r.uri).sort();
test/tui-boards.test.ts added +108
@@ -0,0 +1,108 @@
1 /**
2 * /boards in the terminal (#1817): the screen reads the page's own action, so
3 * a forge admin who is a person lists boards and reads their panels as text,
4 * and an agent, a member or a guest has neither the door nor the rows.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { writeBoard } from "../src/actions/boards.ts";
11 import { createAgent } from "../src/actions/crew.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
15 import { boardRows, boardsScreen } from "../src/tui/screens/boards.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 let dir: string;
23 let app: App;
24 let carol: User;
25 let bob: User;
26 let bot: User;
27
28 const identity = (user?: User) => ({
29 sshUsername: user?.username ?? "git",
30 remoteAddress: "127.0.0.1",
31 fingerprint: "SHA256:test",
32 user,
33 });
34
35 async function screenText(user?: User, keys = ""): Promise<string> {
36 const terminal = headlessTerminal(120, 30);
37 const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false });
38 tui.start();
39 tui.ctx.push(boardsScreen(session, tui.ctx));
40 await settleApp(tui);
41 if (keys !== "") {
42 terminal.feed(keys);
43 await settleApp(tui);
44 }
45 const text = tui.screen.toText();
46 tui.stop?.();
47 return text;
48 }
49
50 const doors = (user?: User): string[] => {
51 const { app: tui, session } = createForgeApp(app, identity(user), {
52 terminal: headlessTerminal(80, 24),
53 exitHooks: false,
54 });
55 const ids = forgeCommands(session)(tui.ctx).map((c) => c.id);
56 tui.stop?.();
57 return ids;
58 };
59
60 beforeAll(async () => {
61 dir = await mkdtemp(join(tmpdir(), "forge-tui-boards-"));
62 app = createApp({ dataDir: dir, port: 0 });
63 const mk = async (username: string, first = false) => {
64 const r = await createUser(app.db, {
65 trusted: true, username, email: `${username}@forge.local`,
66 password: "a-good-passphrase", adminIfFirst: first,
67 });
68 if (!r.ok) throw new Error(r.error);
69 return r.value;
70 };
71 carol = await mk("carol", true);
72 bob = await mk("bob");
73 const made = await createAgent(app, carol, { username: "boardbot", brief: "x" });
74 if (!made.ok) throw new Error(String(made.error));
75 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id);
76 bot = { ...made.value.user, isAdmin: true };
77 const wrote = await writeBoard(app, carol, "runs", {
78 title: "Runs this week",
79 range: "7d",
80 refresh: "off",
81 panels: [{ title: "Failed runs", chart: "number", query: { source: "runs", aggregate: "count" }, w: 1, h: 1 }],
82 });
83 if (!wrote.ok) throw new Error(JSON.stringify(wrote.error));
84 }, 30_000);
85
86 afterAll(async () => {
87 app.close();
88 await rm(dir, { recursive: true, force: true });
89 });
90
91 describe("tui boards", () => {
92 test("an admin person sees the boards and reads a board's panels", async () => {
93 expect(await screenText(carol)).toContain("Runs this week");
94 const text = await screenText(carol, "\r");
95 expect(text).toContain("Failed runs");
96 expect(text).toContain("number");
97 expect(text).toContain("source=runs");
98 });
99
100 test("nobody else has rows or the door", async () => {
101 for (const who of [bob, bot, undefined]) {
102 expect(await boardRows(new Session(app, identity(who)))).toEqual([]);
103 expect(doors(who)).not.toContain("boards");
104 expect(await screenText(who)).not.toContain("Runs this week");
105 }
106 expect(doors(carol)).toContain("boards");
107 });
108 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs