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

A client's first screen lists their apps in plain sentences, each saying whether its agents work or wait #1871

Merged opened this

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

Merged

scout/forge:scout/1812-a-client-s-first-screen-lists-their-apps → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/1812-a-client-s-first-screen-lists-their-apps was merged into main as a19d6ac.

Review

Sign in to review this change.
8 files changed +454 −5
README.md +7
@@ -82,6 +82,13 @@ ssh -p 2222 localhost # the terminal UI
8282 git clone ssh://localhost:2222/you/hello.git # git, same port
8383 ```
8484
85 On a forge made for a client (one that was spawned, signed in by its claim
86 link), the home is their apps, one plain sentence each and one more saying
87 whether its agents are working, about to start, or waiting: no map, board or
88 settings until asked. `/?all=1` is the whole home, and its "Just my apps" link
89 comes back. The terminal's first screen opens with the same lines, from the
90 same query (`src/core/client-apps.ts`).
91
8592 On a host that cannot hold cells or the crew's shell sandbox, a site admin
8693 sees `Reduced protection: …` in one line, under the filter on the terminal's
8794 first screen and in the web footer; nobody else does, and a fully protected
scripts/gate.ts +1
@@ -28,6 +28,7 @@ export type Exclusion = {
2828
2929 export const GATE: readonly string[] = [
3030 "test/accounts-retire.test.ts",
31 "test/client-home.test.ts",
3132 "test/cloud-use.test.ts",
3233 "test/accounts.test.ts",
3334 "test/asks-mute.test.ts",
src/core/client-apps.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * A client's apps and how busy their agents are (#1812): one row per app on
3 * the forge made for them, with the agent runs going and the ones lined up.
4 * The web home and the terminal both read it, so they cannot disagree.
5 */
6
7 import type { App } from "../app.ts";
8 import { spawnedRepos } from "./next-steps.ts";
9 import type { Repo } from "./repos.ts";
10 import type { User } from "./users.ts";
11
12 export interface ClientApp {
13 readonly repo: Repo;
14 /** Agent runs going now. */
15 readonly working: number;
16 /** Agent runs lined up, none going yet. */
17 readonly queued: number;
18 }
19
20 /** The apps `user` can see on a spawned forge, each with its agent counts. */
21 export async function clientAppRows(
22 app: App,
23 user: User,
24 ): Promise<readonly ClientApp[]> {
25 const repos = await spawnedRepos(app, user);
26 if (repos.length === 0) return [];
27 const rows = app.db
28 .query<{ repo_id: string; state: string; n: number }, string[]>(
29 `SELECT repo_id, state, COUNT(*) AS n FROM run
30 WHERE kind = 'agent' AND state IN ('queued','running')
31 AND repo_id IN (${repos.map((_, i) => `?${i + 1}`).join(",")})
32 GROUP BY repo_id, state`,
33 )
34 .all(...repos.map((r) => r.id));
35 return repos.map((repo) => {
36 const own = rows.filter((r) => r.repo_id === repo.id);
37 const n = (state: string): number =>
38 own.find((r) => r.state === state)?.n ?? 0;
39 return { repo, working: n("running"), queued: n("queued") };
40 });
41 }
src/tui/screens/dashboard.ts +34 −2
@@ -17,6 +17,9 @@
1717 import { protectionNotice } from "../../kernel/protection.ts";
1818 import { holdWords, plannerHold } from "../../crew/autonomy.ts";
1919 import { forgeLane, forgeNeeds } from "../../actions/health.ts";
20 import { clientAppRows } from "../../core/client-apps.ts";
21 import { nextStepsFor } from "../../core/next-steps.ts";
22 import { agentsLine } from "../../web/studio/client_home.ts";
2023 import { mayView } from "../../core/sealed.ts";
2124 import { pinRepo, unpinRepo } from "../../actions/origins.ts";
2225 import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts";
@@ -115,6 +118,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
115118 /** Why planning is paused, for the autonomous apps where it is; as on the board. */
116119 let holds = new Map<string, string>();
117120 let forge: string[] | undefined;
121 /** A client's apps, one sentence each on whether its agents work or wait (#1812). */
122 let clientLines: string[] = [];
118123 /** The check lane, as the board's strip says it; null when nothing is queued. */
119124 let lane: string | null = null;
120125 const filter = new Filter();
@@ -126,6 +131,19 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
126131 };
127132 let focus: PaneId = "repos";
128133
134 const loadClientLines = async (): Promise<void> => {
135 clientLines = [];
136 if (!f.user || (await nextStepsFor(f.app, f.user)) === undefined) return;
137 const apps = await clientAppRows(f.app, f.user);
138 clientLines =
139 apps.length === 0
140 ? ["Your apps will appear here."]
141 : apps.map(
142 (a) => `${a.repo.name}: ${agentsLine(a.working, a.queued)}`,
143 );
144 ctx.invalidate();
145 };
146
129147 const reload = (): void => {
130148 // The server remembers the *choice* and nothing about the laptop, so
131149 // this is a list of decisions — never a status that would be stale.
@@ -489,7 +507,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
489507 // functions of the outbox, so one subscription refreshes all of them.
490508 live(f, signal, {}, reload);
491509 reload();
492 return Promise.resolve();
510 return loadClientLines();
493511 },
494512 render(area, s) {
495513 const c = ctx.theme.c;
@@ -497,10 +515,24 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
497515 // A site admin's forge strip, as the board's: "All fine" or a line
498516 // per cause. Anyone else gets no rows and no reads.
499517 let body = area;
518 if (clientLines.length > 0) {
519 const lines = clientLines.slice(0, 5);
520 const [strip, rest] = split(body, "v", [len(lines.length), fill(1)]);

Apps past five vanish without a hint. Add a "+N more" line so the terminal does not look like it has fewer apps than the web.

521 lines.forEach((line, i) =>
522 s.text(
523 strip!.x,
524 strip!.y + i,
525 truncate(line, strip!.w),
526 style(c.fg, c.bg),
527 strip!.x + strip!.w,
528 ),
529 );
530 body = rest!;
531 }
500532 if (forge !== undefined) {
501533 const lines = forge.length === 0 ? ["All fine"] : forge.slice(0, 3);
502534 if (lane !== null) lines.push(`Lane: ${lane}`);
503 const [strip, rest] = split(area, "v", [len(lines.length), fill(1)]);
535 const [strip, rest] = split(body, "v", [len(lines.length), fill(1)]);
504536 lines.forEach((line, i) =>
505537 s.text(
506538 strip!.x,
src/web/pages/next-steps.ts +9 −1
@@ -84,8 +84,8 @@ function stepRow(step: NextStep, primary: boolean, body: Html | null): Html {
8484 export async function nextStepsCard(
8585 ctx: Ctx,
8686 user: User,
87 steps: readonly NextStep[] | undefined,
8788 ): Promise<Html | null> {
88 const steps = await nextStepsFor(ctx.app, user);
8989 if (!steps || steps.every((s) => s.done)) return null;
9090 const repos = await spawnedRepos(ctx.app, user);
9191 const crew = crewAtWork(ctx, repos);
@@ -134,3 +134,11 @@ export async function nextStepsCard(
134134 { id: "next-steps", head: html`<h2>Your next steps</h2>` },
135135 );
136136 }
137
138 /** The steps once per request, for the card and for the lean home to share. */
139 export function nextStepsOf(
140 ctx: Ctx,
141 user: User,
142 ): Promise<readonly NextStep[] | undefined> {
143 return nextStepsFor(ctx.app, user);
144 }
src/web/studio/client_home.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * A client's first screen (#1812): signed in on a forge that was made for
3 * them, the home is their apps, each in one plain sentence and one more that
4 * says whether its agents are working or waiting. No map, board or settings
5 * until asked (`/?all=1` is the whole home). Words only a developer knows
6 * stay off it; `test/client-home.test.ts` holds the list.
7 */
8
9 import { clientAppRows } from "../../core/client-apps.ts";
10 import type { User } from "../../core/users.ts";
11 import type { Ctx } from "../../http/context.ts";
12 import { repoBase } from "../chrome.ts";
13 import { html, type Html } from "../html.ts";
14 import { card } from "../ui.ts";
15
16 /** The sentence about the agents, from how many are going and how many are lined up. */
17 export function agentsLine(working: number, queued: number): string {
18 if (working > 0)
19 return working === 1
20 ? "One agent is working on it now."
21 : `${working} agents are working on it now.`;
22 if (queued > 0) return "Its agents are about to start.";
23 return "Its agents are waiting for something to do.";
24 }
25
26 /** The card of apps; an empty one says they will appear. The caller decides who is a client. */
27 export async function clientApps(ctx: Ctx, user: User): Promise<Html> {
28 const apps = await clientAppRows(ctx.app, user);
29 const rows = apps.map(({ repo, working, queued }) => {
30 const about = repo.description !== "" ? repo.description : "An app of yours.";
31 return html`<li class="client-app">
32 <a href="${repoBase(repo)}"><b>${repo.name}</b></a>
33 <span class="client-app-about">${about}</span>
34 <span class="client-app-agents muted" data-agents="${working > 0 ? "working" : "waiting"}"
35 >${agentsLine(working, queued)}</span
36 >
37 </li>`;
38 });
39 return card(
40 rows.length === 0
41 ? html`<p class="muted">Your apps will appear here.</p>`
42 : html`<ul class="list client-apps">
43 ${rows}
44 </ul>`,
45 { id: "client-apps", head: html`<h2>Your apps</h2>` },
46 );
47 }
src/web/studio/home.ts +30 −2
@@ -50,8 +50,9 @@ import { composer, feedJs, feedNote, feedRegion } from "./feed.ts";
5050 import { homeTabs } from "./home_tabs.ts";
5151 import { crewKeyNote } from "./crew.ts";
5252 import { everydayCard } from "../pages/everyday.ts";
53 import { nextStepsCard } from "../pages/next-steps.ts";
53 import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts";
5454 import { spawnedOrg } from "../../spawn/claim.ts";
55 import { clientApps } from "./client_home.ts";
5556
5657 /** The events that can change what is on you. */
5758 const ON_YOU_LIVE =
@@ -185,9 +186,36 @@ export async function studioHome(
185186 );
186187 // On a spawned forge its owner's first steps come first, and carry the
187188 // everyday account themselves (#600); anywhere else, #803's card asks.
188 const firstSteps = await nextStepsCard(ctx, user);
189 const steps = await nextStepsOf(ctx, user);
190 const firstSteps = await nextStepsCard(ctx, user, steps);
191 // Its owner sees their apps first and nothing else until they ask (#1812).
192 if (
193 ctx.url.searchParams.get("all") === null &&
194 steps !== undefined
195 ) {
196 const apps = await clientApps(ctx, user);
197 const lean = html`<h1 class="sr-only">Home</h1>
198 ${firstSteps} ${address} ${crewKeyNote(ctx)} ${apps}
199 <div class="stack">
200 ${composer(ctx, user)}
201 <p class="small muted">
202 <a href="/?all=1">Show everything</a>
203 </p>
204 </div>`;
205 return page({
206 ctx,
207 title: "Home",
208 body: lean,
209 });
210 }
189211 const body = html`<h1 class="sr-only">Home</h1>
190212 ${firstSteps ?? everydayCard(ctx, user)} ${address}
213 ${when(
214 steps,
215 () => html`<p class="small muted" id="lean-home">
216 <a href="/">Just my apps</a>
217 </p>`,
218 )}
191219 ${homeTabs("feed")}
192220 <div class="dash">
193221 <div class="dash-main stack">
test/client-home.test.ts added +285
@@ -0,0 +1,285 @@
1 /**
2 * A client's first screen (#1812): signed in through a child forge's claim
3 * link, a person sees their apps, one plain line each and whether its agents
4 * work or wait; no map, board or settings until asked, and none of the
5 * developer's words.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { childDirOf, spawnForge } from "../src/actions/spawn.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
16 import { createSession } from "../src/core/sessions.ts";
17 import { linkEveryday } from "../src/core/everyday.ts";
18 import { createUser, getUserByName } from "../src/core/users.ts";
19 import { commitFiles } from "../src/git/write.ts";
20 import { repoDir } from "../src/git/repos.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { firstBoot } from "../src/spawn/first-boot.ts";
23 import { hostedPlacement } from "../src/spawn/placement.ts";
24 import { createForgeApp } from "../src/tui/forge.ts";
25 import { headlessTerminal } from "../src/tui/term/terminal.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27 import { settleApp } from "./helpers/tui.ts";
28
29 quietLogs();
30
31 const PASSWORD = "a-long-first-login-passphrase";
32 const FORGE_JSON = JSON.stringify({
33 app: { run: "bun run index.ts", health: "/" },
34 });
35
36 describe("a client's first screen", () => {
37 let dir = "";
38 let parent: App;
39 let parentServers: Servers;
40 let child: App;
41 let servers: Servers;
42 let owner = "";
43
44 const url = (path: string) => `${servers.url}${path}`;
45 const page = async (cookie: string, path = "/") =>
46 (await (await fetch(url(path), { headers: { cookie } })).text()).replace(
47 /\s+/g,
48 " ",
49 );
50 const post = (cookie: string, path: string, form: Record<string, string>) =>
51 fetch(url(path), {
52 method: "POST",
53 headers: { cookie, origin: servers.url },
54 body: new URLSearchParams(form),
55 redirect: "manual",
56 });
57
58 beforeAll(async () => {
59 dir = await mkdtemp(join(tmpdir(), "forge-first-login-"));
60 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
61 const made = await createUser(parent.db, {
62 trusted: true,
63 username: "alice",
64 email: "[email protected]",
65 password: PASSWORD,
66 isAdmin: true,
67 });
68 if (!made.ok) throw new Error("user");
69 const alice = made.value;
70 const repo = await createRepo(parent, alice, { name: "bakery" });
71 if (!repo.ok) throw new Error("repo");
72 const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), {
73 branch: "main",
74 message: "bakery",
75 files: [
76 { path: "forge.json", content: FORGE_JSON },
77 {
78 path: "index.ts",
79 content: "Bun.serve({fetch(){return new Response('hi')}})\n",
80 },
81 ],
82 author: { name: "a", email: "[email protected]" },
83 });
84 if (!c.ok) throw new Error("commit");
85 const atlas = await createUser(parent.db, {
86 username: "atlas",
87 email: "[email protected]",
88 agent: true,
89 });
90 if (!atlas.ok) throw new Error("agent");
91 createAgentRow(parent.db, {
92 userId: atlas.value.id,
93 brief: "plan",
94 createdBy: alice.id,
95 });
96 addCrew(parent.db, repo.value.id, atlas.value.id, alice.id);
97 setCrewRole(parent.db, repo.value.id, atlas.value.id, "planner");
98 parentServers = await serve(parent);
99 const spawned = await spawnForge(
100 parent,
101 alice,
102 {
103 name: "maria",
104 org: "marias",
105 apps: ["alice/bakery"],
106 carryBriefs: true,
107 carrySubscriptions: false,
108 direction: "Run Maria's bakery.",
109 },
110 {
111 catalog: [
112 { ref: "alice/bakery", readable: true, bytes: 10, forgeJsonBytes: 0 },
113 ],
114 briefs: [{ agent: "atlas", text: "plan" }],
115 subscriptions: [],
116 },
117 {
118 placement: hostedPlacement({ host: "forge.test" }),
119 request: new Request("https://forge.test/x", {
120 method: "POST",
121 headers: { origin: "https://forge.test" },
122 }),
123 url: new URL("https://forge.test/x"),
124 },
125 );
126 if (!spawned.ok) throw new Error(JSON.stringify(spawned.error));
127 child = createApp({
128 dataDir: childDirOf(parent, spawned.value.bodyId),
129 port: 0,
130 });
131 await firstBoot(child);
132 servers = await serve(child);
133 const link = spawned.value.claimUrl!;
134 const token = link.split("/claim#")[1]!;
135 const claimed = await fetch(url("/claim"), {
136 method: "POST",
137 headers: { origin: servers.url },
138 body: new URLSearchParams({
139 token,
140 username: "maria",
141 email: "[email protected]",
142 password: PASSWORD,
143 }),
144 redirect: "manual",
145 });
146 if (claimed.status !== 303) throw new Error(`claim ${claimed.status}`);
147 owner = (claimed.headers.get("set-cookie") ?? "").split(";")[0]!;
148 }, 60_000);
149
150 afterAll(async () => {
151 servers.stop();
152 child.close();
153 parentServers.stop();
154 parent.close();
155 await rm(dir, { recursive: true, force: true });
156 });
157
158 /** What the page itself says, below the bar every page has. */
159 const main = (html: string): string => html.slice(html.indexOf("<main"));
160 const words = (html: string): string =>
161 main(html)
162 .replace(/<(script|style)[\s\S]*?<\/\1>/g, " ")
163 .replace(/<[^>]*>/g, " ")
164 .replace(/\s+/g, " ");
165
166 test("the home is their apps, each a sentence and a line on its agents, with no map, board or settings", async () => {
167 const home = main(await page(owner));
168 const apps = home.slice(home.indexOf('id="client-apps"'));
169 expect(apps).toContain('href="/marias/bakery"');
170 expect(apps).toMatch(/data-agents="(working|waiting)"/);
171 expect(apps).toContain("agents are waiting");
172 expect(home).not.toContain('id="live-apps"');
173 for (const gone of ['href="/map"', 'href="/board"', 'href="/audit"', 'href="/settings"'])
174 expect(home).not.toContain(gone);
175 expect(home).toContain('href="/?all=1"');
176 });
177
178 test("a running agent turns the line to working", async () => {
179 const repo = child.db
180 .query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'bakery'")
181 .get()!;
182 child.db
183 .query("INSERT INTO run (id, repo_id, kind, state) VALUES ('r-client-1', ?1, 'agent', 'running')")
184 .run(repo.id);
185 const home = await page(owner);
186 expect(home).toContain('data-agents="working"');
187 expect(home).toContain("One agent is working on it now.");
188 child.db.query("DELETE FROM run WHERE id = 'r-client-1'").run();
189 });
190
191 test("none of the developer's words appear on it, composer included", async () => {
192 const text = words(await page(owner));
193 expect(text).toContain("Your apps");
194 for (const word of ["pull", "branch", "commit", "merge", "CI", "repository", "repositories"])
195 expect(text).not.toMatch(new RegExp(`\\b${word}`, "i"));
196 });
197
198 test("asked for, the whole home is still there", async () => {
199 const home = await page(owner, "/?all=1");
200 expect(home).toContain('id="live-apps"');
201 expect(home).not.toContain('id="client-apps"');
202 });
203
204 test("a plain reader gets the ordinary home", async () => {
205 const reader = await createUser(child.db, {
206 trusted: true,
207 username: "reader",
208 email: "[email protected]",
209 password: PASSWORD,
210 });
211 if (!reader.ok) throw new Error("reader");
212 const session = createSession(child.db, reader.value.id);
213 expect(await page(`forge_session=${session.token}`)).not.toContain('id="client-apps"');
214 });
215
216 const visibleWords = async (cookie: string): Promise<string> => {
217 const all = main(await page(cookie));
218 // The composer's form; the command palette beside it is the bar's, not the home's.
219 const from = all.indexOf('id="composer"');
220 const m = all.slice(from, all.indexOf("</form>", from));
221 const attrs = [...m.matchAll(/\b(?:placeholder|aria-label|title|alt)="([^"]*)"/g)]
222 .map((x) => x[1])
223 .join(" ");
224 return `${words(m)} ${attrs}`;
225 };
226
227 test("the composer's words, labels and placeholders carry none either", async () => {
228 const home = await page(owner);
229 expect(home).toContain('id="composer"');
230 const text = await visibleWords(owner);
231 for (const word of ["pull", "branch", "commit", "merge", "repository", "repositories"])
232 expect(text).not.toMatch(new RegExp(`\\b${word}`, "i"));
233 });
234
235 test("the lean home ships no live-update script and the whole home links back to it", async () => {
236 expect(await page(owner)).not.toContain("EventSource");
237 const all = await page(owner, "/?all=1");
238 expect(all).toContain('id="lean-home"');
239 expect(all).toContain('href="/"');
240 });
241
242 test("queued agents and no running ones read as about to start", async () => {
243 const repo = child.db
244 .query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'bakery'")
245 .get()!;
246 child.db
247 .query("INSERT INTO run (id, repo_id, kind, state) VALUES ('r-client-2', ?1, 'agent', 'queued')")
248 .run(repo.id);
249 const home = await page(owner);
250 expect(home).toContain("Its agents are about to start.");
251 expect(home).toContain('data-agents="waiting"');
252 child.db.query("DELETE FROM run WHERE id = 'r-client-2'").run();
253 });
254
255 test("an everyday account that cannot see the apps is told they will appear, and no more", async () => {
256 const maria = getUserByName(child.db, "maria")!;
257 const daily = await createUser(child.db, {
258 trusted: true,
259 username: "mariadaily",
260 email: "[email protected]",
261 password: PASSWORD,
262 });
263 if (!daily.ok) throw new Error(JSON.stringify(daily.error));
264 linkEveryday(child.db, maria, daily.value);
265 const cookie = `forge_session=${createSession(child.db, daily.value.id).token}`;
266 const home = await page(cookie);
267 expect(home).toContain('id="client-apps"');
268 expect(home).toContain("Your apps will appear here.");
269 expect(home).not.toContain('href="/marias/bakery"');
270 });
271
272 test("the terminal's first screen says the same about each app", async () => {
273 const maria = getUserByName(child.db, "maria")!;
274 const terminal = headlessTerminal(120, 30);
275 const { app } = createForgeApp(
276 child,
277 { sshUsername: "maria", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: maria },
278 { terminal, exitHooks: false },
279 );
280 app.start();
281 await settleApp(app);
282 expect(app.screen.toText()).toContain("bakery: Its agents are waiting for something to do.");
283 app.stop();
284 });
285 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs