Public: anyone can read this; the crew can change it.

The home count and the health answer count the same forge needs as the needs stack #12356

Merged opened this

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

Merged

scout/forge:scout/12269-the-home-count-and-the-health-answer-cou → main 10 commits fork

  • Merged. scout/12269-the-home-count-and-the-health-answer-cou was merged into main as 0050ff8.

Review

Sign in to review this change.
8 files changed +204 −34
docs/features/forge-needs.md +2
@@ -22,3 +22,5 @@ Decisions, left out of apply by name, each with its `options` and the question:
2222 In the terminal, `a` reports the same `done` rows, or the `refused` reason.
2323
2424 `forge {apply: "recommended"}` plans (dry by default); `dryRun: false` does the safe ones through the asks' bulk actions (`asks_bulk.ts`), so the per-row access check and the receipt are theirs. Undo with `asks_act {action: "undo", operation}`. The rest is returned untouched under `left`.
25
26 Each forge need is one of `forgeNeedList` (`src/actions/health.ts`): the rollout, each platform incident, the vitals' needs and the week's flakes, the very list `forgeNeeds`, the health answer and the board's forge strip print (#12269). One health read per request, and none for anyone not a site admin.
src/actions/health.ts +53 −12
@@ -16,7 +16,7 @@
1616 * One answer for the API (`GET /api/v1/health`) and the MCP `health` tool.
1717 */
1818
19 import { flakeLine, flakeList, flakeNeeds } from "../checks/flake.ts";
19 import { flakeLine, flakeList, flakeNeedLine, flakeNeedRows, type FlakeRow } from "../checks/flake.ts";
2020 import type { App } from "../app.ts";
2121 import { appsRuntime } from "../apps/runtime.ts";
2222 import { listApps } from "../core/apps.ts";
@@ -116,25 +116,63 @@ export function readHealth(
116116 export const DRAINING = "The forge is draining: this process finishes what it holds and takes no new work.";
117117
118118 /**
119 * The forge's causes for concern as one sentence each, for a site admin: the
120 * rollout, each platform incident, then the vitals' needs, in the order the
121 * board's forge strip prints them. An empty list is "All fine"; `undefined`
122 * is anyone else, who is told nothing and costs no reads.
119 * One thing the forge itself needs, as the board's forge strip, the health answer and the
120 * home's stack word it.
123121 */
124 export function forgeNeeds(app: App, actor: User | undefined): string[] | undefined {
122 export interface ForgeNeed {
123 /**
124 * What it is about: "rollout", an incident's key, a vitals cause, "flake:<repo>:<test>",
125 * "ledger".
126 */
127 readonly cause: string;
128 /** The sentence, as `forgeNeeds` and the health answer give it. */
129 readonly line: string;
130 /** Where to look into it. */
131 readonly href: string;
132 }
133
134 const HEALTH_HREF = "/api/v1/health";
135
136 /**
137 * The forge's causes for concern, for a site admin: the rollout, each
138 * platform incident, then the vitals' needs, the week's flakes and a broken ledger chain, in the
139 * order the board's forge strip prints them. The one list the home count,
140 * `/asks`, `GET /api/v1/asks`, the health answer and the strip all read: one
141 * health read. An empty list is "All fine"; `undefined` is anyone else, who
142 * is told nothing and costs no reads.
143 */
144 export function forgeNeedList(app: App, actor: User | undefined): ForgeNeed[] | undefined {
125145 if (!actor?.isAdmin) return undefined;
126146 const h = readHealth(app, actor) as Vitals & ForgeHealth;
127147 const rollout = rolloutLine(h);
128148 const ledger = ledgerNeed();
129149 return [
130 ...(rollout === null ? [] : [rollout]),
131 ...h.incidents.map((i) => `${i.key}: ${i.message}`),
132 ...h.degraded.map(needLine),
133 ...flakeNeeds(h.flakes),
134 ...(ledger === null ? [] : [ledger]),
150 ...(rollout === null ? [] : [{ cause: "rollout", line: rollout, href: HEALTH_HREF }]),
151 ...h.incidents.map((i) => ({ cause: `incident:${i.key}`, line: `${i.key}: ${i.message}`, href: HEALTH_HREF })),
152 ...h.degraded.map((d) => ({ cause: d.cause, line: needLine(d), href: d.href })),
153 ...flakeForgeNeeds(h.flakes),
154 ...(ledger === null ? [] : [{ cause: "ledger", line: ledger, href: HEALTH_HREF }]),
135155 ];
136156 }
137157
158 /**
159 * The week's flakes that need a person, each keyed on its repo and test, so a
160 * need keeps its id (`forge:flake:<repo>:<test>` in the stack) when the week's
161 * flakes reorder.
162 */
163 export function flakeForgeNeeds(rows: readonly FlakeRow[]): ForgeNeed[] {
164 return flakeNeedRows(rows).map((r) => ({
165 cause: `flake:${r.repo}:${r.test}`,
166 line: flakeNeedLine(r),
167 href: HEALTH_HREF,
168 }));
169 }
170
171 /** `forgeNeedList` as its sentences: what the health answer and the forge strip print. */
172 export function forgeNeeds(app: App, actor: User | undefined): string[] | undefined {
173 return forgeNeedList(app, actor)?.map((n) => n.line);
174 }
175
138176 export { sinceWords };
139177
140178 /** A cause as one line: its sentence and when it began (a stuck run's says so already). */
@@ -144,7 +182,10 @@ export function needLine(d: Degraded, now: number = Date.now()): string {
144182 : `${d.sentence} Since ${sinceWords(d.since, now)}.`;
145183 }
146184
147 /** The week's flaky tests as one line for a site admin; `null` for anyone else, and when none flaked. */
185 /**
186 * The week's flaky tests as one line for a site admin; `null` for anyone else, and when none
187 * flaked.
188 */
148189 export function forgeFlakes(app: App, actor: User | undefined): string | null {
149190 return actor?.isAdmin === true ? flakeLine(flakeList(app.db, Date.now())) : null;
150191 }
src/actions/needs.ts +3 −4
@@ -9,7 +9,6 @@
99 import type { App } from "../app.ts";
1010 import { applyAsksBulk, receiptOf } from "./asks_bulk.ts";
1111 import { askStackFor, type askLinesFor } from "../web/studio/asks.ts";
12 import { needLine } from "./health.ts";
1312 import { drillNow } from "./backups_now.ts";
1413 import { builderOf, giveBack, handBack } from "./give_back.ts";
1514 import { getIssueById } from "../core/issues.ts";
@@ -150,11 +149,11 @@ export function needsStack(ctx: Home, user: User, chips?: Chips): Need[] {
150149 const forge = stack.forge.map(
151150 (d, i): Need => ({
152151 id: `forge:${d.cause || i}`,
153 sentence: needLine(d),
154 why: "the forge itself is degraded",
152 sentence: d.line,
153 why: "the forge itself needs looking at",
155154 waits: "it stays that way until someone looks",
156155 choices: ["Open it"],
157 recommended: forgeRecommended(d.cause, d.sentence),
156 recommended: forgeRecommended(d.cause, d.line),
158157 link: d.href,
159158 items: [],
160159 }),
src/checks/flake.ts +11 −3
@@ -374,9 +374,17 @@ export const NEEDS_AT = 3;
374374
375375 /** The tests that flaked {@link NEEDS_AT} times or more, one need each for the attention list. */
376376 export function flakeNeeds(rows: readonly FlakeRow[]): string[] {
377 return rows
378 .filter((r) => r.count >= NEEDS_AT)
379 .map((r) => `flaky: ${r.test} failed and passed alone ${r.count} times this week (${r.repo})`);
377 return flakeNeedRows(rows).map(flakeNeedLine);
378 }
379
380 /** The rows {@link flakeNeeds} words: the tests that flaked {@link NEEDS_AT} times or more. */
381 export function flakeNeedRows(rows: readonly FlakeRow[]): FlakeRow[] {
382 return rows.filter((r) => r.count >= NEEDS_AT);
383 }
384
385 /** One flake need's sentence. */
386 export function flakeNeedLine(r: FlakeRow): string {
387 return `flaky: ${r.test} failed and passed alone ${r.count} times this week (${r.repo})`;
380388 }
381389
382390 /** The flake list as one line for the board and the terminal; null when the week had none. */
src/web/studio/asks.ts +10 −10
@@ -9,9 +9,8 @@
99
1010 import type { Database } from "bun:sqlite";
1111 import { crewHandsFor } from "../../actions/give_back.ts";
12 import { forgeVitals, needLine } from "../../actions/health.ts";
12 import { forgeNeedList, type ForgeNeed } from "../../actions/health.ts";
1313 import { reviewSet } from "./review_set.ts";
14 import type { Degraded } from "../../kernel/vitals.ts";
1514 import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts";
1615 import { crewForRepos } from "../../core/agents.ts";
1716 import { appsForRepos } from "../../core/apps.ts";
@@ -1181,13 +1180,13 @@ export function askLinesFor(
11811180 }
11821181
11831182 /**
1184 * What the forge itself needs, for a site admin: each sentence of the
1185 * health answer (`/api/v1/health`) as one ask. Anyone else gets none — the
1186 * forge's standing is the admin's to read and nobody else's to know about.
1183 * What the forge itself needs, for a site admin: `forgeNeedList`, the very
1184 * list the health answer and the board's forge strip print, each as one ask.
1185 * Anyone else gets none, and costs no health read — the forge's standing is
1186 * the admin's to read and nobody else's to know about.
11871187 */
1188 export function forgeNeedsFor(ctx: Pick<Ctx, "app">, user: User | undefined): Degraded[] {
1189 if (!user?.isAdmin) return [];
1190 return forgeVitals(ctx.app).degraded;
1188 export function forgeNeedsFor(ctx: Pick<Ctx, "app">, user: User | undefined): ForgeNeed[] {
1189 return forgeNeedList(ctx.app, user) ?? [];
11911190 }
11921191
11931192 /**
@@ -1230,10 +1229,11 @@ export function asksAnswer(ctx: Ctx, user: User, chips?: Chips) {
12301229 const stack = askStackFor(ctx, user, chips, (n) => (hidden = n));
12311230 const rows = stack.rows;
12321231 return {
1233 // The very figures Home's line and /review show: `count` is live asks plus the forge's needs; `stale` are older than 30 days.
1232 // The very figures /review shows: `count` is the live asks (the forge's needs are
1233 // `forge`); `stale` are older than 30 days.
12341234 waiting: { count: set.count, stale: set.stale.length },
12351235 rows,
1236 forge: stack.forge.map((d) => needLine(d)),
1236 forge: stack.forge.map((n) => n.line),
12371237 measure: { days: ASKS_WINDOW_DAYS, ...askMeasureWeek(ctx.app.db, user.id) },
12381238 ...(hasAskFilters(askFiltersOf(ctx.app.db, user.id)) ? { filters: { hidden, line: hiddenLine(hidden) } } : {}),
12391239 };
src/web/studio/asks_page.ts +3 −4
@@ -28,8 +28,7 @@ import type { FleetValue } from "../../core/fleet_ops.ts";
2828 import { muteRepo, removeMutes } from "../../core/interactions.ts";
2929 import { applyAskFilter, isAskFilterDo, type AskFilterChange } from "../../actions/ask_filter.ts";
3030 import { askFiltersOf, hasAskFilters, hiddenLine, HIDE_APP_DAY_WORDS, HIDE_KIND_WORDS, isAskKind, SHOW_ALL_WORDS } from "../../core/ask_filters.ts";
31 import { needLine } from "../../actions/health.ts";
32 import type { Degraded } from "../../kernel/vitals.ts";
31 import type { ForgeNeed } from "../../actions/health.ts";
3332 import type { Ctx } from "../../http/context.ts";
3433 import type { User } from "../../core/users.ts";
3534 import { onYouAnswer, onYouRegion } from "./home.ts";
@@ -407,10 +406,10 @@ function rowCard(ctx: Ctx, r: AskRow<Decision>, picked: ReadonlySet<string>): Ht
407406 }
408407
409408 /** The forge is degraded: an alert line, marked in the danger tone, under its head; Health on its own row so it never wraps into the sentence. */
410 export const forgeCard = (d: Degraded): Html =>
409 export const forgeCard = (d: ForgeNeed): Html =>
411410 html`<section class="stack">
412411 <h2>The forge</h2>
413 <p role="alert"><span class="dot" data-tone="danger" aria-hidden="true"></span> <a href="${d.href}">${needLine(d)}</a></p>
412 <p role="alert"><span class="dot" data-tone="danger" aria-hidden="true"></span> <a href="${d.href}">${d.line}</a></p>
414413 <div class="bar">${btn("Health", { href: "/api/v1/health", size: "sm" })}</div>
415414 </section>`;
416415
test/asks-forge-alert.test.ts +1 −1
@@ -3,7 +3,7 @@ import { forgeCard } from "../src/web/studio/asks_page.ts";
33
44 // Principle 7 took the card away; the line must still read as an alert.
55 test("a degraded forge on /asks is an alert line in the danger tone, with Health on its own row", () => {
6 const out = String(forgeCard({ cause: "errors", sentence: "Errors <now>.", since: "2020-01-01T00:00:00.000Z", href: "/x" }));
6 const out = String(forgeCard({ cause: "errors", line: "Errors <now>. Since 2 days ago.", href: "/x" }));
77 const line = /<p role="alert">([\s\S]*?)<\/p>/.exec(out);
88 expect(line).not.toBeNull();
99 expect(line![1]).toContain('class="dot" data-tone="danger"');
test/forge-needs-one-list.test.ts added +121
@@ -0,0 +1,121 @@
1 /**
2 * The home count, `/asks`, `GET /api/v1/asks` and the health answer count the
3 * same forge needs (#12269): a rollout held and a platform incident are needs
4 * on every one of them, not only on the board's forge strip.
5 */
6 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
7 import { mkdtemp, rm, writeFile } 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 * as healthModule from "../src/actions/health.ts";
12 import { flakeForgeNeeds, forgeNeeds } from "../src/actions/health.ts";
13 import type { FlakeRow } from "../src/checks/flake.ts";
14 import { needsStack } from "../src/actions/needs.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import type { Ctx } from "../src/http/context.ts";
17 import { askCountFor, asksAnswer, forgeNeedsFor } from "../src/web/studio/asks.ts";
18 import { needsYouSection } from "../src/web/studio/asks_page.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 let dataDir = "";
24 let guardDir = "";
25 let app: App;
26 let admin: User;
27 let member: User;
28 const before = process.env["FORGE_GUARD_DIR"];
29
30 const ctx = (user: User): Ctx => ({
31 app,
32 request: new Request("http://x/"),
33 url: new URL("http://x/"),
34 params: {},
35 auth: { user, scope: "rw", via: "session" },
36 theme: undefined,
37 });
38
39 beforeAll(async () => {
40 dataDir = await mkdtemp(join(tmpdir(), "forge-needs-list-"));
41 guardDir = await mkdtemp(join(tmpdir(), "forge-guard-"));
42 await writeFile(
43 join(guardDir, "status.json"),
44 JSON.stringify({ incidents: { held: "waiting on the canary", "db:replica": "the replica lags" } }),
45 );
46 process.env["FORGE_GUARD_DIR"] = guardDir;
47 app = createApp({ dataDir, port: 0 });
48 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-list-passphrase", adminIfFirst: true });
49 if (!a.ok) throw new Error(a.error);
50 admin = a.value;
51 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-list-passphrase" });
52 if (!b.ok) throw new Error(b.error);
53 member = b.value;
54 });
55
56 afterAll(async () => {
57 if (before === undefined) delete process.env["FORGE_GUARD_DIR"];
58 else process.env["FORGE_GUARD_DIR"] = before;
59 app.db.close();
60 await rm(dataDir, { recursive: true, force: true });
61 await rm(guardDir, { recursive: true, force: true });
62 });
63
64 test("a held rollout and an incident are needs on the home count, /asks, the asks answer and the health answer alike", () => {
65 const health = forgeNeeds(app, admin)!;
66 expect(health).toContain("rollout held: waiting on the canary");
67 expect(health).toContain("db:replica: the replica lags");
68
69 const answer = asksAnswer(ctx(admin), admin);
70 expect(answer.forge).toEqual(health);
71 expect(forgeNeedsFor(ctx(admin), admin).map((n) => n.line)).toEqual(health);
72 expect(askCountFor(ctx(admin), admin)).toBe(health.length + answer.rows.length);
73 expect(needsStack(ctx(admin), admin).filter((n) => n.id.startsWith("forge:")).map((n) => n.sentence)).toEqual(health);
74
75 const home = needsYouSection(ctx(admin), admin).body.text;
76 expect(home).toContain("rollout held: waiting on the canary");
77 expect(home).toContain("db:replica: the replica lags");
78 });
79
80 test("someone not a site admin is told no forge needs", () => {
81 expect(forgeNeeds(app, member)).toBeUndefined();
82 expect(forgeNeedsFor(ctx(member), member)).toEqual([]);
83 expect(asksAnswer(ctx(member), member).forge).toEqual([]);
84 expect(askCountFor(ctx(member), member)).toBe(asksAnswer(ctx(member), member).rows.length);
85 });
86
87 /** Runs `read` and says how many health reads it cost. */
88 function readsOf(read: () => unknown): number {
89 const spy = spyOn(healthModule, "readHealth");
90 try {
91 read();
92 return spy.mock.calls.length;
93 } finally {
94 spy.mockRestore();
95 }
96 }
97
98 test("someone not a site admin costs no health read, on any surface", () => {
99 expect(readsOf(() => forgeNeeds(app, member))).toBe(0);
100 expect(readsOf(() => forgeNeedsFor(ctx(member), member))).toBe(0);
101 expect(readsOf(() => asksAnswer(ctx(member), member))).toBe(0);
102 expect(readsOf(() => askCountFor(ctx(member), member))).toBe(0);
103 expect(readsOf(() => needsStack(ctx(member), member))).toBe(0);
104 });
105
106 test("a site admin's request costs exactly one health read", () => {
107 expect(readsOf(() => forgeNeeds(app, admin))).toBe(1);
108 expect(readsOf(() => forgeNeedsFor(ctx(admin), admin))).toBe(1);
109 expect(readsOf(() => asksAnswer(ctx(admin), admin))).toBe(1);
110 expect(readsOf(() => askCountFor(ctx(admin), admin))).toBe(1);
111 expect(readsOf(() => needsStack(ctx(admin), admin))).toBe(1);
112 });
113
114 test("a flake need is keyed on its repo and test, so reordering the week keeps its id", () => {
115 const row = (test: string, count: number): FlakeRow => ({ repo: "alice/app", test, file: "t.test.ts", count, lastCommit: "abc", last: "now" });
116 const week = [row("saves a note", 5), row("too rare", 1), row("lists notes", 3)];
117 const causes = flakeForgeNeeds(week).map((n) => n.cause);
118 expect(causes).toEqual(["flake:alice/app:saves a note", "flake:alice/app:lists notes"]);
119 const byLine = (rows: FlakeRow[]) => Object.fromEntries(flakeForgeNeeds(rows).map((n) => [n.line, n.cause]));
120 expect(byLine([...week].reverse())).toEqual(byLine(week));
121 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs