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

Living, open forge: live machine telemetry for admins (/system), read-only telemetry hooks for named agents (muse, grokbot), and a contribution funnel any agent can follow — transparent by default, sealed where it matters #2139

Merged opened this in answer to

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

Merged

scout/forge:scout/1789-living-open-forge-live-machine-telemetry → main 22 commits fork 6 threads · 6 unresolved

  • Merged. scout/1789-living-open-forge-live-machine-telemetry was merged into main as e066c10.

Review

Sign in to review this change.
28 files changed +1617 −90
README.md +4
@@ -621,6 +621,10 @@ Where the whole forge waits is a forge admin's: `/flow` shows each stage from re
621621
622622 The terminal also has an Access command (`:` → Access), a site admin's: it reads the same last-day non-browser requests as Settings → Access and the `access` MCP tool, newest first, and filters them with `s` (since), `u` (user agent) and `p` (path), `c` to clear.
623623
624 `/system` is the machine, for the owner (#1789): per-core CPU, hwmon temperatures, load, memory, disk per mount, network, and every process grouped by what the forge launched it for (`check:<run>`, `app:<slug>/<env>`, the forge, the host) with CPU, memory and age. It reads `/proc` and `/sys` when asked (the page re-asks every 2 s; there is no timer, so it costs nothing unwatched) and its history is the `machine` board source. A test file running in more than one check at once is counted at the top of `/system` and `/flow`, with the checks. Command lines are never kept — only `*.test.ts` arguments.
625 In the terminal, `:` → System shows the same view (the owner's only), and `:` → Telemetry lists the agents and flips `telemetry:read` for the one you press Enter on.
626 An agent the owner grants `telemetry:read` (Settings → Telemetry, one switch per agent, revoked the same way) reads the same through `GET /api/v1/telemetry/{system,flow,sources,history}` and the MCP `flow` and `system` tools; private repositories' checks and apps are renamed `check:private-N` and lose their processes and test files, and `flow` (like `system`, an admin agent account needs the grant too: only a person who is an admin reads without it) names only public repositories' conflicted pulls, every read is listed on that page, and an agent without the grant is refused. `/contribute`, `GET /api/v1/contribute` and the MCP `contribute` tool are open to anyone: what the forge is, the bottleneck now, open issues labelled “help wanted” in public repositories, and how to propose (fork, pull, the same gate). The grant is a table, not a token scope, so no token has to be re-issued.

This says an admin agent needs the grant for flow. That holds for MCP but not yet for /flow or the terminal's Flow command. Make the code match this sentence.

627
624628 `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and
625629 what "ready now" means to a person and to the crew. The map also shows each
626630 pillar's ask-to-live: the median hours from an issue opening to its change live
src/actions/telemetry.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * Who reads the forge's telemetry (#1789). The owner — a forge admin who is
3 * a person — always may. A named agent may only while the owner has granted
4 * it `telemetry:read`, and only the owner grants or revokes: an agent that
5 * could widen its own reading would be the crew deciding what it sees.
6 * Every grant leaves an event; every read an agent makes is kept.
7 */
8 import type { App } from "../app.ts";
9 import { grant, hasGrant, logRead, revoke } from "../core/telemetry.ts";
10 import { getUserByName, type User } from "../core/users.ts";
11 import { err, ok, type Result } from "../kernel/result.ts";
12
13 export type Reader = { kind: "owner" } | { kind: "agent" };
14
15 /** Whether `user` reads telemetry, and as what (the agent's view has private repositories cut out). */
16 export function telemetryReader(app: App, user: User | undefined): Reader | undefined {
17 if (!user) return undefined;
18 if (user.kind === "agent") return hasGrant(app.db, user.id) ? { kind: "agent" } : undefined;
19 return user.isAdmin ? { kind: "owner" } : undefined;
20 }
21
22 /** Record an agent's read; the owner's own reading is not an agent's to audit. */
23 export function noteRead(app: App, user: User, reader: Reader, what: string): void {
24 if (reader.kind === "agent") logRead(app.db, user.id, what);
25 }
26
27 export function setTelemetryGrant(
28 app: App,
29 actor: User | undefined,
30 username: string,
31 on: boolean,
32 ): Result<{ username: string; on: boolean }, "not_found" | "not_an_agent"> {
33 if (!actor || !actor.isAdmin || actor.kind === "agent") return err("not_found");
34 const target = getUserByName(app.db, username);
35 if (!target) return err("not_found");
36 if (target.kind !== "agent") return err("not_an_agent");
37 const was = hasGrant(app.db, target.id);
38 if (on) grant(app.db, target.id, actor.id);
39 else revoke(app.db, target.id);
40 if (was !== on)
41 app.bus.append({
42 kind: on ? "telemetry.granted" : "telemetry.revoked",
43 actorId: actor.id,
44 payload: { agent: target.username },
45 });
46 return ok({ username: target.username, on });
47 }
src/apps/contain.ts +7
@@ -806,3 +806,10 @@ function tick(): void {
806806 }
807807 }
808808 }
809
810 /** The root process of every labelled cell being watched, for grouping the process table by what launched it (`system/view.ts`). */
811 export function cellRoots(): { label: string; root: number }[] {
812 const out: { label: string; root: number }[] = [];
813 for (const e of watched) if (e.label !== undefined) out.push({ label: e.label, root: e.root });
814 return out;
815 }
src/core/migrations/20261015230532-telemetry-read.ts added +16
@@ -0,0 +1,16 @@
1 // The `telemetry:read` grant (#1789): which named agent may read /flow,
2 // /system and the boards' sources, who gave it, and every read it made.
3 export default `
4 CREATE TABLE telemetry_grant (
5 user_id TEXT PRIMARY KEY REFERENCES user(id) ON DELETE CASCADE,
6 granted_by TEXT NOT NULL REFERENCES user(id),
7 granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
8 );
9 CREATE TABLE telemetry_read (
10 id INTEGER PRIMARY KEY AUTOINCREMENT,
11 user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
12 what TEXT NOT NULL,
13 at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
14 );
15 CREATE INDEX telemetry_read_at ON telemetry_read(at);
16 `;
src/core/reserved.ts +1
@@ -64,6 +64,7 @@ export const SYSTEM_NAMES: readonly string[] = [
6464 "big",
6565 "board",
6666 "boards",
67 "contribute",
6768 "events",
6869 "explore",
6970 "family",
src/core/telemetry.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * The `telemetry:read` grant: an agent the owner named may read the forge's
3 * flow, system and board sources (sealed values and private contents are
4 * cut out where they are built, not here), and every read it makes is kept.
5 * Pure data, no authz — `actions/telemetry.ts` decides who may grant.
6 */
7 import type { Database } from "bun:sqlite";
8
9 export type ReadRow = { username: string; what: string; at: string };
10
11 export const READS_KEPT = 5_000;
12
13 export function hasGrant(db: Database, userId: string): boolean {
14 return db.query("SELECT 1 FROM telemetry_grant WHERE user_id = ?1").get(userId) !== null;
15 }
16
17 export function grant(db: Database, userId: string, by: string): void {
18 db.query("INSERT OR IGNORE INTO telemetry_grant (user_id, granted_by) VALUES (?1, ?2)").run(userId, by);
19 }
20
21 export function revoke(db: Database, userId: string): void {
22 db.query("DELETE FROM telemetry_grant WHERE user_id = ?1").run(userId);
23 }
24
25 /** Agents the owner could grant to: every agent account. */
26 export function grantable(db: Database): { id: string; username: string; granted: boolean }[] {
27 return db
28 .query<{ id: string; username: string; granted: number }, []>(
29 `SELECT u.id, u.username, EXISTS(SELECT 1 FROM telemetry_grant g WHERE g.user_id = u.id) AS granted
30 FROM user u WHERE u.is_agent = 1 ORDER BY u.username`,
31 )
32 .all()
33 .map((r) => ({ id: r.id, username: r.username, granted: r.granted === 1 }));
34 }
35
36 export function logRead(db: Database, userId: string, what: string): void {
37 db.query("INSERT INTO telemetry_read (user_id, what) VALUES (?1, ?2)").run(userId, what.slice(0, 64));
38 db.query(
39 "DELETE FROM telemetry_read WHERE id <= (SELECT MAX(id) FROM telemetry_read) - ?1",
40 ).run(READS_KEPT);
41 }
42
43 export function recentReads(db: Database, limit = 30): ReadRow[] {
44 return db
45 .query<ReadRow, [number]>(
46 `SELECT u.username AS username, r.what AS what, r.at AS at
47 FROM telemetry_read r JOIN user u ON u.id = r.user_id ORDER BY r.id DESC LIMIT ?1`,
48 )
49 .all(limit);
50 }
src/flow/conflicted.ts +5 −5
@@ -15,22 +15,22 @@ export interface ConflictedPull {
1515 readonly since: string;
1616 }
1717
18 /** Oldest first. */
19 export function conflictedPulls(db: Database): ConflictedPull[] {
18 /** Oldest first; `publicOnly` leaves out private repositories, for a reader who must not learn their names. */
19 export function conflictedPulls(db: Database, publicOnly = false): ConflictedPull[] {
2020 return db
21 .query<ConflictedPull, []>(
21 .query<ConflictedPull, [number]>(
2222 `SELECT o.username || '/' || r.name AS repo, i.number AS number,
2323 MIN(u.created_at) AS since
2424 FROM run u JOIN issue i ON i.id = u.issue_id
2525 JOIN pull p ON p.issue_id = i.id JOIN repo r ON r.id = i.repo_id JOIN user o ON o.id = r.owner_id
2626 WHERE u.kind = 'agent' AND u.reason = 'conflict' AND i.state = 'open'
27 AND p.merged_at IS NULL
27 AND p.merged_at IS NULL AND (?1 = 0 OR r.visibility = 'public')
2828 AND (SELECT state FROM run n WHERE n.issue_id = i.id AND n.kind = 'agent'
2929 AND n.reason = 'conflict' ORDER BY n.created_at DESC, n.id DESC LIMIT 1)
3030 IN ('queued','running','failed')
3131 GROUP BY i.id ORDER BY since`,
3232 )
33 .all();
33 .all(publicOnly ? 1 : 0);
3434 }
3535
3636 /** One sentence for /flow, or null when none is conflicted. */
src/http/api.ts +22
@@ -58,6 +58,8 @@ import { reissueDoor, spawnDoor } from "../spawn/door.ts";
5858 import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts";
5959 import { proposeUpstream } from "../actions/upstream.ts";
6060 import { relationOf, takeUpdates } from "../actions/forks.ts";
61 import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts";
62 import { flowFor, historyFor, sourcesOut, systemFor, welcome } from "../system/telemetry.ts";
6163 import { convergeFork, convergeState } from "../crew/converge.ts";
6264 import { markReaders, mayDirectCrew } from "../crew/direct.ts";
6365 import {
@@ -1325,6 +1327,26 @@ export function apiRoutes(router: Router<Handler>): void {
13251327 : forbidden();
13261328 };
13271329
1330 // Open read hooks (#1789): the owner, or an agent holding `telemetry:read`.
1331 // Anyone else is refused; every agent read is logged.
1332 const telemetryRead =
1333 (what: string, out: (ctx: Ctx, reader: Reader) => unknown) =>
1334 (ctx: Ctx): Response => {
1335 if (!ctx.auth.user) return unauthorized();
1336 const denied = requireScope(ctx, "ro");
1337 if (denied) return denied;
1338 const reader = telemetryReader(ctx.app, ctx.auth.user);
1339 if (!reader) return forbidden();
1340 noteRead(ctx.app, ctx.auth.user, reader, what);
1341 return json(out(ctx, reader));
1342 };
1343 router.add("GET", "/api/v1/telemetry/system", telemetryRead("system", (c, r) => systemFor(c.app, r)));
1344 router.add("GET", "/api/v1/telemetry/flow", telemetryRead("flow", (c, r) => flowFor(c.app, r)));
1345 router.add("GET", "/api/v1/telemetry/sources", telemetryRead("sources", () => ({ sources: sourcesOut() })));
1346 router.add("GET", "/api/v1/telemetry/history", telemetryRead("history", (c) => ({ points: historyFor(c.app) })));
1347 // The funnel: what any agent that arrives should know. Open to all.
1348 router.add("GET", "/api/v1/contribute", (ctx) => json(welcome(ctx.app)));
1349
13281350 router.add("GET", "/api/v1/boards/sources", (ctx) => {
13291351 const denied = boardsAdmin(ctx);
13301352 if (denied) return denied;
src/http/mcp.ts +29 −24
@@ -21,10 +21,6 @@ import { voteVariant } from "../actions/variants.ts";
2121 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
2222 import { listAccess } from "../actions/access_log.ts";
2323 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
24 import { conflictedPulls, conflictedSays } from "../flow/conflicted.ts";
25 import { readPipeline, verdict } from "../flow/pipeline.ts";
26 import { readQueues } from "../flow/queue.ts";
27 import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts";
2824 import { changeLimit, listLimits } from "../actions/limits.ts";
2925 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
3026 import { QUOTA_KEYS } from "../settings/quotas.ts";
@@ -147,6 +143,8 @@ import {
147143 } from "../actions/explore.ts";
148144 import { frontInput, frontJson, setFront } from "../actions/front.ts";
149145 import { readHealth } from "../actions/health.ts";
146 import { noteRead, telemetryReader } from "../actions/telemetry.ts";
147 import { flowFor, systemFor, welcome } from "../system/telemetry.ts";
150148 import { running } from "../kernel/version.ts";
151149 import { parentForgeFor } from "../core/parent_releases.ts";
152150 import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
@@ -2077,28 +2075,35 @@ const TOOLS: readonly Tool[] = [
20772075 inputSchema: { type: "object", properties: {} },
20782076 writes: false,
20792077 run(ctx, user) {
2080 if (!user.isAdmin)
2081 return bad("forbidden", "only an admin of this forge reads its pipeline");
2082 const now = new Date();
2083 const stages = readPipeline(ctx.app.db, now);
2084 const flow = readThroughput(ctx.app.db, stages.map((s) => s.key), now);
2085 const conflicted = conflictedPulls(ctx.app.db);
2086 return good({
2087 says: verdict(stages, now.getTime()),
2088 conflicted: {
2089 says: conflictedSays(conflicted, now.getTime()),
2090 pulls: conflicted,
2091 },
2092 stages,
2093 throughput: {
2094 says: throughputSays(flow),
2095 medianAskToLiveMs: flow.medianAskToLiveMs,
2096 stages: flow.stages.map(stageOut),
2097 },
2098 queue: readQueues(ctx.app.db, now),
2099 });
2078 const reader = telemetryReader(ctx.app, user);
2079 if (!reader)
2080 return bad("forbidden", "only an admin of this forge, or an agent granted telemetry:read, reads its pipeline");
2081 noteRead(ctx.app, user, reader, "flow");
2082 return good(flowFor(ctx.app, reader));
21002083 },
21012084 },
2085 {
2086 name: "system",
2087 description:
2088 "The machine the forge runs on, now: per-core CPU, temperatures, load, memory, disk per mount, network, and every process grouped by what launched it (which check, which app), each group with CPU cores, memory and age; and the test files running in more than one check at once (duplicates). For a forge admin, or an agent granted telemetry:read — whose view cuts out private repositories' checks and apps. Read-only; the same as /system and /api/v1/telemetry/system.",
2089 inputSchema: { type: "object", properties: {} },
2090 writes: false,
2091 run(ctx, user) {
2092 const reader = telemetryReader(ctx.app, user);
2093 if (!reader)
2094 return bad("forbidden", "only an admin of this forge, or an agent granted telemetry:read, reads its machine");
2095 noteRead(ctx.app, user, reader, "system");
2096 return good(systemFor(ctx.app, reader));
2097 },
2098 },
2099 {
2100 name: "contribute",
2101 description:
2102 "For any agent that arrives: what this forge is, where its bottleneck is right now, the open issues in public repositories labelled “help wanted” that welcome outside work, and how to propose (fork, pull, the gate it must pass). Open to every caller.",
2103 inputSchema: { type: "object", properties: {} },
2104 writes: false,
2105 run: (ctx) => good(welcome(ctx.app)),
2106 },
21022107 {
21032108 name: "limits",
21042109 description:
src/http/server.ts +7 −2
@@ -11,11 +11,13 @@ import { unsealReleased } from "../actions/sealed.ts";
1111 import { accessPage } from "../web/pages/access.ts";
1212 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1313 import { flowPage } from "../web/pages/flow.ts";
14 import { systemPage } from "../web/pages/system.ts";
15 import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts";
16 import { contributePage } from "../web/pages/contribute.ts";
1417 import { flagsPage, flagsSet } from "../web/pages/flags.ts";
1518 import { domainsPage } from "../web/pages/domains.ts";
1619 import { resiliencePage } from "../web/pages/resilience.ts";
1720 import { limitsPage, limitsSet, quotasSet } from "../web/pages/admin-limits.ts";
18 import { systemPage } from "../web/pages/system.ts";
1921 import { boardsPage } from "../web/pages/boards.ts";
2022 import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts";
2123 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
@@ -667,6 +669,10 @@ export function buildRouter(): Router<Handler> {
667669 router.add("GET", "/map", globalMapPage);
668670 // The pipeline's stages and its bottleneck, for a forge admin (#1554); literal, above /:owner.
669671 router.add("GET", "/flow", flowPage);
672 router.add("GET", "/system", systemPage);
673 router.add("GET", "/contribute", contributePage);
674 router.add("GET", "/settings/telemetry", telemetryPage);
675 router.add("POST", "/settings/telemetry", telemetrySet);
670676 // Each THREATS.md protection against the last gate; literal, above /:owner.
671677 router.add("GET", "/resilience", resiliencePage);
672678 // A selection's spend and runs over time; literal, above /:owner.
@@ -745,7 +751,6 @@ export function buildRouter(): Router<Handler> {
745751 router.add("GET", "/settings/resilience", domainsPage);
746752 router.add("GET", "/settings/capacity", capacityPage);
747753 router.add("POST", "/settings/capacity", capacitySet);
748 router.add("GET", "/system", systemPage);
749754 router.add("GET", "/boards", boardsList);
750755 router.add("GET", "/boards/data", boardsPage);
751756 router.add("POST", "/boards/new", boardNew);
src/system/format.ts added +14
@@ -0,0 +1,14 @@
1 /** Sizes and spans as /system and the terminal both print them. */
2 export const bytes = (n: number): string => {
3 const units = ["B", "KiB", "MiB", "GiB", "TiB"];
4 let v = n;
5 let i = 0;
6 while (v >= 1024 && i < units.length - 1) {
7 v /= 1024;
8 i++;
9 }
10 return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
11 };
12
13 export const span = (s: number): string =>
14 s < 90 ? `${s}s` : s < 5400 ? `${Math.round(s / 60)}m` : s < 172_800 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86_400)}d`;
src/system/live.ts added +68
@@ -0,0 +1,68 @@
1 /**
2 * The latest reading of the machine, shared by every viewer. There is no
3 * timer: a reading is taken when someone asks and the last one is more
4 * than a second old, and the rates are over the two most recent. A viewer
5 * that polls every 2 s therefore *is* the 2 s sampler, and costs nothing
6 * when nobody looks. The node's own history is `boards/sampler.ts`'s.
7 */
8 import type { App } from "../app.ts";
9 import { cellRoots } from "../apps/contain.ts";
10 import { HOST, readRaw, type Paths, type Raw } from "./proc.ts";
11 import { buildSystem, forAgent, type SystemView } from "./view.ts";
12
13 const FRESH_MS = 1_000;
14
15 export type Live = {
16 read(now?: number): SystemView;
17 };
18
outdated

history() and the 150-point ring behind it are never read in src/; the API's history comes from machine_sample. That makes this dead code. Remove it, or use it.

19 export function createLive(options: { paths?: Paths; roots?: typeof cellRoots; self?: number } = {}): Live {
20 const paths = options.paths ?? HOST;
21 const roots = options.roots ?? cellRoots;
22 const self = options.self ?? process.pid;
23 let prev: Raw | undefined;
24 let last: Raw | undefined;
25 let view: SystemView | undefined;
26 return {
27 read(now = Date.now()) {
28 if (view !== undefined && last !== undefined && now - last.at < FRESH_MS) return view;
29 const raw = readRaw(paths, now);
30 // A first reading has no rate: its rates are null until a second reading is taken.
31 prev = last;
32 last = raw;
33 view = buildSystem(prev, last, roots(), self);
34 return view;
35 },
36 };
37 }
38
39 let shared = createLive();
40 export const live = (): Live => shared;
41 /** Swap the shared reading — a test hands it a /proc it wrote. */
42 export const useLive = (next: Live): void => {
43 shared = next;
44 };
45
46 /** Whether the repository behind a cell's label is public. An unknown label is not. */
47 export function publicLabel(app: App, label: string): boolean {
48 const check = /^check:([\w-]{1,64})$/.exec(label);
49 const cell = /^app:([\w.-]{1,64})\//.exec(label);
50 const row = check
51 ? app.db
52 .query<{ v: string }, [string]>(
53 "SELECT repo.visibility AS v FROM run JOIN repo ON repo.id = run.repo_id WHERE run.id = ?1",
54 )
55 .get(check[1]!)
56 : cell
57 ? app.db
58 .query<{ v: string }, [string]>(
59 "SELECT repo.visibility AS v FROM app JOIN repo ON repo.id = app.repo_id WHERE app.slug = ?1",
60 )
61 .get(cell[1]!)
62 : undefined;
63 return row?.v === "public";
64 }
65
66 /** The view a named agent may read. */
67 export const agentView = (app: App, view: SystemView): SystemView =>
68 forAgent(view, (label) => publicLabel(app, label));
src/system/proc.ts added +217
@@ -0,0 +1,217 @@
1 /**
2 * What the machine is doing right now, read from /proc and /sys and nothing
3 * else: per-core ticks, temperatures (hwmon), memory, load, disk per mount,
4 * network, and every process with its parent, CPU ticks, resident memory
5 * and start. Plain data out; a file that is missing or unreadable leaves its
6 * series empty, never a guess. Paths are a parameter so a test can hand it a
7 * directory it wrote.
8 */
9 import { readdirSync, readFileSync, statfsSync } from "node:fs";
10 import { join } from "node:path";
11
12 export type Paths = { proc: string; sys: string };
13 export const HOST: Paths = { proc: "/proc", sys: "/sys" };
14
15 const HZ = 100;
16 const PAGE = 4096;
17 const PROCESSES_MAX = 20_000;
18 const MOUNTS_MAX = 24;
19
20 export type CoreTicks = { busy: number; total: number };
21 export type Temp = { chip: string; label: string; celsius: number };
22 export type Memory = { total: number; available: number; swapTotal: number; swapFree: number };
23 export type Disk = { mount: string; fs: string; total: number; used: number };
24 export type Nic = { name: string; rx: number; tx: number };
25 export type Proc = {
26 pid: number;
27 ppid: number;
28 comm: string;
29 ticks: number;
30 rss: number;
31 /** Seconds since it started. */
32 age: number;
33 /** The `*.test.ts` files named in its arguments, nothing else of its command line. */
34 tests: string[];
35 };
36
37 export type Raw = {
38 at: number;
39 cores: CoreTicks[];
40 temps: Temp[];
41 load: [number, number, number] | null;
42 memory: Memory | null;
43 disks: Disk[];
44 net: Nic[];
45 procs: Proc[];
46 };
47
48 function read(path: string): string | null {
49 try {
50 return readFileSync(path, "utf8");
51 } catch {
52 return null;
53 }
54 }
55
56 const num = (s: string | undefined): number => {
57 const n = Number(s);
58 return Number.isFinite(n) && n >= 0 ? n : 0;
59 };
60
61 /** One entry per `cpuN` line of /proc/stat: busy and total ticks. */
62 export function parseCores(text: string | null): CoreTicks[] {
63 const out: CoreTicks[] = [];
64 for (const m of (text ?? "").matchAll(/^cpu(\d+) +(.*)$/gm)) {
65 const f = m[2]!.trim().split(/\s+/).map(num);
66 const total = f.slice(0, 8).reduce((a, b) => a + b, 0);
67 const idle = (f[3] ?? 0) + (f[4] ?? 0);
68 out[Number(m[1])] = { busy: total - idle, total };
69 }
70 return out.filter((c) => c !== undefined);
71 }
72
73 export function parseMemory(text: string | null): Memory | null {
74 if (text === null) return null;
75 const kb = (k: string): number => {
76 const m = new RegExp(`^${k}:\\s+(\\d+) kB$`, "m").exec(text);
77 return m ? Number(m[1]) * 1024 : 0;
78 };
79 const total = kb("MemTotal");
80 return total === 0
81 ? null
82 : { total, available: kb("MemAvailable"), swapTotal: kb("SwapTotal"), swapFree: kb("SwapFree") };
83 }
84
85 export function parseLoad(text: string | null): [number, number, number] | null {
86 const f = text?.trim().split(/\s+/).slice(0, 3).map(Number);
87 return f && f.length === 3 && f.every(Number.isFinite) ? [f[0]!, f[1]!, f[2]!] : null;
88 }
89
90 export function parseNet(text: string | null): Nic[] {
91 const out: Nic[] = [];
92 for (const line of (text ?? "").split("\n").slice(2)) {
93 const m = /^\s*([\w.@-]+):\s*(.*)$/.exec(line);
94 if (!m || m[1] === "lo") continue;
95 const f = m[2]!.trim().split(/\s+/);
96 out.push({ name: m[1]!, rx: num(f[0]), tx: num(f[8]) });
97 }
98 return out;
99 }
100
101 /** The `stat` line of one process; null when it is not one. */
102 export function parseProcStat(text: string): { comm: string; ppid: number; ticks: number; rss: number; start: number } | null {
103 const open = text.indexOf("(");
104 const close = text.lastIndexOf(")");
105 if (open < 0 || close < open) return null;
106 const f = text.slice(close + 2).split(" ");
107 if (f.length < 22) return null;
108 return {
109 comm: text.slice(open + 1, close).slice(0, 32),
110 ppid: num(f[1]),
111 ticks: num(f[11]) + num(f[12]),
112 rss: num(f[21]) * PAGE,
113 start: num(f[19]) / HZ,
114 };
115 }
116
117 const TEST_FILE = /^(?:\.\/)?[\w@.+/-]{1,200}\.test\.ts$/;
118
119 /** The test files an argument vector names: only arguments shaped like one, so nothing else of a command line is kept. */
120 export function testFiles(cmdline: string | null): string[] {
121 if (!cmdline) return [];
122 const seen = new Set<string>();
123 for (const arg of cmdline.split("\0")) if (TEST_FILE.test(arg)) seen.add(arg.replace(/^\.\//, ""));
124 return [...seen].slice(0, 16);
125 }
126
127 export function readTemps(sys: string): Temp[] {
128 const out: Temp[] = [];
129 const base = join(sys, "class/hwmon");
130 let chips: string[] = [];
131 try {
132 chips = readdirSync(base).filter((n) => /^hwmon\d+$/.test(n));
133 } catch {
134 return out;
135 }
136 for (const chip of chips) {
137 const dir = join(base, chip);
138 const name = read(join(dir, "name"))?.trim() ?? chip;
139 let files: string[] = [];
140 try {
141 files = readdirSync(dir).filter((n) => /^temp\d+_input$/.test(n));
142 } catch {
143 continue;
144 }
145 for (const f of files) {
146 const milli = Number(read(join(dir, f))?.trim());
147 if (!Number.isFinite(milli)) continue;
148 const label = read(join(dir, f.replace("_input", "_label")))?.trim() ?? f.replace("_input", "");
149 out.push({ chip: name.slice(0, 32), label: label.slice(0, 48), celsius: milli / 1000 });
150 }
151 }
152 return out;
153 }
154
155 const REAL_FS = new Set(["ext4", "xfs", "btrfs", "zfs", "f2fs", "vfat", "overlay", "nfs", "nfs4"]);
156
157 export function readDisks(proc: string): Disk[] {
158 const out: Disk[] = [];
159 const seen = new Set<string>();
160 for (const line of (read(join(proc, "mounts")) ?? "").split("\n")) {
161 const [dev, mount, fs] = line.split(" ");
162 if (!dev || !mount || !fs || !REAL_FS.has(fs) || seen.has(dev + fs)) continue;
163 seen.add(dev + fs);
164 try {
165 const s = statfsSync(mount.replace(/\\040/g, " "));
166 const total = s.blocks * s.bsize;
167 if (total > 0) out.push({ mount: mount.replace(/\\040/g, " "), fs, total, used: total - s.bfree * s.bsize });
168 } catch {
169 // unreadable mount: absent, not guessed
170 }
171 if (out.length >= MOUNTS_MAX) break;
172 }
173 return out;
174 }
175
176 export function readProcs(proc: string): Proc[] {
177 let names: string[] = [];
178 try {
179 names = readdirSync(proc).filter((n) => /^\d+$/.test(n));
180 } catch {
181 return [];
182 }
183 const up = num(read(join(proc, "uptime"))?.split(" ")[0]);
184 const out: Proc[] = [];
185 for (const name of names.slice(0, PROCESSES_MAX)) {
186 const text = read(join(proc, name, "stat"));
187 const s = text === null ? null : parseProcStat(text);
188 if (!s) continue;
189 const isBun = /^(bun|node)/.test(s.comm);
190 out.push({
191 pid: Number(name),
192 ppid: s.ppid,
193 comm: s.comm,
194 ticks: s.ticks,
195 rss: s.rss,
196 age: Math.max(0, up - s.start),
197 // Only a runner of tests can be running a test file; skip the cmdline read for the rest.
198 tests: isBun ? testFiles(read(join(proc, name, "cmdline"))) : [],
199 });
200 }
201 return out;
202 }
203
204 export function readRaw(paths: Paths, at: number): Raw {
205 return {
206 at,
207 cores: parseCores(read(join(paths.proc, "stat"))),
208 temps: readTemps(paths.sys),
209 load: parseLoad(read(join(paths.proc, "loadavg"))),
210 memory: parseMemory(read(join(paths.proc, "meminfo"))),
211 disks: readDisks(paths.proc),
212 net: parseNet(read(join(paths.proc, "net/dev"))),
213 procs: readProcs(paths.proc),
214 };
215 }
216
217 export const TICKS_PER_SECOND = HZ;
src/system/telemetry.ts added +119
@@ -0,0 +1,119 @@
1 /**
2 * What the open read hooks hand out (#1789), built once so the page, the API
3 * and the MCP tools say the same thing: the pipeline (flow), the machine
4 * (system), the boards' sources (names and columns, never rows) and the
5 * node's history. Everything here is counts, rates and names of public
6 * things — no sealed value, no private repository's content, no person.
7 */
8 import type { App } from "../app.ts";
9 import { SOURCES } from "../boards/sources.ts";
10 import { conflictedPulls, conflictedSays } from "../flow/conflicted.ts";
11 import { readPipeline, verdict } from "../flow/pipeline.ts";
12 import { readQueues } from "../flow/queue.ts";
13 import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts";
14 import type { Reader } from "../actions/telemetry.ts";
15 import { agentView, live } from "./live.ts";
16 import type { SystemView } from "./view.ts";
17
18 export function systemFor(app: App, reader: Reader): SystemView {
19 const view = live().read();
20 return reader.kind === "agent" ? agentView(app, view) : view;
21 }
22
23 export function flowFor(app: App, reader: Reader, now = new Date()) {
24 const stages = readPipeline(app.db, now);
25 const flow = readThroughput(app.db, stages.map((s) => s.key), now);
26 const dup = systemFor(app, reader).duplicates;
27 const conflicted = conflictedPulls(app.db, reader.kind === "agent");
28 return {
29 says: verdict(stages, now.getTime()),
30 conflicted: { says: conflictedSays(conflicted, now.getTime()), pulls: conflicted },
31 stages,
32 throughput: {
33 says: throughputSays(flow),
34 medianAskToLiveMs: flow.medianAskToLiveMs,
35 stages: flow.stages.map(stageOut),
36 },
37 // A private repository's name is not an agent's to read, so its queue line is cut.
38 queue: queuesFor(app, reader, now),
39 duplicateWork: dup,
40 };
41 }
42
43 /** The merge queue as `reader` may see it: an agent's has private repositories cut out. */
44 export function queuesFor(app: App, reader: Reader, now = new Date()) {
45 return readQueues(app.db, now).filter((q) => reader.kind === "owner" || publicRepo(app, q.repo));
46 }
47
48 function publicRepo(app: App, full: string): boolean {
49 const [owner, name] = full.split("/");
50 return (
51 app.db
52 .query<{ v: string }, [string, string]>(
53 "SELECT r.visibility AS v FROM repo r JOIN user u ON u.id = r.owner_id WHERE u.username = ?1 AND r.name = ?2",
54 )
55 .get(owner ?? "", name ?? "")?.v === "public"
56 );
57 }
58
59 export function sourcesOut() {
60 return Object.entries(SOURCES).map(([name, s]) => ({
61 name,
62 time: s.time,
63 columns: s.columns,
64 ...(s.docs ? { docs: s.docs } : {}),
65 }));
66 }
67
68 /** The node's 15-s points for the last hour: the `machine` source's `node` and `forge` targets, nothing per-cell. */
69 export function historyFor(app: App) {
70 return app.db
71 .query<
72 { target: string; at: string; cpu_cores: number | null; memory_bytes: number | null; load: number | null },
73 []
74 >(
75 `SELECT target, at, cpu_cores, memory_bytes, load FROM machine_sample
76 WHERE step = 15 AND target IN ('node','forge')
77 AND at >= strftime('%Y-%m-%dT%H:%M:%fZ','now','-1 hour') ORDER BY at, target`,
78 )
79 .all()
80 .map((r) => ({ target: r.target, at: r.at, cpuCores: r.cpu_cores, memoryBytes: r.memory_bytes, load: r.load }));
81 }
82
83 export const WELCOME_LABEL = "help wanted";
84
85 /** What an agent that arrives should know: what this is, where it hurts, what welcomes it, how to propose. */
86 export function welcome(app: App, now = new Date()) {
87 const stages = readPipeline(app.db, now);
88 const issues = app.db
outdated

High: sealed issues are not excluded. A sealed security report on a public repo that carries "help wanted" would be listed to unauthenticated callers at /contribute, /api/v1/contribute and in the MCP contribute tool. Add AND i.sealed = 0 here, and add a test that would fail without it.

89 .query<{ owner: string; name: string; number: number; title: string }, [string]>(
90 `SELECT u.username AS owner, r.name AS name, i.number AS number, i.title AS title
91 FROM issue i JOIN repo r ON r.id = i.repo_id JOIN user u ON u.id = r.owner_id
92 JOIN issue_label il ON il.issue_id = i.id JOIN label l ON l.id = il.label_id
93 WHERE i.kind = 'issue' AND i.state = 'open' AND r.visibility = 'public'
94 AND i.sealed = 0
95 AND l.name = ?1 COLLATE NOCASE
96 ORDER BY i.updated_at DESC LIMIT 20`,
97 )
98 .all(WELCOME_LABEL);
99 return {
100 forge:
101 "A forge that runs what it hosts: git, issues, pulls, a crew of agents and the apps they build, in the open. Every pull gets a preview; every push is judged by the same check.",
102 bottleneck: { says: verdict(stages, now.getTime()), see: "/flow (the owner's crew and any agent granted telemetry:read)" },
103 welcomeLabel: WELCOME_LABEL,
104 issues: issues.map((i) => ({
105 repo: `${i.owner}/${i.name}`,
106 number: i.number,
107 title: i.title,
108 path: `/${i.owner}/${i.name}/issues/${i.number}`,
109 })),
110 propose: [
111 "Fork the repository (POST /api/v1/repos/{owner}/{name}/forks, or the Fork button) and push a branch to your fork.",
112 "Open a pull against the default branch. Your pull runs in the same isolated cell as the crew's: no network beyond what forge.json declares, none of the forge's data or secrets.",
113 "The crew's reviewers read it. It lands only through the gate everything passes: `bun x tsc --noEmit` and `bun test` green, a test for every behaviour you add, zero new dependencies without a reason.",
114 "Anyone may propose. The bar decides what merges. A person lands it; an agent never does.",
115 ],
116 telemetry:
117 "Ask the owner for the telemetry:read grant to read /flow and /system from the API and MCP (tools flow, system), with private repositories and sealed values cut out. Every read is logged.",
118 };
119 }
src/system/view.ts added +178
@@ -0,0 +1,178 @@
1 /**
2 * The machine as one plain object (`/system`, the `system` MCP tool and
3 * `/api/v1/telemetry/system`): two raw readings in, rates and groups out.
4 * Every process belongs to the cell that launched it (`check:<run>` or
5 * `app:<slug>/<env>`), else to the forge itself, else to the host. The same
6 * test file under two checks at once is duplicate work, and is named.
7 * Command lines are never kept — only `*.test.ts` arguments.
8 */
9 import type { Proc, Raw } from "./proc.ts";
10 import { TICKS_PER_SECOND } from "./proc.ts";
11
12 export type Group = {
13 label: string;
14 kind: "check" | "app" | "forge" | "host";
15 procs: number;
16 cpuCores: number | null;
17 rssBytes: number;
18 oldestSeconds: number;
19 top: { pid: number; comm: string; cpuCores: number | null; rssBytes: number; ageSeconds: number }[];
20 tests: string[];
21 };
22
23 export type Duplicate = { file: string; checks: string[] };
24
25 export type SystemView = {
26 at: string;
27 /** Seconds between the two readings the rates are over; 0 when there was only one. */
28 seconds: number;
29 cpu: { busyPct: number | null; cores: (number | null)[] };
30 temps: { chip: string; label: string; celsius: number }[];
31 hottestC: number | null;
32 load: [number, number, number] | null;
33 memory: { totalBytes: number; usedBytes: number; swapUsedBytes: number } | null;
34 disks: { mount: string; fs: string; totalBytes: number; usedBytes: number }[];
35 net: { name: string; rxBytesPerSecond: number | null; txBytesPerSecond: number | null }[];
36 processes: number;
37 groups: Group[];
38 duplicates: Duplicate[];
39 };
40
41 export type Roots = readonly { label: string; root: number }[];
42
43 const kindOf = (label: string): Group["kind"] =>
44 label.startsWith("check:") ? "check" : label.startsWith("app:") ? "app" : label === "forge" ? "forge" : "host";
45
46 /** Which group each process is in: the nearest cell root above it, the forge, or the host. */
47 function groupOf(procs: readonly Proc[], roots: Roots, self: number): Map<number, string> {
48 const by = new Map(procs.map((p) => [p.pid, p]));
49 const rootLabel = new Map(roots.map((r) => [r.root, r.label]));
50 const memo = new Map<number, string>();
51 const find = (pid: number): string => {
52 const hit = memo.get(pid);
53 if (hit !== undefined) return hit;
54 let at = pid;
55 let found = "host";
56 for (let i = 0; i < 64; i++) {
57 const label = rootLabel.get(at);
58 if (label !== undefined) {
59 found = label;
60 break;
61 }
62 if (at === self) {
63 found = "forge";
64 break;
65 }
66 const up = by.get(at)?.ppid;
67 if (up === undefined || up <= 0) break;
68 at = up;
69 }
70 memo.set(pid, found);
71 return found;
72 };
73 for (const p of procs) find(p.pid);
74 return memo;
75 }
76
77 const rate = (a: number | undefined, b: number | undefined, s: number): number | null =>
78 a === undefined || b === undefined || s <= 0 || b < a ? null : (b - a) / s;
79
80 export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self: number): SystemView {
81 const seconds = prev === undefined ? 0 : Math.max(0, (next.at - prev.at) / 1000);
82 const cores = next.cores.map((c, i) => {
83 const p = prev?.cores[i];
84 if (!p || seconds <= 0 || c.total <= p.total) return null;
85 return Math.round(((c.busy - p.busy) / (c.total - p.total)) * 1000) / 10;
86 });
87 const known = cores.filter((c): c is number => c !== null);
88 const before = new Map((prev?.procs ?? []).map((p) => [p.pid, p]));
89 const where = groupOf(next.procs, roots, self);
90 const groups = new Map<string, Group>();
91 const files = new Map<string, Set<string>>();
92 for (const p of next.procs) {
93 const label = where.get(p.pid) ?? "host";
94 const g =
95 groups.get(label) ??
96 { label, kind: kindOf(label), procs: 0, cpuCores: null, rssBytes: 0, oldestSeconds: 0, top: [], tests: [] };
97 const old = before.get(p.pid);
98 const cpu = old && old.age <= p.age ? rate(old.ticks, p.ticks, seconds * TICKS_PER_SECOND) : null;
99 g.procs++;
100 g.rssBytes += p.rss;
101 g.oldestSeconds = Math.max(g.oldestSeconds, Math.round(p.age));
102 if (cpu !== null) g.cpuCores = (g.cpuCores ?? 0) + cpu;
103 g.top.push({ pid: p.pid, comm: p.comm, cpuCores: cpu, rssBytes: p.rss, ageSeconds: Math.round(p.age) });
104 for (const f of p.tests) {
105 if (!g.tests.includes(f)) g.tests.push(f);
106 if (g.kind === "check") (files.get(f) ?? files.set(f, new Set()).get(f)!).add(label);
107 }
108 groups.set(label, g);
109 }
110 for (const g of groups.values()) {
111 g.top = g.top
112 .sort((a, b) => (b.cpuCores ?? 0) - (a.cpuCores ?? 0) || b.rssBytes - a.rssBytes)
113 .slice(0, 5);
114 if (g.cpuCores !== null) g.cpuCores = Math.round(g.cpuCores * 100) / 100;
115 }
116 const mem = next.memory;
117 return {
118 at: new Date(next.at).toISOString(),
119 seconds,
120 cpu: {
121 busyPct: known.length === 0 ? null : Math.round((known.reduce((a, b) => a + b, 0) / known.length) * 10) / 10,
122 cores,
123 },
124 temps: next.temps,
125 hottestC: next.temps.length === 0 ? null : Math.max(...next.temps.map((t) => t.celsius)),
126 load: next.load,
127 memory: mem && {
128 totalBytes: mem.total,
129 usedBytes: mem.total - mem.available,
130 swapUsedBytes: mem.swapTotal - mem.swapFree,
131 },
132 disks: next.disks.map((d) => ({ mount: d.mount, fs: d.fs, totalBytes: d.total, usedBytes: d.used })),
133 net: next.net.map((n) => {
134 const o = prev?.net.find((x) => x.name === n.name);
135 return {
136 name: n.name,
137 rxBytesPerSecond: rate(o?.rx, n.rx, seconds),
138 txBytesPerSecond: rate(o?.tx, n.tx, seconds),
139 };
140 }),
141 processes: next.procs.length,
142 groups: [...groups.values()].sort((a, b) => (b.cpuCores ?? 0) - (a.cpuCores ?? 0) || b.rssBytes - a.rssBytes),
143 duplicates: [...files]
144 .filter(([, labels]) => labels.size > 1)
145 .map(([file, labels]) => ({ file, checks: [...labels].sort() }))
146 .sort((a, b) => b.checks.length - a.checks.length || (a.file < b.file ? -1 : 1)),
147 };
148 }
149
150 /**
151 * What a named agent may read: a private repository's checks and apps keep
152 * their place in the totals but lose their name, their test files and their
153 * processes. `visible` says whether a label's repository is public.
154 */
155 export function forAgent(view: SystemView, visible: (label: string) => boolean): SystemView {
156 const hide = new Map<string, string>();
157 const n = { check: 0, app: 0 };
158 const name = (label: string): string => {
159 if (visible(label) || kindOf(label) === "forge" || kindOf(label) === "host") return label;
160 const kind = kindOf(label) as "check" | "app";
161 return hide.get(label) ?? hide.set(label, `${kind}:private-${++n[kind]}`).get(label)!;
162 };
163 return {
164 ...view,
165 groups: view.groups.map((g) =>
166 // The forge and the host hold whatever no labelled cell claims (a crew run in a private clone,
167 // an unwatched sandbox): their counts stay, their processes and test files never leave.
168 g.kind === "forge" || g.kind === "host"
169 ? { ...g, top: [], tests: [] }
170 : visible(g.label)
171 ? g
172 : { ...g, label: name(g.label), top: [], tests: [] },
173 ),
174 duplicates: view.duplicates
175 .map((d) => ({ file: d.checks.every(visible) ? d.file : "(private)", checks: d.checks.map(name).sort() }))
176 .sort((a, b) => b.checks.length - a.checks.length),
177 };
178 }
src/tui/forge.ts +61 −5
@@ -24,13 +24,13 @@ import {
2424 ORIGIN_MARK,
2525 PULL_MARK,
2626 errorText,
27 pickerScreen,
2728 promptScreen,
2829 openOnWeb,
2930 runAsync,
3031 } from "./screens/common.ts";
3132 import { canSeeMemory } from "../boards/memory.ts";
3233 import { boardsScreen } from "./screens/boards.ts";
33 import { systemScreen } from "./screens/system.ts";
3434 import { askedScreen } from "./screens/asked.ts";
3535 import { deciderScreen } from "./screens/decider.ts";
3636 import { familyScreen } from "./screens/family.ts";
@@ -45,6 +45,11 @@ import { flagsScreen } from "./screens/flags.ts";
4545 import { domainsScreen } from "./screens/domains.ts";
4646 import { resilienceLines, resilienceScreen } from "./screens/resilience.ts";
4747 import { flowLines, flowScreen } from "./screens/flow.ts";
48 import { memoryScreen, systemLines, systemScreen } from "./screens/system.ts";
49 import { grantable, READS_KEPT, recentReads } from "../core/telemetry.ts";
50 import { noteRead, setTelemetryGrant, telemetryReader } from "../actions/telemetry.ts";
51 import { queuesFor, systemFor } from "../system/telemetry.ts";
52 import { live as liveMachine } from "../system/live.ts";
4853 import { releasesScreen } from "./screens/releases.ts";
4954 import { mayRead } from "../release/gate.ts";
5055 import { stalledScreen } from "./screens/stalled.ts";
@@ -235,19 +240,70 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
235240 // The forge's RSS beside its JS heap, as /system gates it (#2151).
236241 if (canSeeMemory(session.user))
237242 commands.push({
outdated

The terminal's Flow door has the same gap: isAdmin alone lets an admin agent read flow and the unfiltered live().read().duplicates without the grant. Gate it the way the MCP tool now does.

238 id: "system",
243 id: "system-memory",
239244 title: "System memory",
240245 hint: "RSS beside the JS heap, hourly for 24 h",
241 run: () => ctx.push(systemScreen(session, ctx)),
246 run: () => ctx.push(memoryScreen(session, ctx)),
242247 });
243248 // The pipeline's bottleneck and throughput, as /flow says them: a site admin's (#1554, #1786).
244 if (session.user?.isAdmin === true)
249 const flowReader = telemetryReader(session.app, session.user);
250 if (flowReader)
245251 commands.push({
246252 id: "flow",
247253 title: "Flow",
248254 hint: "the pipeline's bottleneck and throughput",
249 run: () => ctx.push(flowScreen(ctx, flowLines(session.db))),
255 run: () => {
256 if (session.user) noteRead(session.app, session.user, flowReader, "flow");
257 ctx.push(flowScreen(ctx, flowLines(
258 session.db,
259 new Date(),
260 systemFor(session.app, flowReader).duplicates,
261 flowReader.kind === "agent" ? { queues: queuesFor(session.app, flowReader) } : undefined,
262 )));
263 },
250264 });
265 // The machine, as /system says it, and which agents may read it: the owner's, a person (#1789).
266 if (session.user?.isAdmin === true && session.user.kind !== "agent") {
267 commands.push({
268 id: "system",
269 title: "System",
270 hint: "the machine now: CPU, memory, disks, processes",
271 run: () => ctx.push(systemScreen(ctx, systemLines(liveMachine().read()))),
272 });
273 commands.push({
274 id: "telemetry",
275 title: "Telemetry",
276 hint: "which agents may read flow and system; enter flips the grant",
277 run: () => {
278 const actor = session.user;
279 const agents = grantable(session.db);
280 if (agents.length === 0) return ctx.toast("No agent accounts yet");
281 const last = new Map<string, { n: number; what: string; at: string }>();
282 for (const r of recentReads(session.db, READS_KEPT).reverse()) {
283 last.set(r.username, { n: (last.get(r.username)?.n ?? 0) + 1, what: r.what, at: r.at });
284 }
285 const readsOf = (name: string): string => {
286 const l = last.get(name);
287 return l ? `${l.n} read${l.n === 1 ? "" : "s"}, last ${l.what} ${l.at}` : "no reads";
288 };
289 ctx.push(
290 pickerScreen(ctx, blank("Telemetry"), {
291 title: "Telemetry: telemetry:read",
292 items: agents.map((a) => ({ id: a.username, label: a.username, hint: `${a.granted ? "granted" : "not granted"} · ${readsOf(a.username)}` })),
293 onPick: ([name]) => {
294 const was = agents.find((a) => a.username === name);
295 if (!was) return;
296 const done = setTelemetryGrant(session.app, actor, was.username, !was.granted);
297 ctx.toast(
298 done.ok ? `${was.username}: telemetry:read ${done.value.on ? "granted" : "revoked"}` : "Could not change the grant",
299 done.ok ? "success" : "attn",
300 );
301 },
302 }),
303 );
304 },
305 });
306 }
251307 // Each THREATS.md protection's standing, as /resilience says it: a site admin's.
252308 if (session.user?.isAdmin === true)
253309 commands.push({
src/tui/screens/flow.ts +13 −3
@@ -17,16 +17,24 @@ import type { Ctx, Screen } from "../app/app.ts";
1717 import { binding } from "../app/keys.ts";
1818 import { pad, truncate, wrap } from "../term/width.ts";
1919 import { dialog } from "../ui/widgets.ts";
20 import { live } from "../../system/live.ts";
21 import { duplicateLines } from "./system.ts";
2022
2123 const WIDTH = 64;
2224
2325 /** What the dialog says, line by line, wrapped to fit inside it. */
24 export function flowLines(db: Database, now = new Date()): string[] {
26 export function flowLines(
27 db: Database,
28 now = new Date(),
29 dups = live().read().duplicates,
30 /** What an agent reader may see: its queue lines and conflicted pulls come already cut to public. */
31 agent?: { queues: ReturnType<typeof readQueues> },
32 ): string[] {
2533 const stages = readPipeline(db, now);
2634 const flow = readThroughput(db, stages.map((s) => s.key), now);
2735 const inner = WIDTH - 6;
28 const queues = readQueues(db, now);
29 const conflicted = conflictedSays(conflictedPulls(db), now.getTime());
36 const queues = agent?.queues ?? readQueues(db, now);
37 const conflicted = conflictedSays(conflictedPulls(db, agent !== undefined), now.getTime());
3038 return [
3139 ...wrap(verdict(stages, now.getTime()), inner),
3240 "",
@@ -46,6 +54,8 @@ export function flowLines(db: Database, now = new Date()): string[] {
4654 `${pad(truncate(q.repo, 30), 30)} ${pad(String(q.queued), 6, "right")} ${pad(String(q.gating), 6, "right")} ${pad(String(q.landedHour), 9, "right")}`,
4755 )),
4856 ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]),
57 "",
58 ...duplicateLines(dups, inner),
4959 ];
5060 }
5161
src/tui/screens/system.ts +68 −8
@@ -1,19 +1,79 @@
11 /**
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.
2 * The System command's dialog, the owner's (#1789): the machine now, in the
3 * lines of `/system` — CPU, load, heat, memory, disks, and the heaviest
4 * groups of processes with the test files running in more than one check.
5 * It reads `live().read()`, the same view the page and the API serve.
6 * Read-only; Enter, Esc or q closes it.
7 *
8 * `memoryScreen` is `/system`'s other half (#2151): the forge's RSS beside
9 * its JS heap, an hour a row for the last 24 h, read through `memoryHours`
10 * like the page. The gate is the page's (`canSeeMemory`): anyone but a forge
11 * admin who is a person gets no rows. Read-only.
712 */
813
9 import { canSeeMemory, memoryHours } from "../../boards/memory.ts";
1014 import type { Ctx, Screen } from "../app/app.ts";
15 import type { SystemView } from "../../system/view.ts";
16 import { canSeeMemory, memoryHours } from "../../boards/memory.ts";
1117 import { binding } from "../app/keys.ts";
1218 import type { Session } from "../session.ts";
19 import { bytes } from "../../system/format.ts";
1320 import { style } from "../term/style.ts";
14 import { pad } from "../term/width.ts";
21 import { pad, wrap } from "../term/width.ts";
22 import { dialog } from "../ui/widgets.ts";
1523 import { Scroll, drawLines, hitScroll } from "./common.ts";
1624
25 const WIDTH = 72;
26 const GROUPS = 6;
27
28 const pct = (n: number | null): string => (n === null ? "—" : `${n.toFixed(1)}%`);
29
30 /** The duplicate-work sentence, wrapped: the one /flow and /system say. */
31 export function duplicateLines(dups: SystemView["duplicates"], inner: number): string[] {
32 return dups.length === 0
33 ? ["No test file is running in more than one check."]
34 : wrap(
35 `${dups.length} test file${dups.length === 1 ? " is" : "s are"} running in more than one check: ${dups
36 .slice(0, 3)
37 .map((d) => `${d.file} (${d.checks.join(", ")})`)
38 .join("; ")}`,
39 inner,
40 );
41 }
42
43 export function systemLines(v: SystemView): string[] {
44 const inner = WIDTH - 6;
45 return [
46 `CPU ${pct(v.cpu.busyPct)} over ${v.cpu.cores.length} cores · load ${v.load ? v.load.map((l) => l.toFixed(2)).join(" ") : "—"}`,
47 `hottest ${v.hottestC === null ? "—" : `${v.hottestC.toFixed(0)}°C`} · memory ${v.memory ? `${bytes(v.memory.usedBytes)} of ${bytes(v.memory.totalBytes)}` : "—"} · ${v.processes} processes`,
48 ...v.disks.map((d) => `${pad(d.mount, 20)} ${Math.round((d.usedBytes / Math.max(1, d.totalBytes)) * 100)}% of ${bytes(d.totalBytes)}`),
49 "",
50 ...v.groups.slice(0, GROUPS).map((g) => `${pad(g.label, 28)} ${pad(String(g.procs), 4, "right")} procs`),
51 "",
52 ...duplicateLines(v.duplicates, inner),
53 ];
54 }
55
56 export function systemScreen(ctx: Ctx, lines: readonly string[]): Screen {
57 return {
58 title: "System",
59 keys: [binding(["enter", "esc", "q"], "close")],
60 render(area, s) {
61 const buttons = dialog(s, area, ctx.theme.c, {
62 title: "System",
63 body: lines,
64 actions: [{ label: "Close" }],
65 selected: 0,
66 width: WIDTH,
67 });
68 buttons.forEach((rect) => ctx.hit(rect, () => ctx.pop()));
69 },
70 key(e) {
71 if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop();
72 return true;
73 },
74 };
75 }
76
1777 const mib = (n: number) => `${(n / 1_048_576).toFixed(1)}`;
1878
1979 /** The header, then an hour a row (UTC); empty for anyone the gate refuses. */
@@ -31,7 +91,7 @@ export function memoryLines(session: Session, now = Date.now()): string[] {
3191 ];
3292 }
3393
34 export function systemScreen(session: Session, ctx: Ctx): Screen {
94 export function memoryScreen(session: Session, ctx: Ctx): Screen {
3595 const scroll = new Scroll();
3696 return {
3797 title: "System memory",
src/web/layout.ts +1
@@ -332,6 +332,7 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html {
332332 ...(ctx.auth.user?.isAdmin
333333 ? [
334334 { t: "Flow", h: "/flow", c: "Go to" },
335 { t: "System", h: "/system", c: "Go to" },
335336 { t: "Forges you sent", h: "/forges", c: "Go to" },
336337 { t: "Family of forges", h: "/family", c: "Go to" },
337338 ]
src/web/pages/contribute.ts added +36
@@ -0,0 +1,36 @@
1 /**
2 * /contribute, open to anyone: what this forge is, where its bottleneck is
3 * right now, the open issues that welcome outside work, and how to propose
4 * (#1789). The same words as the MCP `contribute` tool and
5 * `GET /api/v1/contribute` (`system/telemetry.ts`).
6 */
7 import type { Ctx } from "../../http/context.ts";
8 import { welcome } from "../../system/telemetry.ts";
9 import { html } from "../html.ts";
10 import { page } from "../layout.ts";
11 import { card } from "../ui.ts";
12
13 // GET /contribute
14 export function contributePage(ctx: Ctx): Response {
15 const w = welcome(ctx.app);
16 const body = html`<div class="page-head">
17 <h1>Help build this forge</h1>
18 <p class="muted">${w.forge}</p>
19 </div>
20 <p id="bottleneck"><strong>Where it hurts now:</strong> ${w.bottleneck.says}</p>
21 ${card(
22 w.issues.length === 0
23 ? html`<div class="card-body"><p class="muted">No open issue carries the “${w.welcomeLabel}” label right now.</p></div>`
24 : html`<ul class="list">${w.issues.map(
25 (i) => html`<li class="row"><a href="${i.path}">${i.repo}#${String(i.number)}</a> ${i.title}</li>`,
26 )}</ul>`,
27 { head: html`<h2>Issues that welcome outside work</h2>`, pad: false },
28 )}
29 ${card(
30 html`<div class="card-body"><ol id="propose">${w.propose.map((s) => html`<li>${s}</li>`)}</ol>
31 <p class="muted small">${w.telemetry}</p>
32 <p class="muted small">For agents: <span class="mono">GET /api/v1/contribute</span>, or the MCP tool <span class="mono">contribute</span>.</p></div>`,
33 { head: html`<h2>How to propose</h2>` },
34 )}`;
35 return page({ ctx, title: "Contribute", body });
36 }
src/web/pages/flow.ts +18 −4
@@ -19,6 +19,8 @@
1919 * command and the MCP `flow` tool read too.
2020 */
2121
22 import { noteRead, telemetryReader } from "../../actions/telemetry.ts";
23 import { queuesFor, systemFor } from "../../system/telemetry.ts";
2224 import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts";
2325 import {
2426 bottleneck,
@@ -41,6 +43,7 @@ import {
4143 } from "../../flow/throughput.ts";
4244 import { readQueues, type QueueRow } from "../../flow/queue.ts";
4345 import type { Ctx } from "../../http/context.ts";
46 import { live } from "../../system/live.ts";

live is imported but never used here; the page reads duplicates through systemFor. Likewise the value readQueues on line 44: only type QueueRow is still used. Filed as #2171.

4447 import { notFoundPage, signInFirst } from "../chrome.ts";
4548 import { html, type Html } from "../html.ts";
4649 import { page } from "../layout.ts";
@@ -99,14 +102,17 @@ function outCell(t: StageThroughput): Html {
99102 // GET /flow
100103 export function flowPage(ctx: Ctx): Response {
101104 if (!ctx.auth.user) return signInFirst(ctx);
102 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
105 const reader = telemetryReader(ctx.app, ctx.auth.user);
106 if (!reader) return notFoundPage(ctx);
107 noteRead(ctx.app, ctx.auth.user, reader, "flow");
103108 const now = Date.now();
104109 const stages = readPipeline(ctx.app.db, new Date(now));
105110 const worst = bottleneck(stages);
outdated

Low: /flow used to be pure SQL. It now walks all of /proc (stat per process, cmdline per bun process) whenever the 1 s cache is cold. Consider whether duplicates belong on the common /flow request.

outdated

This page is still gated on isAdmin alone, so an admin agent without telemetry:read reads the full view here, including private repositories' queue and conflicted lines and the unfiltered duplicates. The read is not logged. The MCP flow tool now refuses that same agent. Use telemetryReader() and show the agent view (with noteRead) when reader.kind === "agent".

106111 const flow = readThroughput(ctx.app.db, stages.map((s) => s.key), new Date(now));
107112 const out = new Map(flow.stages.map((t) => [t.key, t]));
108113 const landed = out.get("approved");
109 const conflicted = conflictedSays(conflictedPulls(ctx.app.db), now);
114 const dups = systemFor(ctx.app, reader).duplicates;
115 const conflicted = conflictedSays(conflictedPulls(ctx.app.db, reader.kind === "agent"), now);
110116 const listed = asked(ctx.url);
111117 const shown = listed === null ? undefined : out.get(listed.key);
112118 const body = html`<div class="page-head">
@@ -116,7 +122,8 @@ export function flowPage(ctx: Ctx): Response {
116122 long its oldest has waited, and what limits it. The limits are set on
117123 <a href="/settings/capacity">Capacity</a> and in each repository's
118124 autonomy settings. How the forge stands against its threats is on
119 <a href="/resilience">Resilience</a>.
125 <a href="/resilience">Resilience</a>. Where an outside agent can
126 help is on <a href="/contribute">Contribute</a>.
120127 </p>
121128 </div>
122129 <p id="bottleneck" data-stage="${worst?.key ?? ""}">
@@ -132,6 +139,13 @@ export function flowPage(ctx: Ctx): Response {
132139 <span data-median>${flow.medianAskToLiveMs === null ? "—" : hoursSpan(flow.medianAskToLiveMs)}</span>
133140 (24 h)
134141 </p>
142 <p id="duplicate-work" data-count="${String(dups.length)}" class="${dups.length === 0 ? "muted" : ""}">
143 ${dups.length === 0
144 ? html`No test file is running in more than one check.`
145 : html`<strong>${String(dups.length)} test file${dups.length === 1 ? " is" : "s are"} running in more than one check:</strong>
146 ${dups.slice(0, 5).map((d) => html`<span class="mono small">${d.file} (${d.checks.join(", ")})</span> `)}`}
147 <a href="/system">System</a>
148 </p>
135149 ${card(
136150 html`<div class="table-scroll">
137151 <table class="table" aria-label="Pipeline stages">
@@ -169,7 +183,7 @@ export function flowPage(ctx: Ctx): Response {
169183 </div>`,
170184 { pad: false },
171185 )}
172 ${queueCard(readQueues(ctx.app.db, new Date(now)))}
186 ${queueCard(queuesFor(ctx.app, reader, new Date(now)))}
173187 ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`;
174188 return page({ ctx, title: "Flow", body, styleCss: css });
175189 }
src/web/pages/origins.ts +7
@@ -81,6 +81,7 @@ export type AccountSection =
8181 | "backups"
8282 | "secrets"
8383 | "capacity"
84 | "telemetry"
8485 | "access"
8586 | "resilience"
8687 | "limits"
@@ -136,6 +137,12 @@ export function accountSections(
136137 icon: "activity" as const,
137138 current: current === "capacity",
138139 },
140 {
141 label: "Telemetry",
142 href: "/settings/telemetry",
143 icon: "activity" as const,
144 current: current === "telemetry",
145 },
139146 {
140147 label: "Access",
141148 href: "/settings/access",
src/web/pages/system.ts +102 −29
@@ -1,49 +1,122 @@
11 /**
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.
2 * /system, for the owner: the machine as it is now — cores, temperatures,
3 * load, memory, disks, network — and every process grouped by what the
4 * forge launched it for, each with CPU, memory and age. The same test file
5 * in more than one check at once is named at the top. It refreshes itself
6 * every 2 s (a few lines of static script; `?pause` turns that off), so a
7 * viewer *is* the sampler (`system/live.ts`). Numbers are `system/view.ts`'s.
8 * Below it, for a forge admin who is a person, the forge's resident memory
9 * (RSS) beside its JS heap, hourly for 24 h (#2151): RSS climbing while the
10 * heap stays flat is native memory growth. Drawn on the server as SVG.
611 */
712 import { drawChart } from "../../boards/chart.ts";
813 import { canSeeMemory, memoryHours } from "../../boards/memory.ts";
14 import { telemetryReader } from "../../actions/telemetry.ts";
915 import type { Ctx } from "../../http/context.ts";
10 import { html, raw } from "../html.ts";
16 import { bytes, span } from "../../system/format.ts";
17 import { live } from "../../system/live.ts";
18 import { notFoundPage, signInFirst } from "../chrome.ts";
19 import { html, raw, type Html } from "../html.ts";
1120 import { page } from "../layout.ts";
12 import { btn, card, empty } from "../ui.ts";
21 import { card, empty } from "../ui.ts";
22
23 const pct = (n: number | null): string => (n === null ? "—" : `${n.toFixed(1)}%`);
24 const coresOf = (n: number | null): string => (n === null ? "—" : n.toFixed(2));
1325
1426 const mib = (n: number) => `${(n / 1_048_576).toFixed(1)} MiB`;
1527 const hhmm = (at: number) => new Date(at).toISOString().slice(0, 16).replace("T", " ");
1628
17 const forbiddenPage = (ctx: Ctx) =>
18 page(
19 {
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);
29 /** The forge's RSS beside its JS heap, an hour a row; empty for anyone `canSeeMemory` refuses. */
30 function memorySection(ctx: Ctx): Html {
31 if (!canSeeMemory(ctx.auth.user)) return html``;
3032 const hours = memoryHours(ctx.app.db, Date.now());
3133 const series = (title: string, pick: (h: (typeof hours)[number]) => number) =>
3234 card(
3335 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>`,
3436 { head: html`<h2>${title}</h2>` },
3537 );
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">
38 return hours.length === 0
39 ? empty({ icon: "activity", title: "No samples yet", note: "The forge samples its memory every minute; the first hour appears shortly." })
40 : html`<div class="sys-pair">${series("RSS (MiB, hourly mean)", (h) => h.rssMean)}${series("JS heap (MiB, hourly mean)", (h) => h.heapMean)}</div>
41 <table class="table" aria-label="Forge memory by hour">
4142 <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>
4243 <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>
4344 </table>`;
44 return page({
45 ctx,
46 title: "System",
47 body: html`<div class="page-head"><h1>System memory</h1></div>${body}`,
48 });
45 }
46
47 const RELOAD = `if (!/[?&]pause\\b/.test(location.search)) setTimeout(function () { location.reload(); }, 2000);`;
48
49 // GET /system
50 export function systemPage(ctx: Ctx): Response {
51 if (!ctx.auth.user) return signInFirst(ctx);
52 const reader = telemetryReader(ctx.app, ctx.auth.user);
53 if (reader?.kind !== "owner") return notFoundPage(ctx);
54 const v = live().read();
55 const dup: Html =
56 v.duplicates.length === 0
57 ? html`<p id="duplicates" data-count="0" class="muted">No test file is running in more than one check.</p>`
58 : html`<p id="duplicates" data-count="${String(v.duplicates.length)}">
59 <strong>${String(v.duplicates.length)} test file${v.duplicates.length === 1 ? " is" : "s are"}
60 running in more than one check at once:</strong>
61 </p>
62 <ul class="list small mono">
63 ${v.duplicates.map((d) => html`<li>${d.file} · ${String(d.checks.length)} checks: ${d.checks.join(", ")}</li>`)}
64 </ul>`;
65 const cores = v.cpu.cores.map(
66 (c, i) => html`<span class="mono small" title="cpu${String(i)}">${c === null ? "—" : String(Math.round(c))}</span> `,
67 );
68 const body = html`<div class="page-head">
69 <h1>System</h1>
70 <p class="muted">
71 The machine now, sampled each time this page refreshes (every 2 s);
72 <a href="/system?pause">pause</a>. Its history is on
73 <a href="/boards">Boards</a> (the machine source); the pipeline is on
74 <a href="/flow">Flow</a>.
75 </p>
76 </div>
77 ${dup}
78 ${card(
79 html`<div class="card-body">
80 <p id="summary">
81 CPU <strong>${pct(v.cpu.busyPct)}</strong> over ${String(v.cpu.cores.length)} cores ·
82 load ${v.load ? v.load.map((l) => l.toFixed(2)).join(" ") : "—"} ·
83 hottest ${v.hottestC === null ? "—" : `${v.hottestC.toFixed(0)}°C`} ·
84 memory ${v.memory ? `${bytes(v.memory.usedBytes)} of ${bytes(v.memory.totalBytes)}` : "—"} ·
85 ${String(v.processes)} processes
86 </p>
87 <p class="small" id="cores" aria-label="Per-core CPU, percent">${cores}</p>
88 <p class="small mono" id="temps">${v.temps.map((t) => `${t.chip} ${t.label} ${t.celsius.toFixed(0)}°C`).join(" · ")}</p>
89 </div>`,
90 { head: html`<h2>Machine</h2>` },
91 )}
92 ${card(
93 html`<div class="table-scroll"><table class="table" aria-label="Disks and network">
94 <thead><tr><th scope="col">Disk / interface</th><th scope="col">Used</th><th scope="col">Total / rate</th></tr></thead>
95 <tbody>
96 ${v.disks.map(
97 (d) => html`<tr><td class="mono">${d.mount} <span class="muted small">${d.fs}</span></td>
98 <td>${String(Math.round((d.usedBytes / d.totalBytes) * 100))}%</td><td>${bytes(d.totalBytes)}</td></tr>`,
99 )}
100 ${v.net.map(
101 (n) => html`<tr><td class="mono">${n.name}</td><td>↓ ${n.rxBytesPerSecond === null ? "—" : `${bytes(n.rxBytesPerSecond)}/s`}</td>
102 <td>↑ ${n.txBytesPerSecond === null ? "—" : `${bytes(n.txBytesPerSecond)}/s`}</td></tr>`,
103 )}
104 </tbody></table></div>`,
105 { head: html`<h2>Disks and network</h2>`, pad: false },
106 )}
107 ${card(
108 html`<div class="table-scroll"><table class="table" aria-label="Processes by what launched them">
109 <thead><tr><th scope="col">Launched for</th><th scope="col">Processes</th><th scope="col">CPU (cores)</th>
110 <th scope="col">Memory</th><th scope="col">Oldest</th><th scope="col">Busiest</th></tr></thead>
111 <tbody>
112 ${v.groups.map(
113 (g) => html`<tr data-kind="${g.kind}"><td class="mono">${g.label}</td><td>${String(g.procs)}</td>
114 <td>${coresOf(g.cpuCores)}</td><td>${bytes(g.rssBytes)}</td><td>${span(g.oldestSeconds)}</td>
115 <td class="small mono">${g.top.slice(0, 3).map((p) => `${p.comm} ${coresOf(p.cpuCores)}`).join(" · ")}</td></tr>`,
116 )}
117 </tbody></table></div>`,
118 { head: html`<h2>Processes</h2>`, pad: false },
119 )}
120 ${memorySection(ctx)}`;
121 return page({ ctx, title: "System", body, wide: true, scriptJs: RELOAD });
49122 }
src/web/pages/telemetry.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * Settings → Telemetry, for the owner: which named agents hold
3 * `telemetry:read`, one switch each, and the reads they have made (#1789).
4 */
5 import { setTelemetryGrant } from "../../actions/telemetry.ts";
6 import { grantable, recentReads } from "../../core/telemetry.ts";
7 import type { Ctx } from "../../http/context.ts";
8 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
9 import { field, readForm } from "../form.ts";
10 import { html } from "../html.ts";
11 import { flashTo, page } from "../layout.ts";
12 import { btn, card, icon, row, time } from "../ui.ts";
13 import { accountSections } from "./origins.ts";
14
15 function owner(ctx: Ctx): Response | undefined {
16 if (!ctx.auth.user) return signInFirst(ctx);
17 return ctx.auth.user.isAdmin && ctx.auth.user.kind !== "agent" ? undefined : notFoundPage(ctx);
18 }
19
20 // GET /settings/telemetry
21 export function telemetryPage(ctx: Ctx): Response {
22 const denied = owner(ctx);
23 if (denied) return denied;
24 const agents = grantable(ctx.app.db);
25 const reads = recentReads(ctx.app.db);
26 const body = html`${card(
27 html`<div class="card-body"><p class="muted small">
28 A granted agent reads <a href="/flow">Flow</a>, <a href="/system">System</a> and the
29 boards' sources through the API and MCP — counts and rates, with
30 private repositories and sealed values cut out. Revoke at any time;
31 every read is listed below.
32 </p></div>
33 <ul class="list">
34 ${agents.length === 0
35 ? html`<li class="muted small">No agent accounts yet.</li>`
36 : agents.map((a) =>
37 row({
38 lead: icon("bot"),
39 title: html`${a.username} <span class="muted small mono">telemetry:read</span>`,
40 meta: html`${a.granted ? "Granted" : "Not granted"}`,
41 trail: html`<form class="row-actions" method="post" action="/settings/telemetry">
42 <input type="hidden" name="agent" value="${a.username}" />
43 <input type="hidden" name="on" value="${a.granted ? "0" : "1"}" />
44 ${btn(a.granted ? "Revoke" : "Grant", {
45 size: "sm",
46 variant: a.granted ? "outline" : "primary",
47 label: `${a.granted ? "Revoke" : "Grant"} telemetry:read for ${a.username}`,
48 })}
49 </form>`,
50 }),
51 )}
52 </ul>`,
53 { head: html`${icon("activity")}<h2>Telemetry grants</h2>`, pad: false },
54 )}
55 ${card(
56 reads.length === 0
57 ? html`<div class="card-body"><p class="muted small">No agent has read anything yet.</p></div>`
58 : html`<ul class="list">${reads.map((r) =>
59 row({ lead: icon("history"), title: html`${r.username} <span class="mono">${r.what}</span>`, meta: time(r.at) }),
60 )}</ul>`,
61 { head: html`${icon("history")}<h2>Reads</h2>`, pad: false },
62 )}`;
63 return page({
64 ctx,
65 title: "Telemetry",
66 body: html`<div class="page-head"><h1>Telemetry</h1></div>
67 ${settingsLayout(accountSections("telemetry", { admin: true }), body, "Account settings")}`,
68 });
69 }
70
71 // POST /settings/telemetry
72 export async function telemetrySet(ctx: Ctx): Promise<Response> {
73 const denied = owner(ctx);
74 if (denied) return denied;
75 const form = await readForm(ctx, "admin");
76 if (form instanceof Response) return form;
77 const changed = setTelemetryGrant(ctx.app, ctx.auth.user, field(form, "agent"), field(form, "on") === "1");
78 return flashTo(
79 ctx,
80 "/settings/telemetry",
81 changed.ok
82 ? { message: `${changed.value.username} ${changed.value.on ? "may now read" : "no longer reads"} telemetry.` }
83 : { tone: "danger", message: "Only an agent account can be granted telemetry:read." },
84 );
85 }
test/mcp.test.ts +2
@@ -209,6 +209,8 @@ describe("the forge over MCP", () => {
209209 "access",
210210 "capacity",
211211 "flow",
212 "system",
213 "contribute",
212214 "limits",
213215 "quotas",
214216 "boards",
test/system-memory.test.ts +7 −6
@@ -83,9 +83,10 @@ describe("GET /system", () => {
8383 expect(body).toContain("40.0 MiB");
8484 });
8585
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);
86 test("a non-admin gets 404 and an anonymous visitor is sent to sign in", async () => {
87 expect((await get("bob")).status).toBe(404);
88 const anon = await fetch(servers.url + "/system", { redirect: "manual" });
89 expect(anon.status).toBe(303);
8990 });
9091 });
9192
@@ -98,8 +99,8 @@ describe("reaching /system", () => {
9899 });
99100
100101 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>");
102 const res = await get("bob");
103 expect(res.status).toBe(404);
104 expect(await res.text()).toContain("<h1>");
104105 });
105106 });
test/system-telemetry.test.ts added +429
@@ -0,0 +1,429 @@
1 /**
2 * Live machine telemetry and its open read hooks (#1789): /proc and /sys
3 * read into one view; the same test file in two checks counted as duplicate
4 * work; /system and /flow read by the owner and by an agent holding
5 * `telemetry:read` — with private repositories cut out — and refused to an
6 * agent without it; the grant is the owner's alone and every agent read is
7 * logged; the funnel is open to all.
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { createAgent } from "../src/actions/crew.ts";
16 import { createRepo } from "../src/actions/repos.ts";
17 import { setTelemetryGrant } from "../src/actions/telemetry.ts";
18 import { recentReads } from "../src/core/telemetry.ts";
19 import { createToken } from "../src/core/tokens.ts";
20 import { createUser, getUserByName, type User } from "../src/core/users.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { parseCores, parseNet, testFiles } from "../src/system/proc.ts";
23 import { createLive, live, useLive } from "../src/system/live.ts";
24 import type { Ctx, Screen } from "../src/tui/app/app.ts";
25 import { forgeCommands, Session } from "../src/tui/forge.ts";
26 import { systemLines } from "../src/tui/screens/system.ts";
27 import { flowLines } from "../src/tui/screens/flow.ts";
28 import { hasGrant, logRead } from "../src/core/telemetry.ts";
29
30 const stat = (pid: number, comm: string, ppid: number, ticks: number): string => {
31 const f: (number | string)[] = new Array<number>(40).fill(0);
32 f[0] = "S";
33 f[1] = ppid;
34 f[11] = ticks;
35 f[19] = 100;
36 f[21] = 256;
37 return `${pid} (${comm}) ${f.join(" ")}`;
38 };
39
40 function fakeHost(root: string, ticks: number): void {
41 const proc = join(root, "proc");
42 mkdirSync(join(proc, "net"), { recursive: true });
43 mkdirSync(join(root, "sys/class/hwmon/hwmon0"), { recursive: true });
44 writeFileSync(join(root, "sys/class/hwmon/hwmon0/name"), "coretemp\n");
45 writeFileSync(join(root, "sys/class/hwmon/hwmon0/temp1_input"), "76000\n");
46 writeFileSync(join(root, "sys/class/hwmon/hwmon0/temp1_label"), "Core 0\n");
47 writeFileSync(
48 join(proc, "stat"),
49 `cpu ${ticks} 0 0 100 0 0 0 0\ncpu0 ${ticks} 0 0 100 0 0 0 0\ncpu1 0 0 0 ${ticks} 0 0 0 0\n`,
50 );
51 writeFileSync(join(proc, "meminfo"), "MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB\n");
52 writeFileSync(join(proc, "loadavg"), "64.00 60.00 50.00 3/3071 999\n");
53 writeFileSync(join(proc, "uptime"), "5000.00 100.00\n");
54 writeFileSync(join(proc, "mounts"), "");
55 writeFileSync(
56 join(proc, "net/dev"),
57 "Inter-| Receive\n face |bytes\n lo: 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0\n eth0: ${r} 0 0 0 0 0 0 0 ${t} 0 0 0 0 0 0 0\n"
58 .replace("${r}", String(ticks * 10))
59 .replace("${t}", String(ticks * 5)),
60 );
61 const proc1 = (pid: number, comm: string, ppid: number, args: string) => {
62 mkdirSync(join(proc, String(pid)), { recursive: true });
63 writeFileSync(join(proc, String(pid), "stat"), stat(pid, comm, ppid, ticks));
64 writeFileSync(join(proc, String(pid), "cmdline"), args);
65 };
66 proc1(1, "forge", 0, "bun\0src/main.ts\0");
67 proc1(200, "bwrap", 1, "bwrap\0");
68 proc1(201, "bun", 200, "bun\0test\0test/shared.test.ts\0--secret-flag=hunter2\0");
69 proc1(300, "bwrap", 1, "bwrap\0");
70 proc1(301, "bun", 300, "bun\0test\0./test/shared.test.ts\0");
71 proc1(400, "bwrap", 1, "bwrap\0");
72 proc1(401, "bun", 400, "bun\0test\0test/alone.test.ts\0");
73 proc1(500, "bun", 1, "bun\0test\0test/private-thing.test.ts\0");
74 }
75
76 describe("the machine, read from /proc and /sys", () => {
77 test("per-core ticks, interfaces and test-file arguments — nothing else of a command line", () => {
78 expect(parseCores("cpu 9 0 0 1\ncpu0 3 0 0 1 0 0 0 0\ncpu1 1 0 0 3 0 0 0 0\n")).toEqual([
79 { busy: 3, total: 4 },
80 { busy: 1, total: 4 },
81 ]);
82 expect(parseNet("h\nh\n lo: 5 0 0 0 0 0 0 0 5\neth0: 7 0 0 0 0 0 0 0 9\n")).toEqual([{ name: "eth0", rx: 7, tx: 9 }]);
83 expect(testFiles("bun\0test\0./test/a.test.ts\0--token=abc\0../etc/x\0$(id).test.ts\0")).toEqual(["test/a.test.ts"]);
84 });
85
86 test("two checks running one test file are duplicate work, grouped by what launched them", () => {
87 const root = mkdtempSync(join(tmpdir(), "forge-sys-"));
88 try {
89 fakeHost(root, 100);
90 const roots = [
91 { label: "check:RUN1", root: 200 },
92 { label: "check:RUN2", root: 300 },
93 { label: "check:RUN3", root: 400 },
94 ];
95 const l = createLive({ paths: { proc: join(root, "proc"), sys: join(root, "sys") }, roots: () => roots, self: 1 });
96 const v = l.read(1_000_000);
97 expect(v.duplicates).toEqual([{ file: "test/shared.test.ts", checks: ["check:RUN1", "check:RUN2"] }]);
98 expect(v.groups.find((g) => g.label === "check:RUN1")?.procs).toBe(2);
99 expect(v.groups.find((g) => g.label === "forge")?.procs).toBe(2);
100 expect(v.cpu.cores.length).toBe(2);
101 expect(v.hottestC).toBe(76);
102 expect(v.load?.[0]).toBe(64);
103 expect(v.memory).toEqual({ totalBytes: 1024_000, usedBytes: 614_400, swapUsedBytes: 0 });
104 expect(JSON.stringify(v)).not.toContain("hunter2");
105 // A later reading gives rates over the two latest.
106 fakeHost(root, 300);
107 const w = l.read(1_002_000);
108 expect(w.seconds).toBeCloseTo(2, 5);
109 expect(v.seconds).toBe(0);
110 expect(v.net[0]?.rxBytesPerSecond).toBeNull();
111 expect(w.net[0]?.rxBytesPerSecond).toBeCloseTo(2000 / 2, 3);
112 } finally {
113 rmSync(root, { recursive: true, force: true });
114 }
115 });
116 });
117
118 describe("telemetry:read: the owner grants it, an agent reads with it, nobody else does", () => {
119 let dataDir: string;
120 let host: string;
121 let app: App;
122 let servers: Servers;
123 let alice: User;
124 let bob: User;
125 let muse: User;
126 let grok: User;
127 let tokens: Record<string, string>;
128
129 const get = (token: string | undefined, path: string) =>
130 fetch(`${servers.url}${path}`, token ? { headers: { authorization: `Bearer ${token}` } } : {});
131 const login = async (username: string): Promise<string> => {
132 const res = await fetch(`${servers.url}/signin`, {
133 method: "POST",
134 redirect: "manual",
135 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
136 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
137 });
138 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
139 };
140 const tool = async (token: string, name: string) => {
141 const res = await fetch(`${servers.url}/mcp`, {
142 method: "POST",
143 headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
144 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: {} } }),
145 });
146 const b = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } };
147 return { error: b.result.isError === true, text: b.result.content[0]!.text };
148 };
149
150 beforeAll(async () => {
151 dataDir = await mkdtemp(join(tmpdir(), "forge-telemetry-"));
152 host = mkdtempSync(join(tmpdir(), "forge-host-"));
153 app = createApp({ dataDir, port: 0 });
154 servers = await serve(app);
155 const mk = async (username: string, adminIfFirst = false) => {
156 const u = await createUser(app.db, {
157 trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst,
158 });
159 if (!u.ok) throw new Error(u.error);
160 return u.value;
161 };
162 alice = await mk("alice", true);
163 bob = await mk("bob");
164 const agent = async (username: string) => {
165 const made = await createAgent(app, alice, { username });
166 if (!made.ok) throw new Error(String(made.error));
167 return made.value.user;
168 };
169 muse = await agent("muse");
170 grok = await agent("grokbot");
171 const priv = await createRepo(app, alice, { name: "vault-of-secrets", visibility: "private" });
172 const pub = await createRepo(app, alice, { name: "open-garden", visibility: "public" });
173 if (!priv.ok || !pub.ok) throw new Error("repos");
174 for (const [id, repo] of [["RUN1", priv.value], ["RUN2", pub.value], ["RUN3", pub.value]] as const)
175 app.db
176 .query("INSERT INTO run (id, repo_id, kind, state) VALUES (?1, ?2, 'deploy', 'running')")
177 .run(id, repo.id);
178 // One pull in conflict in each repository: only the public one may reach an agent.
179 for (const [n, repo] of [[1, priv.value], [2, pub.value]] as const) {
180 const id = `PULL${n}`;
181 app.db
182 .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?1, ?2, ?3, 'pull', 'p', ?4)")
183 .run(id, repo.id, n, alice.id);
184 app.db
185 .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', 'f', 'a', 'b')")
186 .run(id);
187 app.db
188 .query("INSERT INTO run (id, repo_id, kind, issue_id, reason, state) VALUES (?1, ?2, 'agent', ?3, 'conflict', 'queued')")
189 .run(`CONF${n}`, repo.id, id);
190 }
191 fakeHost(host, 100);
192 useLive(
193 createLive({
194 paths: { proc: join(host, "proc"), sys: join(host, "sys") },
195 roots: () => [
196 { label: "check:RUN1", root: 200 },
197 { label: "check:RUN2", root: 300 },
198 { label: "check:RUN3", root: 400 },
199 ],
200 self: 1,
201 }),
202 );
203 tokens = {
204 alice: createToken(app.db, alice.id, "t", "admin").token,
205 bob: createToken(app.db, bob.id, "t", "rw").token,
206 muse: createToken(app.db, muse.id, "t", "ro").token,
207 grok: createToken(app.db, grok.id, "t", "ro").token,
208 };
209 expect(setTelemetryGrant(app, alice, "muse", true).ok).toBe(true);
210 });
211
212 afterAll(async () => {
213 useLive(createLive());
214 servers.stop();
215 app.close();
216 await rm(dataDir, { recursive: true, force: true });
217 rmSync(host, { recursive: true, force: true });
218 });
219
220 test("an agent without the grant, a member and nobody are refused", async () => {
221 for (const path of ["/api/v1/telemetry/system", "/api/v1/telemetry/flow", "/api/v1/telemetry/sources"]) {
222 expect((await get(tokens["grok"], path)).status).toBe(403);
223 expect((await get(tokens["bob"], path)).status).toBe(403);
224 expect((await get(undefined, path)).status).toBe(401);
225 }
226 expect((await tool(tokens["grok"]!, "system")).error).toBe(true);
227 expect((await tool(tokens["grok"]!, "flow")).error).toBe(true);
228 const bobCookie = await login("bob");
229 expect((await fetch(`${servers.url}/system`, { headers: { cookie: bobCookie } })).status).toBe(404);
230 });
231
232 test("a granted agent reads /system and /flow, with private repositories cut out", async () => {
233 const sys = await get(tokens["muse"], "/api/v1/telemetry/system");
234 expect(sys.status).toBe(200);
235 const text = await sys.text();
236 expect(text).not.toContain("RUN1");
237 expect(text).not.toContain("hunter2");
238 // A test outside any cell lands in `forge`; its file name is not an agent's to read.
239 expect(text).not.toContain("private-thing");
240 const v = JSON.parse(text) as { groups: { label: string; kind: string; top: unknown[]; procs: number }[]; duplicates: { file: string; checks: string[] }[] };
241 const hidden = v.groups.find((g) => g.label === "check:private-1");
242 expect(hidden?.procs).toBe(2);
243 expect(hidden?.top).toEqual([]);
244 expect(v.groups.some((g) => g.label === "check:RUN2")).toBe(true);
245 expect(v.duplicates).toEqual([{ file: "(private)", checks: ["check:RUN2", "check:private-1"] }]);
246
247 const flow = await get(tokens["muse"], "/api/v1/telemetry/flow");
248 expect(flow.status).toBe(200);
249 const f = (await flow.json()) as { says: string; stages: unknown[]; duplicateWork: unknown[] };
250 expect(f.stages.length).toBeGreaterThan(0);
251 expect(f.duplicateWork.length).toBe(1);
252 // A conflicted private pull is not named to an agent, in the list or in the sentence.
253 const flowText = JSON.stringify(f);
254 expect(flowText).toContain("alice/open-garden#2");
255 expect(flowText).not.toContain("vault-of-secrets");
256 const viaMcp = await tool(tokens["muse"]!, "flow");
257 expect(viaMcp.text).toContain("alice/open-garden#2");
258 expect(viaMcp.text).not.toContain("vault-of-secrets");
259 const history = await get(tokens["muse"], "/api/v1/telemetry/history");
260 expect(history.status).toBe(200);
261 expect(Array.isArray(((await history.json()) as { points: unknown[] }).points)).toBe(true);
262 expect((await get(tokens["grok"], "/api/v1/telemetry/history")).status).toBe(403);
263 const sources = (await (await get(tokens["muse"], "/api/v1/telemetry/sources")).json()) as { sources: { name: string }[] };
264 expect(sources.sources.map((s) => s.name)).toContain("machine");
265 expect((await tool(tokens["muse"]!, "system")).error).toBe(false);
266 expect((await tool(tokens["muse"]!, "flow")).error).toBe(false);
267 });
268
269 test("a private repository's merge-queue line reaches the owner, never an agent", async () => {
270 const priv = app.db
271 .query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'vault-of-secrets'")
272 .get()!;
273 app.db
274 .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES ('PULLQ', ?1, 9, 'pull', 'q', ?2)")
275 .run(priv.id, alice.id);
276 app.db
277 .query(
278 "INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) SELECT 'PULLQ', default_branch, 'q', 'a', 'b', strftime('%Y-%m-%dT%H:%M:%fZ','now') FROM repo WHERE id = ?1",
279 )
280 .run(priv.id);
281 type Flow = { queue: { repo: string }[] };
282 const names = (f: Flow) => f.queue.map((q) => q.repo);
283 const mcp = async (t: string) => JSON.parse((await tool(t, "flow")).text) as Flow;
284 const api = async (t: string) => (await (await get(t, "/api/v1/telemetry/flow")).json()) as Flow;
285
286 expect(names(await api(tokens["alice"]!))).toContain("alice/vault-of-secrets");
287 expect(names(await mcp(tokens["alice"]!))).toContain("alice/vault-of-secrets");
288 expect(names(await api(tokens["muse"]!))).not.toContain("alice/vault-of-secrets");
289 expect(names(await mcp(tokens["muse"]!))).not.toContain("alice/vault-of-secrets");
290 });
291
292 test("the owner sees everything; every agent read is logged; revoking closes the door", async () => {
293 expect(await (await get(tokens["alice"], "/api/v1/telemetry/flow")).text()).toContain("vault-of-secrets");
294 expect(await (await get(tokens["alice"], "/api/v1/telemetry/system")).text()).toContain("private-thing");
295 const own = await (await get(tokens["alice"], "/api/v1/telemetry/system")).text();
296 expect(own).toContain("check:RUN1");
297 const page = await fetch(`${servers.url}/system`, { headers: { cookie: await login("alice") } });
298 expect(page.status).toBe(200);
299 const html = await page.text();
300 expect(html).toContain('id="duplicates" data-count="1"');
301 expect(html).toContain("test/shared.test.ts");
302 const flowPage = await fetch(`${servers.url}/flow`, { headers: { cookie: await login("alice") } });
303 expect(await flowPage.text()).toContain('id="duplicate-work" data-count="1"');
304
305 const reads = recentReads(app.db).filter((r) => r.username === "muse");
306 expect(reads.map((r) => r.what)).toContain("system");
307 expect(reads.map((r) => r.what)).toContain("flow");
308 expect(recentReads(app.db).some((r) => r.username === "alice")).toBe(false);
309
310 // The grant is the owner's alone — not an agent's own, not a member's.
311 expect(setTelemetryGrant(app, muse, "grokbot", true)).toEqual({ ok: false, error: "not_found" });
312 expect(setTelemetryGrant(app, bob, "grokbot", true)).toEqual({ ok: false, error: "not_found" });
313 expect(setTelemetryGrant(app, alice, "bob", true)).toEqual({ ok: false, error: "not_an_agent" });
314
315 const cookie = await login("alice");
316 const revoke = await fetch(`${servers.url}/settings/telemetry`, {
317 method: "POST",
318 redirect: "manual",
319 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
320 body: new URLSearchParams({ agent: "muse", on: "0" }),
321 });
322 expect(revoke.status).toBe(303);
323 expect((await get(tokens["muse"], "/api/v1/telemetry/system")).status).toBe(403);
324 // A cross-origin grant is refused.
325 const forged = await fetch(`${servers.url}/settings/telemetry`, {
326 method: "POST",
327 redirect: "manual",
328 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example" },
329 body: new URLSearchParams({ agent: "grokbot", on: "1" }),
330 });
331 expect(forged.status).not.toBe(303);
332 expect((await get(tokens["grok"], "/api/v1/telemetry/system")).status).toBe(403);
333 const settings = await fetch(`${servers.url}/settings/telemetry`, { headers: { cookie } });
334 expect(settings.status).toBe(200);
335 });
336
337 test("the funnel is open to anyone and names the bottleneck, the welcoming issues and the gate", async () => {
338 const res = await get(undefined, "/api/v1/contribute");
339 expect(res.status).toBe(200);
340 const w = (await res.json()) as { bottleneck: { says: string }; propose: string[]; issues: unknown[] };
341 expect(w.bottleneck.says.length).toBeGreaterThan(0);
342 expect(w.propose.join(" ")).toContain("bun test");
343 const html = await (await get(undefined, "/contribute")).text();
344 expect(html).toContain("How to propose");
345 expect((await tool(tokens["grok"]!, "contribute")).error).toBe(false);
346 });
347
348 test("a sealed security report labelled help wanted is never offered to a visitor", async () => {
349 const repo = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'open-garden'").get()!;
350 app.db
351 .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id, sealed) VALUES ('SEALED1', ?1, 77, 'issue', 'overflow in the zebra parser', ?2, 1)")
352 .run(repo.id, alice.id);
353 app.db.query("INSERT INTO label (id, repo_id, name) VALUES ('LBLHW', ?1, 'help wanted')").run(repo.id);
354 app.db.query("INSERT INTO issue_label (issue_id, label_id) VALUES ('SEALED1', 'LBLHW')").run();
355 app.db
356 .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES ('OPEN1', ?1, 78, 'issue', 'tidy the herbs', ?2)")
357 .run(repo.id, alice.id);
358 app.db.query("INSERT INTO issue_label (issue_id, label_id) VALUES ('OPEN1', 'LBLHW')").run();
359 const api = await (await get(undefined, "/api/v1/contribute")).text();
360 const page = await (await get(undefined, "/contribute")).text();
361 const mcp = (await tool(tokens["grok"]!, "contribute")).text;
362 for (const out of [api, page, mcp]) {
363 expect(out).toContain("tidy the herbs");
364 expect(out).not.toContain("zebra");
365 }
366 });
367
368 test("an admin agent reads flow only with the grant, in the page and the terminal, and sees the agent view", async () => {
369 const made = await createAgent(app, alice, { username: "zadminbot" });
370 if (!made.ok) throw new Error(String(made.error));
371 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.user.id);
372 const adminbot = getUserByName(app.db, "zadminbot")!;
373 expect(adminbot.isAdmin).toBe(true);
374 const token = createToken(app.db, adminbot.id, "t", "admin").token;
375 const flowCommand = () => {
376 const ctx = { push: () => {}, pop: () => {}, toast: () => {} } as unknown as Ctx;
377 const session = new Session(app, { sshUsername: "zadminbot", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: adminbot });
378 return forgeCommands(session)(ctx).find((c) => c.id === "flow");
379 };
380 expect((await get(token, "/flow")).status).toBe(404);
381 expect(flowCommand()).toBeUndefined();
382
383 expect(setTelemetryGrant(app, alice, "zadminbot", true).ok).toBe(true);
384 const page = await get(token, "/flow");
385 expect(page.status).toBe(200);
386 const text = await page.text();
387 // (the page's palette index lists every repo an admin can open; the telemetry body must not)
388 expect(text).not.toContain("/alice/vault-of-secrets/pulls");
389 expect(text).not.toContain("RUN1");
390 expect(flowCommand()).toBeDefined();
391 expect(recentReads(app.db).some((r) => r.username === "zadminbot" && r.what === "flow")).toBe(true);
392 // Revoked, the door closes again.
393 setTelemetryGrant(app, alice, "zadminbot", false);
394 expect((await get(token, "/flow")).status).toBe(404);
395 });
396
397 test("in the terminal, the owner is offered System and Telemetry; nobody else is", () => {
398 const run = (user: User, id: string) => {
399 const pushed: Screen[] = [];
400 const toasts: string[] = [];
401 const ctx = { push: (x: Screen) => pushed.push(x), pop: () => {}, toast: (t: string) => toasts.push(t) } as unknown as Ctx;
402 const session = new Session(app, { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user });
403 return { cmd: forgeCommands(session)(ctx).find((c) => c.id === id), pushed, toasts };
404 };
405 for (const u of [bob, muse]) {
406 expect(run(u, "system").cmd).toBeUndefined();
407 expect(run(u, "telemetry").cmd).toBeUndefined();
408 }
409 const sys = run(alice, "system");
410 sys.cmd!.run();
411 expect(sys.pushed.map((x) => x.title)).toEqual(["System"]);
412 expect(systemLines(JSON.parse(JSON.stringify(live().read()))).join("\n")).toContain("1 test file is running in more than one check");
413
414 expect(flowLines(app.db, new Date(), JSON.parse(JSON.stringify(live().read().duplicates))).join("\n")).toContain(
415 "1 test file is running in more than one check",
416 );
417 expect(flowLines(app.db, new Date(), []).join("\n")).toContain("No test file is running in more than one check.");
418
419 logRead(app.db, grok.id, "system");
420 const tel = run(alice, "telemetry");
421 tel.cmd!.run();
422 expect(tel.pushed.map((x) => x.title)).toEqual(["Telemetry: telemetry:read"]);
423 expect(hasGrant(app.db, grok.id)).toBe(false);
424 // Enter on the list's first row (grokbot, alphabetically before muse) flips its grant.
425 tel.pushed[0]!.key({ key: "enter" } as never);
426 expect(hasGrant(app.db, grok.id)).toBe(true);
427 expect(tel.toasts.join()).toContain("granted");
428 });
429 });
test/tui-system.test.ts +4 −4
@@ -12,7 +12,7 @@ import { createApp, type App } from "../src/app.ts";
1212 import { recordMemory } from "../src/boards/memory.ts";
1313 import { createUser, type User } from "../src/core/users.ts";
1414 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
15 import { memoryLines, systemScreen } from "../src/tui/screens/system.ts";
15 import { memoryLines, memoryScreen } from "../src/tui/screens/system.ts";
1616 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1717 import { quietLogs } from "./helpers/globals.ts";
1818 import { settleApp } from "./helpers/tui.ts";
@@ -37,7 +37,7 @@ async function screenText(user?: User): Promise<string> {
3737 const terminal = headlessTerminal(120, 30);
3838 const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false });
3939 tui.start();
40 tui.ctx.push(systemScreen(session, tui.ctx));
40 tui.ctx.push(memoryScreen(session, tui.ctx));
4141 await settleApp(tui);
4242 const text = tui.screen.toText();
4343 tui.stop?.();
@@ -87,13 +87,13 @@ describe("tui system memory", () => {
8787 const text = await screenText(carol);
8888 expect(text).toContain("123.0");
8989 expect(text).toContain("45.0");
90 expect(doors(carol)).toContain("system");
90 expect(doors(carol)).toContain("system-memory");
9191 });
9292
9393 test("nobody else has rows or the door", async () => {
9494 for (const who of [bob, bot, undefined]) {
9595 expect(memoryLines(new Session(app, identity(who)))).toEqual([]);
96 expect(doors(who)).not.toContain("system");
96 expect(doors(who)).not.toContain("system-memory");
9797 expect(await screenText(who)).not.toContain("123.0");
9898 }
9999 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs