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

Merged opened this

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

Merged

mason/forge:mason/67-the-board-speaks-the-studio-s-five-words → main 3 commits fork

  • Merged. mason/67-the-board-speaks-the-studio-s-five-words was merged into main as 5828960.

Review

Sign in to review this change.
5 files changed +337 −90
docs/STUDIO.md +13 −3
@@ -481,14 +481,24 @@ front page's "All public apps" leads here.
481481 (**Focus** on that card opens it): no top bar, dark unless you chose a
482482 theme, four regions that fill the window. **Working now** is one card per
483483 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
485485 changes across your apps, each with the app's production look on a phone;
486486 for an app with pillars it leads with what merged in the last 7 days by
487487 pillar, each a link to that pillar's issues (`docs/PLANNING.md`).
488488 **Decided** is the last dozen entries in the decision ledger: which issue
489489 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
492502 other app the crew spent on this week — and beneath it the efficiency
493503 signal: spend per shipped change over the last 7 days (the usage ledger's
494504 `cost_usd` ÷ pulls merged, `core/efficiency.ts`, every app in one grouped
src/web/pages/board.ts +101 −66
@@ -7,7 +7,9 @@
77 * forge's health, and each subscription's windows; whoever owns an app gets
88 * a line for each thing its crew waits on them for. Nothing on it
99 * 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`.
1113 *
1214 * `?wall` is its one wall mode, for a screen across the room that nobody
1315 * touches: the same regions in larger type, without the key hints or a
@@ -15,7 +17,7 @@
1517 */
1618
1719 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";
1921 import { sentinelNotice } from "../../crew/sentinel.ts";
2022 import { appsForRepos, type AppRecord } from "../../core/apps.ts";
2123 import { ASKS_WINDOW_DAYS, askMeasureWeek, asksMeasureEmpty, asksMeasureParts } from "../../core/ask_ledger.ts";
@@ -59,13 +61,20 @@ import {
5961 pillarView,
6062 reposWithUmbrellas,
6163 } 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";
6372 import {
6473 listReposVisibleTo,
6574 ownerFactRepoIds,
6675 type Repo,
6776 } from "../../core/repos.ts";
68 import { listActiveRuns, listRuns, type Run } from "../../core/runs.ts";
77 import { agentRunTotals, listActiveRuns, type Run } from "../../core/runs.ts";
6978 import { getUsersByIds } from "../../core/users.ts";
7079 import {
7180 autonomyStatus,
@@ -92,14 +101,22 @@ import {
92101 pill,
93102 time,
94103 tokenCount,
95 type Tone,
96104 } from "../ui.ts";
105 import { tellWord } from "../studio/tell.ts";
97106 import { voiceFor } from "../studio/voice.ts";
107 import { repoDir } from "../../git/repos.ts";
98108 import { perShippedWords } from "../../core/efficiency.ts";
99109 import {
110 pausedWords,
111 pullWord,
112 runWord,
113 servingHistory,
100114 windowWords,
115 wordBadge,
101116 type FigureWords,
117 type WordCtx,
102118 } from "../studio/words.ts";
119 import { appsRuntime } from "../../apps/runtime.ts";
103120 import { lookUrl } from "../../core/looks.ts";
104121 import { thumbLook } from "./looks.ts";
105122 import { milestoneHref } from "./milestones.ts";
@@ -126,29 +143,6 @@ function reasonWords(reason: string): string {
126143 }
127144 }
128145
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
152146 const KIND_WORDS: Record<DecisionKind, string> = {
153147 next: "next issue",
154148 land: "ship or show",
@@ -191,6 +185,9 @@ interface BoardScope {
191185 * access, same as the studio. A repo the viewer can only read is on the
192186 * board (it is still theirs to watch land) but carries none of that. */
193187 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;
194191 }
195192
196193 /** The scope for some repositories: the ones among them that are apps. */
@@ -200,19 +197,37 @@ function boardScope(
200197 records: ReadonlyMap<string, AppRecord>,
201198 ): BoardScope {
202199 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>();
203203 return {
204204 repos: kept,
205205 ids: kept.map((r) => r.id),
206206 byId: new Map(kept.map((r) => [r.id, r])),
207207 records,
208208 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 },
209224 };
210225 }
211226
212227 // ---- the page ----
213228
214229 /** 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> {
216231 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
217232 const user = ctx.auth.user;
218233 if (!user) return signInFirst(ctx);
@@ -251,9 +266,9 @@ export function boardPage(ctx: Ctx): Response {
251266 const asks = timePhase(rq, "asks", () => asksStrip(ctx));
252267 const returns = timePhase(rq, "returns", () => returnsStrip(ctx, scope));
253268 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));
257272 const spendNow = timePhase(rq, "today", () => today(ctx, scope));
258273 const rendering = performance.now();
259274
@@ -651,7 +666,7 @@ function windowsStrip(ctx: Ctx): Html | null {
651666
652667 // ---- working now ----
653668
654 function working(ctx: Ctx, scope: BoardScope): Html {
669 async function working(ctx: Ctx, scope: BoardScope): Promise<Html> {
655670 const db = ctx.app.db;
656671 const runs = listActiveRuns(db, scope.ids);
657672 // 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 {
669684 db,
670685 runs.map((r) => r.issueId).filter((id): id is string => id !== null),
671686 );
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) => {
673691 const repo = scope.byId.get(run.repoId)!;
674692 // The same rule the app's page signs its rows by, `voiceFor`
675693 // (`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 {
683701 scope.ownerFacts.has(repo.id),
684702 );
685703 const issue = run.issueId === null ? undefined : about.get(run.issueId);
686 const word = stateWord(run);
704 const { word, note } = words[n]!;
687705 return boardRow({
688706 key: `run:${run.id}`,
689707 href: `${repoBase(repo)}/runs/${run.id}`,
@@ -692,12 +710,12 @@ function working(ctx: Ctx, scope: BoardScope): Html {
692710 titleAttr: issue?.title ?? run.summary,
693711 meta: [
694712 html`<b>${who}</b> · ${repo.name} ·
695 ${word === "paused" ? "resumes soon" : reasonWords(run.reason)}${when(
713 ${run.notBefore === null ? reasonWords(run.reason) : note}${when(
696714 run.costUsd > 0 && scope.ownerFacts.has(repo.id),
697715 () => html` · <span class="num">${money(run.costUsd)}</span>`,
698716 )}`,
699717 ],
700 trail: html`${badge(word, STATE_LOOK[word])}<span
718 trail: html`${wordBadge(word)}<span
701719 class="num"
702720 data-started="${at(run)}"
703721 ></span>`,
@@ -717,7 +735,7 @@ function working(ctx: Ctx, scope: BoardScope): Html {
717735
718736 // ---- landed ----
719737
720 function landed(ctx: Ctx, scope: BoardScope): Html {
738 async function landed(ctx: Ctx, scope: BoardScope): Promise<Html> {
721739 const db = ctx.app.db;
722740 const pulls = mergedPullsForRepos(db, scope.ids, 12);
723741 // The newest production picture of each app, on a phone, in one read.
@@ -741,7 +759,10 @@ function landed(ctx: Ctx, scope: BoardScope): Html {
741759 lookUrl(repoBase(repo), look)
742760 );
743761 };
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) => {
745766 const repo = scope.byId.get(pull.repoId)!;
746767 const picture = pictureOf(repo);
747768 const author = voiceFor(
@@ -759,7 +780,7 @@ function landed(ctx: Ctx, scope: BoardScope): Html {
759780 title: pull.title,
760781 titleAttr: pull.title,
761782 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)}`,
763784 });
764785 });
765786 return region(
@@ -843,7 +864,7 @@ const PILE_LEAD: Record<DeciderPile, string> = {
843864 asked: "What the decider asked you about today.",
844865 };
845866
846 function decided(ctx: Ctx, scope: BoardScope): Html {
867 async function decided(ctx: Ctx, scope: BoardScope): Promise<Html> {
847868 const db = ctx.app.db;
848869 // The decision ledger is the owner's own record of what the crew chose —
849870 // never shown for a repository this viewer can only read.
@@ -863,15 +884,41 @@ function decided(ctx: Ctx, scope: BoardScope): Html {
863884 db,
864885 decisions.map((d) => d.issueId).filter((id): id is string => id !== null),
865886 );
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) => {
867918 const repo = scope.byId.get(d.repoId)!;
868919 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;
875922 const href =
876923 issue === undefined
877924 ? base
@@ -890,9 +937,7 @@ function decided(ctx: Ctx, scope: BoardScope): Html {
890937 ],
891938 under: meter(sure, `${sure}% sure`),
892939 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)
896941 }<span class="num muted small" title="${sure}% sure">${sure}%</span>${time(d.createdAt)}`,
897942 });
898943 });
@@ -956,26 +1001,16 @@ function today(ctx: Ctx, scope: BoardScope): Html {
9561001 )
9571002 .map((repo) => {
9581003 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);
9661008 const share = s.ceilingUsd > 0 ? s.spentTodayUsd / s.ceilingUsd : 0;
9671009 const review = reviewWords(reviewStats(db, repo.id, since));
9681010 const held =
9691011 repo.autonomy === "autonomous" ? plannerHold(ctx.app, repo, now) : null;
9701012 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);
9791014 return boardRow({
9801015 key: `app:${repo.ownerName}/${repo.name}`,
9811016 href: repoBase(repo),
src/web/studio/app.ts +2 −6
@@ -59,7 +59,7 @@ import { nowCard, nowRegion } from "./now.ts";
5959 import { repoAsks } from "./asks.ts";
6060 import { notesCard } from "./notes.ts";
6161 import { appPanels } from "./previews.ts";
62 import { noCrewLine, servingHistory, type WordCtx } from "./words.ts";
62 import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts";
6363 import { crewKeyNote } from "./crew.ts";
6464 import {
6565 notePhase,
@@ -472,11 +472,7 @@ export async function studioApp(
472472 <dd>${governor.why}</dd>`}
473473 ${autonomy.paused
474474 ? 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>`
480476 : null}
481477 </dl>
482478 ${when(crew.length === 0, () => noCrewLine(`${base}/settings`))}`,
src/web/studio/words.ts +44 −12
@@ -41,6 +41,7 @@ import {
4141 } from "../../core/runs.ts";
4242 import { html, type Html } from "../html.ts";
4343 import { icon } from "../ui.ts";
44 import type { Paused } from "../../crew/autonomy.ts";
4445
4546 /**
4647 * The empty crew, said once: the shelf's stuck tell, the app page's autonomy
@@ -535,22 +536,38 @@ export function productionFact(
535536 return failed ?? shipped;
536537 }
537538
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
538565 /** 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`). */
542567 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";
551568 return html`<span
552569 class="badge"
553 data-variant="${variant}"
570 data-variant="${wordVariant(word)}"
554571 data-word="${word}"
555572 ${id === undefined ? null : html`id="${id}"`}
556573 >${word === "working" ? icon("loader") : null}${word === "told"
@@ -559,6 +576,21 @@ export function wordBadge(word: Word, id?: string): Html {
559576 >`;
560577 }
561578
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
562594 /** A word at rest: one small coloured dot, the word (and its note) for a
563595 * screen reader. Colour alone is not a mark, so a state that needs someone
564596 * (`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";
33 * The board: a live wall of the crews at work. Signed in, four live
44 * regions; a run in flight and a merged pull on it; `?apps=` narrows it;
55 * `?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.
77 */
88 import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
99 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -11,6 +11,7 @@ import { mkdtemp, rm } from "node:fs/promises";
1111 import { tmpdir } from "node:os";
1212 import { join } from "node:path";
1313 import { createApp, type App } from "../src/app.ts";
14 import { AppsRuntime } from "../src/apps/runtime.ts";
1415 import { forgeNeeds } from "../src/actions/health.ts";
1516 import { changeCapacity } from "../src/actions/capacity.ts";
1617 import { enableApp } from "../src/actions/apps.ts";
@@ -247,8 +248,11 @@ describe("the board", () => {
247248 html.indexOf('id="working"'),
248249 html.indexOf('id="landed"'),
249250 );
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");
252256 expect(working.indexOf("on the cart")).toBeLessThan(
253257 working.indexOf("Make the notes page load"),
254258 );
@@ -408,6 +412,176 @@ describe("the board", () => {
408412 });
409413 });
410414
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
411585 describe("the board's forge strip", () => {
412586 let dataDir: string;
413587 let app: App;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs