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

A studio page spawns one git process per row to decide the word "shipped" #319

Merged opened this

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

Merged

mason/forge:mason/285-a-studio-page-spawns-one-git-process-per → main 1 commit fork

  • Merged. mason/285-a-studio-page-spawns-one-git-process-per was merged into main as 877c5e6.

Review

Sign in to review this change.
13 files changed +158 −18
docs/STUDIO.md +4 −1
@@ -42,7 +42,10 @@ state, and once it is over, from what it produced: a pull, judged by
4242 (`checkRunWord`), closed without a merge is *stopped*, and merged is
4343 *shipped* only once production's live commit is at or past the one the
4444 merge carried (an ancestry check in git, not equality — a later merge
45 already deploying it still counts). Between a merge and production
45 already deploying it still counts). That check is one `git rev-list` of
46 production's commit per repository per page, memoised on the page's
47 `WordCtx` (`servingHistory`) and read by every row — never one git process
48 per row, and never kept past the render. Between a merge and production
4649 catching up, the word is its own honest one — *working* while production
4750 deploys it, *stopped* if that deploy failed, *waiting* if production has
4851 not started on it yet — and it becomes *shipped* on the next render or
src/git/merge.ts +14
@@ -173,6 +173,20 @@ export async function mergeBase(
173173 return result.ok ? result.value.trim() : undefined;
174174 }
175175
176 /**
177 * Every commit reachable from `oid`, itself included — the whole ancestry in
178 * one spawn, so a page asking "is this at or behind that" of many commits
179 * reads a set instead of running `mergeBase` once per commit.
180 */
181 export async function ancestorsOf(
182 dir: string,
183 oid: string,
184 ): Promise<ReadonlySet<string> | undefined> {
185 if (!isSafeOid(oid)) return undefined;
186 const result = await runGit(["rev-list", oid], { cwd: dir });
187 return result.ok ? new Set(result.value.split("\n")) : undefined;
188 }
189
176190 /** Count commits reachable from head but not base (the PR's commit count). */
177191 export async function aheadCount(
178192 dir: string,
src/web/pages/studio.ts +6 −1
@@ -10,4 +10,9 @@
1010
1111 export { decisionsFor } from "../studio/asks.ts";
1212 export { pendingLeans, withLikely } from "../studio/leans.ts";
13 export { pullWord, runWord, type WordCtx } from "../studio/words.ts";
13 export {
14 pullWord,
15 runWord,
16 servingHistory,
17 type WordCtx,
18 } from "../studio/words.ts";
src/web/studio/app.ts +2 −1
@@ -58,7 +58,7 @@ import { appPanels } from "./previews.ts";
5858 import { stopForm } from "./run.ts";
5959 import { tellWord } from "./tell.ts";
6060 import { voiceFor, type Actor } from "./voice.ts";
61 import { pullWord, runWord, wordBadge, type WordCtx } from "./words.ts";
61 import { pullWord, runWord, servingHistory, wordBadge, type WordCtx } from "./words.ts";
6262
6363 /**
6464 * The week's decisions in one line: how many, how many the decider took
@@ -164,6 +164,7 @@ export async function studioApp(
164164 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
165165 runtime: appsRuntime(ctx.app),
166166 record,
167 history: servingHistory(),
167168 };
168169 const crew = listCrewRoles(db, repo.id);
169170 const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent);
src/web/studio/change.ts +2 −1
@@ -38,7 +38,7 @@ import {
3838 decisionsFor,
3939 type Decision,
4040 } from "./asks.ts";
41 import { pullWord, wordBadge, type WordCtx } from "./words.ts";
41 import { pullWord, servingHistory, wordBadge, type WordCtx } from "./words.ts";
4242
4343 /** What a conflicted change's own page says: the base it no longer merges
4444 * into, the first few files (bounded, so a huge conflict never floods the
@@ -76,6 +76,7 @@ export async function studioPull(
7676 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
7777 runtime: appsRuntime(ctx.app),
7878 record,
79 history: servingHistory(),
7980 };
8081 const { word, note: checkNote } = await pullWord(wordCtx, pull);
8182 // Whether this change still merges into its base is asked here, on its own
src/web/studio/home.ts +10 −2
@@ -27,7 +27,7 @@ import { withLikely } from "./leans.ts";
2727 import { changeHref } from "./nouns.ts";
2828 import { stopForm } from "./run.ts";
2929 import { productionFactsForRepos, shelfRows } from "./shelf.ts";
30 import { pullWord, wordBadge, type WordCtx } from "./words.ts";
30 import { pullWord, servingHistory, wordBadge, type WordCtx } from "./words.ts";
3131
3232 /**
3333 * The home in the studio: what asks you first, then your apps as a shelf of
@@ -48,7 +48,14 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
4848 // Read once, shown twice again: a shipped or failed deploy is a row's own
4949 // word, and — when nothing is running and no change waits — the rail's.
5050 const productionFacts = productionFactsForRepos(ctx, appRepos);
51 const appRows = await shelfRows(ctx, appRepos, { decisions, productionFacts });
51 // One memo of production's history for every word on this page: the
52 // shelf's rows and the Now list below ask it once per repository.
53 const history = servingHistory();
54 const appRows = await shelfRows(ctx, appRepos, {
55 decisions,
56 productionFacts,
57 history,
58 });
5259
5360 // What the crew cost today across every app on the shelf, and how many
5461 // are paused at their ceiling right now — the owner's first question at
@@ -179,6 +186,7 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
179186 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
180187 runtime,
181188 record: records.get(repo.id),
189 history,
182190 },
183191 pull,
184192 );
src/web/studio/lists.ts +4
@@ -49,6 +49,7 @@ import {
4949 runWord,
5050 wordBadge,
5151 type Word,
52 servingHistory,
5253 type WordCtx,
5354 } from "./words.ts";
5455
@@ -148,6 +149,7 @@ export async function studioTells(
148149 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
149150 runtime: appsRuntime(ctx.app),
150151 record,
152 history: servingHistory(),
151153 };
152154 const closingPulls = openPullsClosing(db, repo.id);
153155 const words = await Promise.all(
@@ -243,6 +245,7 @@ export async function studioChanges(
243245 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
244246 runtime: appsRuntime(ctx.app),
245247 record,
248 history: servingHistory(),
246249 };
247250 const words = await Promise.all(
248251 fetched.map((pull) => pullWord(wordCtx, pull)),
@@ -357,6 +360,7 @@ export async function studioRuns(
357360 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
358361 runtime: appsRuntime(ctx.app),
359362 record,
363 history: servingHistory(),
360364 };
361365 const actors = actorsFor(ctx, fetched);
362366 const issues = issuesFor(ctx, fetched);
src/web/studio/run.ts +2 −1
@@ -31,7 +31,7 @@ import { costText, durationText, runTitle } from "../pages/runs.ts";
3131 import { logBox, runVoice, RUN_TRANSCRIPT_CAP } from "./log.ts";
3232 import { changeHref, tellHref } from "./nouns.ts";
3333 import { unnamedRun, voiceFor, type Actor } from "./voice.ts";
34 import { runWord, wordBadge, type WordCtx } from "./words.ts";
34 import { runWord, servingHistory, wordBadge, type WordCtx } from "./words.ts";
3535
3636 /**
3737 * The one control a live run has: a person who can write here stops a
@@ -94,6 +94,7 @@ export async function studioRun(
9494 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
9595 runtime: appsRuntime(ctx.app),
9696 record: getAppByRepo(db, repo.id),
97 history: servingHistory(),
9798 };
9899 const { word } = await runWord(wordCtx, run);
99100 const unfinished = !isTerminal(run.state);
src/web/studio/shelf.ts +7
@@ -47,7 +47,9 @@ import {
4747 productionFact,
4848 pullWord,
4949 runWord,
50 servingHistory,
5051 type ProductionFact,
52 type ServingHistory,
5153 type WordCtx,
5254 } from "./words.ts";
5355
@@ -58,6 +60,9 @@ export interface ShelfReads {
5860 /** Production's own fact for each app — a deploy that just landed or just
5961 * failed. The home rail reads the same map for its own rows. */
6062 readonly productionFacts?: ReadonlyMap<string, ProductionFact> | undefined;
63 /** The page's memo of production's history, when the page words more
64 * than the shelf — the home's own rows ask the same question. */
65 readonly history?: ServingHistory | undefined;
6166 }
6267
6368 /**
@@ -109,6 +114,7 @@ export async function shelfRows(
109114 const db = ctx.app.db;
110115 const user = ctx.auth.user;
111116 const runtime = appsRuntime(ctx.app);
117 const history = reads.history ?? servingHistory();
112118 const records = appsForRepos(
113119 db,
114120 repos.map((r) => r.id),
@@ -177,6 +183,7 @@ export async function shelfRows(
177183 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
178184 runtime,
179185 record,
186 history,
180187 };
181188 const agentWord =
182189 useOpenPull || latest === undefined
src/web/studio/tell.ts +2
@@ -43,6 +43,7 @@ import {
4343 runWord,
4444 wordBadge,
4545 type Word,
46 servingHistory,
4647 type WordCtx,
4748 } from "./words.ts";
4849
@@ -232,6 +233,7 @@ export async function studioTell(
232233 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
233234 runtime: appsRuntime(ctx.app),
234235 record: getAppByRepo(db, repo.id),
236 history: servingHistory(),
235237 };
236238 const [wordInfo, runsCard, closingCard] = await Promise.all([
237239 direction ? undefined : tellWord(wordCtx, repo, issue, closingPull),
src/web/studio/words.ts +34 −8
@@ -9,7 +9,7 @@
99 */
1010
1111 import type { Database } from "bun:sqlite";
12 import { mergeBase } from "../../git/merge.ts";
12 import { ancestorsOf } from "../../git/merge.ts";
1313 import type { AppsRuntime, EnvStatus } from "../../apps/runtime.ts";
1414 import type { AppRecord } from "../../core/apps.ts";
1515 import { getIssueById } from "../../core/issues.ts";
@@ -35,17 +35,36 @@ export type Word =
3535 | "done"
3636 | "cancelled";
3737
38 /**
39 * Production's history as one page has asked it: the ancestry of each
40 * distinct (git directory, production commit) it met, read from git once
41 * and shared by every row after — a page of N merged changes is one spawn
42 * per repository, not N. Made fresh for each render (`servingHistory`) and
43 * dropped with it, so a deploy that lands is seen on the very next page.
44 */
45 export type ServingHistory = Map<
46 string,
47 Promise<ReadonlySet<string> | undefined>
48 >;
49
50 /** An empty memo for one page's render. */
51 export function servingHistory(): ServingHistory {
52 return new Map();
53 }
54
3855 /**
3956 * Everything the word functions below need to see production: the repo's
40 * git directory (an ancestry check reads it), and the runtime plus the
41 * app record, when the repository runs one. Built once per page and
42 * threaded through — never a second way to ask what is serving.
57 * git directory (an ancestry check reads it), the runtime plus the app
58 * record, when the repository runs one, and the page's memo of what
59 * production's history holds. Built once per page and threaded through —
60 * never a second way to ask what is serving.
4361 */
4462 export interface WordCtx {
4563 readonly db: Database;
4664 readonly dir: string;
4765 readonly runtime: AppsRuntime | undefined;
4866 readonly record: AppRecord | undefined;
67 readonly history: ServingHistory;
4968 }
5069
5170 /**
@@ -64,15 +83,22 @@ function pullFromRun(db: Database, run: Run): Pull | undefined {
6483 return pullForBranch(db, run.repoId, run.branch);
6584 }
6685
67 /** Whether production is serving this commit, or something later than it. */
86 /** Whether production is serving this commit, or something later than it:
87 * ancestry, never equality, asked of git once per page and repository. */
6888 async function caughtUp(
69 dir: string,
89 ctx: WordCtx,
7090 production: EnvStatus | undefined,
7191 commitOid: string,
7292 ): Promise<boolean> {
7393 if (production === undefined || production.commitOid === "") return false;
7494 if (production.commitOid === commitOid) return true;
75 return (await mergeBase(dir, commitOid, production.commitOid)) === commitOid;
95 const key = `${ctx.dir}\0${production.commitOid}`;
96 let ancestry = ctx.history.get(key);
97 if (ancestry === undefined) {
98 ancestry = ancestorsOf(ctx.dir, production.commitOid);
99 ctx.history.set(key, ancestry);
100 }
101 return (await ancestry)?.has(commitOid) ?? false;
76102 }
77103
78104 /** A merged pull production has not caught up to yet: its own honest word. */
@@ -149,7 +175,7 @@ export async function pullWord(
149175 const production = ctx.runtime
150176 .status(ctx.record)
151177 .find((e) => e.env === "production");
152 if (await caughtUp(ctx.dir, production, commitOid))
178 if (await caughtUp(ctx, production, commitOid))
153179 return { word: "shipped", note: "production is serving it" };
154180 return mergedPullWord(production, commitOid);
155181 }
test/apps.test.ts +2 −1
@@ -48,7 +48,7 @@ import {
4848 STOPPED_SUMMARY,
4949 type Run,
5050 } from "../src/core/runs.ts";
51 import { runWord } from "../src/web/studio/words.ts";
51 import { runWord, servingHistory } from "../src/web/studio/words.ts";
5252 import { createToken } from "../src/core/tokens.ts";
5353 import { createUser, type User } from "../src/core/users.ts";
5454 import { serve, type Servers } from "../src/http/server.ts";
@@ -1929,6 +1929,7 @@ describe("the forge's own restart never reads as this deploy's failure (issue #2
19291929 dir: repoDir(app.reposRoot, repo.ownerName, repo.name),
19301930 runtime: undefined,
19311931 record: undefined,
1932 history: servingHistory(),
19321933 },
19331934 finished!,
19341935 );
test/studio.test.ts +69 −2
@@ -2,7 +2,7 @@
22 * The studio: apps as the unit, three verbs, and a switch to dev. The same
33 * URLs answer in both modes; the cookie decides which face they show.
44 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5 import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
66 import { mkdirSync, writeFileSync } from "node:fs";
77 import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
@@ -49,7 +49,13 @@ import { createUser, getUserByName, type User } from "../src/core/users.ts";
4949 import type { Decider } from "../src/crew/decide.ts";
5050 import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts";
5151 import { serve, type Servers } from "../src/http/server.ts";
52 import { pendingLeans, pullWord, runWord, type WordCtx } from "../src/web/pages/studio.ts";
52 import {
53 pendingLeans,
54 pullWord,
55 runWord,
56 servingHistory,
57 type WordCtx,
58 } from "../src/web/pages/studio.ts";
5359 import { servedPhrase } from "../src/web/ui.ts";
5460 import { repoDir } from "../src/git/repos.ts";
5561 import { appendRunLog } from "../src/runs/log.ts";
@@ -3480,6 +3486,7 @@ describe("words come from facts, not from run.summary", () => {
34803486 dir: repoDir(app.reposRoot, repo.ownerName, repo.name),
34813487 runtime,
34823488 record,
3489 history: servingHistory(),
34833490 };
34843491 });
34853492
@@ -3520,6 +3527,66 @@ describe("words come from facts, not from run.summary", () => {
35203527 expect(now.word).toBe("shipped");
35213528 });
35223529
3530 test("a page of merged pulls asks git about production once, however many rows (#285)", async () => {
3531 const pulls: Pull[] = [];
3532 for (let i = 0; i < 5; i++) {
3533 const branch = await commitToBranch(app, alice, repo, {
3534 branch: `feature/many-${i}`,
3535 base: "main",
3536 message: `Many ${i}`,
3537 files: [{ path: `MANY-${i}.md`, content: `${i}\n` }],
3538 });
3539 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
3540 const opened = await openPull(app, alice, repo, {
3541 title: `Many ${i}`,
3542 body: "",
3543 baseBranch: "main",
3544 headBranch: `feature/many-${i}`,
3545 });
3546 if (!opened.ok) throw new Error(String(opened.error));
3547 const merged = await mergePull(app, alice, repo, opened.value, "squash");
3548 if (!merged.ok) throw new Error(String(merged.error));
3549 pulls.push(getPull(app.db, repo.id, opened.value.number)!);
3550 }
3551
3552 // Every git process spawned while one page's words are decided.
3553 const spawned: string[] = [];
3554 const original = Bun.spawn.bind(Bun);
3555 const spy = spyOn(Bun, "spawn").mockImplementation(((
3556 cmd: string[],
3557 options: never,
3558 ) => {
3559 if (cmd[0] === "git") spawned.push(cmd.slice(1).join(" "));
3560 return original(cmd, options);
3561 }) as unknown as typeof Bun.spawn);
3562 const wordsOfOnePage = async (): Promise<string[]> => {
3563 const page: WordCtx = { ...wordCtx, history: servingHistory() };
3564 const words = await Promise.all(pulls.map((p) => pullWord(page, p)));
3565 return words.map((w) => w.word);
3566 };
3567 try {
3568 // Production at the second merge: that one and the one before it are
3569 // shipped (ancestry, not equality), the three after it are not.
3570 put({ commitOid: pulls[1]!.mergedOid! });
3571 expect(await wordsOfOnePage()).toEqual([
3572 "shipped",
3573 "shipped",
3574 "waiting",
3575 "waiting",
3576 "waiting",
3577 ]);
3578 expect(spawned).toHaveLength(1);
3579
3580 // A new page is a new question: the deploy that just landed is seen.
3581 spawned.length = 0;
3582 put({ commitOid: pulls[4]!.mergedOid! });
3583 expect(new Set(await wordsOfOnePage())).toEqual(new Set(["shipped"]));
3584 expect(spawned).toHaveLength(1);
3585 } finally {
3586 spy.mockRestore();
3587 }
3588 });
3589
35233590 test('an agent run that opened no pull and pushed nothing is not shipped', async () => {
35243591 const told = await openIssue(app, alice, repo, {
35253592 title: "Write a plan",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs