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

Merged opened this

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

Merged

torque/forge:torque/11676-api-v1-telemetry-home-names-the-commit-p → main 1 commit fork

  • Merged. torque/11676-api-v1-telemetry-home-names-the-commit-p was merged into main as 80b7ed3.

Review

Sign in to review this change.
5 files changed +117 −6
docs/HOME-EVAL.md +1 −1
@@ -17,7 +17,7 @@ The five facts, and where the truth comes from:
1717
1818 | Fact | Truth | `stated` when the page says | `wrong` when |
1919 | --- | --- | --- | --- |
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 |
2121 | 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 |
2222 | 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 |
2323 | 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 @@
11 import type { Route } from "../shared.ts";
22
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";
1818 import { buildersOf, type Builders } from "../git/coauthors.ts";
1919 import { runGit } from "../git/env.ts";
2020 import { repoDir } from "../git/repos.ts";
21 import { readTip } from "../git/tip.ts";
2122 import { running } from "../kernel/version.ts";
2223
2324 export interface Landing {
@@ -92,12 +93,16 @@ interface LiveState {
9293 readonly at: number;
9394 /** The running commit these were asked of. */
9495 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;
95100 }
96101 let kept: LiveState | undefined;
97102
98103 /** 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 }) };
101106 }
102107
103108 /** 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> {
210215 // Misses are kept only for the merged rows still listed.
211216 const listed = new Set(rows.map((r) => r.oid));
212217 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 } };
214251 }
215252
216253 /** 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";
1010 import type { User } from "../core/users.ts";
1111 import { forgeCeiling } from "../flow/ceiling.ts";
1212 import { readKpis } from "../flow/kpis.ts";
13 import { running } from "../kernel/version.ts";
14 import { mainGap } from "../release/landings.ts";
1315 import { waitingOn } from "../web/studio/asks.ts";
1416
1517 /** 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()) {
2426 const k = readKpis(app.db, now, { ceilingUsd: forgeCeiling(app.config) });
2527 const asks = waitingOn(app.db, user, listReposVisibleTo(app.db, user, { limit: 200, withoutArchived: true }));
2628 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);
2732 return {
2833 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,
3037 builders: { ...k.builders, idle: idleReason(k.builders, k.spend) },
3138 spend: k.spend,
3239 waiting: { count: asks.length, oldestAt: oldest },
test/home-truth.test.ts +59
@@ -7,6 +7,8 @@ import { createApp, type App } from "../src/app.ts";
77 import { createRepo, moveArchived } from "../src/core/repos.ts";
88 import { createUser, type User } from "../src/core/users.ts";
99 import { id } from "../src/kernel/id.ts";
10 import { repoDir } from "../src/git/repos.ts";
11 import { forgetLive, readLandings } from "../src/release/landings.ts";
1012 import { homeTruth, idleReason } from "../src/system/home_truth.ts";
1113
1214 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", () => {
8183 const t = homeTruth(app, owner, now);
8284 expect(t.builders.idle).toBe(idleReason(t.builders, t.spend));
8385 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs