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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12269-the-home-count-and-the-health-answer-cou → main 10 commits fork
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: | ||
| 22 | 22 | In the terminal, `a` reports the same `done` rows, or the `refused` reason. |
| 23 | 23 | |
| 24 | 24 | `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 @@ | ||
| 16 | 16 | * One answer for the API (`GET /api/v1/health`) and the MCP `health` tool. |
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | import { flakeLine, flakeList, flakeNeeds } from "../checks/flake.ts"; | |
| 19 | import { flakeLine, flakeList, flakeNeedLine, flakeNeedRows, type FlakeRow } from "../checks/flake.ts"; | |
| 20 | 20 | import type { App } from "../app.ts"; |
| 21 | 21 | import { appsRuntime } from "../apps/runtime.ts"; |
| 22 | 22 | import { listApps } from "../core/apps.ts"; |
| @@ -116,25 +116,63 @@ export function readHealth( | ||
| 116 | 116 | export const DRAINING = "The forge is draining: this process finishes what it holds and takes no new work."; |
| 117 | 117 | |
| 118 | 118 | /** |
| 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. | |
| 123 | 121 | */ |
| 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 { | |
| 125 | 145 | if (!actor?.isAdmin) return undefined; |
| 126 | 146 | const h = readHealth(app, actor) as Vitals & ForgeHealth; |
| 127 | 147 | const rollout = rolloutLine(h); |
| 128 | 148 | const ledger = ledgerNeed(); |
| 129 | 149 | 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 }]), | |
| 135 | 155 | ]; |
| 136 | 156 | } |
| 137 | 157 | |
| 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 | ||
| 138 | 176 | export { sinceWords }; |
| 139 | 177 | |
| 140 | 178 | /** 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 { | ||
| 144 | 182 | : `${d.sentence} Since ${sinceWords(d.since, now)}.`; |
| 145 | 183 | } |
| 146 | 184 | |
| 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 | */ | |
| 148 | 189 | export function forgeFlakes(app: App, actor: User | undefined): string | null { |
| 149 | 190 | return actor?.isAdmin === true ? flakeLine(flakeList(app.db, Date.now())) : null; |
| 150 | 191 | } |
src/actions/needs.ts +3 −4
| @@ -9,7 +9,6 @@ | ||
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | 10 | import { applyAsksBulk, receiptOf } from "./asks_bulk.ts"; |
| 11 | 11 | import { askStackFor, type askLinesFor } from "../web/studio/asks.ts"; |
| 12 | import { needLine } from "./health.ts"; | |
| 13 | 12 | import { drillNow } from "./backups_now.ts"; |
| 14 | 13 | import { builderOf, giveBack, handBack } from "./give_back.ts"; |
| 15 | 14 | import { getIssueById } from "../core/issues.ts"; |
| @@ -150,11 +149,11 @@ export function needsStack(ctx: Home, user: User, chips?: Chips): Need[] { | ||
| 150 | 149 | const forge = stack.forge.map( |
| 151 | 150 | (d, i): Need => ({ |
| 152 | 151 | 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", | |
| 155 | 154 | waits: "it stays that way until someone looks", |
| 156 | 155 | choices: ["Open it"], |
| 157 | recommended: forgeRecommended(d.cause, d.sentence), | |
| 156 | recommended: forgeRecommended(d.cause, d.line), | |
| 158 | 157 | link: d.href, |
| 159 | 158 | items: [], |
| 160 | 159 | }), |
src/checks/flake.ts +11 −3
| @@ -374,9 +374,17 @@ export const NEEDS_AT = 3; | ||
| 374 | 374 | |
| 375 | 375 | /** The tests that flaked {@link NEEDS_AT} times or more, one need each for the attention list. */ |
| 376 | 376 | 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})`; | |
| 380 | 388 | } |
| 381 | 389 | |
| 382 | 390 | /** 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 @@ | ||
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | 11 | import { crewHandsFor } from "../../actions/give_back.ts"; |
| 12 | import { forgeVitals, needLine } from "../../actions/health.ts"; | |
| 12 | import { forgeNeedList, type ForgeNeed } from "../../actions/health.ts"; | |
| 13 | 13 | import { reviewSet } from "./review_set.ts"; |
| 14 | import type { Degraded } from "../../kernel/vitals.ts"; | |
| 15 | 14 | import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts"; |
| 16 | 15 | import { crewForRepos } from "../../core/agents.ts"; |
| 17 | 16 | import { appsForRepos } from "../../core/apps.ts"; |
| @@ -1181,13 +1180,13 @@ export function askLinesFor( | ||
| 1181 | 1180 | } |
| 1182 | 1181 | |
| 1183 | 1182 | /** |
| 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. | |
| 1187 | 1187 | */ |
| 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) ?? []; | |
| 1191 | 1190 | } |
| 1192 | 1191 | |
| 1193 | 1192 | /** |
| @@ -1230,10 +1229,11 @@ export function asksAnswer(ctx: Ctx, user: User, chips?: Chips) { | ||
| 1230 | 1229 | const stack = askStackFor(ctx, user, chips, (n) => (hidden = n)); |
| 1231 | 1230 | const rows = stack.rows; |
| 1232 | 1231 | 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. | |
| 1234 | 1234 | waiting: { count: set.count, stale: set.stale.length }, |
| 1235 | 1235 | rows, |
| 1236 | forge: stack.forge.map((d) => needLine(d)), | |
| 1236 | forge: stack.forge.map((n) => n.line), | |
| 1237 | 1237 | measure: { days: ASKS_WINDOW_DAYS, ...askMeasureWeek(ctx.app.db, user.id) }, |
| 1238 | 1238 | ...(hasAskFilters(askFiltersOf(ctx.app.db, user.id)) ? { filters: { hidden, line: hiddenLine(hidden) } } : {}), |
| 1239 | 1239 | }; |
src/web/studio/asks_page.ts +3 −4
| @@ -28,8 +28,7 @@ import type { FleetValue } from "../../core/fleet_ops.ts"; | ||
| 28 | 28 | import { muteRepo, removeMutes } from "../../core/interactions.ts"; |
| 29 | 29 | import { applyAskFilter, isAskFilterDo, type AskFilterChange } from "../../actions/ask_filter.ts"; |
| 30 | 30 | 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"; | |
| 33 | 32 | import type { Ctx } from "../../http/context.ts"; |
| 34 | 33 | import type { User } from "../../core/users.ts"; |
| 35 | 34 | import { onYouAnswer, onYouRegion } from "./home.ts"; |
| @@ -407,10 +406,10 @@ function rowCard(ctx: Ctx, r: AskRow<Decision>, picked: ReadonlySet<string>): Ht | ||
| 407 | 406 | } |
| 408 | 407 | |
| 409 | 408 | /** 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 => | |
| 411 | 410 | html`<section class="stack"> |
| 412 | 411 | <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> | |
| 414 | 413 | <div class="bar">${btn("Health", { href: "/api/v1/health", size: "sm" })}</div> |
| 415 | 414 | </section>`; |
| 416 | 415 |
test/asks-forge-alert.test.ts +1 −1
| @@ -3,7 +3,7 @@ import { forgeCard } from "../src/web/studio/asks_page.ts"; | ||
| 3 | 3 | |
| 4 | 4 | // Principle 7 took the card away; the line must still read as an alert. |
| 5 | 5 | 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" })); | |
| 7 | 7 | const line = /<p role="alert">([\s\S]*?)<\/p>/.exec(out); |
| 8 | 8 | expect(line).not.toBeNull(); |
| 9 | 9 | 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 | }); |
new above
offline