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

Merged opened this

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

Merged

scout/forge:scout/11091-an-agent-reads-one-slice-of-the-openapi → main 2 commits fork

  • Merged. scout/11091-an-agent-reads-one-slice-of-the-openapi was merged into main as 6c73fe5.

Review

Sign in to review this change.
6 files changed +230 −4
src/contract/openapi.ts +3
@@ -13,6 +13,7 @@
1313
1414 import type { QueryType, Route } from "./openapi/shared.ts";
1515 import { OAUTH_SCOPES, type OAuthScope } from "../core/oauth.ts";
16 import { tagList, tagOf } from "./tags.ts";
1617 import * as each from "./openapi/routes/index.ts";
1718
1819 /** 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
7273 }));
7374 (paths[r.path] ??= {})[r.method] = {
7475 operationId: operationId(r),
76 tags: [tagOf(r.path)],
7577 summary: r.summary,
7678 "x-role": r.role,
7779 parameters: [...params, ...query],
@@ -131,6 +133,7 @@ export function openapi(origin: string, version: string): Record<string, unknown
131133 "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.",
132134 },
133135 servers: [{ url: origin }],
136 tags: tagList(ROUTES.map((r) => tagOf(r.path))),
134137 // The default: a bearer token. Open routes say `security: []`.
135138 security: [{ token: [] }],
136139 paths,
src/contract/served.ts +36 −2
@@ -8,9 +8,12 @@ import { holds, weakEtag } from "../http/etag.ts";
88 import type { Handler } from "../http/context.ts";
99 import type { Router } from "../http/router.ts";
1010 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";
1213
1314 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);
1417 const built = new Map<string, { text: string; etag: string }>();
1518
1619 /**
@@ -30,9 +33,40 @@ export function openapiText(origin: string, version: string): { text: string; et
3033 return hit;
3134 }
3235
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
3360 export function openapiRoute(router: Router<Handler>): void {
3461 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);
3670 const headers = { "cache-control": "public, max-age=300", etag: hit.etag, vary: "accept-encoding" };
3771 if (holds(ctx.request, hit.etag)) return new Response(null, { status: 304, headers });
3872 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";
4646 import { mcpToolsSection } from "./llms_tools.ts";
4747 import { apiCatalog, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts";
4848 import { forgeDocument } from "../core/standards_index.ts";
49 import { TAGS } from "../contract/served.ts";
4950 import { running } from "../kernel/version.ts";
5051 import { timePhase, timePhaseAsync } from "../core/route_timing.ts";
5152 import { shippedReleases, type Shipped } from "../release/shipped.ts";
@@ -479,7 +480,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
479480 `- MCP: ${mcp.url} (OAuth discovery: ${mcp.oauth}; server card, a draft format: ${o}${DISCOVERY.card})`,
480481 `- API: ${o}${DISCOVERY.api} (the root lists the other URLs), Authorization: Bearer <token>`,
481482 `- 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(", ")})`,
483484 "",
484485 ...mcpToolsSection(o, signedIn),
485486 "## 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[] => {
4646 test("the anonymous text is small, varies by reader, and lists report and start plus one line", async () => {
4747 const res = await fetch(`${servers.url}/llms.txt`);
4848 const body = await res.text();
49 expect(Buffer.byteLength(body)).toBeLessThan(6 * 1024);
49 expect(Buffer.byteLength(body)).toBeLessThan(6.5 * 1024);
5050 expect(res.headers.get("vary")).toContain("Cookie");
5151 expect(res.headers.get("vary")).toContain("Authorization");
5252 const listed = section(body);
@@ -83,3 +83,10 @@ test("a signed-in reader still gets every tool and report", async () => {
8383 }).then((r) => r.text());
8484 expect(section(body)).toHaveLength(TOOLS.length + 2);
8585 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs