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

The front door says an app is quiet while a change of its own waits for a look #199

Merged opened this

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

Merged

scout/forge:scout/191-the-front-door-says-an-app-is-quiet-whil → main 1 commit fork

  • Merged. scout/191-the-front-door-says-an-app-is-quiet-whil was merged into main as 2fb44d4.

Review

Sign in to review this change.
4 files changed +270 −28
src/core/pulls.ts +31
@@ -219,6 +219,37 @@ export function listOpenPullsFromHead(
219219 return joinMany(db, [...getIssuesByIds(db, ids).values()]);
220220 }
221221
222 /**
223 * The newest open pull of each repository, in one query — what a shelf row
224 * shows when nothing about the app is moving otherwise: a change waits for
225 * a look whether an agent opened it or a person did, and the row is about
226 * the app, not about who typed it.
227 */
228 export function openPullsForRepos(
229 db: Database,
230 repoIds: readonly string[],
231 ): Map<string, Pull> {
232 const out = new Map<string, Pull>();
233 const unique = [...new Set(repoIds)];
234 const CHUNK = 400;
235 for (let i = 0; i < unique.length; i += CHUNK) {
236 const batch = unique.slice(i, i + CHUNK);
237 const rows = db
238 .query<{ issue_id: string }, string[]>(
239 `SELECT issue_id FROM (
240 SELECT pull.issue_id AS issue_id,
241 ROW_NUMBER() OVER (PARTITION BY issue.repo_id ORDER BY issue.created_at DESC) AS rn
242 FROM pull JOIN issue ON issue.id = pull.issue_id
243 WHERE issue.state = 'open' AND issue.repo_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})
244 ) WHERE rn = 1`,
245 )
246 .all(...batch);
247 const issues = getIssuesByIds(db, rows.map((r) => r.issue_id));
248 for (const pull of joinMany(db, [...issues.values()])) out.set(pull.repoId, pull);
249 }
250 return out;
251 }
252
222253 export type ListPullsOptions = Omit<ListIssuesOptions, "kind">;
223254
224255 export function listPulls(
src/web/studio/home.ts +47 −7
@@ -7,9 +7,12 @@
77 * apps grow.
88 */
99
10 import { appsRuntime } from "../../apps/runtime.ts";
11 import { openPullsForRepos, type Pull } from "../../core/pulls.ts";
1012 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
1113 import { listActiveRuns } from "../../core/runs.ts";
1214 import { getUsersByIds, type User } from "../../core/users.ts";
15 import { repoDir } from "../../git/repos.ts";
1316 import type { Ctx } from "../../http/context.ts";
1417 import { repoBase } from "../chrome.ts";
1518 import { asksJs, liveRegionsJs } from "../client.ts";
@@ -20,8 +23,10 @@ import { btn, card, empty, icon, listCard, row, time } from "../ui.ts";
2023 import { onYouCard } from "../pages/site.ts";
2124 import { appsOf, asksPanel, decisionsFor } from "./asks.ts";
2225 import { withLikely } from "./leans.ts";
26 import { changeHref } from "./nouns.ts";
2327 import { stopForm } from "./run.ts";
2428 import { shelfRows } from "./shelf.ts";
29 import { pullWord, wordBadge, type WordCtx } from "./words.ts";
2530
2631 /**
2732 * The home in the studio: what asks you first, then your apps as a shelf of
@@ -31,7 +36,8 @@ import { shelfRows } from "./shelf.ts";
3136 */
3237 export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
3338 const db = ctx.app.db;
34 const { repos: appRepos } = appsOf(ctx, user);
39 const { repos: appRepos, records } = appsOf(ctx, user);
40 const runtime = appsRuntime(ctx.app);
3541 // Read once, shown twice: the panel below is these same asks, and the
3642 // shelf counts them into each row's pill.
3743 const decisions = withLikely(ctx, decisionsFor(ctx, appRepos));
@@ -84,12 +90,16 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
8490 size: "sm",
8591 title: "The board: a live wall of the crews at work",
8692 })}`;
87 const now =
93 // Nothing running is not the same as nothing happening: an open change
94 // still waits for a look, an agent's or a person's own, and the rail
95 // links straight to it rather than reporting silence.
96 const openPulls: readonly Pull[] =
8897 running.length === 0
89 ? card(html`<p class="muted small">Nobody is working right now.</p>`, {
90 head: nowHead,
91 })
92 : listCard(
98 ? [...openPullsForRepos(db, appRepos.map((r) => r.id)).values()]
99 : [];
100 const now =
101 running.length > 0
102 ? listCard(
93103 running.map((r) => {
94104 const repo = byId.get(r.repoId)!;
95105 const actorName =
@@ -121,7 +131,37 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
121131 });
122132 }),
123133 { head: nowHead },
124 );
134 )
135 : openPulls.length === 0
136 ? card(html`<p class="muted small">Nobody is working right now.</p>`, {
137 head: nowHead,
138 })
139 : listCard(
140 await Promise.all(
141 openPulls.slice(0, 10).map(async (pull) => {
142 const repo = byId.get(pull.repoId)!;
143 const info = await pullWord(
144 {
145 db,
146 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
147 runtime,
148 record: records.get(repo.id),
149 },
150 pull,
151 );
152 const href = changeHref(repoBase(repo), pull.number);
153 return row({
154 href,
155 nav: true,
156 lead: icon("eye"),
157 title: html`<a href="${href}">${pull.title} · ${repo.name}</a>`,
158 meta: html`${info.note} · ${time(pull.updatedAt)}`,
159 trail: wordBadge(info.word),
160 });
161 }),
162 ),
163 { head: nowHead },
164 );
125165
126166 const body = html`<h1 class="sr-only">Studio</h1>
127167 <div class="dash">
src/web/studio/shelf.ts +45 −21
@@ -9,18 +9,25 @@
99 *
1010 * Every read is made once for the whole shelf and never once per row: the
1111 * app records in one query, the newest run of every app in one, who ran them
12 * in one, the asks in one pass. The words are gathered together because
13 * `runWord` reads git. A caller that has already read what asks the viewer —
14 * the home draws its asks panel from the same list — hands that list in
15 * rather than paying for it twice.
12 * in one, the newest open change of every app in one, the asks in one pass.
13 * The words are gathered together because `runWord` reads git. A caller that
14 * has already read what asks the viewer — the home draws its asks panel from
15 * the same list — hands that list in rather than paying for it twice.
16 *
17 * What a row says it is doing, in order: a run still in flight (that is the
18 * thing moving); else an open change, whoever opened it — the row is about
19 * the app, not about who typed it; else the last run's own word, its
20 * history; else quiet, or told-but-stuck when nobody on the crew can take
21 * it.
1622 */
1723
1824 import { appsRuntime } from "../../apps/runtime.ts";
1925 import { appsForRepos } from "../../core/apps.ts";
2026 import { reposWithBuilder } from "../../core/agents.ts";
2127 import { reposWithUnclaimedTell } from "../../core/issues.ts";
28 import { openPullsForRepos } from "../../core/pulls.ts";
2229 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
23 import { latestAgentRunsForRepos } from "../../core/runs.ts";
30 import { isTerminal, latestAgentRunsForRepos } from "../../core/runs.ts";
2431 import { getUsersByIds } from "../../core/users.ts";
2532 import { repoDir } from "../../git/repos.ts";
2633 import type { Ctx } from "../../http/context.ts";
@@ -29,7 +36,7 @@ import { html, type Html } from "../html.ts";
2936 import { dot, pill, row, time, type Tone } from "../ui.ts";
3037 import { decisionsFor, type Decision } from "./asks.ts";
3138 import { withLikely } from "./leans.ts";
32 import { runWord } from "./words.ts";
39 import { pullWord, runWord, type WordCtx } from "./words.ts";
3340
3441 /** Reads the caller has already made, so the shelf does not make them again. */
3542 export interface ShelfReads {
@@ -70,6 +77,12 @@ export async function shelfRows(
7077 .map((r) => r.actorId)
7178 .filter((id): id is string => id !== null),
7279 );
80 // The newest open change of every app, one read — a person's own change
81 // counts here too, not only one an agent run produced.
82 const openPulls = openPullsForRepos(
83 db,
84 repos.map((r) => r.id),
85 );
7386 const decisions = reads.decisions ?? withLikely(ctx, decisionsFor(ctx, repos));
7487 const asksByRepo = new Map<string, number>();
7588 for (const d of decisions)
@@ -93,20 +106,29 @@ export async function shelfRows(
93106 const production = envs.find((e) => e.env === "production");
94107 const state = production?.state ?? "stopped";
95108 const latest = latestRuns.get(repo.id);
96 const word =
97 latest === undefined
109 // A run still in flight is the thing that is moving, and outranks a
110 // change sitting open; once nothing is moving, an open change — an
111 // agent's or a person's own — outranks the quiet of the last run's
112 // history.
113 const runIsMoving = latest !== undefined && !isTerminal(latest.state);
114 const openPull = openPulls.get(repo.id);
115 const useOpenPull = openPull !== undefined && !runIsMoving;
116 const wordCtx: WordCtx = {
117 db,
118 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
119 runtime,
120 record,
121 };
122 const word = useOpenPull
123 ? await pullWord(wordCtx, openPull)
124 : latest === undefined
98125 ? undefined
99 : await runWord(
100 {
101 db,
102 dir: repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
103 runtime,
104 record,
105 },
106 latest,
107 );
108 const who =
109 latest?.actorId == null || !ownerFacts.has(repo.id)
126 : await runWord(wordCtx, latest);
127 const who = useOpenPull
128 ? ownerFacts.has(repo.id)
129 ? openPull.authorName
130 : undefined
131 : latest?.actorId == null || !ownerFacts.has(repo.id)
110132 ? undefined
111133 : actors.get(latest.actorId)?.username;
112134 const asks = asksByRepo.get(repo.id) ?? 0;
@@ -117,7 +139,7 @@ export async function shelfRows(
117139 ? "danger"
118140 : "default";
119141 const happening =
120 latest === undefined || word === undefined
142 word === undefined
121143 ? stuck.has(repo.id)
122144 ? html`<span class="muted"
123145 >told — nobody on the crew to take it</span
@@ -129,7 +151,9 @@ export async function shelfRows(
129151 ${who ? html`· ${who}` : null}
130152 ${word.word === "working"
131153 ? html`· now`
132 : html`· ${time(latest.finishedAt ?? latest.createdAt)}`}`;
154 : useOpenPull
155 ? html`· ${time(openPull.updatedAt)}`
156 : html`· ${time(latest!.finishedAt ?? latest!.createdAt)}`}`;
133157 return row({
134158 href: repoBase(repo),
135159 nav: true,
test/studio.test.ts +147
@@ -3871,6 +3871,153 @@ describe("a tell nobody on the crew can take is not \"quiet\"", () => {
38713871 });
38723872 });
38733873
3874 /**
3875 * Issue #191: `appShelf`/`shelfRows` used to derive a row from agent runs
3876 * alone, so a change a person opened by hand — no run anywhere near it —
3877 * fell to "quiet — tell the crew something" even while it sat green and
3878 * ready for a look, and "Happening now" said nobody was working while that
3879 * same change waited. Both read from `openPullsForRepos` now, grouped once
3880 * for the whole shelf, so a person's own change counts exactly as an
3881 * agent's does. Failed before this change.
3882 */
3883 describe("an open change is never quiet on the front door", () => {
3884 let dataDir: string;
3885 let app: App;
3886 let servers: Servers;
3887 let base: string;
3888 let repo: Repo;
3889 let alice: User;
3890 let cookie: string;
3891 let pull: Pull;
3892
3893 const get = (path: string) =>
3894 fetch(`${base}${path}`, {
3895 headers: { cookie: `${cookie}; forge_mode=studio` },
3896 redirect: "manual",
3897 });
3898
3899 beforeAll(async () => {
3900 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-open-change-"));
3901 app = createApp({ dataDir, port: 0 });
3902 servers = await serve(app);
3903 base = servers.url;
3904 const a = await createUser(app.db, {
3905 username: "alice",
3906 email: "[email protected]",
3907 password: "a-good-passphrase",
3908 adminIfFirst: true,
3909 });
3910 if (!a.ok) throw new Error(a.error);
3911 alice = a.value;
3912 cookie = (
3913 (
3914 await fetch(`${base}/signin`, {
3915 method: "POST",
3916 body: new URLSearchParams({
3917 username: "alice",
3918 password: "a-good-passphrase",
3919 }),
3920 redirect: "manual",
3921 })
3922 ).headers.get("set-cookie") ?? ""
3923 ).split(";")[0]!;
3924 const made = await createRepo(app, alice, {
3925 name: "pad",
3926 visibility: "private",
3927 description: "a pad for notes",
3928 });
3929 if (!made.ok) throw new Error(String(made.error));
3930 repo = made.value;
3931 const seeded = await commitToBranch(app, alice, repo, {
3932 message: "Start",
3933 files: [{ path: "README.md", content: "pad\n" }],
3934 });
3935 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
3936 const enabled = await enableApp(app, alice, repo, { slug: "pad" });
3937 if (!enabled.ok) throw new Error(String(enabled.error));
3938
3939 // Alice opens the change herself, through the editor — no agent run
3940 // anywhere near it, the exact case `latestAgentRunsForRepos` misses.
3941 const branch = await commitToBranch(app, alice, repo, {
3942 branch: "note",
3943 base: "main",
3944 message: "A note",
3945 files: [{ path: "NOTE.md", content: "note\n" }],
3946 });
3947 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
3948 const opened = await openPull(app, alice, repo, {
3949 title: "A note",
3950 body: "",
3951 baseBranch: "main",
3952 headBranch: "note",
3953 });
3954 if (!opened.ok) throw new Error(String(opened.error));
3955 pull = opened.value;
3956 const check = createRun(app.db, {
3957 repoId: repo.id,
3958 kind: "check",
3959 issueId: pull.id,
3960 env: `pull-${pull.number}`,
3961 commitOid: pull.headOid,
3962 reason: "pull",
3963 });
3964 claimRun(app.db, check.id);
3965 finishRun(app.db, check.id, { state: "ok", summary: "check passed in 1s" });
3966
3967 // A second app, otherwise identical, with nothing open at all — the
3968 // control that must keep reading "quiet".
3969 const madeQuiet = await createRepo(app, alice, {
3970 name: "quiet",
3971 visibility: "private",
3972 description: "says nothing",
3973 });
3974 if (!madeQuiet.ok) throw new Error(String(madeQuiet.error));
3975 const seededQuiet = await commitToBranch(app, alice, madeQuiet.value, {
3976 message: "Start",
3977 files: [{ path: "README.md", content: "quiet\n" }],
3978 });
3979 if (!seededQuiet.ok) throw new Error(JSON.stringify(seededQuiet.error));
3980 const enabledQuiet = await enableApp(app, alice, madeQuiet.value, {
3981 slug: "quiet",
3982 });
3983 if (!enabledQuiet.ok) throw new Error(String(enabledQuiet.error));
3984 });
3985
3986 afterAll(async () => {
3987 servers.stop();
3988 app.close();
3989 await rm(dataDir, { recursive: true, force: true });
3990 });
3991
3992 test("the shelf row carries the change's own word, its badge and dot kept", async () => {
3993 const home = await (await get("/")).text();
3994 const at = home.indexOf('href="/alice/pad"');
3995 expect(at).toBeGreaterThan(-1);
3996 const start = home.lastIndexOf("<li>", at);
3997 const shelfRow = home.slice(start, home.indexOf("</li>", at) + 5);
3998 expect(shelfRow).not.toContain("quiet — tell the crew something");
3999 expect(shelfRow).toContain('data-word="look"');
4000 expect(shelfRow).toContain('class="dot"');
4001 expect(shelfRow).toContain('class="badge"');
4002 });
4003
4004 test('"Happening now" links to the open change instead of reporting silence', async () => {
4005 const home = await (await get("/")).text();
4006 expect(home).not.toContain("Nobody is working right now.");
4007 expect(home).toContain("A note");
4008 expect(home).toContain(`href="/alice/pad/changes/${pull.number}"`);
4009 });
4010
4011 test("an app with nothing open still reads quiet", async () => {
4012 const home = await (await get("/")).text();
4013 const at = home.indexOf('href="/alice/quiet"');
4014 expect(at).toBeGreaterThan(-1);
4015 const start = home.lastIndexOf("<li>", at);
4016 const shelfRow = home.slice(start, home.indexOf("</li>", at) + 5);
4017 expect(shelfRow).toContain("quiet — tell the crew something");
4018 });
4019 });
4020
38744021 /**
38754022 * Issue #65: a public app's studio page is the app, not the owner's books.
38764023 * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs