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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2458-boards-step-6a-the-forge-writes-the-usag → main 2 commits fork 1 thread · 1 unresolved
5 files
changed +170
−4
README.md +2 −1
| @@ -1239,9 +1239,10 @@ work here. | ||
| 1239 | 1239 | it aside. Number is the latest group's value over a sparkline of all of them. |
| 1240 | 1240 | `/boards` holds the boards themselves: a board is one JSON file |
| 1241 | 1241 | (`{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 | |
| 1243 | 1243 | by the admin who made it, and a file that does not validate is not shown. Make, rename, |
| 1244 | 1244 | 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. | |
| 1245 | 1246 | A board with a refresh redraws its grid on that beat through the live-region swap |
| 1246 | 1247 | (`/boards/:slug/live`, etag'd). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug` |
| 1247 | 1248 | (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 { | ||
| 14 | 14 | BOARDS_OWNER, BOARDS_REPO, emptyBoard, isSlug, listBoards, parseBoard, readBoard, removeBoard, saveBoard, slugOf, |
| 15 | 15 | type Board, type Stored, type StoreError, |
| 16 | 16 | } 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"; | |
| 18 | 21 | import { createOrg } from "../core/orgs.ts"; |
| 19 | 22 | 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"; | |
| 21 | 24 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 22 | 25 | |
| 23 | 26 | export type BoardError = StoreError | "not_found" | "no_slug" | "repo" | { refused: string }; |
| @@ -27,7 +30,7 @@ function admin(actor: User | undefined): Result<User, "not_found"> { | ||
| 27 | 30 | } |
| 28 | 31 | |
| 29 | 32 | /** 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> { | |
| 31 | 34 | if (getRepo(app.db, BOARDS_OWNER, BOARDS_REPO)) return true; |
| 32 | 35 | let owner = getUserByName(app.db, BOARDS_OWNER); |
| 33 | 36 | if (!owner) { |
| @@ -140,3 +143,36 @@ export async function deleteBoard(app: App, actor: User | undefined, slug: strin | ||
| 140 | 143 | if (done.ok) app.bus.append({ kind: "board.deleted", actorId: who.value.id, payload: { slug } }); |
| 141 | 144 | return done; |
| 142 | 145 | } |
| 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` }, | |
| 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"; | ||
| 15 | 15 | import { backfillCarried } from "./core/carried.ts"; |
| 16 | 16 | import { flushFlagUses, loadFlags } from "./core/flags.ts"; |
| 17 | 17 | import { reconcile } from "./core/reconcile.ts"; |
| 18 | import { offerStartingBoards } from "./actions/boards.ts"; | |
| 18 | 19 | import { syncAllPillars } from "./actions/pillars.ts"; |
| 19 | 20 | import { bootHostedChildren } from "./spawn/host.ts"; |
| 20 | 21 | import { firstBoot } from "./spawn/first-boot.ts"; |
| @@ -353,6 +354,10 @@ const snapshots = startSnapshots( | ||
| 353 | 354 | // node, every 15 s into a ring the `machine` board source reads. |
| 354 | 355 | const sampler = startSampler(app); |
| 355 | 356 | 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) })); | |
| 356 | 361 | const crewHeldOff = crewOffInPreview(); |
| 357 | 362 | if (appsOn && crewHeldOff) |
| 358 | 363 | 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 | }); |
new above
offline
Add a test for this branch: pre-create
usageas a person, then call offerStartingBoards and assert that board is unchanged and thatusageis in.offered. The current tests would still pass if thefreefilter were removed.