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

The OpenAPI document describes forges and the fleet: forges, sizes, claim links, fleet ops, front, health and drills #4882

Merged opened this

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

Merged

scout/forge:scout/4873-the-openapi-document-describes-forges-an → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/4873-the-openapi-document-describes-forges-an was merged into main as 6b815f6.

Review

Sign in to review this change.
2 files changed +131 −0
src/contract/openapi.ts +96
@@ -143,6 +143,102 @@ export const ROUTES: readonly Route[] = [
143143 summary: "Stop an issue waiting for another",
144144 auth: true,
145145 },
146 { method: "get", path: "/api/v1/forges", summary: "List the child forges (a site admin's, ro scope); answers { items, next_cursor: null }", auth: true },
147 {
148 method: "post",
149 path: "/api/v1/forges",
150 summary:
151 "Make a forge, as a link: `dryRun` is true unless it is false and then answers 200 with { dryRun: true, plan, firstAdmin } and makes nothing; made, it answers 201 (200 when it already exists) with { dryRun: false, name, url, claimUrl, ... }. A signed-in caller needs rw scope; a stranger may call it, same-origin, rate-limited, with no crew, model key or `for`",
152 auth: true,
153 status: 201,
154 body: {
155 name: "string",
156 apps: "array",
157 crew: "array",
158 dryRun: "boolean",
159 direction: "string",
160 keepAccess: "boolean",
161 size: "string",
162 for: "string",
163 fresh: "boolean",
164 followMode: "string",
165 budgetUsd: "number",
166 modelKey: "string",
167 modelEndpoint: "string",
168 },
169 refusals: {
170 invalid: "a field is missing or not valid",
171 rate_limited: "a stranger has started enough forges today",
172 },
173 },
174 { method: "get", path: "/api/v1/forges/sizes", summary: "What small, medium and large mean; public, cacheable by ETag", auth: false },
175 {
176 method: "post",
177 path: "/api/v1/forges/{name}/claim-link",
178 summary:
179 "Make a new claim link for a forge nobody has claimed (a site admin's, rw scope); answers 201 with { name, claimUrl }. The link is shown once, here, and not again: the old link dies, so keep this one",
180 auth: true,
181 status: 201,
182 refusals: { not_found: "no child forge has this name", forbidden: "the caller is not a site admin", claimed: "the forge is already claimed" },
183 },
184 { method: "get", path: "/api/v1/forge", summary: "A spawned forge's word about itself: claimed or not, to anyone; when anything last happened, only to its parent; not_found when it was not spawned", auth: false },
185 {

auth:false here but getFleetOp filters by caller; say so in the summary so clients send a token.

186 method: "get",
187 path: "/api/v1/fleet/graph",
188 summary: "Spend and runs per repository by UTC hour (24 h) and day (14 d); `repo` (owner/name) repeats, else every repository the caller may write that spent lately",
189 auth: true,
190 query: { repo: "string" },
191 refusals: { invalid_selection: "`repo` is not a list of owner/name" },
192 },
193 {
194 method: "get",
195 path: "/api/v1/fleet/ops",
196 summary: "List the fleet operations, newest first; answers { items, next_cursor }",
197 auth: true,
198 query: { limit: "integer", cursor: "string" },
199 },
200 { method: "get", path: "/api/v1/fleet/ops/{id}", summary: "Read one fleet operation and what it changed", auth: false },
201 {
202 method: "post",
203 path: "/api/v1/fleet/ops",
204 summary:
205 "Plan or apply one action over the repositories a query selects; `dryRun` is true unless it is false: the plan needs ro scope and changes nothing (200, { dryRun: true, ... }), applying needs admin scope and answers 201 with { dryRun: false, ... }; each row is still checked against the actor's admin of its repository",
206 auth: true,
207 status: 201,
208 body: { query: "string", action: "object", dryRun: "boolean" },
209 },
210 { method: "post", path: "/api/v1/fleet/ops/{id}/undo", summary: "Undo a fleet operation (admin scope); answers 201 with the undoing operation", auth: true, status: 201 },
211 { method: "get", path: "/api/v1/front", summary: "The front page: what a stranger is shown; a site admin also gets the picks", auth: false },
212 {
213 method: "get",
214 path: "/api/v1/health",
215 summary: "How the forge is, uncached; anyone may ask, a stranger is told little, the release probe and a site admin the whole of it",
216 auth: false,
217 },
218 { method: "get", path: "/api/v1/map", summary: "The global map: every repository the caller can see, with its issues and dependency edges", auth: false },
219 {
220 method: "post",
221 path: "/api/v1/heartbeat",
222 summary:
223 "A child forge's signed heartbeat (counts and version, never data); only a child holding the parent's secret may call it, signed in the signature header, and body at most 1 KiB; answers { ok: true }, and every refusal is the same 404 `not_found`",
224 auth: false,
225 refusals: { not_found: "the beat is not signed by a child of this forge" },
226 },
227 {
228 method: "post",
229 path: "/api/v1/drills",
230 summary:
231 "Record a domain drill and its verdict, green or red (a site admin's, admin token only); answers 201 with the recorded drill",
232 auth: true,
233 status: 201,
234 body: { domain: "string", seen: "object", backSeconds: "integer" },
235 refusals: {
236 unknown_domain: "`domain` is not one of the forge's failure domains",
237 invalid_seen: "`seen` needs health and board as text",
238 invalid_back: "`backSeconds` is not a whole number of seconds, 0 to 31536000",
239 too_large: "the body is too large",
240 },
241 },
146242 {
147243 method: "post",
148244 path: "/api/v1/migrate",
test/openapi-oa-forges.test.ts added +35
@@ -0,0 +1,35 @@
1 /** The OpenAPI document describes forges, the fleet, front, health and drills. */
2 import { expect, test } from "bun:test";
3 import { openapi } from "../src/contract/openapi.ts";
4
5 const WANT: readonly (readonly [string, string])[] = [
6 ["get", "/api/v1/forges"],
7 ["post", "/api/v1/forges"],
8 ["get", "/api/v1/forges/sizes"],
9 ["post", "/api/v1/forges/{name}/claim-link"],
10 ["get", "/api/v1/forge"],
11 ["get", "/api/v1/fleet/graph"],
12 ["get", "/api/v1/fleet/ops"],
13 ["get", "/api/v1/fleet/ops/{id}"],
14 ["post", "/api/v1/fleet/ops"],
15 ["post", "/api/v1/fleet/ops/{id}/undo"],
16 ["get", "/api/v1/front"],
17 ["get", "/api/v1/health"],
18 ["get", "/api/v1/map"],
19 ["post", "/api/v1/heartbeat"],
20 ["post", "/api/v1/drills"],
21 ];
22
23 test("each forges and fleet path and method is in the document", () => {
24 const doc = openapi("https://f.test", "1") as any;
25 for (const [method, path] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined();
26 });
27
28 test("the claim link is shown once and the heartbeat and drills say who calls them", () => {
29 const doc = openapi("https://f.test", "1") as any;
30 expect(doc.paths["/api/v1/forges/{name}/claim-link"].post.summary).toContain("shown once");
31 expect(doc.paths["/api/v1/heartbeat"].post.summary).toContain("child");
32 expect(doc.paths["/api/v1/heartbeat"].post.security).toBeUndefined();
33 expect(doc.paths["/api/v1/drills"].post.summary).toContain("site admin");
34 expect(doc.paths["/api/v1/drills"].post.security).toBeDefined();
35 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs