Public: anyone can read this; the crew can change it.
/api/v1/telemetry/home names the commit production runs apart from main's head #11679
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11676-api-v1-telemetry-home-names-the-commit-p → main 1 commit fork
5 files
changed +117
−6
docs/HOME-EVAL.md +1 −1
| @@ -17,7 +17,7 @@ The five facts, and where the truth comes from: | ||
| 17 | 17 | |
| 18 | 18 | | Fact | Truth | `stated` when the page says | `wrong` when | |
| 19 | 19 | | --- | --- | --- | --- | |
| 20 | | landed and released | `GET /api/v1/telemetry/home`: landings in 24 h, the live version | `N changes in 24 h` and the live short sha | N differs | | |
| 20 | | landed and released | `GET /api/v1/telemetry/home`: landings in 24 h, the live version (the running commit; `main` and `behind` beside it) | `N changes in 24 h` and the live short sha | N differs | | |
| 21 | 21 | | waiting on the owner | the same: the home's own ask list | `N waiting on you` (or nothing at zero) | N differs, or a line at zero | |
| 22 | 22 | | builders idle and why | the same: running of slots, and the reason (ceiling reached, no slots, no ready work) | the right `N of M` and the reason | `N of M` differs | |
| 23 | 23 | | spend against the forge cap | the same: spent today and the daily ceiling | `$X of $Y` | X or Y differs, or `no ceiling` while one is set | |
src/contract/openapi/routes/getApiV1TelemetryHome.ts +9 −1
| @@ -1,3 +1,11 @@ | ||
| 1 | 1 | import type { Route } from "../shared.ts"; |
| 2 | 2 | |
| 3 | export const route: Route = { method: "get", path: "/api/v1/telemetry/home", summary: "Read the figures the home is scored against: landed in 24 h, live version, builders and why idle, spend against the ceiling, what waits on the caller; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true, role: "member" }; | |
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/telemetry/home", | |
| 6 | summary: | |
| 7 | "Read the figures the home is scored against: landed in 24 h; live {sha, version}, the commit this process runs (null when unstamped); main {sha}, the head of the forge's own repository's default branch, and behind {commits, pulls}, what main holds that live does not (0 and 0 when they match; both null until the forge has read its own main); builders and why idle, spend against the ceiling, what waits on the caller; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", | |
| 8 | auth: true, | |
| 9 | role: "member", | |
| 10 | reply: { landed: "integer", live: "object", main: "object", behind: "object", builders: "object", spend: "object", waiting: "object" }, | |
| 11 | }; |
src/release/landings.ts +40 −3
| @@ -18,6 +18,7 @@ import { getRepo, type Repo } from "../core/repos.ts"; | ||
| 18 | 18 | import { buildersOf, type Builders } from "../git/coauthors.ts"; |
| 19 | 19 | import { runGit } from "../git/env.ts"; |
| 20 | 20 | import { repoDir } from "../git/repos.ts"; |
| 21 | import { readTip } from "../git/tip.ts"; | |
| 21 | 22 | import { running } from "../kernel/version.ts"; |
| 22 | 23 | |
| 23 | 24 | export interface Landing { |
| @@ -92,12 +93,16 @@ interface LiveState { | ||
| 92 | 93 | readonly at: number; |
| 93 | 94 | /** The running commit these were asked of. */ |
| 94 | 95 | readonly commit: string; |
| 96 | /** The tip of the forge's own default branch as the last read found it, read from the ref files. */ | |
| 97 | readonly head?: string; | |
| 98 | /** Commits in `head` and not in `commit` (`git rev-list --count commit..head`), asked once per pair. */ | |
| 99 | readonly ahead?: number; | |
| 95 | 100 | } |
| 96 | 101 | let kept: LiveState | undefined; |
| 97 | 102 | |
| 98 | 103 | /** Keep what the running commit contains; a test says it directly. */ |
| 99 | export function keepLive(own: string, oids: Iterable<string>, at: number): void { | |
| 100 | kept = { own, oids: new Set(oids), misses: new Set(), at, commit: "" }; | |
| 104 | export function keepLive(own: string, oids: Iterable<string>, at: number, gap?: { commit: string; head: string; ahead: number }): void { | |
| 105 | kept = { own, oids: new Set(oids), misses: new Set(), at, commit: gap?.commit ?? "", ...(gap === undefined ? {} : { head: gap.head, ahead: gap.ahead }) }; | |
| 101 | 106 | } |
| 102 | 107 | |
| 103 | 108 | /** Forget it, so the alarm is silent until the next read. */ |
| @@ -210,7 +215,39 @@ async function askLive(app: App, rows: readonly MergedRow[]): Promise<void> { | ||
| 210 | 215 | // Misses are kept only for the merged rows still listed. |
| 211 | 216 | const listed = new Set(rows.map((r) => r.oid)); |
| 212 | 217 | for (const o of misses) if (!listed.has(o)) misses.delete(o); |
| 213 | kept = { own: named, oids, misses, at, commit }; | |
| 218 | // How far main runs ahead of the running commit (#11676): the tip from the ref | |
| 219 | // files, the count asked of git only when the pair changed since the last read. | |
| 220 | const tip = await readTip(dir, `refs/heads/${repo.defaultBranch}`); | |
| 221 | let ahead: number | undefined; | |
| 222 | if (tip === commit) ahead = 0; | |
| 223 | else if (tip !== undefined && before?.head === tip && before.ahead !== undefined) ahead = before.ahead; | |
| 224 | else if (tip !== undefined) { | |
| 225 | const n = await runGit(["rev-list", "--count", `${commit}..${tip}`], { cwd: dir }); | |
| 226 | const v = n.ok ? Number(n.value.trim()) : NaN; | |
| 227 | if (Number.isSafeInteger(v) && v >= 0) ahead = v; | |
| 228 | } | |
| 229 | kept = { own: named, oids, misses, at, commit, ...(tip === undefined ? {} : { head: tip }), ...(ahead === undefined ? {} : { ahead }) }; | |
| 230 | } | |
| 231 | ||
| 232 | export interface MainGap { | |
| 233 | /** The head of the forge's own default branch. */ | |
| 234 | readonly main: { readonly sha: string }; | |
| 235 | /** What main holds that the running commit does not: commits, and merged pulls on their way. */ | |
| 236 | readonly behind: { readonly commits: number; readonly pulls: number }; | |
| 237 | } | |
| 238 | ||
| 239 | /** | |
| 240 | * How far production stands behind main, from what the last read kept: no | |
| 241 | * git, one query. Undefined until a read found the forge's own main. | |
| 242 | */ | |
| 243 | export function mainGap(db: Database): MainGap | undefined { | |
| 244 | const k = kept; | |
| 245 | if (k === undefined || k.head === undefined) return undefined; | |
| 246 | if (k.head === k.commit) return { main: { sha: k.head }, behind: { commits: 0, pulls: 0 } }; | |
| 247 | const own = k.own.toLowerCase(); | |
| 248 | const pulls = split(mergedRows(db), k).onItsWay.filter((l) => l.repo.toLowerCase() === own).length; | |
| 249 | // A count not yet asked for this pair is at least the pulls on their way. | |
| 250 | return { main: { sha: k.head }, behind: { commits: k.ahead ?? pulls, pulls } }; | |
| 214 | 251 | } |
| 215 | 252 | |
| 216 | 253 | /** Who built the forge's own landed commits, by `repo#number`. */ |
src/system/home_truth.ts +8 −1
| @@ -10,6 +10,8 @@ import { listReposVisibleTo } from "../core/repos.ts"; | ||
| 10 | 10 | import type { User } from "../core/users.ts"; |
| 11 | 11 | import { forgeCeiling } from "../flow/ceiling.ts"; |
| 12 | 12 | import { readKpis } from "../flow/kpis.ts"; |
| 13 | import { running } from "../kernel/version.ts"; | |
| 14 | import { mainGap } from "../release/landings.ts"; | |
| 13 | 15 | import { waitingOn } from "../web/studio/asks.ts"; |
| 14 | 16 | |
| 15 | 17 | /** Why builders are not all busy, in words; null when every slot runs. */ |
| @@ -24,9 +26,14 @@ export function homeTruth(app: App, user: User, now: Date = new Date()) { | ||
| 24 | 26 | const k = readKpis(app.db, now, { ceilingUsd: forgeCeiling(app.config) }); |
| 25 | 27 | const asks = waitingOn(app.db, user, listReposVisibleTo(app.db, user, { limit: 200, withoutArchived: true })); |
| 26 | 28 | const oldest = asks.reduce<string | null>((o, a) => (o === null || Date.parse(a.since) < Date.parse(o) ? a.since : o), null); |
| 29 | // What runs is the process's own stamp, never main's head (#11676); main and the gap beside it. | |
| 30 | const r = running(); | |
| 31 | const gap = mainGap(app.db); | |
| 27 | 32 | return { |
| 28 | 33 | landed: k.landed, |
| 29 | live: k.live, | |
| 34 | live: r.commit === null ? null : { sha: r.commit, version: r.version }, | |
| 35 | main: gap?.main ?? null, | |
| 36 | behind: gap?.behind ?? null, | |
| 30 | 37 | builders: { ...k.builders, idle: idleReason(k.builders, k.spend) }, |
| 31 | 38 | spend: k.spend, |
| 32 | 39 | waiting: { count: asks.length, oldestAt: oldest }, |
test/home-truth.test.ts +59
| @@ -7,6 +7,8 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 7 | 7 | import { createRepo, moveArchived } from "../src/core/repos.ts"; |
| 8 | 8 | import { createUser, type User } from "../src/core/users.ts"; |
| 9 | 9 | import { id } from "../src/kernel/id.ts"; |
| 10 | import { repoDir } from "../src/git/repos.ts"; | |
| 11 | import { forgetLive, readLandings } from "../src/release/landings.ts"; | |
| 10 | 12 | import { homeTruth, idleReason } from "../src/system/home_truth.ts"; |
| 11 | 13 | |
| 12 | 14 | test("idleReason says nothing when every slot runs, even over the ceiling", () => { |
| @@ -81,3 +83,60 @@ test("homeTruth carries the idle reason beside the builders", () => { | ||
| 81 | 83 | const t = homeTruth(app, owner, now); |
| 82 | 84 | expect(t.builders.idle).toBe(idleReason(t.builders, t.spend)); |
| 83 | 85 | }); |
| 86 | ||
| 87 | // #11676: live is the commit this process runs, beside main's head and the gap between them. | |
| 88 | test("homeTruth names the running commit apart from main's head, and how far behind it stands", async () => { | |
| 89 | const saved = { commit: process.env.FORGE_COMMIT, version: process.env.FORGE_VERSION, repo: app.config.forgeRepo }; | |
| 90 | try { | |
| 91 | const made = await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "forge", visibility: "public", description: "forge" }); | |
| 92 | if (!made.ok) throw new Error(made.error); | |
| 93 | const repo = made.value; | |
| 94 | const dir = repoDir(app.reposRoot, owner.username, "forge"); | |
| 95 | const git = (...args: string[]) => | |
| 96 | Bun.spawnSync(["git", ...args], { | |
| 97 | cwd: dir, | |
| 98 | env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }, | |
| 99 | }).stdout.toString().trim(); | |
| 100 | const TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; | |
| 101 | const a = git("commit-tree", TREE, "-m", "a"); | |
| 102 | const b = git("commit-tree", TREE, "-p", a, "-m", "b"); | |
| 103 | const c = git("commit-tree", TREE, "-p", b, "-m", "c"); | |
| 104 | git("update-ref", `refs/heads/${repo.defaultBranch}`, c); | |
| 105 | let n = 0; | |
| 106 | const merge = (oid: string, minutesAgo: number) => { | |
| 107 | const at = new Date(Date.now() - minutesAgo * 60_000).toISOString(); | |
| 108 | const number = ++n; | |
| 109 | const issueId = id(); | |
| 110 | app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at, updated_at) VALUES (?1, ?2, ?3, 'pull', 'P', '', ?4, 'closed', ?5, ?5)`).run(issueId, repo.id, number, owner.id, at); | |
| 111 | app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at, merged_oid) VALUES (?1, ?2, ?3, '', '', ?4, ?5, ?6)`).run(issueId, repo.defaultBranch, `b/${number}`, repo.id, at, oid); | |
| 112 | }; | |
| 113 | merge(a, 30); | |
| 114 | merge(b, 20); | |
| 115 | merge(c, 10); | |
| 116 | (app.config as { forgeRepo?: string }).forgeRepo = `${owner.username}/forge`; | |
| 117 | process.env.FORGE_VERSION = "1.2.3"; | |
| 118 | ||
| 119 | process.env.FORGE_COMMIT = a; | |
| 120 | forgetLive(); | |
| 121 | await readLandings(app); | |
| 122 | const behind = homeTruth(app, owner, now); | |
| 123 | expect(behind.live).toEqual({ sha: a, version: "1.2.3" }); | |
| 124 | expect(behind.main).toEqual({ sha: c }); | |
| 125 | expect(behind.behind).toEqual({ commits: 2, pulls: 2 }); | |
| 126 | ||
| 127 | process.env.FORGE_COMMIT = c; | |
| 128 | await readLandings(app); | |
| 129 | const even = homeTruth(app, owner, now); | |
| 130 | expect(even.live).toEqual({ sha: c, version: "1.2.3" }); | |
| 131 | expect(even.main).toEqual({ sha: c }); | |
| 132 | expect(even.behind).toEqual({ commits: 0, pulls: 0 }); | |
| 133 | } finally { | |
| 134 | for (const [k, v] of [["FORGE_COMMIT", saved.commit], ["FORGE_VERSION", saved.version]] as const) { | |
| 135 | if (v === undefined) delete process.env[k]; | |
| 136 | else process.env[k] = v; | |
| 137 | } | |
| 138 | if (saved.repo === undefined) delete (app.config as { forgeRepo?: string }).forgeRepo; | |
| 139 | else (app.config as { forgeRepo?: string }).forgeRepo = saved.repo; | |
| 140 | forgetLive(); | |
| 141 | } | |
| 142 | }); |
new above
offline