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 #5726

Merged opened this

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

Merged

scout/forge:scout/4804-mcp-telemetry-history-the-machine-s-cpu → main 13 commits fork

  • Merged. scout/4804-mcp-telemetry-history-the-machine-s-cpu was merged into main as 2d5f895.

Review

Sign in to review this change.
6 files changed +194 −8
docs/MCP.md +1
@@ -127,6 +127,7 @@ for a tunnel and a forge that sleeps.
127127 | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | |
128128 | `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 |
129129 | `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 | |
130131 | `tell` | a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick | yes |
131132 | `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` | |
132133 | `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";
114114 export { tool as take_updates } from "./take_updates.ts";
115115 export { tool as teams } from "./teams.ts";
116116 export { tool as telemetry_grant } from "./telemetry_grant.ts";
117 export { tool as telemetry_history } from "./telemetry_history.ts";
117118 export { tool as tell } from "./tell.ts";
118119 export { tool as thought } from "./thought.ts";
119120 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() {
8585
8686 /** The node's 15-s points for the last hour: the `machine` source's `node` and `forge` targets, nothing per-cell. */
8787 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
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
89111 .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]
92114 >(
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`,
96118 )
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)[]) => {
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));
99149 }
100150
101151 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", () => {
279279 "scorecard",
280280 "flow",
281281 "system",
282 "telemetry_history",
282283 "contribute",
283284 "limits",
284285 "app_env",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs