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

The terminal opens the board's returns: what the window's dollars bought, in the same rows as /board/returns #11046

Parked opened this
Closed without merging

mason/forge:mason/10997-the-terminal-opens-the-board-s-returns-w → main 3 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
3 files changed +242 −0
src/tui/screens/dashboard.ts +3
@@ -35,6 +35,7 @@ import { askForEveryday, everydayOf, EVERYDAY_WHY, keepAdmin } from "../../core/
3535 import { mayView } from "../../core/sealed.ts";
3636 import { applyFleetIds, previewFleetIds, undoFleetOp } from "../../actions/fleet.ts";
3737 import { plain } from "./boards.ts";
38 import { returnsScreen } from "./returns.ts";
3839 import { pinRepo, unpinRepo } from "../../actions/origins.ts";
3940 import { getProposal } from "../../core/proposals.ts";
4041 import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts";
@@ -1016,6 +1017,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10161017 disabled: focus !== "machine" || pins.length === 0,
10171018 }),
10181019 binding(["C"], "decider", { disabled: !f.user }),
1020 binding(["T"], "what the tokens bought", { disabled: !f.user }),
10191021 ...(clientLines.length > 0 ? [binding(["t"], "ask for something")] : []),
10201022 ...(clientLive.size > 0 ? [binding(["o"], "open the app")] : []),
10211023 binding(["n"], "new repo", { disabled: !f.signedIn }),
@@ -1243,6 +1245,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
12431245 if (e.key === "U" && f.user) return undoHand(), true;
12441246 if (e.key === "L" && f.user) return listenAgain(), true;
12451247 if (e.key === "C" && f.user) return ctx.push(deciderScreen(f, ctx)), true;
1248 if (e.key === "T" && f.user) return ctx.push(returnsScreen(f, ctx)), true;
12461249 if (e.key === "P" && f.user) return showPeople(), true;
12471250 if (f.user && e.key >= "1" && e.key <= String(KIND_CHIPS.length))
12481251 return toggleChip(Number(e.key) - 1), true;
src/tui/screens/returns.ts added +96
@@ -0,0 +1,96 @@
1 /**
2 * What the window's dollars bought, in the terminal: the rows of
3 * `/board/returns`, read through the same `readReturns` gate (owner facts
4 * only). Spent and landed first, then what did not land and why, then the
5 * issues that cost the most. `d` cycles the window through 1, 7 and 30 days.
6 */
7
8 import { readReturns, returnsRepos } from "../../actions/returns.ts";
9 import { usd } from "../../core/money.ts";
10 import type { Money, Returns } from "../../core/returns.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import type { Session } from "../session.ts";
14 import { style } from "../term/style.ts";
15 import { truncate } from "../term/width.ts";
16 import { Scroll } from "./common.ts";
17
18 /** The windows `d` cycles through, in days; 7 is where the screen opens. */
19 export const RETURNS_DAYS_CYCLE = [1, 7, 30] as const;
20
21 /** Said to anyone who holds no owner facts, as the page's not-found is. */
22 export const RETURNS_REFUSED = "Nothing here is yours to see.";
23
24 export interface ReturnsLine {
25 readonly text: string;
26 readonly heading?: true;
27 }
28
29 const runs = (m: Money): string => `${m.runs} ${m.runs === 1 ? "run" : "runs"}`;
30 const money = (title: string, m: Money, note?: string): ReturnsLine => ({
31 text: `${title} ${usd(m.usd)} · ${runs(m)}${note === undefined ? "" : ` · ${note}`}`,
32 });
33 const head = (text: string): ReturnsLine => ({ text, heading: true });
34
35 /** The page's rows, in its order and words, as plain lines. */
36 export function returnsLines(r: Returns, days: number): ReturnsLine[] {
37 const n = r.landedNothing;
38 const per = r.onLanded.perChange;
39 return [
40 head(`What the tokens bought, ${days} days`),
41 {
42 text: `${usd(r.spent.usd)} over ${runs(r.spent)} · ${r.landed} changes landed${
43 per.changes === 0 ? "" : ` · ${usd(per.median)} a landed change (median), ${usd(r.onLanded.usd)} in all`
44 }`,
45 },
46 head("Where it went"),
47 money("On work that landed", r.onLanded, `${per.changes} changes`),
48 money("On work closed without a merge", n.closed),
49 money("On work still open, nothing merged yet", n.open),
50 head("What did not land, and why"),
51 money("Runs that failed", n.failed),
52 ...n.topFailures.map((f) => money(` of which: ${f.reason}`, f)),
53 money("Runs cancelled", n.cancelled),
54 head("What cost the most"),
55 ...r.costliest.map((c) => ({
56 text: `#${c.number} ${c.title} ${usd(c.usd)} · ${runs(c)} · ${
57 c.outcome === "landed" ? "landed" : c.outcome === "closed" ? "closed with nothing merged" : "nothing merged yet"
58 }`,
59 })),
60 ];
61 }
62
63 export function returnsScreen(f: Session, ctx: Ctx): Screen {
64 const scroll = new Scroll();
65 let span = RETURNS_DAYS_CYCLE.indexOf(7);
66 // Read when the screen opens and when the window moves, never per frame.
67 const read = (): ReturnsLine[] => {
68 const days = RETURNS_DAYS_CYCLE[span]!;
69 const got = readReturns(f.app, f.user, { days });
70 if (!got.ok || f.user === undefined || returnsRepos(f.app, f.user).length === 0) return [{ text: RETURNS_REFUSED }];
71 return returnsLines(got.value, days);
72 };
73 let lines = read();
74 return {
75 title: "Returns",
76 keys: [binding(["j", "k"], "scroll"), binding(["d"], "window: 1, 7, 30 days")],
77 render(area, s) {
78 const c = ctx.theme.c;
79 const from = scroll.window(area.h, lines.length);
80 for (let i = 0; i < area.h; i++) {
81 const l = lines[from + i];
82 if (l === undefined) break;
83 s.text(area.x, area.y + i, truncate(l.text, area.w), style(l.heading ? c.fg : c.mutedFg, c.bg), area.x + area.w);
84 }
85 },
86 key: (e) => {
87 if (!e.ctrl && !e.alt && e.key === "d") {
88 span = (span + 1) % RETURNS_DAYS_CYCLE.length;
89 lines = read();
90 scroll.to(0, lines.length);
91 return true;
92 }
93 return scroll.key(e, lines.length);
94 },
95 };
96 }
test/tui-returns.test.ts added +143
@@ -0,0 +1,143 @@
1 /**
2 * The terminal's returns screen (#10997): the same owner-only gate as
3 * `/board/returns`, the same numbers on one fixture, `d` cycling the window,
4 * and one key from the dashboard.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createAgent, addToCrew } from "../src/actions/crew.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { readReturns } from "../src/actions/returns.ts";
15 import { createRun } from "../src/core/runs.ts";
16 import { createUser, getUserByName } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { createForgeApp } from "../src/tui/forge.ts";
19 import { RETURNS_REFUSED, returnsLines } from "../src/tui/screens/returns.ts";
20 import type { TuiIdentity } from "../src/tui/session.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23 import { settleApp } from "./helpers/tui.ts";
24
25 quietLogs();
26
27 const PASSWORD = "a-long-returns-passphrase";
28
29 describe("the returns screen", () => {
30 let dataDir = "";
31 let app: App;
32 let servers: Servers;
33
34 const boot = (username: string) => {
35 const user = getUserByName(app.db, username)!;
36 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
37 const { app: tui } = createForgeApp(app, identity, { terminal: headlessTerminal(140, 40), exitHooks: false });
38 tui.start();
39 const press = async (...keys: string[]) => {
40 for (const key of keys) tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
41 await settleApp(tui);
42 };
43 return { text: () => tui.screen.toText(), press, stop: () => tui.stop() };
44 };
45
46 beforeAll(async () => {
47 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-returns-"));
48 app = createApp({ dataDir, port: 0 });
49 servers = await serve(app);
50 for (const [username, admin] of [["alice", true], ["bob", false]] as const) {
51 const made = await createUser(app.db, {
52 origin: "seed",
53 username,
54 email: `${username}@forge.local`,
55 password: PASSWORD,
56 adminIfFirst: admin,
57 trusted: true,
58 });
59 if (!made.ok) throw new Error(made.error);
60 }
61 const alice = getUserByName(app.db, "alice")!;
62 const made = await createRepo(app, alice, { name: "mill", visibility: "public" });
63 if (!made.ok) throw new Error(String(made.error));
64 await createAgent(app, alice, { username: "bot" });
65 addToCrew(app, alice, made.value, "bot");
66 const bot = getUserByName(app.db, "bot")!;
67 for (const [title, state, cost] of [
68 ["Grind the grain", "failed", 2.5],
69 ["Sift the flour", "ok", 4],
70 ] as const) {
71 const opened = openIssue(app, alice, made.value, { title });
72 if (!opened.ok) throw new Error(String(opened.error));
73 const run = createRun(app.db, {
74 repoId: made.value.id,
75 kind: "agent",
76 issueId: opened.value.id,
77 branch: "",
78 actorId: bot.id,
79 reason: "assigned",
80 });
81 const at = new Date().toISOString();
82 app.db
83 .query(`UPDATE run SET state = ?2, cost_usd = ?3, summary = ?4, started_at = ?5, finished_at = ?5 WHERE id = ?1`)
84 .run(run.id, state, cost, state === "failed" ? "failed: the check is red" : "ok", at);
85 }
86 });
87
88 afterAll(async () => {
89 await servers.stop?.();
90 app.close?.();
91 await rm(dataDir, { recursive: true, force: true });
92 });
93
94 test("a non-owner is refused, on the screen and by the page's own gate", async () => {
95 const bob = getUserByName(app.db, "bob")!;
96 // bob may read the public mill but holds no owner facts for it.
97 const h = boot("bob");
98 await h.press("T");
99 expect(h.text()).toContain(RETURNS_REFUSED);
100 expect(h.text()).not.toContain("Grind the grain");
101 h.stop();
102 expect(readReturns(app, bob, { repo: "alice/mill" })).toEqual({ ok: false, error: "not_found" });
103 });
104
105 test("the owner's numbers equal the page's, and d cycles the window", async () => {
106 const alice = getUserByName(app.db, "alice")!;
107 const read = readReturns(app, alice, { days: 7 });
108 if (!read.ok) throw new Error(read.error);
109 const lines = returnsLines(read.value, 7);
110
111 const signed = await fetch(`${servers.url}/signin`, {
112 method: "POST",
113 body: new URLSearchParams({ username: "alice", password: PASSWORD }),
114 redirect: "manual",
115 });
116 const cookie = (signed.headers.get("set-cookie") ?? "").split(";")[0]!;
117 const page = (await (await fetch(`${servers.url}/board/returns`, { headers: { cookie } })).text())
118 .replace(/<[^>]*>/g, " ")
119 .replace(/\s+/g, " ");
120 // Every figure the screen shows is on the page, beside the same words.
121 const figures = lines.flatMap((l) => l.text.match(/\$\d+(?:\.\d\d)?/g) ?? []);
122 expect(figures).toContain("$6.50");
123 expect(figures).toContain("$2.50");
124 for (const f of new Set(figures)) expect(page).toContain(f);
125 expect(page).toContain("Grind the grain");
126
127 const h = boot("alice");
128 await h.press("T");
129 const text = h.text();
130 expect(text).toContain("What the tokens bought, 7 days");
131 expect(text).toContain("$6.50 over 2 runs");
132 expect(text).toContain("Runs that failed $2.50");
133 expect(text.indexOf("$6.50")).toBeLessThan(text.indexOf("What did not land, and why"));
134 expect(text.indexOf("What did not land, and why")).toBeLessThan(text.indexOf("What cost the most"));
135 await h.press("d");
136 expect(h.text()).toContain("What the tokens bought, 30 days");
137 await h.press("d");
138 expect(h.text()).toContain("What the tokens bought, 1 days");
139 await h.press("d");
140 expect(h.text()).toContain("What the tokens bought, 7 days");
141 h.stop();
142 });
143 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs