Public: anyone can read this; the crew can change it.
The board speaks the studio's five words, from the same place the pages do #8915
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/67-the-board-speaks-the-studio-s-five-words → main 3 commits fork
5 files
changed +337
−90
docs/STUDIO.md +13 −3
| @@ -481,14 +481,24 @@ front page's "All public apps" leads here. | ||
| 481 | 481 | (**Focus** on that card opens it): no top bar, dark unless you chose a |
| 482 | 482 | theme, four regions that fill the window. **Working now** is one card per |
| 483 | 483 | run in flight — who, which app, what it is on, why, how long, what it has |
| 484 | cost — ordered by when it started. **Landed** is the last dozen shipped | |
| 484 | cost — ordered by when it started. **Landed** is the last dozen merged | |
| 485 | 485 | changes across your apps, each with the app's production look on a phone; |
| 486 | 486 | for an app with pillars it leads with what merged in the last 7 days by |
| 487 | 487 | pillar, each a link to that pillar's issues (`docs/PLANNING.md`). |
| 488 | 488 | **Decided** is the last dozen entries in the decision ledger: which issue |
| 489 | 489 | next, ship or show, try again — the answer, how sure the decider was, and |
| 490 | what came of it. **Today** is one row per app that runs on its own: spend | |
| 491 | against the ceiling, runs, shipped, and whether it is paused — plus any | |
| 490 | what came of it. The board speaks the same five words as every studio page | |
| 491 | and nothing of its own: a run card's word is `runWord`'s, a landed change's | |
| 492 | `pullWord`'s, and what came of a decision is where its pull or tell stands | |
| 493 | now (`pullWord`/`tellWord`, `studio/words.ts`) — never the ledger's own | |
| 494 | outcome — so a merge production has not served yet reads _waiting_ on the | |
| 495 | wall exactly as it does on the app's page, and _shipped_ only once | |
| 496 | production is serving it. Every word is coloured by `wordVariant`, the | |
| 497 | same map the badge draws from, so a word added later is never uncoloured. | |
| 498 | **Today** is one row per app that runs on its own: spend against the | |
| 499 | ceiling, runs, shipped, and whether it is paused — the app page's own | |
| 500 | reads (`autonomyStatus`, `agentRunTotals`, `mergedPullCount`) over today, | |
| 501 | and its paused words (`pausedWords`) — plus any | |
| 492 | 502 | other app the crew spent on this week — and beneath it the efficiency |
| 493 | 503 | signal: spend per shipped change over the last 7 days (the usage ledger's |
| 494 | 504 | `cost_usd` ÷ pulls merged, `core/efficiency.ts`, every app in one grouped |
src/web/pages/board.ts +101 −66
| @@ -7,7 +7,9 @@ | ||
| 7 | 7 | * forge's health, and each subscription's windows; whoever owns an app gets |
| 8 | 8 | * a line for each thing its crew waits on them for. Nothing on it |
| 9 | 9 | * needs a click, and everything on it links somewhere. It reads the same |
| 10 | * tables as the studio and adds no state of its own. | |
| 10 | * tables as the studio and adds no state of its own — nor words: every | |
| 11 | * run, change and decision on it is worded by `runWord`/`pullWord`/ | |
| 12 | * `tellWord`, the studio's own, and coloured by `wordVariant`. | |
| 11 | 13 | * |
| 12 | 14 | * `?wall` is its one wall mode, for a screen across the room that nobody |
| 13 | 15 | * touches: the same regions in larger type, without the key hints or a |
| @@ -15,7 +17,7 @@ | ||
| 15 | 17 | */ |
| 16 | 18 | |
| 17 | 19 | import { usd } from "../../core/money.ts"; |
| 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 20 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 19 | 21 | import { sentinelNotice } from "../../crew/sentinel.ts"; |
| 20 | 22 | import { appsForRepos, type AppRecord } from "../../core/apps.ts"; |
| 21 | 23 | import { ASKS_WINDOW_DAYS, askMeasureWeek, asksMeasureEmpty, asksMeasureParts } from "../../core/ask_ledger.ts"; |
| @@ -59,13 +61,20 @@ import { | ||
| 59 | 61 | pillarView, |
| 60 | 62 | reposWithUmbrellas, |
| 61 | 63 | } from "../../core/pillars.ts"; |
| 62 | import { listPulls, mergedPullsForRepos, toldBy, type Pull } from "../../core/pulls.ts"; | |
| 64 | import { | |
| 65 | getPull, | |
| 66 | mergedPullCount, | |
| 67 | mergedPullsForRepos, | |
| 68 | openPullsClosing, | |
| 69 | toldBy, | |
| 70 | type Pull, | |
| 71 | } from "../../core/pulls.ts"; | |
| 63 | 72 | import { |
| 64 | 73 | listReposVisibleTo, |
| 65 | 74 | ownerFactRepoIds, |
| 66 | 75 | type Repo, |
| 67 | 76 | } from "../../core/repos.ts"; |
| 68 | import { listActiveRuns, listRuns, type Run } from "../../core/runs.ts"; | |
| 77 | import { agentRunTotals, listActiveRuns, type Run } from "../../core/runs.ts"; | |
| 69 | 78 | import { getUsersByIds } from "../../core/users.ts"; |
| 70 | 79 | import { |
| 71 | 80 | autonomyStatus, |
| @@ -92,14 +101,22 @@ import { | ||
| 92 | 101 | pill, |
| 93 | 102 | time, |
| 94 | 103 | tokenCount, |
| 95 | type Tone, | |
| 96 | 104 | } from "../ui.ts"; |
| 105 | import { tellWord } from "../studio/tell.ts"; | |
| 97 | 106 | import { voiceFor } from "../studio/voice.ts"; |
| 107 | import { repoDir } from "../../git/repos.ts"; | |
| 98 | 108 | import { perShippedWords } from "../../core/efficiency.ts"; |
| 99 | 109 | import { |
| 110 | pausedWords, | |
| 111 | pullWord, | |
| 112 | runWord, | |
| 113 | servingHistory, | |
| 100 | 114 | windowWords, |
| 115 | wordBadge, | |
| 101 | 116 | type FigureWords, |
| 117 | type WordCtx, | |
| 102 | 118 | } from "../studio/words.ts"; |
| 119 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 103 | 120 | import { lookUrl } from "../../core/looks.ts"; |
| 104 | 121 | import { thumbLook } from "./looks.ts"; |
| 105 | 122 | import { milestoneHref } from "./milestones.ts"; |
| @@ -126,29 +143,6 @@ function reasonWords(reason: string): string { | ||
| 126 | 143 | } |
| 127 | 144 | } |
| 128 | 145 | |
| 129 | type StateWord = "queued" | "working" | "paused"; | |
| 130 | ||
| 131 | function stateWord(run: Run): StateWord { | |
| 132 | if (run.state === "running") return "working"; | |
| 133 | return run.notBefore === null ? "queued" : "paused"; | |
| 134 | } | |
| 135 | ||
| 136 | /** How a run's state word wears its badge: working is what moves. */ | |
| 137 | const STATE_LOOK: Record<StateWord, Parameters<typeof badge>[1]> = { | |
| 138 | working: { tone: "attn" }, | |
| 139 | queued: { variant: "secondary" }, | |
| 140 | paused: { variant: "outline" }, | |
| 141 | }; | |
| 142 | ||
| 143 | /** What came of a decision, in the tone of the thing it came to. */ | |
| 144 | const OUTCOME_TONE: Readonly<Record<string, Tone>> = { | |
| 145 | shipped: "success", | |
| 146 | green: "success", | |
| 147 | red: "danger", | |
| 148 | shown: "attn", | |
| 149 | closed: "attn", | |
| 150 | }; | |
| 151 | ||
| 152 | 146 | const KIND_WORDS: Record<DecisionKind, string> = { |
| 153 | 147 | next: "next issue", |
| 154 | 148 | land: "ship or show", |
| @@ -191,6 +185,9 @@ interface BoardScope { | ||
| 191 | 185 | * access, same as the studio. A repo the viewer can only read is on the |
| 192 | 186 | * board (it is still theirs to watch land) but carries none of that. */ |
| 193 | 187 | readonly ownerFacts: ReadonlySet<string>; |
| 188 | /** What the studio's word functions need to see each app's production, | |
| 189 | * every app sharing one render's memo of production's history. */ | |
| 190 | readonly words: (repo: Repo) => WordCtx; | |
| 194 | 191 | } |
| 195 | 192 | |
| 196 | 193 | /** The scope for some repositories: the ones among them that are apps. */ |
| @@ -200,19 +197,37 @@ function boardScope( | ||
| 200 | 197 | records: ReadonlyMap<string, AppRecord>, |
| 201 | 198 | ): BoardScope { |
| 202 | 199 | const kept = repos.filter((r) => records.has(r.id)); |
| 200 | const runtime = appsRuntime(ctx.app); | |
| 201 | const history = servingHistory(); | |
| 202 | const contexts = new Map<string, WordCtx>(); | |
| 203 | 203 | return { |
| 204 | 204 | repos: kept, |
| 205 | 205 | ids: kept.map((r) => r.id), |
| 206 | 206 | byId: new Map(kept.map((r) => [r.id, r])), |
| 207 | 207 | records, |
| 208 | 208 | ownerFacts: ownerFactRepoIds(ctx.app.db, kept, ctx.auth.user), |
| 209 | words: (repo) => { | |
| 210 | let made = contexts.get(repo.id); | |
| 211 | if (made === undefined) { | |
| 212 | made = { | |
| 213 | db: ctx.app.db, | |
| 214 | dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), | |
| 215 | runtime, | |
| 216 | record: records.get(repo.id), | |
| 217 | history, | |
| 218 | viewer: ctx.auth.user, | |
| 219 | }; | |
| 220 | contexts.set(repo.id, made); | |
| 221 | } | |
| 222 | return made; | |
| 223 | }, | |
| 209 | 224 | }; |
| 210 | 225 | } |
| 211 | 226 | |
| 212 | 227 | // ---- the page ---- |
| 213 | 228 | |
| 214 | 229 | /** GET /board — signed in; every app the viewer can see, or `?apps=` some; `?wall` for the wall mode. */ |
| 215 | export function boardPage(ctx: Ctx): Response { | |
| 230 | export async function boardPage(ctx: Ctx): Promise<Response> { | |
| 216 | 231 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 217 | 232 | const user = ctx.auth.user; |
| 218 | 233 | if (!user) return signInFirst(ctx); |
| @@ -251,9 +266,9 @@ export function boardPage(ctx: Ctx): Response { | ||
| 251 | 266 | const asks = timePhase(rq, "asks", () => asksStrip(ctx)); |
| 252 | 267 | const returns = timePhase(rq, "returns", () => returnsStrip(ctx, scope)); |
| 253 | 268 | const windows = timePhase(rq, "windows", () => windowsStrip(ctx)); |
| 254 | const workingNow = timePhase(rq, "working", () => working(ctx, scope)); | |
| 255 | const landedNow = timePhase(rq, "landed", () => landed(ctx, scope)); | |
| 256 | const decidedNow = timePhase(rq, "decided", () => decided(ctx, scope)); | |
| 269 | const workingNow = await timePhaseAsync(rq, "working", () => working(ctx, scope)); | |
| 270 | const landedNow = await timePhaseAsync(rq, "landed", () => landed(ctx, scope)); | |
| 271 | const decidedNow = await timePhaseAsync(rq, "decided", () => decided(ctx, scope)); | |
| 257 | 272 | const spendNow = timePhase(rq, "today", () => today(ctx, scope)); |
| 258 | 273 | const rendering = performance.now(); |
| 259 | 274 | |
| @@ -651,7 +666,7 @@ function windowsStrip(ctx: Ctx): Html | null { | ||
| 651 | 666 | |
| 652 | 667 | // ---- working now ---- |
| 653 | 668 | |
| 654 | function working(ctx: Ctx, scope: BoardScope): Html { | |
| 669 | async function working(ctx: Ctx, scope: BoardScope): Promise<Html> { | |
| 655 | 670 | const db = ctx.app.db; |
| 656 | 671 | const runs = listActiveRuns(db, scope.ids); |
| 657 | 672 | // Working first, by when it started; then what waits, by when it was queued. |
| @@ -669,7 +684,10 @@ function working(ctx: Ctx, scope: BoardScope): Html { | ||
| 669 | 684 | db, |
| 670 | 685 | runs.map((r) => r.issueId).filter((id): id is string => id !== null), |
| 671 | 686 | ); |
| 672 | const rows = runs.map((run) => { | |
| 687 | const words = await Promise.all( | |
| 688 | runs.map((run) => runWord(scope.words(scope.byId.get(run.repoId)!), run)), | |
| 689 | ); | |
| 690 | const rows = runs.map((run, n) => { | |
| 673 | 691 | const repo = scope.byId.get(run.repoId)!; |
| 674 | 692 | // The same rule the app's page signs its rows by, `voiceFor` |
| 675 | 693 | // (`studio/voice.ts`): the crew's own work reads as the app's own story |
| @@ -683,7 +701,7 @@ function working(ctx: Ctx, scope: BoardScope): Html { | ||
| 683 | 701 | scope.ownerFacts.has(repo.id), |
| 684 | 702 | ); |
| 685 | 703 | const issue = run.issueId === null ? undefined : about.get(run.issueId); |
| 686 | const word = stateWord(run); | |
| 704 | const { word, note } = words[n]!; | |
| 687 | 705 | return boardRow({ |
| 688 | 706 | key: `run:${run.id}`, |
| 689 | 707 | href: `${repoBase(repo)}/runs/${run.id}`, |
| @@ -692,12 +710,12 @@ function working(ctx: Ctx, scope: BoardScope): Html { | ||
| 692 | 710 | titleAttr: issue?.title ?? run.summary, |
| 693 | 711 | meta: [ |
| 694 | 712 | html`<b>${who}</b> · ${repo.name} · |
| 695 | ${word === "paused" ? "resumes soon" : reasonWords(run.reason)}${when( | |
| 713 | ${run.notBefore === null ? reasonWords(run.reason) : note}${when( | |
| 696 | 714 | run.costUsd > 0 && scope.ownerFacts.has(repo.id), |
| 697 | 715 | () => html` · <span class="num">${money(run.costUsd)}</span>`, |
| 698 | 716 | )}`, |
| 699 | 717 | ], |
| 700 | trail: html`${badge(word, STATE_LOOK[word])}<span | |
| 718 | trail: html`${wordBadge(word)}<span | |
| 701 | 719 | class="num" |
| 702 | 720 | data-started="${at(run)}" |
| 703 | 721 | ></span>`, |
| @@ -717,7 +735,7 @@ function working(ctx: Ctx, scope: BoardScope): Html { | ||
| 717 | 735 | |
| 718 | 736 | // ---- landed ---- |
| 719 | 737 | |
| 720 | function landed(ctx: Ctx, scope: BoardScope): Html { | |
| 738 | async function landed(ctx: Ctx, scope: BoardScope): Promise<Html> { | |
| 721 | 739 | const db = ctx.app.db; |
| 722 | 740 | const pulls = mergedPullsForRepos(db, scope.ids, 12); |
| 723 | 741 | // The newest production picture of each app, on a phone, in one read. |
| @@ -741,7 +759,10 @@ function landed(ctx: Ctx, scope: BoardScope): Html { | ||
| 741 | 759 | lookUrl(repoBase(repo), look) |
| 742 | 760 | ); |
| 743 | 761 | }; |
| 744 | const rows = pulls.map((pull: Pull) => { | |
| 762 | const words = await Promise.all( | |
| 763 | pulls.map((pull) => pullWord(scope.words(scope.byId.get(pull.repoId)!), pull)), | |
| 764 | ); | |
| 765 | const rows = pulls.map((pull: Pull, n) => { | |
| 745 | 766 | const repo = scope.byId.get(pull.repoId)!; |
| 746 | 767 | const picture = pictureOf(repo); |
| 747 | 768 | const author = voiceFor( |
| @@ -759,7 +780,7 @@ function landed(ctx: Ctx, scope: BoardScope): Html { | ||
| 759 | 780 | title: pull.title, |
| 760 | 781 | titleAttr: pull.title, |
| 761 | 782 | meta: [html`${repo.name} · ${author}${told === null ? "" : html` · told by ${told}`}`], |
| 762 | trail: time(pull.mergedAt ?? pull.createdAt), | |
| 783 | trail: html`${wordBadge(words[n]!.word)}${time(pull.mergedAt ?? pull.createdAt)}`, | |
| 763 | 784 | }); |
| 764 | 785 | }); |
| 765 | 786 | return region( |
| @@ -843,7 +864,7 @@ const PILE_LEAD: Record<DeciderPile, string> = { | ||
| 843 | 864 | asked: "What the decider asked you about today.", |
| 844 | 865 | }; |
| 845 | 866 | |
| 846 | function decided(ctx: Ctx, scope: BoardScope): Html { | |
| 867 | async function decided(ctx: Ctx, scope: BoardScope): Promise<Html> { | |
| 847 | 868 | const db = ctx.app.db; |
| 848 | 869 | // The decision ledger is the owner's own record of what the crew chose — |
| 849 | 870 | // never shown for a repository this viewer can only read. |
| @@ -863,15 +884,41 @@ function decided(ctx: Ctx, scope: BoardScope): Html { | ||
| 863 | 884 | db, |
| 864 | 885 | decisions.map((d) => d.issueId).filter((id): id is string => id !== null), |
| 865 | 886 | ); |
| 866 | const rows = decisions.map((d: Decision) => { | |
| 887 | // What each decision was about: the issue picked for `next`, the pull | |
| 888 | // for the rest. A pick names its issue by number. | |
| 889 | const subjects = decisions.map((d): Issue | undefined => { | |
| 890 | const issue = d.issueId === null ? undefined : about.get(d.issueId); | |
| 891 | if (d.kind === "next" && /^\d+$/.test(d.answer)) | |
| 892 | return getIssue(db, d.repoId, Number(d.answer)) ?? issue; | |
| 893 | return issue; | |
| 894 | }); | |
| 895 | // What came of it is where that pull or tell stands now, in the studio's | |
| 896 | // own words — never the ledger's private outcome, so a merge production | |
| 897 | // has not served yet reads *waiting* here as it does on the app's page. | |
| 898 | const closing = new Map<string, Map<number, Pull>>(); | |
| 899 | const words = await Promise.all( | |
| 900 | decisions.map(async (d, n) => { | |
| 901 | const issue = subjects[n]; | |
| 902 | if (issue === undefined) return undefined; | |
| 903 | const repo = scope.byId.get(d.repoId)!; | |
| 904 | const wordCtx = scope.words(repo); | |
| 905 | if (issue.kind === "pull") { | |
| 906 | const pull = getPull(db, repo.id, issue.number); | |
| 907 | return pull && (await pullWord(wordCtx, pull)); | |
| 908 | } | |
| 909 | let pulls = closing.get(repo.id); | |
| 910 | if (pulls === undefined) { | |
| 911 | pulls = openPullsClosing(db, repo.id); | |
| 912 | closing.set(repo.id, pulls); | |
| 913 | } | |
| 914 | return await tellWord(wordCtx, repo, issue, pulls.get(issue.number)); | |
| 915 | }), | |
| 916 | ); | |
| 917 | const rows = decisions.map((d: Decision, n) => { | |
| 867 | 918 | const repo = scope.byId.get(d.repoId)!; |
| 868 | 919 | const base = repoBase(repo); |
| 869 | // What the decision was about: the issue picked for `next`, the pull | |
| 870 | // for the rest. A pick names its issue by number. | |
| 871 | let issue: Issue | undefined = | |
| 872 | d.issueId === null ? undefined : about.get(d.issueId); | |
| 873 | if (d.kind === "next" && /^\d+$/.test(d.answer)) | |
| 874 | issue = getIssue(db, repo.id, Number(d.answer)) ?? issue; | |
| 920 | const issue = subjects[n]; | |
| 921 | const word = words[n]?.word; | |
| 875 | 922 | const href = |
| 876 | 923 | issue === undefined |
| 877 | 924 | ? base |
| @@ -890,9 +937,7 @@ function decided(ctx: Ctx, scope: BoardScope): Html { | ||
| 890 | 937 | ], |
| 891 | 938 | under: meter(sure, `${sure}% sure`), |
| 892 | 939 | trail: html`${ |
| 893 | d.outcome === null | |
| 894 | ? html`<span class="muted">—</span>` | |
| 895 | : badge(d.outcome, { tone: OUTCOME_TONE[d.outcome] ?? "default" }) | |
| 940 | word === undefined ? html`<span class="muted">—</span>` : wordBadge(word) | |
| 896 | 941 | }<span class="num muted small" title="${sure}% sure">${sure}%</span>${time(d.createdAt)}`, |
| 897 | 942 | }); |
| 898 | 943 | }); |
| @@ -956,26 +1001,16 @@ function today(ctx: Ctx, scope: BoardScope): Html { | ||
| 956 | 1001 | ) |
| 957 | 1002 | .map((repo) => { |
| 958 | 1003 | const s = autonomyStatus(ctx.app, repo); |
| 959 | const runs = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter( | |
| 960 | (r) => r.createdAt >= since, | |
| 961 | ).length; | |
| 962 | const shipped = listPulls(db, repo.id, { | |
| 963 | state: "closed", | |
| 964 | limit: 100, | |
| 965 | }).filter((p) => p.mergedAt !== null && p.mergedAt >= since).length; | |
| 1004 | // The app page's own reads (`studio/app.ts`), over today rather | |
| 1005 | // than the week: counted by the database, not off a page of rows. | |
| 1006 | const runs = agentRunTotals(db, repo.id, since).runs; | |
| 1007 | const shipped = mergedPullCount(db, repo.id, since); | |
| 966 | 1008 | const share = s.ceilingUsd > 0 ? s.spentTodayUsd / s.ceilingUsd : 0; |
| 967 | 1009 | const review = reviewWords(reviewStats(db, repo.id, since)); |
| 968 | 1010 | const held = |
| 969 | 1011 | repo.autonomy === "autonomous" ? plannerHold(ctx.app, repo, now) : null; |
| 970 | 1012 | const holdLine = held === null ? null : holdWords(held); |
| 971 | const note = | |
| 972 | s.paused === "ceiling" | |
| 973 | ? "at its ceiling until 00:00 UTC" | |
| 974 | : s.paused === "nobody" | |
| 975 | ? "nobody turned it on" | |
| 976 | : s.paused === "no crew" | |
| 977 | ? "no crew" | |
| 978 | : repo.autonomy; | |
| 1013 | const note = s.paused === null ? repo.autonomy : pausedWords(s.paused); | |
| 979 | 1014 | return boardRow({ |
| 980 | 1015 | key: `app:${repo.ownerName}/${repo.name}`, |
| 981 | 1016 | href: repoBase(repo), |
src/web/studio/app.ts +2 −6
| @@ -59,7 +59,7 @@ import { nowCard, nowRegion } from "./now.ts"; | ||
| 59 | 59 | import { repoAsks } from "./asks.ts"; |
| 60 | 60 | import { notesCard } from "./notes.ts"; |
| 61 | 61 | import { appPanels } from "./previews.ts"; |
| 62 | import { noCrewLine, servingHistory, type WordCtx } from "./words.ts"; | |
| 62 | import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts"; | |
| 63 | 63 | import { crewKeyNote } from "./crew.ts"; |
| 64 | 64 | import { |
| 65 | 65 | notePhase, |
| @@ -472,11 +472,7 @@ export async function studioApp( | ||
| 472 | 472 | <dd>${governor.why}</dd>`} |
| 473 | 473 | ${autonomy.paused |
| 474 | 474 | ? html`<dt>Paused</dt> |
| 475 | <dd> | |
| 476 | ${autonomy.paused === "ceiling" | |
| 477 | ? "at the ceiling, until 00:00 UTC" | |
| 478 | : autonomy.paused} | |
| 479 | </dd>` | |
| 475 | <dd>${pausedWords(autonomy.paused)}</dd>` | |
| 480 | 476 | : null} |
| 481 | 477 | </dl> |
| 482 | 478 | ${when(crew.length === 0, () => noCrewLine(`${base}/settings`))}`, |
src/web/studio/words.ts +44 −12
| @@ -41,6 +41,7 @@ import { | ||
| 41 | 41 | } from "../../core/runs.ts"; |
| 42 | 42 | import { html, type Html } from "../html.ts"; |
| 43 | 43 | import { icon } from "../ui.ts"; |
| 44 | import type { Paused } from "../../crew/autonomy.ts"; | |
| 44 | 45 | |
| 45 | 46 | /** |
| 46 | 47 | * The empty crew, said once: the shelf's stuck tell, the app page's autonomy |
| @@ -535,22 +536,38 @@ export function productionFact( | ||
| 535 | 536 | return failed ?? shipped; |
| 536 | 537 | } |
| 537 | 538 | |
| 539 | /** The colour a word is drawn in. */ | |
| 540 | export type WordVariant = "success" | "danger" | "accent" | "secondary"; | |
| 541 | ||
| 542 | /** | |
| 543 | * Each word's colour, decided once: the badge draws it and the board | |
| 544 | * colours its cards by it, so a word added to `Word` cannot render | |
| 545 | * uncoloured anywhere — the `Record` will not compile without it. | |
| 546 | * `cancelled` draws the same quiet, secondary look as `waiting` and | |
| 547 | * `planned` — a restart is never the danger red a real failure earns. | |
| 548 | */ | |
| 549 | const WORD_VARIANT: Record<Word, WordVariant> = { | |
| 550 | working: "accent", | |
| 551 | look: "success", | |
| 552 | shipped: "success", | |
| 553 | stopped: "danger", | |
| 554 | waiting: "secondary", | |
| 555 | planned: "secondary", | |
| 556 | told: "secondary", | |
| 557 | done: "success", | |
| 558 | cancelled: "secondary", | |
| 559 | silent: "danger", | |
| 560 | "needs input": "secondary", | |
| 561 | }; | |
| 562 | ||
| 563 | export const wordVariant = (word: Word): WordVariant => WORD_VARIANT[word]; | |
| 564 | ||
| 538 | 565 | /** One of the five (or six) studio words, as a badge. An id names the one a |
| 539 | * live script repaints as a run finishes (`studioRunJs`). `cancelled` draws | |
| 540 | * the same quiet, secondary look as `waiting` and `planned` — a restart is | |
| 541 | * never the danger red a real failure earns. */ | |
| 566 | * live script repaints as a run finishes (`studioRunJs`). */ | |
| 542 | 567 | export function wordBadge(word: Word, id?: string): Html { |
| 543 | const variant = | |
| 544 | word === "look" || word === "shipped" || word === "done" | |
| 545 | ? "success" | |
| 546 | : word === "stopped" || word === "silent" | |
| 547 | ? "danger" | |
| 548 | : word === "working" | |
| 549 | ? "accent" | |
| 550 | : "secondary"; | |
| 551 | 568 | return html`<span |
| 552 | 569 | class="badge" |
| 553 | data-variant="${variant}" | |
| 570 | data-variant="${wordVariant(word)}" | |
| 554 | 571 | data-word="${word}" |
| 555 | 572 | ${id === undefined ? null : html`id="${id}"`} |
| 556 | 573 | >${word === "working" ? icon("loader") : null}${word === "told" |
| @@ -559,6 +576,21 @@ export function wordBadge(word: Word, id?: string): Html { | ||
| 559 | 576 | >`; |
| 560 | 577 | } |
| 561 | 578 | |
| 579 | /** Why an app that runs on its own is not running right now, in words — | |
| 580 | * the app page's *On its own* card and the board's *Today* row both. */ | |
| 581 | export function pausedWords(paused: Paused): string { | |
| 582 | switch (paused) { | |
| 583 | case "ceiling": | |
| 584 | return "at the ceiling, until 00:00 UTC"; | |
| 585 | case "forge ceiling": | |
| 586 | return "at the forge's ceiling, until 00:00 UTC"; | |
| 587 | case "nobody": | |
| 588 | return "nobody turned it on"; | |
| 589 | case "no crew": | |
| 590 | return "no crew"; | |
| 591 | } | |
| 592 | } | |
| 593 | ||
| 562 | 594 | /** A word at rest: one small coloured dot, the word (and its note) for a |
| 563 | 595 | * screen reader. Colour alone is not a mark, so a state that needs someone |
| 564 | 596 | * (`wordNeedsText`) is also written beside the row (see `wordText`). */ |
test/board.test.ts +177 −3
| @@ -3,7 +3,7 @@ import { stylesheetAsset } from "../src/web/asset.ts"; | ||
| 3 | 3 | * The board: a live wall of the crews at work. Signed in, four live |
| 4 | 4 | * regions; a run in flight and a merged pull on it; `?apps=` narrows it; |
| 5 | 5 | * `?wall` is its wall mode, where every old `/big` address lands; |
| 6 | * anonymous is sent to sign in. | |
| 6 | * anonymous is sent to sign in. Its words are the studio's own. | |
| 7 | 7 | */ |
| 8 | 8 | import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; |
| 9 | 9 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| @@ -11,6 +11,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 11 | 11 | import { tmpdir } from "node:os"; |
| 12 | 12 | import { join } from "node:path"; |
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 14 | 15 | import { forgeNeeds } from "../src/actions/health.ts"; |
| 15 | 16 | import { changeCapacity } from "../src/actions/capacity.ts"; |
| 16 | 17 | import { enableApp } from "../src/actions/apps.ts"; |
| @@ -247,8 +248,11 @@ describe("the board", () => { | ||
| 247 | 248 | html.indexOf('id="working"'), |
| 248 | 249 | html.indexOf('id="landed"'), |
| 249 | 250 | ); |
| 250 | expect(working).toContain(">working</span"); | |
| 251 | expect(working).toContain(">queued</span"); | |
| 251 | // In the studio's words (`runWord`): in flight is *working*, queued | |
| 252 | // is *waiting* — the board has no words of its own. | |
| 253 | expect(working).toContain('data-word="working"'); | |
| 254 | expect(working).toContain('data-word="waiting"'); | |
| 255 | expect(working).not.toContain(">queued</span"); | |
| 252 | 256 | expect(working.indexOf("on the cart")).toBeLessThan( |
| 253 | 257 | working.indexOf("Make the notes page load"), |
| 254 | 258 | ); |
| @@ -408,6 +412,176 @@ describe("the board", () => { | ||
| 408 | 412 | }); |
| 409 | 413 | }); |
| 410 | 414 | |
| 415 | describe("the board speaks the studio's words", () => { | |
| 416 | let dataDir: string; | |
| 417 | let app: App; | |
| 418 | let servers: Servers; | |
| 419 | let runtime: AppsRuntime; | |
| 420 | let cookie: string; | |
| 421 | ||
| 422 | const board = async () => | |
| 423 | ( | |
| 424 | await fetch(`${servers.url}/board`, { | |
| 425 | headers: { cookie }, | |
| 426 | redirect: "manual", | |
| 427 | }) | |
| 428 | ).text(); | |
| 429 | const between = (html: string, from: string, to: string) => | |
| 430 | html.slice(html.indexOf(`id="${from}"`), html.indexOf(`id="${to}"`)); | |
| 431 | ||
| 432 | /** Production at this commit, as the runtime would report it — the same | |
| 433 | * stand-in `test/studio.test.ts` uses for its words. */ | |
| 434 | const serving = (appId: string, commitOid: string): void => { | |
| 435 | const envs = (runtime as unknown as { envs: Map<string, unknown> }).envs; | |
| 436 | const record = { id: appId }; | |
| 437 | envs.set(`${appId}/production`, { | |
| 438 | app: record, | |
| 439 | env: "production", | |
| 440 | chain: Promise.resolve(), | |
| 441 | restarts: [], | |
| 442 | live: { | |
| 443 | commitOid, | |
| 444 | handle: { port: 4321 }, | |
| 445 | runId: "fake", | |
| 446 | since: new Date().toISOString(), | |
| 447 | manifest: { tty: null }, | |
| 448 | }, | |
| 449 | }); | |
| 450 | }; | |
| 451 | ||
| 452 | beforeAll(async () => { | |
| 453 | dataDir = await mkdtemp(join(tmpdir(), "forge-board-words-")); | |
| 454 | app = createApp({ dataDir, port: 0 }); | |
| 455 | servers = await serve(app); | |
| 456 | // Never started: nothing deploys on its own, so production stands | |
| 457 | // exactly where `serving` puts it. | |
| 458 | runtime = new AppsRuntime(app, { | |
| 459 | domain: "board-words.test", | |
| 460 | scheme: "http", | |
| 461 | publicPort: servers.port, | |
| 462 | forgeUrl: servers.url, | |
| 463 | concurrency: 1, | |
| 464 | }); | |
| 465 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 466 | username: "alice", | |
| 467 | email: "[email protected]", | |
| 468 | password: "a-good-passphrase", | |
| 469 | adminIfFirst: true, | |
| 470 | }); | |
| 471 | if (!a.ok) throw new Error(a.error); | |
| 472 | cookie = ( | |
| 473 | ( | |
| 474 | await fetch(`${servers.url}/signin`, { | |
| 475 | method: "POST", | |
| 476 | body: new URLSearchParams({ | |
| 477 | username: "alice", | |
| 478 | password: "a-good-passphrase", | |
| 479 | }), | |
| 480 | redirect: "manual", | |
| 481 | }) | |
| 482 | ).headers.get("set-cookie") ?? "" | |
| 483 | ).split(";")[0]!; | |
| 484 | }, 30_000); | |
| 485 | ||
| 486 | afterAll(async () => { | |
| 487 | servers.stop(); | |
| 488 | app.close(); | |
| 489 | await rm(dataDir, { recursive: true, force: true }); | |
| 490 | }); | |
| 491 | ||
| 492 | test("a merge production has not served is not shipped; a run in flight is working; the regions are as they were", async () => { | |
| 493 | const alice = getUserByName(app.db, "alice")!; | |
| 494 | const made = await createRepo(app, alice, { name: "notes" }); | |
| 495 | if (!made.ok) throw new Error(String(made.error)); | |
| 496 | const repo = made.value; | |
| 497 | const seeded = await commitToBranch(app, alice, repo, { | |
| 498 | message: "Start", | |
| 499 | files: [{ path: "README.md", content: "start\n" }], | |
| 500 | }); | |
| 501 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 502 | const enabled = await enableApp(app, alice, repo, { slug: "notes" }); | |
| 503 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 504 | const record = getAppByRepo(app.db, repo.id)!; | |
| 505 | ||
| 506 | const branch = await commitToBranch(app, alice, repo, { | |
| 507 | branch: "feature/fast", | |
| 508 | base: "main", | |
| 509 | message: "Faster", | |
| 510 | files: [{ path: "FAST.md", content: "fast\n" }], | |
| 511 | }); | |
| 512 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 513 | const opened = await openPull(app, alice, repo, { | |
| 514 | title: "Faster notes", | |
| 515 | body: "", | |
| 516 | baseBranch: "main", | |
| 517 | headBranch: "feature/fast", | |
| 518 | }); | |
| 519 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 520 | recordDecision(app.db, { | |
| 521 | kind: "land", | |
| 522 | repoId: repo.id, | |
| 523 | issueId: opened.value.id, | |
| 524 | state: {}, | |
| 525 | answer: "ship", | |
| 526 | p: 0.9, | |
| 527 | by: "typesafe", | |
| 528 | ms: 1, | |
| 529 | }); | |
| 530 | const merged = await mergePull(app, alice, repo, opened.value, "squash"); | |
| 531 | if (!merged.ok) throw new Error(JSON.stringify(merged.error)); | |
| 532 | const pull = getPull(app.db, repo.id, opened.value.number)!; | |
| 533 | const run = createRun(app.db, { | |
| 534 | repoId: repo.id, | |
| 535 | kind: "agent", | |
| 536 | reason: "feedback", | |
| 537 | summary: "on the list", | |
| 538 | }); | |
| 539 | claimRun(app.db, run.id); | |
| 540 | ||
| 541 | // Production is still on the commit before the merge. | |
| 542 | serving(record.id, seeded.value.commitOid); | |
| 543 | const before = await board(); | |
| 544 | const landed = between(before, "landed", "decided"); | |
| 545 | expect(landed).toContain("Faster notes"); | |
| 546 | expect(landed).toMatch(/data-variant="secondary"\s+data-word="waiting"/); | |
| 547 | expect(landed).not.toContain('data-word="shipped"'); | |
| 548 | // The ledger wrote *shipped* at the merge; the board does not repeat it. | |
| 549 | const decided = between(before, "decided", "today"); | |
| 550 | expect(decided).toContain("ship or show"); | |
| 551 | expect(decided).toContain('data-word="waiting"'); | |
| 552 | expect(decided).not.toContain(">shipped<"); | |
| 553 | expect(decided).not.toContain("data-outcome"); | |
| 554 | const working = between(before, "working", "landed"); | |
| 555 | expect(working).toMatch( | |
| 556 | /data-variant="accent"\s+data-word="working"/, | |
| 557 | ); | |
| 558 | expect(working).toContain("on what you said"); | |
| 559 | // The regions themselves are as they were. | |
| 560 | expect(before).toMatch( | |
| 561 | /id="working"\s+data-live="run\.queued,run\.started,run\.finished"/, | |
| 562 | ); | |
| 563 | expect(before).toMatch( | |
| 564 | /id="landed"\s+data-live="pull\.merged,run\.finished"/, | |
| 565 | ); | |
| 566 | expect(before).toMatch( | |
| 567 | /id="decided"\s+data-live="run\.queued,run\.started,run\.finished,pull\.\*"/, | |
| 568 | ); | |
| 569 | expect(before).toMatch( | |
| 570 | /id="today"\s+data-live="run\.queued,run\.started,run\.finished,pull\.merged"/, | |
| 571 | ); | |
| 572 | ||
| 573 | // Production caught up with the merge: shipped, in both lists. | |
| 574 | serving(record.id, pull.mergedOid!); | |
| 575 | const after = await board(); | |
| 576 | expect(between(after, "landed", "decided")).toMatch( | |
| 577 | /data-variant="success"\s+data-word="shipped"/, | |
| 578 | ); | |
| 579 | expect(between(after, "decided", "today")).toContain( | |
| 580 | 'data-word="shipped"', | |
| 581 | ); | |
| 582 | }); | |
| 583 | }); | |
| 584 | ||
| 411 | 585 | describe("the board's forge strip", () => { |
| 412 | 586 | let dataDir: string; |
| 413 | 587 | let app: App; |
new above
offline