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
mason/forge:mason/10997-the-terminal-opens-the-board-s-returns-w → main 3 commits fork
3 files
changed +242
−0
src/tui/screens/dashboard.ts +3
| @@ -35,6 +35,7 @@ import { askForEveryday, everydayOf, EVERYDAY_WHY, keepAdmin } from "../../core/ | ||
| 35 | 35 | import { mayView } from "../../core/sealed.ts"; |
| 36 | 36 | import { applyFleetIds, previewFleetIds, undoFleetOp } from "../../actions/fleet.ts"; |
| 37 | 37 | import { plain } from "./boards.ts"; |
| 38 | import { returnsScreen } from "./returns.ts"; | |
| 38 | 39 | import { pinRepo, unpinRepo } from "../../actions/origins.ts"; |
| 39 | 40 | import { getProposal } from "../../core/proposals.ts"; |
| 40 | 41 | import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts"; |
| @@ -1016,6 +1017,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1016 | 1017 | disabled: focus !== "machine" || pins.length === 0, |
| 1017 | 1018 | }), |
| 1018 | 1019 | binding(["C"], "decider", { disabled: !f.user }), |
| 1020 | binding(["T"], "what the tokens bought", { disabled: !f.user }), | |
| 1019 | 1021 | ...(clientLines.length > 0 ? [binding(["t"], "ask for something")] : []), |
| 1020 | 1022 | ...(clientLive.size > 0 ? [binding(["o"], "open the app")] : []), |
| 1021 | 1023 | binding(["n"], "new repo", { disabled: !f.signedIn }), |
| @@ -1243,6 +1245,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1243 | 1245 | if (e.key === "U" && f.user) return undoHand(), true; |
| 1244 | 1246 | if (e.key === "L" && f.user) return listenAgain(), true; |
| 1245 | 1247 | 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; | |
| 1246 | 1249 | if (e.key === "P" && f.user) return showPeople(), true; |
| 1247 | 1250 | if (f.user && e.key >= "1" && e.key <= String(KIND_CHIPS.length)) |
| 1248 | 1251 | 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 | }); |
new above
offline