Public: anyone can read this; the crew can change it.
The forge says how it is at /api/v1/health: disk, memory, queue, stuck runs, apps asleep and previews, as one plain answer #352
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/343-the-forge-says-how-it-is-at-api-v1-healt → main 2 commits fork
6 files
changed +319
−1
docs/ARCHITECTURE.md +10
| @@ -486,6 +486,16 @@ budget, a deadline. Containment is the forge's, not the agent's; the agent | ||
| 486 | 486 | is trusted to work, and the forge is built so that trust costs at most one |
| 487 | 487 | run. |
| 488 | 488 | |
| 489 | ## The forge's standing | |
| 490 | ||
| 491 | `GET /api/v1/health` (`kernel/vitals.ts`) answers a site admin, by session or | |
| 492 | any token scope, with `{ ok, needs, disk, memory, queue, stuck, apps, at }`; | |
| 493 | everyone else gets a 404. `needs` is one sentence per thing off: disk under | |
| 494 | 10% free on the `FORGE_DATA` volume, a run running with a lapsed lease (or | |
| 495 | past six hours), a queue whose oldest run has waited over thirty minutes. | |
| 496 | It is a fixed handful of queries however many runs and apps exist, measured | |
| 497 | per request and never cached. `/healthz` stays the orchestrator's answer. | |
| 498 | ||
| 489 | 499 | ## Search, and where semantic search would go |
| 490 | 500 | |
| 491 | 501 | Search (`core/search.ts`, `actions/search.ts`, `GET /api/v1/search`) holds no |
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts", | |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/core/runs.ts +37
| @@ -789,3 +789,40 @@ export function listActiveRuns( | ||
| 789 | 789 | .all(...unique.slice(0, 400)) |
| 790 | 790 | .map(toRun); |
| 791 | 791 | } |
| 792 | ||
| 793 | /** What is waiting to start, by kind: one grouped query, however many runs there are. */ | |
| 794 | export function queueDepth( | |
| 795 | db: Database, | |
| 796 | now = Date.now(), | |
| 797 | ): { kind: RunKind; queued: number; oldest: string }[] { | |
| 798 | return db | |
| 799 | .query<{ kind: RunKind; queued: number; oldest: string }, [string]>( | |
| 800 | `SELECT kind, COUNT(*) AS queued, MIN(created_at) AS oldest | |
| 801 | FROM run | |
| 802 | WHERE state = 'queued' AND (not_before IS NULL OR not_before <= ?1) | |
| 803 | GROUP BY kind`, | |
| 804 | ) | |
| 805 | .all(new Date(now).toISOString()); | |
| 806 | } | |
| 807 | ||
| 808 | /** Every running run with the repository and issue number it is about, in one query. */ | |
| 809 | export function listRunning( | |
| 810 | db: Database, | |
| 811 | ): { run: Run; repo: string; issue: number | null }[] { | |
| 812 | const rows = db | |
| 813 | .query<RunRow & { slug: string; issue_number: number | null }, []>( | |
| 814 | `SELECT run.*, user.username || '/' || repo.name AS slug, | |
| 815 | issue.number AS issue_number | |
| 816 | FROM run | |
| 817 | JOIN repo ON repo.id = run.repo_id | |
| 818 | JOIN user ON user.id = repo.owner_id | |
| 819 | LEFT JOIN issue ON issue.id = run.issue_id | |
| 820 | WHERE run.state = 'running'`, | |
| 821 | ) | |
| 822 | .all(); | |
| 823 | return rows.map((r) => ({ | |
| 824 | run: toRun(r), | |
| 825 | repo: r.slug, | |
| 826 | issue: r.issue_number, | |
| 827 | })); | |
| 828 | } |
src/http/api.ts +14
| @@ -213,6 +213,7 @@ import { | ||
| 213 | 213 | type Run, |
| 214 | 214 | } from "../core/runs.ts"; |
| 215 | 215 | import { resolveOid } from "../git/merge.ts"; |
| 216 | import { vitals } from "../kernel/vitals.ts"; | |
| 216 | 217 | import { readRunLog, subscribeRunLog } from "../runs/log.ts"; |
| 217 | 218 | import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts"; |
| 218 | 219 | import { repoDir } from "../git/repos.ts"; |
| @@ -2679,6 +2680,19 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2679 | 2680 | |
| 2680 | 2681 | // ---- apps, runs, and the crew (docs/APPS.md) ---- |
| 2681 | 2682 | |
| 2683 | // How the forge itself is: a site admin's to read, and nobody else's to | |
| 2684 | // know exists — a 404, never the numbers. | |
| 2685 | router.add("GET", "/api/v1/health", (ctx) => { | |
| 2686 | if (!ctx.auth.user?.isAdmin) return notFound(); | |
| 2687 | const runtime = appsRuntime(ctx.app); | |
| 2688 | const envs = runtime | |
| 2689 | ? listApps(ctx.app.db).flatMap((record) => runtime.status(record)) | |
| 2690 | : []; | |
| 2691 | return json( | |
| 2692 | vitals({ db: ctx.app.db, dataDir: ctx.app.config.dataDir, envs }), | |
| 2693 | ); | |
| 2694 | }); | |
| 2695 | ||
| 2682 | 2696 | router.add("GET", "/api/v1/apps", (ctx) => { |
| 2683 | 2697 | const records = listApps(ctx.app.db); |
| 2684 | 2698 | const repos = getReposByIds( |
src/kernel/vitals.ts added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | /** | |
| 2 | * The forge's standing: is it fine, and if not, what is off. Plain data in, | |
| 3 | * plain data out — the disk under `FORGE_DATA`, this process's memory, the | |
| 4 | * queue, runs that stopped making progress, and how many apps are serving, | |
| 5 | * asleep or previewed. A fixed handful of queries however many runs and | |
| 6 | * apps there are; nothing is cached, nothing shelled out. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { statfsSync } from "node:fs"; | |
| 11 | import { freemem, totalmem } from "node:os"; | |
| 12 | import { leaseLapsed, listRunning, queueDepth } from "../core/runs.ts"; | |
| 13 | import { STALE_AFTER_MS } from "../runs/lease.ts"; | |
| 14 | ||
| 15 | /** Free disk under this share of the volume is something to say. */ | |
| 16 | export const DISK_FREE_MIN = 0.1; | |
| 17 | /** A run still running this long after it started is stuck even if it breathes. */ | |
| 18 | export const NO_PROGRESS_MS = 6 * 3_600_000; | |
| 19 | /** A run that has waited this long to start is a queue that is not moving. */ | |
| 20 | export const QUEUE_WAIT_MAX_MS = 30 * 60_000; | |
| 21 | ||
| 22 | export interface EnvCount { | |
| 23 | readonly env: string; | |
| 24 | readonly state: string; | |
| 25 | } | |
| 26 | ||
| 27 | export interface Vitals { | |
| 28 | readonly ok: boolean; | |
| 29 | readonly needs: string[]; | |
| 30 | readonly disk: { freeBytes: number; totalBytes: number }; | |
| 31 | readonly memory: { | |
| 32 | rssBytes: number; | |
| 33 | hostFreeBytes: number; | |
| 34 | hostTotalBytes: number; | |
| 35 | }; | |
| 36 | readonly queue: Record<string, number>; | |
| 37 | readonly stuck: { | |
| 38 | runId: string; | |
| 39 | repo: string; | |
| 40 | issue: number | null; | |
| 41 | since: string; | |
| 42 | }[]; | |
| 43 | readonly apps: { serving: number; asleep: number; previews: number }; | |
| 44 | readonly at: string; | |
| 45 | } | |
| 46 | ||
| 47 | export interface VitalsInput { | |
| 48 | readonly db: Database; | |
| 49 | /** The directory holding the forge's data: its volume is the one measured. */ | |
| 50 | readonly dataDir: string; | |
| 51 | /** Every environment the runtime holds, when one is up. */ | |
| 52 | readonly envs?: readonly EnvCount[]; | |
| 53 | readonly now?: number; | |
| 54 | } | |
| 55 | ||
| 56 | export function vitals(input: VitalsInput): Vitals { | |
| 57 | const now = input.now ?? Date.now(); | |
| 58 | const needs: string[] = []; | |
| 59 | ||
| 60 | const fs = statfsSync(input.dataDir); | |
| 61 | const disk = { | |
| 62 | freeBytes: Number(fs.bavail) * Number(fs.bsize), | |
| 63 | totalBytes: Number(fs.blocks) * Number(fs.bsize), | |
| 64 | }; | |
| 65 | if (disk.totalBytes > 0 && disk.freeBytes / disk.totalBytes < DISK_FREE_MIN) | |
| 66 | needs.push( | |
| 67 | `Disk is nearly full: ${Math.round((disk.freeBytes / disk.totalBytes) * 100)}% free.`, | |
| 68 | ); | |
| 69 | ||
| 70 | const queue: Record<string, number> = {}; | |
| 71 | for (const row of queueDepth(input.db, now)) { | |
| 72 | queue[row.kind] = row.queued; | |
| 73 | if (now - new Date(row.oldest).getTime() > QUEUE_WAIT_MAX_MS) | |
| 74 | needs.push( | |
| 75 | `The ${row.kind} queue has waited over ${QUEUE_WAIT_MAX_MS / 60_000} minutes.`, | |
| 76 | ); | |
| 77 | } | |
| 78 | ||
| 79 | const stuck: Vitals["stuck"] = []; | |
| 80 | for (const { run, repo, issue } of listRunning(input.db)) { | |
| 81 | const started = new Date(run.startedAt ?? run.createdAt).getTime(); | |
| 82 | if (leaseLapsed(run, STALE_AFTER_MS, now) || now - started > NO_PROGRESS_MS) | |
| 83 | stuck.push({ | |
| 84 | runId: run.id, | |
| 85 | repo, | |
| 86 | issue, | |
| 87 | since: run.heartbeatAt ?? run.startedAt ?? run.createdAt, | |
| 88 | }); | |
| 89 | } | |
| 90 | if (stuck.length > 0) | |
| 91 | needs.push( | |
| 92 | `${stuck.length} run${stuck.length === 1 ? " is" : "s are"} stuck.`, | |
| 93 | ); | |
| 94 | ||
| 95 | const apps = { serving: 0, asleep: 0, previews: 0 }; | |
| 96 | for (const e of input.envs ?? []) { | |
| 97 | if (e.env !== "production") apps.previews++; | |
| 98 | else if (e.state === "asleep") apps.asleep++; | |
| 99 | else if (e.state === "running") apps.serving++; | |
| 100 | } | |
| 101 | ||
| 102 | return { | |
| 103 | ok: needs.length === 0, | |
| 104 | needs, | |
| 105 | disk, | |
| 106 | memory: { | |
| 107 | rssBytes: process.memoryUsage().rss, | |
| 108 | hostFreeBytes: freemem(), | |
| 109 | hostTotalBytes: totalmem(), | |
| 110 | }, | |
| 111 | queue, | |
| 112 | stuck, | |
| 113 | apps, | |
| 114 | at: new Date(now).toISOString(), | |
| 115 | }; | |
| 116 | } |
test/vitals.test.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | /** The forge's standing: its shape, the admin gate, and what it flags. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createRun } from "../src/core/runs.ts"; | |
| 9 | import { vitals } from "../src/kernel/vitals.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | ||
| 12 | let dataDir: string; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | let admin = ""; | |
| 16 | let plain = ""; | |
| 17 | let repoId = ""; | |
| 18 | ||
| 19 | const post = (path: string, body: unknown, token?: string) => | |
| 20 | fetch(servers.url + path, { | |
| 21 | method: "POST", | |
| 22 | headers: { | |
| 23 | "content-type": "application/json", | |
| 24 | ...(token ? { authorization: `Bearer ${token}` } : {}), | |
| 25 | }, | |
| 26 | body: JSON.stringify(body), | |
| 27 | }); | |
| 28 | const mint = async (username: string, scope: string) => | |
| 29 | ( | |
| 30 | (await ( | |
| 31 | await post("/api/v1/tokens", { | |
| 32 | username, | |
| 33 | password: "a-good-passphrase", | |
| 34 | name: scope, | |
| 35 | scope, | |
| 36 | }) | |
| 37 | ).json()) as { token: string } | |
| 38 | ).token; | |
| 39 | const health = (token?: string) => | |
| 40 | fetch(servers.url + "/api/v1/health", { | |
| 41 | headers: token ? { authorization: `Bearer ${token}` } : {}, | |
| 42 | }); | |
| 43 | ||
| 44 | beforeAll(async () => { | |
| 45 | dataDir = await mkdtemp(join(tmpdir(), "forge-vitals-")); | |
| 46 | app = createApp({ dataDir, port: 0 }); | |
| 47 | servers = await serve(app); | |
| 48 | for (const username of ["alice", "bob"]) | |
| 49 | await post("/api/v1/users", { | |
| 50 | username, | |
| 51 | email: `${username}@forge.local`, | |
| 52 | password: "a-good-passphrase", | |
| 53 | }); | |
| 54 | admin = await mint("alice", "ro"); | |
| 55 | plain = await mint("bob", "admin"); | |
| 56 | repoId = ( | |
| 57 | app.db.query("SELECT id FROM repo").get() as { id: string } | null | |
| 58 | )?.id ?? ""; | |
| 59 | if (!repoId) { | |
| 60 | const owner = app.db.query("SELECT id FROM user LIMIT 1").get() as { | |
| 61 | id: string; | |
| 62 | }; | |
| 63 | repoId = "repo-vitals"; | |
| 64 | app.db | |
| 65 | .query("INSERT INTO repo (id, owner_id, name) VALUES (?1, ?2, 'r')") | |
| 66 | .run(repoId, owner.id); | |
| 67 | } | |
| 68 | }); | |
| 69 | ||
| 70 | afterAll(async () => { | |
| 71 | servers.stop(); | |
| 72 | app.close(); | |
| 73 | await rm(dataDir, { recursive: true, force: true }); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a site admin with a read-only token gets the shape; others get a 404", async () => { | |
| 77 | const res = await health(admin); | |
| 78 | expect(res.status).toBe(200); | |
| 79 | const v = (await res.json()) as Record<string, any>; | |
| 80 | expect(Object.keys(v).sort()).toEqual( | |
| 81 | ["apps", "at", "disk", "memory", "needs", "ok", "queue", "stuck"].sort(), | |
| 82 | ); | |
| 83 | expect(v.disk.totalBytes).toBeGreaterThan(0); | |
| 84 | expect(v.memory.rssBytes).toBeGreaterThan(0); | |
| 85 | expect(v.apps).toEqual({ serving: 0, asleep: 0, previews: 0 }); | |
| 86 | expect((await health(plain)).status).toBe(404); | |
| 87 | expect((await health()).status).toBe(404); | |
| 88 | expect((await fetch(servers.url + "/healthz")).status).toBe(200); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a full queue shows in queue, and an old one in needs", () => { | |
| 92 | const old = Date.now() - 3_600_000; | |
| 93 | for (let i = 0; i < 3; i++) createRun(app.db, { repoId, kind: "agent" }); | |
| 94 | const v = vitals({ db: app.db, dataDir }); | |
| 95 | expect(v.queue).toEqual({ agent: 3 }); | |
| 96 | expect(v.ok).toBe(true); | |
| 97 | const later = vitals({ db: app.db, dataDir, now: old + 2 * 3_600_000 + 3_600_000 }); | |
| 98 | expect(later.ok).toBe(false); | |
| 99 | expect(later.needs.join(" ")).toContain("agent queue"); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a running run with a lapsed lease is stuck, and needs says so", async () => { | |
| 103 | const run = createRun(app.db, { repoId, kind: "deploy", env: "production" }); | |
| 104 | app.db | |
| 105 | .query( | |
| 106 | "UPDATE run SET state='running', started_at=?2, heartbeat_at=?2 WHERE id=?1", | |
| 107 | ) | |
| 108 | .run(run.id, new Date(Date.now() - 10 * 60_000).toISOString()); | |
| 109 | const v = (await (await health(admin)).json()) as ReturnType<typeof vitals>; | |
| 110 | expect(v.stuck.map((s) => s.runId)).toEqual([run.id]); | |
| 111 | expect(v.stuck[0]!.repo).toBe("alice/r"); | |
| 112 | expect(v.ok).toBe(false); | |
| 113 | expect(v.needs.some((n) => n.includes("stuck"))).toBe(true); | |
| 114 | // A fresh heartbeat clears it. | |
| 115 | app.db | |
| 116 | .query("UPDATE run SET heartbeat_at=?2 WHERE id=?1") | |
| 117 | .run(run.id, new Date().toISOString()); | |
| 118 | expect(vitals({ db: app.db, dataDir }).stuck).toEqual([]); | |
| 119 | }); | |
| 120 | ||
| 121 | test("the number of queries does not grow with runs", () => { | |
| 122 | const count = () => { | |
| 123 | let n = 0; | |
| 124 | const counting = new Proxy(app.db, { | |
| 125 | get(t, k) { | |
| 126 | const v = Reflect.get(t, k, t); | |
| 127 | if (k === "query") return (...a: any[]) => (n++, (v as any).apply(t, a)); | |
| 128 | return typeof v === "function" ? v.bind(t) : v; | |
| 129 | }, | |
| 130 | }); | |
| 131 | vitals({ db: counting, dataDir }); | |
| 132 | return n; | |
| 133 | }; | |
| 134 | const before = count(); | |
| 135 | for (let i = 0; i < 40; i++) { | |
| 136 | const r = createRun(app.db, { repoId, kind: "check" }); | |
| 137 | if (i % 2) app.db.query("UPDATE run SET state='running' WHERE id=?1").run(r.id); | |
| 138 | } | |
| 139 | expect(count()).toBe(before); | |
| 140 | expect(before).toBe(2); | |
| 141 | }); |
new above
offline