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

The terminal's Family card says, per sent forge, how many of its apps run and which one does not #1950

Merged opened this

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

Merged

scout/forge:scout/1898-the-terminal-s-family-card-says-per-sent → main 12 commits fork 2 threads · 2 unresolved

  • Merged. scout/1898-the-terminal-s-family-card-says-per-sent was merged into main as ca492f0.

Review

Sign in to review this change.
5 files changed +113 −13
README.md +3 −1
@@ -109,7 +109,9 @@ you cannot read.
109109 The family across forges: `:` → Family in the terminal, `/family` on the web,
110110 `GET /api/v1/family` and the MCP `family` tool all give the same view. The
111111 forge that sent this one is shown to anyone; the forges it sent, only to a
112 site admin (docs/SPAWN.md, "The family across forges").
112 site admin (docs/SPAWN.md, "The family across forges"). Each sent forge also
113 says how many of its apps run and names those that do not ("1 of 2 apps
114 running — bakery asleep").
113115
114116 Passkeys: Settings → Passkeys lists, adds and removes them (several per
115117 account; `src/auth/webauthn.ts` verifies, no dependency, `none` attestation).
src/spawn/family.ts +32 −3
@@ -7,16 +7,44 @@
77 import type { App } from "../app.ts";
88 import { parentForgeFor } from "../core/parent_releases.ts";
99 import { running } from "../kernel/version.ts";
10 import type { ChildAppsSummary } from "./child-apps.ts";
1011 import { childStandings, parentUpstream, type ChildStanding } from "./heartbeat.ts";
12 import { hostedChildApps } from "./host.ts";
13
14 /** A child as the family shows it: its standing, and what its apps are doing. */
15 export interface FamilyChild extends ChildStanding {
16 /** "1 of 2 apps running — bakery asleep", or null when it was given no apps. */
17 readonly apps: string | null;
18 }
19
20 const REASONS = new Set(["asleep", "failed", "stopped", "deploying"]);
21
22 /** One word for why an app is not running; free-text errors read as failing. */
23 function reason(why: string): string {
24 if (why === "not started") return "not deployed";
25 return REASONS.has(why) ? why : "failing";
26 }
27
28 /** The one line /family and the terminal share, from the state /forges shows. */
29 export function appsLine(s: ChildAppsSummary | undefined | null): string | null {
30 if (s === undefined || s === null || s.total === 0) return null;
31 const head = `${s.running} of ${s.total} ${s.total === 1 ? "app" : "apps"} running`;
32 if (s.notRunning.length === 0) return head;
33 return `${head} — ${s.notRunning.map((n) => `${n.slug} ${reason(n.why)}`).join("; ")}`;
34 }
1135
1236 export interface FamilyView {
1337 readonly parent: { readonly url: string; readonly host: string; readonly behind: number | null } | null;
1438 readonly here: { readonly host: string | null; readonly version: string };
15 readonly children: readonly ChildStanding[];
39 readonly children: readonly FamilyChild[];
1640 readonly more: number;
1741 }
1842
19 export async function familyView(app: App, isAdmin: boolean): Promise<FamilyView> {
43 export async function familyView(
44 app: App,
45 isAdmin: boolean,
46 appsOf: (app: App, name: string) => ChildAppsSummary | undefined = hostedChildApps,
47 ): Promise<FamilyView> {
2048 const known = parentForgeFor(app.config);
2149 const url = app.config.parentUrl ?? (await parentUpstream(app));
2250 const kids = isAdmin ? await childStandings(app) : { items: [], more: 0 };
@@ -29,7 +57,7 @@ export async function familyView(app: App, isAdmin: boolean): Promise<FamilyView
2957 host: app.config.url === undefined ? null : new URL(app.config.url).host,
3058 version: running().version,
3159 },
32 children: kids.items,
60 children: kids.items.map((c) => ({ ...c, apps: appsLine(appsOf(app, c.name)) })),
3361 more: kids.more,
3462 };
3563 }
@@ -53,6 +81,7 @@ export function familyLines(view: FamilyView): [string, string][] {
5381 c.openProposals === null ? "" : `${c.openProposals} open`,
5482 ].filter((b) => b !== "");
5583 out.push([`child ${c.name}`, bits.join(" · ")]);
84 if (c.apps !== null) out.push(["", c.apps]);
5685 }
5786 if (view.more > 0) out.push(["", `${view.more} more not shown`]);
5887 return out;
src/web/pages/family.ts +11 −8
@@ -8,11 +8,10 @@
88 */
99
1010 import type { Ctx } from "../../http/context.ts";
11 import { familyView } from "../../spawn/family.ts";
12 import {
13 MAX_DEPTH,
14 type ChildStanding,
15 } from "../../spawn/heartbeat.ts";
11 import type { ChildAppsSummary } from "../../spawn/child-apps.ts";
12 import { familyView, type FamilyChild } from "../../spawn/family.ts";
13 import { hostedChildApps } from "../../spawn/host.ts";
14 import { MAX_DEPTH } from "../../spawn/heartbeat.ts";
outdated

Malformed import left after removing ChildStanding. Make it a single line: import { MAX_DEPTH } from "../../spawn/heartbeat.ts";

1615 import { pageHead } from "../chrome.ts";
1716 import { html, type Html } from "../html.ts";
1817 import { page } from "../layout.ts";
@@ -30,7 +29,7 @@ interface Node {
3029 const plural = (n: number, one: string, many = `${one}s`) =>
3130 `${n} ${n === 1 ? one : many}`;
3231
33 function standingNode(c: ChildStanding): Node {
32 function standingNode(c: FamilyChild): Node {
3433 const meta: Html[] = [];
3534 if (c.state === "unheard") meta.push(html`has not reported yet`);
3635 else {
@@ -44,6 +43,7 @@ function standingNode(c: ChildStanding): Node {
4443 if (c.ownChildren !== null && c.ownChildren > 0)
4544 meta.push(html`${plural(c.ownChildren, "forge")} of its own`);
4645 }
46 if (c.apps !== null) meta.push(html`<span data-apps>${c.apps}</span>`);

This new web output has no test. Render /family as admin and assert the data-apps text, which also proves the escaping.

4747 return {
4848 label: c.name,
4949 href: c.url,
@@ -83,10 +83,13 @@ function row(n: Node, depth: number): Html {
8383 }
8484
8585 // GET /family
86 export async function familyPage(ctx: Ctx): Promise<Response> {
86 export async function familyPage(
87 ctx: Ctx,
88 appsOf: (app: Ctx["app"], name: string) => ChildAppsSummary | undefined = hostedChildApps,
89 ): Promise<Response> {
8790 const app = ctx.app;
8891 const owns = ctx.auth.user?.isAdmin === true;
89 const view = await familyView(app, owns);
92 const view = await familyView(app, owns, appsOf);
9093 const parent = view.parent;
9194 const kids = { items: view.children, more: view.more };
9295
src/web/style/content.ts +1
@@ -219,6 +219,7 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
219219 .handoff-links { flex: 1 1 16rem; min-width: 0; }
220220 .handoff-links .mono { user-select: all; overflow-wrap: anywhere; }
221221 .break { overflow-wrap: anywhere; }
222 [data-apps] { overflow-wrap: anywhere; }
222223
223224 .bar > .device-code { flex: 1 1 auto; }
224225
test/family-heartbeat.test.ts +66 −1
@@ -24,8 +24,14 @@ import {
2424 sign,
2525 SIGNATURE_HEADER,
2626 } from "../src/spawn/heartbeat.ts";
27 import { familyLines, familyView } from "../src/spawn/family.ts";
27 import { appsLine, familyLines, familyView } from "../src/spawn/family.ts";
2828 import { firstBoot } from "../src/spawn/first-boot.ts";
29 import { createForgeApp } from "../src/tui/forge.ts";
30 import { familyScreen } from "../src/tui/screens/family.ts";
31 import { headlessTerminal } from "../src/tui/term/terminal.ts";
32 import type { Ctx } from "../src/http/context.ts";
33 import { familyPage } from "../src/web/pages/family.ts";
34 import { settleApp } from "./helpers/tui.ts";
2935 import { quietLogs } from "./helpers/globals.ts";
3036
3137 quietLogs();
@@ -221,6 +227,65 @@ describe("family heartbeats", () => {
221227 expect(admin.some(([k]) => k === "child maria")).toBe(true);
222228 });
223229
230 const two = () => ({
231 total: 2,
232 running: 1,
233 notRunning: [{ slug: "bakery", why: "asleep" }],
234 });
235 const line = "1 of 2 apps running — bakery asleep";
236
237 test("the family view carries the apps line for an admin and none for a guest", async () => {
238 const view = await familyView(parent, true, two);
239 expect(view.children[0]?.apps).toBe(line);
240 expect(familyLines(view).some(([, v]) => v === line)).toBe(true);
241 expect((await familyView(parent, false, two)).children).toEqual([]);
242 expect((await familyView(parent, true, () => ({ total: 0, running: 0, notRunning: [] }))).children[0]?.apps).toBeNull();
243 });
244
245 test("the terminal's Family card shows the apps line", async () => {
246 const view = await familyView(parent, true, two);
247 const { app } = createForgeApp(
248 parent,
249 { sshUsername: "alice", remoteAddress: "127.0.0.1", user: undefined },
250 { terminal: headlessTerminal(120, 30), exitHooks: false },
251 );
252 app.start();
253 await settleApp(app);
254 app.push(familyScreen(app.ctx, familyLines(view)));
255 await settleApp(app);
256 expect(app.screen.toText()).toContain(line);
257 app.stop();
258 });
259
260 test("the family page shows the apps line to an admin, escaped, and to no guest", async () => {
261 const ctxOf = (isAdmin: boolean): Ctx => ({
262 app: parent,
263 request: new Request("http://localhost/family"),
264 url: new URL("http://localhost/family"),
265 params: {},
266 auth: isAdmin
267 ? { user: { id: 1, username: "alice", isAdmin: true } as never, scope: undefined, via: "session" }
268 : { user: undefined, scope: undefined, via: "none" },
269 theme: undefined,
270 });
271 const text = async (c: Ctx, f: Parameters<typeof familyPage>[1]) => (await familyPage(c, f)).text();
272 expect(await text(ctxOf(true), two)).toContain(`<span data-apps>${line}</span>`);
273 const hostileHtml = await text(ctxOf(true), () => ({
274 total: 1,
275 running: 0,
276 notRunning: [{ slug: "<b>x</b>", why: "asleep" }],
277 }));
278 expect(hostileHtml).toContain("&lt;b&gt;x&lt;/b&gt; asleep");
279 expect(hostileHtml).not.toContain("<b>x</b>");
280 expect(await text(ctxOf(false), two)).not.toContain("data-apps");
281 expect(await text(ctxOf(true), () => ({ total: 0, running: 0, notRunning: [] }))).not.toContain("data-apps");
282 });
283
284 test("appsLine counts what runs and says why the rest does not", () => {
285 expect(appsLine({ total: 2, running: 0, notRunning: [{ slug: "a", why: "not started" }, { slug: "b", why: "boom: x" }] }))
286 .toBe("0 of 2 apps running — a not deployed; b failing");
287 });
288
224289 test("a child sends its own beat, signed with the key spawn gave it", async () => {
225290 const sent: { url: string; body: string; sig: string }[] = [];
226291 const ok = await sendBeat(child, async (url, init) => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs