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

/forges shows each sent forge's crew spend today against its starter budget, carried by its heartbeat #5884

Merged opened this

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

Merged

scout/forge:scout/5866-forges-shows-each-sent-forge-s-crew-spen → main 4 commits fork 6 threads · 6 unresolved

  • Merged. scout/5866-forges-shows-each-sent-forge-s-crew-spen was merged into main as b7efbe4.

Review

Sign in to review this change.
9 files changed +189 −8
README.md +3 −1
@@ -22,7 +22,9 @@ A forge this one spawned (hosted) runs the apps it was given too: its `bakery`
2222 answers at `bakery--<child>--<this forge's host>`, in this forge's lanes, on the
2323 child's own data, and `/forges` says how many are running and why any is not. Each row also ends with the first step its person has not taken (`not claimed yet`, `no everyday account yet`, `no tell yet`, or `going: last tell <time>`; `has not reported yet` before the child's first beat), read from the child's heartbeat; the terminal's forges screen and the MCP `forges` tool (`nextStep`) say the same.
2424 In the terminal an admin's palette offers "Forges you sent": each forge with its
25 claim state and the same apps-running note, never a claim link.
25 claim state and the same apps-running note, never a claim link. Both carry the
26 crew's spend today against the daily ceiling that pauses it (`$3.20 of $10 today`), as the
27 child's heartbeat last reported it.
2628
2729 Each agent's scorecard — pulls landed of opened, review rounds, red checks,
2830 reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the
src/http/mcp.ts +1 −1
@@ -2673,7 +2673,7 @@ const TOOLS: readonly Tool[] = [
26732673 {
26742674 name: "forges",
26752675 description:
2676 "The forges sent from here (admin): name, address, whom for, apps, claimed or not, last activity.",
2676 "The forges sent from here (admin): name, address, for, apps, claimed, spendTodayUsd, ceilingUsd.",
26772677 inputSchema: { type: "object", properties: {} },
26782678 writes: false,
26792679 async run(ctx, user) {
src/spawn/clients.ts +23
@@ -37,6 +37,10 @@ export interface ClientRow {
3737 readonly lastActivityAt: string | null;
3838 /** The apps this parent runs for the child; null when it runs none here. */
3939 readonly running: ChildAppsSummary | null;
40 /** Dollars the child's crew spent today, from its last heartbeat; null when it has not said. */
41 readonly spendTodayUsd: number | null;
42 /** The child's forge cap in dollars; null when none or unsaid. */
43 readonly ceilingUsd: number | null;
4044 /** The first step its person has not taken, read from the child's heartbeat. */
4145 readonly nextStep: NextStep;
4246 }
@@ -188,6 +192,8 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
188192 claimed: states[i]?.claimed ?? null,
189193 lastActivityAt: states[i]?.lastActivityAt ?? null,
190194 running: hostedChildApps(app, c.name) ?? null,
195 spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null,
196 ceilingUsd: beats[i]?.beat.ceilingUsd ?? null,
191197 nextStep: nextStepOf(states[i]?.claimed ?? null, beats[i]?.beat),
192198 }))
193199 .sort(
@@ -218,3 +224,20 @@ export function runningWords(r: ClientRow): string | null {
218224 if (a.notRunning.length === 0) return word;
219225 return `${word} · ${a.notRunning.map((n) => `${n.slug}: ${n.why}`).join("; ")}`;
220226 }
227
228 const money = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixed(2));
229
230 /**
231 * "$3.20 of $10 today", with "ceiling reached" at the cap; `warn` from 80%.
232 * Null when the child has not reported spend.
233 */
234 export function spendWords(r: ClientRow): { words: string; warn: boolean } | null {
235 if (r.spendTodayUsd === null) return null;
236 const spent = `$${r.spendTodayUsd.toFixed(2)}`;
237 if (r.ceilingUsd === null) return { words: `${spent} today`, warn: false };
238 const share = r.spendTodayUsd / r.ceilingUsd;
239 const base = `${spent} of $${money(r.ceilingUsd)} today`;
240 return share >= 1
241 ? { words: `${base} · ceiling reached`, warn: true }
242 : { words: base, warn: share >= 0.8 };
243 }
src/spawn/heartbeat.ts +32 −1
@@ -17,6 +17,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
1717 import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
1818 import { join } from "node:path";
1919 import type { App } from "../app.ts";
20 import { spentTodayEverywhere } from "../crew/autonomy.ts";
2021 import { beatKeyOf, listChildren } from "../actions/spawn.ts";
2122 import releases from "../core/releases.json" with { type: "json" };
2223 import { forgeLinkOf, forgeTipOf, FOLLOW_MODES, type FollowMode, type ForgeLink } from "./chain.ts";
@@ -55,6 +56,10 @@ export interface Beat {
5556 readonly upstreamBehind?: number | null;
5657 /** True while a converge issue is open on the child; absent from an older child. */
5758 readonly conflict?: boolean;
59 /** Dollars the child's crew spent since 00:00 UTC; absent from an older child or when it sent a bad value. */
60 readonly spendTodayUsd?: number;
61 /** The child's forge cap in dollars (the daily ceiling that pauses its crew), null when it has none; absent when unsaid or bad. */
62 readonly ceilingUsd?: number | null;
5863 /** Whether its person has claimed it; absent from an older child or when the value was bad. */
5964 readonly claimed?: boolean;
6065 /** Whether it has an everyday account (or chose to go without); absent likewise. */
@@ -64,8 +69,13 @@ export interface Beat {
6469 }
6570
6671 const OPTIONAL = new Set([
67 "forgeTip", "severedAt", "mode", "upstreamBehind", "conflict", "claimed", "everyday", "lastTellAt",
72 "forgeTip", "severedAt", "mode", "upstreamBehind", "conflict", "spendTodayUsd", "ceilingUsd", "claimed", "everyday", "lastTellAt",
6873 ]);
74 const USD_MAX = 1_000_000;
75
76 /** Dollars: a finite number from `min` to a million; anything else is not a sum of money. */
77 const dollars = (v: unknown, min: number): v is number =>
78 typeof v === "number" && Number.isFinite(v) && v >= min && v <= USD_MAX;
6979
7080 export function sign(key: string, body: string): string {
7181 return createHmac("sha256", key).update(body).digest("hex");
@@ -118,12 +128,17 @@ export function parseBeat(raw: string): Beat | undefined {
118128 if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) return undefined;
119129 if (typeof version !== "string" || !VERSION.test(version)) return undefined;
120130 if (!count(openProposals) || !count(children)) return undefined;
131 // A bad sum drops the field, not the beat.
132 const spend = dollars(d["spendTodayUsd"], 0) ? d["spendTodayUsd"] : undefined;
133 const ceil = d["ceilingUsd"] === null ? null : dollars(d["ceilingUsd"], 0.01) ? d["ceilingUsd"] : undefined;
121134 return {
122135 name,
123136 at,
124137 version,
125138 openProposals,
126139 children,
140 ...(spend === undefined ? {} : { spendTodayUsd: spend }),
141 ...(ceil === undefined ? {} : { ceilingUsd: ceil }),
127142 ...(forgeTip === undefined ? {} : { forgeTip: forgeTip as string | null }),
128143 ...(severedAt === undefined ? {} : { severedAt: severedAt as string | null }),
129144 ...(mode === undefined ? {} : { mode: mode as FollowMode }),
@@ -236,6 +251,15 @@ export function standing(app: App, name: string, at = Date.now()): Beat {
236251 };
237252 }
238253
254 /** What the crew spent today, and the daily ceiling that pauses it (null when the forge sets none). */
255 export function spendReport(app: App, now = new Date()): { spendTodayUsd: number; ceilingUsd: number | null } {
256 const cap = app.config.crewCeilingUsd;

Nit: forgeCeiling(app.config) in src/flow/ceiling.ts already names this cap. Calling it here (plus the > 0 guard) keeps one definition of the forge ceiling.

257 return {
258 spendTodayUsd: Math.min(Math.round(spentTodayEverywhere(app.db, now) * 100) / 100, USD_MAX),
259 ceilingUsd: cap === undefined || !(cap > 0) ? null : Math.min(cap, USD_MAX),
260 };
261 }
262
239263 /** What a fork says of how it follows: its tip, whether it cut the link, and how far behind it is. */
240264 async function forkReport(app: App, forge: ForgeLink) {
241265 const up = await upstreamState(app);
@@ -267,6 +291,7 @@ export async function sendBeat(
267291 const body = JSON.stringify({
268292 ...standing(app, link.name, lastAt),
269293 children: Math.min(own, COUNT_MAX),
294 ...spendReport(app),
270295 ...progress(app),
271296 // Only a forge that carries a fork of its parent's code has these to say.
272297 ...(forge === undefined
@@ -316,6 +341,10 @@ export interface ChildStanding {
316341 readonly upstreamBehind: number | null;
317342 /** A converge issue is open on the child; false when none or unsaid. */
318343 readonly conflict: boolean;
344 /** Dollars the child's crew spent today, as last reported; null when unsaid. */
345 readonly spendTodayUsd: number | null;
346 /** The child's forge cap in dollars; null when it has none or did not say. */
347 readonly ceilingUsd: number | null;
319348 }
320349
321350 /** How many releases of this forge are newer than `version`; null when it is not one of them. */
@@ -347,6 +376,8 @@ export async function childStandings(
347376 mode: heard?.beat.mode ?? null,
348377 upstreamBehind: heard?.beat.upstreamBehind ?? null,
349378 conflict: heard?.beat.conflict ?? false,
379 spendTodayUsd: heard?.beat.spendTodayUsd ?? null,
380 ceilingUsd: heard?.beat.ceilingUsd ?? null,
350381 });
351382 }
352383 return { items, more: Math.max(0, all.length - MAX_CHILDREN) };
src/tui/screens/forges.ts +3 −3
@@ -1,12 +1,12 @@
11 /**
22 * The forges this one sent (#2211) in a card: one row per client with its
3 * claim state and the apps-running note, in the words /forges uses. Never a
3 * claim state, the apps-running note and the crew's spend today, in the words /forges uses. Never a
44 * claim link; that stays on the page.
55 */
66
77 import type { Ctx, Screen } from "../app/app.ts";
88 import { binding } from "../app/keys.ts";
9 import { CLAIM_WORDS, claimState, runningWords, type ClientRow } from "../../spawn/clients.ts";
9 import { CLAIM_WORDS, claimState, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
1010 import { card } from "../ui/widgets.ts";
1111 import { drawFacts } from "./common.ts";
1212
@@ -15,7 +15,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
1515 if (rows.length === 0) return [["", "You have not sent a forge yet"]];
1616 return rows.map((r) => [
1717 r.name,
18 [r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], runningWords(r), r.nextStep.words]
18 [r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], runningWords(r), r.nextStep.words, spendWords(r)?.words ?? null]
1919 .filter((m): m is string => m !== null)
2020 .join(" · "),
2121 ]);
src/web/pages/forges.ts +11 −1
@@ -7,7 +7,7 @@
77 */
88
99 import type { Ctx } from "../../http/context.ts";
10 import { CLAIM_WORDS, claimState, clientRows, runningWords, type ClientRow } from "../../spawn/clients.ts";
10 import { CLAIM_WORDS, claimState, clientRows, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
1111 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1212 import { notFoundPage, pageHead } from "../chrome.ts";
1313 import { html, type Html } from "../html.ts";
@@ -28,6 +28,15 @@ function runningNote(r: ClientRow): Html | null {
2828 >`;
2929 }
3030
31 /** The crew's spend today against its cap; attention-toned from 80%. */

No test renders this. A page test that seeds a beat at 85% and expects data-spend="warn" in the GET /forges HTML (as admin) would catch a regression in this function or in the clientRows wiring.

32 function spendNote(r: ClientRow): Html | null {
33 const s = spendWords(r);
34 if (s === null) return null;
35 return s.warn
36 ? html`<span data-spend="warn">${badge(s.words, { tone: "attn" })}</span>`
37 : html`<span data-spend="ok">${s.words}</span>`;
38 }
39
3140 function forgeRow(r: ClientRow): Html {
3241 const state = claimState(r);
3342 const meta = [
@@ -35,6 +44,7 @@ function forgeRow(r: ClientRow): Html {
3544 r.for === null ? null : html`for ${r.for}`,
3645 r.apps.length === 0 ? null : html`${r.apps.join(", ")}`,
3746 runningNote(r),
47 spendNote(r),
3848 r.sentAt === null ? null : html`sent ${time(r.sentAt)}`,
3949 r.lastActivityAt === null
4050 ? null
test/family-heartbeat.test.ts +23 −1
@@ -11,7 +11,7 @@ import { join } from "node:path";
1111 import { createRepo } from "../src/actions/repos.ts";
1212 import { beatKeyOf, childDirOf } from "../src/actions/spawn.ts";
1313 import { createApp, type App } from "../src/app.ts";
14 import { createUser } from "../src/core/users.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
1515 import { commitFiles } from "../src/git/write.ts";
1616 import { repoDir } from "../src/git/repos.ts";
1717 import { createToken } from "../src/core/tokens.ts";
@@ -335,12 +335,14 @@ describe("family heartbeats", () => {
335335 expect(sent[0]?.sig).toBe(sign(key, sent[0]!.body));
336336 expect(Object.keys(JSON.parse(sent[0]!.body)).sort()).toEqual([
337337 "at",
338 "ceilingUsd",
338339 "children",
339340 "claimed",
340341 "everyday",
341342 "lastTellAt",
342343 "name",
343344 "openProposals",
345 "spendTodayUsd",
344346 "version",
345347 ]);
346348 // A forge that was not spawned has no one to tell.
@@ -441,4 +443,24 @@ describe("family heartbeats", () => {
441443 await rm(join(dataDir, "upstream.json"), { force: true });
442444 }
443445 });
446
447 test("a stored beat with spend shows on /forges and in the MCP forges tool", async () => {
448 const body = beat({ at: Date.now() + 60_000, spendTodayUsd: 8.5, ceilingUsd: 10 });
449 expect((await post(body, sign(key, body))).status).toBe(200);
450 const page = await (await fetch(`${servers.url}/forges`, { headers: { cookie: adminCookie } })).text();
451 expect(page).toContain('data-spend="warn"');
452 expect(page).toContain("$8.50 of $10 today");
453 const alice = getUserByName(parent.db, "alice")!;
454 const res = await fetch(`${servers.url}/mcp`, {
455 method: "POST",
456 headers: {
457 "content-type": "application/json",
458 accept: "application/json, text/event-stream",
459 authorization: `Bearer ${createToken(parent.db, alice.id, "mcp", "rw").token}`,
460 },
461 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "forges", arguments: {} } }),
462 });
463 const out = JSON.parse(((await res.json()) as any).result.content[0].text);
464 expect(out.forges.find((f: any) => f.name === "maria")).toMatchObject({ spendTodayUsd: 8.5, ceilingUsd: 10 });
465 });
444466 });
test/forges-spend.test.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * The crew's spend on /forges (#5866): a heartbeat may carry spendTodayUsd
3 * and ceilingUsd, a bad value drops the field and not the beat, and the row
4 * says "$3.20 of $10 today", attention-toned from 80%, "ceiling reached" at 100%.
5 */
6
7 import { describe, expect, test } from "bun:test";
8 import { parseBeat, spendReport } from "../src/spawn/heartbeat.ts";
9 import { spendWords, type ClientRow } from "../src/spawn/clients.ts";
10 import { createApp } from "../src/app.ts";
11 import { mkdtempSync } from "node:fs";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createRepo } from "../src/actions/repos.ts";
15 import { createRun } from "../src/core/runs.ts";
16 import { createUser } from "../src/core/users.ts";
17
18 const beat = (o: Record<string, unknown> = {}) =>
19 JSON.stringify({ name: "maria", at: Date.now(), version: "0.7.114", openProposals: 1, children: 0, ...o });
20
21 const row = (spendTodayUsd: number | null, ceilingUsd: number | null) =>
22 ({ spendTodayUsd, ceilingUsd }) as ClientRow;
23
24 describe("a beat's spend", () => {
25 test("with spend and a cap", () => {
26 expect(parseBeat(beat({ spendTodayUsd: 3.2, ceilingUsd: 10 }))).toMatchObject({ spendTodayUsd: 3.2, ceilingUsd: 10 });
27 expect(parseBeat(beat({ spendTodayUsd: 0, ceilingUsd: null }))).toMatchObject({ spendTodayUsd: 0, ceilingUsd: null });
28 });
29 test("without spend", () => {
30 const b = parseBeat(beat());
31 expect(b?.name).toBe("maria");
32 expect(b).not.toHaveProperty("spendTodayUsd");
33 expect(b).not.toHaveProperty("ceilingUsd");
34 });
35 test("a negative or non-numeric value drops the field, not the beat", () => {
36 for (const bad of [-1, "3", {}, true, 1e9]) {
37 const b = parseBeat(beat({ spendTodayUsd: bad, ceilingUsd: bad }));
38 expect(b?.name).toBe("maria");
39 expect(b).not.toHaveProperty("spendTodayUsd");
40 expect(b).not.toHaveProperty("ceilingUsd");
41 }
42 const half = parseBeat(beat({ spendTodayUsd: 2, ceilingUsd: -5 }));
43 expect(half).toMatchObject({ spendTodayUsd: 2 });
44 expect(half).not.toHaveProperty("ceilingUsd");
45 });
46 test("a child reports what its crew spent today and the daily ceiling", async () => {
47 const app = createApp({
48 dataDir: mkdtempSync(join(tmpdir(), "forge-spend-")),
49 url: "https://x.test",
50 port: 0,
51 crewCeilingUsd: 10,
52 });
53 expect(spendReport(app)).toEqual({ spendTodayUsd: 0, ceilingUsd: 10 });
54 const user = await createUser(app.db, {
55 origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true,
56 });
57 if (!user.ok) throw new Error(user.error);
58 const repo = await createRepo(app, user.value, { name: "one", visibility: "private" });
59 if (!repo.ok) throw new Error("repo");
60 const run = createRun(app.db, { repoId: repo.value.id, kind: "agent" });
61 app.db.query("UPDATE run SET cost_usd = 3.204 WHERE id = ?1").run(run.id);
62 expect(spendReport(app).spendTodayUsd).toBe(3.2);
63 const bare = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-spend-")), url: "https://x.test", port: 0 });
64 expect(spendReport(bare).ceilingUsd).toBeNull();
65 });
66 });
67
68 describe("the row's words", () => {
69 test("says nothing when the child has not reported", () => {
70 expect(spendWords(row(null, 10))).toBeNull();
71 });
72 test("50%: plain", () => {
73 expect(spendWords(row(5, 10))).toEqual({ words: "$5.00 of $10 today", warn: false });
74 expect(spendWords(row(3.2, 10))?.words).toBe("$3.20 of $10 today");
75 });
76 test("85%: attention", () => {
77 expect(spendWords(row(8.5, 10))).toEqual({ words: "$8.50 of $10 today", warn: true });
78 });
79 test("100%: ceiling reached", () => {
80 expect(spendWords(row(10, 10))).toEqual({ words: "$10.00 of $10 today · ceiling reached", warn: true });
81 });
82 test("no cap: just the sum", () => {
83 expect(spendWords(row(4, null))).toEqual({ words: "$4.00 today", warn: false });
84 });
85 });
test/tui-forges.test.ts +8
@@ -25,6 +25,8 @@ const row = (over: Partial<ClientRow>): ClientRow => ({
2525 claimed: false,
2626 lastActivityAt: null,
2727 running: null,
28 spendTodayUsd: null,
29 ceilingUsd: null,
2830 nextStep: { key: "unreported", words: "has not reported yet", lastTellAt: null },
2931 ...over,
3032 });
@@ -94,6 +96,12 @@ describe("forges you sent in the terminal", () => {
9496 }
9597 });
9698
99 test("a row carries the crew's spend today, and says when the ceiling is reached", () => {
100 expect(forgeLines([row({ spendTodayUsd: 10, ceilingUsd: 10 })])[0]![1]).toContain("ceiling reached");
101 expect(forgeLines([row({ spendTodayUsd: 3.2, ceilingUsd: 10 })])[0]![1]).toContain("$3.20 of $10 today");
102 expect(forgeLines([row({})])[0]![1]).not.toContain("today");
103 });
104
97105 test("a row says who, the claim state and the apps note; never a link", () => {
98106 const up = { total: 3, running: 3, notRunning: [] };
99107 const down = { total: 2, running: 1, notRunning: [{ slug: "notes", why: "build failed" }] };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs