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

The first-login page shows each app with the crew member working on it, in one plain line #10819

Merged opened this

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

Merged

mason/forge:mason/10753-the-first-login-page-shows-each-app-with → main 4 commits fork 5 threads · 5 unresolved

  • Merged. mason/10753-the-first-login-page-shows-each-app-with was merged into main as c8e3e11.

Review

Sign in to review this change.
7 files changed +118 −54
docs/features/app-crew.md added +12
@@ -0,0 +1,12 @@
1 # Each app with its crew member
2
3 On a forge made for someone, every app is one plain line: the app's name, then
4 who in the crew is on it and what they are doing, e.g. `bakery` — "atlas is
5 working toward Run Maria's bakery". An app whose direction is closed or has no
6 assignee reads "nobody is on it yet". The tracker's "Direction: " label is never
7 shown.
8
9 - **Web:** the first-login card on `/` (the row's title is the app, its meta the sentence; the row opens the direction).
10 - **Terminal:** the client's home in `forge tui` (`src/tui/screens/dashboard.ts`), one line per app.
11 - Both read `appCrew()` in `src/core/app-crew.ts`, so they say the same thing.
12 - Try it: spawn a forge, claim it, open `/` (or run the terminal) — the line is there before any run starts.
src/core/app-crew.ts added +51
@@ -0,0 +1,51 @@
1 /**
2 * Each app with the crew member on it, in one plain sentence (#10753). The
3 * first-login card and the terminal's home both read it, so the two doors say
4 * the same thing. Plain data in, plain data out: no markup here.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { DIRECTION_PREFIX } from "../crew/autonomy.ts";
9 import { getIssueById } from "./issues.ts";
10 import type { Repo } from "./repos.ts";
11 import { getUserById } from "./users.ts";
12
13 export interface AppCrew {
14 readonly repo: Repo;
15 /** The crew member on the app's direction; null when nobody is. */
16 readonly who: string | null;
17 /** The direction's issue number, to open it; null with nobody on it. */
18 readonly issueNumber: number | null;
19 /** What `who` is doing, in words: "atlas is working toward Run Maria's bakery". */
20 readonly says: string;
21 /** An agent run is going or lined up. */
22 readonly busy: boolean;
23 }
24
25 /** An issue title without the tracker's "Direction: " label. */
26 function directionText(title: string): string {
27 return title.startsWith(DIRECTION_PREFIX) ? title.slice(DIRECTION_PREFIX.length).trim() : title.trim();
28 }
29
30 /** For each of `repos`: who is on its direction and what they are doing now, or that nobody is. */
31 export function appCrew(db: Database, repos: readonly Repo[]): readonly AppCrew[] {
32 return repos.map((repo) => {
33 const issue = repo.directionIssueId === null ? null : getIssueById(db, repo.directionIssueId);
34 const agent =
35 issue && issue.state === "open" && issue.assigneeId !== null ? getUserById(db, issue.assigneeId) : null;
36 if (!issue || !agent) return { repo, who: null, issueNumber: null, says: "nobody is on it yet", busy: false };
37 const run = db
38 .query<{ state: string }, [string]>(
39 "SELECT state FROM run WHERE issue_id = ?1 AND kind = 'agent' ORDER BY created_at DESC LIMIT 1",
40 )
41 .get(issue.id);
42 const goal = directionText(issue.title);
43 const says =
44 run?.state === "running"
45 ? `${agent.username} is working toward ${goal}`
46 : run?.state === "queued"
47 ? `${agent.username} is about to start on ${goal}`
48 : `${agent.username} is ready to work toward ${goal}`;
49 return { repo, who: agent.username, issueNumber: issue.number, says, busy: run?.state === "running" || run?.state === "queued" };
50 });
51 }
src/crew/autonomy.ts +1 −1
@@ -1228,7 +1228,7 @@ export function startSweep(
12281228
12291229 /** The label every direction issue's title starts with. Read elsewhere to
12301230 * find a repository's standing direction — keep it exactly as it is. */
1231 const DIRECTION_PREFIX = "Direction: ";
1231 export const DIRECTION_PREFIX = "Direction: ";
12321232 /** How much of the direction fits in the title, capped so the title
12331233 * (prefix included) never exceeds `MAX_TITLE`. */
12341234 const DIRECTION_TITLE_LIMIT = Math.min(120, MAX_TITLE - DIRECTION_PREFIX.length);
src/tui/screens/dashboard.ts +4 −1
@@ -23,6 +23,7 @@ import { ownerWaits, waitLine, withMore } from "../../actions/owner_waits.ts";
2323 import { forgeHealth } from "../../kernel/guard.ts";
2424 import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts";
2525 import { clientAppRows, clientDoings, clientFinished, doingLine, finishedLine } from "../../core/client-apps.ts";
26 import { appCrew } from "../../core/app-crew.ts";
2627 import { nextStepsFor } from "../../core/next-steps.ts";
2728 import { agentsLine } from "../../web/studio/client_home.ts";
2829 import { lastVisit } from "../../core/home_visit.ts";
@@ -281,6 +282,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
281282 clientLive = new Map(apps.flatMap((a) => (a.live === undefined ? [] : [[a.repo.id, a.live] as const])));
282283 const now = new Date();
283284 const ids = apps.map((a) => a.repo.id);
285 const crewOf = new Map(appCrew(f.app.db, apps.map((a) => a.repo)).map((c) => [c.repo.id, c] as const));
284286 const doings = clientDoings(f.app.db, ids);
285287 const finished = clientFinished(f.app.db, ids);
286288 clientLines =
@@ -295,7 +297,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
295297 .join(" ");
296298 const mark = a.live === undefined ? "" : " (running, o opens it)";
297299 const line = `${a.repo.name}${mark}: ${agentsLine(a.working, a.queued)}`;
298 return doing === "" ? line : `${line} ${doing}`;
300 const crew = crewOf.get(a.repo.id)!.says;
301 return `${line}${doing === "" ? "" : ` ${doing}`} ${crew[0]!.toUpperCase()}${crew.slice(1)}.`;
299302 });
300303 ctx.invalidate();
301304 };
src/web/pages/next-steps.ts +13 −46
@@ -10,13 +10,13 @@
1010 * all three are done.
1111 */
1212
13 import { getIssueById } from "../../core/issues.ts";
13 import { appCrew } from "../../core/app-crew.ts";
1414 import {
1515 nextStepsFor,
1616 spawnedRepos,
1717 type NextStep,
1818 } from "../../core/next-steps.ts";
19 import { getUserById, type User } from "../../core/users.ts";
19 import type { User } from "../../core/users.ts";
2020 import type { Ctx } from "../../http/context.ts";
2121 import { html, type Html } from "../html.ts";
2222 import { icon } from "../icons.ts";
@@ -25,40 +25,16 @@ import { tellHref } from "../studio/nouns.ts";
2525 import { everydayAsk } from "./everyday.ts";
2626 import { EVERYDAY_WHY } from "../../core/everyday.ts";
2727
28 /** One line per app whose crew has its direction: who, on what, and how far. */
29 function crewAtWork(
30 ctx: Ctx,
31 repos: Awaited<ReturnType<typeof spawnedRepos>>,
32 ): { lines: Html[]; busy: boolean } {
33 const lines: Html[] = [];
34 let busy = false;
35 for (const repo of repos) {
36 if (repo.directionIssueId === null) continue;
37 const issue = getIssueById(ctx.app.db, repo.directionIssueId);
38 if (!issue || issue.state !== "open" || issue.assigneeId === null) continue;
39 const agent = getUserById(ctx.app.db, issue.assigneeId);
40 if (!agent) continue;
41 const run = ctx.app.db
42 .query<
43 { state: string },
44 [string]
45 >("SELECT state FROM run WHERE issue_id = ?1 AND kind = 'agent' ORDER BY created_at DESC LIMIT 1")
46 .get(issue.id);
47 if (run?.state === "running" || run?.state === "queued") busy = true;
48 const doing =
49 run?.state === "running"
50 ? "is working on"
51 : run?.state === "queued"
52 ? "is about to start on"
53 : "has been given";
54 lines.push(
55 row({
56 href: tellHref(`/${repo.ownerName}/${repo.name}`, issue.number),
57 title: html`<b>${agent.username}</b> ${doing} ${issue.title}`,
58 }),
59 );
60 }
61 return { lines, busy };
28 /** One line per app: the app is the row's title, the crew member and what they are doing its meta. */
29 function crewAtWork(ctx: Ctx, repos: Awaited<ReturnType<typeof spawnedRepos>>): Html[] {
30 return appCrew(ctx.app.db, repos).map((c) => {
31 const href = `/${c.repo.ownerName}/${c.repo.name}`;
32 return row({
33 href: c.issueNumber === null ? href : tellHref(href, c.issueNumber),
34 title: c.repo.name,
35 meta: html`${c.says}`,
36 });
37 });
6238 }
6339
6440 /** One step: its row (title, "Done" trail; the primary one in bold), then its
@@ -120,16 +96,7 @@ export async function nextStepsCard(
12096 };
12197 return html`<section id="next-steps">
12298 <h2>Your next steps</h2>
123 ${crew.lines.length > 0
124 ? html`<p>
125 ${crew.busy
126 ? "Your crew is already at work:"
127 : "Your crew has its direction:"}
128 </p>
129 <ul class="list">
130 ${crew.lines}
131 </ul>`
132 : null}
99 ${crew.length > 0 ? html`<ul class="list">${crew}</ul>` : null}
133100 ${steps.map((s) => stepBlock(s, s.key === primary, bodies[s.key]))}
134101 </section>`;
135102 }
test/client-home.test.ts +1
@@ -452,6 +452,7 @@ describe("a client's first screen", () => {
452452 app.start();
453453 await settleApp(app);
454454 expect(app.screen.toText()).toContain("bakery: Its agents are waiting for something to do.");
455 expect(app.screen.toText()).toMatch(/Atlas is ready to work toward Run Maria/);
455456 expect(app.screen.toText()).not.toContain("Shipped per hour");
456457 app.stop();
457458 });
test/first-login.test.ts +36 −6
@@ -16,6 +16,7 @@ import { tmpdir } from "node:os";
1616 import { join } from "node:path";
1717 import { createRepo } from "../src/actions/repos.ts";
1818 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
19 import { createRun } from "../src/core/runs.ts";
1920 import { createApp, type App } from "../src/app.ts";
2021 import { addCrew, createAgentRow } from "../src/core/agents.ts";
2122 import { createSession } from "../src/core/sessions.ts";
@@ -211,15 +212,15 @@ describe("the first login to a spawned forge", () => {
211212 expect(home.split("Make your everyday account").length - 1).toBe(1);
212213 // The planner and the direction it was given, under the studio's noun.
213214 // No run has started (no model key), so it does not claim work under way.
214 expect(home).toContain("Your crew has its direction:");
215 expect(home).not.toContain("already at work");
216 expect(home).toContain("atlas");
217 expect(home).toMatch(/href="\/marias\/bakery\/issues\/\d+"[^>]*>[\s\S]*?Run Maria/);
215 expect(home).toContain("atlas is ready to work toward Run Maria");
216 expect(home).not.toContain("Direction: Run");
217 expect(home).not.toMatch(/Your (crew|apps)[^<]*:/);
218 expect(home).toMatch(/href="\/marias\/bakery\/issues\/\d+"[^>]*>[\s\S]*?atlas is ready/);
218219 for (const old of ["next-step", "next-crew"]) expect(home).not.toContain(`class="${old}`);
219220 expect(home).not.toMatch(/class="next-step/);
220221 // A pending step's body is a block of its own, never inside the row's
221222 // one-line, clipped `.row-meta`.
222 expect(home).not.toContain("row-meta");
223 expect(home).not.toMatch(/data-step[\s\S]{0,400}row-meta/);
223224 expect(home).toMatch(/<div class="step-body">\s*<p class="muted small">/);
224225 // Each app with its way in.
225226 expect(home).toContain('href="/marias/bakery"');
@@ -239,11 +240,40 @@ describe("the first login to a spawned forge", () => {
239240 // It can read the client's own apps, and the crew line reads for it too.
240241 expect(home).toContain('href="/marias/bakery"');
241242 expect(home).not.toContain("Your apps arrive here");
242 expect(home).toContain("Your crew has its direction:");
243 expect(home).toContain("atlas is ready to work toward Run Maria");
243244 // The admin's card moves along with it.
244245 expect(steps(await page(owner)).everyday).toBe("yes");
245246 });
246247
248 test("each app is one line: the crew member on it and what they do now, or that nobody is", async () => {
249 const maria = getUserByName(child.db, "maria")!;
250 const bakery = child.db
251 .query<{ id: string; direction_issue_id: string }, []>(
252 "SELECT id, direction_issue_id FROM repo WHERE name = 'bakery'",
253 )
254 .get()!;
255 const spare = await createRepo(child, maria, { name: "spare", owner: "marias" });
256 if (!spare.ok) throw new Error("spare");
257 const atlasId = getUserByName(child.db, "atlas")!.id;
258 const run = createRun(child.db, {
259 repoId: bakery.id,
260 kind: "agent",
261 issueId: bakery.direction_issue_id,
262 reason: "work",
263 });
264 child.db.query("UPDATE run SET state = 'running' WHERE id = ?1").run(run.id);
265 const home = await page(owner);
266 expect(home).toMatch(/class="row-title"[^>]*>bakery<\/span\s*>\s*<span class="row-meta">atlas is working toward Run Maria(?:'|&#39;|&#x27;)s bakery\.?</);
outdated

500 ms is not the page's time budget; the issue asks that the page stay under it. Use the real budget, and add the closed or unassigned direction case.

267 expect(home.slice(home.indexOf("id=\"next-steps\""), home.indexOf("</section>"))).not.toContain("Direction:");
268 expect(home).toMatch(/class="row-title"[^>]*>spare<\/span\s*>\s*<span class="row-meta">nobody is on it yet</);
outdated

This pins the 'Direction: Run Maria' title text, so it locks in the non-plain wording. Pin the plain sentence once it is fixed.

269 // A direction closed or unassigned is nobody on it, too.
270 child.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(bakery.direction_issue_id);
271 expect(await page(owner)).toMatch(/class="row-title"[^>]*>bakery<\/span\s*>\s*<span class="row-meta">nobody is on it yet</);
272 child.db.query("UPDATE issue SET state = 'open', assignee_id = NULL WHERE id = ?1").run(bakery.direction_issue_id);
273 expect(await page(owner)).toMatch(/class="row-title"[^>]*>bakery<\/span\s*>\s*<span class="row-meta">nobody is on it yet</);
274 child.db.query("UPDATE issue SET assignee_id = ?2 WHERE id = ?1").run(bakery.direction_issue_id, atlasId);
275 });
276
247277 test("the everyday account tells the crew something: the third step", async () => {
248278 const told = await post(daily, "/marias/bakery/tell", {
249279 text: "Add a weekly menu page.",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs