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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8955-foundry-reads-more-of-the-forge-through → main 12 commits fork 2 threads · 2 unresolved
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"; | ||
| 21 | 21 | import type { User } from "../core/users.ts"; |
| 22 | 22 | |
| 23 | 23 | /** 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) { | |
| 25 | 25 | const { repos } = appsOf(ctx, user); |
| 26 | 26 | const byId = new Map(repos.map((r) => [r.id, r])); |
| 27 | 27 | // 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 | ||
| 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"; | ||
| 71 | 71 | export { route as getApiV1FleetOps } from "./getApiV1FleetOps.ts"; |
| 72 | 72 | export { route as getApiV1FleetOpsId } from "./getApiV1FleetOpsId.ts"; |
| 73 | 73 | export { route as getApiV1Forge } from "./getApiV1Forge.ts"; |
| 74 | export { route as getApiV1ForgeView } from "./getApiV1ForgeView.ts"; | |
| 74 | 75 | export { route as getApiV1Forges } from "./getApiV1Forges.ts"; |
| 75 | 76 | export { route as getApiV1ForgesSizes } from "./getApiV1ForgesSizes.ts"; |
| 76 | 77 | 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"; | ||
| 335 | 335 | import { passportRoutes } from "./passport_edge.ts"; |
| 336 | 336 | import { egressGrantRoutes } from "./api/egress_grants.ts"; |
| 337 | 337 | import { forgeRoutes } from "./api/forges.ts"; |
| 338 | import { forgeViewRoutes } from "./api/forge_view.ts"; | |
| 338 | 339 | import { releasesRoutes } from "./api/releases.ts"; |
| 339 | 340 | import { hookRoutes } from "./api/hooks.ts"; |
| 340 | 341 | import { keeperRoutes } from "./api/keeper.ts"; |
| @@ -3437,6 +3438,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3437 | 3438 | }); |
| 3438 | 3439 | |
| 3439 | 3440 | forgeRoutes(router); |
| 3441 | forgeViewRoutes(router); | |
| 3440 | 3442 | releasesRoutes(router); |
| 3441 | 3443 | |
| 3442 | 3444 | 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"; | ||
| 23 | 23 | import { entry as domains } from "./domains.ts"; |
| 24 | 24 | import { entry as edgeOnly } from "./edgeonly.ts"; |
| 25 | 25 | import { entry as feed } from "./feed.ts"; |
| 26 | import { entry as forgeView } from "./forgeview.ts"; | |
| 26 | 27 | import { entry as front } from "./front.ts"; |
| 27 | 28 | import { entry as forges } from "./forges.ts"; |
| 28 | 29 | import { entry as homePins } from "./home_pins.ts"; |
| @@ -109,6 +110,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 109 | 110 | originAhead, |
| 110 | 111 | profile, |
| 111 | 112 | now, |
| 113 | forgeView, | |
| 112 | 114 | repos, |
| 113 | 115 | newIssue, |
| 114 | 116 | ]; |
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 | ||
| 13 | 13 | import { protectionNotice } from "../kernel/protection.ts"; |
| 14 | 14 | import { administersAny } from "../core/admin_repos.ts"; |
| 15 | 15 | import { appsForRepos } from "../core/apps.ts"; |
| 16 | import { foundryHref } from "../actions/foundry_door.ts"; | |
| 16 | 17 | import { appsRuntime } from "../apps/runtime.ts"; |
| 17 | 18 | import { |
| 18 | 19 | enterableApps, |
| @@ -423,6 +424,7 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html { | ||
| 423 | 424 | return app !== undefined && enterable.has(app.id) ? [app] : []; |
| 424 | 425 | }); |
| 425 | 426 | const states = open.length > 0 ? appsRuntime(ctx.app)?.statusMany(open) : undefined; |
| 427 | const foundry = foundryHref(ctx.app, ctx.auth.user); | |
| 426 | 428 | const items: PaletteItem[] = [ |
| 427 | 429 | ...extra, |
| 428 | 430 | ...repos.map((r): PaletteItem => { |
| @@ -460,6 +462,7 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html { | ||
| 460 | 462 | ...(sendsForges(ctx) |
| 461 | 463 | ? [{ t: "New forge", h: "/forges/new", c: "Create" }] |
| 462 | 464 | : []), |
| 465 | ...(foundry === undefined ? [] : [{ t: "Foundry", h: foundry, c: "Go to" }]), | |
| 463 | 466 | ...(ctx.auth.user?.isAdmin |
| 464 | 467 | ? [ |
| 465 | 468 | { 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"; | ||
| 68 | 68 | /** Repos narrowed to those with an app record, with the records — the one |
| 69 | 69 | * join `appsOf` and `writableAppsOf` share. */ |
| 70 | 70 | function appsFiltered( |
| 71 | ctx: Ctx, | |
| 71 | ctx: Pick<Ctx, "app">, | |
| 72 | 72 | repos: readonly Repo[], |
| 73 | 73 | ): { repos: Repo[]; records: ReturnType<typeof appsForRepos> } { |
| 74 | 74 | const records = appsForRepos( |
| @@ -83,7 +83,7 @@ function appsFiltered( | ||
| 83 | 83 | * Everyone's public apps sit in this list beside the viewer's own: right |
| 84 | 84 | * for search, the palette and the asks count, wrong for a shelf headed |
| 85 | 85 | * "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) { | |
| 87 | 87 | return appsFiltered( |
| 88 | 88 | ctx, |
| 89 | 89 | listReposVisibleTo( |
src/web/studio/home.ts +3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * best of what is public (`actions/front.ts`). |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { foundryHref } from "../../actions/foundry_door.ts"; | |
| 9 | 10 | import { forgeCeiling } from "../../flow/ceiling.ts"; |
| 10 | 11 | import { liveLine } from "../pages/releases.ts"; |
| 11 | 12 | import { |
| @@ -119,6 +120,7 @@ function yourApps(ctx: Ctx, user: User, apps = writableAppsOf(ctx, user)): Html | ||
| 119 | 120 | }); |
| 120 | 121 | }); |
| 121 | 122 | const archived = archivedAppCount(ctx); |
| 123 | const foundry = foundryHref(ctx.app, ctx.auth.user); | |
| 122 | 124 | const list = html`${rows.length === 0 |
| 123 | 125 | ? html`<p class="muted small">No apps yet.</p>` |
| 124 | 126 | : html`<ul class="list"> |
| @@ -135,6 +137,7 @@ function yourApps(ctx: Ctx, user: User, apps = writableAppsOf(ctx, user)): Html | ||
| 135 | 137 | ${btn("Map", { href: "/map", size: "sm", icon: "map" })} |
| 136 | 138 | ${btn("Audit", { href: "/audit", size: "sm", icon: "shield" })} |
| 137 | 139 | ${btn("Board", { href: "/board", size: "sm", icon: "activity" })} |
| 140 | ${foundry === undefined ? "" : btn("Foundry", { href: foundry, size: "sm", icon: "eye" })} | |
| 138 | 141 | ${when(user.isAdmin, () => |
| 139 | 142 | btn("Front page", { href: "/front", size: "sm", icon: "globe" }), |
| 140 | 143 | )} |
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", () => { | ||
| 83 | 83 | ["bugs", "Bugs", "open bugs and the bug clock"], |
| 84 | 84 | ["home-pins", "Pinned panels", "the panels pinned to home: reorder and unpin"], |
| 85 | 85 | ["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"], | |
| 86 | 87 | ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"], |
| 87 | 88 | ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"], |
| 88 | 89 | ["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", | ||
| 194 | 195 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 195 | 196 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 196 | 197 | ["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"], | |
| 197 | 199 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 198 | 200 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| 199 | 201 | ["quit", "Quit", "disconnect"], |
| @@ -258,6 +260,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 258 | 260 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 259 | 261 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], |
| 260 | 262 | ["profile", "Your profile", "display name and email"], |
| 263 | ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"], | |
| 261 | 264 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 262 | 265 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| 263 | 266 | ["quit", "Quit", "disconnect"], |
test/tui-backups.test.ts +2 −1
| @@ -17,7 +17,7 @@ import { createForgeApp } from "../src/tui/forge.ts"; | ||
| 17 | 17 | import type { TuiIdentity } from "../src/tui/session.ts"; |
| 18 | 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 19 | 19 | import { quietLogs } from "./helpers/globals.ts"; |
| 20 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | import { settleApp, settlePalette } from "./helpers/tui.ts"; | |
| 21 | 21 | |
| 22 | 22 | quietLogs(); |
| 23 | 23 | |
| @@ -83,6 +83,7 @@ describe("the snapshot destination in the terminal", () => { | ||
| 83 | 83 | key("k", true); |
| 84 | 84 | await settleApp(app); |
| 85 | 85 | await type("Resilience"); |
| 86 | await settlePalette(app); | |
| 86 | 87 | return press("enter"); |
| 87 | 88 | }; |
| 88 | 89 | return { text: frame, press, type, open, stop: () => app.stop() }; |
new above
offline
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.