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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6346-a-fired-flow-alarm-is-one-line-at-the-to → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6346-a-fired-flow-alarm-is-one-line-at-the-to was merged into main as efc1c1b.

Review

Sign in to review this change.
13 files changed +277 −0
README.md +1
@@ -562,6 +562,7 @@ work here.
562562 default branches (force-push and deletion refused, honest fast-forwards
563563 allowed).
564564 - **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.
565566 - **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
566567 gets a 404 on the page): the query is the URL (`?source=runs&aggregate=count&by=day&fcolumn=state&fvalue=failed`),
567568 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";
4040 import { health } from "../http/health.ts";
4141 import { forgeVitals } from "../actions/health.ts";
4242 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";
4345 import { askLinesFor } from "../web/studio/asks.ts";
4446
4547 const KEY = "notify_url";
@@ -241,6 +243,25 @@ export function redLines(
241243 return out;
242244 }
243245
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
244265 export interface Notifier {
245266 /** Resolves when the work queued so far is done. For tests and shutdown. */
246267 settled(): Promise<void>;
@@ -263,6 +284,8 @@ export interface NotifyOptions {
263284 drainingSince?: () => number | null;
264285 /** The causes the vitals hold now; the live vitals unless a test says. */
265286 causes?: () => readonly { cause: string; sentence: string }[];
287 /** The alarms firing now; the live four unless a test says. */
288 alarms?: () => readonly FiredAlarm[];
266289 }
267290
268291 /** 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 {
424447 ...domainDrillLines(app, user, now(), options.forgeUrl),
425448 ...mirrorLines(app, user, now()),
426449 ...redLines(app, user, now()),
450 ...alarmLines(app, user, now(), options.alarms?.()),
427451 ];
428452 const before = seen.get(userId);
429453 // 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 {
1818 readonly fired: boolean;
1919 /** When the oldest stuck pull's last event happened; set only when fired. */
2020 readonly since?: string;
21 /** The pull it names, `owner/name` and number; set only when fired. */
22 readonly repo?: string;
23 readonly number?: number;
2124 readonly says: string;
2225 }
2326
@@ -129,6 +132,8 @@ export function alarmStuck(from: App | Database, now: Date, scope: StuckScope =
129132 return {
130133 fired: true,
131134 since: last.at,
135 repo,
136 number,
132137 says: `${repo}#${number} has sat approved for ${sat(now.getTime() - Date.parse(last.at))}; its last event was ${last.what} at ${hhmm}.`,
133138 };
134139 }
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 } : {});

This is the only thing that keeps a private repository's name out of the agent's flow reading, 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.

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 @@
88 import type { App } from "../app.ts";
99 import { SOURCES } from "../boards/sources.ts";
1010 import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../flow/conflicted.ts";
11 import { firedAlarms } from "../flow/alarms.ts";
1112 import { readVitals, vitalsLine, vitalsMisses } from "../flow/vitals.ts";
1213 import { readPipeline, verdict } from "../flow/pipeline.ts";
1314 import { type QueueReading, readQueues } from "../flow/queue.ts";
@@ -36,6 +37,8 @@ export function flowFor(app: App, reader: Reader, now = new Date()) {
3637 says: verdict(stages, now.getTime()),
3738 // Open pulls, opened and landed in 1 h and 6 h, and conflicted, against #5924's targets (#5935).
3839 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, // Review runs in flight per reviewer…, belongs to reviewing. Inserting alarms here pulls it away from that line. Put alarms and its comment above it.

3942 // Review runs in flight per reviewer, cut to public repositories for an agent (#4733).
4043 reviewing: { says: reviewingSays(busy), reviewers: busy },
4144 conflicted: { says: conflictedSays(conflicted, now.getTime()), pulls: conflicted },
src/tui/forge.ts +2
@@ -64,6 +64,7 @@ import { teamsScreen } from "./screens/teams.ts";
6464 import { domainsScreen } from "./screens/domains.ts";
6565 import { ownerScreen } from "./screens/owner.ts";
6666 import { resilienceLines, resilienceScreen } from "./screens/resilience.ts";
67 import { forgeCeiling } from "../flow/ceiling.ts";
6768 import { flowLines, flowScreen } from "./screens/flow.ts";
6869 import { readHeld } from "../flow/held.ts";
6970 import { memoryScreen, systemLines, systemScreen } from "./screens/system.ts";
@@ -289,6 +290,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
289290 systemFor(session.app, flowReader).duplicates,
290291 flowReader.kind === "agent" ? { queues: queuesFor(session.app, flowReader, new Date(), { batches: true }) } : undefined,
291292 readHeld(session.app, flowReader.kind === "owner"),
293 forgeCeiling(session.app.config),
292294 )));
293295 },
294296 });
src/tui/screens/dashboard.ts +2
@@ -98,6 +98,7 @@ import { issueScreen } from "./issue.ts";
9898 import { listPinned } from "../../actions/board-pins.ts";
9999 import { migrateScreen } from "./migrate.ts";
100100 import { newRepoScreen } from "./newrepo.ts";
101 import { firedAlarms } from "../../flow/alarms.ts";
101102 import { forgeCeiling } from "../../flow/ceiling.ts";
102103 import { pullScreen } from "./pull.ts";
103104 import { repoScreen } from "./repo.ts";
@@ -269,6 +270,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
269270 : [
270271 ...kpiLines(readKpis(f.db, now, kpiForge(f.app)), commit === null ? null : { sha: commit, since: ago(BOOTED) }),
271272 vitalsLine(readVitals(f.db, now)),
273 ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }).map((a) => a.says),
272274 ];
273275 // "Yours" is ownership plus the orgs you are on a team of; typing in the
274276 // filter reaches every repository you can see.
src/tui/screens/flow.ts +4
@@ -18,6 +18,7 @@ import { HOLDS } from "../../crew/held.ts";
1818 import type { HeldRow } from "../../flow/held.ts";
1919 import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../../flow/conflicted.ts";
2020 import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts";
21 import { firedAlarms } from "../../flow/alarms.ts";
2122 import { readVitals, vitalsLine } from "../../flow/vitals.ts";
2223 import { readQueues, redBatchWords } from "../../flow/queue.ts";
2324 import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts";
@@ -41,6 +42,8 @@ export function flowLines(
4142 agent?: { queues: ReturnType<typeof readQueues> } | undefined,
4243 /** Why ready issues wait, per autonomous repository (`readHeld`, already cut to what the reader may see). */
4344 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,
4447 ): string[] {
4548 const stages = readPipeline(db, now);
4649 const flow = readThroughput(db, stages.map((s) => s.key), now);
@@ -61,6 +64,7 @@ export function flowLines(
6164 ...wrap(verdict(stages, now.getTime()), inner),
6265 "",
6366 ...wrap(vitalsLine(readVitals(db, now, agent !== undefined)), inner),
67 ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd }).flatMap((a) => wrap(a.says, inner)),
6468 "",
6569 ...wrap(throughputSays(flow), inner),
6670 "",
src/web/pages/boards-view.ts +3
@@ -20,6 +20,8 @@ import { html, when, type Html } from "../html.ts";
2020 import { fragment, page } from "../layout.ts";
2121 import { btn, card, chartBox, empty, field, selectField } from "../ui.ts";
2222 import { flowPanel } from "./flow-panel.ts";
23 import { forgeCeiling } from "../../flow/ceiling.ts";
24 import { alarmLines, cachedAlarms } from "../studio/alarm_lines.ts";
2325 import { isPinned, listPins } from "../../boards/pins.ts";
2426 import { panelData, runFor } from "../../boards/panel_data.ts";
2527 import { cellsOf, paramsOf, queryFields, readParams } from "./boards.ts";
@@ -108,6 +110,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> {
108110 title: "Boards",
109111 body: html`<div class="page-head"><h1>Boards</h1>
110112 ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div>
113 ${alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config)))}
111114 ${card(make)} ${body} ${flow}`,
112115 });
113116 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 (src/tui/screens/flow.ts:63) and dashboard (dashboard.ts:271) show only the vitals line. Draw the same says lines there, cut with publicOnly for an agent, and test it.

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";
5252 import { needsYouSection } from "./asks_page.ts";
5353 import { flowCards } from "./flow_card.ts";
5454 import { composer, feedJs, feedNote, feedRegion } from "./feed.ts";
55 import { alarmLines, cachedAlarms } from "./alarm_lines.ts";
5556 import { kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts";
5657 import { feedCandidateCache } from "../../feed/feed.ts";
5758 import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
@@ -274,6 +275,7 @@ export async function studioHome(
274275 ? timePhase(ctx.request, "kpis", () => kpiRow(ctx.app.db, kpiOpts), () => kpiCache(ctx.app.db))
275276 : liveLine(ctx)}
276277 ${figures ? timePhase(ctx.request, "vitals", () => vitalsRow(ctx.app.db)) : null}
278 ${figures ? alarmLines(cachedAlarms(ctx.app.db, new Date(), ceilingUsd)) : null}
277279 ${timePhase(ctx.request, "waiting", () => waitingLine(ctx.app.db, user), () => waitingCache(ctx.app.db))}
278280 ${figures
279281 ? 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs