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

Foundry reads more of the forge through a read-only view, and the forge links to it #9193

Merged opened this

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

Merged

scout/forge:scout/8955-foundry-reads-more-of-the-forge-through → main 12 commits fork 2 threads · 2 unresolved

  • Merged. scout/8955-foundry-reads-more-of-the-forge-through was merged into main as 691cfe2.

Review

Sign in to review this change.
16 files changed +529 −4
docs/features/forge-view.md added +34
@@ -0,0 +1,34 @@
1 # The forge view (`GET /api/v1/forge/view`)
2
3 One read for a reader that draws the forge — foundry (`trevato/foundry`, a live 3D model) is the first. It is on the
4 public API and in the OpenAPI document like any route; there is no private door for it. It is the forge's own reads
5 composed, so its numbers are the ones home and the board show.
6
7 ```json
8 { "counts": { "apps": 12, "running": 2, "queued": 1, "waiting": 0 },
9 "apps": [{ "app": "ada/hello", "slug": "hello", "access": "public", "running": 1, "queued": 0 }],
10 "runs": [ /* the rows of GET /api/v1/crew/now: run, app, who, on, why, state, since, costUsd */ ],
11 "health": { /* GET /api/v1/health, as it answers this caller */ },
12 "at": "2026-11-02T10:00:00.000Z" }
13 ```
14
15 - **Stations.** Each run's `state` (`queued`, `running`) and `why` are where it stands; `waiting` counts the rows that
16 wait on a person (`run: null`). Per-app `running`/`queued` are the same rows grouped.
17 - **Who sees what.** The caller's own visibility: private apps, sealed reports and owner-only facts follow the same
18 checks as `/api/v1/crew/now` and `/api/v1/apps`. Health is the stranger/signed-in/admin tiers of `/api/v1/health`.
19 - **Read-only.** Any token scope (`ro` included) reads it; the route accepts GET only (other methods are 404/405), and
20 a `ro` token is refused on the forge's writes as everywhere.
21 - **Cheap to leave on.** The answer is built at most once per two seconds per caller (`VIEW_TTL_MS`), carries an
22 `ETag` that ignores `at`, and a poll that holds it gets a 304. Lists are bounded: queued and running runs at 50 (the `LIMIT 50` of `listActiveRuns`, which `crewNow` reads), the
23 rows waiting on a person by what `crewNow` finds, and apps at 200 (`VIEW_APPS`; `counts.apps` is still the true number). Poll every
24 2–5 s; there is no stream, deliberately, so an always-on view adds no connection to hold (#7559).
25
26 **Links.** Home (the apps card) and the command palette show **Foundry** when an app with the slug `foundry` is
27 installed and the reader may enter it (`actions/foundry_door.ts`). The slug is not enough: the app must be owned by
28 a site admin or by the `trevato` org, so nobody can plant a "Foundry" link by naming an app first. Nothing in the
29 forge depends on it.
30
31 **Terminal.** The palette's **Forge view** opens a screen with the view's counts and the Foundry address (when there
32 is a door); Enter opens Foundry on the web.
33
34 Not done: a push stream (`/api/v1/forge/view` is polled), and anything that writes.
src/actions/crew_now.ts +1 −1
@@ -21,7 +21,7 @@ import type { Ctx } from "../http/context.ts";
2121 import type { User } from "../core/users.ts";
2222
2323 /** Rows of the crew's work as `user` may see them (none for no one). */
24 export function crewNow(ctx: Ctx, user: User) {
24 export function crewNow(ctx: Pick<Ctx, "app">, user: User) {
2525 const { repos } = appsOf(ctx, user);
2626 const byId = new Map(repos.map((r) => [r.id, r]));
2727 // A private app's runs are for those who may enter it (`mayReadRuns`).
src/actions/forge_view.ts added +83
@@ -0,0 +1,83 @@
1 /**
2 * The forge as one read-only picture, for a reader that draws it (foundry,
3 * a wall, a second monitor): the apps the caller may see, the crew's runs and
4 * the station each is at, and the health numbers. It is composed of the
5 * reads the board and home already use (`appsOf`, `crewNow`, `readHealth`), so
6 * its numbers are theirs; it keeps no state of its own.
7 *
8 * An always-on view must not add load: the answer is built at most once per
9 * `VIEW_TTL_MS` per reader, and a poll that holds its ETag costs a header.
10 */
11 import type { App } from "../app.ts";
12 import type { User } from "../core/users.ts";
13 import type { Ctx } from "../http/context.ts";
14 import { appsOf } from "../web/studio/asks.ts";
15 import { crewNow } from "./crew_now.ts";
16 import { readHealth } from "./health.ts";
17
18 export const VIEW_TTL_MS = 2000;
19 /** Most apps listed; `counts.apps` stays the true number. */
20 export const VIEW_APPS = 200;
21
22 type Row = ReturnType<typeof crewNow>[number];
23
24 export interface ForgeView {
25 readonly counts: { apps: number; running: number; queued: number; waiting: number };
26 readonly apps: readonly { app: string; slug: string; access: string; running: number; queued: number }[];
27 readonly runs: readonly Row[];
28 readonly health: unknown;
29 }
30
31 interface Kept {
32 at: number;
33 text: string;
34 }
35
36 const kept = new WeakMap<App, Map<string, Kept>>();
37 let builds = 0;
38
39 /** How many times a view was built rather than reused (the cost test reads it). */
40 export const viewBuilds = (): number => builds;
41
42 export function forgeView(ctx: Pick<Ctx, "app">, user: User): ForgeView {
43 builds++;
44 const { repos, records } = appsOf(ctx, user);
45 const runs = crewNow(ctx, user);
46 const tally = new Map<string, { running: number; queued: number }>();
47 for (const r of runs) {
48 if (r.run === null) continue;
49 const t = tally.get(r.app) ?? { running: 0, queued: 0 };
50 if (r.state === "running") t.running++;
51 else t.queued++;
52 tally.set(r.app, t);
53 }
54 const apps = repos.slice(0, VIEW_APPS).map((repo) => {
55 const name = `${repo.ownerName}/${repo.name}`;
56 const record = records.get(repo.id)!;
57 const t = tally.get(name) ?? { running: 0, queued: 0 };
58 return { app: name, slug: record.slug, access: record.access, ...t };
59 });
60 return {
61 counts: {
62 apps: repos.length,
63 running: runs.filter((r) => r.run !== null && r.state === "running").length,
64 queued: runs.filter((r) => r.run !== null && r.state !== "running").length,
65 waiting: runs.filter((r) => r.run === null).length,
66 },
67 apps,
68 runs,
69 health: readHealth(ctx.app, user),
70 };
71 }
72
73 /** The view as JSON text for this reader, reused for `VIEW_TTL_MS`. */
74 export function forgeViewText(ctx: Pick<Ctx, "app">, user: User, now = Date.now()): string {
75 let mine = kept.get(ctx.app);
76 if (mine === undefined) kept.set(ctx.app, (mine = new Map()));
77 const held = mine.get(user.id);
78 if (held !== undefined && now - held.at < VIEW_TTL_MS) return held.text;
79 const text = JSON.stringify({ ...forgeView(ctx, user), at: new Date(now).toISOString() });
80 if (mine.size > 200) mine.clear();
81 mine.set(user.id, { at: now, text });
82 return text;
83 }
src/actions/foundry_door.ts added +57
@@ -0,0 +1,57 @@
1 /**
2 * Where foundry is, when it is installed here (#8955): the open address of
3 * the app whose slug is `foundry`, for a signed-in reader who may enter it.
4 * Foundry is an app, not forge code: nothing depends on it, and when it is
5 * not installed (or not the reader's to see) there is no door at all.
6 */
7 import { getUserById } from "../core/users.ts";
8 import { getAppBySlug } from "../core/apps.ts";
9 import { canRead, enterableApps, getRepoById } from "../core/repos.ts";
10 import type { App } from "../app.ts";
11 import type { User } from "../core/users.ts";
12
13 export const FOUNDRY_SLUG = "foundry";
14
15 /** The forge's own org: its apps are as trusted as an admin's. */
16 export const FOUNDRY_ORG = "trevato";
17
outdated

This is called on every page render (palette) and on home. Return early cheaply when no app has the slug, and cache the answer briefly. Add a test for the trevato-org owner branch.

18 /** How long a reader's answer is reused: the door moves when an app is installed, not per page. */
19 export const DOOR_TTL_MS = 5000;
20
21 const kept = new WeakMap<App, Map<string, { at: number; href: string | undefined }>>();
22
23 /** Forget the held answers (a test that installs or removes the app). */
24 export function forgetFoundryDoor(app: App): void {
25 kept.delete(app);
26 }
27
28 /**
29 * Foundry's open address for `user`, or undefined. The common forge has no
30 * app with the slug, so that one indexed read comes first and ends the work;
31 * the rest is held for `DOOR_TTL_MS` per reader.
32 */
33 export function foundryHref(app: App, user: User | undefined, now = Date.now()): string | undefined {
34 if (user === undefined) return undefined;
35 let mine = kept.get(app);
36 if (mine === undefined) kept.set(app, (mine = new Map()));
37 const held = mine.get(user.id);
38 if (held !== undefined && now - held.at < DOOR_TTL_MS) return held.href;
39 const href = resolve(app, user);
40 if (mine.size > 200) mine.clear();
41 mine.set(user.id, { at: now, href });
42 return href;
43 }
44
45 function resolve(app: App, user: User): string | undefined {
46 const db = app.db;
47 const record = getAppBySlug(db, FOUNDRY_SLUG);
48 if (record === undefined) return undefined;
49 const repo = getRepoById(db, record.repoId);
50 if (repo === undefined) return undefined;
51 // The slug is first come, first served: only an app an admin or the
52 // forge's own org owns is Foundry, or the link would be anyone's to plant.
53 if (repo.ownerName.toLowerCase() !== FOUNDRY_ORG && getUserById(db, repo.ownerId)?.isAdmin !== true) return undefined;
54 if (!canRead(db, repo, user)) return undefined;
55 if (!enterableApps(db, [{ repo, app: record }], user, { named: false }).has(record.id)) return undefined;
56 return `/${repo.ownerName}/${repo.name}/app/production/open`;
57 }
src/contract/openapi/routes/getApiV1ForgeView.ts added +9
@@ -0,0 +1,9 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/forge/view",
6 summary: "The forge as one read-only picture for a reader that draws it: { counts: { apps, running, queued, waiting }, apps: [{ app, slug, access, running, queued }], runs: [the rows of /api/v1/crew/now; each run's state is its station], health: as /api/v1/health answers this caller, at } for the apps the caller may read. Any token scope reads it; it is built at most once per two seconds per caller and answers 304 to a held ETag, so polling it is cheap",
7 auth: true,
8 refusals: { unauthorized: "no valid credentials were sent" },
9 };
src/contract/openapi/routes/index.ts +1
@@ -71,6 +71,7 @@ export { route as getApiV1FleetGraph } from "./getApiV1FleetGraph.ts";
7171 export { route as getApiV1FleetOps } from "./getApiV1FleetOps.ts";
7272 export { route as getApiV1FleetOpsId } from "./getApiV1FleetOpsId.ts";
7373 export { route as getApiV1Forge } from "./getApiV1Forge.ts";
74 export { route as getApiV1ForgeView } from "./getApiV1ForgeView.ts";
7475 export { route as getApiV1Forges } from "./getApiV1Forges.ts";
7576 export { route as getApiV1ForgesSizes } from "./getApiV1ForgesSizes.ts";
7677 export { route as getApiV1ForgesTemplates } from "./getApiV1ForgesTemplates.ts";
src/http/api.ts +2
@@ -335,6 +335,7 @@ import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
335335 import { passportRoutes } from "./passport_edge.ts";
336336 import { egressGrantRoutes } from "./api/egress_grants.ts";
337337 import { forgeRoutes } from "./api/forges.ts";
338 import { forgeViewRoutes } from "./api/forge_view.ts";
338339 import { releasesRoutes } from "./api/releases.ts";
339340 import { hookRoutes } from "./api/hooks.ts";
340341 import { keeperRoutes } from "./api/keeper.ts";
@@ -3437,6 +3438,7 @@ export function apiRoutes(router: Router<Handler>): void {
34373438 });
34383439
34393440 forgeRoutes(router);
3441 forgeViewRoutes(router);
34403442 releasesRoutes(router);
34413443
34423444 router.add("GET", "/api/v1/apps", (ctx) => {
src/http/api/forge_view.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * GET /api/v1/forge/view: the forge as one read-only picture (apps, runs and
3 * their stations, health), for a reader that draws it. Any scope reads it;
4 * there is no write on this path. See docs/features/forge-view.md.
5 */
6 import { forgeViewText } from "../../actions/forge_view.ts";
7 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
8 import type { Handler } from "../context.ts";
9 import { holds, weakEtag } from "../etag.ts";
10 import { requireScope } from "../respond.ts";
11 import type { Router } from "../router.ts";
12
13 export function forgeViewRoutes(router: Router<Handler>): void {
14 router.add("GET", "/api/v1/forge/view", (ctx) => {
15 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
16 const denied = requireScope(ctx, "ro");
17 if (denied) return denied;
18 const user = ctx.auth.user!;
19 const text = timePhase(ctx.request, "view", () => forgeViewText(ctx, user));
20 // The tag leaves out the moment it was built, so an unchanged forge is a 304.
21 const etag = weakEtag(text.replace(/,"at":"[^"]*"\}$/, "}"));
22 const headers = { etag, "cache-control": "private, max-age=2", vary: "authorization, cookie" };
23 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
24 return new Response(text, { headers: { "content-type": "application/json", ...headers } });
25 });
26 }
src/tui/screens/entries.ts +2
@@ -23,6 +23,7 @@ import { entry as crewSplits } from "./crewsplits.ts";
2323 import { entry as domains } from "./domains.ts";
2424 import { entry as edgeOnly } from "./edgeonly.ts";
2525 import { entry as feed } from "./feed.ts";
26 import { entry as forgeView } from "./forgeview.ts";
2627 import { entry as front } from "./front.ts";
2728 import { entry as forges } from "./forges.ts";
2829 import { entry as homePins } from "./home_pins.ts";
@@ -109,6 +110,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
109110 originAhead,
110111 profile,
111112 now,
113 forgeView,
112114 repos,
113115 newIssue,
114116 ];
src/tui/screens/forgeview.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * The forge view (`GET /api/v1/forge/view`) as a screen: the apps, running,
3 * queued and waiting counts it carries, the first apps and runs, and the Foundry address when the
4 * reader has a door to it (`foundryHref`). Enter opens Foundry on the web.
5 * Signed-in readers only; the numbers are the view's own, read once when the
6 * screen opens (not per frame or key), so it is a picture, not a live board.
7 */
8
9 import { foundryHref } from "../../actions/foundry_door.ts";
10 import { forgeView } from "../../actions/forge_view.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import { style } from "../term/style.ts";
14 import { truncate } from "../term/width.ts";
15 import type { Session } from "../session.ts";
16 import { openOnWeb } from "./common.ts";
17 import type { PaletteEntry } from "./entries.ts";
18
19 /** Apps and runs the screen lists below the counts; the rest is `counts`. */
20 const SHOWN = 12;
21
22 /** What the screen shows for this session, read once: when it opens. */
23 export function forgeViewLines(f: Session): { lines: string[]; foundry: string | undefined } {
outdated

A cast to a fake http Ctx is fragile. Take what you need explicitly, and do not rebuild the view per frame (render and key both call this).

24 const user = f.user;
25 if (user === undefined) return { lines: ["Sign in to see the forge."], foundry: undefined };
26 const { counts, apps, runs } = forgeView({ app: f.app }, user);
27 const foundry = foundryHref(f.app, user);
28 const working = runs.filter((r) => r.run !== null);
29 return {
30 foundry,
31 lines: [
32 `${counts.apps} apps · ${counts.running} running · ${counts.queued} queued · ${counts.waiting} waiting for a person`,
33 foundry === undefined ? "Foundry is not installed here." : `Foundry: ${foundry} (enter opens it)`,
34 "",
35 ...(working.length === 0 ? ["No runs right now."] : ["Runs:"]),
36 ...working.slice(0, SHOWN).map((r) => ` ${r.app} ${r.state} ${r.who}: ${r.on}`),
37 "",
38 "Apps:",
39 ...apps.slice(0, SHOWN).map((a) => ` ${a.app} ${a.access}${a.running + a.queued > 0 ? ` ${a.running} running, ${a.queued} queued` : ""}`),
40 ...(counts.apps > SHOWN ? [` … and ${counts.apps - SHOWN} more`] : []),
41 ],
42 };
43 }
44
45 export function forgeViewScreen(f: Session, ctx: Ctx): Screen {
46 const { lines, foundry } = forgeViewLines(f);
47 return {
48 title: "Forge view",
49 keys: [binding(["enter"], "open Foundry on the web")],
50 render(area, s) {
51 const c = ctx.theme.c;
52 lines.forEach((line, i) => {
53 if (i < area.h) s.text(area.x, area.y + i, truncate(line, area.w), style(c.mutedFg, c.bg), area.x + area.w);
54 });
55 },
56 key(e) {
57 if (e.ctrl || e.alt || e.key !== "enter") return false;
58 if (foundry === undefined) return false;
59 openOnWeb(f, ctx, foundry, "Foundry");
60 return true;
61 },
62 };
63 }
64
65 export const entry: PaletteEntry = {
66 id: "forge-view",
67 title: "Forge view",
68 hint: "the forge's counts, and Foundry when it is installed",
69 after: ["now"],
70 offered: (session) => session.user !== undefined,
71 run: (session, ctx) => ctx.push(forgeViewScreen(session, ctx)),
72 };
src/web/layout.ts +3
@@ -13,6 +13,7 @@ import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch
1313 import { protectionNotice } from "../kernel/protection.ts";
1414 import { administersAny } from "../core/admin_repos.ts";
1515 import { appsForRepos } from "../core/apps.ts";
16 import { foundryHref } from "../actions/foundry_door.ts";
1617 import { appsRuntime } from "../apps/runtime.ts";
1718 import {
1819 enterableApps,
@@ -423,6 +424,7 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html {
423424 return app !== undefined && enterable.has(app.id) ? [app] : [];
424425 });
425426 const states = open.length > 0 ? appsRuntime(ctx.app)?.statusMany(open) : undefined;
427 const foundry = foundryHref(ctx.app, ctx.auth.user);
426428 const items: PaletteItem[] = [
427429 ...extra,
428430 ...repos.map((r): PaletteItem => {
@@ -460,6 +462,7 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html {
460462 ...(sendsForges(ctx)
461463 ? [{ t: "New forge", h: "/forges/new", c: "Create" }]
462464 : []),
465 ...(foundry === undefined ? [] : [{ t: "Foundry", h: foundry, c: "Go to" }]),
463466 ...(ctx.auth.user?.isAdmin
464467 ? [
465468 { t: "System", h: "/system", c: "Go to" },
src/web/studio/asks.ts +2 −2
@@ -68,7 +68,7 @@ import { NO_LONGER_ASKS } from "../../actions/asks_words.ts";
6868 /** Repos narrowed to those with an app record, with the records — the one
6969 * join `appsOf` and `writableAppsOf` share. */
7070 function appsFiltered(
71 ctx: Ctx,
71 ctx: Pick<Ctx, "app">,
7272 repos: readonly Repo[],
7373 ): { repos: Repo[]; records: ReturnType<typeof appsForRepos> } {
7474 const records = appsForRepos(
@@ -83,7 +83,7 @@ function appsFiltered(
8383 * Everyone's public apps sit in this list beside the viewer's own: right
8484 * for search, the palette and the asks count, wrong for a shelf headed
8585 * "your apps" — that reading is `writableAppsOf`. */
86 export function appsOf(ctx: Ctx, user: User | undefined, archived = false) {
86 export function appsOf(ctx: Pick<Ctx, "app">, user: User | undefined, archived = false) {
8787 return appsFiltered(
8888 ctx,
8989 listReposVisibleTo(
src/web/studio/home.ts +3
@@ -6,6 +6,7 @@
66 * best of what is public (`actions/front.ts`).
77 */
88
9 import { foundryHref } from "../../actions/foundry_door.ts";
910 import { forgeCeiling } from "../../flow/ceiling.ts";
1011 import { liveLine } from "../pages/releases.ts";
1112 import {
@@ -119,6 +120,7 @@ function yourApps(ctx: Ctx, user: User, apps = writableAppsOf(ctx, user)): Html
119120 });
120121 });
121122 const archived = archivedAppCount(ctx);
123 const foundry = foundryHref(ctx.app, ctx.auth.user);
122124 const list = html`${rows.length === 0
123125 ? html`<p class="muted small">No apps yet.</p>`
124126 : html`<ul class="list">
@@ -135,6 +137,7 @@ function yourApps(ctx: Ctx, user: User, apps = writableAppsOf(ctx, user)): Html
135137 ${btn("Map", { href: "/map", size: "sm", icon: "map" })}
136138 ${btn("Audit", { href: "/audit", size: "sm", icon: "shield" })}
137139 ${btn("Board", { href: "/board", size: "sm", icon: "activity" })}
140 ${foundry === undefined ? "" : btn("Foundry", { href: foundry, size: "sm", icon: "eye" })}
138141 ${when(user.isAdmin, () =>
139142 btn("Front page", { href: "/front", size: "sm", icon: "globe" }),
140143 )}
test/api-forge-view.test.ts added +229
@@ -0,0 +1,229 @@
1 /**
2 * The forge's read-only view (#8955): GET /api/v1/forge/view gives apps, runs
3 * and health to any token, writes nothing, matches the reads home and the
4 * board use, and costs one build per window however often it is polled.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { foundryHref, forgetFoundryDoor } from "../src/actions/foundry_door.ts";
12 import { forgeView, VIEW_APPS, viewBuilds } from "../src/actions/forge_view.ts";
13 import { createApp as createAppRecord } from "../src/core/apps.ts";
14 import { createRun } from "../src/core/runs.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { createSession } from "../src/core/sessions.ts";
17 import { forgeViewLines, forgeViewScreen } from "../src/tui/screens/forgeview.ts";
18 import { createRepo } from "../src/actions/repos.ts";
19 import { createOrg } from "../src/core/orgs.ts";
20 import { createRepo as createRepoRow } from "../src/core/repos.ts";
21 import { createToken } from "../src/core/tokens.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import { seedAdmin } from "./helpers/admin.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25 import { writableAppsOf } from "../src/web/studio/asks.ts";
26 import { listActiveRuns } from "../src/core/runs.ts";
27 import type { Ctx } from "../src/http/context.ts";
28
29 quietLogs();
30
31 const APPS = 120;
32
33 describe("the forge view over the API", () => {
34 let dataDir: string;
35 let app: App;
36 let servers: Servers;
37 let ro: string;
38 let admin: Awaited<ReturnType<typeof seedAdmin>>;
39 const get = (token?: string, headers: Record<string, string> = {}) =>
40 fetch(`${servers.url}/api/v1/forge/view`, {
41 headers: { ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), ...headers },
42 });
43
44 beforeAll(async () => {
45 dataDir = await mkdtemp(join(tmpdir(), "forge-view-"));
46 app = createApp({ dataDir, port: 0 });
47 servers = await serve(app);
48 admin = await seedAdmin(app, { username: "ada", email: "[email protected]" });
49 if (!admin.ok) throw new Error(admin.error);
50 for (let i = 0; i < APPS; i++) {
51 const repo = await createRepo(app, admin.value, { name: `app${i}`, visibility: "public" });
52 if (!repo.ok) throw new Error(String(repo.error));
53 createAppRecord(app.db, { repoId: repo.value.id, slug: `app${i}`, access: "public" });
54 for (let n = 0; n < 8; n++)
55 createRun(app.db, { repoId: repo.value.id, kind: "agent", reason: "work" });
56 }
57 ro = createToken(app.db, admin.value.id, "t", "ro").token;
58 });
59 afterAll(async () => {
60 await servers.stop();
61 await rm(dataDir, { recursive: true, force: true });
62 });
63
64 test("no credentials is a 401", async () => {
65 expect((await get()).status).toBe(401);
66 });
67
68 test("a read-only token reads apps, runs and health, matching home and the board's sources", async () => {
69 const res = await get(ro);
70 expect(res.status).toBe(200);
71 const view = (await res.json()) as any;
72 const ctx = { app, auth: { user: admin.ok ? admin.value : undefined } } as unknown as Ctx;
73 const mine = writableAppsOf(ctx, admin.ok ? admin.value : undefined).repos;
74 expect(view.counts.apps).toBe(APPS);
75 expect(view.apps.length).toBe(mine.length);
76 // The runs are the same bounded list crew/now and the board read.
77 const active = listActiveRuns(app.db, mine.map((r) => r.id));
78 expect(view.counts.queued + view.counts.running).toBe(active.length);
79 expect(view.runs.length).toBeLessThanOrEqual(50 + view.counts.waiting);
80 expect(view.health.ok).toBe(true);
81 const now = (await (await fetch(`${servers.url}/api/v1/crew/now`, { headers: { authorization: `Bearer ${ro}` } })).json()) as any;
82 expect(view.runs).toEqual(now.items);
83 });
84
85 test("polling costs one build per window and a held ETag is a 304", async () => {
86 await get(ro);
87 const before = viewBuilds();
88 const selects: string[] = [];
89 const original = app.db.query.bind(app.db);
90 (app.db as unknown as { query: unknown }).query = (sql: string) => {
91 selects.push(sql);
92 return original(sql);
93 };
94 try {
95 const first = await get(ro);
96 const etag = first.headers.get("etag")!;
97 for (let i = 0; i < 20; i++) expect((await get(ro)).status).toBe(200);
98 expect((await get(ro, { "if-none-match": etag })).status).toBe(304);
99 } finally {
100 (app.db as unknown as { query: unknown }).query = original;
101 }
102 expect(viewBuilds() - before).toBeLessThanOrEqual(1);
103 // Reads of the view itself stay few, however many apps and runs: the
104 // statements are per-request constants, not per app (the token lookup
105 // and session reads are all that remain on a cached answer).
106 expect(selects.filter((s) => /FROM run\b/.test(s)).length).toBeLessThanOrEqual(1);
107 expect(selects.length).toBeLessThan(80);
108 });
109
110 test("a ro token can't write through it", async () => {
111 for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
112 const res = await fetch(`${servers.url}/api/v1/forge/view`, {
113 method,
114 headers: { authorization: `Bearer ${ro}`, "content-type": "application/json" },
115 body: method === "DELETE" ? null : "{}",
116 });
117 expect(res.status).toBeGreaterThanOrEqual(400);
118 expect([404, 405]).toContain(res.status);
119 }
120 const before = (await (await get(ro)).json() as any).counts.apps;
121 const made = await fetch(`${servers.url}/api/v1/apps`, {
122 method: "POST",
123 headers: { authorization: `Bearer ${ro}`, "content-type": "application/json" },
124 body: JSON.stringify({ repo: "ada/app0", slug: "sneaky" }),
125 });
126 expect(made.status).toBe(403);
127 expect(before).toBe(APPS);
128 });
129
130 test("a cold build over the fixture is a constant number of reads, with bounded lists", () => {
131 const ctx = { app, auth: { user: admin.ok ? admin.value : undefined } } as unknown as Ctx;
132 const selects: string[] = [];
133 const original = app.db.query.bind(app.db);
134 (app.db as unknown as { query: unknown }).query = (sql: string) => {
135 selects.push(sql);
136 return original(sql);
137 };
138 let view;
139 try {
140 view = forgeView(ctx, admin.ok ? admin.value : (undefined as never));
141 } finally {
142 (app.db as unknown as { query: unknown }).query = original;
143 }
144 expect(view.counts.apps).toBe(APPS);
145 expect(view.apps.length).toBeLessThanOrEqual(VIEW_APPS);
146 expect(view.runs.filter((r) => r.run !== null).length).toBeLessThanOrEqual(50);
147 // 120 apps, 960 runs. `mayReadRuns` (shared with /crew/now) reads two
148 // rows per app today; anything beyond that, or a read per run, fails.
149 expect(selects.length).toBeLessThanOrEqual(2 * APPS + 20);
150 });
151
152 test("foundry has a door only for an app an admin or the forge's org owns", async () => {
153 const adminUser = admin.ok ? admin.value : (undefined as never);
154 const door = () => foundryHref(app, adminUser);
155 const lines = () => forgeViewLines({ app, user: adminUser } as never);
156 expect(door()).toBeUndefined();
157 // A non-admin who names an app `foundry` first plants no door.
158 const mallory = await createUser(app.db, { username: "mallory", email: "[email protected]", password: "a-long-passphrase-here", origin: "seed", trusted: true });
159 if (!mallory.ok) throw new Error(mallory.error);
160 const fake = await createRepo(app, adminUser, { name: "foundry", visibility: "public" });
161 if (!fake.ok) throw new Error(String(fake.error));
162 app.db.query("UPDATE repo SET owner_id = ?1 WHERE id = ?2").run(mallory.value.id, fake.value.id);
163 createAppRecord(app.db, { repoId: fake.value.id, slug: "foundry", access: "public" });
164 forgetFoundryDoor(app);
165 expect(door()).toBeUndefined();
166 expect(await homeAndPalette(adminUser.id)).toEqual({ button: false, row: false });
167 expect(lines().lines[1]).toContain("not installed");
168 app.db.query("DELETE FROM app WHERE repo_id = ?1").run(fake.value.id);
169 // The admin's own is Foundry.
170 const repo = await createRepo(app, adminUser, { name: "foundry", visibility: "public" });
171 if (!repo.ok) throw new Error(String(repo.error));
172 createAppRecord(app.db, { repoId: repo.value.id, slug: "foundry", access: "public" });
173 forgetFoundryDoor(app);
174 expect(door()).toBe("/ada/foundry/app/production/open");
175 expect(foundryHref(app, undefined)).toBeUndefined();
176 expect(await homeAndPalette(adminUser.id)).toEqual({ button: true, row: true });
177 expect(lines().lines[1]).toContain("/ada/foundry/app/production/open");
178 });
179
180 test("an app of the trevato org is Foundry for a reader who is no admin", async () => {
181 // Move the admin's Foundry away; the org's takes the slug.
182 app.db.query("DELETE FROM app WHERE slug = 'foundry'").run();
183 const org = createOrg(app.db, "trevato", admin.ok ? admin.value.id : "");
184 if (!org.ok) throw new Error(String(org.error));
185 const made = await createRepoRow(app.db, app.reposRoot, { ownerId: org.value.id, name: "foundry", visibility: "public" });
186 if (!made.ok) throw new Error(String(made.error));
187 createAppRecord(app.db, { repoId: made.value.id, slug: "foundry", access: "public" });
188 const reader = await createUser(app.db, { username: "reader", email: "[email protected]", password: "a-long-passphrase-here", origin: "seed", trusted: true });
189 if (!reader.ok) throw new Error(reader.error);
190 forgetFoundryDoor(app);
191 expect(foundryHref(app, reader.value)).toBe("/trevato/foundry/app/production/open");
192 // The answer is held, so the next page render costs no read.
193 const seen: string[] = [];
194 const original = app.db.query.bind(app.db);
195 (app.db as unknown as { query: unknown }).query = (sql: string) => (seen.push(sql), original(sql));
196 try {
197 expect(foundryHref(app, reader.value)).toBe("/trevato/foundry/app/production/open");
198 } finally {
199 (app.db as unknown as { query: unknown }).query = original;
200 }
201 expect(seen).toEqual([]);
202 // The terminal's Enter opens it; with no door it does nothing.
203 const opened: string[] = [];
204 const ctxT = { toast: (m: string) => opened.push(m), theme: { c: {} } } as never;
205 const session = { app, user: reader.value } as never;
206 const screen = forgeViewScreen(session, ctxT);
207 expect(screen.key!({ key: "enter" } as never)).toBe(true);
208 expect(opened.join()).toContain("/trevato/foundry/app/production/open");
209 app.db.query("DELETE FROM app WHERE slug = 'foundry'").run();
210 forgetFoundryDoor(app);
211 const none = forgeViewScreen(session, ctxT);
212 opened.length = 0;
213 expect(none.key!({ key: "enter" } as never)).toBe(false);
214 expect(opened).toEqual([]);
215 });
216
217 /** Whether `/` shows the Foundry button and the palette index the row, for a user. */
218 async function homeAndPalette(userId: string) {
219 const cookie = `forge_session=${createSession(app.db, userId).token}`;
220 const html = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
221 // Home's apps card is a live region with its own address.
222 const card = await (await fetch(`${servers.url}/live/apps`, { headers: { cookie } })).text();
223 const index = /id="palette-index">([\s\S]*?)<\/script>/.exec(html)?.[1] ?? "";
224 return {
225 button: /href="\/ada\/foundry\/app\/production\/open"[^>]*>\s*(<[^>]*>\s*)*Foundry/.test(card),
226 row: index.includes('{"t":"Foundry","h":"/ada/foundry/app/production/open","c":"Go to"}'),
227 };
228 }
229 });
test/palette-roster.test.ts +3
@@ -83,6 +83,7 @@ test("a site-admin person is offered the whole roster", () => {
8383 ["bugs", "Bugs", "open bugs and the bug clock"],
8484 ["home-pins", "Pinned panels", "the panels pinned to home: reorder and unpin"],
8585 ["now", "Now", "what check, review, land and live count, with the rows"],
86 ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"],
8687 ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"],
8788 ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"],
8889 ["flow", "Flow", "the pipeline's bottleneck and throughput"],
@@ -194,6 +195,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
194195 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
195196 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
196197 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
198 ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"],
197199 ["family", "Family", "the forge that sent this one, and the forges it sent"],
198200 ["chain", "Chain", "the forges this forge's code descends from, root first"],
199201 ["quit", "Quit", "disconnect"],
@@ -258,6 +260,7 @@ test("a member person is offered the signed-in roster without the admin entries"
258260 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
259261 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
260262 ["profile", "Your profile", "display name and email"],
263 ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"],
261264 ["family", "Family", "the forge that sent this one, and the forges it sent"],
262265 ["chain", "Chain", "the forges this forge's code descends from, root first"],
263266 ["quit", "Quit", "disconnect"],
test/tui-backups.test.ts +2 −1
@@ -17,7 +17,7 @@ import { createForgeApp } from "../src/tui/forge.ts";
1717 import type { TuiIdentity } from "../src/tui/session.ts";
1818 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1919 import { quietLogs } from "./helpers/globals.ts";
20 import { settleApp } from "./helpers/tui.ts";
20 import { settleApp, settlePalette } from "./helpers/tui.ts";
2121
2222 quietLogs();
2323
@@ -83,6 +83,7 @@ describe("the snapshot destination in the terminal", () => {
8383 key("k", true);
8484 await settleApp(app);
8585 await type("Resilience");
86 await settlePalette(app);
8687 return press("enter");
8788 };
8889 return { text: frame, press, type, open, stop: () => app.stop() };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs