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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10753-the-first-login-page-shows-each-app-with → main 4 commits fork 5 threads · 5 unresolved
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( | ||
| 1228 | 1228 | |
| 1229 | 1229 | /** The label every direction issue's title starts with. Read elsewhere to |
| 1230 | 1230 | * find a repository's standing direction — keep it exactly as it is. */ |
| 1231 | const DIRECTION_PREFIX = "Direction: "; | |
| 1231 | export const DIRECTION_PREFIX = "Direction: "; | |
| 1232 | 1232 | /** How much of the direction fits in the title, capped so the title |
| 1233 | 1233 | * (prefix included) never exceeds `MAX_TITLE`. */ |
| 1234 | 1234 | 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"; | ||
| 23 | 23 | import { forgeHealth } from "../../kernel/guard.ts"; |
| 24 | 24 | import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts"; |
| 25 | 25 | import { clientAppRows, clientDoings, clientFinished, doingLine, finishedLine } from "../../core/client-apps.ts"; |
| 26 | import { appCrew } from "../../core/app-crew.ts"; | |
| 26 | 27 | import { nextStepsFor } from "../../core/next-steps.ts"; |
| 27 | 28 | import { agentsLine } from "../../web/studio/client_home.ts"; |
| 28 | 29 | import { lastVisit } from "../../core/home_visit.ts"; |
| @@ -281,6 +282,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 281 | 282 | clientLive = new Map(apps.flatMap((a) => (a.live === undefined ? [] : [[a.repo.id, a.live] as const]))); |
| 282 | 283 | const now = new Date(); |
| 283 | 284 | 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)); | |
| 284 | 286 | const doings = clientDoings(f.app.db, ids); |
| 285 | 287 | const finished = clientFinished(f.app.db, ids); |
| 286 | 288 | clientLines = |
| @@ -295,7 +297,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 295 | 297 | .join(" "); |
| 296 | 298 | const mark = a.live === undefined ? "" : " (running, o opens it)"; |
| 297 | 299 | 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)}.`; | |
| 299 | 302 | }); |
| 300 | 303 | ctx.invalidate(); |
| 301 | 304 | }; |
src/web/pages/next-steps.ts +13 −46
| @@ -10,13 +10,13 @@ | ||
| 10 | 10 | * all three are done. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { getIssueById } from "../../core/issues.ts"; | |
| 13 | import { appCrew } from "../../core/app-crew.ts"; | |
| 14 | 14 | import { |
| 15 | 15 | nextStepsFor, |
| 16 | 16 | spawnedRepos, |
| 17 | 17 | type NextStep, |
| 18 | 18 | } from "../../core/next-steps.ts"; |
| 19 | import { getUserById, type User } from "../../core/users.ts"; | |
| 19 | import type { User } from "../../core/users.ts"; | |
| 20 | 20 | import type { Ctx } from "../../http/context.ts"; |
| 21 | 21 | import { html, type Html } from "../html.ts"; |
| 22 | 22 | import { icon } from "../icons.ts"; |
| @@ -25,40 +25,16 @@ import { tellHref } from "../studio/nouns.ts"; | ||
| 25 | 25 | import { everydayAsk } from "./everyday.ts"; |
| 26 | 26 | import { EVERYDAY_WHY } from "../../core/everyday.ts"; |
| 27 | 27 | |
| 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 | }); | |
| 62 | 38 | } |
| 63 | 39 | |
| 64 | 40 | /** One step: its row (title, "Done" trail; the primary one in bold), then its |
| @@ -120,16 +96,7 @@ export async function nextStepsCard( | ||
| 120 | 96 | }; |
| 121 | 97 | return html`<section id="next-steps"> |
| 122 | 98 | <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} | |
| 133 | 100 | ${steps.map((s) => stepBlock(s, s.key === primary, bodies[s.key]))} |
| 134 | 101 | </section>`; |
| 135 | 102 | } |
test/client-home.test.ts +1
| @@ -452,6 +452,7 @@ describe("a client's first screen", () => { | ||
| 452 | 452 | app.start(); |
| 453 | 453 | await settleApp(app); |
| 454 | 454 | 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/); | |
| 455 | 456 | expect(app.screen.toText()).not.toContain("Shipped per hour"); |
| 456 | 457 | app.stop(); |
| 457 | 458 | }); |
test/first-login.test.ts +36 −6
| @@ -16,6 +16,7 @@ import { tmpdir } from "node:os"; | ||
| 16 | 16 | import { join } from "node:path"; |
| 17 | 17 | import { createRepo } from "../src/actions/repos.ts"; |
| 18 | 18 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; |
| 19 | import { createRun } from "../src/core/runs.ts"; | |
| 19 | 20 | import { createApp, type App } from "../src/app.ts"; |
| 20 | 21 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; |
| 21 | 22 | import { createSession } from "../src/core/sessions.ts"; |
| @@ -211,15 +212,15 @@ describe("the first login to a spawned forge", () => { | ||
| 211 | 212 | expect(home.split("Make your everyday account").length - 1).toBe(1); |
| 212 | 213 | // The planner and the direction it was given, under the studio's noun. |
| 213 | 214 | // 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/); | |
| 218 | 219 | for (const old of ["next-step", "next-crew"]) expect(home).not.toContain(`class="${old}`); |
| 219 | 220 | expect(home).not.toMatch(/class="next-step/); |
| 220 | 221 | // A pending step's body is a block of its own, never inside the row's |
| 221 | 222 | // 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/); | |
| 223 | 224 | expect(home).toMatch(/<div class="step-body">\s*<p class="muted small">/); |
| 224 | 225 | // Each app with its way in. |
| 225 | 226 | expect(home).toContain('href="/marias/bakery"'); |
| @@ -239,11 +240,40 @@ describe("the first login to a spawned forge", () => { | ||
| 239 | 240 | // It can read the client's own apps, and the crew line reads for it too. |
| 240 | 241 | expect(home).toContain('href="/marias/bakery"'); |
| 241 | 242 | 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"); | |
| 243 | 244 | // The admin's card moves along with it. |
| 244 | 245 | expect(steps(await page(owner)).everyday).toBe("yes"); |
| 245 | 246 | }); |
| 246 | 247 | |
| 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(?:'|'|')s bakery\.?</); | |
| 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 | ||
| 247 | 277 | test("the everyday account tells the crew something: the third step", async () => { |
| 248 | 278 | const told = await post(daily, "/marias/bakery/tell", { |
| 249 | 279 | text: "Add a weekly menu page.", |
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.