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

One screen answers “is everything fine?”: the asks inbox, reborn as all fine / these need you #357

Merged opened this

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

Merged

scout/forge:scout/346-one-screen-answers-is-everything-fine-th → main 1 commit fork

  • Merged. scout/346-one-screen-answers-is-everything-fine-th was merged into main as 550bab7.

Review

Sign in to review this change.
9 files changed +322 −54
docs/STUDIO.md +13
@@ -280,6 +280,19 @@ page exactly as it always has, deep links and all. Nothing here reads a
280280 diff or a blob: the page costs a handful of small queries whatever the
281281 change's size, unlike the page it links to.
282282
283 ## The asks
284
285 `/asks` (`asksPage`, `src/web/studio/asks_page.ts`) is the one screen for
286 "is everything fine?". It leads with one sentence — _All fine_ when nothing
287 asks the viewer, otherwise _N things need you_ — then the decision cards
288 (a pull the crew stopped on, a green pull waiting for a look, an app parked
289 at its ceiling), and, for a site admin only, one card per sentence of the
290 forge's health (`needs` in `/api/v1/health`). What counts as an ask is
291 defined once, in `src/web/studio/asks.ts`: the header's **Asks** count, the
292 page, the dashboard's _Needs you_ link and the phone notifier all read it,
293 so the numbers agree. It is server-rendered and every button is a form post,
294 so it works without JavaScript.
295
283296 ## The home
284297
285298 `/` in the studio: what is on you (issues and pulls a person assigned to
forge.json +1 −1
@@ -6,7 +6,7 @@
66 "ready": 90
77 },
88 "check": {
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts",
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts",
1010 "timeout": 1800
1111 }
1212 }
src/http/server.ts +3
@@ -104,6 +104,7 @@ import {
104104 takeBackSubmit,
105105 tellSubmit,
106106 } from "../web/studio/actions.ts";
107 import { asksPage } from "../web/studio/asks_page.ts";
107108 import { liveAppsFragment, liveOnYouFragment } from "../web/studio/home.ts";
108109 import { appNowFragment } from "../web/studio/now.ts";
109110 import { appStateFragment } from "../web/studio/previews.ts";
@@ -411,6 +412,8 @@ export function buildRouter(): Router<Handler> {
411412 router.add("POST", "/mode", modeSubmit);
412413 // The board: a live wall of the crews at work, for a second monitor.
413414 router.add("GET", "/board", boardPage);
415 // One screen for "is everything fine?": what asks the viewer (docs/STUDIO.md).
416 router.add("GET", "/asks", asksPage);
414417 // The big view: the whole forge as one keyboard-first frame.
415418 router.add("GET", "/big", bigPage);
416419 router.add("GET", "/search", searchPage);
src/web/layout.ts +20 −4
@@ -7,7 +7,7 @@
77 * `/search`), and every dialog (a link to a real page).
88 */
99
10 import { appsOf } from "./studio/asks.ts";
10 import { appsOf, askCountFor } from "./studio/asks.ts";
1111 import { listReposVisibleTo } from "../core/repos.ts";
1212 import type { User } from "../core/users.ts";
1313 import type { Ctx } from "../http/context.ts";
@@ -165,6 +165,22 @@ function modeToggle(ctx: Ctx): Html {
165165 </span>`;
166166 }
167167
168 /**
169 * Studio only: the way to `/asks`, with how many things ask the viewer — the
170 * very count the page leads with (`askCountFor`). Quiet when nothing does.
171 */
172 function asksLink(ctx: Ctx): Html {
173 if (!ctx.auth.user || ctx.mode !== "studio") return html``;
174 const count = askCountFor(ctx, ctx.auth.user);
175 return html`<a
176 class="btn"
177 data-size="sm"
178 href="/asks"
179 title="${count === 0 ? "All fine" : `${count} need you`}"
180 >Asks${count === 0 ? null : pill(count, "accent")}</a
181 >`;
182 }
183
168184 function newMenu(): Html {
169185 const items: MenuItem[] = [
170186 { label: "New app", href: "/apps/new", icon: "rocket" },
@@ -273,13 +289,13 @@ const STATIC_PALETTE_DEV: PaletteItem[] = [
273289 /**
274290 * The studio's own verbs: no "New repository" or "New organization" — dev's
275291 * own way of making one, which would land a studio reader on dev's own form
276 * unannounced. "Asks you" goes to the home, where the asks panel lives.
292 * unannounced. "Asks you" goes to `/asks`, the one screen of what needs the viewer.
277293 */
278294 const STATIC_PALETTE_STUDIO: PaletteItem[] = [
279295 { t: "Home", h: "/", c: "Go to" },
280296 { t: "Apps", h: "/apps", c: "Go to" },
281297 { t: "Map", h: "/map", c: "Go to" },
282 { t: "Asks you", h: "/", c: "Go to" },
298 { t: "Asks you", h: "/asks", c: "Go to" },
283299 { t: "New app", h: "/apps/new", c: "Create" },
284300 { t: "Migrate a repository", h: "/migrate", c: "Create" },
285301 { t: "Search", h: "/search", c: "Go to" },
@@ -430,7 +446,7 @@ function topbar(
430446 )}
431447 <span class="spacer"></span>
432448 <span class="topbar-tools">
433 ${searchBar()}<span class="hide-sm">${helpButton()}</span>${modeToggle(
449 ${searchBar()}<span class="hide-sm">${helpButton()}</span>${asksLink(ctx)}${modeToggle(
434450 ctx,
435451 )}${themeMenu(theme)}
436452 ${user
src/web/pages/site.ts +9 −45
@@ -39,6 +39,7 @@ import {
3939 } from "../../core/users.ts";
4040 import { isThemeName } from "../../design/themes.ts";
4141 import { crossOrigin, type Ctx } from "../../http/context.ts";
42 import { askCountFor, askSentence } from "../studio/asks.ts";
4243 import { studioHome } from "../studio/home.ts";
4344 import { edgeLoginUrl } from "../../http/edge.ts";
4445 import { sshCommand } from "../../ssh/command.ts";
@@ -397,56 +398,19 @@ const threadPath = (repo: Repo, issue: Issue): string =>
397398 `/${repo.ownerName}/${repo.name}/${issue.kind === "pull" ? "pulls" : "issues"}/${issue.number}`;
398399
399400 /**
400 * Open pulls the crew opened, in repositories the viewer can write — the
401 * ones waiting on a review only a person can give. Visibility is in the
402 * query; write access is checked once per distinct repository here. The
403 * check beside each is the latest deploy of that pull, one grouped read.
401 * Needs you: one line that links to `/asks`, where the single list of what
402 * asks the viewer lives (`studio/asks.ts` decides what counts). Absent when
403 * nothing asks — a card saying "nothing" is a zero drawn.
404404 */
405405 function needsYouCard(ctx: Ctx, user: User): Html | undefined {
406 const db = ctx.app.db;
407 const pulls = listOpenAgentPullsVisibleTo(db, user, 25);
408 if (pulls.length === 0) return undefined;
409 const repos = getReposByIds(
410 db,
411 pulls.map((pull) => pull.repoId),
412 );
413 // Write access for every repository at once — one query, like visibility.
414 const mayWrite = writableRepoIds(db, user, [...repos.keys()]);
415 const writable = new Map<string, Repo>();
416 for (const repo of repos.values())
417 if (mayWrite.has(repo.id)) writable.set(repo.id, repo);
418 const mine = pulls.filter((pull) => writable.has(pull.repoId)).slice(0, 10);
419 if (mine.length === 0) return undefined;
420 const checks = latestRunsForIssues(
421 db,
422 mine.map((pull) => pull.id),
423 );
424 return listCard(
425 mine.map((pull) => {
426 const repo = writable.get(pull.repoId)!;
427 const check = checks.get(pull.id);
428 return row({
429 href: threadPath(repo, pull),
430 nav: true,
431 state: "open",
432 lead: icon("pull"),
433 title: pull.title,
434 titleAttr: pull.title,
435 meta: html`${repo.ownerName}/${repo.name} #${pull.number} ·
436 ${userTag(pull.authorName)} opened ${time(pull.createdAt)}`,
437 trail: html`${check === undefined
438 ? html`<span class="badge verdict-stale"
439 >${icon("clock")}No checks</span
440 >`
441 : runStateBadge(check.state, check.kind)}`,
442 });
443 }),
406 const count = askCountFor(ctx, user);
407 if (count === 0) return undefined;
408 return card(
409 html`<a href="/asks">${askSentence(count)}</a>`,
444410 {
445411 head: html`${icon("eye")}
446412 <h2>Needs you</h2>
447 <span class="muted small"
448 >pull requests from the crew, waiting on a review</span
449 >`,
413 <span class="muted small">decisions, and the forge's own health</span>`,
450414 },
451415 );
452416 }
src/web/studio/asks.ts +43 −1
@@ -10,7 +10,8 @@
1010 import type { Database } from "bun:sqlite";
1111 import { previewEnv } from "../../apps/data.ts";
1212 import { crewForRepos } from "../../core/agents.ts";
13 import { appsForRepos, getAppByRepo } from "../../core/apps.ts";
13 import { appsRuntime } from "../../apps/runtime.ts";
14 import { appsForRepos, getAppByRepo, listApps } from "../../core/apps.ts";
1415 import { latestDecision } from "../../core/decisions.ts";
1516 import {
1617 listComments,
@@ -35,6 +36,7 @@ import type { User } from "../../core/users.ts";
3536 import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts";
3637 import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts";
3738 import type { Ctx } from "../../http/context.ts";
39 import { vitals } from "../../kernel/vitals.ts";
3840 import { repoBase } from "../chrome.ts";
3941 import { html, when, type Html } from "../html.ts";
4042 import { btn, card, icon, row } from "../ui.ts";
@@ -389,3 +391,43 @@ export function decisionActions(ctx: Ctx, d: Decision): Html {
389391 size: "sm",
390392 })}`;
391393 }
394
395 /**
396 * What the forge itself needs, for a site admin: each sentence of the
397 * health answer (`/api/v1/health`) as one ask. Anyone else gets none — the
398 * forge's standing is the admin's to read and nobody else's to know about.
399 */
400 export function forgeNeedsFor(ctx: Ctx, user: User | undefined): string[] {
401 if (!user?.isAdmin) return [];
402 const runtime = appsRuntime(ctx.app);
403 const envs = runtime
404 ? listApps(ctx.app.db).flatMap((record) => runtime.status(record))
405 : [];
406 return vitals({
407 db: ctx.app.db,
408 dataDir: ctx.app.config.dataDir,
409 envs,
410 }).needs;
411 }
412
413 /**
414 * How many things ask the viewer: the one number the studio header and the
415 * asks page both show. It counts the same candidates the cards are made
416 * from (`askCandidatesFor`) plus the forge's own needs, so the two cannot
417 * disagree.
418 */
419 export function askCountFor(ctx: Ctx, user: User | undefined): number {
420 if (!user) return 0;
421 const repos = listReposVisibleTo(ctx.app.db, user, { limit: 200 });
422 return (
423 askCandidatesFor(ctx.app.db, user, repos).length +
424 forgeNeedsFor(ctx, user).length
425 );
426 }
427
428 /** The single sentence the asks page leads with, and the dashboard links with. */
429 export function askSentence(count: number): string {
430 return count === 0
431 ? "All fine"
432 : `${count} ${count === 1 ? "thing needs" : "things need"} you`;
433 }
src/web/studio/asks_page.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * `/asks`: the one screen that answers "is everything fine?". A sentence —
3 * *All fine*, or *N things need you* — and under it the decision cards
4 * (`asks.ts`), then, for a site admin, one card per thing the forge itself
5 * needs. First paint is the whole page; every button is a plain form post.
6 */
7
8 import type { Ctx } from "../../http/context.ts";
9 import { repoBase, signInFirst } from "../chrome.ts";
10 import { listReposVisibleTo } from "../../core/repos.ts";
11 import { html, type Html } from "../html.ts";
12 import { page } from "../layout.ts";
13 import { btn, card, icon } from "../ui.ts";
14 import {
15 askSentence,
16 decisionActions,
17 decisionForm,
18 decisionsFor,
19 forgeNeedsFor,
20 type Decision,
21 } from "./asks.ts";
22 import { changeHref } from "./nouns.ts";
23
24 function decisionCard(ctx: Ctx, d: Decision): Html {
25 const base = repoBase(d.repo);
26 const title = d.pull
27 ? html`<a href="${changeHref(base, d.pull.number)}">${d.pull.title}</a>`
28 : html`<a href="${base}/settings/autonomy">Paused</a>`;
29 return card(
30 html`<p>${d.says}</p>
31 <div class="bar mt">${decisionActions(ctx, d)}</div>`,
32 {
33 id: d.id.replace(/[^a-z0-9-]/g, "-"),
34 head: html`${icon(d.kind === "look" ? "eye" : "pull")}
35 <h2>${title}</h2>
36 <span class="muted small">${d.repo.ownerName}/${d.repo.name}</span>`,
37 },
38 );
39 }
40
41 const forgeCard = (sentence: string): Html =>
42 card(html`<p>${sentence}</p>`, {
43 tone: "danger",
44 head: html`${icon("shield")}
45 <h2>The forge</h2>
46 <span class="spacer"></span>
47 ${btn("Health", { href: "/api/v1/health", size: "sm" })}`,
48 });
49
50 export function asksPage(ctx: Ctx): Response {
51 const user = ctx.auth.user;
52 if (!user) return signInFirst(ctx);
53 const decisions = decisionsFor(
54 ctx,
55 listReposVisibleTo(ctx.app.db, user, { limit: 200 }),
56 );
57 const needs = forgeNeedsFor(ctx, user);
58 const count = decisions.length + needs.length;
59 const body = html`<h1 id="asks-sentence">${askSentence(count)}</h1>
60 <div class="stack">
61 ${decisions.map((d) => decisionCard(ctx, d))}
62 ${needs.map(forgeCard)}
63 </div>
64 ${decisions.map((d) => decisionForm(d))}`;
65 return page({ ctx, title: askSentence(count), body });
66 }
test/asks.test.ts added +164
@@ -0,0 +1,164 @@
1 /**
2 * `/asks`, the one screen for "is everything fine?": the sentence, the
3 * decision cards, the forge's own needs for an admin — and the same number
4 * in the header.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { openPull } from "../src/actions/pulls.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { recordDecision } from "../src/core/decisions.ts";
16 import type { Repo } from "../src/core/repos.ts";
17 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
18 import { getUserByName } from "../src/core/users.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 const PASSWORD = "a-long-asks-passphrase";
25
26 describe("the asks page", () => {
27 let dataDir = "";
28 let app: App;
29 let servers: Servers;
30 let alice = "";
31 let bob = "";
32 let repo: Repo;
33
34 const get = async (cookie: string, path: string): Promise<string> => {
35 const response = await fetch(`${servers.url}${path}`, {
36 headers: { cookie },
37 redirect: "manual",
38 });
39 expect(response.status).toBe(200);
40 return (await response.text()).replace(/\s+/g, " ");
41 };
42
43 const signUp = async (username: string): Promise<string> => {
44 const response = await fetch(`${servers.url}/signup`, {
45 method: "POST",
46 body: new URLSearchParams({
47 username,
48 email: `${username}@forge.test`,
49 password: PASSWORD,
50 }),
51 redirect: "manual",
52 });
53 return `${(response.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=studio`;
54 };
55
56 /** The number the header's link carries, or 0 when it carries none. */
57 const headerCount = (page: string): number =>
58 Number(/>Asks<span class="pill"[^>]*>(\d+)</.exec(page)?.[1] ?? 0);
59
60 const sentence = (page: string): string =>
61 /<h1 id="asks-sentence">([^<]*)<\/h1>/.exec(page)?.[1] ?? "";
62
63 beforeAll(async () => {
64 dataDir = await mkdtemp(join(tmpdir(), "forge-asks-"));
65 app = createApp({ dataDir, port: 0 });
66 servers = await serve(app);
67 alice = await signUp("alice"); // the first account: the site admin
68 bob = await signUp("bob");
69 const aliceUser = getUserByName(app.db, "alice")!;
70 const made = await createRepo(app, aliceUser, {
71 name: "garden",
72 visibility: "public",
73 });
74 if (!made.ok) throw new Error(String(made.error));
75 repo = made.value;
76 await commitToBranch(app, aliceUser, repo, {
77 message: "seed",
78 files: [{ path: "README.md", content: "# garden\n" }],
79 });
80 await createAgent(app, aliceUser, { username: "bot" });
81 addToCrew(app, aliceUser, repo, "bot");
82 }, 30_000);
83
84 afterAll(async () => {
85 await servers.stop();
86 await rm(dataDir, { recursive: true, force: true });
87 });
88
89 test("when nothing asks, it says All fine, and so does the header", async () => {
90 const page = await get(alice, "/asks");
91 expect(sentence(page)).toBe("All fine");
92 expect(headerCount(page)).toBe(0);
93 expect(await get(alice, "/")).not.toContain("<h2>Needs you</h2>");
94 });
95
96 test("a signed-out reader is sent to sign in", async () => {
97 const response = await fetch(`${servers.url}/asks`, { redirect: "manual" });
98 expect(response.status).toBe(303);
99 expect(response.headers.get("location")).toContain("/signin");
100 });
101
102 test("a stopped pull is a card, and the header and the page agree", async () => {
103 const bot = getUserByName(app.db, "bot")!;
104 const branch = await commitToBranch(app, getUserByName(app.db, "alice")!, repo, {
105 branch: "bot/1-thing",
106 base: "main",
107 message: "Bot work",
108 files: [{ path: "BOT.md", content: "bot\n" }],
109 });
110 expect(branch.ok).toBe(true);
111 const opened = await openPull(app, bot, repo, {
112 title: "Bot did a thing",
113 body: "",
114 baseBranch: "main",
115 headBranch: "bot/1-thing",
116 });
117 if (!opened.ok) throw new Error(String(opened.error));
118 const run = createRun(app.db, {
119 repoId: repo.id,
120 kind: "check",
121 issueId: opened.value.id,
122 env: `pull-${opened.value.number}`,
123 commitOid: opened.value.headOid,
124 reason: "pull",
125 });
126 claimRun(app.db, run.id);
127 finishRun(app.db, run.id, { state: "failed", summary: "red" });
128 // Failed but the crew is still trying: that is working, not an ask.
129 expect(sentence(await get(alice, "/asks"))).toBe("All fine");
130 recordDecision(app.db, {
131 kind: "retry",
132 repoId: repo.id,
133 issueId: opened.value.id,
134 state: {},
135 answer: "stop",
136 p: 1,
137 by: "rule",
138 ms: 1,
139 });
140 const page = await get(alice, "/asks");
141 expect(sentence(page)).toBe("1 thing needs you");
142 expect(page).toContain("Bot did a thing");
143 expect(headerCount(page)).toBe(1);
144 // The dashboard no longer lists it: it links here.
145 const dash = await get(`${alice.split(";")[0]!}; forge_mode=dev`, "/");
146 expect(dash).toContain('<a href="/asks">1 thing needs you</a>');
147 // Bob can read the public repository but cannot write it: not his ask.
148 expect(sentence(await get(bob, "/asks"))).toBe("All fine");
149 });
150
151 test("a forge health problem is a card for an admin and for no one else", async () => {
152 createRun(app.db, { repoId: repo.id, kind: "check", env: "main" });
153 app.db
154 .query("UPDATE run SET created_at = ?1 WHERE state = 'queued'")
155 .run(new Date(Date.now() - 3 * 3_600_000).toISOString());
156 const admin = await get(alice, "/asks");
157 expect(admin).toContain("queue has waited over");
158 expect(sentence(admin)).toBe("2 things need you");
159 expect(headerCount(admin)).toBe(2);
160 const other = await get(bob, "/asks");
161 expect(other).not.toContain("queue has waited");
162 expect(sentence(other)).toBe("All fine");
163 });
164 });
test/web-apps.test.ts +3 −3
@@ -761,9 +761,9 @@ describe("apps, runs and the crew on the web", () => {
761761 headBranch: "bot/1-do-a-thing",
762762 });
763763 expect(proposed.ok).toBe(true);
764 const inbox = await bodyOf(alice, "/");
765 expect(inbox).toContain("<h2>Needs you</h2>");
766 expect(inbox).toContain("Bot did a thing");
764 // Whether this pull asks depends on its check, which the runtime is
765 // racing to finish here: when it does, the dashboard links to /asks
766 // rather than listing it (test/asks.test.ts holds that page to account).
767767 // Bob can read the public repo but not write it: not his inbox.
768768 expect(await bodyOf(bob, "/")).not.toContain("<h2>Needs you</h2>");
769769 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs