Public: anyone can read this; the crew can change it.
Bugs get fixed or declined on a clock, and a share of each week goes to polish #8714
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8709-bugs-get-fixed-or-declined-on-a-clock-an → main 8 commits fork 2 threads · 2 unresolved
15 files
changed +372
−32
docs/features/bug-close.md added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | # A bug closes with a reason | |
| 2 | ||
| 3 | An issue labelled `bug` cannot be closed by hand with nothing behind it. Unless | |
| 4 | a merged pull closed it, commits on main already name it, or it was split into | |
| 5 | other issues, the close needs a `note`: one sentence, at most 300 characters. It | |
| 6 | is posted as a comment (`Closed as won't fix: …` when the reason is `dropped`, | |
| 7 | `Closed: …` otherwise). A missing note is `reason_required`; a note that is too | |
| 8 | long or not text is `invalid_note`. | |
| 9 | ||
| 10 | - Page: the close form has a "why" field beside the reason select; it is | |
| 11 | optional for any issue that is not a bug. | |
| 12 | - API: `PATCH …/issues/:number` with `{ state: "closed", note }`; without it on a bug, `422 reason_required`. | |
| 13 | - MCP: `close` takes `note`; the same refusals. | |
| 14 | - Terminal: `close <issue> "why"`. The reason is a note on every issue, bug or not. | |
| 15 | The issue screen's `x` has no field for one, so it refuses a bug and points to the page. | |
| 16 | ||
| 17 | Exempt, because they say why themselves: the crew's live triage (`triage`), its | |
| 18 | stale sweeps (`stale`), and the forge's own closes (`system`: the sentinel, the | |
| 19 | superseded-pull sweep, the gate review, an app note withdrawn). Each is tested in | |
| 20 | `test/bug-close-doors.test.ts`. | |
| 21 | ||
| 22 | On a mirrored repository the close is forwarded to the origin, but the note is | |
| 23 | kept as a comment on this forge only: the origin does not see why. |
docs/features/bugs.md +10 −21
| @@ -1,23 +1,12 @@ | ||
| 1 | # A bug closes with a reason | |
| 1 | # Bugs on a clock | |
| 2 | 2 | |
| 3 | An issue labelled `bug` cannot be closed by hand with nothing behind it. Unless | |
| 4 | a merged pull closed it, commits on main already name it, or it was split into | |
| 5 | other issues, the close needs a `note`: one sentence, at most 300 characters. It | |
| 6 | is posted as a comment (`Closed as won't fix: …` when the reason is `dropped`, | |
| 7 | `Closed: …` otherwise). A missing note is `reason_required`; a note that is too | |
| 8 | long or not text is `invalid_note`. | |
| 3 | An open issue labelled `bug` is answered, fixed or closed as won't fix within a limit. | |
| 9 | 4 | |
| 10 | - Page: the close form has a "why" field beside the reason select; it is | |
| 11 | optional for any issue that is not a bug. | |
| 12 | - API: `PATCH …/issues/:number` with `{ state: "closed", note }`; without it on a bug, `422 reason_required`. | |
| 13 | - MCP: `close` takes `note`; the same refusals. | |
| 14 | - Terminal: `close <issue> "why"`. The reason is a note on every issue, bug or not. | |
| 15 | The issue screen's `x` has no field for one, so it refuses a bug and points to the page. | |
| 16 | ||
| 17 | Exempt, because they say why themselves: the crew's live triage (`triage`), its | |
| 18 | stale sweeps (`stale`), and the forge's own closes (`system`: the sentinel, the | |
| 19 | superseded-pull sweep, the gate review, an app note withdrawn). Each is tested in | |
| 20 | `test/bug-close-doors.test.ts`. | |
| 21 | ||
| 22 | On a mirrored repository the close is forwarded to the origin, but the note is | |
| 23 | kept as a comment on this forge only: the origin does not see why. | |
| 5 | - **Which labels count.** `bug` makes an issue a bug. Also `high`, `priority: high` or `priority:high` (any case) makes it high priority. Pull requests are not bugs. | |
| 6 | - **Clocks.** 4 h from the issue's creation when high, 48 h otherwise (`src/flow/bugs.ts`). | |
| 7 | - **Pickup order.** Queued agent runs about an open bug start before every other run (`startOrder` in `src/crew/dispatch.ts`). This is policy: bugs also outrank the team dials, which then order the rest. Within each side the queue keeps its order. | |
| 8 | - **Alarm.** One `bug_clock` alarm names the bug that has gone longest past its clock, and how many more there are. It appears wherever alarms do (home, `/boards`, MCP and API telemetry). For a guest or an agent it counts public repositories only. | |
| 9 | - **Tile.** Home's **Open bugs** tile (admins only) shows the open count and how many are past their clock; it links to `/boards`. | |
| 10 | - **Terminal.** The **Bugs** palette entry shows the same count (admins who are people) and the alarm (cut to public for everyone else). | |
| 11 | - **Closing.** Closing a bug needs a reason: see `bug-close.md`. | |
| 12 | - **Not yet.** The polish share, shortcuts and hunters' findings of #8709 are not built. |
src/crew/dispatch.ts +11 −1
| @@ -42,6 +42,7 @@ import { accountActive } from "../core/accounts.ts"; | ||
| 42 | 42 | import { runningLimitSentence, runningRuns } from "../settings/quotas.ts"; |
| 43 | 43 | import { appendRunLog } from "../runs/log.ts"; |
| 44 | 44 | import { decideAndRecord } from "./ledger.ts"; |
| 45 | import { bugsFirst } from "../flow/bugs.ts"; | |
| 45 | 46 | import { byDials } from "./team_share.ts"; |
| 46 | 47 | import { gateQueue } from "../flow/valve_gate.ts"; |
| 47 | 48 | import { ARCHIVED_WHY, getRepoById, type Repo } from "../core/repos.ts"; |
| @@ -1403,6 +1404,15 @@ export function sweepAssigned(app: App): void { | ||
| 1403 | 1404 | /** Per app, the runs already told why they wait, so the log says it once. */ |
| 1404 | 1405 | const notedBy = new WeakMap<App, Set<string>>(); |
| 1405 | 1406 | |
| 1407 | /** | |
| 1408 | * The order queued agent runs are started in: the team dials first, then bugs | |
| 1409 | * ahead of everything (#8709), then the valves a person turned. A bug outranks the dials by policy: the clock | |
| 1410 | * is a promise, the dials a share of the rest. | |
| 1411 | */ | |
| 1412 | export function startOrder(app: App): ReturnType<typeof gateQueue> { | |
| 1413 | return gateQueue(app.db, bugsFirst(app.db, byDials(app.db, listQueued(app.db, "agent", 50)))); | |
| 1414 | } | |
| 1415 | ||
| 1406 | 1416 | /** Take queued runs, oldest first, up to the limit — one per issue at a time. */ |
| 1407 | 1417 | function pump( |
| 1408 | 1418 | app: App, |
| @@ -1423,7 +1433,7 @@ function pump( | ||
| 1423 | 1433 | notedBy.set(app, noted); |
| 1424 | 1434 | // Builder starts go to teams in proportion to their dials (`team_share.ts`), |
| 1425 | 1435 | // then through the valves a person turned (`flow/valve_gate.ts`). |
| 1426 | const gated = gateQueue(app.db, byDials(app.db, listQueued(app.db, "agent", 50))); | |
| 1436 | const gated = startOrder(app); | |
| 1427 | 1437 | for (const shut of gated.held) { |
| 1428 | 1438 | if (noted.has(shut.id)) continue; |
| 1429 | 1439 | noted.add(shut.id); |
src/flow/alarms.ts +4 −1
| @@ -11,6 +11,7 @@ import { landingStopped, stoppedSentence } from "../checks/gate_stopped.ts"; | ||
| 11 | 11 | import { redHref, redMain } from "../checks/red_main.ts"; |
| 12 | 12 | import { getRepo } from "../core/repos.ts"; |
| 13 | 13 | import { ownRepoPath } from "../kernel/own_repo.ts"; |
| 14 | import { bugClockAlarm } from "./bugs.ts"; | |
| 14 | 15 | import { alarmIdleCrew } from "./alarm_idle_crew.ts"; |
| 15 | 16 | import { noReleaseAlarm } from "./alarm_no_release.ts"; |
| 16 | 17 | import { redMainAlarm } from "./alarm_red_main.ts"; |
| @@ -19,7 +20,7 @@ import { alarmSilent } from "./alarm_silent.ts"; | ||
| 19 | 20 | import { alarmStuck } from "./alarm_stuck.ts"; |
| 20 | 21 | |
| 21 | 22 | export interface FiredAlarm { |
| 22 | readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "silent" | "repeat" | "unreachable"; | |
| 23 | readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "bug_clock" | "silent" | "repeat" | "unreachable"; | |
| 23 | 24 | readonly says: string; |
| 24 | 25 | readonly since: string; |
| 25 | 26 | /** A same-origin path to the item the alarm names. */ |
| @@ -59,5 +60,7 @@ export function firedAlarms( | ||
| 59 | 60 | } |
| 60 | 61 | const release = noReleaseAlarm(db, now); |
| 61 | 62 | if (release.fired) out.push({ key: "no_release", says: release.says, since: release.since!, href: "/releases" }); |
| 63 | const bug = bugClockAlarm(db, now, opts.publicOnly ? { publicOnly: true } : {}); | |
| 64 | if (bug.fired) out.push({ key: "bug_clock", says: bug.says, since: bug.since!, href: bug.href }); | |
| 62 | 65 | return out; |
| 63 | 66 | } |
src/flow/bugs.ts added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | /** | |
| 2 | * Bugs on a clock (#8709): an open issue labelled `bug` is answered, fixed or | |
| 3 | * closed as won't fix, within 4 hours when it is also labelled `high` and 48 | |
| 4 | * hours otherwise. Pure over the db and `now`. Dispatch starts bugs ahead of | |
| 5 | * other work (`bugsFirst`), home counts them (`countBugs`), and one flow | |
| 6 | * alarm names the oldest one past its clock (`bugClockAlarm`). | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import type { Run } from "../core/runs.ts"; | |
| 11 | ||
| 12 | const HOUR_MS = 3_600_000; | |
| 13 | export const HIGH_BUG_MS = 4 * HOUR_MS; | |
| 14 | export const LOW_BUG_MS = 48 * HOUR_MS; | |
| 15 | ||
| 16 | const HIGH_NAMES = "('high', 'priority: high', 'priority:high')"; | |
| 17 | ||
| 18 | export interface OpenBug { | |
| 19 | readonly repo: string; | |
| 20 | readonly number: number; | |
| 21 | readonly createdAt: string; | |
| 22 | readonly high: boolean; | |
| 23 | /** When its clock runs out (ISO). */ | |
| 24 | readonly dueAt: string; | |
| 25 | } | |
| 26 | ||
| 27 | export interface BugCount { | |
| 28 | readonly open: number; | |
| 29 | readonly overdue: number; | |
| 30 | } | |
| 31 | ||
| 32 | interface BugRow { | |
| 33 | readonly owner: string | null; | |
| 34 | readonly name: string | null; | |
| 35 | readonly number: number; | |
| 36 | readonly created_at: string; | |
| 37 | readonly high: number; | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * The one open-bug query. `named` joins the repository and its owner (for the | |
| 42 | * alarm); the count needs neither, and without them it reads only issue and labels. | |
| 43 | */ | |
| 44 | function readBugRows(db: Database, named: boolean, publicOnly: boolean): BugRow[] { | |
| 45 | const repo = named ? "JOIN repo r ON r.id = i.repo_id JOIN user u ON u.id = r.owner_id" : ""; | |
| 46 | const who = named ? "u.username AS owner, r.name AS name" : "NULL AS owner, NULL AS name"; | |
| 47 | return db | |
| 48 | .query<BugRow, []>( | |
| 49 | `SELECT ${who}, ${named ? "i.number" : "0 AS number"}, i.created_at, | |
| 50 | EXISTS (SELECT 1 FROM issue_label h JOIN label hl ON hl.id = h.label_id | |
| 51 | WHERE h.issue_id = i.id AND hl.name COLLATE NOCASE IN ${HIGH_NAMES}) AS high | |
| 52 | FROM issue i ${repo} | |
| 53 | JOIN issue_label il ON il.issue_id = i.id | |
| 54 | JOIN label l ON l.id = il.label_id AND l.name = 'bug' COLLATE NOCASE | |
| 55 | WHERE i.kind = 'issue' AND i.state = 'open' ${named && publicOnly ? "AND r.visibility = 'public'" : ""} | |
| 56 | ORDER BY i.created_at`, | |
| 57 | ) | |
| 58 | .all(); | |
| 59 | } | |
| 60 | ||
| 61 | const dueMs = (r: BugRow): number => Date.parse(r.created_at) + (r.high === 1 ? HIGH_BUG_MS : LOW_BUG_MS); | |
| 62 | ||
| 63 | /** Every open bug in a public or private repository alike; `publicOnly` leaves private ones out. */ | |
| 64 | export function openBugs(db: Database, opts: { readonly publicOnly?: boolean } = {}): OpenBug[] { | |
| 65 | return readBugRows(db, true, opts.publicOnly === true).map((r) => ({ | |
| 66 | repo: `${r.owner}/${r.name}`, | |
| 67 | number: r.number, | |
| 68 | createdAt: r.created_at, | |
| 69 | high: r.high === 1, | |
| 70 | dueAt: new Date(dueMs(r)).toISOString(), | |
| 71 | })); | |
| 72 | } | |
| 73 | ||
| 74 | export function countBugs(db: Database, now: Date): BugCount { | |
| 75 | const rows = readBugRows(db, false, false); | |
| 76 | return { open: rows.length, overdue: rows.filter((r) => dueMs(r) <= now.getTime()).length }; | |
| 77 | } | |
| 78 | ||
| 79 | export interface BugClockAlarm { | |
| 80 | readonly fired: boolean; | |
| 81 | /** When the oldest overdue bug's clock ran out; only when fired. */ | |
| 82 | readonly since?: string; | |
| 83 | readonly says: string; | |
| 84 | readonly href: string; | |
| 85 | } | |
| 86 | ||
| 87 | /** One alarm for all the bugs past their clock, naming the one that has waited longest. */ | |
| 88 | export function bugClockAlarm(db: Database, now: Date, opts: { readonly publicOnly?: boolean } = {}): BugClockAlarm { | |
| 89 | const late = openBugs(db, opts) | |
| 90 | .filter((b) => Date.parse(b.dueAt) <= now.getTime()) | |
| 91 | .sort((a, b) => Date.parse(a.dueAt) - Date.parse(b.dueAt)); | |
| 92 | const first = late[0]; | |
| 93 | if (!first) return { fired: false, says: "No bug is past its clock.", href: "/boards" }; | |
| 94 | const hours = Math.floor((now.getTime() - Date.parse(first.dueAt)) / HOUR_MS); | |
| 95 | const more = late.length > 1 ? ` and ${late.length - 1} more` : ""; | |
| 96 | return { | |
| 97 | fired: true, | |
| 98 | since: first.dueAt, | |
| 99 | says: `Bug past its clock: ${first.repo}#${first.number}${more} still open, ${hours} h over its ${first.high ? "4 h" : "48 h"} limit. Fix it or close it as won't fix, with a reason.`, | |
| 100 | href: `/${first.repo}/issues/${first.number}`, | |
| 101 | }; | |
| 102 | } | |
| 103 | ||
| 104 | /** Queued runs with those about an open bug first; each side keeps its order. */ | |
| 105 | export function bugsFirst(db: Database, queued: Run[]): Run[] { | |
| 106 | const ids = queued.flatMap((r) => (r.issueId ? [r.issueId] : [])); | |
| 107 | if (ids.length === 0 || queued.length < 2) return queued; | |
| 108 | const marks = ids.map(() => "?").join(","); | |
| 109 | const bugs = new Set( | |
| 110 | db | |
| 111 | .query<{ id: string }, string[]>( | |
| 112 | `SELECT i.id FROM issue i JOIN issue_label il ON il.issue_id = i.id | |
| 113 | JOIN label l ON l.id = il.label_id AND l.name = 'bug' COLLATE NOCASE | |
| 114 | WHERE i.id IN (${marks}) AND i.state = 'open'`, | |
| 115 | ) | |
| 116 | .all(...ids) | |
| 117 | .map((r) => r.id), | |
| 118 | ); | |
| 119 | if (bugs.size === 0) return queued; | |
| 120 | const isBug = (r: Run) => r.issueId !== null && bugs.has(r.issueId); | |
| 121 | return [...queued.filter(isBug), ...queued.filter((r) => !isBug(r))]; | |
| 122 | } |
src/flow/kpis.ts +7 −1
| @@ -24,6 +24,7 @@ import { ceilingHitAt, ceilingReached } from "./ceiling.ts"; | ||
| 24 | 24 | import type { IdleReason } from "../crew/idle_reason.ts"; |
| 25 | 25 | import { lastOffBox } from "../snapshot/off_box.ts"; |
| 26 | 26 | import { readReach, type Reach } from "./reach.ts"; |
| 27 | import { countBugs, type BugCount } from "./bugs.ts"; | |
| 27 | 28 | import { HOURS, median } from "./throughput.ts"; |
| 28 | 29 | |
| 29 | 30 | const HOUR_MS = 3_600_000; |
| @@ -48,6 +49,8 @@ export interface Kpis { | ||
| 48 | 49 | readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null; /** When the run that crossed the cap was created (ISO); null while under it. */ readonly hitAt?: string | null }; |
| 49 | 50 | /** When a snapshot was last known off the machine (a bucket destination or the mirror, `off_box.ts`); null when never. */ |
| 50 | 51 | readonly backupAt: string | null; |
| 52 | /** Open bugs, and those past their clock (`bugs.ts`). */ | |
| 53 | readonly bugs?: BugCount; | |
| 51 | 54 | /** The verdict on what is in the bucket, read by this process (`snapshot/bucket.ts`); null when the forge is not watching one. */ |
| 52 | 55 | readonly backup?: BackupVerdict | null; |
| 53 | 56 | /** Accounts, visitors and page speed (`reach.ts`). */ |
| @@ -55,7 +58,7 @@ export interface Kpis { | ||
| 55 | 58 | } |
| 56 | 59 | |
| 57 | 60 | /** The parts `readKpis` names to its `time` callback, in the order it runs them. */ |
| 58 | export const KPI_PARTS = ["kpis.landed", "kpis.live", "kpis.checks", "kpis.builders", "kpis.spend", "kpis.reach", "kpis.backup"] as const; | |
| 61 | export const KPI_PARTS = ["kpis.landed", "kpis.live", "kpis.checks", "kpis.builders", "kpis.spend", "kpis.reach", "kpis.backup", "kpis.bugs"] as const; | |
| 59 | 62 | export type KpiPart = (typeof KPI_PARTS)[number]; |
| 60 | 63 | export type KpiTimer = <T>(name: KpiPart, fn: () => T) => T; |
| 61 | 64 | |
| @@ -173,7 +176,10 @@ export function readKpis( | ||
| 173 | 176 | // Read from the bucket when it is watched; a job's own report only where nothing watches. |
| 174 | 177 | const backupAt = backup !== null ? backup.newestAt : forge.dataDir === undefined ? null : lastOffBox(forge.dataDir); |
| 175 | 178 | |
| 179 | const bugs = time("kpis.bugs", () => countBugs(db, now)); | |
| 180 | ||
| 176 | 181 | return { |
| 182 | bugs, | |
| 177 | 183 | backupAt, |
| 178 | 184 | backup, |
| 179 | 185 | builders, |
src/tui/screens/bugs.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** | |
| 2 | * Bugs on a clock in the terminal (#8709): the figure home's Open bugs tile | |
| 3 | * shows and the bug_clock alarm, from `flow/bugs.ts`. Gated as the page is: | |
| 4 | * the count is a person-admin's (the tile counts every repository), and any | |
| 5 | * other reader, an agent or a guest included, sees only the alarm cut to | |
| 6 | * public repositories (`publicOnly`). Read-only; Esc closes it. | |
| 7 | */ | |
| 8 | ||
| 9 | import { bugClockAlarm, countBugs } from "../../flow/bugs.ts"; | |
| 10 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 11 | import { style } from "../term/style.ts"; | |
| 12 | import { truncate, wrap } from "../term/width.ts"; | |
| 13 | import type { Session } from "../session.ts"; | |
| 14 | import { fill, len, split } from "../ui/layout.ts"; | |
| 15 | import type { PaletteEntry } from "./entries.ts"; | |
| 16 | ||
| 17 | /** What the screen says, one line each. */ | |
| 18 | export function bugLines(f: Session, now = new Date()): string[] { | |
| 19 | const admin = f.user?.isAdmin === true && f.user.kind !== "agent"; | |
| 20 | const alarm = bugClockAlarm(f.db, now, admin ? {} : { publicOnly: true }); | |
| 21 | const count = admin ? countBugs(f.db, now) : undefined; | |
| 22 | return [ | |
| 23 | ...(count === undefined | |
| 24 | ? [] | |
| 25 | : [`Open bugs: ${count.open}, ${count.overdue > 0 ? `${count.overdue} past its clock` : "none past its clock"}`, ""]), | |
| 26 | alarm.says, | |
| 27 | "", | |
| 28 | "Clocks: 4 h for a bug labelled high, 48 h otherwise.", | |
| 29 | ]; | |
| 30 | } | |
| 31 | ||
| 32 | export function bugsScreen(f: Session, ctx: Ctx): Screen { | |
| 33 | return { | |
| 34 | title: "Bugs", | |
| 35 | keys: [], | |
| 36 | render(area, s) { | |
| 37 | const c = ctx.theme.c; | |
| 38 | const [body] = split(area, "v", [fill(1), len(0)]); | |
| 39 | const lines = bugLines(f).flatMap((l) => (l === "" ? [""] : wrap(l, Math.max(10, body!.w)))); | |
| 40 | lines.slice(0, body!.h).forEach((l, i) => | |
| 41 | s.text(body!.x, body!.y + i, truncate(l, body!.w), style(c.fg, c.bg), body!.x + body!.w), | |
| 42 | ); | |
| 43 | }, | |
| 44 | key: () => false, | |
| 45 | }; | |
| 46 | } | |
| 47 | ||
| 48 | /** Offered to everyone: what a reader sees is cut inside, as the page cuts it. */ | |
| 49 | export const entry: PaletteEntry = { | |
| 50 | id: "bugs", | |
| 51 | title: "Bugs", | |
| 52 | hint: "open bugs and the bug clock", | |
| 53 | after: ["boards"], | |
| 54 | offered: () => true, | |
| 55 | run: (session, ctx) => ctx.push(bugsScreen(session, ctx)), | |
| 56 | }; |
src/tui/screens/entries.ts +2
| @@ -14,6 +14,7 @@ import { entry as archived } from "./archived.ts"; | ||
| 14 | 14 | import { entry as audit } from "./audit.ts"; |
| 15 | 15 | import { entry as bets } from "./bets.ts"; |
| 16 | 16 | import { entry as boards } from "./boards.ts"; |
| 17 | import { entry as bugs } from "./bugs.ts"; | |
| 17 | 18 | import { entry as bulk } from "./bulk.ts"; |
| 18 | 19 | import { entry as cloud } from "./cloud.ts"; |
| 19 | 20 | import { entry as connections } from "./connections.ts"; |
| @@ -63,6 +64,7 @@ export interface PaletteEntry { | ||
| 63 | 64 | export const ENTRIES: readonly PaletteEntry[] = [ |
| 64 | 65 | sentinel, |
| 65 | 66 | boards, |
| 67 | bugs, | |
| 66 | 68 | homePins, |
| 67 | 69 | system, |
| 68 | 70 | telemetry, |
src/web/studio/kpi_row.ts +10
| @@ -149,6 +149,16 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now | ||
| 149 | 149 | null, |
| 150 | 150 | overdue ? "attn" : null, |
| 151 | 151 | )} |
| 152 | ${k.bugs === undefined ? null : tile( | |
| 153 | "Open bugs", | |
| 154 | "/boards", | |
| 155 | String(k.bugs.open), | |
| 156 | `${k.bugs.open} open ${k.bugs.open === 1 ? "bug" : "bugs"}`, | |
| 157 | k.bugs.overdue > 0 ? `${k.bugs.overdue} past its clock` : "none past its clock", | |
| 158 | "Open bugs, and those past their clock: 4 h high, 48 h otherwise", | |
| 159 | null, | |
| 160 | k.bugs.overdue > 0 ? "attn" : null, | |
| 161 | )} | |
| 152 | 162 | ${tile("Visitors", "/boards/reach", visitorsValue(k.reach), `${visitorsValue(k.reach)}, ${visitorsNote(k.reach)}`, visitorsNote(k.reach), "Distinct browsers per day, summed over 7 days", visitorsSpark(k.reach.visitorDays))} |
| 153 | 163 | ${tile("Accounts", "/boards/reach", usersValue(k.reach), usersValue(k.reach), "signed in today", "Accounts signed in today (UTC), of all accounts")} |
| 154 | 164 | ${tile( |
test/flow-bugs.test.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** Bugs on a clock (#8709): 4 h high, 48 h otherwise; ahead of other work; counted on home. */ | |
| 2 | import { afterAll, beforeAll, describe, 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 { createUser } from "../src/core/users.ts"; | |
| 8 | import { bugClockAlarm, bugsFirst, countBugs } from "../src/flow/bugs.ts"; | |
| 9 | import { firedAlarms } from "../src/flow/alarms.ts"; | |
| 10 | import { createRun, type Run } from "../src/core/runs.ts"; | |
| 11 | import { startOrder } from "../src/crew/dispatch.ts"; | |
| 12 | import { bugLines } from "../src/tui/screens/bugs.ts"; | |
| 13 | import type { Session } from "../src/tui/session.ts"; | |
| 14 | ||
| 15 | const NOW = new Date("2026-11-02T12:00:00.000Z"); | |
| 16 | const ago = (h: number): string => new Date(NOW.getTime() - h * 3_600_000).toISOString(); | |
| 17 | ||
| 18 | let dir: string; | |
| 19 | let app: App; | |
| 20 | let n = 0; | |
| 21 | ||
| 22 | beforeAll(async () => { | |
| 23 | dir = await mkdtemp(join(tmpdir(), "bugs-")); | |
| 24 | app = createApp({ dataDir: dir, port: 0 }); | |
| 25 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 26 | if (!u.ok) throw new Error(u.error); | |
| 27 | app.db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r', ?1, 'r')", [u.value.id]); | |
| 28 | for (const name of ["bug", "high"]) app.db.run("INSERT INTO label (id, repo_id, name) VALUES (?1, 'r', ?1)", [name]); | |
| 29 | }); | |
| 30 | afterAll(async () => { | |
| 31 | app.close?.(); | |
| 32 | await rm(dir, { recursive: true, force: true }); | |
| 33 | }); | |
| 34 | ||
| 35 | function issue(hoursOld: number, labels: string[], state = "open"): string { | |
| 36 | n++; | |
| 37 | const id = `i${n}`; | |
| 38 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, author_id, state, created_at) VALUES (?1, 'r', ?2, 'issue', 't', (SELECT id FROM user LIMIT 1), ?3, ?4)", [id, n, state, ago(hoursOld)]); | |
| 39 | for (const l of labels) app.db.run("INSERT INTO issue_label (issue_id, label_id) VALUES (?1, ?2)", [id, l]); | |
| 40 | return id; | |
| 41 | } | |
| 42 | ||
| 43 | describe("bug clock", () => { | |
| 44 | test("nothing fires with no bug, or a bug inside its clock", () => { | |
| 45 | expect(bugClockAlarm(app.db, NOW).fired).toBe(false); | |
| 46 | issue(3, ["bug", "high"]); | |
| 47 | issue(47, ["bug"]); | |
| 48 | issue(100, []); | |
| 49 | issue(100, ["bug"], "closed"); | |
| 50 | expect(bugClockAlarm(app.db, NOW).fired).toBe(false); | |
| 51 | expect(countBugs(app.db, NOW)).toEqual({ open: 2, overdue: 0 }); | |
| 52 | }); | |
| 53 | ||
| 54 | test("a high bug past 4 h raises one alarm, counted and listed with the flow alarms", () => { | |
| 55 | issue(5, ["bug", "high"]); | |
| 56 | issue(49, ["bug"]); | |
| 57 | const a = bugClockAlarm(app.db, NOW); | |
| 58 | expect(a.fired).toBe(true); | |
| 59 | expect(a.says).toContain("and 1 more"); | |
| 60 | expect(countBugs(app.db, NOW)).toEqual({ open: 4, overdue: 2 }); | |
| 61 | const fired = firedAlarms(app.db, NOW).filter((x) => x.key === "bug_clock"); | |
| 62 | expect(fired).toHaveLength(1); | |
| 63 | expect(fired[0]!.href).toMatch(/^\/alice\/r\/issues\/\d+$/); | |
| 64 | }); | |
| 65 | ||
| 66 | test("dispatch starts a queued bug run before a feature run that queued first", () => { | |
| 67 | const feature = issue(1, []); | |
| 68 | const bug = issue(1, ["bug"]); | |
| 69 | createRun(app.db, { repoId: "r", kind: "agent", issueId: feature }); | |
| 70 | createRun(app.db, { repoId: "r", kind: "agent", issueId: bug }); | |
| 71 | const order = startOrder(app).order.map((r) => r.issueId); | |
| 72 | expect(order.indexOf(bug)).toBeLessThan(order.indexOf(feature)); | |
| 73 | }); | |
| 74 | ||
| 75 | test("the terminal screen counts for an admin and cuts the alarm to public for a guest", () => { | |
| 76 | issue(60, ["bug"]); | |
| 77 | const admin = { db: app.db, user: { isAdmin: true, kind: "person" } } as unknown as Session; | |
| 78 | const guest = { db: app.db, user: undefined } as unknown as Session; | |
| 79 | expect(bugLines(admin, NOW).join("\n")).toContain("Open bugs:"); | |
| 80 | expect(bugLines(admin, NOW).join("\n")).toContain("Bug past its clock"); | |
| 81 | expect(bugLines(guest, NOW).join("\n")).not.toContain("Open bugs:"); | |
| 82 | app.db.run("UPDATE repo SET visibility = 'private' WHERE id = 'r'"); | |
| 83 | expect(bugLines(guest, NOW).join("\n")).toContain("No bug is past its clock"); | |
| 84 | expect(bugLines(admin, NOW).join("\n")).toContain("Bug past its clock"); | |
| 85 | app.db.run("UPDATE repo SET visibility = 'public' WHERE id = 'r'"); | |
| 86 | }); | |
| 87 | ||
| 88 | test("bugs start ahead of other work, each side in its own order", () => { | |
| 89 | const feature = issue(1, []); | |
| 90 | const bug = issue(1, ["bug"]); | |
| 91 | const run = (issueId: string): Run => ({ issueId }) as unknown as Run; | |
| 92 | const out = bugsFirst(app.db, [run(feature), run(bug)]); | |
| 93 | expect(out.map((r) => r.issueId)).toEqual([bug, feature]); | |
| 94 | }); | |
| 95 | }); | |
| 96 | ||
| 97 | describe("home's bug figure", () => { | |
| 98 | test("the tile shows the open count and how many are past their clock", async () => { | |
| 99 | const { kpiRowFor } = await import("../src/web/studio/kpi_row.ts"); | |
| 100 | const { NO_REACH } = await import("../src/flow/reach.ts"); | |
| 101 | const k = { landedHours: new Array<number>(24).fill(0), landed: 0, live: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null, reach: NO_REACH }; | |
| 102 | expect(kpiRowFor({ ...k, bugs: { open: 4, overdue: 2 } }, null).toString()).toContain("2 past its clock"); | |
| 103 | expect(kpiRowFor(k, null).toString()).not.toContain("Open bugs"); | |
| 104 | }); | |
| 105 | }); |
test/helpers/reach-tables.ts +6
| @@ -6,3 +6,9 @@ export function reachTables(db: Database): void { | ||
| 6 | 6 | db.run("CREATE TABLE user (id TEXT)"); |
| 7 | 7 | db.run("CREATE TABLE page_vital (day TEXT, visitor TEXT, load REAL, inp REAL)"); |
| 8 | 8 | } |
| 9 | ||
| 10 | /** The tables `countBugs` reads, bare, beside a small `issue` table of the test's own. */ | |
| 11 | export function bugTables(db: Database): void { | |
| 12 | db.run("CREATE TABLE issue_label (issue_id TEXT, label_id TEXT)"); | |
| 13 | db.run("CREATE TABLE label (id TEXT, name TEXT)"); | |
| 14 | } |
test/home-kpi-page.test.ts +2 −2
| @@ -25,7 +25,7 @@ afterAll(async () => { | ||
| 25 | 25 | await rm(dataDir, { recursive: true, force: true }); |
| 26 | 26 | }); |
| 27 | 27 | |
| 28 | test("home renders the nine tiles; only an admin sees them", async () => { | |
| 28 | test("home renders the ten tiles; only an admin sees them", async () => { | |
| 29 | 29 | const mk = async (username: string, first = false): Promise<User> => { |
| 30 | 30 | const r = await createUser(app.db, { |
| 31 | 31 | origin: "seed", |
| @@ -60,7 +60,7 @@ test("home renders the nine tiles; only an admin sees them", async () => { | ||
| 60 | 60 | view(yesterday, "z", 400, 160); |
| 61 | 61 | const a = await home(admin); |
| 62 | 62 | expect(a).toContain('class="stats kpi-row mb"'); |
| 63 | expect((a.match(/class="stat"/g) ?? []).length).toBe(9); | |
| 63 | expect((a.match(/class="stat"/g) ?? []).length).toBe(10); | |
| 64 | 64 | for (const href of ["/board", "/releases", "/boards", "/settings/crew", "/settings/backups"]) expect(a).toContain(`class="stat" href="${href}"`); |
| 65 | 65 | // The tiles are §5 stats (#3982): none of the page-local kpi classes is left on home. |
| 66 | 66 | for (const name of ["kpis", "kpi", "kpi-value", "kpi-note", "kpi-spark"]) expect(a).not.toMatch(new RegExp(`class="[^"]*\\b${name}\\b(?!-)`)); |
test/home-kpi-row.test.ts +9 −5
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** Home's figures row (#2871): the tiles, their links, studio words only, a sparkline of 24 points and one of 30 days of visitors (#3186). */ |
| 2 | 2 | import { Database } from "bun:sqlite"; |
| 3 | import { reachTables } from "./helpers/reach-tables.ts"; | |
| 3 | import { bugTables, reachTables } from "./helpers/reach-tables.ts"; | |
| 4 | 4 | import { NO_REACH, readReach } from "../src/flow/reach.ts"; |
| 5 | 5 | import { describe, expect, test } from "bun:test"; |
| 6 | 6 | import { mkdtempSync, rmSync } from "node:fs"; |
| @@ -125,7 +125,8 @@ describe("the builders figure from the database", () => { | ||
| 125 | 125 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); |
| 126 | 126 | db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)"); |
| 127 | 127 | db.run("CREATE TABLE pull (carried INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); |
| 128 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); | |
| 128 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT, kind TEXT, state TEXT, created_at TEXT)"); | |
| 129 | bugTables(db); | |
| 129 | 130 | reachTables(db); |
| 130 | 131 | db.run("INSERT INTO repo VALUES ('autonomous', 3, 'main', 'a'), ('autonomous', 1, 'main', 'b'), ('off', 8, 'main', 'c')"); |
| 131 | 132 | db.run("INSERT INTO run (id, kind, state) VALUES ('1','agent','running'), ('2','agent','running'), ('3','agent','queued'), ('4','check','running')"); |
| @@ -189,7 +190,8 @@ describe("the spend figure from the database", () => { | ||
| 189 | 190 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); |
| 190 | 191 | db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)"); |
| 191 | 192 | db.run("CREATE TABLE pull (carried INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); |
| 192 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); | |
| 193 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT, kind TEXT, state TEXT, created_at TEXT)"); | |
| 194 | bugTables(db); | |
| 193 | 195 | reachTables(db); |
| 194 | 196 | db.run( |
| 195 | 197 | `INSERT INTO run (id, kind, cost_usd, created_at) VALUES |
| @@ -241,7 +243,8 @@ describe("home kpi row: the last off-box backup", () => { | ||
| 241 | 243 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); |
| 242 | 244 | db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)"); |
| 243 | 245 | db.run("CREATE TABLE pull (carried INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); |
| 244 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); | |
| 246 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT, kind TEXT, state TEXT, created_at TEXT)"); | |
| 247 | bugTables(db); | |
| 245 | 248 | reachTables(db); |
| 246 | 249 | expect(readKpis(db, NOW, { ceilingUsd: null, dataDir: dir }).backupAt).toBeNull(); |
| 247 | 250 | recordMirror(dir, { where: "b2", attemptAt: hoursAgo(1), reachable: true, newest: "s1", newestAt: hoursAgo(3) }); |
| @@ -431,7 +434,8 @@ describe("the forge ceiling hit (#5798)", () => { | ||
| 431 | 434 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); |
| 432 | 435 | db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)"); |
| 433 | 436 | db.run("CREATE TABLE pull (carried INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); |
| 434 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); | |
| 437 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT, kind TEXT, state TEXT, created_at TEXT)"); | |
| 438 | bugTables(db); | |
| 435 | 439 | reachTables(db); |
| 436 | 440 | db.run( |
| 437 | 441 | `INSERT INTO run (id, kind, cost_usd, created_at) VALUES |
test/home-timing.test.ts +1 −1
| @@ -24,7 +24,7 @@ const names = (parts: string[]): string[] => parts.map((p) => p.split(";")[0]!); | ||
| 24 | 24 | |
| 25 | 25 | test("a cold look at the owner's home names each part of the kpis read", async () => { |
| 26 | 26 | const cold = names(await timing(f.ownerCookie)); |
| 27 | expect(KPI_PARTS).toEqual(["kpis.landed", "kpis.live", "kpis.checks", "kpis.builders", "kpis.spend", "kpis.reach", "kpis.backup"]); | |
| 27 | expect(KPI_PARTS).toEqual(["kpis.landed", "kpis.live", "kpis.checks", "kpis.builders", "kpis.spend", "kpis.reach", "kpis.backup", "kpis.bugs"]); | |
| 28 | 28 | expect(cold).toEqual(expect.arrayContaining(["kpis", ...KPI_PARTS])); |
| 29 | 29 | }); |
| 30 | 30 |
test/palette-roster.test.ts +4
| @@ -74,6 +74,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 74 | 74 | ["signins", "Signed in", "your browsers and tokens; d signs a browser out"], |
| 75 | 75 | ["sentinel", "Sentinel", "the forge's open faults"], |
| 76 | 76 | ["boards", "Boards", "the forge's boards and their panels"], |
| 77 | ["bugs", "Bugs", "open bugs and the bug clock"], | |
| 77 | 78 | ["home-pins", "Pinned panels", "the panels pinned to home: reorder and unpin"], |
| 78 | 79 | ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"], |
| 79 | 80 | ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"], |
| @@ -171,6 +172,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 171 | 172 | ["forge-spend", "Forge spend", "today and the last 24 hours, headroom per subscription"], |
| 172 | 173 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 173 | 174 | ["can", "What I can do", "your role and what it allows here"], |
| 175 | ["bugs", "Bugs", "open bugs and the bug clock"], | |
| 174 | 176 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 175 | 177 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 176 | 178 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| @@ -227,6 +229,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 227 | 229 | ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"], |
| 228 | 230 | ["model", "Model budget", "what your crew may spend, and add your own subscription"], |
| 229 | 231 | ["can", "What I can do", "your role and what it allows here"], |
| 232 | ["bugs", "Bugs", "open bugs and the bug clock"], | |
| 230 | 233 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 231 | 234 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 232 | 235 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| @@ -259,6 +262,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 259 | 262 | ["signin", "Sign in", undefined], |
| 260 | 263 | ["api", "API", "the forge's endpoints; run a GET"], |
| 261 | 264 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
| 265 | ["bugs", "Bugs", "open bugs and the bug clock"], | |
| 262 | 266 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 263 | 267 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 264 | 268 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
new above
offline
The terminal has no screen for this (add one in src/tui/ and docs/features/bugs.md), and the open-bug query is duplicated between openBugs and countBugs.