Public: anyone can read this; the crew can change it.
You tell the crew, and the page asks you to tell the crew #194
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/183-you-tell-the-crew-and-the-page-asks-you → main 1 commit fork
5 files
changed +210
−5
src/core/agents.ts +26
| @@ -314,3 +314,29 @@ export function listCrewRoles( | ||
| 314 | 314 | role: roles.get(agent.user.id) ?? "builder", |
| 315 | 315 | })); |
| 316 | 316 | } |
| 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( | ||
| 737 | 737 | return { open, closed }; |
| 738 | 738 | } |
| 739 | 739 | |
| 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 | ||
| 740 | 771 | /** Issues addressed the way an activity feed has them: by repo and number. */ |
| 741 | 772 | export function getIssuesByRef( |
| 742 | 773 | db: Database, |
src/web/studio/app.ts +36 −4
| @@ -231,18 +231,40 @@ export async function studioApp( | ||
| 231 | 231 | // ---- now: one row per thing, in the five words ---- |
| 232 | 232 | const proposed = new Set(openPulls.map((p) => p.id)); |
| 233 | 233 | const openByIssue = openPullsClosing(db, repo.id); |
| 234 | const told = listIssues(db, repo.id, { | |
| 234 | const openTells = listIssues(db, repo.id, { | |
| 235 | 235 | kind: "issue", |
| 236 | 236 | state: "open", |
| 237 | 237 | 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 | : []; | |
| 239 | 258 | const everRan = latestRunsForIssues( |
| 240 | 259 | db, |
| 241 | told.map((i) => i.id), | |
| 260 | [...told, ...untaken].map((i) => i.id), | |
| 242 | 261 | ["agent"], |
| 243 | 262 | ); |
| 244 | 263 | 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 | ); | |
| 246 | 268 | const rowsNow = runs |
| 247 | 269 | .filter((r) => { |
| 248 | 270 | if (r.issueId === null) return true; |
| @@ -339,6 +361,16 @@ export async function studioApp( | ||
| 339 | 361 | trail: wordBadge("waiting"), |
| 340 | 362 | }); |
| 341 | 363 | }), |
| 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 | }), | |
| 342 | 374 | ]; |
| 343 | 375 | const now = |
| 344 | 376 | nowRows.length === 0 |
src/web/studio/shelf.ts +16 −1
| @@ -17,6 +17,8 @@ | ||
| 17 | 17 | |
| 18 | 18 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 19 | 19 | import { appsForRepos } from "../../core/apps.ts"; |
| 20 | import { reposWithBuilder } from "../../core/agents.ts"; | |
| 21 | import { reposWithUnclaimedTell } from "../../core/issues.ts"; | |
| 20 | 22 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 21 | 23 | import { latestAgentRunsForRepos } from "../../core/runs.ts"; |
| 22 | 24 | import { getUsersByIds } from "../../core/users.ts"; |
| @@ -72,6 +74,15 @@ export async function shelfRows( | ||
| 72 | 74 | const asksByRepo = new Map<string, number>(); |
| 73 | 75 | for (const d of decisions) |
| 74 | 76 | 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 | ); | |
| 75 | 86 | |
| 76 | 87 | return await Promise.all(repos.map(async (repo) => { |
| 77 | 88 | const record = records.get(repo.id); |
| @@ -107,7 +118,11 @@ export async function shelfRows( | ||
| 107 | 118 | : "default"; |
| 108 | 119 | const happening = |
| 109 | 120 | 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>` | |
| 111 | 126 | : html`<span class="studio-word" data-word="${word.word}" |
| 112 | 127 | >${word.word}</span |
| 113 | 128 | > |
test/studio.test.ts +101
| @@ -3770,6 +3770,107 @@ describe(`"Now" while production is coming up and nothing else is`, () => { | ||
| 3770 | 3770 | }); |
| 3771 | 3771 | }); |
| 3772 | 3772 | |
| 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 | ||
| 3773 | 3874 | /** |
| 3774 | 3875 | * Issue #65: a public app's studio page is the app, not the owner's books. |
| 3775 | 3876 | * `canSeeOwnerFacts` (`src/core/repos.ts`) is the one predicate — the same |
new above
offline