Public: anyone can read this; the crew can change it.
An agent reads one slice of the OpenAPI description: operations carry tags and /contract/openapi.json?tag=pulls answers just those #11115
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11091-an-agent-reads-one-slice-of-the-openapi → main 2 commits fork
6 files
changed +230
−4
src/contract/openapi.ts +3
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | |
| 14 | 14 | import type { QueryType, Route } from "./openapi/shared.ts"; |
| 15 | 15 | import { OAUTH_SCOPES, type OAuthScope } from "../core/oauth.ts"; |
| 16 | import { tagList, tagOf } from "./tags.ts"; | |
| 16 | 17 | import * as each from "./openapi/routes/index.ts"; |
| 17 | 18 | |
| 18 | 19 | /** What each OAuth scope grants; a scope added to OAUTH_SCOPES without a line here fails the type check. */ |
| @@ -72,6 +73,7 @@ export function openapi(origin: string, version: string): Record<string, unknown | ||
| 72 | 73 | })); |
| 73 | 74 | (paths[r.path] ??= {})[r.method] = { |
| 74 | 75 | operationId: operationId(r), |
| 76 | tags: [tagOf(r.path)], | |
| 75 | 77 | summary: r.summary, |
| 76 | 78 | "x-role": r.role, |
| 77 | 79 | parameters: [...params, ...query], |
| @@ -131,6 +133,7 @@ export function openapi(origin: string, version: string): Record<string, unknown | ||
| 131 | 133 | "The routes the forge names to an agent. `not_found` means missing or not yours to see. Every 429 and 503 carries an integer `Retry-After` and `retryAfter` (the same seconds) in a JSON body.", |
| 132 | 134 | }, |
| 133 | 135 | servers: [{ url: origin }], |
| 136 | tags: tagList(ROUTES.map((r) => tagOf(r.path))), | |
| 134 | 137 | // The default: a bearer token. Open routes say `security: []`. |
| 135 | 138 | security: [{ token: [] }], |
| 136 | 139 | paths, |
src/contract/served.ts +36 −2
| @@ -8,9 +8,12 @@ import { holds, weakEtag } from "../http/etag.ts"; | ||
| 8 | 8 | import type { Handler } from "../http/context.ts"; |
| 9 | 9 | import type { Router } from "../http/router.ts"; |
| 10 | 10 | import { running } from "../kernel/version.ts"; |
| 11 | import { openapi } from "./openapi.ts"; | |
| 11 | import { openapi, ROUTES } from "./openapi.ts"; | |
| 12 | import { tagList, tagOf } from "./tags.ts"; | |
| 12 | 13 | |
| 13 | 14 | const MAX = 8; |
| 15 | /** The tags the document lists. */ | |
| 16 | export const TAGS: readonly string[] = tagList(ROUTES.map((r) => tagOf(r.path))).map((t) => t.name); | |
| 14 | 17 | const built = new Map<string, { text: string; etag: string }>(); |
| 15 | 18 | |
| 16 | 19 | /** |
| @@ -30,9 +33,40 @@ export function openapiText(origin: string, version: string): { text: string; et | ||
| 30 | 33 | return hit; |
| 31 | 34 | } |
| 32 | 35 | |
| 36 | /** | |
| 37 | * One tag's slice: the same document with only the paths (and operations) | |
| 38 | * carrying the tag, and the tag list cut to it. Info, servers, security and | |
| 39 | * components are unchanged. Built per (origin, version, tag); the tag has | |
| 40 | * been checked against the list, so the cache is bounded by it. | |
| 41 | */ | |
| 42 | export function openapiSliceText(origin: string, version: string, tag: string): { text: string; etag: string } { | |
| 43 | const key = `${version}\n${origin}\n${tag}`; | |
| 44 | let hit = built.get(key); | |
| 45 | if (!hit) { | |
| 46 | const doc = openapi(origin, version) as { paths: Record<string, Record<string, { tags: string[] }>>; tags: { name: string }[] }; | |
| 47 | const paths: typeof doc.paths = {}; | |
| 48 | for (const [path, ops] of Object.entries(doc.paths)) { | |
| 49 | const kept = Object.entries(ops).filter(([, op]) => op.tags.includes(tag)); | |
| 50 | if (kept.length > 0) paths[path] = Object.fromEntries(kept); | |
| 51 | } | |
| 52 | const text = JSON.stringify({ ...doc, tags: doc.tags.filter((t) => t.name === tag), paths }); | |
| 53 | hit = { text, etag: weakEtag(text) }; | |
| 54 | if (built.size >= MAX * 4) built.delete(built.keys().next().value as string); | |
| 55 | built.set(key, hit); | |
| 56 | } | |
| 57 | return hit; | |
| 58 | } | |
| 59 | ||
| 33 | 60 | export function openapiRoute(router: Router<Handler>): void { |
| 34 | 61 | router.add("GET", "/contract/openapi.json", (ctx) => { |
| 35 | const hit = openapiText(ctx.url.origin, running().version); | |
| 62 | const tag = ctx.url.searchParams.get("tag"); | |
| 63 | if (tag !== null && !TAGS.includes(tag)) { | |
| 64 | return Response.json( | |
| 65 | { error: "invalid_query", message: `unknown tag; the tags are ${TAGS.join(", ")}` }, | |
| 66 | { status: 400, headers: { "cache-control": "no-store" } }, | |
| 67 | ); | |
| 68 | } | |
| 69 | const hit = tag === null ? openapiText(ctx.url.origin, running().version) : openapiSliceText(ctx.url.origin, running().version, tag); | |
| 36 | 70 | const headers = { "cache-control": "public, max-age=300", etag: hit.etag, vary: "accept-encoding" }; |
| 37 | 71 | if (holds(ctx.request, hit.etag)) return new Response(null, { status: 304, headers }); |
| 38 | 72 | const type = { "content-type": "application/json", ...headers }; |
src/contract/tags.ts added +127
| @@ -0,0 +1,127 @@ | ||
| 1 | /** | |
| 2 | * The tag of an OpenAPI operation: the resource its path acts on. Plain | |
| 3 | * data in, plain data out; `openapi.ts` stamps it on every operation and | |
| 4 | * `served.ts` slices the document by it (`?tag=pulls`, #11091). | |
| 5 | */ | |
| 6 | ||
| 7 | /** A path segment that names a resource under another tag. */ | |
| 8 | const ALIAS: Readonly<Record<string, string>> = { | |
| 9 | app: "apps", | |
| 10 | "app-templates": "apps", | |
| 11 | display: "builds", | |
| 12 | following: "forks", | |
| 13 | upstream: "forks", | |
| 14 | "make-it-yours": "forks", | |
| 15 | contribute: "forks", | |
| 16 | queue: "gates", | |
| 17 | checks: "pulls", | |
| 18 | conversation: "conversations", | |
| 19 | "conversation-confirms": "conversations", | |
| 20 | "conversation-tokens": "conversations", | |
| 21 | origin: "repos", | |
| 22 | "origin-hook": "repos", | |
| 23 | sync: "repos", | |
| 24 | transfer: "repos", | |
| 25 | atlas: "repos", | |
| 26 | tags: "repos", | |
| 27 | branches: "repos", | |
| 28 | commits: "repos", | |
| 29 | contents: "repos", | |
| 30 | statement: "plan", | |
| 31 | vision: "plan", | |
| 32 | roadmap: "plan", | |
| 33 | iterations: "plan", | |
| 34 | edits: "plan", | |
| 35 | waves: "plan", | |
| 36 | users: "user", | |
| 37 | secrets: "tokens", | |
| 38 | sessions: "tokens", | |
| 39 | "first-admin": "admin", | |
| 40 | health: "meta", | |
| 41 | version: "meta", | |
| 42 | releases: "meta", | |
| 43 | changelog: "meta", | |
| 44 | flags: "meta", | |
| 45 | here: "meta", | |
| 46 | map: "meta", | |
| 47 | api: "meta", | |
| 48 | mcp: "meta", | |
| 49 | metrics: "meta", | |
| 50 | packs: "meta", | |
| 51 | keeper: "meta", | |
| 52 | heartbeat: "meta", | |
| 53 | telemetry: "meta", | |
| 54 | drills: "meta", | |
| 55 | devices: "meta", | |
| 56 | thoughts: "feed", | |
| 57 | share: "feed", | |
| 58 | explore: "feed", | |
| 59 | front: "feed", | |
| 60 | search: "feed", | |
| 61 | family: "forges", | |
| 62 | start: "forges", | |
| 63 | cloud: "forges", | |
| 64 | migrate: "forges", | |
| 65 | returns: "billing", | |
| 66 | credit: "billing", | |
| 67 | spend: "billing", | |
| 68 | usage: "billing", | |
| 69 | forge: "forges", | |
| 70 | passport: "user", | |
| 71 | "proposal-tokens": "tokens", | |
| 72 | asked: "asks", | |
| 73 | decisions: "runs", | |
| 74 | events: "runs", | |
| 75 | flow: "runs", | |
| 76 | agents: "crew", | |
| 77 | fleet: "crew", | |
| 78 | features: "meta", | |
| 79 | orgs: "forges", | |
| 80 | boards: "forges", | |
| 81 | data: "apps", | |
| 82 | }; | |
| 83 | ||
| 84 | /** One line on each tag the document lists. */ | |
| 85 | export const TAG_WORDS: Readonly<Record<string, string>> = { | |
| 86 | admin: "Administration: accounts, settings, the forge's own state (admin role)", | |
| 87 | apps: "An app's deployment: environments, domains, env vars, members, notes, templates, its data files", | |
| 88 | asks: "Questions the crew asks a person, and their answers", | |
| 89 | billing: "Credit, spend, usage and returns", | |
| 90 | builds: "Builds of a pull or branch, their screenshots and cart", | |
| 91 | contract: "This description of the API", | |
| 92 | conversations: "Conversations with the crew and the tokens that confirm them", | |
| 93 | crew: "The agents of a repository, their fleet and their roster", | |
| 94 | "egress-grants": "Hosts an app may reach", | |
| 95 | feed: "The timeline, posts, shares, search and exploring", | |
| 96 | forges: "Forges, organizations and boards: creating and following them", | |
| 97 | forks: "Forks, upstream following, offers and make-it-yours", | |
| 98 | gates: "The gate queue and the log of each gate", | |
| 99 | hooks: "Webhooks of a repository and their deliveries", | |
| 100 | issues: "Issues, their comments, dependencies, teams and plans", | |
| 101 | labels: "Labels on issues and pulls", | |
| 102 | meta: "The forge itself: health, version, manifest, flags, telemetry", | |
| 103 | milestones: "Milestones and watching them", | |
| 104 | plan: "A repository's vision, statement, roadmap, iterations and waves", | |
| 105 | prompts: "Saved prompts and running them", | |
| 106 | pulls: "Pull requests: reviews, threads, checks, looks, merging and holding", | |
| 107 | repos: "A repository: its record, branches, commits, contents, tags, origin and transfer", | |
| 108 | reports: "Reports filed on a repository and their triage", | |
| 109 | runs: "Runs of the crew: logs, steering, cancelling, decisions and events", | |
| 110 | tokens: "API tokens, sessions and secrets", | |
| 111 | user: "The signed-in person and people: profile, keys, settings, notifications", | |
| 112 | }; | |
| 113 | ||
| 114 | /** The tag of the route at `path`. */ | |
| 115 | export function tagOf(path: string): string { | |
| 116 | const s = path.split("/").filter(Boolean); | |
| 117 | let raw: string; | |
| 118 | if (s[0] !== "api") raw = s[0] ?? "meta"; | |
| 119 | else if (s[2] === "repos") raw = s[5] ?? "repos"; | |
| 120 | else if (s[2] === "openapi.json") raw = "contract"; | |
| 121 | else raw = s[2] ?? "api"; | |
| 122 | return ALIAS[raw] ?? raw; | |
| 123 | } | |
| 124 | ||
| 125 | /** The tag list the document opens with, sorted. */ | |
| 126 | export const tagList = (tags: Iterable<string>): { name: string; description: string }[] => | |
| 127 | [...new Set(tags)].sort().map((name) => ({ name, description: TAG_WORDS[name] ?? name })); |
src/web/standards.ts +2 −1
| @@ -46,6 +46,7 @@ import { SITE_LINE } from "./site_line.ts"; | ||
| 46 | 46 | import { mcpToolsSection } from "./llms_tools.ts"; |
| 47 | 47 | import { apiCatalog, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts"; |
| 48 | 48 | import { forgeDocument } from "../core/standards_index.ts"; |
| 49 | import { TAGS } from "../contract/served.ts"; | |
| 49 | 50 | import { running } from "../kernel/version.ts"; |
| 50 | 51 | import { timePhase, timePhaseAsync } from "../core/route_timing.ts"; |
| 51 | 52 | import { shippedReleases, type Shipped } from "../release/shipped.ts"; |
| @@ -479,7 +480,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 479 | 480 | `- MCP: ${mcp.url} (OAuth discovery: ${mcp.oauth}; server card, a draft format: ${o}${DISCOVERY.card})`, |
| 480 | 481 | `- API: ${o}${DISCOVERY.api} (the root lists the other URLs), Authorization: Bearer <token>`, |
| 481 | 482 | `- API catalog (RFC 9727, one linkset of the above): ${o}${DISCOVERY.catalog}`, |
| 482 | `- OpenAPI: GET ${o}${DISCOVERY.api}/openapi.json (redirects to ${o}${DISCOVERY.openapi})`, | |
| 483 | `- OpenAPI: GET ${o}${DISCOVERY.openapi}, or one slice with ?tag= (${TAGS.join(", ")})`, | |
| 483 | 484 | "", |
| 484 | 485 | ...mcpToolsSection(o, signedIn), |
| 485 | 486 | "## Public repositories", |
test/contract-openapi-tags.test.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { openapi, ROUTES } from "../src/contract/openapi.ts"; | |
| 3 | import { TAG_WORDS, tagOf } from "../src/contract/tags.ts"; | |
| 4 | import { openapiSliceText, TAGS } from "../src/contract/served.ts"; | |
| 5 | ||
| 6 | type Doc = { tags: { name: string; description: string }[]; paths: Record<string, Record<string, { tags: string[] }>>; components: unknown; info: unknown }; | |
| 7 | const doc = openapi("https://x.test", "v") as Doc; | |
| 8 | ||
| 9 | test("every operation carries exactly one tag, and every tag is listed with a line", () => { | |
| 10 | const listed = new Set(doc.tags.map((t) => t.name)); | |
| 11 | for (const ops of Object.values(doc.paths)) for (const op of Object.values(ops)) { | |
| 12 | expect(op.tags).toHaveLength(1); | |
| 13 | expect(listed.has(op.tags[0]!)).toBe(true); | |
| 14 | } | |
| 15 | for (const t of doc.tags) expect(TAG_WORDS[t.name]).toBeTruthy(); | |
| 16 | expect(Object.keys(TAG_WORDS).sort()).toEqual([...listed].sort()); | |
| 17 | }); | |
| 18 | ||
| 19 | test("a path's tag is its resource", () => { | |
| 20 | expect(tagOf("/api/v1/repos/{owner}/{name}/pulls/{number}/merge")).toBe("pulls"); | |
| 21 | expect(tagOf("/api/v1/repos/{owner}/{name}/issues")).toBe("issues"); | |
| 22 | expect(tagOf("/api/v1/repos/{owner}/{name}/app/env")).toBe("apps"); | |
| 23 | expect(tagOf("/api/v1/repos/{owner}/{name}/forks")).toBe("forks"); | |
| 24 | expect(tagOf("/api/v1/repos/{owner}/{name}")).toBe("repos"); | |
| 25 | }); | |
| 26 | ||
| 27 | test("a slice keeps only its tag's operations and the rest of the document", () => { | |
| 28 | const slice = JSON.parse(openapiSliceText("https://x.test", "v", "pulls").text) as Doc; | |
| 29 | expect(slice.info).toEqual(doc.info); | |
| 30 | expect(slice.components).toEqual(doc.components); | |
| 31 | expect(slice.tags.map((t) => t.name)).toEqual(["pulls"]); | |
| 32 | const n = Object.values(slice.paths).flatMap((o) => Object.values(o)).length; | |
| 33 | expect(n).toBe(ROUTES.filter((r) => tagOf(r.path) === "pulls").length); | |
| 34 | expect(openapiSliceText("https://x.test", "v", "pulls").text.length).toBeLessThan(40_000); | |
| 35 | }); | |
| 36 | ||
| 37 | test("?tag= over HTTP: a slice, the whole, and a 400 naming the tags", async () => { | |
| 38 | const { openapiRoute } = await import("../src/contract/served.ts"); | |
| 39 | const { Router } = await import("../src/http/router.ts"); | |
| 40 | const router = new Router<(ctx: any) => Response>(); | |
| 41 | openapiRoute(router as never); | |
| 42 | const call = async (q: string) => { | |
| 43 | const m = (router as any).match("GET", "/contract/openapi.json"); | |
| 44 | const url = new URL(`https://x.test/contract/openapi.json${q}`); | |
| 45 | return m.handler({ url, request: new Request(url.href) }) as Response; | |
| 46 | }; | |
| 47 | const bad = await call("?tag=nope"); | |
| 48 | expect(bad.status).toBe(400); | |
| 49 | expect(((await bad.json()) as { message: string }).message).toContain(TAGS.join(", ")); | |
| 50 | const whole = JSON.parse(await (await call("")).text()) as Doc; | |
| 51 | expect(Object.keys(whole.paths).length).toBe(Object.keys(doc.paths).length); | |
| 52 | const one = JSON.parse(await (await call("?tag=pulls")).text()) as Doc; | |
| 53 | expect(Object.keys(one.paths).length).toBeLessThan(Object.keys(whole.paths).length); | |
| 54 | }); |
test/llms-anonymous.test.ts +8 −1
| @@ -46,7 +46,7 @@ const section = (body: string): string[] => { | ||
| 46 | 46 | test("the anonymous text is small, varies by reader, and lists report and start plus one line", async () => { |
| 47 | 47 | const res = await fetch(`${servers.url}/llms.txt`); |
| 48 | 48 | const body = await res.text(); |
| 49 | expect(Buffer.byteLength(body)).toBeLessThan(6 * 1024); | |
| 49 | expect(Buffer.byteLength(body)).toBeLessThan(6.5 * 1024); | |
| 50 | 50 | expect(res.headers.get("vary")).toContain("Cookie"); |
| 51 | 51 | expect(res.headers.get("vary")).toContain("Authorization"); |
| 52 | 52 | const listed = section(body); |
| @@ -83,3 +83,10 @@ test("a signed-in reader still gets every tool and report", async () => { | ||
| 83 | 83 | }).then((r) => r.text()); |
| 84 | 84 | expect(section(body)).toHaveLength(TOOLS.length + 2); |
| 85 | 85 | }); |
| 86 | ||
| 87 | test("the OpenAPI line names ?tag= and the tags", async () => { | |
| 88 | const body = await (await fetch(`${servers.url}/llms.txt`)).text(); | |
| 89 | const line = body.split("\n").find((l) => l.startsWith("- OpenAPI:")) ?? ""; | |
| 90 | expect(line).toContain("?tag="); | |
| 91 | expect(line).toContain("pulls"); | |
| 92 | }); |