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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1789-living-open-forge-live-machine-telemetry → main 22 commits fork 6 threads · 6 unresolved
README.md +4
| @@ -621,6 +621,10 @@ Where the whole forge waits is a forge admin's: `/flow` shows each stage from re | ||
| 621 | 621 | |
| 622 | 622 | 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. |
| 623 | 623 | |
| 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. | |
| 627 | ||
| 624 | 628 | `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and |
| 625 | 629 | what "ready now" means to a person and to the crew. The map also shows each |
| 626 | 630 | 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 { | ||
| 806 | 806 | } |
| 807 | 807 | } |
| 808 | 808 | } |
| 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[] = [ | ||
| 64 | 64 | "big", |
| 65 | 65 | "board", |
| 66 | 66 | "boards", |
| 67 | "contribute", | |
| 67 | 68 | "events", |
| 68 | 69 | "explore", |
| 69 | 70 | "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 { | ||
| 15 | 15 | readonly since: string; |
| 16 | 16 | } |
| 17 | 17 | |
| 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[] { | |
| 20 | 20 | return db |
| 21 | .query<ConflictedPull, []>( | |
| 21 | .query<ConflictedPull, [number]>( | |
| 22 | 22 | `SELECT o.username || '/' || r.name AS repo, i.number AS number, |
| 23 | 23 | MIN(u.created_at) AS since |
| 24 | 24 | FROM run u JOIN issue i ON i.id = u.issue_id |
| 25 | 25 | 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 |
| 26 | 26 | 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') | |
| 28 | 28 | AND (SELECT state FROM run n WHERE n.issue_id = i.id AND n.kind = 'agent' |
| 29 | 29 | AND n.reason = 'conflict' ORDER BY n.created_at DESC, n.id DESC LIMIT 1) |
| 30 | 30 | IN ('queued','running','failed') |
| 31 | 31 | GROUP BY i.id ORDER BY since`, |
| 32 | 32 | ) |
| 33 | .all(); | |
| 33 | .all(publicOnly ? 1 : 0); | |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | 36 | /** 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"; | ||
| 58 | 58 | import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts"; |
| 59 | 59 | import { proposeUpstream } from "../actions/upstream.ts"; |
| 60 | 60 | 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"; | |
| 61 | 63 | import { convergeFork, convergeState } from "../crew/converge.ts"; |
| 62 | 64 | import { markReaders, mayDirectCrew } from "../crew/direct.ts"; |
| 63 | 65 | import { |
| @@ -1325,6 +1327,26 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1325 | 1327 | : forbidden(); |
| 1326 | 1328 | }; |
| 1327 | 1329 | |
| 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 | ||
| 1328 | 1350 | router.add("GET", "/api/v1/boards/sources", (ctx) => { |
| 1329 | 1351 | const denied = boardsAdmin(ctx); |
| 1330 | 1352 | if (denied) return denied; |
src/http/mcp.ts +29 −24
| @@ -21,10 +21,6 @@ import { voteVariant } from "../actions/variants.ts"; | ||
| 21 | 21 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 22 | 22 | import { listAccess } from "../actions/access_log.ts"; |
| 23 | 23 | 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"; | |
| 28 | 24 | import { changeLimit, listLimits } from "../actions/limits.ts"; |
| 29 | 25 | import { changeQuotas, readQuotas } from "../actions/quotas.ts"; |
| 30 | 26 | import { QUOTA_KEYS } from "../settings/quotas.ts"; |
| @@ -147,6 +143,8 @@ import { | ||
| 147 | 143 | } from "../actions/explore.ts"; |
| 148 | 144 | import { frontInput, frontJson, setFront } from "../actions/front.ts"; |
| 149 | 145 | import { readHealth } from "../actions/health.ts"; |
| 146 | import { noteRead, telemetryReader } from "../actions/telemetry.ts"; | |
| 147 | import { flowFor, systemFor, welcome } from "../system/telemetry.ts"; | |
| 150 | 148 | import { running } from "../kernel/version.ts"; |
| 151 | 149 | import { parentForgeFor } from "../core/parent_releases.ts"; |
| 152 | 150 | import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; |
| @@ -2077,28 +2075,35 @@ const TOOLS: readonly Tool[] = [ | ||
| 2077 | 2075 | inputSchema: { type: "object", properties: {} }, |
| 2078 | 2076 | writes: false, |
| 2079 | 2077 | 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)); | |
| 2100 | 2083 | }, |
| 2101 | 2084 | }, |
| 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 | }, | |
| 2102 | 2107 | { |
| 2103 | 2108 | name: "limits", |
| 2104 | 2109 | description: |
src/http/server.ts +7 −2
| @@ -11,11 +11,13 @@ import { unsealReleased } from "../actions/sealed.ts"; | ||
| 11 | 11 | import { accessPage } from "../web/pages/access.ts"; |
| 12 | 12 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 13 | 13 | 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"; | |
| 14 | 17 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 15 | 18 | import { domainsPage } from "../web/pages/domains.ts"; |
| 16 | 19 | import { resiliencePage } from "../web/pages/resilience.ts"; |
| 17 | 20 | import { limitsPage, limitsSet, quotasSet } from "../web/pages/admin-limits.ts"; |
| 18 | import { systemPage } from "../web/pages/system.ts"; | |
| 19 | 21 | import { boardsPage } from "../web/pages/boards.ts"; |
| 20 | 22 | import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts"; |
| 21 | 23 | import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts"; |
| @@ -667,6 +669,10 @@ export function buildRouter(): Router<Handler> { | ||
| 667 | 669 | router.add("GET", "/map", globalMapPage); |
| 668 | 670 | // The pipeline's stages and its bottleneck, for a forge admin (#1554); literal, above /:owner. |
| 669 | 671 | 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); | |
| 670 | 676 | // Each THREATS.md protection against the last gate; literal, above /:owner. |
| 671 | 677 | router.add("GET", "/resilience", resiliencePage); |
| 672 | 678 | // A selection's spend and runs over time; literal, above /:owner. |
| @@ -745,7 +751,6 @@ export function buildRouter(): Router<Handler> { | ||
| 745 | 751 | router.add("GET", "/settings/resilience", domainsPage); |
| 746 | 752 | router.add("GET", "/settings/capacity", capacityPage); |
| 747 | 753 | router.add("POST", "/settings/capacity", capacitySet); |
| 748 | router.add("GET", "/system", systemPage); | |
| 749 | 754 | router.add("GET", "/boards", boardsList); |
| 750 | 755 | router.add("GET", "/boards/data", boardsPage); |
| 751 | 756 | 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
|
||
| 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 |
||
| 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 { | ||
| 24 | 24 | ORIGIN_MARK, |
| 25 | 25 | PULL_MARK, |
| 26 | 26 | errorText, |
| 27 | pickerScreen, | |
| 27 | 28 | promptScreen, |
| 28 | 29 | openOnWeb, |
| 29 | 30 | runAsync, |
| 30 | 31 | } from "./screens/common.ts"; |
| 31 | 32 | import { canSeeMemory } from "../boards/memory.ts"; |
| 32 | 33 | import { boardsScreen } from "./screens/boards.ts"; |
| 33 | import { systemScreen } from "./screens/system.ts"; | |
| 34 | 34 | import { askedScreen } from "./screens/asked.ts"; |
| 35 | 35 | import { deciderScreen } from "./screens/decider.ts"; |
| 36 | 36 | import { familyScreen } from "./screens/family.ts"; |
| @@ -45,6 +45,11 @@ import { flagsScreen } from "./screens/flags.ts"; | ||
| 45 | 45 | import { domainsScreen } from "./screens/domains.ts"; |
| 46 | 46 | import { resilienceLines, resilienceScreen } from "./screens/resilience.ts"; |
| 47 | 47 | 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"; | |
| 48 | 53 | import { releasesScreen } from "./screens/releases.ts"; |
| 49 | 54 | import { mayRead } from "../release/gate.ts"; |
| 50 | 55 | import { stalledScreen } from "./screens/stalled.ts"; |
| @@ -235,19 +240,70 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 235 | 240 | // The forge's RSS beside its JS heap, as /system gates it (#2151). |
| 236 | 241 | if (canSeeMemory(session.user)) |
| 237 | 242 | commands.push({ |
outdated
The terminal's Flow door has the same gap: |
||
| 238 | id: "system", | |
| 243 | id: "system-memory", | |
| 239 | 244 | title: "System memory", |
| 240 | 245 | hint: "RSS beside the JS heap, hourly for 24 h", |
| 241 | run: () => ctx.push(systemScreen(session, ctx)), | |
| 246 | run: () => ctx.push(memoryScreen(session, ctx)), | |
| 242 | 247 | }); |
| 243 | 248 | // 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) | |
| 245 | 251 | commands.push({ |
| 246 | 252 | id: "flow", |
| 247 | 253 | title: "Flow", |
| 248 | 254 | 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 | }, | |
| 250 | 264 | }); |
| 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 | } | |
| 251 | 307 | // Each THREATS.md protection's standing, as /resilience says it: a site admin's. |
| 252 | 308 | if (session.user?.isAdmin === true) |
| 253 | 309 | commands.push({ |
src/tui/screens/flow.ts +13 −3
| @@ -17,16 +17,24 @@ import type { Ctx, Screen } from "../app/app.ts"; | ||
| 17 | 17 | import { binding } from "../app/keys.ts"; |
| 18 | 18 | import { pad, truncate, wrap } from "../term/width.ts"; |
| 19 | 19 | import { dialog } from "../ui/widgets.ts"; |
| 20 | import { live } from "../../system/live.ts"; | |
| 21 | import { duplicateLines } from "./system.ts"; | |
| 20 | 22 | |
| 21 | 23 | const WIDTH = 64; |
| 22 | 24 | |
| 23 | 25 | /** 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[] { | |
| 25 | 33 | const stages = readPipeline(db, now); |
| 26 | 34 | const flow = readThroughput(db, stages.map((s) => s.key), now); |
| 27 | 35 | 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()); | |
| 30 | 38 | return [ |
| 31 | 39 | ...wrap(verdict(stages, now.getTime()), inner), |
| 32 | 40 | "", |
| @@ -46,6 +54,8 @@ export function flowLines(db: Database, now = new Date()): string[] { | ||
| 46 | 54 | `${pad(truncate(q.repo, 30), 30)} ${pad(String(q.queued), 6, "right")} ${pad(String(q.gating), 6, "right")} ${pad(String(q.landedHour), 9, "right")}`, |
| 47 | 55 | )), |
| 48 | 56 | ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]), |
| 57 | "", | |
| 58 | ...duplicateLines(dups, inner), | |
| 49 | 59 | ]; |
| 50 | 60 | } |
| 51 | 61 |
src/tui/screens/system.ts +68 −8
| @@ -1,19 +1,79 @@ | ||
| 1 | 1 | /** |
| 2 | * `/system` in the terminal (#2151): the forge's RSS beside its JS heap, an | |
| 3 | * hour a row for the last 24 h, read through `memoryHours` like the page. The | |
| 4 | * gate is the page's (`canSeeMemory`): anyone but a forge admin who is a | |
| 5 | * person gets no rows, and the palette offers the door only to that person. | |
| 6 | * Read-only. | |
| 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. | |
| 7 | 12 | */ |
| 8 | 13 | |
| 9 | import { canSeeMemory, memoryHours } from "../../boards/memory.ts"; | |
| 10 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | import type { SystemView } from "../../system/view.ts"; | |
| 16 | import { canSeeMemory, memoryHours } from "../../boards/memory.ts"; | |
| 11 | 17 | import { binding } from "../app/keys.ts"; |
| 12 | 18 | import type { Session } from "../session.ts"; |
| 19 | import { bytes } from "../../system/format.ts"; | |
| 13 | 20 | 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"; | |
| 15 | 23 | import { Scroll, drawLines, hitScroll } from "./common.ts"; |
| 16 | 24 | |
| 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 | ||
| 17 | 77 | const mib = (n: number) => `${(n / 1_048_576).toFixed(1)}`; |
| 18 | 78 | |
| 19 | 79 | /** 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[] { | ||
| 31 | 91 | ]; |
| 32 | 92 | } |
| 33 | 93 | |
| 34 | export function systemScreen(session: Session, ctx: Ctx): Screen { | |
| 94 | export function memoryScreen(session: Session, ctx: Ctx): Screen { | |
| 35 | 95 | const scroll = new Scroll(); |
| 36 | 96 | return { |
| 37 | 97 | title: "System memory", |
src/web/layout.ts +1
| @@ -332,6 +332,7 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html { | ||
| 332 | 332 | ...(ctx.auth.user?.isAdmin |
| 333 | 333 | ? [ |
| 334 | 334 | { t: "Flow", h: "/flow", c: "Go to" }, |
| 335 | { t: "System", h: "/system", c: "Go to" }, | |
| 335 | 336 | { t: "Forges you sent", h: "/forges", c: "Go to" }, |
| 336 | 337 | { t: "Family of forges", h: "/family", c: "Go to" }, |
| 337 | 338 | ] |
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 @@ | ||
| 19 | 19 | * command and the MCP `flow` tool read too. |
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | import { noteRead, telemetryReader } from "../../actions/telemetry.ts"; | |
| 23 | import { queuesFor, systemFor } from "../../system/telemetry.ts"; | |
| 22 | 24 | import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts"; |
| 23 | 25 | import { |
| 24 | 26 | bottleneck, |
| @@ -41,6 +43,7 @@ import { | ||
| 41 | 43 | } from "../../flow/throughput.ts"; |
| 42 | 44 | import { readQueues, type QueueRow } from "../../flow/queue.ts"; |
| 43 | 45 | import type { Ctx } from "../../http/context.ts"; |
| 46 | import { live } from "../../system/live.ts"; | |
|
||
| 44 | 47 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 45 | 48 | import { html, type Html } from "../html.ts"; |
| 46 | 49 | import { page } from "../layout.ts"; |
| @@ -99,14 +102,17 @@ function outCell(t: StageThroughput): Html { | ||
| 99 | 102 | // GET /flow |
| 100 | 103 | export function flowPage(ctx: Ctx): Response { |
| 101 | 104 | 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"); | |
| 103 | 108 | const now = Date.now(); |
| 104 | 109 | const stages = readPipeline(ctx.app.db, new Date(now)); |
| 105 | 110 | 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 |
||
| 106 | 111 | const flow = readThroughput(ctx.app.db, stages.map((s) => s.key), new Date(now)); |
| 107 | 112 | const out = new Map(flow.stages.map((t) => [t.key, t])); |
| 108 | 113 | 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); | |
| 110 | 116 | const listed = asked(ctx.url); |
| 111 | 117 | const shown = listed === null ? undefined : out.get(listed.key); |
| 112 | 118 | const body = html`<div class="page-head"> |
| @@ -116,7 +122,8 @@ export function flowPage(ctx: Ctx): Response { | ||
| 116 | 122 | long its oldest has waited, and what limits it. The limits are set on |
| 117 | 123 | <a href="/settings/capacity">Capacity</a> and in each repository's |
| 118 | 124 | 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>. | |
| 120 | 127 | </p> |
| 121 | 128 | </div> |
| 122 | 129 | <p id="bottleneck" data-stage="${worst?.key ?? ""}"> |
| @@ -132,6 +139,13 @@ export function flowPage(ctx: Ctx): Response { | ||
| 132 | 139 | <span data-median>${flow.medianAskToLiveMs === null ? "—" : hoursSpan(flow.medianAskToLiveMs)}</span> |
| 133 | 140 | (24 h) |
| 134 | 141 | </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> | |
| 135 | 149 | ${card( |
| 136 | 150 | html`<div class="table-scroll"> |
| 137 | 151 | <table class="table" aria-label="Pipeline stages"> |
| @@ -169,7 +183,7 @@ export function flowPage(ctx: Ctx): Response { | ||
| 169 | 183 | </div>`, |
| 170 | 184 | { pad: false }, |
| 171 | 185 | )} |
| 172 | ${queueCard(readQueues(ctx.app.db, new Date(now)))} | |
| 186 | ${queueCard(queuesFor(ctx.app, reader, new Date(now)))} | |
| 173 | 187 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; |
| 174 | 188 | return page({ ctx, title: "Flow", body, styleCss: css }); |
| 175 | 189 | } |
src/web/pages/origins.ts +7
| @@ -81,6 +81,7 @@ export type AccountSection = | ||
| 81 | 81 | | "backups" |
| 82 | 82 | | "secrets" |
| 83 | 83 | | "capacity" |
| 84 | | "telemetry" | |
| 84 | 85 | | "access" |
| 85 | 86 | | "resilience" |
| 86 | 87 | | "limits" |
| @@ -136,6 +137,12 @@ export function accountSections( | ||
| 136 | 137 | icon: "activity" as const, |
| 137 | 138 | current: current === "capacity", |
| 138 | 139 | }, |
| 140 | { | |
| 141 | label: "Telemetry", | |
| 142 | href: "/settings/telemetry", | |
| 143 | icon: "activity" as const, | |
| 144 | current: current === "telemetry", | |
| 145 | }, | |
| 139 | 146 | { |
| 140 | 147 | label: "Access", |
| 141 | 148 | href: "/settings/access", |
src/web/pages/system.ts +102 −29
| @@ -1,49 +1,122 @@ | ||
| 1 | 1 | /** |
| 2 | * GET /system, for a forge admin who is a person: the forge's resident | |
| 3 | * memory (RSS) beside its JS heap, hourly for the last 24 h (#2151). RSS | |
| 4 | * climbing while the heap stays flat is native memory growth. Drawn on the | |
| 5 | * server as SVG, with the hours under it as a table. Anyone else gets 403. | |
| 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. | |
| 6 | 11 | */ |
| 7 | 12 | import { drawChart } from "../../boards/chart.ts"; |
| 8 | 13 | import { canSeeMemory, memoryHours } from "../../boards/memory.ts"; |
| 14 | import { telemetryReader } from "../../actions/telemetry.ts"; | |
| 9 | 15 | 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"; | |
| 11 | 20 | 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)); | |
| 13 | 25 | |
| 14 | 26 | const mib = (n: number) => `${(n / 1_048_576).toFixed(1)} MiB`; |
| 15 | 27 | const hhmm = (at: number) => new Date(at).toISOString().slice(0, 16).replace("T", " "); |
| 16 | 28 | |
| 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``; | |
| 30 | 32 | const hours = memoryHours(ctx.app.db, Date.now()); |
| 31 | 33 | const series = (title: string, pick: (h: (typeof hours)[number]) => number) => |
| 32 | 34 | card( |
| 33 | 35 | html`<div class="chart-box">${raw(drawChart("line", hours.map((h) => ({ label: hhmm(h.at).slice(11), value: pick(h) / 1_048_576 })), title))}</div>`, |
| 34 | 36 | { head: html`<h2>${title}</h2>` }, |
| 35 | 37 | ); |
| 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"> | |
| 41 | 42 | <thead><tr><th scope="col">Hour (UTC)</th><th scope="col" class="num">RSS mean</th><th scope="col" class="num">RSS max</th><th scope="col" class="num">Heap mean</th><th scope="col" class="num">Heap max</th></tr></thead> |
| 42 | 43 | <tbody>${hours.map((h) => html`<tr><td class="mono">${hhmm(h.at)}</td><td class="num mono">${mib(h.rssMean)}</td><td class="num mono">${mib(h.rssMax)}</td><td class="num mono">${mib(h.heapMean)}</td><td class="num mono">${mib(h.heapMax)}</td></tr>`)}</tbody> |
| 43 | 44 | </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 }); | |
| 49 | 122 | } |
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", () => { | ||
| 209 | 209 | "access", |
| 210 | 210 | "capacity", |
| 211 | 211 | "flow", |
| 212 | "system", | |
| 213 | "contribute", | |
| 212 | 214 | "limits", |
| 213 | 215 | "quotas", |
| 214 | 216 | "boards", |
test/system-memory.test.ts +7 −6
| @@ -83,9 +83,10 @@ describe("GET /system", () => { | ||
| 83 | 83 | expect(body).toContain("40.0 MiB"); |
| 84 | 84 | }); |
| 85 | 85 | |
| 86 | test("a non-admin and an anonymous visitor get 403", async () => { | |
| 87 | expect((await get("bob")).status).toBe(403); | |
| 88 | expect((await get(null)).status).toBe(403); | |
| 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); | |
| 89 | 90 | }); |
| 90 | 91 | }); |
| 91 | 92 | |
| @@ -98,8 +99,8 @@ describe("reaching /system", () => { | ||
| 98 | 99 | }); |
| 99 | 100 | |
| 100 | 101 | 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>"); | |
| 104 | 105 | }); |
| 105 | 106 | }); |
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"; | ||
| 12 | 12 | import { recordMemory } from "../src/boards/memory.ts"; |
| 13 | 13 | import { createUser, type User } from "../src/core/users.ts"; |
| 14 | 14 | 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"; | |
| 16 | 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 17 | 17 | import { quietLogs } from "./helpers/globals.ts"; |
| 18 | 18 | import { settleApp } from "./helpers/tui.ts"; |
| @@ -37,7 +37,7 @@ async function screenText(user?: User): Promise<string> { | ||
| 37 | 37 | const terminal = headlessTerminal(120, 30); |
| 38 | 38 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false }); |
| 39 | 39 | tui.start(); |
| 40 | tui.ctx.push(systemScreen(session, tui.ctx)); | |
| 40 | tui.ctx.push(memoryScreen(session, tui.ctx)); | |
| 41 | 41 | await settleApp(tui); |
| 42 | 42 | const text = tui.screen.toText(); |
| 43 | 43 | tui.stop?.(); |
| @@ -87,13 +87,13 @@ describe("tui system memory", () => { | ||
| 87 | 87 | const text = await screenText(carol); |
| 88 | 88 | expect(text).toContain("123.0"); |
| 89 | 89 | expect(text).toContain("45.0"); |
| 90 | expect(doors(carol)).toContain("system"); | |
| 90 | expect(doors(carol)).toContain("system-memory"); | |
| 91 | 91 | }); |
| 92 | 92 | |
| 93 | 93 | test("nobody else has rows or the door", async () => { |
| 94 | 94 | for (const who of [bob, bot, undefined]) { |
| 95 | 95 | expect(memoryLines(new Session(app, identity(who)))).toEqual([]); |
| 96 | expect(doors(who)).not.toContain("system"); | |
| 96 | expect(doors(who)).not.toContain("system-memory"); | |
| 97 | 97 | expect(await screenText(who)).not.toContain("123.0"); |
| 98 | 98 | } |
| 99 | 99 | }); |
This says an admin agent needs the grant for
flow. That holds for MCP but not yet for/flowor the terminal's Flow command. Make the code match this sentence.