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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1898-the-terminal-s-family-card-says-per-sent → main 12 commits fork 2 threads · 2 unresolved
5 files
changed +113
−13
README.md +3 −1
| @@ -109,7 +109,9 @@ you cannot read. | ||
| 109 | 109 | The family across forges: `:` → Family in the terminal, `/family` on the web, |
| 110 | 110 | `GET /api/v1/family` and the MCP `family` tool all give the same view. The |
| 111 | 111 | 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"). | |
| 113 | 115 | |
| 114 | 116 | Passkeys: Settings → Passkeys lists, adds and removes them (several per |
| 115 | 117 | account; `src/auth/webauthn.ts` verifies, no dependency, `none` attestation). |
src/spawn/family.ts +32 −3
| @@ -7,16 +7,44 @@ | ||
| 7 | 7 | import type { App } from "../app.ts"; |
| 8 | 8 | import { parentForgeFor } from "../core/parent_releases.ts"; |
| 9 | 9 | import { running } from "../kernel/version.ts"; |
| 10 | import type { ChildAppsSummary } from "./child-apps.ts"; | |
| 10 | 11 | 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 | } | |
| 11 | 35 | |
| 12 | 36 | export interface FamilyView { |
| 13 | 37 | readonly parent: { readonly url: string; readonly host: string; readonly behind: number | null } | null; |
| 14 | 38 | readonly here: { readonly host: string | null; readonly version: string }; |
| 15 | readonly children: readonly ChildStanding[]; | |
| 39 | readonly children: readonly FamilyChild[]; | |
| 16 | 40 | readonly more: number; |
| 17 | 41 | } |
| 18 | 42 | |
| 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> { | |
| 20 | 48 | const known = parentForgeFor(app.config); |
| 21 | 49 | const url = app.config.parentUrl ?? (await parentUpstream(app)); |
| 22 | 50 | const kids = isAdmin ? await childStandings(app) : { items: [], more: 0 }; |
| @@ -29,7 +57,7 @@ export async function familyView(app: App, isAdmin: boolean): Promise<FamilyView | ||
| 29 | 57 | host: app.config.url === undefined ? null : new URL(app.config.url).host, |
| 30 | 58 | version: running().version, |
| 31 | 59 | }, |
| 32 | children: kids.items, | |
| 60 | children: kids.items.map((c) => ({ ...c, apps: appsLine(appsOf(app, c.name)) })), | |
| 33 | 61 | more: kids.more, |
| 34 | 62 | }; |
| 35 | 63 | } |
| @@ -53,6 +81,7 @@ export function familyLines(view: FamilyView): [string, string][] { | ||
| 53 | 81 | c.openProposals === null ? "" : `${c.openProposals} open`, |
| 54 | 82 | ].filter((b) => b !== ""); |
| 55 | 83 | out.push([`child ${c.name}`, bits.join(" · ")]); |
| 84 | if (c.apps !== null) out.push(["", c.apps]); | |
| 56 | 85 | } |
| 57 | 86 | if (view.more > 0) out.push(["", `${view.more} more not shown`]); |
| 58 | 87 | return out; |
src/web/pages/family.ts +11 −8
| @@ -8,11 +8,10 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | 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"; | |
| 16 | 15 | import { pageHead } from "../chrome.ts"; |
| 17 | 16 | import { html, type Html } from "../html.ts"; |
| 18 | 17 | import { page } from "../layout.ts"; |
| @@ -30,7 +29,7 @@ interface Node { | ||
| 30 | 29 | const plural = (n: number, one: string, many = `${one}s`) => |
| 31 | 30 | `${n} ${n === 1 ? one : many}`; |
| 32 | 31 | |
| 33 | function standingNode(c: ChildStanding): Node { | |
| 32 | function standingNode(c: FamilyChild): Node { | |
| 34 | 33 | const meta: Html[] = []; |
| 35 | 34 | if (c.state === "unheard") meta.push(html`has not reported yet`); |
| 36 | 35 | else { |
| @@ -44,6 +43,7 @@ function standingNode(c: ChildStanding): Node { | ||
| 44 | 43 | if (c.ownChildren !== null && c.ownChildren > 0) |
| 45 | 44 | meta.push(html`${plural(c.ownChildren, "forge")} of its own`); |
| 46 | 45 | } |
| 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. |
||
| 47 | 47 | return { |
| 48 | 48 | label: c.name, |
| 49 | 49 | href: c.url, |
| @@ -83,10 +83,13 @@ function row(n: Node, depth: number): Html { | ||
| 83 | 83 | } |
| 84 | 84 | |
| 85 | 85 | // 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> { | |
| 87 | 90 | const app = ctx.app; |
| 88 | 91 | const owns = ctx.auth.user?.isAdmin === true; |
| 89 | const view = await familyView(app, owns); | |
| 92 | const view = await familyView(app, owns, appsOf); | |
| 90 | 93 | const parent = view.parent; |
| 91 | 94 | const kids = { items: view.children, more: view.more }; |
| 92 | 95 |
src/web/style/content.ts +1
| @@ -219,6 +219,7 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 219 | 219 | .handoff-links { flex: 1 1 16rem; min-width: 0; } |
| 220 | 220 | .handoff-links .mono { user-select: all; overflow-wrap: anywhere; } |
| 221 | 221 | .break { overflow-wrap: anywhere; } |
| 222 | [data-apps] { overflow-wrap: anywhere; } | |
| 222 | 223 | |
| 223 | 224 | .bar > .device-code { flex: 1 1 auto; } |
| 224 | 225 |
test/family-heartbeat.test.ts +66 −1
| @@ -24,8 +24,14 @@ import { | ||
| 24 | 24 | sign, |
| 25 | 25 | SIGNATURE_HEADER, |
| 26 | 26 | } from "../src/spawn/heartbeat.ts"; |
| 27 | import { familyLines, familyView } from "../src/spawn/family.ts"; | |
| 27 | import { appsLine, familyLines, familyView } from "../src/spawn/family.ts"; | |
| 28 | 28 | 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"; | |
| 29 | 35 | import { quietLogs } from "./helpers/globals.ts"; |
| 30 | 36 | |
| 31 | 37 | quietLogs(); |
| @@ -221,6 +227,65 @@ describe("family heartbeats", () => { | ||
| 221 | 227 | expect(admin.some(([k]) => k === "child maria")).toBe(true); |
| 222 | 228 | }); |
| 223 | 229 | |
| 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("<b>x</b> 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 | ||
| 224 | 289 | test("a child sends its own beat, signed with the key spawn gave it", async () => { |
| 225 | 290 | const sent: { url: string; body: string; sig: string }[] = []; |
| 226 | 291 | const ok = await sendBeat(child, async (url, init) => { |
new above
offline
Malformed import left after removing ChildStanding. Make it a single line: import { MAX_DEPTH } from "../../spawn/heartbeat.ts";