Public: anyone can read this; the crew can change it.
`bun scripts/home-eval.ts` scores the owner's home against ground truth: which of the five facts that mattered the page states, and which it states wrong #6006
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5805-bun-scripts-home-eval-ts-scores-the-owne → main 2 commits fork 4 threads · 4 unresolved
README.md +2
| @@ -348,6 +348,8 @@ lines, from the same queries (`src/core/client-apps.ts`). | ||
| 348 | 348 | |
| 349 | 349 | **Reading the home as the eye does (#5735, step 1).** `bun scripts/home-read.ts <url>` fetches a page and prints its visible text, then one line: `chars N · words N · fetched in N ms`. Set `FORGE_COOKIE` (for example `forge_session=…; forge_mode=studio`) to read it as the owner; the cookie is sent and never printed. It exits non-zero on any status but 200. The conversion is `pageText` in `src/web/page_text.ts`: scripts, styles, templates, the head and anything `hidden` or `aria-hidden="true"` are dropped, link text stays, blocks sit on their own lines, entities are decoded. The method then continues: (2) a fresh agent with no context reads only that text and answers in at most 150 words, (3) the steward scores it against ground truth on coverage of the 5 things that mattered, errors, and seconds, (4) repeat after every feed change. The steward runs steps 2–4 and logs them on #5735. |
| 350 | 350 | |
| 351 | **Scoring the home (#5805).** `bun scripts/home-eval.ts <url>` is the deterministic half of that loop: it reads `/` with the same text conversion, builds ground truth from the forge's own API (`GET /api/v1/telemetry/home` for landings, the live version, builders and why idle, spend against the ceiling and what waits on the owner; the repository list and each newest commit for repositories quiet 3 days), and prints each of the five facts as `stated`, `missing` or `wrong` with the page's words, then coverage, errors, characters and fetch time. The method and a table of results, one row per feed pull, are in `docs/HOME-EVAL.md`. No dependency was added. | |
| 352 | ||
| 351 | 353 | Home shows nine figures first, from `src/flow/kpis.ts`: shipped per hour (with |
| 352 | 354 | a 24 h sparkline), the live version and since when, the median check time, the crew builders |
| 353 | 355 | running of their slots, and the crew's spend today of the forge's daily |
docs/HOME-EVAL.md added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | # Scoring the owner's home | |
| 2 | ||
| 3 | The owner's words, from #5735 (2026-10-25): "it's still kinda hard for me to catch up with what was done… the widgets and graphs are nice… but when I scroll down the 'what you asked for' is just not helpful, and the pull requests/decisions, crew words, releases, and the agents buttons do nothing. This was supposed to be like an X feed… iterate heavily, and the validation should be sending an agent with no context to read the feed, then produce a summary in its own words. That must align and get closer over time while also getting more concise and direct. Measure the real time it takes to produce, so that we can target fast comprehension." | |
| 4 | ||
| 5 | ## The method | |
| 6 | ||
| 7 | 1. Fetch `/` as the owner and convert it to text: what the eye sees, with scripts and styles stripped (`bun scripts/home-read.ts <url>`, #5765). | |
| 8 | 2. A fresh agent with no context reads only that text and answers, in at most 150 words: what got done, what needs me and what I'd do about it, what's worrying, and its confidence. The steward runs this step. | |
| 9 | 3. Score it against ground truth on three numbers: **coverage** of the 5 things that truly mattered, **errors** (claims the page made wrong), and **seconds** to produce. | |
| 10 | 4. Repeat after every feed change. The target: coverage ≥ 4 of 5, 0 errors, under 15 s, and falling words. | |
| 11 | ||
| 12 | ## The deterministic half | |
| 13 | ||
| 14 | `bun scripts/home-eval.ts <forge-url>` does step 1 and scores the page itself, so the crew can run it on every feed change. Set `FORGE_COOKIE` (the owner's session) and/or `FORGE_TOKEN` (a `ro` bearer token); neither is printed. It prints each fact as `stated`, `missing` or `wrong` with the page's words beside what the forge says, then `coverage N of 5 · errors N · chars N · fetched in N ms`. | |
| 15 | ||
| 16 | The five facts, and where the truth comes from: | |
| 17 | ||
| 18 | | Fact | Truth | `stated` when the page says | `wrong` when | | |
| 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 | | |
| 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 | | 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 | | 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 | | |
| 24 | | repositories with no commit in 3 days | `GET /api/v1/repos` then each repository's newest commit | every quiet repository named on a line with `no commit`, `quiet`, `stalled` or `silent` | such a line names a repository that is not quiet | | |
| 25 | ||
| 26 | Limits: the window is the last 24 hours, the one `/api/v1/telemetry/home` reads; landings are counted, not named. A fact the page may rightly leave out (nothing waiting, no quiet repository) scores `stated` when the page does not claim otherwise. The cold-read agent of step 2 stays the steward's. | |
| 27 | ||
| 28 | ## Results | |
| 29 | ||
| 30 | One row per feed pull, newest last. Coverage is out of 5; `agent s` is the cold read's seconds (steward), `script` is the fetch time of `home-eval.ts`. | |
| 31 | ||
| 32 | | Date | Pull | Coverage | Errors | Chars | agent s | script ms | Note | | |
| 33 | | --- | --- | --- | --- | --- | --- | --- | --- | | |
| 34 | | 2026-10-25 | baseline (#5735) | ~1.5 | 2 | 7,333 | 21.8 | — | ceiling shown as `no ceiling`; "5 waiting" listed 3 | |
scripts/home-eval.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * `bun scripts/home-eval.ts <base-url>` scores the owner's home against | |
| 3 | * ground truth (#5805), the deterministic half of #5735's loop: it reads `/` | |
| 4 | * as text (`pageText`, #5765), asks the forge's own API for the five facts | |
| 5 | * that mattered, and prints each as `stated`, `missing` or `wrong` with the | |
| 6 | * page's words beside it, then coverage, errors, characters and fetch time. | |
| 7 | * `FORGE_COOKIE` is sent as the cookie and `FORGE_TOKEN` as a bearer token; | |
| 8 | * neither is printed. Exits 1 when the page or the API will not answer. | |
| 9 | */ | |
| 10 | import { pageText } from "../src/web/page_text.ts"; | |
| 11 | import { coverage, errors, scoreHome, type Truth } from "../src/system/home_eval.ts"; | |
| 12 | ||
| 13 | const base = process.argv[2]?.replace(/\/+$/, ""); | |
| 14 | if (!base || !/^https?:\/\//i.test(base)) { | |
| 15 | console.error("usage: bun scripts/home-eval.ts <http(s)://forge>"); | |
| 16 | process.exit(2); | |
| 17 | } | |
| 18 | const cookie = process.env.FORGE_COOKIE; | |
| 19 | const token = process.env.FORGE_TOKEN; | |
| 20 | const headers: Record<string, string> = { ...(cookie ? { cookie } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) }; | |
| 21 | const DAY = 86_400_000; | |
| 22 | ||
| 23 | async function get(path: string): Promise<any> { | |
| 24 | const res = await fetch(`${base}${path}`, { headers, redirect: "manual", signal: AbortSignal.timeout(15_000) }); | |
| 25 | if (res.status !== 200) throw new Error(`${path} answered ${res.status}`); | |
| 26 | return res.json(); | |
| 27 | } | |
| 28 | ||
| 29 | async function truth(): Promise<Truth> { | |
| 30 | const home = await get("/api/v1/telemetry/home"); | |
| 31 | const repos: { ownerName: string; name: string; archivedAt?: string | null }[] = []; | |
| 32 | for (let cursor: string | null = null, page = 0; page < 10; page++) { | |
| 33 | const r: { items: typeof repos; next_cursor: string | number | null } = await get(`/api/v1/repos?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`); | |
| 34 | repos.push(...r.items); | |
| 35 | if (r.next_cursor === null) break; | |
| 36 | cursor = String(r.next_cursor); | |
| 37 | } | |
| 38 | const live = repos.filter((r) => !r.archivedAt).map((r) => `${r.ownerName}/${r.name}`); | |
| 39 | const quiet: string[] = []; | |
| 40 | for (let i = 0; i < live.length; i += 8) { | |
| 41 | await Promise.all( | |
| 42 | live.slice(i, i + 8).map(async (full) => { | |
| 43 | const log: { date: string }[] = await get(`/api/v1/repos/${full}/commits?limit=1`); | |
| 44 | if (log[0] !== undefined && Date.now() - Date.parse(log[0].date) > 3 * DAY) quiet.push(full); | |
| 45 | }), | |
| 46 | ); | |
| 47 | } | |
| 48 | return { ...home, repos: live, quiet: quiet.sort() }; | |
| 49 | } | |
| 50 | ||
| 51 | try { | |
| 52 | const started = performance.now(); | |
| 53 | const res = await fetch(`${base}/`, { headers, redirect: "manual", signal: AbortSignal.timeout(15_000) }); | |
| 54 | const html = await res.text(); | |
| 55 | const ms = Math.round(performance.now() - started); | |
| 56 | if (res.status !== 200) throw new Error(`/ answered ${res.status}`); | |
| 57 | const text = pageText(html); | |
| 58 | const facts = scoreHome(text, await truth()); | |
| 59 | for (const f of facts) { | |
| 60 | console.log(`${f.verdict.padEnd(7)} ${f.label}`); | |
| 61 | console.log(` page: ${f.words === "" ? "—" : `“${f.words}”`}`); | |
| 62 | console.log(` forge: ${f.truth}`); | |
| 63 | } | |
| 64 | console.log(`coverage ${coverage(facts)} of 5 · errors ${errors(facts)} · chars ${text.length} · fetched in ${ms} ms`); | |
| 65 | } catch (e) { | |
| 66 | console.error(e instanceof Error ? e.message : String(e)); | |
| 67 | process.exit(1); | |
| 68 | } |
src/contract/openapi.ts +1
| @@ -442,6 +442,7 @@ export const ROUTES: readonly Route[] = [ | ||
| 442 | 442 | }, |
| 443 | 443 | { method: "get", path: "/api/v1/telemetry/system", summary: "Read the forge's system view; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true }, |
| 444 | 444 | { method: "get", path: "/api/v1/telemetry/flow", summary: "Read the forge's flow view; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true }, |
| 445 | { 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 }, | |
| 445 | 446 | { method: "get", path: "/api/v1/telemetry/sources", summary: "List telemetry sources; answers { sources }; the owner or an agent holding telemetry:read", auth: true }, |
| 446 | 447 | { method: "get", path: "/api/v1/telemetry/history", summary: "Read telemetry history; answers { points }; the owner or an agent holding telemetry:read", auth: true }, |
| 447 | 448 | { method: "get", path: `${REPO}/pulls/{number}`, summary: "Read one pull with its diff and review gate (diff=0 leaves the diff out)", auth: false, query: { diff: "string" } }, |
src/http/api.ts +2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * back to continue); errors are `{ error, message }`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { homeTruth } from "../system/home_truth.ts"; | |
| 11 | 12 | import { |
| 12 | 13 | checkUpstreamNow, |
| 13 | 14 | FOLLOW_REFUSALS, |
| @@ -1484,6 +1485,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1484 | 1485 | }; |
| 1485 | 1486 | router.add("GET", "/api/v1/telemetry/system", telemetryRead("system", (c, r) => systemFor(c.app, r))); |
| 1486 | 1487 | router.add("GET", "/api/v1/telemetry/flow", telemetryRead("flow", (c, r) => flowFor(c.app, r))); |
| 1488 | router.add("GET", "/api/v1/telemetry/home", telemetryRead("home", (c) => homeTruth(c.app, c.auth.user!))); | |
| 1487 | 1489 | router.add("GET", "/api/v1/telemetry/sources", telemetryRead("sources", () => ({ sources: sourcesOut() }))); |
| 1488 | 1490 | router.add("GET", "/api/v1/telemetry/history", telemetryRead("history", (c) => ({ points: historyFor(c.app) }))); |
| 1489 | 1491 | // The funnel: what any agent that arrives should know. Open to all. |
src/system/home_eval.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * Scoring the owner's home against ground truth (#5805): plain data in, one | |
| 3 | * verdict per fact out. `stated` is right, `missing` is absent, `wrong` is a | |
| 4 | * claim the page makes that the forge's own figures contradict. The page's | |
| 5 | * words come from `pageText` (#5765); the truth from `homeTruth` and the | |
| 6 | * repository API, gathered by `scripts/home-eval.ts`. | |
| 7 | */ | |
| 8 | ||
| 9 | export type Verdict = "stated" | "missing" | "wrong"; | |
| 10 | ||
| 11 | export interface Truth { | |
| 12 | readonly landed: number; | |
| 13 | readonly live: { readonly sha: string } | null; | |
| 14 | readonly builders: { readonly running: number; readonly slots: number; readonly idle: string | null }; | |
| 15 | readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null }; | |
| 16 | readonly waiting: { readonly count: number }; | |
| 17 | /** Every repository the owner sees, and which of them have had no commit in 3 days. */ | |
| 18 | readonly repos: readonly string[]; | |
| 19 | readonly quiet: readonly string[]; | |
| 20 | } | |
| 21 | ||
| 22 | export interface Fact { | |
| 23 | readonly key: "landed" | "waiting" | "spend" | "builders" | "quiet"; | |
| 24 | readonly label: string; | |
| 25 | readonly verdict: Verdict; | |
| 26 | /** The page's words for it, or "" when it says nothing. */ | |
| 27 | readonly words: string; | |
| 28 | /** What the forge says, for the reader to compare. */ | |
| 29 | readonly truth: string; | |
| 30 | } | |
| 31 | ||
| 32 | const lineWith = (text: string, re: RegExp): string => text.split("\n").find((l) => re.test(l)) ?? ""; | |
| 33 | const flat = (s: string): string => s.replace(/\s+/g, " ").trim(); | |
| 34 | const usd = (n: number): string => (n < 10 ? n.toFixed(2) : String(Math.round(n))); | |
| 35 | ||
| 36 | function landed(text: string, t: Truth): Fact { | |
| 37 | const m = /(\d+) changes? in 24 h/.exec(text); | |
| 38 | const sha = t.live === null ? null : t.live.sha.slice(0, 7); | |
| 39 | const truth = `${t.landed} ${t.landed === 1 ? "change" : "changes"} in 24 h${sha === null ? ", nothing live" : `, live ${sha}`}`; | |
| 40 | const words = m === null ? "" : m[0] + (sha !== null && text.includes(sha) ? ` · ${sha}` : ""); | |
| 41 | const verdict: Verdict = m === null ? "missing" : Number(m[1]) !== t.landed ? "wrong" : sha === null || text.includes(sha) ? "stated" : "missing"; | |
| 42 | return { key: "landed", label: "landed and released", verdict, words, truth }; | |
| 43 | } | |
| 44 | ||
| 45 | function waiting(text: string, t: Truth): Fact { | |
| 46 | const m = /(\d+) waiting on you/.exec(text); | |
| 47 | const n = t.waiting.count; | |
| 48 | const verdict: Verdict = m === null ? (n === 0 ? "stated" : "missing") : Number(m[1]) === n ? "stated" : "wrong"; | |
| 49 | return { key: "waiting", label: "waiting on the owner", verdict, words: m === null ? "" : flat(lineWith(text, /waiting on you/)), truth: `${n} waiting on you` }; | |
| 50 | } | |
| 51 | ||
| 52 | function spend(text: string, t: Truth): Fact { | |
| 53 | const m = /Crew spend today\s+\$([\d.]+)(?:\s+of\s+\$([\d.]+))?(\s+no ceiling)?/.exec(text); | |
| 54 | const { todayUsd, ceilingUsd } = t.spend; | |
| 55 | const truth = `$${usd(todayUsd)} of ${ceilingUsd === null ? "no ceiling" : `$${usd(ceilingUsd)}`}`; | |
| 56 | if (m === null) return { key: "spend", label: "spend against the forge cap", verdict: "missing", words: "", truth }; | |
| 57 | const pageCeiling = m[2] === undefined ? null : Number(m[2]); | |
| 58 | const right = Math.abs(Number(m[1]) - todayUsd) <= 1 && (ceilingUsd === null ? pageCeiling === null : pageCeiling !== null && Math.abs(pageCeiling - ceilingUsd) <= 0.5); | |
| 59 | return { key: "spend", label: "spend against the forge cap", verdict: right ? "stated" : "wrong", words: flat(m[0]), truth }; | |
| 60 | } | |
| 61 | ||
| 62 | const REASONS: readonly [RegExp, RegExp][] = [ | |
| 63 | [/ceiling of \$/, /ceiling (reached|hit)|reached (the |its )?(daily |forge )?ceiling|paused at (the )?(forge )?ceiling/i], | |
| 64 | [/^no slots set$/, /no slots set/i], | |
| 65 | [/^no ready work$/, /no ready work|nothing (is )?ready/i], | |
| 66 | ]; | |
| 67 | ||
| 68 | function builders(text: string, t: Truth): Fact { | |
| 69 | const { running, slots, idle } = t.builders; | |
| 70 | const truth = `${running} of ${slots} running${idle === null ? "" : `, idle: ${idle}`}`; | |
| 71 | const m = /Builders running\s+(?:(\d+) of (\d+)|—)/.exec(text); | |
| 72 | if (m === null) return { key: "builders", label: "builders idle and why", verdict: "missing", words: "", truth }; | |
| 73 | const words = flat(m[0]); | |
| 74 | const countRight = m[1] === undefined ? slots === 0 : Number(m[1]) === running && Number(m[2]) === slots; | |
| 75 | if (!countRight) return { key: "builders", label: "builders idle and why", verdict: "wrong", words, truth }; | |
| 76 | if (idle === null) return { key: "builders", label: "builders idle and why", verdict: "stated", words, truth }; | |
| 77 | const why = REASONS.find(([truthRe]) => truthRe.test(idle))?.[1]; | |
| 78 | const said = why === undefined ? "" : lineWith(text, why); | |
| 79 | return { key: "builders", label: "builders idle and why", verdict: said === "" ? "missing" : "stated", words: said === "" ? words : flat(said), truth }; | |
| 80 | } | |
| 81 | ||
| 82 | const QUIET_LINE = /no commit|quiet|stalled|silent/i; | |
| 83 | const names = (repo: string): string[] => [repo, repo.split("/")[1] ?? repo]; | |
| 84 | const mentions = (line: string, repo: string): boolean => names(repo).some((n) => new RegExp(`(^|[^\\w/-])${n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\w/-])`).test(line)); | |
| 85 | ||
| 86 | function quiet(text: string, t: Truth): Fact { | |
| 87 | const truth = t.quiet.length === 0 ? "none" : t.quiet.join(", "); | |
| 88 | const lines = text.split("\n").filter((l) => QUIET_LINE.test(l)); | |
| 89 | const wrongLine = lines.find((l) => t.repos.some((r) => !t.quiet.includes(r) && mentions(l, r) && !t.quiet.some((q) => mentions(l, q)))); | |
| 90 | if (wrongLine !== undefined) return { key: "quiet", label: "repositories with no commit in 3 days", verdict: "wrong", words: flat(wrongLine), truth }; | |
| 91 | if (t.quiet.length === 0) return { key: "quiet", label: "repositories with no commit in 3 days", verdict: "stated", words: "", truth }; | |
| 92 | const said = lines.filter((l) => t.quiet.some((q) => mentions(l, q))); | |
| 93 | const all = t.quiet.every((q) => said.some((l) => mentions(l, q))); | |
| 94 | return { key: "quiet", label: "repositories with no commit in 3 days", verdict: all ? "stated" : "missing", words: flat(said.join(" ")), truth }; | |
| 95 | } | |
| 96 | ||
| 97 | export function scoreHome(text: string, truth: Truth): Fact[] { | |
| 98 | return [landed(text, truth), waiting(text, truth), builders(text, truth), spend(text, truth), quiet(text, truth)]; | |
| 99 | } | |
| 100 | ||
| 101 | export const coverage = (facts: readonly Fact[]): number => facts.filter((f) => f.verdict === "stated").length; | |
| 102 | export const errors = (facts: readonly Fact[]): number => facts.filter((f) => f.verdict === "wrong").length; |
src/system/home_truth.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * The figures the owner's home is scored against (#5805): what landed in the | |
| 3 | * last 24 hours and what is live, the builders and the spend against the | |
| 4 | * forge ceiling, why builders sit idle, and what waits on the reader. The | |
| 5 | * same sources the home reads, as plain data for `scripts/home-eval.ts`. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { listReposVisibleTo } from "../core/repos.ts"; | |
| 10 | import type { User } from "../core/users.ts"; | |
| 11 | import { readKpis } from "../flow/kpis.ts"; | |
| 12 | import { waitingOn } from "../web/studio/asks.ts"; | |
| 13 | ||
| 14 | /** Why builders are not all busy, in words; null when every slot runs. */ | |
| 15 | export function idleReason(b: { running: number; slots: number }, s: { todayUsd: number; ceilingUsd: number | null }): string | null { | |
Nit: nothing calls |
||
| 16 | if (b.slots > 0 && b.running >= b.slots) return null; | |
| 17 | if (b.slots === 0) return "no slots set"; | |
| 18 | if (s.ceilingUsd !== null && s.todayUsd >= s.ceilingUsd) return `daily ceiling of $${s.ceilingUsd} reached`; | |
| 19 | return "no ready work"; | |
| 20 | } | |
| 21 | ||
| 22 | export function homeTruth(app: App, user: User, now: Date = new Date()) { | |
| 23 | const k = readKpis(app.db, now, { ceilingUsd: app.config.crewCeilingUsd ?? null }); | |
| 24 | const asks = waitingOn(app.db, user, listReposVisibleTo(app.db, user, { limit: 200, withoutArchived: true })); | |
| 25 | const oldest = asks.reduce<string | null>((o, a) => (o === null || Date.parse(a.since) < Date.parse(o) ? a.since : o), null); | |
| 26 | return { | |
| 27 | landed: k.landed, | |
| 28 | live: k.live, | |
| 29 | builders: { ...k.builders, idle: idleReason(k.builders, k.spend) }, | |
| 30 | spend: k.spend, | |
| 31 | waiting: { count: asks.length, oldestAt: oldest }, | |
| 32 | }; | |
| 33 | } |
test/home-eval.test.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** The owner's home scored against ground truth (#5805). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createSession } from "../src/core/sessions.ts"; | |
| 8 | import { createRepo as createRepoRow } from "../src/core/repos.ts"; | |
| 9 | import { id } from "../src/kernel/id.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { coverage, errors, scoreHome, type Truth } from "../src/system/home_eval.ts"; | |
| 13 | import { promote } from "./helpers/members.ts"; | |
| 14 | ||
| 15 | let dataDir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let cookie: string; | |
| 19 | ||
| 20 | beforeAll(async () => { | |
| 21 | dataDir = await mkdtemp(join(tmpdir(), "forge-home-eval-")); | |
| 22 | app = createApp({ dataDir, port: 0 }); | |
| 23 | servers = await serve(app); | |
| 24 | const r = await createUser(app.db, { origin: "seed", trusted: true, username: "keeper", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 25 | if (!r.ok) throw new Error(r.error); | |
| 26 | promote(app.db, r.value); | |
| 27 | cookie = `forge_session=${createSession(app.db, r.value.id).token}; forge_mode=studio`; | |
| 28 | const owner = r.value.id; | |
| 29 | const repo = await createRepoRow(app.db, app.reposRoot, { ownerId: owner, name: "wait", visibility: "public", description: "wait" }); | |
| 30 | if (!repo.ok) throw new Error(repo.error); | |
| 31 | for (const i of [0, 1, 2]) { | |
| 32 | const issueId = id(); | |
| 33 | const number = app.db.query<{ issue_seq: number }, [string]>("UPDATE repo SET issue_seq = issue_seq + 1 WHERE id = ?1 RETURNING issue_seq").get(repo.value.id)!.issue_seq; | |
| 34 | app.db | |
| 35 | .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'open', ?6)`) | |
| 36 | .run(issueId, repo.value.id, number, `Stale ${i}`, owner, new Date(Date.now() - 40 * 3_600_000).toISOString()); | |
| 37 | app.db | |
| 38 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, ?3, ?3, ?4)`) | |
| 39 | .run(issueId, `b/${i}`, "0".repeat(40), repo.value.id); | |
| 40 | } | |
| 41 | }); | |
| 42 | ||
| 43 | afterAll(async () => { | |
| 44 | servers.stop(); | |
| 45 | app.close(); | |
| 46 | await rm(dataDir, { recursive: true, force: true }); | |
| 47 | }); | |
| 48 | ||
| 49 | let stub: ReturnType<typeof Bun.serve> | undefined; | |
| 50 | afterAll(() => stub?.stop(true)); | |
| 51 | ||
| 52 | const run = async (args: string[]) => { | |
| 53 | const proc = Bun.spawn(["bun", "scripts/home-eval.ts", ...args], { env: { ...process.env, FORGE_COOKIE: cookie, FORGE_TOKEN: "" }, stdout: "pipe", stderr: "pipe" }); | |
| 54 | const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); | |
| 55 | return { out, err, code }; | |
| 56 | }; | |
| 57 | ||
| 58 | test("scoreHome marks a wrong waiting count wrong and the rest stated", async () => { | |
| 59 | const truth = (await (await fetch(`${servers.url}/api/v1/telemetry/home`, { headers: { cookie } })).json()) as { waiting: { count: number } }; | |
| 60 | expect(truth.waiting.count).toBe(3); | |
| 61 | // The page as it should read, then the same page with the count wrong. | |
| 62 | const page = `3 changes in 24 h\nCrew spend today $0.00 no ceiling\nBuilders running —\nNo slots set\n3 waiting on you, oldest 40 h`; | |
| 63 | const t: Truth = { landed: 3, live: null, builders: { running: 0, slots: 0, idle: "no slots set" }, spend: { todayUsd: 0, ceilingUsd: null }, waiting: { count: 3 }, repos: [], quiet: [] }; | |
outdated
This is the only place a |
||
| 64 | const good = scoreHome(page, t); | |
| 65 | expect(good.map((f) => f.verdict)).toEqual(["stated", "stated", "stated", "stated", "stated"]); | |
| 66 | const bad = scoreHome(page.replace("3 waiting", "5 waiting"), t); | |
| 67 | expect(bad.find((f) => f.key === "waiting")).toMatchObject({ verdict: "wrong", words: "5 waiting on you, oldest 40 h" }); | |
| 68 | expect(errors(bad)).toBe(1); | |
| 69 | expect(coverage(bad)).toBe(4); | |
| 70 | }); | |
| 71 | ||
| 72 | test("scripts/home-eval.ts prints a verdict per fact with the page's words, then coverage, errors, characters and fetch time", async () => { | |
| 73 | const r = await run([servers.url]); | |
| 74 | expect(r.err).toBe(""); | |
| 75 | expect(r.code).toBe(0); | |
outdated
This depends on the three pulls seeded inside the previous test. Move the seeding into |
||
| 76 | const lines = r.out.trimEnd().split("\n"); | |
| 77 | expect(lines.filter((l) => /^(stated|missing|wrong) /.test(l))).toHaveLength(5); | |
| 78 | expect(r.out).toContain("3 waiting on you"); | |
| 79 | expect(lines.at(-1)).toMatch(/^coverage \d of 5 · errors \d · chars \d+ · fetched in \d+ ms$/); | |
| 80 | expect(r.out).not.toContain(cookie.match(/forge_session=([^;]+)/)![1]!); | |
| 81 | }); | |
| 82 | ||
| 83 | test("the real home never contradicts its own figures; with one figure changed in front of it, the script says wrong", async () => { | |
| 84 | const honest = await run([servers.url]); | |
| 85 | expect(honest.out).toMatch(/errors 0 ·/); | |
| 86 | expect(honest.out).not.toMatch(/^wrong /m); | |
| 87 | const home = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); | |
| 88 | expect(home).toContain("3 waiting on you"); | |
| 89 | // A stand-in in front of the forge: the real home with one figure wrong, every API read passed through. | |
| 90 | stub = Bun.serve({ | |
| 91 | port: 0, | |
| 92 | fetch: (req) => { | |
| 93 | const u = new URL(req.url); | |
| 94 | if (u.pathname === "/") return new Response(home.replace("3 waiting on you", "5 waiting on you"), { headers: { "content-type": "text/html" } }); | |
| 95 | return fetch(`${servers.url}${u.pathname}${u.search}`, { headers: { cookie } }); | |
| 96 | }, | |
| 97 | }); | |
| 98 | const lied = await run([`http://127.0.0.1:${stub.port}`]); | |
| 99 | expect(lied.code).toBe(0); | |
| 100 | expect(lied.out).toMatch(/^wrong\s+waiting on the owner$/m); | |
| 101 | expect(lied.out).toContain("5 waiting on you"); | |
| 102 | expect(lied.out).toMatch(/errors 1 ·/); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a ceiling the page hides, a quiet repo it blames wrongly and an unnamed idle reason are scored", () => { | |
| 106 | const t: Truth = { | |
| 107 | landed: 1, | |
| 108 | live: { sha: "abcdef0123" }, | |
| 109 | builders: { running: 0, slots: 2, idle: "daily ceiling of $600 reached" }, | |
| 110 | spend: { todayUsd: 601, ceilingUsd: 600 }, | |
| 111 | waiting: { count: 0 }, | |
| 112 | repos: ["a/old", "a/new"], | |
| 113 | quiet: ["a/old"], | |
| 114 | }; | |
| 115 | const page = "1 change in 24 h\nLive version abcdef0\nBuilders running 0 of 2\nrunning now\nCrew spend today $601 no ceiling\na/new has no commit since Friday"; | |
| 116 | const f = Object.fromEntries(scoreHome(page, t).map((x) => [x.key, x.verdict])); | |
| 117 | expect(f).toEqual({ landed: "stated", waiting: "stated", builders: "missing", spend: "wrong", quiet: "wrong" }); | |
| 118 | expect(scoreHome("nothing", t).find((x) => x.key === "landed")?.verdict).toBe("missing"); | |
| 119 | }); | |
| 120 | ||
| 121 | test("scripts/home-eval.ts exits 2 on a non-http argument and 1 when the page will not answer", async () => { | |
| 122 | expect((await run(["file:///etc/passwd"])).code).toBe(2); | |
| 123 | const denied = await run([`${servers.url}/nope`]); | |
| 124 | expect(denied.code).toBe(1); | |
| 125 | }); |
The direction freezes new pulls touching this file while more than 40 pulls are open. The entry is needed for the new route, so keep it to this one line and say so in the pull description.