Public: anyone can read this; the crew can change it.
MCP `telemetry_history`: the machine's CPU, memory and disk over the last hours, beside `system`'s now #4812
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4804-mcp-telemetry-history-the-machine-s-cpu → main 13 commits fork 6 threads · 6 unresolved
docs/MCP.md +1
| @@ -127,6 +127,7 @@ for a tunnel and a forge that sleeps. | ||
| 127 | 127 | | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | | |
| 128 | 128 | | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused. On a spawned forge's code repository it opens the one `forge/take-updates` pull and never moves the default branch | yes | |
| 129 | 129 | | `telemetry_grant` | `{ agent, on }`: grant an agent `telemetry:read`, or take it back with `false`, as the Telemetry page does; a person admin's, with an admin token; answers `says` | yes | |
| 130 | | `telemetry_history` | `{ hours? }` (1–72, default 6): the node's and the forge's CPU cores, memory and load over the window, `{ hours, points: [{ target, at, cpuCores, cpuCoresMax (the bucket's peak), memoryBytes, load }] }`, at most 300 points a series (15-s samples to 24 h, 5-min beyond, averaged down); a forge admin or `telemetry:read`, as `system`; the machine records no disk history | | | |
| 130 | 131 | | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes | |
| 131 | 132 | | `thought` | `{ id }`: where one of your thoughts went (its id comes from `tell` without a repo): the placement, and the earlier thoughts it repeats or takes back and the later ones that did the same; not yours and not there answer alike, `not_found` | | |
| 132 | 133 | | `threads` | `{ repo, number, anchor?, resolved? }`: a pull's review threads with their comments; with `anchor` (from the list) resolve it (`resolved` true, the default) or reopen it (false), by the API's rules: a writer or someone who commented in the thread | yes | |
src/http/mcp/tools/index.ts +1
| @@ -114,6 +114,7 @@ export { tool as system } from "./system.ts"; | ||
| 114 | 114 | export { tool as take_updates } from "./take_updates.ts"; |
| 115 | 115 | export { tool as teams } from "./teams.ts"; |
| 116 | 116 | export { tool as telemetry_grant } from "./telemetry_grant.ts"; |
| 117 | export { tool as telemetry_history } from "./telemetry_history.ts"; | |
| 117 | 118 | export { tool as tell } from "./tell.ts"; |
| 118 | 119 | export { tool as thought } from "./thought.ts"; |
| 119 | 120 | export { tool as threads } from "./threads.ts"; |
src/http/mcp/tools/telemetry_history.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | import { noteRead, telemetryReader } from "../../../actions/telemetry.ts"; | |
| 2 | import { historyOver } from "../../../system/telemetry.ts"; | |
| 3 | import { good, bad, type Tool } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "telemetry_history", | |
| 7 | doc: "`{ hours? }` (1–72, default 6): the node's and the forge's CPU cores, memory and load over the window, `{ hours, points: [{ target, at, cpuCores, cpuCoresMax (the bucket's peak), memoryBytes, load }] }`, at most 300 points a series (15-s samples to 24 h, 5-min beyond, averaged down); a forge admin or `telemetry:read`, as `system`; the machine records no disk history", | |
| 8 | description: "Machine CPU, memory, load over `hours` (1–72, default 6).", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { hours: { type: "integer", minimum: 1, maximum: 72 } }, | |
| 12 | }, | |
| 13 | writes: false, | |
| 14 | run(ctx, user, args) { | |
| 15 | const hours = args.hours === undefined ? 6 : args.hours; | |
| 16 | if (typeof hours !== "number" || !Number.isInteger(hours) || hours < 1 || hours > 72) | |
| 17 | return bad("invalid", "hours is a whole number from 1 to 72"); | |
| 18 | const reader = telemetryReader(ctx.app, user); | |
| 19 | if (!reader) | |
| 20 | return bad("forbidden", "only an admin of this forge, or an agent granted telemetry:read, reads its machine"); | |
| 21 | noteRead(ctx.app, user, reader, "history"); | |
| 22 | return good({ hours, points: historyOver(ctx.app, hours) }); | |
| 23 | }, | |
| 24 | }; |
src/system/telemetry.ts +58 −8
| @@ -85,17 +85,67 @@ export function sourcesOut() { | ||
| 85 | 85 | |
| 86 | 86 | /** The node's 15-s points for the last hour: the `machine` source's `node` and `forge` targets, nothing per-cell. */ |
| 87 | 87 | export function historyFor(app: App) { |
| 88 | return app.db | |
| 88 | return historyOver(app, 1); | |
| 89 | } | |
| 90 | ||
| 91 | /** Most points one series carries in `historyOver`. */ | |
| 92 | export const HISTORY_POINTS = 300; | |
| 93 | ||
| 94 | type HistoryPoint = { | |
| 95 | target: string; | |
| 96 | at: string; | |
| 97 | cpuCores: number | null; | |
| 98 | /** The busiest sample in the bucket, so a short spike survives the averaging. */ | |
| 99 | cpuCoresMax: number | null; | |
| 100 | memoryBytes: number | null; | |
| 101 | load: number | null; | |
| 102 | }; | |
| 103 | ||
outdated
Nit: this SELECT and its mapping repeat |
||
| 104 | /** | |
| 105 | * The same points over the last `hours` (15-s steps inside the day, 5-min beyond it), each target | |
| 106 | * averaged down to at most `HISTORY_POINTS`, so the answer is bounded however long the window. | |
| 107 | */ | |
| 108 | export function historyOver(app: App, hours: number) { | |
| 109 | const step = hours <= 24 ? 15 : 300; | |
| 110 | const rows = app.db | |
| 89 | 111 | .query< |
| 90 | { target: string; at: string; cpu_cores: number | null; memory_bytes: number | null; load: number | null }, | |
| 91 | [] | |
| 112 | { target: string; at: string; cpu_cores: number | null; cpu_cores_max: number | null; memory_bytes: number | null; load: number | null }, | |
| 113 | [number, string] | |
| 92 | 114 | >( |
| 93 | `SELECT target, at, cpu_cores, memory_bytes, load FROM machine_sample | |
| 94 | WHERE step = 15 AND target IN ('node','forge') | |
| 95 | AND at >= strftime('%Y-%m-%dT%H:%M:%fZ','now','-1 hour') ORDER BY at, target`, | |
| 115 | `SELECT target, at, cpu_cores, cpu_cores_max, memory_bytes, load FROM machine_sample | |
| 116 | WHERE step = ?1 AND target IN ('node','forge') | |
| 117 | AND at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?2) ORDER BY at, target`, | |
| 96 | 118 | ) |
| 97 | .all() | |
| 98 | .map((r) => ({ target: r.target, at: r.at, cpuCores: r.cpu_cores, memoryBytes: r.memory_bytes, load: r.load })); | |
| 119 | .all(step, `-${hours} hours`) | |
| 120 | .map((r) => ({ | |
| 121 | target: r.target, | |
| 122 | at: r.at, | |
| 123 | cpuCores: r.cpu_cores, | |
| 124 | cpuCoresMax: r.cpu_cores_max ?? r.cpu_cores, | |
| 125 | memoryBytes: r.memory_bytes, | |
| 126 | load: r.load, | |
| 127 | })); | |
| 128 | const mean = (xs: (number | null)[]) => { | |
outdated
A bucket mean hides a 15-second spike once the window is longer than about 75 minutes. If an owner is asking whether the box struggled, the peak is the answer they need. A |
||
| 129 | const v = xs.filter((x): x is number => x !== null); | |
| 130 | return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null; | |
| 131 | }; | |
| 132 | const out: HistoryPoint[] = []; | |
| 133 | for (const target of ["node", "forge"]) { | |
| 134 | const own = rows.filter((r) => r.target === target); | |
| 135 | const size = Math.ceil(own.length / HISTORY_POINTS) || 1; | |
| 136 | for (let i = 0; i < own.length; i += size) { | |
| 137 | const g = own.slice(i, i + size); | |
| 138 | out.push({ | |
| 139 | target, | |
| 140 | at: g[0]!.at, | |
| 141 | cpuCores: mean(g.map((r) => r.cpuCores)), | |
| 142 | cpuCoresMax: g.reduce<number | null>((m, r) => (r.cpuCoresMax === null ? m : Math.max(m ?? -Infinity, r.cpuCoresMax)), null), | |
| 143 | memoryBytes: mean(g.map((r) => r.memoryBytes)), | |
| 144 | load: mean(g.map((r) => r.load)), | |
| 145 | }); | |
| 146 | } | |
| 147 | } | |
| 148 | return out.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : a.target < b.target ? -1 : 1)); | |
| 99 | 149 | } |
| 100 | 150 | |
| 101 | 151 | export const WELCOME_LABEL = "help wanted"; |
test/mcp-telemetry-history.test.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * The `telemetry_history` tool: an admin reads seeded samples, `hours` | |
| 3 | * outside 1–72 is refused, a person without the scope is refused, and a | |
| 4 | * long window comes back bounded. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createToken } from "../src/core/tokens.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | ||
| 15 | describe("telemetry_history over MCP", () => { | |
| 16 | let dataDir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let admin: string; | |
| 20 | let plain: string; | |
| 21 | let id = 1; | |
| 22 | ||
| 23 | const tool = async (token: string, args: unknown = {}) => { | |
| 24 | const res = await fetch(`${servers.url}/mcp`, { | |
| 25 | method: "POST", | |
| 26 | headers: { | |
| 27 | "content-type": "application/json", | |
| 28 | accept: "application/json, text/event-stream", | |
| 29 | authorization: `Bearer ${token}`, | |
| 30 | }, | |
| 31 | body: JSON.stringify({ | |
| 32 | jsonrpc: "2.0", | |
| 33 | id: id++, | |
| 34 | method: "tools/call", | |
| 35 | params: { name: "telemetry_history", arguments: args }, | |
| 36 | }), | |
| 37 | }); | |
| 38 | const body = (await res.json()) as any; | |
| 39 | const text: string = body.result.content[0].text; | |
| 40 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 41 | }; | |
| 42 | ||
| 43 | beforeAll(async () => { | |
| 44 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-telhist-")); | |
| 45 | app = createApp({ dataDir, port: 0 }); | |
| 46 | servers = await serve(app); | |
| 47 | const a = await createUser(app.db, { | |
| 48 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 49 | password: "a-good-passphrase", adminIfFirst: true, | |
| 50 | }); | |
| 51 | if (!a.ok) throw new Error(a.error); | |
| 52 | admin = createToken(app.db, a.value.id, "rw", "rw").token; | |
| 53 | const b = await createUser(app.db, { | |
| 54 | origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase", | |
| 55 | }); | |
| 56 | if (!b.ok) throw new Error(b.error); | |
| 57 | plain = createToken(app.db, b.value.id, "rw", "rw").token; | |
| 58 | const ins = app.db.query( | |
| 59 | "INSERT INTO machine_sample (at, step, target, cpu_cores, memory_bytes, load) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", | |
| 60 | ); | |
| 61 | const now = Date.now(); | |
| 62 | for (let i = 0; i < 600; i++) { | |
| 63 | ins.run(new Date(now - i * 15_000).toISOString(), 15, "node", i === 100 ? 9 : 1.5, 1000, 0.5); | |
| 64 | } | |
| 65 | for (let i = 0; i < 800; i++) { | |
| 66 | ins.run(new Date(now - i * 300_000).toISOString(), 300, "node", 2, 2000, 1); | |
| 67 | } | |
| 68 | }, 30_000); | |
| 69 | ||
| 70 | afterAll(async () => { | |
| 71 | servers.stop(); | |
| 72 | app.close(); | |
| 73 | await rm(dataDir, { recursive: true, force: true }); | |
| 74 | }); | |
| 75 | ||
| 76 | test("an admin reads the seeded samples", async () => { | |
| 77 | const r = await tool(admin, { hours: 1 }); | |
| 78 | expect(r.value.hours).toBe(1); | |
| 79 | expect(r.value.points.length).toBeGreaterThan(200); | |
| 80 | expect(r.value.points[0]).toMatchObject({ target: "node", cpuCores: 1.5, memoryBytes: 1000, load: 0.5 }); | |
| 81 | }); | |
| 82 | ||
| 83 | test("the default is six hours and a long window is bounded", async () => { | |
| 84 | expect((await tool(admin)).value.hours).toBe(6); | |
| 85 | const day = await tool(admin, { hours: 24 }); | |
| 86 | expect(day.value.points.length).toBeLessThanOrEqual(300); | |
| 87 | const three = await tool(admin, { hours: 72 }); | |
| 88 | expect(three.value.points.length).toBeLessThanOrEqual(300); | |
| 89 | expect(three.value.points.length).toBeGreaterThan(0); | |
| 90 | expect(three.value.points[0].cpuCores).toBe(2); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a bucket keeps its peak, so a short spike survives the mean", async () => { | |
| 94 | const day = await tool(admin, { hours: 24 }); | |
| 95 | const pts = day.value.points as { cpuCores: number; cpuCoresMax: number }[]; | |
| 96 | expect(Math.max(...pts.map((p) => p.cpuCoresMax))).toBe(9); | |
| 97 | expect(Math.max(...pts.map((p) => p.cpuCores))).toBeLessThan(9); | |
| 98 | }); | |
| 99 | ||
| 100 | test("hours outside 1 to 72 is refused", async () => { | |
| 101 | for (const hours of [0, 73, -1, 1.5, "6"]) { | |
| 102 | expect((await tool(admin, { hours })).error).toBeTruthy(); | |
| 103 | } | |
| 104 | }); | |
| 105 | ||
| 106 | test("a person without the scope is refused", async () => { | |
| 107 | expect((await tool(plain, { hours: 1 })).error).toContain("telemetry:read"); | |
| 108 | }); | |
| 109 | }); |
test/mcp.test.ts +1
| @@ -279,6 +279,7 @@ describe("the forge over MCP", () => { | ||
| 279 | 279 | "scorecard", |
| 280 | 280 | "flow", |
| 281 | 281 | "system", |
| 282 | "telemetry_history", | |
| 282 | 283 | "contribute", |
| 283 | 284 | "limits", |
| 284 | 285 | "app_env", |
This repeats the SQL and row mapping of
historyForjust above. Withhours = 1the result is the same: 240 fine points, which is under the cap, in the same order. Keep one copy: havehistoryFor(app)returnhistoryOver(app, 1). The API route stays as it is, and the two doors can't drift apart.