Public: anyone can read this; the crew can change it.
A fired flow alarm is one line at the top of home and on /boards, with one notification to the owner #6455
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6346-a-fired-flow-alarm-is-one-line-at-the-to → main 2 commits fork 3 threads · 3 unresolved
13 files
changed +277
−0
README.md +1
| @@ -562,6 +562,7 @@ work here. | ||
| 562 | 562 | default branches (force-push and deletion refused, honest fast-forwards |
| 563 | 563 | allowed). |
| 564 | 564 | - **System memory** — `/system`, for a forge admin who is a person (anyone else gets 403): the forge's RSS beside its JS heap (`heapStats()` from bun:jsc), hourly mean and max for the last 24 h, drawn on the server. Sampled once a minute into `memory_hour` in `app.db` (a week kept). RSS rising while the heap stays flat is native memory growth. In the terminal the same admin has a **System memory** command in the palette: the last 24 hours as text rows (hour, RSS mean/max, heap mean/max in MiB), read-only. The account menu links `/system` for that admin. |
| 565 | - **Flow alarms** — a firing alarm (red main, a stuck approved pull, an idle crew, no release for 90 min) shows as one linked line above home's feed and on `/boards` (admin), under the vitals line of the terminal's Flow dialog and home, as one phone notification when it starts (not repeated while it stands), and in the `flow` tool's `alarms` (MCP, `/api/v1/telemetry/flow`; an agent's cut to public repositories). Nothing firing draws nothing. | |
| 565 | 566 | - **Boards** — `/boards` also shows the pipeline (a row per stage, the bottleneck named) and the held counts (why each autonomous repository's ready issues wait; private repositories included), for the same admin who is a person; they sit as two cards under the board list, not as panels of a board, because they are not saved queries. The terminal's Boards screen does not draw them: its door to the same rows is the **Flow** dialog. `/boards/data`, for a forge admin who is a person (anyone else |
| 566 | 567 | gets a 404 on the page): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`), |
| 567 | 568 | drawn server-side as the chart chosen beside it (`chart=line|bar|area|number|table|heat`) |
src/crew/notify.ts +24
| @@ -40,6 +40,8 @@ import { newestDrills } from "../core/domain_drills.ts"; | ||
| 40 | 40 | import { health } from "../http/health.ts"; |
| 41 | 41 | import { forgeVitals } from "../actions/health.ts"; |
| 42 | 42 | import { RECOVERED, drainIncident, nextPage, observe, recoveredLine, type Watch } from "./incidents.ts"; |
| 43 | import { firedAlarms, type FiredAlarm } from "../flow/alarms.ts"; | |
| 44 | import { forgeCeiling } from "../flow/ceiling.ts"; | |
| 43 | 45 | import { askLinesFor } from "../web/studio/asks.ts"; |
| 44 | 46 | |
| 45 | 47 | const KEY = "notify_url"; |
| @@ -241,6 +243,25 @@ export function redLines( | ||
| 241 | 243 | return out; |
| 242 | 244 | } |
| 243 | 245 | |
| 246 | /** | |
| 247 | * The owner hears once when a flow alarm (#5930) starts firing. The id is the | |
| 248 | * alarm and when it began, so a standing alarm is the same ask on every tick | |
| 249 | * and a later one is new. Admin only: the words name private repositories. | |
| 250 | */ | |
| 251 | export function alarmLines( | |
| 252 | app: App, | |
| 253 | user: User, | |
| 254 | now = Date.now(), | |
| 255 | fired?: readonly FiredAlarm[], | |
| 256 | ): { id: string; line: string; items: readonly string[]; link: string }[] { | |
| 257 | if (!user.isAdmin || user.kind !== "user") return []; | |
| 258 | const alarms = fired ?? firedAlarms(app.db, new Date(now), { ceilingUsd: forgeCeiling(app.config) }); | |
| 259 | return alarms.map((a) => { | |
| 260 | const id = `alarm:${a.key}@${a.since}`; | |
| 261 | return { id, line: a.says, items: [id], link: a.href }; | |
| 262 | }); | |
| 263 | } | |
| 264 | ||
| 244 | 265 | export interface Notifier { |
| 245 | 266 | /** Resolves when the work queued so far is done. For tests and shutdown. */ |
| 246 | 267 | settled(): Promise<void>; |
| @@ -263,6 +284,8 @@ export interface NotifyOptions { | ||
| 263 | 284 | drainingSince?: () => number | null; |
| 264 | 285 | /** The causes the vitals hold now; the live vitals unless a test says. */ |
| 265 | 286 | causes?: () => readonly { cause: string; sentence: string }[]; |
| 287 | /** The alarms firing now; the live four unless a test says. */ | |
| 288 | alarms?: () => readonly FiredAlarm[]; | |
| 266 | 289 | } |
| 267 | 290 | |
| 268 | 291 | /** Long enough for a domain drill line to keep every command it names. */ |
| @@ -424,6 +447,7 @@ export function startNotifier(app: App, options: NotifyOptions): Notifier { | ||
| 424 | 447 | ...domainDrillLines(app, user, now(), options.forgeUrl), |
| 425 | 448 | ...mirrorLines(app, user, now()), |
| 426 | 449 | ...redLines(app, user, now()), |
| 450 | ...alarmLines(app, user, now(), options.alarms?.()), | |
| 427 | 451 | ]; |
| 428 | 452 | const before = seen.get(userId); |
| 429 | 453 | // What is gone may ask again later, and then it is new again. Seen by |
src/flow/alarm_stuck.ts +5
| @@ -18,6 +18,9 @@ export interface StuckAlarm { | ||
| 18 | 18 | readonly fired: boolean; |
| 19 | 19 | /** When the oldest stuck pull's last event happened; set only when fired. */ |
| 20 | 20 | readonly since?: string; |
| 21 | /** The pull it names, `owner/name` and number; set only when fired. */ | |
| 22 | readonly repo?: string; | |
| 23 | readonly number?: number; | |
| 21 | 24 | readonly says: string; |
| 22 | 25 | } |
| 23 | 26 | |
| @@ -129,6 +132,8 @@ export function alarmStuck(from: App | Database, now: Date, scope: StuckScope = | ||
| 129 | 132 | return { |
| 130 | 133 | fired: true, |
| 131 | 134 | since: last.at, |
| 135 | repo, | |
| 136 | number, | |
| 132 | 137 | says: `${repo}#${number} has sat approved for ${sat(now.getTime() - Date.parse(last.at))}; its last event was ${last.what} at ${hhmm}.`, |
| 133 | 138 | }; |
| 134 | 139 | } |
src/flow/alarms.ts added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | /** | |
| 2 | * The four flow alarms (#5930) read together: the ones that are firing, each | |
| 3 | * with its words, when it began and the page of the item it names. Home and | |
| 4 | * /boards draw them, the notifier tells the owner once per start, and the | |
| 5 | * MCP `flow` tool carries them. `publicOnly` leaves private repositories out | |
| 6 | * for a reader who must not learn their names. Pure over the db and `now`. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { redHref, redMain } from "../checks/red_main.ts"; | |
| 11 | import { getRepo } from "../core/repos.ts"; | |
| 12 | import { ownRepoPath } from "../kernel/own_repo.ts"; | |
| 13 | import { alarmIdleCrew } from "./alarm_idle_crew.ts"; | |
| 14 | import { noReleaseAlarm } from "./alarm_no_release.ts"; | |
| 15 | import { redMainAlarm } from "./alarm_red_main.ts"; | |
| 16 | import { alarmStuck } from "./alarm_stuck.ts"; | |
| 17 | ||
| 18 | export interface FiredAlarm { | |
| 19 | readonly key: "red_main" | "stuck" | "idle_crew" | "no_release"; | |
| 20 | readonly says: string; | |
| 21 | readonly since: string; | |
| 22 | /** A same-origin path to the item the alarm names. */ | |
| 23 | readonly href: string; | |
| 24 | } | |
| 25 | ||
| 26 | function redMainHref(db: Database): string { | |
| 27 | const [owner, name] = ownRepoPath().split("/"); | |
| 28 | const repo = owner && name ? getRepo(db, owner, name) : undefined; | |
| 29 | const red = repo ? redMain(db, repo.id) : undefined; | |
| 30 | return red ? redHref(red) : "/boards"; | |
| 31 | } | |
| 32 | ||
| 33 | export function firedAlarms( | |
| 34 | db: Database, | |
| 35 | now: Date, | |
| 36 | opts: { readonly publicOnly?: boolean; readonly ceilingUsd?: number | null } = {}, | |
| 37 | ): FiredAlarm[] { | |
| 38 | const out: FiredAlarm[] = []; | |
| 39 | const red = redMainAlarm(db, now); | |
| 40 | if (red.fired) out.push({ key: "red_main", says: red.says, since: red.since!, href: redMainHref(db) }); | |
| 41 | const stuck = alarmStuck(db, now, opts.publicOnly ? { publicOnly: true } : {}); | |
| 42 | if (stuck.fired) { | |
| 43 | out.push({ key: "stuck", says: stuck.says, since: stuck.since!, href: `/${stuck.repo}/pulls/${stuck.number}` }); | |
| 44 | } | |
| 45 | if (!opts.publicOnly) { | |
| 46 | const idle = alarmIdleCrew(db, now, { ceilingUsd: opts.ceilingUsd ?? null }); | |
| 47 | if (idle.fired) out.push({ key: "idle_crew", says: idle.says, since: idle.since!, href: "/boards" }); | |
| 48 | } | |
| 49 | const release = noReleaseAlarm(db, now); | |
| 50 | if (release.fired) out.push({ key: "no_release", says: release.says, since: release.since!, href: "/releases" }); | |
| 51 | return out; | |
| 52 | } |
src/system/telemetry.ts +3
| @@ -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 { firedAlarms } from "../flow/alarms.ts"; | |
| 11 | 12 | import { readVitals, vitalsLine, vitalsMisses } from "../flow/vitals.ts"; |
| 12 | 13 | import { readPipeline, verdict } from "../flow/pipeline.ts"; |
| 13 | 14 | import { type QueueReading, readQueues } from "../flow/queue.ts"; |
| @@ -36,6 +37,8 @@ export function flowFor(app: App, reader: Reader, now = new Date()) { | ||
| 36 | 37 | says: verdict(stages, now.getTime()), |
| 37 | 38 | // Open pulls, opened and landed in 1 h and 6 h, and conflicted, against #5924's targets (#5935). |
| 38 | 39 | vitals: { says: vitalsLine(vitals), misses: vitalsMisses(vitals), ...vitals }, |
| 40 | // The four alarms (#5930) that are firing now; empty when none is. | |
| 41 | alarms: firedAlarms(app.db, now, { publicOnly: reader.kind === "agent" }), | |
outdated
The comment above, |
||
| 39 | 42 | // Review runs in flight per reviewer, cut to public repositories for an agent (#4733). |
| 40 | 43 | reviewing: { says: reviewingSays(busy), reviewers: busy }, |
| 41 | 44 | conflicted: { says: conflictedSays(conflicted, now.getTime()), pulls: conflicted }, |
src/tui/forge.ts +2
| @@ -64,6 +64,7 @@ import { teamsScreen } from "./screens/teams.ts"; | ||
| 64 | 64 | import { domainsScreen } from "./screens/domains.ts"; |
| 65 | 65 | import { ownerScreen } from "./screens/owner.ts"; |
| 66 | 66 | import { resilienceLines, resilienceScreen } from "./screens/resilience.ts"; |
| 67 | import { forgeCeiling } from "../flow/ceiling.ts"; | |
| 67 | 68 | import { flowLines, flowScreen } from "./screens/flow.ts"; |
| 68 | 69 | import { readHeld } from "../flow/held.ts"; |
| 69 | 70 | import { memoryScreen, systemLines, systemScreen } from "./screens/system.ts"; |
| @@ -289,6 +290,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 289 | 290 | systemFor(session.app, flowReader).duplicates, |
| 290 | 291 | flowReader.kind === "agent" ? { queues: queuesFor(session.app, flowReader, new Date(), { batches: true }) } : undefined, |
| 291 | 292 | readHeld(session.app, flowReader.kind === "owner"), |
| 293 | forgeCeiling(session.app.config), | |
| 292 | 294 | ))); |
| 293 | 295 | }, |
| 294 | 296 | }); |
src/tui/screens/dashboard.ts +2
| @@ -98,6 +98,7 @@ import { issueScreen } from "./issue.ts"; | ||
| 98 | 98 | import { listPinned } from "../../actions/board-pins.ts"; |
| 99 | 99 | import { migrateScreen } from "./migrate.ts"; |
| 100 | 100 | import { newRepoScreen } from "./newrepo.ts"; |
| 101 | import { firedAlarms } from "../../flow/alarms.ts"; | |
| 101 | 102 | import { forgeCeiling } from "../../flow/ceiling.ts"; |
| 102 | 103 | import { pullScreen } from "./pull.ts"; |
| 103 | 104 | import { repoScreen } from "./repo.ts"; |
| @@ -269,6 +270,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 269 | 270 | : [ |
| 270 | 271 | ...kpiLines(readKpis(f.db, now, kpiForge(f.app)), commit === null ? null : { sha: commit, since: ago(BOOTED) }), |
| 271 | 272 | vitalsLine(readVitals(f.db, now)), |
| 273 | ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }).map((a) => a.says), | |
| 272 | 274 | ]; |
| 273 | 275 | // "Yours" is ownership plus the orgs you are on a team of; typing in the |
| 274 | 276 | // filter reaches every repository you can see. |
src/tui/screens/flow.ts +4
| @@ -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 { firedAlarms } from "../../flow/alarms.ts"; | |
| 21 | 22 | import { readVitals, vitalsLine } from "../../flow/vitals.ts"; |
| 22 | 23 | import { readQueues, redBatchWords } from "../../flow/queue.ts"; |
| 23 | 24 | import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts"; |
| @@ -41,6 +42,8 @@ export function flowLines( | ||
| 41 | 42 | agent?: { queues: ReturnType<typeof readQueues> } | undefined, |
| 42 | 43 | /** Why ready issues wait, per autonomous repository (`readHeld`, already cut to what the reader may see). */ |
| 43 | 44 | held?: readonly HeldRow[], |
| 45 | /** The forge's daily ceiling, which the idle-crew alarm needs to know it is not paused on purpose. */ | |
| 46 | ceilingUsd: number | null = null, | |
| 44 | 47 | ): string[] { |
| 45 | 48 | const stages = readPipeline(db, now); |
| 46 | 49 | const flow = readThroughput(db, stages.map((s) => s.key), now); |
| @@ -61,6 +64,7 @@ export function flowLines( | ||
| 61 | 64 | ...wrap(verdict(stages, now.getTime()), inner), |
| 62 | 65 | "", |
| 63 | 66 | ...wrap(vitalsLine(readVitals(db, now, agent !== undefined)), inner), |
| 67 | ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd }).flatMap((a) => wrap(a.says, inner)), | |
| 64 | 68 | "", |
| 65 | 69 | ...wrap(throughputSays(flow), inner), |
| 66 | 70 | "", |
src/web/pages/boards-view.ts +3
| @@ -20,6 +20,8 @@ import { html, when, type Html } from "../html.ts"; | ||
| 20 | 20 | import { fragment, page } from "../layout.ts"; |
| 21 | 21 | import { btn, card, chartBox, empty, field, selectField } from "../ui.ts"; |
| 22 | 22 | import { flowPanel } from "./flow-panel.ts"; |
| 23 | import { forgeCeiling } from "../../flow/ceiling.ts"; | |
| 24 | import { alarmLines, cachedAlarms } from "../studio/alarm_lines.ts"; | |
| 23 | 25 | import { isPinned, listPins } from "../../boards/pins.ts"; |
| 24 | 26 | import { panelData, runFor } from "../../boards/panel_data.ts"; |
| 25 | 27 | import { cellsOf, paramsOf, queryFields, readParams } from "./boards.ts"; |
| @@ -108,6 +110,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 108 | 110 | title: "Boards", |
| 109 | 111 | body: html`<div class="page-head"><h1>Boards</h1> |
| 110 | 112 | ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div> |
| 113 | ${alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config)))} | |
| 111 | 114 | ${card(make)} ${body} ${flow}`, |
| 112 | 115 | }); |
| 113 | 116 | notePhase(ctx.request, "render", performance.now() - rendering); |
src/web/studio/alarm_lines.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * The fired flow alarms (#5930) as lines: one `.small` line each, `says` | |
| 3 | * linked to the item it names, above the feed. Nothing fired draws nothing, | |
| 4 | * so there is no empty box and no layout shift. Owner only: the caller has | |
| 5 | * checked, because the words name private repositories. | |
| 6 | */ | |
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { firedAlarms, type FiredAlarm } from "../../flow/alarms.ts"; | |
| 9 | import { writeStamp } from "../../core/write_clock.ts"; | |
| 10 | import { Stamped } from "../../kernel/stamped.ts"; | |
| 11 | import { html, type Html } from "../html.ts"; | |
| 12 | import { HOME_MAX_AGE_MS } from "./home_cache.ts"; | |
| 13 | ||
| 14 | const caches = new WeakMap<Database, Stamped<FiredAlarm[]>>(); | |
| 15 | ||
| 16 | /** Valid like the vitals: while the write clock holds, 30 seconds at most. */ | |
| 17 | export function cachedAlarms(db: Database, now: Date, ceilingUsd: number | null): FiredAlarm[] { | |
| 18 | const stamp = writeStamp(db); | |
| 19 | if (stamp === undefined) return firedAlarms(db, now, { ceilingUsd }); | |
| 20 | let cache = caches.get(db); | |
| 21 | if (cache === undefined) { | |
| 22 | cache = new Stamped<FiredAlarm[]>({ maxEntries: 2, maxAgeMs: HOME_MAX_AGE_MS }); | |
| 23 | caches.set(db, cache); | |
| 24 | } | |
| 25 | return cache.get("alarms", stamp, () => firedAlarms(db, now, { ceilingUsd })); | |
| 26 | } | |
| 27 | ||
| 28 | export function alarmLines(alarms: readonly FiredAlarm[]): Html { | |
The page draws these lines, but the terminal's flow screen ( |
||
| 29 | if (alarms.length === 0) return html``; | |
| 30 | return html`<div class="stack" role="alert"> | |
| 31 | ${alarms.map((a) => html`<p class="small"><a href="${a.href}">${a.says}</a></p>`)} | |
| 32 | </div>`; | |
| 33 | } |
src/web/studio/home.ts +2
| @@ -52,6 +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 { alarmLines, cachedAlarms } from "./alarm_lines.ts"; | |
| 55 | 56 | import { kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts"; |
| 56 | 57 | import { feedCandidateCache } from "../../feed/feed.ts"; |
| 57 | 58 | import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| @@ -274,6 +275,7 @@ export async function studioHome( | ||
| 274 | 275 | ? timePhase(ctx.request, "kpis", () => kpiRow(ctx.app.db, kpiOpts), () => kpiCache(ctx.app.db)) |
| 275 | 276 | : liveLine(ctx)} |
| 276 | 277 | ${figures ? timePhase(ctx.request, "vitals", () => vitalsRow(ctx.app.db)) : null} |
| 278 | ${figures ? alarmLines(cachedAlarms(ctx.app.db, new Date(), ceilingUsd)) : null} | |
| 277 | 279 | ${timePhase(ctx.request, "waiting", () => waitingLine(ctx.app.db, user), () => waitingCache(ctx.app.db))} |
| 278 | 280 | ${figures |
| 279 | 281 | ? await timePhaseAsync(ctx.request, "panels", () => pinnedPanels(ctx.app, user), () => panelCache(ctx.app.db)) |
test/flow-alarm-lines.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** Fired flow alarms (#6346): one line above home's feed and on /boards, one notification per start, in the MCP flow reading. */ | |
| 2 | import { afterEach, 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 { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { id } from "../src/kernel/id.ts"; | |
| 11 | import { serve } from "../src/http/server.ts"; | |
| 12 | import { firedAlarms, type FiredAlarm } from "../src/flow/alarms.ts"; | |
| 13 | import { setNotifyUrl, startNotifier } from "../src/crew/notify.ts"; | |
| 14 | import { flowFor } from "../src/system/telemetry.ts"; | |
| 15 | import { alarmLines } from "../src/web/studio/alarm_lines.ts"; | |
| 16 | ||
| 17 | const dirs: string[] = []; | |
| 18 | afterEach(() => { | |
| 19 | for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); | |
| 20 | }); | |
| 21 | ||
| 22 | async function seed(mergedMinAgo?: number) { | |
| 23 | const dir = mkdtempSync(join(tmpdir(), "forge-alarm-lines-")); | |
| 24 | dirs.push(dir); | |
| 25 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 26 | const u = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true }); | |
| 27 | if (!u.ok) throw new Error(u.error); | |
| 28 | const repo = await createRepo(app.db, app.reposRoot, { ownerId: u.value.id, name: "r", visibility: "public", description: "r" }); | |
| 29 | if (!repo.ok) throw new Error(repo.error); | |
| 30 | if (mergedMinAgo !== undefined) { | |
| 31 | const at = new Date(Date.now() - mergedMinAgo * 60_000).toISOString(); | |
| 32 | const issueId = id(); | |
| 33 | app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, 1, 'pull', 'P', '', ?3, 'closed', ?4)`).run(issueId, repo.value.id, u.value.id, at); | |
| 34 | app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, 'b/1', '', '', ?3, ?4)`).run(issueId, repo.value.defaultBranch, repo.value.id, at); | |
| 35 | } | |
| 36 | return { app, user: u.value }; | |
| 37 | } | |
| 38 | ||
| 39 | const fired: FiredAlarm = { key: "no_release", says: "Main moved 91 min ago and nothing has gone live yet.", since: "2026-10-26T10:29:00.000Z", href: "/releases" }; | |
| 40 | ||
| 41 | describe("alarm lines", () => { | |
| 42 | test("a fired alarm is a line linking its item; none fired draws nothing", () => { | |
| 43 | expect(String(alarmLines([fired]))).toContain(`<a href="/releases">Main moved 91 min ago`); | |
| 44 | expect(String(alarmLines([]))).toBe(""); | |
| 45 | }); | |
| 46 | ||
| 47 | test("/boards and home show the line when an alarm fires, and nothing when none does", async () => { | |
| 48 | for (const [merged, want] of [[200, true], [undefined, false]] as const) { | |
| 49 | const { app, user } = await seed(merged); | |
| 50 | const servers = await serve(app); | |
| 51 | const headers = { authorization: `Bearer ${createToken(app.db, user.id, "t", "admin").token}` }; | |
| 52 | for (const path of ["/boards", "/"]) { | |
| 53 | const body = await (await fetch(servers.url + path, { headers })).text(); | |
| 54 | expect(body.includes("nothing has gone live"), `${path} ${String(merged)}`).toBe(want); | |
| 55 | expect(body.includes('role="alert"')).toBe(want); | |
| 56 | } | |
| 57 | await servers.stop?.(); | |
| 58 | } | |
| 59 | }); | |
| 60 | ||
| 61 | test("the MCP flow reading carries the fired alarms", async () => { | |
| 62 | const { app } = await seed(200); | |
| 63 | expect(flowFor(app, { kind: "owner" }).alarms.map((a) => a.key)).toEqual(["no_release"]); | |
| 64 | const none = await seed(); | |
| 65 | expect(flowFor(none.app, { kind: "owner" }).alarms).toEqual([]); | |
| 66 | expect(firedAlarms(none.app.db, new Date())).toEqual([]); | |
| 67 | }); | |
| 68 | ||
| 69 | test("the owner is told once when an alarm starts firing, not again while it stands", async () => { | |
| 70 | const { app, user } = await seed(); | |
| 71 | setNotifyUrl(app.db, user.id, "https://ntfy.example.test/topic"); | |
| 72 | const posts: string[] = []; | |
| 73 | let now = Date.parse("2026-10-26T12:00:00.000Z"); | |
| 74 | let on: FiredAlarm[] = []; | |
| 75 | const notifier = startNotifier(app, { | |
| 76 | forgeUrl: "https://forge.example.test", | |
| 77 | now: () => now, | |
| 78 | drainingSince: () => null, | |
| 79 | causes: () => [], | |
| 80 | alarms: () => on, | |
| 81 | send: async (_u, body) => { | |
| 82 | posts.push(body); | |
| 83 | return true; | |
| 84 | }, | |
| 85 | }); | |
| 86 | await notifier.settled(); | |
| 87 | const wake = async () => { | |
| 88 | now += 60_000; | |
| 89 | app.bus.append({ kind: "drill.finished", payload: {} }); | |
| 90 | await notifier.settled(); | |
| 91 | }; | |
| 92 | await wake(); | |
| 93 | expect(posts).toHaveLength(0); | |
| 94 | on = [fired]; | |
| 95 | await wake(); | |
| 96 | await wake(); | |
| 97 | expect(posts).toHaveLength(1); | |
| 98 | expect(posts[0]).toContain("nothing has gone live"); | |
| 99 | expect(posts[0]).toContain("https://forge.example.test/releases"); | |
| 100 | await notifier.close(); | |
| 101 | }); | |
| 102 | }); |
test/flow-alarm-private.test.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | /** A stuck pull in a private repository: the owner's `flow` reading and terminal name it, an agent's never does (#6346). */ | |
| 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 { createUser } from "../src/core/users.ts"; | |
| 8 | import { flowFor } from "../src/system/telemetry.ts"; | |
| 9 | import { flowLines } from "../src/tui/screens/flow.ts"; | |
| 10 | ||
| 11 | const NOW = new Date("2026-10-26T12:00:00.000Z"); | |
| 12 | const ago = (m: number): string => new Date(NOW.getTime() - m * 60_000).toISOString(); | |
| 13 | let dir: string; | |
| 14 | let app: App; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dir = await mkdtemp(join(tmpdir(), "alarm-private-")); | |
| 18 | app = createApp({ dataDir: dir, port: 0 }); | |
| 19 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 20 | if (!u.ok) throw new Error(u.error); | |
| 21 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility, autonomy) VALUES ('r', ?1, 'secretrepo', 'private', 'auto')", [u.value.id]); | |
| 22 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, author_id, state, created_at) VALUES ('p1', 'r', 1, 'pull', 't', ?1, 'open', ?2)", [u.value.id, ago(10_000)]); | |
| 23 | app.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES ('p1', 'main', 'b1', '', 'h')"); | |
| 24 | app.db.run("INSERT INTO review (id, issue_id, author_id, state, commit_oid, created_at) VALUES ('rv', 'p1', ?1, 'approved', 'h', ?2)", [u.value.id, ago(600)]); | |
| 25 | app.db.run("INSERT INTO run (id, repo_id, kind, issue_id, state, commit_oid, created_at, finished_at) VALUES ('c', 'r', 'check', 'p1', 'ok', 'h', ?1, ?1)", [ago(60)]); | |
| 26 | }); | |
| 27 | afterAll(async () => { | |
| 28 | app.close?.(); | |
| 29 | await rm(dir, { recursive: true, force: true }); | |
| 30 | }); | |
| 31 | ||
| 32 | test("the owner's alarms name the private pull; an agent's leave it and the idle-crew line out", () => { | |
| 33 | const owner = flowFor(app, { kind: "owner" }, NOW).alarms; | |
| 34 | expect(owner.map((a) => a.key)).toContain("stuck"); | |
| 35 | expect(owner.find((a) => a.key === "stuck")!.says).toContain("alice/secretrepo#1"); | |
| 36 | const agent = flowFor(app, { kind: "agent" } as never, NOW).alarms; | |
| 37 | expect(JSON.stringify(agent)).not.toContain("secretrepo"); | |
| 38 | expect(agent.map((a) => a.key)).not.toContain("idle_crew"); | |
| 39 | }); | |
| 40 | ||
| 41 | test("the terminal's flow screen draws the fired alarm for the owner and not for an agent", () => { | |
| 42 | expect(flowLines(app.db, NOW, []).join("\n")).toContain("alice/secretrepo#1 has sat approved"); | |
| 43 | expect(flowLines(app.db, NOW, [], { queues: [] }).join("\n")).not.toContain("secretrepo"); | |
| 44 | }); |
new above
offline
This is the only thing that keeps a private repository's name out of the agent's
flowreading, and nothing tests it. Add a test with a stuck pull in a private repository and check that the agent's alarms leave it out while the owner's include it.