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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/191-the-front-door-says-an-app-is-quiet-whil → main 1 commit fork
4 files
changed +270
−28
src/core/pulls.ts +31
| @@ -219,6 +219,37 @@ export function listOpenPullsFromHead( | ||
| 219 | 219 | return joinMany(db, [...getIssuesByIds(db, ids).values()]); |
| 220 | 220 | } |
| 221 | 221 | |
| 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 | ||
| 222 | 253 | export type ListPullsOptions = Omit<ListIssuesOptions, "kind">; |
| 223 | 254 | |
| 224 | 255 | export function listPulls( |
src/web/studio/home.ts +47 −7
| @@ -7,9 +7,12 @@ | ||
| 7 | 7 | * apps grow. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 11 | import { openPullsForRepos, type Pull } from "../../core/pulls.ts"; | |
| 10 | 12 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 11 | 13 | import { listActiveRuns } from "../../core/runs.ts"; |
| 12 | 14 | import { getUsersByIds, type User } from "../../core/users.ts"; |
| 15 | import { repoDir } from "../../git/repos.ts"; | |
| 13 | 16 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 17 | import { repoBase } from "../chrome.ts"; |
| 15 | 18 | import { asksJs, liveRegionsJs } from "../client.ts"; |
| @@ -20,8 +23,10 @@ import { btn, card, empty, icon, listCard, row, time } from "../ui.ts"; | ||
| 20 | 23 | import { onYouCard } from "../pages/site.ts"; |
| 21 | 24 | import { appsOf, asksPanel, decisionsFor } from "./asks.ts"; |
| 22 | 25 | import { withLikely } from "./leans.ts"; |
| 26 | import { changeHref } from "./nouns.ts"; | |
| 23 | 27 | import { stopForm } from "./run.ts"; |
| 24 | 28 | import { shelfRows } from "./shelf.ts"; |
| 29 | import { pullWord, wordBadge, type WordCtx } from "./words.ts"; | |
| 25 | 30 | |
| 26 | 31 | /** |
| 27 | 32 | * 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"; | ||
| 31 | 36 | */ |
| 32 | 37 | export async function studioHome(ctx: Ctx, user: User): Promise<Response> { |
| 33 | 38 | 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); | |
| 35 | 41 | // Read once, shown twice: the panel below is these same asks, and the |
| 36 | 42 | // shelf counts them into each row's pill. |
| 37 | 43 | const decisions = withLikely(ctx, decisionsFor(ctx, appRepos)); |
| @@ -84,12 +90,16 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 84 | 90 | size: "sm", |
| 85 | 91 | title: "The board: a live wall of the crews at work", |
| 86 | 92 | })}`; |
| 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[] = | |
| 88 | 97 | 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( | |
| 93 | 103 | running.map((r) => { |
| 94 | 104 | const repo = byId.get(r.repoId)!; |
| 95 | 105 | const actorName = |
| @@ -121,7 +131,37 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 121 | 131 | }); |
| 122 | 132 | }), |
| 123 | 133 | { 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 | ); | |
| 125 | 165 | |
| 126 | 166 | const body = html`<h1 class="sr-only">Studio</h1> |
| 127 | 167 | <div class="dash"> |
src/web/studio/shelf.ts +45 −21
| @@ -9,18 +9,25 @@ | ||
| 9 | 9 | * |
| 10 | 10 | * Every read is made once for the whole shelf and never once per row: the |
| 11 | 11 | * 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. | |
| 16 | 22 | */ |
| 17 | 23 | |
| 18 | 24 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 19 | 25 | import { appsForRepos } from "../../core/apps.ts"; |
| 20 | 26 | import { reposWithBuilder } from "../../core/agents.ts"; |
| 21 | 27 | import { reposWithUnclaimedTell } from "../../core/issues.ts"; |
| 28 | import { openPullsForRepos } from "../../core/pulls.ts"; | |
| 22 | 29 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 23 | import { latestAgentRunsForRepos } from "../../core/runs.ts"; | |
| 30 | import { isTerminal, latestAgentRunsForRepos } from "../../core/runs.ts"; | |
| 24 | 31 | import { getUsersByIds } from "../../core/users.ts"; |
| 25 | 32 | import { repoDir } from "../../git/repos.ts"; |
| 26 | 33 | import type { Ctx } from "../../http/context.ts"; |
| @@ -29,7 +36,7 @@ import { html, type Html } from "../html.ts"; | ||
| 29 | 36 | import { dot, pill, row, time, type Tone } from "../ui.ts"; |
| 30 | 37 | import { decisionsFor, type Decision } from "./asks.ts"; |
| 31 | 38 | import { withLikely } from "./leans.ts"; |
| 32 | import { runWord } from "./words.ts"; | |
| 39 | import { pullWord, runWord, type WordCtx } from "./words.ts"; | |
| 33 | 40 | |
| 34 | 41 | /** Reads the caller has already made, so the shelf does not make them again. */ |
| 35 | 42 | export interface ShelfReads { |
| @@ -70,6 +77,12 @@ export async function shelfRows( | ||
| 70 | 77 | .map((r) => r.actorId) |
| 71 | 78 | .filter((id): id is string => id !== null), |
| 72 | 79 | ); |
| 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 | ); | |
| 73 | 86 | const decisions = reads.decisions ?? withLikely(ctx, decisionsFor(ctx, repos)); |
| 74 | 87 | const asksByRepo = new Map<string, number>(); |
| 75 | 88 | for (const d of decisions) |
| @@ -93,20 +106,29 @@ export async function shelfRows( | ||
| 93 | 106 | const production = envs.find((e) => e.env === "production"); |
| 94 | 107 | const state = production?.state ?? "stopped"; |
| 95 | 108 | 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 | |
| 98 | 125 | ? 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) | |
| 110 | 132 | ? undefined |
| 111 | 133 | : actors.get(latest.actorId)?.username; |
| 112 | 134 | const asks = asksByRepo.get(repo.id) ?? 0; |
| @@ -117,7 +139,7 @@ export async function shelfRows( | ||
| 117 | 139 | ? "danger" |
| 118 | 140 | : "default"; |
| 119 | 141 | const happening = |
| 120 | latest === undefined || word === undefined | |
| 142 | word === undefined | |
| 121 | 143 | ? stuck.has(repo.id) |
| 122 | 144 | ? html`<span class="muted" |
| 123 | 145 | >told — nobody on the crew to take it</span |
| @@ -129,7 +151,9 @@ export async function shelfRows( | ||
| 129 | 151 | ${who ? html`· ${who}` : null} |
| 130 | 152 | ${word.word === "working" |
| 131 | 153 | ? html`· now` |
| 132 | : html`· ${time(latest.finishedAt ?? latest.createdAt)}`}`; | |
| 154 | : useOpenPull | |
| 155 | ? html`· ${time(openPull.updatedAt)}` | |
| 156 | : html`· ${time(latest!.finishedAt ?? latest!.createdAt)}`}`; | |
| 133 | 157 | return row({ |
| 134 | 158 | href: repoBase(repo), |
| 135 | 159 | nav: true, |
test/studio.test.ts +147
| @@ -3871,6 +3871,153 @@ describe("a tell nobody on the crew can take is not \"quiet\"", () => { | ||
| 3871 | 3871 | }); |
| 3872 | 3872 | }); |
| 3873 | 3873 | |
| 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 | ||
| 3874 | 4021 | /** |
| 3875 | 4022 | * Issue #65: a public app's studio page is the app, not the owner's books. |
| 3876 | 4023 | * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same |
new above
offline