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

/system shows RSS beside the JS heap, hour by hour, so native memory growth is visible #2158

Merged opened this

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

Merged

scout/forge:scout/2151-system-shows-rss-beside-the-js-heap-hour → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/2151-system-shows-rss-beside-the-js-heap-hour was merged into main as 7913069.

Review

Sign in to review this change.
12 files changed +423 −1
README.md +1
@@ -1007,6 +1007,7 @@ work here.
10071007 commit history) with README rendering and **syntax highlighting**, protected
10081008 default branches (force-push and deletion refused, honest fast-forwards
10091009 allowed).
1010 - **System memory** — `/system`, for a forge admin who is a person (anyone else gets 403): the forge's RSS beside its JS heap (`heapStats()` from bun:jsc), hourly mean and max for the last 24 h, drawn on the server. Sampled once a minute into `memory_hour` in `app.db` (a week kept). RSS rising while the heap stays flat is native memory growth. In the terminal the same admin has a **System memory** command in the palette: the last 24 hours as text rows (hour, RSS mean/max, heap mean/max in MiB), read-only. The account menu links `/system` for that admin.
10101011 - **Boards** — `/boards/data`, for a forge admin who is a person (anyone else
10111012 gets a 404 on the page): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`),
10121013 drawn server-side as the chart chosen beside it (`chart=line|bar|area|number|table|heat`)
src/boards/memory.ts added +78
@@ -0,0 +1,78 @@
1 /**
2 * The forge's own memory against its JS heap, hour by hour (#2151): every
3 * minute `process.memoryUsage().rss` and `heapStats().heapSize` (bun:jsc)
4 * are folded into the hour they fall in (`memory_hour` in `app.db`, under
5 * the data directory), as a count, a sum and a max. RSS rising while the
6 * heap stays flat is native memory growth. A week of hours is kept.
7 */
8 import type { Database } from "bun:sqlite";
9 import { heapStats } from "bun:jsc";
10 import type { App } from "../app.ts";
11
12 export const MEMORY_EVERY_MS = 60_000;
13 export const HOUR_MS = 3_600_000;
14 const KEEP_MS = 7 * 24 * HOUR_MS;
15
16 export type MemoryHour = {
17 /** Epoch ms of the hour's start. */
18 at: number;
19 samples: number;
20 rssMean: number;
21 rssMax: number;
22 heapMean: number;
23 heapMax: number;
24 };
25
26 const valid = (n: number) => Number.isFinite(n) && n >= 0;
27
28 /** Fold one reading into its hour and drop hours past the week. Ignores a reading that is not a count of bytes. */
29 export function recordMemory(db: Database, rss: number, heap: number, now: number): void {
30 if (!valid(rss) || !valid(heap) || !valid(now)) return;
31 const hour = Math.floor(now / HOUR_MS) * HOUR_MS;
32 db.transaction(() => {
33 db.run(
34 `INSERT INTO memory_hour (hour, samples, rss_sum, rss_max, heap_sum, heap_max) VALUES (?, 1, ?, ?, ?, ?)
35 ON CONFLICT(hour) DO UPDATE SET samples = samples + 1, rss_sum = rss_sum + excluded.rss_sum,
36 rss_max = max(rss_max, excluded.rss_max), heap_sum = heap_sum + excluded.heap_sum,
37 heap_max = max(heap_max, excluded.heap_max)`,
38 [hour, rss, rss, heap, heap],
39 );
40 db.run("DELETE FROM memory_hour WHERE hour < ?", [hour - KEEP_MS]);
41 })();
42 }
43
44 /** The hours in the `hours` before `now` (the current one included), oldest first. */
45 export function memoryHours(db: Database, now: number, hours = 24): MemoryHour[] {
46 const last = Math.floor(now / HOUR_MS) * HOUR_MS;
47 const rows = db
48 .query<{ hour: number; samples: number; rss_sum: number; rss_max: number; heap_sum: number; heap_max: number }, [number, number]>(
49 "SELECT hour, samples, rss_sum, rss_max, heap_sum, heap_max FROM memory_hour WHERE hour > ? AND hour <= ? ORDER BY hour",
50 )
51 .all(last - hours * HOUR_MS, last);
52 return rows.map((r) => ({
53 at: r.hour, samples: r.samples,
54 rssMean: r.rss_sum / r.samples, rssMax: r.rss_max,
55 heapMean: r.heap_sum / r.samples, heapMax: r.heap_max,
56 }));
57 }
58
59 export type MemorySampler = { tick(now?: number): void; stop(): void };
60
61 /** Sample now and every minute until stopped. A failed tick is logged and the next one tries again. */
62 export function startMemorySampler(app: App, everyMs = MEMORY_EVERY_MS): MemorySampler {
63 const tick = (now = Date.now()) => {
64 try {
65 recordMemory(app.db, process.memoryUsage().rss, heapStats().heapSize, now);
66 } catch (error) {
67 app.log.warn("memory sample failed", { error: String(error) });
68 }
69 };
70 tick();
71 const timer = setInterval(tick, everyMs);
72 timer.unref?.();
73 return { tick, stop: () => clearInterval(timer) };
74 }
75
76 /** Who may read the series: a forge admin who is a person, never an agent. The page, the nav and the terminal all ask this. */
77 export const canSeeMemory = (user: { isAdmin: boolean; kind?: string } | null | undefined): boolean =>
78 user?.isAdmin === true && user.kind !== "agent";
src/core/migrations/20261016150000-memory-hour.ts added +14
@@ -0,0 +1,14 @@
1 // The forge's own memory, one row an hour (#2151, boards/memory.ts): the
2 // resident set (RSS) beside the JS heap, so RSS climbing while the heap
3 // stays flat shows as a native leak. A running sum and count give the mean;
4 // telemetry, not state — a lost row is a gap in a chart.
5 export default `
6 CREATE TABLE memory_hour (
7 hour INTEGER PRIMARY KEY,
8 samples INTEGER NOT NULL,
9 rss_sum REAL NOT NULL,
10 rss_max REAL NOT NULL,
11 heap_sum REAL NOT NULL,
12 heap_max REAL NOT NULL
13 );
14 `;
src/http/server.ts +2
@@ -15,6 +15,7 @@ import { flagsPage, flagsSet } from "../web/pages/flags.ts";
1515 import { domainsPage } from "../web/pages/domains.ts";
1616 import { resiliencePage } from "../web/pages/resilience.ts";
1717 import { limitsPage, limitsSet, quotasSet } from "../web/pages/admin-limits.ts";
18 import { systemPage } from "../web/pages/system.ts";
1819 import { boardsPage } from "../web/pages/boards.ts";
1920 import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts";
2021 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
@@ -744,6 +745,7 @@ export function buildRouter(): Router<Handler> {
744745 router.add("GET", "/settings/resilience", domainsPage);
745746 router.add("GET", "/settings/capacity", capacityPage);
746747 router.add("POST", "/settings/capacity", capacitySet);
748 router.add("GET", "/system", systemPage);

Nothing in the UI links to this route. Add a nav or boards link, visible only to an admin who passes the gate, so a person can reach /system from the page.

747749 router.add("GET", "/boards", boardsList);
748750 router.add("GET", "/boards/data", boardsPage);
749751 router.add("POST", "/boards/new", boardNew);
src/main.ts +3
@@ -4,6 +4,7 @@
44 * environment-only — a handful of variables, all with sane defaults.
55 */
66
7 import { startMemorySampler } from "./boards/memory.ts";
78 import { startSampler } from "./boards/sampler.ts";
89 import { sweepStanding } from "./crew/standing.ts";
910 import { drainOnHandover, handover } from "./release/swap.ts";
@@ -338,6 +339,7 @@ const snapshots = startSnapshots(
338339 // What the machine uses (boards/sampler.ts): the pod, each cell and the
339340 // node, every 15 s into a ring the `machine` board source reads.
340341 const sampler = startSampler(app);
342 const memorySampler = startMemorySampler(app);
341343 const crewHeldOff = crewOffInPreview();
342344 if (appsOn && crewHeldOff)
343345 app.log.info(CREW_OFF_IN_PREVIEW, {
@@ -542,6 +544,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> {
542544 probes?.stop(),
543545 snapshots.stop(),
544546 sampler.stop(),
547 memorySampler.stop(),
545548 ])
546549 .catch(() => {})
547550 .then(() => {
src/tui/forge.ts +10
@@ -28,7 +28,9 @@ import {
2828 openOnWeb,
2929 runAsync,
3030 } from "./screens/common.ts";
31 import { canSeeMemory } from "../boards/memory.ts";
3132 import { boardsScreen } from "./screens/boards.ts";
33 import { systemScreen } from "./screens/system.ts";
3234 import { askedScreen } from "./screens/asked.ts";
3335 import { deciderScreen } from "./screens/decider.ts";
3436 import { familyScreen } from "./screens/family.ts";
@@ -230,6 +232,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
230232 hint: "the forge's boards and their panels",
231233 run: () => ctx.push(boardsScreen(session, ctx)),
232234 });
235 // The forge's RSS beside its JS heap, as /system gates it (#2151).
236 if (canSeeMemory(session.user))
237 commands.push({
238 id: "system",
239 title: "System memory",
240 hint: "RSS beside the JS heap, hourly for 24 h",
241 run: () => ctx.push(systemScreen(session, ctx)),
242 });
233243 // The pipeline's bottleneck and throughput, as /flow says them: a site admin's (#1554, #1786).
234244 if (session.user?.isAdmin === true)
235245 commands.push({
src/tui/screens/system.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * `/system` in the terminal (#2151): the forge's RSS beside its JS heap, an
3 * hour a row for the last 24 h, read through `memoryHours` like the page. The
4 * gate is the page's (`canSeeMemory`): anyone but a forge admin who is a
5 * person gets no rows, and the palette offers the door only to that person.
6 * Read-only.
7 */
8
9 import { canSeeMemory, memoryHours } from "../../boards/memory.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import type { Session } from "../session.ts";
13 import { style } from "../term/style.ts";
14 import { pad } from "../term/width.ts";
15 import { Scroll, drawLines, hitScroll } from "./common.ts";
16
17 const mib = (n: number) => `${(n / 1_048_576).toFixed(1)}`;
18
19 /** The header, then an hour a row (UTC); empty for anyone the gate refuses. */
20 export function memoryLines(session: Session, now = Date.now()): string[] {
21 if (!canSeeMemory(session.user)) return [];
22 const hours = memoryHours(session.db, now);
23 if (hours.length === 0) return ["No samples yet; the forge samples its memory every minute."];
24 const col = (s: string) => pad(s, 10, "right");
25 return [
26 `${pad("Hour (UTC)", 17)}${col("RSS mean")}${col("RSS max")}${col("Heap mean")}${col("Heap max")} MiB`,
27 ...hours.map(
28 (h) =>
29 `${pad(new Date(h.at).toISOString().slice(0, 16).replace("T", " "), 17)}${col(mib(h.rssMean))}${col(mib(h.rssMax))}${col(mib(h.heapMean))}${col(mib(h.heapMax))}`,
30 ),
31 ];
32 }
33
34 export function systemScreen(session: Session, ctx: Ctx): Screen {
35 const scroll = new Scroll();
36 return {
37 title: "System memory",
38 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
39 render(area, s) {
40 const c = ctx.theme.c;
41 const lines = memoryLines(session);
42 const spans = lines.map((text, i) => [{ text, style: style(i === 0 ? c.fg : c.mutedFg, c.bg) }]);
43 drawLines(s, area, c, scroll, spans);
44 hitScroll(ctx, area, scroll, lines.length);
45 },
46 key(e) {
47 if (scroll.key(e, memoryLines(session).length)) return true;
48 if (e.key === "esc" || e.key === "q") {
49 ctx.pop();
50 return true;
51 }
52 return false;
53 },
54 };
55 }
src/web/layout.ts +4 −1
@@ -218,7 +218,10 @@ function userMenu(ctx: Ctx, user: User, signOut: MenuItem): Html {
218218 { label: "Crew", href: "/settings/crew", icon: "bot" as const },
219219 ...(user.kind === "agent"
220220 ? []
221 : [{ label: "Boards", href: "/boards", icon: "activity" as const }]),
221 : [
222 { label: "Boards", href: "/boards", icon: "activity" as const },
223 { label: "System memory", href: "/system", icon: "activity" as const },

Minor: the menu applies the gate itself (user.isAdmin and kind !== "agent") and does not call canSeeMemory(user), although the module doc says the nav asks it. The two match today. Calling canSeeMemory(user) here would keep them from drifting apart.

224 ]),
222225 ]
223226 : []),
224227 { separator: true, label: "" },
src/web/pages/system.ts added +49
@@ -0,0 +1,49 @@
1 /**
2 * GET /system, for a forge admin who is a person: the forge's resident
3 * memory (RSS) beside its JS heap, hourly for the last 24 h (#2151). RSS
4 * climbing while the heap stays flat is native memory growth. Drawn on the
5 * server as SVG, with the hours under it as a table. Anyone else gets 403.
6 */
7 import { drawChart } from "../../boards/chart.ts";
8 import { canSeeMemory, memoryHours } from "../../boards/memory.ts";
9 import type { Ctx } from "../../http/context.ts";
10 import { html, raw } from "../html.ts";
11 import { page } from "../layout.ts";
12 import { btn, card, empty } from "../ui.ts";
13
14 const mib = (n: number) => `${(n / 1_048_576).toFixed(1)} MiB`;
15 const hhmm = (at: number) => new Date(at).toISOString().slice(0, 16).replace("T", " ");
16
17 const forbiddenPage = (ctx: Ctx) =>
18 page(
19 {
outdated

The terminal needs a door with this same gate: a read-only memory view in src/tui/screens (next to boards.ts), built on memoryHours and given only to an admin who is a person. Add a test for both the admin and the refused cases.

20 ctx,
21 title: "Forbidden",
22 body: html`<div class="page-head"><h1>403</h1></div>
23 ${empty({ icon: "lock", title: "Admins only", note: "System memory is for a forge admin who is a person.", action: btn("Go home", { href: "/", variant: "primary" }) })}`,
24 },
25 403,
26 );
27
28 export async function systemPage(ctx: Ctx): Promise<Response> {
29 if (!canSeeMemory(ctx.auth.user)) return forbiddenPage(ctx);
30 const hours = memoryHours(ctx.app.db, Date.now());
31 const series = (title: string, pick: (h: (typeof hours)[number]) => number) =>
32 card(
33 html`<div class="chart-box">${raw(drawChart("line", hours.map((h) => ({ label: hhmm(h.at).slice(11), value: pick(h) / 1_048_576 })), title))}</div>`,
34 { head: html`<h2>${title}</h2>` },
35 );
36 const body =
37 hours.length === 0
38 ? empty({ icon: "activity", title: "No samples yet", note: "The forge samples its memory every minute; the first hour appears shortly." })
39 : html`<div class="sys-pair">${series("RSS (MiB, hourly mean)", (h) => h.rssMean)}${series("JS heap (MiB, hourly mean)", (h) => h.heapMean)}</div>
40 <table class="table">
41 <thead><tr><th scope="col">Hour (UTC)</th><th scope="col" class="num">RSS mean</th><th scope="col" class="num">RSS max</th><th scope="col" class="num">Heap mean</th><th scope="col" class="num">Heap max</th></tr></thead>
42 <tbody>${hours.map((h) => html`<tr><td class="mono">${hhmm(h.at)}</td><td class="num mono">${mib(h.rssMean)}</td><td class="num mono">${mib(h.rssMax)}</td><td class="num mono">${mib(h.heapMean)}</td><td class="num mono">${mib(h.heapMax)}</td></tr>`)}</tbody>
43 </table>`;
44 return page({
45 ctx,
46 title: "System",
47 body: html`<div class="page-head"><h1>System memory</h1></div>${body}`,
48 });
49 }
src/web/style/content.ts +2
@@ -383,6 +383,8 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
383383 .bp-h2 .chart-box.is-table { height: 400px; }
384384 .bp-h3 .chart-box.is-table { height: 540px; }
385385 .boards-result .table { margin-top: 12px; }
386 .sys-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-bottom: 16px; }
387 .sys-pair .chart-box { max-width: none; }
386388 .bgrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
387389 .bp-w2 { grid-column: span 2; }
388390 .bp-w3 { grid-column: span 3; }
test/system-memory.test.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * /system (#2151): fixed samples roll up into hours (mean, max, count),
3 * old hours are dropped, and the page draws RSS and the heap for an admin
4 * and is 403 for anyone else.
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 { createApp } from "../src/app.ts";
11 import { HOUR_MS, memoryHours, recordMemory } from "../src/boards/memory.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve } from "../src/http/server.ts";
15
16 const MiB = 1_048_576;
17 let dir: string;
18 let app: ReturnType<typeof createApp>;
19 let servers: Awaited<ReturnType<typeof serve>>;
20 const tok: Record<string, string> = {};
21 const get = (who: string | null) =>
22 fetch(servers.url + "/system", { headers: who ? { authorization: `Bearer ${tok[who]}` } : {} });
23
24 beforeAll(async () => {
25 dir = await mkdtemp(join(tmpdir(), "forge-system-"));
26 app = createApp({ dataDir: dir, port: 0 });
27 servers = await serve(app);
28 for (const [username, first] of [["alice", true], ["bob", false]] as const) {
29 const r = await createUser(app.db, {
30 trusted: true, username, email: `${username}@forge.local`,
31 password: "a-good-passphrase", ...(first ? { adminIfFirst: true } : {}),
32 });
33 if (!r.ok) throw new Error(r.error);
34 tok[username] = createToken(app.db, r.value.id, "t", "admin").token;
35 }
36 });
37 afterAll(async () => {
38 await servers.stop();
39 await rm(dir, { recursive: true, force: true });
40 });
41
42 describe("hourly roll-up", () => {
43 test("mean, max and count per hour from fixed samples", () => {
44 const h0 = 1_000 * HOUR_MS;
45 recordMemory(app.db, 100 * MiB, 10 * MiB, h0);
46 recordMemory(app.db, 300 * MiB, 30 * MiB, h0 + 60_000);
47 recordMemory(app.db, 200 * MiB, 20 * MiB, h0 + 120_000);
48 recordMemory(app.db, 500 * MiB, 10 * MiB, h0 + HOUR_MS + 5);
49 const hours = memoryHours(app.db, h0 + HOUR_MS + 10);
50 expect(hours).toEqual([
51 { at: h0, samples: 3, rssMean: 200 * MiB, rssMax: 300 * MiB, heapMean: 20 * MiB, heapMax: 30 * MiB },
52 { at: h0 + HOUR_MS, samples: 1, rssMean: 500 * MiB, rssMax: 500 * MiB, heapMean: 10 * MiB, heapMax: 10 * MiB },
53 ]);
54 });
55
56 test("a bad reading is ignored and hours past a week are dropped", () => {
57 const now = 5_000 * HOUR_MS;
58 recordMemory(app.db, Number.NaN, 1, now);
59 recordMemory(app.db, -1, 1, now);
60 recordMemory(app.db, 1, 1, now - 8 * 24 * HOUR_MS);
61 recordMemory(app.db, MiB, MiB, now);
62 expect(memoryHours(app.db, now, 24 * 30).map((h) => h.at)).toEqual([now]);
63 });
64
65 test("only the last 24 hours are returned", () => {
66 const now = 9_000 * HOUR_MS;
67 recordMemory(app.db, MiB, MiB, now - 30 * HOUR_MS);
68 recordMemory(app.db, MiB, MiB, now);
69 expect(memoryHours(app.db, now).map((h) => h.at)).toEqual([now]);
70 });
71 });
72
73 describe("GET /system", () => {
74 test("an admin sees both series, server-rendered", async () => {
75 recordMemory(app.db, 120 * MiB, 40 * MiB, Date.now());
76 const res = await get("alice");
77 expect(res.status).toBe(200);
78 const body = await res.text();
79 expect(body).toContain("RSS (MiB, hourly mean)");
80 expect(body).toContain("JS heap (MiB, hourly mean)");
81 expect(body.match(/<svg/g)?.length).toBeGreaterThanOrEqual(2);
82 expect(body).toContain("120.0 MiB");
83 expect(body).toContain("40.0 MiB");
84 });
85
86 test("a non-admin and an anonymous visitor get 403", async () => {
87 expect((await get("bob")).status).toBe(403);
88 expect((await get(null)).status).toBe(403);
89 });
90 });
91
92 describe("reaching /system", () => {
93 test("the account menu links it for an admin only", async () => {
94 const withCookie = async (who: string) =>

Nit: the helper is called withCookie but it sends a bearer token. Something like homeFor would say what it does.

95 (await (await fetch(servers.url + "/", { headers: { authorization: `Bearer ${tok[who]}` } })).text());
96 expect(await withCookie("alice")).toContain('href="/system"');
97 expect(await withCookie("bob")).not.toContain('href="/system"');
98 });
99
100 test("a refused visitor gets a page, not bare text", async () => {
101 const res = await get(null);
102 expect(res.status).toBe(403);
103 expect(await res.text()).toContain("<h1>403</h1>");
104 });
105 });
test/tui-system.test.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * /system in the terminal (#2151): a forge admin who is a person reads the
3 * hourly RSS and heap rows and is offered the door; an agent, a member and a
4 * guest get neither.
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 { createAgent } from "../src/actions/crew.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { recordMemory } from "../src/boards/memory.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
15 import { memoryLines, systemScreen } from "../src/tui/screens/system.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 const MiB = 1_048_576;
23 let dir: string;
24 let app: App;
25 let carol: User;
26 let bob: User;
27 let bot: User;
28
29 const identity = (user?: User) => ({
30 sshUsername: user?.username ?? "git",
31 remoteAddress: "127.0.0.1",
32 fingerprint: "SHA256:test",
33 user,
34 });
35
36 async function screenText(user?: User): Promise<string> {
37 const terminal = headlessTerminal(120, 30);
38 const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false });
39 tui.start();
40 tui.ctx.push(systemScreen(session, tui.ctx));
41 await settleApp(tui);
42 const text = tui.screen.toText();
43 tui.stop?.();
44 return text;
45 }
46
47 const doors = (user?: User): string[] => {
48 const { app: tui, session } = createForgeApp(app, identity(user), {
49 terminal: headlessTerminal(80, 24),
50 exitHooks: false,
51 });
52 const ids = forgeCommands(session)(tui.ctx).map((c) => c.id);
53 tui.stop?.();
54 return ids;
55 };
56
57 beforeAll(async () => {
58 dir = await mkdtemp(join(tmpdir(), "forge-tui-system-"));
59 app = createApp({ dataDir: dir, port: 0 });
60 const mk = async (username: string, first = false) => {
61 const r = await createUser(app.db, {
62 trusted: true, username, email: `${username}@forge.local`,
63 password: "a-good-passphrase", adminIfFirst: first,
64 });
65 if (!r.ok) throw new Error(r.error);
66 return r.value;
67 };
68 carol = await mk("carol", true);
69 bob = await mk("bob");
70 const made = await createAgent(app, carol, { username: "sysbot", brief: "x" });
71 if (!made.ok) throw new Error(String(made.error));
72 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id);
73 bot = { ...made.value.user, isAdmin: true };
74 recordMemory(app.db, 123 * MiB, 45 * MiB, Date.now());
75 }, 30_000);
76
77 afterAll(async () => {
78 app.close();
79 await rm(dir, { recursive: true, force: true });
80 });
81
82 describe("tui system memory", () => {
83 test("an admin person gets the rows and the door", async () => {
84 const lines = memoryLines(new Session(app, identity(carol)));
85 expect(lines[0]).toContain("RSS mean");
86 expect(lines.join("\n")).toContain("123.0");
87 const text = await screenText(carol);
88 expect(text).toContain("123.0");
89 expect(text).toContain("45.0");
90 expect(doors(carol)).toContain("system");
91 });
92
93 test("nobody else has rows or the door", async () => {
94 for (const who of [bob, bot, undefined]) {
95 expect(memoryLines(new Session(app, identity(who)))).toEqual([]);
96 expect(doors(who)).not.toContain("system");
97 expect(await screenText(who)).not.toContain("123.0");
98 }
99 });
100 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs