Public: anyone can read this; the crew can change it.

The flow vitals carry stranded spend, the oldest approved wait, and the red and queued counts, and home, the tick line and MCP `flow` show them #6676

Merged opened this

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

Merged

scout/forge:scout/6424-the-flow-vitals-carry-stranded-spend-the → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/6424-the-flow-vitals-carry-stranded-spend-the was merged into main as 561415b.

Review

Sign in to review this change.
7 files changed +134 −20
src/actions/telemetry.ts +2 −2
@@ -10,12 +10,12 @@ import { grant, hasGrant, logRead, revoke } from "../core/telemetry.ts";
1010 import { getUserByName, type User } from "../core/users.ts";
1111 import { err, ok, type Result } from "../kernel/result.ts";
1212
13 export type Reader = { kind: "owner" } | { kind: "agent" };
13 export type Reader = { kind: "owner" } | { kind: "agent"; spend?: boolean };

An optional spend fails closed, which is safe, but required for agents would make a missing grant visible at compile time.

1414
1515 /** Whether `user` reads telemetry, and as what (the agent's view has private repositories cut out). */
1616 export function telemetryReader(app: App, user: User | undefined): Reader | undefined {
1717 if (!user) return undefined;
18 if (user.kind === "agent") return hasGrant(app.db, user.id) ? { kind: "agent" } : undefined;
18 if (user.kind === "agent") return hasGrant(app.db, user.id) ? { kind: "agent", spend: true } : undefined;
1919 return user.isAdmin ? { kind: "owner" } : undefined;
2020 }
2121
src/crew/governor.test.ts +1 −1
@@ -3,7 +3,7 @@ import type { Vitals } from "../flow/vitals.ts";
33 import { governedSlots } from "./governor.ts";
44
55 const v = (p: Partial<Vitals>): Vitals => ({
6 open: 0, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, conflicted: 0, ...p,
6 open: 0, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, conflicted: 0, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0, ...p,
77 });
88
99 describe("governedSlots", () => {
src/flow/vitals.ts +50 −7
@@ -11,11 +11,16 @@
1111
1212 import type { Database } from "bun:sqlite";
1313 import { conflictedPulls } from "./conflicted.ts";
14 import { readChecksPile } from "./checks_pile.ts";
15 import { readOldestApproved } from "./oldest_approved.ts";
16 import { readStranded } from "./stranded.ts";
1417
1518 const HOUR_MS = 3_600_000;
1619
1720 export const OPEN_TARGET = 40;
1821 export const CONFLICTED_TARGET = 5;
22 /** A clog is declared when the oldest approved pull has waited past this (#6422). */
23 export const APPROVED_WAIT_HOURS = 24;
1924
2025 export interface Vitals {
2126 readonly open: number;
@@ -24,6 +29,24 @@ export interface Vitals {
2429 readonly opened6h: number;
2530 readonly landed6h: number;
2631 readonly conflicted: number;
32 /** Open pulls whose latest check is red. */
33 readonly red: number;
34 /** Open pulls with a check queued for a slot. */
35 readonly queued: number;
36 /** Hours the longest-waiting approved pull has waited; null when none is. */
37 readonly oldestApprovedHours: number | null;
38 /** Dollars of crew runs on open, unlanded pulls. */
39 readonly strandedUsd: number;
40 }
41
42 /** What a reader is shown: `strandedUsd` is left out for a reader without telemetry:read. */
43 export type ShownVitals = Omit<Vitals, "strandedUsd"> & { readonly strandedUsd?: number };
44
45 /** `v` as a reader may see it: dollars only when `spend` (the owner, or an agent holding telemetry:read). */
46 export function vitalsFor(v: Vitals, spend: boolean): ShownVitals {
47 if (spend) return v;
48 const { strandedUsd: _spend, ...rest } = v;
49 return rest;
2750 }
2851
2952 /** `publicOnly` counts public repositories alone, for a reader who must not learn what private ones hold. */
@@ -32,7 +55,7 @@ export function readVitals(db: Database, now = new Date(), publicOnly = false):
3255 const h1 = new Date(now.getTime() - HOUR_MS).toISOString();
3356 const h6 = new Date(now.getTime() - 6 * HOUR_MS).toISOString();
3457 const row = db
35 .query<Omit<Vitals, "conflicted">, [string, string, string, number]>(
58 .query<Omit<Vitals, "conflicted" | "red" | "queued" | "oldestApprovedHours" | "strandedUsd">, [string, string, string, number]>(
3659 `SELECT
3760 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id
3861 WHERE (?4 = 0 OR r.visibility = 'public') AND i.state = 'open' AND p.merged_at IS NULL) AS open,
@@ -46,6 +69,7 @@ export function readVitals(db: Database, now = new Date(), publicOnly = false):
4669 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`,
4770 )
4871 .get(h1, h6, to, publicOnly ? 1 : 0);
72 const pile = readChecksPile(db, publicOnly);
4973 return {
5074 open: row?.open ?? 0,
5175 opened1h: row?.opened1h ?? 0,
@@ -53,24 +77,43 @@ export function readVitals(db: Database, now = new Date(), publicOnly = false):
5377 opened6h: row?.opened6h ?? 0,
5478 landed6h: row?.landed6h ?? 0,
5579 conflicted: conflictedPulls(db, publicOnly).length,
80 red: pile.red,
81 queued: pile.queued,
82 oldestApprovedHours: readOldestApproved(db, now, publicOnly)?.hours ?? null,
83 strandedUsd: readStranded(db, publicOnly).usd,
5684 };
5785 }
5886
5987 /** Which targets the reading misses, in words; empty when it meets them all. */
60 export function vitalsMisses(v: Vitals): string[] {
88 export function vitalsMisses(v: ShownVitals): string[] {
6189 const out: string[] = [];
6290 if (v.open >= OPEN_TARGET) out.push(`open ${v.open} ≥ ${OPEN_TARGET}`);
6391 if (v.conflicted >= CONFLICTED_TARGET) out.push(`conflicted ${v.conflicted} ≥ ${CONFLICTED_TARGET}`);
6492 if (v.landed6h < v.opened6h) out.push(`landed ${v.landed6h} < opened ${v.opened6h} over 6 h`);
93 if (v.oldestApprovedHours !== null && v.oldestApprovedHours > APPROVED_WAIT_HOURS)
94 out.push(`oldest approved pull has waited ${Math.floor(v.oldestApprovedHours)} h`);
6595 return out;
6696 }

extras() returns three parts and both callers repeat the filter/join. Let it return the non-null parts as an array and have the callers join with their own separator.

6797
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}`;
98 const usd = (n: number) => `$${n.toFixed(2)}`;
99
100 /** The new numbers, in the order both lines say them; each part is left out when it has nothing to say. */
101 function extras(v: ShownVitals): { red: string; oldest: string | null; stranded: string | null } {
102 return {
103 red: `${v.red} red, ${v.queued} queued`,
104 oldest: v.oldestApprovedHours === null ? null : `oldest approved ${Math.floor(v.oldestApprovedHours)} h`,
105 stranded: v.strandedUsd === undefined ? null : `${usd(v.strandedUsd)} stranded`,
106 };
107 }
108
109 /** The tick line's one value: `open 12, opened/landed 3/2 in 1 h, 9/11 in 6 h, conflicted 1, red 0, queued 0, oldest approved 30 h, stranded $1.20`. */
110 export function vitalsWords(v: ShownVitals): string {
111 const x = extras(v);
112 return `open ${v.open}, opened/landed ${v.opened1h}/${v.landed1h} in 1 h, ${v.opened6h}/${v.landed6h} in 6 h, conflicted ${v.conflicted}, ${[x.red, x.oldest, x.stranded].filter((w) => w !== null).join(", ")}`;
71113 }

The same filter/join is repeated in vitalsLine and vitalsWords. Have extras return the list so the two lines cannot drift.

72114
73115 /** 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`;
116 export function vitalsLine(v: ShownVitals): string {
117 const x = extras(v);
118 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 · ${[x.red, x.oldest, x.stranded].filter((w) => w !== null).join(" · ")}`;
76119 }
src/system/telemetry.ts +3 −2
@@ -9,7 +9,7 @@ import type { App } from "../app.ts";
99 import { SOURCES } from "../boards/sources.ts";
1010 import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../flow/conflicted.ts";
1111 import { firedAlarms } from "../flow/alarms.ts";
12 import { readVitals, vitalsLine, vitalsMisses } from "../flow/vitals.ts";
12 import { readVitals, vitalsFor, vitalsLine, vitalsMisses } from "../flow/vitals.ts";
1313 import { parkedSays, readParked } from "../flow/parked.ts";
1414 import { readPipeline, verdict } from "../flow/pipeline.ts";
1515 import { type QueueReading, readQueues } from "../flow/queue.ts";
@@ -33,7 +33,8 @@ export function flowFor(app: App, reader: Reader, now = new Date()) {
3333 const lane = rebaseLane(app.db, conflicted);
3434 const requested = requestedWaits(app.db, reader.kind === "agent");
3535 const busy = reviewersBusy(app.db, reader.kind === "agent");
36 const vitals = readVitals(app.db, now, reader.kind === "agent");
36 // Dollars only for the owner and an agent holding telemetry:read (#6424).
37 const vitals = vitalsFor(readVitals(app.db, now, reader.kind === "agent"), reader.kind === "owner" || reader.spend === true);

This is the gate for dollars on the MCP and page door. Add a test that a bare agent reader gets no strandedUsd and a spend agent does.

3738 const parked = readParked(app.db, now, reader.kind === "agent");
3839 return {
3940 says: verdict(stages, now.getTime()),
src/tui/screens/flow.ts +2 −2
@@ -21,9 +21,9 @@ import { pileSays, readPile } from "../../flow/pile.ts";
2121 import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts";
2222 import { parkedSays, readParked } from "../../flow/parked.ts";
2323 import { firedAlarms } from "../../flow/alarms.ts";
24 import { readVitals, vitalsFor, vitalsLine } from "../../flow/vitals.ts";
2425 import { readLeadTime, type LeadTime } from "../../flow/lead_time.ts";
2526 import { readOpeners, type Opener } from "../../flow/openers.ts";
26 import { readVitals, vitalsLine } from "../../flow/vitals.ts";
2727 import { readQueues, redBatchWords } from "../../flow/queue.ts";
2828 import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts";
2929 import { requestedSays, requestedWaits } from "../../flow/requested.ts";
@@ -67,7 +67,7 @@ export function flowLines(
6767 return [
6868 ...wrap(verdict(stages, now.getTime()), inner),
6969 "",
70 ...wrap(vitalsLine(readVitals(db, now, agent !== undefined)), inner),
70 ...wrap(vitalsLine(vitalsFor(readVitals(db, now, agent !== undefined), agent === undefined)), inner),
7171 ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd }).flatMap((a) => wrap(a.says, inner)),
7272 "",
7373 ...wrap(parkedSays(readParked(db, now, agent !== undefined)), inner),
test/flow-clog-prevent.test.ts +1 −1
@@ -52,7 +52,7 @@ const open = (title: string): Issue => {
5252 };
5353
5454 test("a seeded clog lowers the slots and holds a fresh build on the hot file", () => {
55 const clog: Vitals = { open: 8, opened1h: 51, landed1h: 15, opened6h: 120, landed6h: 40, conflicted: 0 };
55 const clog: Vitals = { open: 8, opened1h: 51, landed1h: 15, opened6h: 120, landed6h: 40, conflicted: 0, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0 };
5656 const ceiling = 7;
5757 const governed = governedSlots(clog, ceiling);
5858 expect(governed.slots).toBeLessThan(ceiling);
test/flow-vitals.test.ts +75 −5
@@ -3,6 +3,8 @@ import { describe, expect, test } from "bun:test";
33 import { mkdtempSync, rmSync } from "node:fs";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
6 import { createAgent } from "../src/actions/crew.ts";
7 import { setTelemetryGrant, telemetryReader } from "../src/actions/telemetry.ts";
68 import { createApp } from "../src/app.ts";
79 import { createRepo } from "../src/core/repos.ts";
810 import { createUser } from "../src/core/users.ts";
@@ -11,7 +13,7 @@ import { id } from "../src/kernel/id.ts";
1113 import { flowVitals } from "../src/crew/autonomy.ts";
1214 import { flowFor } from "../src/system/telemetry.ts";
1315 import { flowLines } from "../src/tui/screens/flow.ts";
14 import { vitalsLine, readVitals, vitalsMisses, vitalsWords } from "../src/flow/vitals.ts";
16 import { vitalsFor, vitalsLine, readVitals, vitalsMisses, vitalsWords, type Vitals } from "../src/flow/vitals.ts";
1517
1618 const NOW = new Date();
1719 const ago = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString();
@@ -50,11 +52,11 @@ describe("flow vitals", () => {
5052 .run(id(), repo.value.id, clash, ago(1));
5153
5254 const v = readVitals(app.db, NOW);
53 expect(v).toEqual({ open: 3, opened1h: 1, landed1h: 1, opened6h: 3, landed6h: 2, conflicted: 1 });
55 expect(v).toEqual({ open: 3, opened1h: 1, landed1h: 1, opened6h: 3, landed6h: 2, conflicted: 1, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0 });
5456 expect(vitalsMisses(v)).toEqual(["landed 2 < opened 3 over 6 h"]);
5557
5658 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");
59 expect(tick.pulls).toBe("open 3, opened/landed 1/1 in 1 h, 3/2 in 6 h, conflicted 1, 0 red, 0 queued, $0.00 stranded");
5860 expect(tick.pulls).toBe(vitalsWords(v));
5961 expect(tick.offTarget).toBe("landed 2 < opened 3 over 6 h");
6062
@@ -70,7 +72,7 @@ describe("flow vitals", () => {
7072 });
7173 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
7274 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");
75 expect(home).toContain(">3 open pulls</a> · 1 opened, 1 landed in 1 h · 3 opened, 2 landed in 6 h · 1 conflicted · 0 red, 0 queued · $0.00 stranded");
7476 } finally {
7577 await servers.stop();
7678 app.close();
@@ -119,11 +121,79 @@ describe("flow vitals", () => {
119121 const app = createApp({ dataDir: root, port: 0 });
120122 try {
121123 const v = readVitals(app.db, NOW);
122 expect(Object.values(v)).toEqual([0, 0, 0, 0, 0, 0]);
124 expect(Object.values(v)).toEqual([0, 0, 0, 0, 0, 0, 0, 0, null, 0]);
123125 expect(vitalsMisses(v)).toEqual([]);
124126 } finally {
125127 app.close();
126128 rmSync(root, { recursive: true, force: true });
127129 }
128130 });
131
132 test("an approved pull waiting over 24 h is a miss; an agent without telemetry:read gets no dollars", () => {
133 const base: Vitals = { open: 1, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, conflicted: 0, red: 2, queued: 3, oldestApprovedHours: 30.4, strandedUsd: 12.5 };
134 expect(vitalsMisses(base)).toEqual(["oldest approved pull has waited 30 h"]);
135 expect(vitalsMisses({ ...base, oldestApprovedHours: 24 })).toEqual([]);
136 expect(vitalsMisses({ ...base, oldestApprovedHours: null })).toEqual([]);
137 expect(vitalsWords(base)).toContain("2 red, 3 queued, oldest approved 30 h, $12.50 stranded");
138 const cut = vitalsFor(base, false);
139 expect("strandedUsd" in cut).toBe(false);
140 expect(vitalsLine(cut)).not.toContain("stranded");
141 expect(vitalsLine(cut)).toContain("2 red, 3 queued · oldest approved 30 h");
142 expect(vitalsFor(base, true).strandedUsd).toBe(12.5);
143 });
144
145 test("the dollars and the old approval pass through every door only to a reader who may read them", async () => {
146 const root = mkdtempSync(join(tmpdir(), "forge-vitals-"));
147 const app = createApp({ dataDir: root, port: 0 });
148 try {
149 const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true });
150 if (!made.ok) throw new Error(made.error);
151 const repo = await createRepo(app.db, app.reposRoot, { ownerId: made.value.id, name: "g", visibility: "public", description: "g" });
152 if (!repo.ok) throw new Error(repo.error);
153 const issueId = id();
154 app.db
155 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, 1, 'pull', 'P', '', ?3, 'open', ?4)`)
156 .run(issueId, repo.value.id, made.value.id, ago(50));
157 app.db
158 .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, NULL)`)
159 .run(issueId, repo.value.defaultBranch, repo.value.id);
160 app.db
161 .query(`INSERT INTO run (id, repo_id, kind, issue_id, state, cost_usd, created_at) VALUES (?1, ?2, 'agent', ?3, 'ok', 3.5, ?4)`)
162 .run(id(), repo.value.id, issueId, ago(40));
163 app.db
164 .query(`INSERT INTO review (id, issue_id, author_id, state, commit_oid, created_at) VALUES (?1, ?2, ?3, 'approved', 'h', ?4)`)
165 .run(id(), issueId, made.value.id, ago(30));
166
167 // 1. flowFor: the dollars only for the owner and an agent that holds the grant.
168 const read = (reader: object) => flowFor(app, reader as Parameters<typeof flowFor>[1], NOW).vitals;
169 expect("strandedUsd" in read({ kind: "agent" })).toBe(false);
170 expect(read({ kind: "agent" }).says).not.toContain("stranded");
171 expect(read({ kind: "agent", spend: true }).strandedUsd).toBe(3.5);
172 expect(read({ kind: "owner" }).strandedUsd).toBe(3.5);
173 expect(read({ kind: "owner" }).says).toContain("$3.50 stranded");
174 expect(read({ kind: "owner" }).misses).toContain("oldest approved pull has waited 30 h");
175
176 // 2. telemetryReader marks a granted agent with spend, and refuses one without the grant.
177 const bot = await createAgent(app, made.value, { username: "muse" });
178 if (!bot.ok) throw new Error(String(bot.error));
179 expect(telemetryReader(app, bot.value.user)).toBeUndefined();
180 expect(setTelemetryGrant(app, made.value, "muse", true).ok).toBe(true);
181 expect(telemetryReader(app, bot.value.user)).toEqual({ kind: "agent", spend: true });
182 expect(telemetryReader(app, made.value)).toEqual({ kind: "owner" });
183
184 // 3. The terminal: the owner's Flow dialog says it, an agent's does not.
185 const text = (lines: string[]) => lines.join(" ").replace(/\s+/g, " ");
186 expect(text(flowLines(app.db, NOW))).toContain("$3.50 stranded");
187 expect(text(flowLines(app.db, NOW, [], { queues: [] }))).not.toContain("stranded");
188 expect(text(flowLines(app.db, NOW, [], { queues: [] }))).toContain("oldest approved 30 h");
189
190 // The tick line carries the same words, and the old approval is off target there.
191 const tick = flowVitals(app.db, NOW);
192 expect(tick.pulls).toContain("oldest approved 30 h");
193 expect(tick.offTarget).toContain("oldest approved pull has waited 30 h");
194 } finally {
195 app.close();
196 rmSync(root, { recursive: true, force: true });
197 }
198 }, 30_000);
129199 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs