Public: anyone can read this; the crew can change it.
The tick line and home show open pulls, opened/landed per hour and conflicted #5944
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5935-the-tick-line-and-home-show-open-pulls-o → main 8 commits fork 3 threads · 3 unresolved
11 files
changed +260
−4
README.md +2
| @@ -382,6 +382,8 @@ with the same lines, worded by the same helpers (`src/core/client-apps.ts`). | ||
| 382 | 382 | |
| 383 | 383 | **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. |
| 384 | 384 | |
| 385 | Under the figures, an admin's home, the terminal's dashboard and its Flow dialog carry one vitals line (#5935, `src/flow/vitals.ts`): open pulls, opened and landed in 1 h and 6 h, and conflicted, against #5924's targets; the autonomy tick line says the same as `pulls` (and `offTarget` when a target is missed). The MCP `flow` tool and `GET /api/v1/telemetry/flow` carry it as `vitals: { says, misses, … }`; the tool's description is unchanged because `tools/list` is at its size ceiling, so an agent finds `vitals` in the result, not the listing. An agent reader's counts cover public repositories only. | |
| 386 | ||
| 385 | 387 | Home shows nine figures first, from `src/flow/kpis.ts`: shipped per hour (with |
| 386 | 388 | a 24 h sparkline), the live version and since when, the median check time, the crew builders |
| 387 | 389 | running of their slots, and the crew's spend today of the forge's daily |
src/crew/autonomy.ts +10
| @@ -125,6 +125,7 @@ import { | ||
| 125 | 125 | } from "./direct.ts"; |
| 126 | 126 | import { crewOffInPreview } from "./preview.ts"; |
| 127 | 127 | import { approvedOnHead, finishApproved, finishFields } from "./finishing.ts"; |
| 128 | import { readVitals, vitalsMisses, vitalsWords } from "../flow/vitals.ts"; | |
| 128 | 129 | import { moveRequested, tallyRequested, tallyWords } from "./requested.ts"; |
| 129 | 130 | import { reviewersOf, sendToReview, sweepReviews } from "./review.ts"; |
| 130 | 131 | import { lastWaits, noteWaits, tallyWaits, type Wait, waitWords } from "./waits.ts"; |
| @@ -1283,6 +1284,8 @@ export async function autonomyTick( | ||
| 1283 | 1284 | ...(held.by.hot_file.length === 0 ? {} : { hot: hotWords(hotFiles(current.id)) }), |
| 1284 | 1285 | // Stalled issues this tick parked and freed for another builder (#4073). |
| 1285 | 1286 | unstuck, |
| 1287 | // Open pulls, opened/landed per hour and per 6 h, conflicted (#5935). | |
| 1288 | ...flowVitals(app.db, now), | |
| 1286 | 1289 | active: status.active, |
| 1287 | 1290 | slots: status.slots, |
| 1288 | 1291 | // Approved pulls being brought up to date, those still waiting (#3682), |
| @@ -1333,6 +1336,13 @@ export async function autonomyTick( | ||
| 1333 | 1336 | } |
| 1334 | 1337 | } |
| 1335 | 1338 | |
| 1339 | /** The tick line's flow vitals (#5935): the counts in words, and the targets they miss. */ | |
| 1340 | export function flowVitals(db: App["db"], now: Date): { pulls: string; offTarget?: string } { | |
| 1341 | const v = readVitals(db, now); | |
| 1342 | const misses = vitalsMisses(v); | |
| 1343 | return { pulls: vitalsWords(v), ...(misses.length === 0 ? {} : { offTarget: misses.join("; ") }) }; | |
| 1344 | } | |
| 1345 | ||
| 1336 | 1346 | /** |
| 1337 | 1347 | * The stalled issues freed this tick (`freeStalled`): none while the crew |
| 1338 | 1348 | * is off or paused, or nobody stands behind it. |
src/flow/vitals.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | /** | |
| 2 | * The flow vitals (#5935): how many pulls are open, how many opened and | |
| 3 | * landed in the last hour and the last 6 h, and how many open pulls conflict | |
| 4 | * with their base. One reading, shared by home, the autonomy tick line and | |
| 5 | * the MCP `flow` tool, so the three say the same numbers. | |
| 6 | * | |
| 7 | * Targets from #5924: open < 40, conflicted < 5, landed >= opened over each | |
| 8 | * 6 h. Counts only, no repository named; forge-wide for the owner, | |
| 9 | * public repositories alone for an agent. "Conflicted" is the count `conflicted.ts` reads from the bring-up-to-date runs. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import { conflictedPulls } from "./conflicted.ts"; | |
| 14 | ||
| 15 | const HOUR_MS = 3_600_000; | |
| 16 | ||
| 17 | export const OPEN_TARGET = 40; | |
| 18 | export const CONFLICTED_TARGET = 5; | |
| 19 | ||
| 20 | export interface Vitals { | |
| 21 | readonly open: number; | |
| 22 | readonly opened1h: number; | |
| 23 | readonly landed1h: number; | |
| 24 | readonly opened6h: number; | |
| 25 | readonly landed6h: number; | |
| 26 | readonly conflicted: number; | |
| 27 | } | |
| 28 | ||
| 29 | /** `publicOnly` counts public repositories alone, for a reader who must not learn what private ones hold. */ | |
| 30 | export function readVitals(db: Database, now = new Date(), publicOnly = false): Vitals { | |
| 31 | const to = now.toISOString(); | |
| 32 | const h1 = new Date(now.getTime() - HOUR_MS).toISOString(); | |
| 33 | const h6 = new Date(now.getTime() - 6 * HOUR_MS).toISOString(); | |
| 34 | const row = db | |
| 35 | .query<Omit<Vitals, "conflicted">, [string, string, string, number]>( | |
| 36 | `SELECT | |
| 37 | (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 38 | WHERE (?4 = 0 OR r.visibility = 'public') AND i.state = 'open' AND p.merged_at IS NULL) AS open, | |
| 39 | (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 40 | WHERE (?4 = 0 OR r.visibility = 'public') AND i.created_at >= ?1 AND i.created_at <= ?3) AS opened1h, | |
| 41 | (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 42 | WHERE (?4 = 0 OR r.visibility = 'public') AND i.created_at >= ?2 AND i.created_at <= ?3) AS opened6h, | |
| 43 | (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 44 | WHERE (?4 = 0 OR r.visibility = 'public') AND p.base_branch = r.default_branch AND p.merged_at >= ?1 AND p.merged_at <= ?3) AS landed1h, | |
| 45 | (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 46 | WHERE (?4 = 0 OR r.visibility = 'public') AND p.base_branch = r.default_branch AND p.merged_at >= ?2 AND p.merged_at <= ?3) AS landed6h`, | |
| 47 | ) | |
| 48 | .get(h1, h6, to, publicOnly ? 1 : 0); | |
| 49 | return { | |
| 50 | open: row?.open ?? 0, | |
| 51 | opened1h: row?.opened1h ?? 0, | |
| 52 | landed1h: row?.landed1h ?? 0, | |
| 53 | opened6h: row?.opened6h ?? 0, | |
| 54 | landed6h: row?.landed6h ?? 0, | |
| 55 | conflicted: conflictedPulls(db, publicOnly).length, | |
| 56 | }; | |
| 57 | } | |
| 58 | ||
| 59 | /** Which targets the reading misses, in words; empty when it meets them all. */ | |
| 60 | export function vitalsMisses(v: Vitals): string[] { | |
| 61 | const out: string[] = []; | |
| 62 | if (v.open >= OPEN_TARGET) out.push(`open ${v.open} ≥ ${OPEN_TARGET}`); | |
| 63 | if (v.conflicted >= CONFLICTED_TARGET) out.push(`conflicted ${v.conflicted} ≥ ${CONFLICTED_TARGET}`); | |
| 64 | if (v.landed6h < v.opened6h) out.push(`landed ${v.landed6h} < opened ${v.opened6h} over 6 h`); | |
| 65 | return out; | |
| 66 | } | |
| 67 | ||
| 68 | /** The tick line's one value: `open 12, opened/landed 3/2 in 1 h, 9/11 in 6 h, conflicted 1`. */ | |
| 69 | export function vitalsWords(v: Vitals): string { | |
| 70 | return `open ${v.open}, opened/landed ${v.opened1h}/${v.landed1h} in 1 h, ${v.opened6h}/${v.landed6h} in 6 h, conflicted ${v.conflicted}`; | |
| 71 | } | |
| 72 | ||
| 73 | /** Home's line for an admin; the counts are the whole of it. */ | |
| 74 | export function vitalsLine(v: Vitals): string { | |
| 75 | return `${v.open} open pulls · ${v.opened1h} opened, ${v.landed1h} landed in 1 h · ${v.opened6h} opened, ${v.landed6h} landed in 6 h · ${v.conflicted} conflicted`; | |
| 76 | } |
src/system/telemetry.ts +4
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | 9 | import { SOURCES } from "../boards/sources.ts"; |
| 10 | 10 | import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../flow/conflicted.ts"; |
| 11 | import { readVitals, vitalsLine, vitalsMisses } from "../flow/vitals.ts"; | |
| 11 | 12 | import { readPipeline, verdict } from "../flow/pipeline.ts"; |
| 12 | 13 | import { type QueueReading, readQueues } from "../flow/queue.ts"; |
| 13 | 14 | import { reviewersBusy, reviewingSays } from "../flow/reviewing.ts"; |
| @@ -30,8 +31,11 @@ export function flowFor(app: App, reader: Reader, now = new Date()) { | ||
| 30 | 31 | const lane = rebaseLane(app.db, conflicted); |
| 31 | 32 | const requested = requestedWaits(app.db, reader.kind === "agent"); |
| 32 | 33 | const busy = reviewersBusy(app.db, reader.kind === "agent"); |
| 34 | const vitals = readVitals(app.db, now, reader.kind === "agent"); | |
outdated
Unscoped forge-wide counts handed to every reader, including agents and guests; private-repo pulls leak into open/opened/landed. Cut to public repos for non-admin readers like reviewing/requested above, and test it. |
||
| 33 | 35 | return { |
| 34 | 36 | says: verdict(stages, now.getTime()), |
| 37 | // Open pulls, opened and landed in 1 h and 6 h, and conflicted, against #5924's targets (#5935). | |
| 38 | vitals: { says: vitalsLine(vitals), misses: vitalsMisses(vitals), ...vitals }, | |
| 35 | 39 | // Review runs in flight per reviewer, cut to public repositories for an agent (#4733). |
| 36 | 40 | reviewing: { says: reviewingSays(busy), reviewers: busy }, |
| 37 | 41 | conflicted: { says: conflictedSays(conflicted, now.getTime()), pulls: conflicted }, |
src/tui/screens/dashboard.ts +5 −1
| @@ -88,6 +88,7 @@ import { | ||
| 88 | 88 | plural, |
| 89 | 89 | linkScreen, |
| 90 | 90 | } from "./common.ts"; |
| 91 | import { readVitals, vitalsLine } from "../../flow/vitals.ts"; | |
| 91 | 92 | import { readKpis } from "../../flow/kpis.ts"; |
| 92 | 93 | import { kpiLines } from "../../flow/kpi_words.ts"; |
| 93 | 94 | import { BOOTED } from "../../release/gate.ts"; |
| @@ -265,7 +266,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 265 | 266 | figures = |
| 266 | 267 | f.user?.isAdmin !== true |
| 267 | 268 | ? [] |
| 268 | : kpiLines(readKpis(f.db, now, kpiForge(f.app)), commit === null ? null : { sha: commit, since: ago(BOOTED) }); | |
| 269 | : [ | |
| 270 | ...kpiLines(readKpis(f.db, now, kpiForge(f.app)), commit === null ? null : { sha: commit, since: ago(BOOTED) }), | |
| 271 | vitalsLine(readVitals(f.db, now)), | |
| 272 | ]; | |
| 269 | 273 | // "Yours" is ownership plus the orgs you are on a team of; typing in the |
| 270 | 274 | // filter reaches every repository you can see. |
| 271 | 275 | const orgs = f.user |
src/tui/screens/flow.ts +3
| @@ -18,6 +18,7 @@ import { HOLDS } from "../../crew/held.ts"; | ||
| 18 | 18 | import type { HeldRow } from "../../flow/held.ts"; |
| 19 | 19 | import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../../flow/conflicted.ts"; |
| 20 | 20 | import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts"; |
| 21 | import { readVitals, vitalsLine } from "../../flow/vitals.ts"; | |
| 21 | 22 | import { readQueues, redBatchWords } from "../../flow/queue.ts"; |
| 22 | 23 | import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts"; |
| 23 | 24 | import { requestedSays, requestedWaits } from "../../flow/requested.ts"; |
| @@ -59,6 +60,8 @@ export function flowLines( | ||
| 59 | 60 | return [ |
| 60 | 61 | ...wrap(verdict(stages, now.getTime()), inner), |
| 61 | 62 | "", |
| 63 | ...wrap(vitalsLine(readVitals(db, now, agent !== undefined)), inner), | |
| 64 | "", | |
| 62 | 65 | ...wrap(throughputSays(flow), inner), |
| 63 | 66 | "", |
| 64 | 67 | `${pad("Out", 12)} ${pad("last hour", 9, "right")} ${pad("24 h", 5, "right")}`, |
src/web/studio/home.ts +3 −2
| @@ -52,7 +52,7 @@ import { writableAppsOf } from "./asks.ts"; | ||
| 52 | 52 | import { needsYouSection } from "./asks_page.ts"; |
| 53 | 53 | import { flowCards } from "./flow_card.ts"; |
| 54 | 54 | import { composer, feedJs, feedNote, feedRegion } from "./feed.ts"; |
| 55 | import { kpiRow, waitingLine } from "./kpi_row.ts"; | |
| 55 | import { kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts"; | |
| 56 | 56 | import { feedCandidateCache } from "../../feed/feed.ts"; |
| 57 | 57 | import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 58 | 58 | import { kpiCache, panelCache, waitingCache } from "./home_cache.ts"; |
| @@ -261,6 +261,7 @@ export async function studioHome( | ||
| 261 | 261 | ${ask} ${address} ${figures |
| 262 | 262 | ? timePhase(ctx.request, "kpis", () => kpiRow(ctx.app.db, kpiOpts), () => kpiCache(ctx.app.db)) |
| 263 | 263 | : liveLine(ctx)} |
| 264 | ${figures ? timePhase(ctx.request, "vitals", () => vitalsRow(ctx.app.db)) : null} | |
| 264 | 265 | ${timePhase(ctx.request, "waiting", () => waitingLine(ctx.app.db, user), () => waitingCache(ctx.app.db))} |
| 265 | 266 | ${figures |
| 266 | 267 | ? await timePhaseAsync(ctx.request, "panels", () => pinnedPanels(ctx.app, user), () => panelCache(ctx.app.db)) |
| @@ -301,7 +302,7 @@ export async function studioHome( | ||
| 301 | 302 | body, |
| 302 | 303 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}${ask ? `\n${passkeyJs}` : ""}`, |
| 303 | 304 | }); |
| 304 | notePhaseRest(ctx.request, "render", rendering, ["kpis", "waiting", "panels", "feed"]); | |
| 305 | notePhaseRest(ctx.request, "render", rendering, ["kpis", "vitals", "waiting", "panels", "feed"]); | |
| 305 | 306 | return res; |
| 306 | 307 | } |
| 307 | 308 |
src/web/studio/home_cache.ts +12
| @@ -11,12 +11,14 @@ import type { Database } from "bun:sqlite"; | ||
| 11 | 11 | import type { Answer } from "../../boards/query.ts"; |
| 12 | 12 | import { writeStamp } from "../../core/write_clock.ts"; |
| 13 | 13 | import { readKpis, type Kpis } from "../../flow/kpis.ts"; |
| 14 | import { readVitals, type Vitals } from "../../flow/vitals.ts"; | |
| 14 | 15 | import { Stamped } from "../../kernel/stamped.ts"; |
| 15 | 16 | |
| 16 | 17 | export const HOME_MAX_AGE_MS = 30_000; |
| 17 | 18 | |
| 18 | 19 | const kpiCaches = new WeakMap<Database, Stamped<Kpis>>(); |
| 19 | 20 | const panelCaches = new WeakMap<Database, Stamped<Answer>>(); |
| 21 | const vitalsCaches = new WeakMap<Database, Stamped<Vitals>>(); | |
| 20 | 22 | const waitingCaches = new WeakMap<Database, Stamped<Waiting>>(); |
| 21 | 23 | |
| 22 | 24 | /** What the waiting line keeps of the viewer's asks: how many, and the oldest's time. */ |
| @@ -39,6 +41,9 @@ export const kpiCache = (db: Database): Stamped<Kpis> => cacheOf(kpiCaches, db, | ||
| 39 | 41 | /** The pinned-panel cache of `db`: for tests, which count its hits. */ |
| 40 | 42 | export const panelCache = (db: Database): Stamped<Answer> => cacheOf(panelCaches, db, 64); |
| 41 | 43 | |
| 44 | /** The vitals cache of `db`: for tests, which count its hits. */ | |
| 45 | export const vitalsCache = (db: Database): Stamped<Vitals> => cacheOf(vitalsCaches, db, 2); | |
| 46 | ||
| 42 | 47 | /** The waiting-line cache of `db`: for tests, which count its hits. */ |
| 43 | 48 | export const waitingCache = (db: Database): Stamped<Waiting> => cacheOf(waitingCaches, db, 64); |
| 44 | 49 | |
| @@ -73,3 +78,10 @@ export function cachedPanel(db: Database, identity: unknown, compute: () => Answ | ||
| 73 | 78 | if (stamp === undefined) return compute(); |
| 74 | 79 | return panelCache(db).get(`panel:${JSON.stringify(identity)}`, stamp, compute); |
| 75 | 80 | } |
| 81 | ||
| 82 | /** Admin only: the caller has checked. The forge-wide flow vitals, valid like the KPIs: while the write clock holds, 30 seconds at most. */ | |
| 83 | export function cachedVitals(db: Database, now: Date): Vitals { | |
| 84 | const stamp = writeStamp(db); | |
| 85 | if (stamp === undefined) return readVitals(db, now); | |
| 86 | return vitalsCache(db).get("vitals", stamp, () => readVitals(db, now)); | |
| 87 | } |
src/web/studio/kpi_row.ts +9 −1
| @@ -10,7 +10,8 @@ | ||
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | 11 | import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendCeilingWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue, waitingWords } from "../../flow/kpi_words.ts"; |
| 12 | 12 | import type { Kpis } from "../../flow/kpis.ts"; |
| 13 | import { cachedKpis, cachedWaiting } from "./home_cache.ts"; | |
| 13 | import { vitalsLine } from "../../flow/vitals.ts"; | |
| 14 | import { cachedKpis, cachedVitals, cachedWaiting } from "./home_cache.ts"; | |
| 14 | 15 | import { escapeHtml, html, raw, type Html } from "../html.ts"; |
| 15 | 16 | import { BOOTED } from "../../release/gate.ts"; |
| 16 | 17 | import { running } from "../../kernel/version.ts"; |
| @@ -183,3 +184,10 @@ export function waitingLine(db: Database, user: User, now: Date = new Date()): H | ||
| 183 | 184 | const words = waitingWords(count, oldest, now); |
| 184 | 185 | return words === null ? html`` : html`<p class="small muted"><a href="/review">${words}</a></p>`; |
| 185 | 186 | } |
| 187 | ||
| 188 | /** Home's flow vitals for an admin (#5935): one muted line of counts; the open count links to the boards. */ | |
| 189 | export function vitalsRow(db: Database, now: Date = new Date()): Html { | |
| 190 | const v = cachedVitals(db, now); | |
| 191 | const rest = vitalsLine(v).split(" · ").slice(1).join(" · "); | |
| 192 | return html`<p class="small muted"><a href="/boards">${v.open} open pulls</a> · ${rest}</p>`; | |
| 193 | } | |
Splitting the sentence on " · " couples home to the wording of vitalsLine; export the tail from vitals.ts instead. |
test/flow-vitals.test.ts added +129
| @@ -0,0 +1,129 @@ | ||
| 1 | /** The flow vitals (#5935): home, the tick line and the MCP `flow` tool read the same three numbers from seeded pulls. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/core/repos.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve } from "../src/http/server.ts"; | |
| 10 | import { id } from "../src/kernel/id.ts"; | |
| 11 | import { flowVitals } from "../src/crew/autonomy.ts"; | |
| 12 | import { flowFor } from "../src/system/telemetry.ts"; | |
| 13 | import { flowLines } from "../src/tui/screens/flow.ts"; | |
| 14 | import { vitalsLine, readVitals, vitalsMisses, vitalsWords } from "../src/flow/vitals.ts"; | |
| 15 | ||
| 16 | const NOW = new Date(); | |
| 17 | const ago = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); | |
| 18 | ||
| 19 | describe("flow vitals", () => { | |
| 20 | test("home, the tick line and the MCP flow tool say the same numbers", async () => { | |
| 21 | const root = mkdtempSync(join(tmpdir(), "forge-vitals-")); | |
| 22 | const app = createApp({ dataDir: root, port: 0 }); | |
| 23 | const servers = await serve(app); | |
| 24 | try { | |
| 25 | const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true }); | |
| 26 | if (!made.ok) throw new Error(made.error); | |
| 27 | const repo = await createRepo(app.db, app.reposRoot, { ownerId: made.value.id, name: "v", visibility: "public", description: "v" }); | |
| 28 | if (!repo.ok) throw new Error(repo.error); | |
| 29 | const branch = repo.value.defaultBranch; | |
| 30 | let n = 0; | |
| 31 | const pull = (opened: string, merged: string | null, state: string) => { | |
| 32 | const issueId = id(); | |
| 33 | n++; | |
| 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, ?6, ?7)`) | |
| 36 | .run(issueId, repo.value.id, n, `P${n}`, made.value.id, state, opened); | |
| 37 | app.db | |
| 38 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, ?3, '', '', ?4, ?5)`) | |
| 39 | .run(issueId, branch, `b/${n}`, repo.value.id, merged); | |
| 40 | return issueId; | |
| 41 | }; | |
| 42 | pull(ago(0.5), null, "open"); // open, opened in the hour | |
| 43 | const clash = pull(ago(3), null, "open"); // open, opened in 6 h, conflicted | |
| 44 | pull(ago(20), null, "open"); // open, old | |
| 45 | pull(ago(4), ago(0.2), "closed"); // landed in the hour | |
| 46 | pull(ago(30), ago(5), "closed"); // landed in 6 h only | |
| 47 | pull(ago(30), ago(10), "closed"); // landed long ago | |
| 48 | app.db | |
| 49 | .query(`INSERT INTO run (id, repo_id, kind, issue_id, state, reason, created_at) VALUES (?1, ?2, 'agent', ?3, 'queued', 'conflict', ?4)`) | |
| 50 | .run(id(), repo.value.id, clash, ago(1)); | |
| 51 | ||
| 52 | const v = readVitals(app.db, NOW); | |
| 53 | expect(v).toEqual({ open: 3, opened1h: 1, landed1h: 1, opened6h: 3, landed6h: 2, conflicted: 1 }); | |
| 54 | expect(vitalsMisses(v)).toEqual(["landed 2 < opened 3 over 6 h"]); | |
| 55 | ||
| 56 | const tick = flowVitals(app.db, NOW); | |
| 57 | expect(tick.pulls).toBe("open 3, opened/landed 1/1 in 1 h, 3/2 in 6 h, conflicted 1"); | |
| 58 | expect(tick.pulls).toBe(vitalsWords(v)); | |
| 59 | expect(tick.offTarget).toBe("landed 2 < opened 3 over 6 h"); | |
| 60 | ||
| 61 | const mcp = flowFor(app, { kind: "owner" } as Parameters<typeof flowFor>[1], NOW).vitals; | |
| 62 | expect(mcp).toMatchObject(v); | |
| 63 | expect(mcp.says).toContain("3 open pulls"); | |
| 64 | ||
| 65 | const signin = await fetch(`${servers.url}/signin`, { | |
| 66 | method: "POST", | |
| 67 | headers: { origin: servers.url }, | |
| 68 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 69 | redirect: "manual", | |
| 70 | }); | |
| 71 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 72 | const home = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); | |
| 73 | expect(home).toContain(">3 open pulls</a> · 1 opened, 1 landed in 1 h · 3 opened, 2 landed in 6 h · 1 conflicted"); | |
| 74 | } finally { | |
| 75 | await servers.stop(); | |
| 76 | app.close(); | |
| 77 | rmSync(root, { recursive: true, force: true }); | |
| 78 | } | |
| 79 | }, 30_000); | |
| 80 | ||
| 81 | test("an agent reads public repositories alone; the owner reads all", async () => { | |
| 82 | const root = mkdtempSync(join(tmpdir(), "forge-vitals-")); | |
| 83 | const app = createApp({ dataDir: root, port: 0 }); | |
| 84 | try { | |
| 85 | const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true }); | |
| 86 | if (!made.ok) throw new Error(made.error); | |
| 87 | let n = 0; | |
| 88 | for (const visibility of ["public", "private"] as const) { | |
| 89 | const repo = await createRepo(app.db, app.reposRoot, { ownerId: made.value.id, name: visibility, visibility, description: "v" }); | |
| 90 | if (!repo.ok) throw new Error(repo.error); | |
| 91 | for (const merged of [null, ago(0.2)]) { | |
| 92 | const issueId = id(); | |
| 93 | n++; | |
| 94 | app.db | |
| 95 | .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, ?6, ?7)`) | |
| 96 | .run(issueId, repo.value.id, n, `P${n}`, made.value.id, merged === null ? "open" : "closed", ago(0.5)); | |
| 97 | app.db | |
| 98 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, ?3, '', '', ?4, ?5)`) | |
| 99 | .run(issueId, repo.value.defaultBranch, `b/${n}`, repo.value.id, merged); | |
| 100 | } | |
| 101 | } | |
| 102 | const owner = flowFor(app, { kind: "owner" } as Parameters<typeof flowFor>[1], NOW).vitals; | |
| 103 | const agent = flowFor(app, { kind: "agent" } as Parameters<typeof flowFor>[1], NOW).vitals; | |
| 104 | expect(owner).toMatchObject({ open: 2, opened1h: 4, landed1h: 2 }); | |
| 105 | expect(agent).toMatchObject({ open: 1, opened1h: 2, landed1h: 1 }); | |
| 106 | // The terminal's Flow dialog says the same: whole for the owner, public alone for an agent. | |
| 107 | expect(flowLines(app.db, NOW).join(" ").replace(/\s+/g, " ")).toContain(vitalsLine(owner)); | |
| 108 | const cut = flowLines(app.db, NOW, [], { queues: [] }).join(" ").replace(/\s+/g, " "); | |
| 109 | expect(cut).toContain(vitalsLine(agent)); | |
| 110 | expect(cut).not.toContain(vitalsLine(owner)); | |
| 111 | } finally { | |
| 112 | app.close(); | |
| 113 | rmSync(root, { recursive: true, force: true }); | |
| 114 | } | |
| 115 | }, 30_000); | |
| 116 | ||
| 117 | test("an empty forge reads zeros and misses nothing", async () => { | |
| 118 | const root = mkdtempSync(join(tmpdir(), "forge-vitals-")); | |
| 119 | const app = createApp({ dataDir: root, port: 0 }); | |
| 120 | try { | |
| 121 | const v = readVitals(app.db, NOW); | |
| 122 | expect(Object.values(v)).toEqual([0, 0, 0, 0, 0, 0]); | |
| 123 | expect(vitalsMisses(v)).toEqual([]); | |
| 124 | } finally { | |
| 125 | app.close(); | |
| 126 | rmSync(root, { recursive: true, force: true }); | |
| 127 | } | |
| 128 | }); | |
| 129 | }); |
test/tui-kpis.test.ts +7
| @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 7 | 7 | import { createUser, type User } from "../src/core/users.ts"; |
| 8 | import { readVitals, vitalsLine } from "../src/flow/vitals.ts"; | |
| 8 | 9 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 9 | 10 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 10 | 11 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -95,3 +96,9 @@ test("past the forge cap the terminal and the web tile read the same words", asy | ||
| 95 | 96 | const w = spendCeilingWords({ todayUsd: 601, ceilingUsd: 600, hitAt: "2026-10-25T14:03:20.000Z" }); |
| 96 | 97 | expect(w).toEqual({ note: "ceiling hit 14:03Z, builders idle", title: "ceiling hit 14:03Z, builders idle", attn: true }); |
| 97 | 98 | }); |
| 99 | ||
| 100 | test("an admin's first screen carries the flow vitals line; a non-admin's does not (#5935)", async () => { | |
| 101 | const text = await withCommit(undefined, () => firstScreen(alice)); | |
| 102 | expect(text).toContain(vitalsLine(readVitals(forge.db))); | |
| 103 | expect(await firstScreen(bob)).not.toContain("open pulls"); | |
| 104 | }); |
new above
offline
flowVitals sits between unstickNow doc comment (above) and unstickNow itself. Move it above that comment block.