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

The terminal's decider screen says, per app, what one shipped change cost this week, as the board's Today line does #8079

Merged opened this

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

Merged

scout/forge:scout/8057-the-terminal-s-decider-screen-says-per-a → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/8057-the-terminal-s-decider-screen-says-per-a was merged into main as 1048192.

Review

Sign in to review this change.
7 files changed +183 −16
docs/ASKS.md +5 −2
@@ -26,8 +26,11 @@ its ledger rows (`/board?ledger=closed|deferred|asked`; any other value is
2626 ignored, `?apps=` is kept). A deferral is the decider's own `stop`, never the
2727 rule's. The ledger view lists up to 200 rows and says when it left some out.
2828 In the terminal the same line sits above the dashboard, `C` (or the palette's
29 Decider) opens the rows, and `1`, `2`, `3` switch pile. Both are for the
30 repositories you own only: a reader sees nothing.
29 Decider) opens the rows, and `1`, `2`, `3` switch pile. Under the line, one
30 muted line per app that ran or shipped this week says what one shipped change
31 cost, in the board's words ("$3.00 per shipped change this week"); at most
32 three, then `+N more apps`. Both are for the repositories you own only: a
33 reader sees nothing.
3134
3235 **Give back to the crew** is on a pull a pass gave up on and assigned to you
3336 (finishing, requested, sweep, picker): its row says the pass's reason in one
src/core/efficiency.ts +11
@@ -77,3 +77,14 @@ export function efficiencyForRepos(
7777 }
7878 return out;
7979 }
80
81 /** An app's spend per shipped change in a few words:
82 * `$1.37 per shipped change`, or what is true when there is no ratio. */
83 export function perShippedWords(e: Efficiency | undefined): string {
84 const dollars = (n: number): string => `$${n.toFixed(2)}`;
85 if (e === undefined || (e.spentUsd === 0 && e.shipped === 0))
86 return "nothing spent or shipped";
87 if (e.perShippedUsd === null)
88 return `${dollars(e.spentUsd)} spent, nothing shipped`;
89 return `${dollars(e.perShippedUsd)} per shipped change`;
90 }
src/tui/screens/decider.ts +23 −1
@@ -15,6 +15,7 @@ import {
1515 listDeciderPile,
1616 type DeciderPile,
1717 } from "../../core/decisions.ts";
18 import { efficiencyForRepos, perShippedWords } from "../../core/efficiency.ts";
1819 import { deciderSentence } from "../../story/words.ts";
1920 import { getIssuesByIds } from "../../core/issues.ts";
2021 import { getReposByIds } from "../../core/repos.ts";
@@ -46,6 +47,24 @@ export function deciderLine(app: App, viewer: User | undefined, now = new Date()
4647 return n.closed + n.deferred + n.asked === 0 ? null : deciderSentence((pile) => n[pile]).join("");
4748 }
4849
50 const COST_LINES = 3;
51
52 /** One muted line per owned app that ran or shipped this week, at most three, then `+N more apps`. */
53 export function efficiencyLines(app: App, viewer: User | undefined, now = new Date()): string[] {
54 const repos = viewer === undefined ? [] : returnsRepos(app, viewer);
55 const week = efficiencyForRepos(
56 app.db,
57 repos.map((r) => r.id),
58 new Date(now.getTime() - 7 * 86_400_000).toISOString(),
59 );
60 const lines = repos.flatMap((r) => {
61 const e = week.get(r.id);
outdated

Unbounded flatMap: cap the lines (3 + a '+N more' line) so a tall head cannot starve the body on a short terminal.

62 return e === undefined ? [] : [`${r.name}: ${perShippedWords(e)} this week`];
63 });
64 const more = lines.length - COST_LINES;
65 return more <= 0 ? lines : [...lines.slice(0, COST_LINES), `+${more} more app${more === 1 ? "" : "s"}`];
66 }
67
4968 /** The ledger rows behind one pile's count today, newest first. */
5069 export function deciderRows(app: App, viewer: User | undefined, pile: DeciderPile, now = new Date()): DeciderRow[] {
5170 const db: Database = app.db;
@@ -74,8 +93,10 @@ export function deciderScreen(f: Session, ctx: Ctx, first: DeciderPile = "closed
7493 let pile: DeciderPile = first;
7594 let rows: DeciderRow[] = [];
7695 let line: string | null = null;
96 let costs: string[] = [];
7797 const read = (): void => {
7898 line = deciderLine(f.app, f.user);
99 costs = efficiencyLines(f.app, f.user);
79100 rows = deciderRows(f.app, f.user, pile);
80101 state.clamp(rows.length);
81102 };
@@ -100,9 +121,10 @@ export function deciderScreen(f: Session, ctx: Ctx, first: DeciderPile = "closed
100121 load: async () => read(),
101122 render(area, s) {
102123 const c = ctx.theme.c;
103 const [head, body] = split(area, "v", [len(1), fill(1)]);
124 const [head, body] = split(area, "v", [len(1 + costs.length), fill(1)]);
104125 const sentence = `${line ?? "The decider has done nothing today."} [${pile}]`;
105126 s.text(head!.x, head!.y, truncate(sentence, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
127 costs.forEach((t, i) => s.text(head!.x, head!.y + 1 + i, truncate(t, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w));
106128 if (rows.length === 0) {
107129 emptyState(s, body!, c, { title: PILE_EMPTY[pile], body: "Press 1, 2 or 3 for another pile." });
108130 return;
src/web/pages/board.ts +1 −1
@@ -95,8 +95,8 @@ import {
9595 type Tone,
9696 } from "../ui.ts";
9797 import { voiceFor } from "../studio/voice.ts";
98 import { perShippedWords } from "../../core/efficiency.ts";
9899 import {
99 perShippedWords,
100100 windowWords,
101101 type FigureWords,
102102 } from "../studio/words.ts";
src/web/studio/words.ts −11
@@ -19,7 +19,6 @@ import {
1919 import type { AppRecord } from "../../core/apps.ts";
2020 import { canEnterApp, getRepoById, type Repo } from "../../core/repos.ts";
2121 import type { User } from "../../core/users.ts";
22 import type { Efficiency } from "../../core/efficiency.ts";
2322 import type { WindowStanding } from "../../core/standing.ts";
2423 import { whenText } from "../../crew/limits.ts";
2524 import { reviewWaitsFor, waitsForReviewer } from "../../crew/review.ts";
@@ -603,13 +602,3 @@ export function windowWords(w: WindowStanding): WindowWords {
603602 usedPct,
604603 };
605604 }
606
607 /** An app's spend per shipped change (`core/efficiency.ts`) in a few words:
608 * `$1.37 per shipped change`, or what is true when there is no ratio. */
609 export function perShippedWords(e: Efficiency | undefined): string {
610 if (e === undefined || (e.spentUsd === 0 && e.shipped === 0))
611 return "nothing spent or shipped";
612 if (e.perShippedUsd === null)
613 return `${dollars(e.spentUsd)} spent, nothing shipped`;
614 return `${dollars(e.perShippedUsd)} per shipped change`;
615 }
test/board-windows.test.ts +2 −1
@@ -25,7 +25,8 @@ import { recordUsage } from "../src/core/usage.ts";
2525 import { createUser, type User } from "../src/core/users.ts";
2626 import { serve, type Servers } from "../src/http/server.ts";
2727 import { id } from "../src/kernel/id.ts";
28 import { perShippedWords, windowWords } from "../src/web/studio/words.ts";
28 import { perShippedWords } from "../src/core/efficiency.ts";
29 import { windowWords } from "../src/web/studio/words.ts";
2930
3031 /** Count the statements a block of work runs, through `db.query`. */
3132 async function statements(db: Database, work: () => Promise<void> | void) {
test/tui-decider-efficiency.test.ts added +141
@@ -0,0 +1,141 @@
1 /**
2 * The decider's per-app cost line: what one shipped change cost this week,
3 * in the board's own words, only for someone holding owner facts.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { openIssue } from "../src/actions/issues.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { efficiencyForRepos, perShippedWords } from "../src/core/efficiency.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import { createRun } from "../src/core/runs.ts";
15 import { recordUsage } from "../src/core/usage.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import { deciderScreen, efficiencyLines } from "../src/tui/screens/decider.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { settleApp } from "./helpers/tui.ts";
22
23 quietLogs();
24
25 /** A repository made by the admin and handed to `owner`, who is not a member. */
26 async function ownedBy(owner: User, name: string): Promise<Repo> {
27 const made = await createRepo(app, alice, { name, visibility: "public" });
28 if (!made.ok) throw new Error(String(made.error));
29 app.db.query("UPDATE repo SET owner_id = ?1 WHERE id = ?2").run(owner.id, made.value.id);
30 return made.value;
31 }
32
33 let dir: string;
34 let app: App;
35 let alice: User;
36 let bob: User;
37 let repo: Repo;
38
39 beforeAll(async () => {
40 dir = await mkdtemp(join(tmpdir(), "forge-tui-decider-eff-"));
41 app = createApp({ dataDir: dir, port: 0 });
42 const mk = async (username: string, first = false) => {
43 const r = await createUser(app.db, {
44 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
45 password: "a-good-passphrase", adminIfFirst: first,
46 });
47 if (!r.ok) throw new Error(r.error);
48 return r.value;
49 };
50 alice = await mk("alice", true);
51 bob = await mk("bob");
52 const made = await createRepo(app, alice, { name: "notes", visibility: "public" });
53 if (!made.ok) throw new Error(String(made.error));
54 repo = made.value;
55 const at = new Date().toISOString();
56 for (let n = 0; n < 2; n++)
57 recordUsage(app.db, {
58 runId: createRun(app.db, { repoId: repo.id, kind: "agent", actorId: alice.id }).id, repoId: repo.id, issueId: null, agentId: null, subscriptionId: null,
59 tokensIn: 1, tokensOut: 1, costUsd: 1.5, wallMs: 1, outcome: "ok", at,
60 });
61 const issue = openIssue(app, alice, repo, { title: "Shipped" });
62 if (!issue.ok) throw new Error(String(issue.error));
63 app.db
64 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, 'main', 'b', '', '', ?2, ?3)`)
65 .run(issue.value.id, repo.id, at);
66 });
67 afterAll(async () => {
68 app.close();
69 await rm(dir, { recursive: true, force: true });
70 });
71
72 async function screenText(user: User): Promise<string> {
73 const terminal = headlessTerminal(120, 30);
74 const { app: tui, session } = createForgeApp(
75 app,
76 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
77 { terminal, exitHooks: false },
78 );
79 tui.start();
80 tui.ctx.push(deciderScreen(session, tui.ctx));
81 await settleApp(tui);
82 const text = tui.screen.toText();
83 tui.stop?.();
84 return text;
85 }
86
87 test("the owner's screen says what a shipped change cost, as the board does", async () => {
88 const week = efficiencyForRepos(app.db, [repo.id], new Date(Date.now() - 7 * 86_400_000).toISOString());
89 const words = perShippedWords(week.get(repo.id));
90 expect(words).toBe("$3.00 per shipped change");
91 expect(efficiencyLines(app, alice)).toEqual([`notes: ${words} this week`]);
92 expect(await screenText(alice)).toContain(`notes: ${words} this week`);
93 });
94
95 test("a reader with no owner facts sees no line", async () => {
96 expect(efficiencyLines(app, bob)).toEqual([]);
97 expect(efficiencyLines(app, undefined)).toEqual([]);
98 expect(await screenText(bob)).not.toContain("per shipped change");
99 });
100
101 test("an owner whose app neither ran nor shipped gets no line", async () => {
102 const carol = await createUser(app.db, {
103 origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase",
104 });
105 if (!carol.ok) throw new Error(carol.error);
106 await ownedBy(carol.value, "idle");
107 expect(efficiencyLines(app, carol.value)).toEqual([]);
108 });
109
110 test("many apps: three lines then +N more apps, and the pile rows stay on a short terminal", async () => {
111 const dave = await createUser(app.db, {
112 origin: "seed", trusted: true, username: "dave", email: "[email protected]", password: "a-good-passphrase",
113 });
114 if (!dave.ok) throw new Error(dave.error);
115 const at = new Date().toISOString();
116 for (let n = 0; n < 5; n++) {
117 const made = { value: await ownedBy(dave.value, `app${n}`) };
118 recordUsage(app.db, {
119 runId: createRun(app.db, { repoId: made.value.id, kind: "agent", actorId: dave.value.id }).id, repoId: made.value.id, issueId: null, agentId: null, subscriptionId: null,
120 tokensIn: 1, tokensOut: 1, costUsd: 1, wallMs: 1, outcome: "ok", at,
121 });
122 }
123 const lines = efficiencyLines(app, dave.value);
124 expect(lines).toHaveLength(4);
125 expect(lines[3]).toBe("+2 more apps");
126
127 const terminal = headlessTerminal(80, 12);
128 const { app: tui, session } = createForgeApp(
129 app,
130 { sshUsername: "dave", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: dave.value },
131 { terminal, exitHooks: false },
132 );
133 tui.start();
134 tui.ctx.push(deciderScreen(session, tui.ctx));
135 await settleApp(tui);
136 const text = tui.screen.toText();
137 tui.stop?.();
138 expect(text).toContain("+2 more apps");
139 expect(text).toContain("The decider has done nothing today.");
140 expect(text).toContain("Press 1, 2 or 3 for another pile.");
141 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs