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

You tell the crew, and the page asks you to tell the crew #194

Merged opened this

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

Merged

scout/forge:scout/183-you-tell-the-crew-and-the-page-asks-you → main 1 commit fork

  • Merged. scout/183-you-tell-the-crew-and-the-page-asks-you was merged into main as 1634821.

Review

Sign in to review this change.
5 files changed +210 −5
src/core/agents.ts +26
@@ -314,3 +314,29 @@ export function listCrewRoles(
314314 role: roles.get(agent.user.id) ?? "builder",
315315 }));
316316 }
317
318 /**
319 * Which of these repositories have a builder on their crew, as a set — a
320 * repo absent from it has none, and nothing can pick up a tell there
321 * (`routeIssue`, crew/route.ts). The shelf's one batched read for that
322 * question, so it never asks it once per row.
323 */
324 export function reposWithBuilder(
325 db: Database,
326 repoIds: readonly string[],
327 ): Set<string> {
328 const out = new Set<string>();
329 const unique = [...new Set(repoIds)];
330 const CHUNK = 400;
331 for (let i = 0; i < unique.length; i += CHUNK) {
332 const batch = unique.slice(i, i + CHUNK);
333 const rows = db
334 .query<{ repo_id: string }, string[]>(
335 `SELECT DISTINCT repo_id FROM repo_crew
336 WHERE role = 'builder' AND repo_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})`,
337 )
338 .all(...batch);
339 for (const row of rows) out.add(row.repo_id);
340 }
341 return out;
342 }
src/core/issues.ts +31
@@ -737,6 +737,37 @@ export function issueCounts(
737737 return { open, closed };
738738 }
739739
740 /**
741 * Which of these repositories have an open tell nobody holds — no assignee,
742 * and not the repository's own standing direction issue, which is never
743 * picked up like an ordinary tell. The shelf's one batched read for that
744 * question, paired with `reposWithBuilder` (core/agents.ts) to say whether
745 * it is stuck for want of a builder, not merely unclaimed for a moment.
746 */
747 export function reposWithUnclaimedTell(
748 db: Database,
749 repoIds: readonly string[],
750 ): Set<string> {
751 const out = new Set<string>();
752 const unique = [...new Set(repoIds)];
753 const CHUNK = 400;
754 for (let i = 0; i < unique.length; i += CHUNK) {
755 const batch = unique.slice(i, i + CHUNK);
756 const rows = db
757 .query<{ repo_id: string }, string[]>(
758 `SELECT DISTINCT issue.repo_id FROM issue
759 JOIN repo ON repo.id = issue.repo_id
760 WHERE issue.kind = 'issue' AND issue.state = 'open'
761 AND issue.assignee_id IS NULL
762 AND (repo.direction_issue_id IS NULL OR repo.direction_issue_id != issue.id)
763 AND issue.repo_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})`,
764 )
765 .all(...batch);
766 for (const row of rows) out.add(row.repo_id);
767 }
768 return out;
769 }
770
740771 /** Issues addressed the way an activity feed has them: by repo and number. */
741772 export function getIssuesByRef(
742773 db: Database,
src/web/studio/app.ts +36 −4
@@ -231,18 +231,40 @@ export async function studioApp(
231231 // ---- now: one row per thing, in the five words ----
232232 const proposed = new Set(openPulls.map((p) => p.id));
233233 const openByIssue = openPullsClosing(db, repo.id);
234 const told = listIssues(db, repo.id, {
234 const openTells = listIssues(db, repo.id, {
235235 kind: "issue",
236236 state: "open",
237237 limit: 30,
238 }).filter((i) => i.assigneeIsAgent && !openByIssue.has(i.number));
238 });
239 const told = openTells.filter(
240 (i) => i.assigneeIsAgent && !openByIssue.has(i.number),
241 );
242 // With nobody on the crew to build, `tellCrew` cannot route a tell to
243 // anyone — it stays unassigned forever, not merely unpicked-up like
244 // `waiting` below. Scoped to that one honest cause (never to a tell a
245 // person simply hasn't assigned while builders exist), so widening this
246 // never turns "Now" into a plain issue list.
247 const untaken =
248 builders.length === 0
249 ? openTells
250 .filter(
251 (i) =>
252 i.assigneeId === null &&
253 i.id !== repo.directionIssueId &&
254 !openByIssue.has(i.number),
255 )
256 .slice(0, 5)
257 : [];
239258 const everRan = latestRunsForIssues(
240259 db,
241 told.map((i) => i.id),
260 [...told, ...untaken].map((i) => i.id),
242261 ["agent"],
243262 );
244263 const waiting = told.filter((i) => !everRan.has(i.id)).slice(0, 5);
245 const issueNumberById = new Map(told.map((i) => [i.id, i.number]));
264 const stuck = untaken.filter((i) => !everRan.has(i.id));
265 const issueNumberById = new Map(
266 [...told, ...untaken].map((i) => [i.id, i.number]),
267 );
246268 const rowsNow = runs
247269 .filter((r) => {
248270 if (r.issueId === null) return true;
@@ -339,6 +361,16 @@ export async function studioApp(
339361 trail: wordBadge("waiting"),
340362 });
341363 }),
364 ...stuck.map((i) => {
365 return row({
366 lead: avatar("crew"),
367 title: html`<a href="${tellHref(base, i.number)}">${i.title}</a>`,
368 meta: html`told ${time(i.createdAt)} ·
369 <span class="muted">nobody on the crew to take it —</span>
370 <a href="${base}/settings">add someone</a>`,
371 trail: wordBadge("told"),
372 });
373 }),
342374 ];
343375 const now =
344376 nowRows.length === 0
src/web/studio/shelf.ts +16 −1
@@ -17,6 +17,8 @@
1717
1818 import { appsRuntime } from "../../apps/runtime.ts";
1919 import { appsForRepos } from "../../core/apps.ts";
20 import { reposWithBuilder } from "../../core/agents.ts";
21 import { reposWithUnclaimedTell } from "../../core/issues.ts";
2022 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
2123 import { latestAgentRunsForRepos } from "../../core/runs.ts";
2224 import { getUsersByIds } from "../../core/users.ts";
@@ -72,6 +74,15 @@ export async function shelfRows(
7274 const asksByRepo = new Map<string, number>();
7375 for (const d of decisions)
7476 asksByRepo.set(d.repo.id, (asksByRepo.get(d.repo.id) ?? 0) + 1);
77 // A tell nobody can pick up (no builder on the crew) is stuck, not quiet —
78 // both reads batched across the whole shelf, never once per row.
79 const repoIds = repos.map((r) => r.id);
80 const withBuilder = reposWithBuilder(db, repoIds);
81 const stuck = new Set(
82 [...reposWithUnclaimedTell(db, repoIds)].filter(
83 (id) => !withBuilder.has(id),
84 ),
85 );
7586
7687 return await Promise.all(repos.map(async (repo) => {
7788 const record = records.get(repo.id);
@@ -107,7 +118,11 @@ export async function shelfRows(
107118 : "default";
108119 const happening =
109120 latest === undefined || word === undefined
110 ? html`<span class="muted">quiet — tell the crew something</span>`
121 ? stuck.has(repo.id)
122 ? html`<span class="muted"
123 >told — nobody on the crew to take it</span
124 >`
125 : html`<span class="muted">quiet — tell the crew something</span>`
111126 : html`<span class="studio-word" data-word="${word.word}"
112127 >${word.word}</span
113128 >
test/studio.test.ts +101
@@ -3770,6 +3770,107 @@ describe(`"Now" while production is coming up and nothing else is`, () => {
37703770 });
37713771 });
37723772
3773 /**
3774 * Issue #183: `tellCrew` cannot route a tell to anyone with nobody on the
3775 * crew — the issue it opens stays unassigned forever, not merely
3776 * unpicked-up like an agent-held tell. Both surfaces that used to call this
3777 * "quiet" or "nothing yet" must say the honest reason instead, and both
3778 * failed before this change.
3779 */
3780 describe("a tell nobody on the crew can take is not \"quiet\"", () => {
3781 let dataDir: string;
3782 let app: App;
3783 let servers: Servers;
3784 let base: string;
3785 let repo: Repo;
3786 let alice: User;
3787 let cookie: string;
3788
3789 const get = (path: string) =>
3790 fetch(`${base}${path}`, {
3791 headers: { cookie: `${cookie}; forge_mode=studio` },
3792 redirect: "manual",
3793 });
3794 const post = (path: string, body: Record<string, string>) =>
3795 fetch(`${base}${path}`, {
3796 method: "POST",
3797 headers: { cookie, origin: base },
3798 body: new URLSearchParams(body),
3799 redirect: "manual",
3800 });
3801
3802 beforeAll(async () => {
3803 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-untaken-"));
3804 app = createApp({ dataDir, port: 0 });
3805 servers = await serve(app);
3806 base = servers.url;
3807 const a = await createUser(app.db, {
3808 username: "alice",
3809 email: "[email protected]",
3810 password: "a-good-passphrase",
3811 adminIfFirst: true,
3812 });
3813 if (!a.ok) throw new Error(a.error);
3814 alice = a.value;
3815 cookie = (
3816 (
3817 await fetch(`${base}/signin`, {
3818 method: "POST",
3819 body: new URLSearchParams({
3820 username: "alice",
3821 password: "a-good-passphrase",
3822 }),
3823 redirect: "manual",
3824 })
3825 ).headers.get("set-cookie") ?? ""
3826 ).split(";")[0]!;
3827 const made = await createRepo(app, alice, {
3828 name: "pad",
3829 visibility: "private",
3830 description: "a pad for notes",
3831 });
3832 if (!made.ok) throw new Error(String(made.error));
3833 repo = made.value;
3834 const seeded = await commitToBranch(app, alice, repo, {
3835 message: "Start",
3836 files: [{ path: "README.md", content: "pad\n" }],
3837 });
3838 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
3839 const enabled = await enableApp(app, alice, repo, { slug: "pad" });
3840 if (!enabled.ok) throw new Error(String(enabled.error));
3841 // No crew at all — the exact state `tellCrew` cannot route out of.
3842 });
3843
3844 afterAll(async () => {
3845 servers.stop();
3846 app.close();
3847 await rm(dataDir, { recursive: true, force: true });
3848 });
3849
3850 test('a crewless tell renders a "Now" row, not the empty state, and links to where a crew member is added', async () => {
3851 const told = await post("/alice/pad/tell", {
3852 text: "Add a dark mode toggle.",
3853 });
3854 expect(told.status).toBe(303);
3855
3856 const page = await (await get("/alice/pad")).text();
3857 expect(page).not.toContain("Nothing yet. Tell the crew something.");
3858 expect(page).toContain("Add a dark mode toggle.");
3859 expect(page).toContain("nobody on the crew to take it");
3860 expect(page).toContain(`href="/alice/pad/settings"`);
3861 });
3862
3863 test("the shelf row for that app is not \"quiet\"", async () => {
3864 const home = await (await get("/")).text();
3865 const at = home.indexOf('href="/alice/pad"');
3866 expect(at).toBeGreaterThan(-1);
3867 const start = home.lastIndexOf("<li>", at);
3868 const shelfRow = home.slice(start, home.indexOf("</li>", at) + 5);
3869 expect(shelfRow).not.toContain("quiet — tell the crew something");
3870 expect(shelfRow).toContain("nobody on the crew to take it");
3871 });
3872 });
3873
37733874 /**
37743875 * Issue #65: a public app's studio page is the app, not the owner's books.
37753876 * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs