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

Boards, step 6a: the forge writes the Usage and Crew starting boards on boot, and never overwrites an edited one #2468

Merged opened this

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

Merged

scout/forge:scout/2458-boards-step-6a-the-forge-writes-the-usag → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/2458-boards-step-6a-the-forge-writes-the-usag was merged into main as 6201a25.

Review

Sign in to review this change.
5 files changed +170 −4
README.md +2 −1
@@ -1239,9 +1239,10 @@ work here.
12391239 it aside. Number is the latest group's value over a sparkline of all of them.
12401240 `/boards` holds the boards themselves: a board is one JSON file
12411241 (`{title, range, refresh: off|10s|1m, panels: [{title, chart, query, w, h}]}`) in
1242 `forge/boards`, a repo the forge owns and makes on the first board; every save is a commit
1242 `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit
12431243 by the admin who made it, and a file that does not validate is not shown. Make, rename,
12441244 duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`.
1245 The first boot also writes two starting boards, **Usage** and **Crew**, once each: `.offered` in that repo records them, so an edit stays and a deletion sticks.
12451246 A board with a refresh redraws its grid on that beat through the live-region swap
12461247 (`/boards/:slug/live`, etag'd). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug`
12471248 (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.
src/actions/boards.ts +39 −3
@@ -14,10 +14,13 @@ import {
1414 BOARDS_OWNER, BOARDS_REPO, emptyBoard, isSlug, listBoards, parseBoard, readBoard, removeBoard, saveBoard, slugOf,
1515 type Board, type Stored, type StoreError,
1616 } from "../boards/store.ts";
17 import { commitIdentity } from "../core/commit_identity.ts";
17 import { OFFERED_FILE, readOffered, STARTING_BOARDS } from "../boards/starting.ts";
18 import { commitFiles } from "../git/write.ts";
19 import { repoDir } from "../git/repos.ts";
20 import { commitIdentity, forgeHost } from "../core/commit_identity.ts";
1821 import { createOrg } from "../core/orgs.ts";
1922 import { createRepo, getRepo } from "../core/repos.ts";
20 import { getUserByName, type User } from "../core/users.ts";
23 import { getUserById, getUserByName, type User } from "../core/users.ts";
2124 import { err, ok, type Result } from "../kernel/result.ts";
2225
2326 export type BoardError = StoreError | "not_found" | "no_slug" | "repo" | { refused: string };
@@ -27,7 +30,7 @@ function admin(actor: User | undefined): Result<User, "not_found"> {
2730 }
2831
2932 /** The forge's boards repo, made if it is not there yet. */
30 async function ensureRepo(app: App, who: User): Promise<boolean> {
33 export async function ensureRepo(app: App, who: User): Promise<boolean> {
3134 if (getRepo(app.db, BOARDS_OWNER, BOARDS_REPO)) return true;
3235 let owner = getUserByName(app.db, BOARDS_OWNER);
3336 if (!owner) {
@@ -140,3 +143,36 @@ export async function deleteBoard(app: App, actor: User | undefined, slug: strin
140143 if (done.ok) app.bus.append({ kind: "board.deleted", actorId: who.value.id, payload: { slug } });
141144 return done;
142145 }
146
147 /**
148 * Write the starting boards the forge has not offered yet, in one commit
149 * that also records them in `.offered`. A board that was offered is never
150 * written again, so an edit stays and a deletion sticks. Needs a person who
151 * is an admin to found the repo; without one nothing is written and the
152 * next boot tries again. Returns the slugs written.
153 */
154 export async function offerStartingBoards(app: App): Promise<string[]> {
155 const founder = app.db
156 .query<{ id: string }, []>("SELECT id FROM user WHERE is_admin = 1 AND is_agent = 0 AND kind = 'user' ORDER BY created_at LIMIT 1")
157 .get();
158 const person = founder ? getUserById(app.db, founder.id) : undefined;
159 if (!person) return [];
160 if (!(await ensureRepo(app, person))) return [];
161 const offered = await readOffered(app.reposRoot);
162 const fresh = Object.keys(STARTING_BOARDS).filter((slug) => !offered.includes(slug));
163 if (fresh.length === 0) return [];
164 // A board a person already made under that slug is theirs: recorded as offered, not written.
165 const free: string[] = [];
166 for (const slug of fresh) if ((await readBoard(app.reposRoot, slug)) === undefined) free.push(slug);
167 const done = await commitFiles(repoDir(app.reposRoot, BOARDS_OWNER, BOARDS_REPO), {
168 branch: "main",
169 message: `Offer the starting boards: ${fresh.join(", ")}`,
170 files: [
171 ...free.map((slug) => ({ path: `${slug}.json`, content: `${JSON.stringify(STARTING_BOARDS[slug], null, 2)}\n` })),
172 { path: OFFERED_FILE, content: `${JSON.stringify([...offered, ...fresh])}\n` },

Add a test for this branch: pre-create usage as a person, then call offerStartingBoards and assert that board is unchanged and that usage is in .offered. The current tests would still pass if the free filter were removed.

173 ],
174 author: { name: "forge", email: `forge@${forgeHost(app.config)}` },
175 });
176 if (!done.ok) return [];
177 return free;
178 }
src/boards/starting.ts added +62
@@ -0,0 +1,62 @@
1 /**
2 * The boards the forge offers a new install: Usage and Crew. They are plain
3 * boards in `forge/boards` once written, so a person edits or deletes them
4 * like any other; `.offered` (a JSON list of slugs, committed with the
5 * boards) records what was offered so a deletion sticks and an edit is
6 * never overwritten. Each panel uses a source already in `sources.ts`.
7 */
8 import { repoDir } from "../git/repos.ts";
9 import { readerFor } from "../git/read.ts";
10 import type { Board, Panel } from "./store.ts";
11 import { BOARDS_OWNER, BOARDS_REPO } from "./store.ts";
12
13 export const OFFERED_FILE = ".offered";
14
15 const panel = (title: string, chart: Panel["chart"], query: Record<string, unknown>, w = 1, h = 1): Panel => ({
16 title, chart, query, w, h,
17 });
18 const MINUTE = { bucket: "minute" };
19 const HOUR = { bucket: "hour" };
20 const machine = (op: "eq" | "starts", value: string, column: string) => ({
21 source: "machine", aggregate: "avg", column, groupBy: MINUTE, filter: [{ column: "target", op, value }],
22 });
23
24 export const STARTING_BOARDS: Readonly<Record<string, Board>> = {
25 usage: {
26 title: "Usage",
27 range: "24h",
28 refresh: "1m",
29 panels: [
30 panel("Pod CPU (cores)", "line", machine("eq", "forge", "cpu_cores"), 2),
31 panel("Pod memory (bytes)", "line", machine("eq", "forge", "memory_bytes"), 2),
32 panel("Cells CPU (cores)", "area", { ...machine("starts", "app:", "cpu_cores"), series: "target" }, 2),
33 panel("Cells memory (bytes)", "area", { ...machine("starts", "app:", "memory_bytes"), series: "target" }, 2),
34 panel("Node CPU (cores)", "line", machine("eq", "node", "cpu_cores")),
35 panel("Node load", "line", machine("eq", "node", "load")),
36 panel("Requests per hour", "bar", { source: "usage", aggregate: "sum", column: "n", groupBy: "hour", series: "status" }, 2),
37 ],
38 },
39 crew: {
40 title: "Crew",
41 range: "7d",
42 refresh: "1m",
43 panels: [
44 panel("Runs by agent and outcome", "bar", { source: "spend", aggregate: "count", groupBy: "agent_id", series: "outcome" }, 2),
45 panel("Runs by state", "bar", { source: "runs", aggregate: "count", groupBy: "state" }),
46 panel("Review verdicts", "bar", { source: "reviews", aggregate: "count", groupBy: "state" }),
47 panel("Spend per hour (USD)", "line", { source: "spend", aggregate: "sum", column: "cost_usd", groupBy: HOUR }, 2),
48 ],
49 },
50 };
51
52 /** The slugs already offered, from the repo; none when it has no such file yet. */
53 export async function readOffered(reposRoot: string): Promise<string[]> {
54 const got = await readerFor(repoDir(reposRoot, BOARDS_OWNER, BOARDS_REPO)).object(`main:${OFFERED_FILE}`);
55 if (!got.ok || got.value.type !== "blob" || got.value.size > 4096) return [];
56 try {
57 const list: unknown = JSON.parse(new TextDecoder().decode(got.value.body));
58 return Array.isArray(list) ? list.filter((s): s is string => typeof s === "string") : [];
59 } catch {
60 return [];
61 }
62 }
src/main.ts +5
@@ -15,6 +15,7 @@ import { createApp } from "./app.ts";
1515 import { backfillCarried } from "./core/carried.ts";
1616 import { flushFlagUses, loadFlags } from "./core/flags.ts";
1717 import { reconcile } from "./core/reconcile.ts";
18 import { offerStartingBoards } from "./actions/boards.ts";
1819 import { syncAllPillars } from "./actions/pillars.ts";
1920 import { bootHostedChildren } from "./spawn/host.ts";
2021 import { firstBoot } from "./spawn/first-boot.ts";
@@ -353,6 +354,10 @@ const snapshots = startSnapshots(
353354 // node, every 15 s into a ring the `machine` board source reads.
354355 const sampler = startSampler(app);
355356 const memorySampler = startMemorySampler(app);
357 // The Usage and Crew starting boards, once each (actions/boards.ts).
358 void offerStartingBoards(app)
359 .then((slugs) => slugs.length > 0 && app.log.info("starting boards offered", { slugs: slugs.join(", ") }))
360 .catch((error) => app.log.warn("starting boards not offered", { error: String(error) }));
356361 const crewHeldOff = crewOffInPreview();
357362 if (appsOn && crewHeldOff)
358363 app.log.info(CREW_OFF_IN_PREVIEW, {
test/boards-starting.test.ts added +62
@@ -0,0 +1,62 @@
1 /**
2 * The forge offers the Usage and Crew boards on boot: every panel runs, an
3 * edit survives the next boot, and a deletion is not undone.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { changeBoard, deleteBoard, offerStartingBoards } from "../src/actions/boards.ts";
10 import { createApp } from "../src/app.ts";
11 import { runQuery } from "../src/boards/query.ts";
12 import { STARTING_BOARDS } from "../src/boards/starting.ts";
13 import { listBoards, readBoard } from "../src/boards/store.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15
16 let dir: string;
17 let app: ReturnType<typeof createApp>;
18 let admin: User;
19
20 beforeAll(async () => {
21 dir = await mkdtemp(join(tmpdir(), "forge-boards-starting-"));
22 app = createApp({ dataDir: dir, port: 0 });
23 const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "correct horse battery", isAdmin: true });
24 if (!made.ok) throw new Error("no admin");
25 admin = made.value;
26 });
27 afterAll(async () => {
28 await rm(dir, { recursive: true, force: true });
29 });
30
31 test("a forge with no person to found the repo writes nothing", async () => {
32 const bare = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-boards-bare-")), port: 0 });
33 expect(await offerStartingBoards(bare)).toEqual([]);
34 });
35
36 test("a fresh boot writes Usage and Crew, and every panel runs", async () => {
37 expect((await offerStartingBoards(app)).sort()).toEqual(["crew", "usage"]);
38 const boards = await listBoards(app.reposRoot);
39 expect(boards.map((b) => b.slug)).toEqual(["crew", "usage"]);
40 let panels = 0;
41 for (const { board } of boards) {
42 for (const p of board.panels) {
43 const ran = runQuery(app.db, p.query, { repoIds: "all" });
44 expect(ran.ok).toBe(true);
45 panels++;
46 }
47 }
48 expect(panels).toBe(STARTING_BOARDS.usage!.panels.length + STARTING_BOARDS.crew!.panels.length);
49 });
50
51 test("a second boot leaves an edit in place", async () => {
52 const edited = await changeBoard(app, admin, "usage", { kind: "title", title: "My usage" });
53 expect(edited.ok).toBe(true);
54 expect(await offerStartingBoards(app)).toEqual([]);
55 expect((await readBoard(app.reposRoot, "usage"))?.title).toBe("My usage");
56 });
57
58 test("a deleted starting board stays deleted", async () => {
59 expect((await deleteBoard(app, admin, "crew")).ok).toBe(true);
60 expect(await offerStartingBoards(app)).toEqual([]);
61 expect(await readBoard(app.reposRoot, "crew")).toBeUndefined();
62 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs