Public: anyone can read this; the crew can change it.
POST /api/v1/apps makes an app from `sentence` alone, and its OpenAPI row says so #8456
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8427-post-api-v1-apps-makes-an-app-from-sente → main 3 commits fork
6 files
changed +95
−16
docs/features/new-app.md +7
| @@ -22,3 +22,10 @@ limit sentences. Nothing is made and no screen is pushed. | ||
| 22 | 22 | |
| 23 | 23 | Try it: `ssh` to the forge (`$FORGE_APP_SSH` on an app), press the palette key, |
| 24 | 24 | pick **New app**. |
| 25 | ||
| 26 | ## From the API and the MCP | |
| 27 | ||
| 28 | `POST /api/v1/apps` and the MCP `new_app` tool take a `sentence` with no | |
| 29 | `name` and go through the same `newAppFromSentence`: the name is derived, a | |
| 30 | repeat gets `-2`, and nothing to name from is `no_name` (422 on the API). When | |
| 31 | both are given, `name` wins and `sentence` is ignored. |
src/contract/openapi/routes/postApiV1Apps.ts +3 −2
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | import type { Route } from "../shared.ts"; |
| 2 | 2 | |
| 3 | 3 | /** What `POST /api/v1/apps` reads from its body, and the refusals it answers. */ |
| 4 | const APP_BODY = { name: "string", owner: "string", description: "string", visibility: "string", agent: "string", pick: "boolean" }; | |
| 4 | const APP_BODY = { name: "string", owner: "string", description: "string", sentence: "string", visibility: "string", agent: "string", pick: "boolean" }; | |
| 5 | 5 | const APP_REFUSALS = { |
| 6 | 6 | unverified: "the account has not verified its email", |
| 7 | no_name: "no `name`, and the `sentence` leaves nothing to make one from", | |
| 7 | 8 | app_limit: "the owner is at the app limit", |
| 8 | 9 | no_such_agent: "`agent` names no one on the crew", |
| 9 | 10 | slug_taken: "another app already has this name's slug", |
| @@ -13,7 +14,7 @@ const APP_REFUSALS = { | ||
| 13 | 14 | export const route: Route = { |
| 14 | 15 | method: "post", |
| 15 | 16 | path: "/api/v1/apps", |
| 16 | summary: "Make an app: a repository, its app and a first issue; answers 201 with { repo, app, issue }", | |
| 17 | summary: "Make an app (named by `name`, or derived from `sentence` alone; `name` wins when both are given): a repository, its app and a first issue; answers 201 with { repo, app, issue }", | |
| 17 | 18 | auth: true, |
| 18 | 19 | status: 201, |
| 19 | 20 | body: APP_BODY, |
src/http/api.ts +37 −7
| @@ -181,7 +181,13 @@ import { appLimitWords, appsOwnedBy } from "../actions/app_limit.ts"; | ||
| 181 | 181 | import { getQuota } from "../settings/quotas.ts"; |
| 182 | 182 | import { isSafeRef, isSafeTreePath } from "../git/guard.ts"; |
| 183 | 183 | import { compareBranches, listBranchRows } from "../git/branches.ts"; |
| 184 | import { newApp } from "../actions/apps.ts"; | |
| 184 | import { | |
| 185 | NO_NAME_ERROR, | |
| 186 | newApp, | |
| 187 | newAppFromSentence, | |
| 188 | type NewAppInput, | |
| 189 | type NewAppResult, | |
| 190 | } from "../actions/apps.ts"; | |
| 185 | 191 | import { listDomains } from "../core/domains.ts"; |
| 186 | 192 | import { |
| 187 | 193 | migrateMessage, |
| @@ -3209,19 +3215,43 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3209 | 3215 | if (denied) return denied; |
| 3210 | 3216 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 3211 | 3217 | const input = await body(ctx.request); |
| 3212 | const made = await newApp(ctx.app, ctx.auth.user, { | |
| 3213 | name: str(input.name), | |
| 3218 | const options: Omit<NewAppInput, "name" | "description"> = { | |
| 3214 | 3219 | ...(typeof input.owner === "string" ? { owner: input.owner } : {}), |
| 3215 | ...(typeof input.description === "string" | |
| 3216 | ? { description: input.description } | |
| 3217 | : {}), | |
| 3218 | 3220 | ...(input.visibility === "public" || input.visibility === "private" |
| 3219 | 3221 | ? { visibility: input.visibility } |
| 3220 | 3222 | : {}), |
| 3221 | 3223 | ...(typeof input.agent === "string" ? { agent: input.agent } : {}), |
| 3222 | 3224 | // Only a real boolean asks; like the other fields, anything else is ignored. |
| 3223 | 3225 | ...(input.pick === true ? { pickAgent: true } : {}), |
| 3224 | }, (name, ms) => notePhase(ctx.request, name, ms)); | |
| 3226 | }; | |
| 3227 | const onPhase = (name: string, ms: number) => notePhase(ctx.request, name, ms); | |
| 3228 | // No `name` but a `sentence`: the name is derived, as the web form and terminal do. | |
| 3229 | const fromSentence = str(input.name) === "" && typeof input.sentence === "string"; | |
| 3230 | let made: { ok: true; value: NewAppResult } | { ok: false; error: string }; | |
| 3231 | if (fromSentence) { | |
| 3232 | const r = await newAppFromSentence( | |
| 3233 | ctx.app, | |
| 3234 | ctx.auth.user, | |
| 3235 | { ...options, sentence: str(input.sentence) }, | |
| 3236 | onPhase, | |
| 3237 | ); | |
| 3238 | made = r.ok ? { ok: true, value: r } : { ok: false, error: r.code }; | |
| 3239 | } else { | |
| 3240 | made = await newApp( | |
| 3241 | ctx.app, | |
| 3242 | ctx.auth.user, | |
| 3243 | { | |
| 3244 | name: str(input.name), | |
| 3245 | ...(typeof input.description === "string" | |
| 3246 | ? { description: input.description } | |
| 3247 | : {}), | |
| 3248 | ...options, | |
| 3249 | }, | |
| 3250 | onPhase, | |
| 3251 | ); | |
| 3252 | } | |
| 3253 | if (!made.ok && made.error === "no_name") | |
| 3254 | return apiError("no_name", 422, NO_NAME_ERROR); | |
| 3225 | 3255 | if (!made.ok) { |
| 3226 | 3256 | if (made.error === "repository_limit") |
| 3227 | 3257 | return apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!)); |
src/http/mcp/tools/new_app.ts +19 −7
| @@ -2,7 +2,7 @@ import { appsRuntime } from "../../../apps/runtime.ts"; | ||
| 2 | 2 | import { listActiveRuns } from "../../../core/runs.ts"; |
| 3 | 3 | import { getUserByName } from "../../../core/users.ts"; |
| 4 | 4 | import { repositoryLimitMessage } from "../../../actions/repos.ts"; |
| 5 | import { newApp } from "../../../actions/apps.ts"; | |
| 5 | import { NO_NAME_ERROR, newApp, newAppFromSentence, type NewAppInput, type NewAppResult } from "../../../actions/apps.ts"; | |
| 6 | 6 | import { appLimitWords } from "../../../actions/app_limit.ts"; |
| 7 | 7 | import { storageLimitMessage } from "../../../core/storage.ts"; |
| 8 | 8 | import { VERIFY_FIRST } from "../../../actions/verify_words.ts"; |
| @@ -10,20 +10,20 @@ import { good, bad, type Tool, str } from "../shared.ts"; | ||
| 10 | 10 | |
| 11 | 11 | export const tool: Tool = { |
| 12 | 12 | name: "new_app", |
| 13 | doc: "`{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)", | |
| 13 | doc: "`{ name?, sentence?, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; `name` wins when both are given, and `sentence` alone derives the name (`no_name` when nothing is left to name from); answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)", | |
| 14 | 14 | description: |
| 15 | 15 | "Make an app from a sentence: a repo, production, and with `description` a first issue.", |
| 16 | 16 | inputSchema: { |
| 17 | 17 | type: "object", |
| 18 | 18 | properties: { |
| 19 | 19 | name: { type: "string", maxLength: 100 }, |
| 20 | sentence: { type: "string", maxLength: 4000, description: "Names the app when `name` is absent." }, | |
| 20 | 21 | description: { type: "string", maxLength: 4000, description: "The first issue." }, |
| 21 | 22 | owner: { type: "string", maxLength: 39 }, |
| 22 | 23 | visibility: { type: "string", enum: ["public", "private"] }, |
| 23 | 24 | agent: { type: "string", maxLength: 39 }, |
| 24 | 25 | pick: { type: "boolean" }, |
| 25 | 26 | }, |
| 26 | required: ["name"], | |
| 27 | 27 | }, |
| 28 | 28 | writes: true, |
| 29 | 29 | async run(ctx, user, args) { |
| @@ -32,14 +32,24 @@ export const tool: Tool = { | ||
| 32 | 32 | const owner = text("owner"); |
| 33 | 33 | const description = text("description"); |
| 34 | 34 | const agent = text("agent"); |
| 35 | const made = await newApp(ctx.app, user, { | |
| 36 | name: str(args["name"]), | |
| 35 | const options: Omit<NewAppInput, "name" | "description"> = { | |
| 37 | 36 | ...(owner === undefined ? {} : { owner }), |
| 38 | ...(description === undefined ? {} : { description }), | |
| 39 | 37 | ...(visibility === "public" || visibility === "private" ? { visibility } : {}), |
| 40 | 38 | ...(agent === undefined ? {} : { agent }), |
| 41 | 39 | ...(args["pick"] === true ? { pickAgent: true } : {}), |
| 42 | }); | |
| 40 | }; | |
| 41 | const sentence = text("sentence"); | |
| 42 | let made: { ok: true; value: NewAppResult } | { ok: false; error: string }; | |
| 43 | if (str(args["name"]) === "" && sentence !== undefined) { | |
| 44 | const r = await newAppFromSentence(ctx.app, user, { ...options, sentence: str(sentence) }); | |
| 45 | made = r.ok ? { ok: true, value: r } : { ok: false, error: r.code }; | |
| 46 | } else { | |
| 47 | made = await newApp(ctx.app, user, { | |
| 48 | name: str(args["name"]), | |
| 49 | ...(description === undefined ? {} : { description }), | |
| 50 | ...options, | |
| 51 | }); | |
| 52 | } | |
| 43 | 53 | if (!made.ok) { |
| 44 | 54 | switch (made.error) { |
| 45 | 55 | case "unverified": |
| @@ -52,6 +62,8 @@ export const tool: Tool = { | ||
| 52 | 62 | return bad("repository_limit", repositoryLimitMessage(ctx.app, user)); |
| 53 | 63 | case "storage_limit": |
| 54 | 64 | return bad("storage_limit", storageLimitMessage(ctx.app, user.id)); |
| 65 | case "no_name": | |
| 66 | return bad("no_name", NO_NAME_ERROR); | |
| 55 | 67 | default: |
| 56 | 68 | return bad(made.error, made.error); |
| 57 | 69 | } |
test/apps-api.test.ts +19
| @@ -233,6 +233,25 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che | ||
| 233 | 233 | } |
| 234 | 234 | }, 60_000); |
| 235 | 235 | |
| 236 | test("POST /apps with a sentence and no name derives the name; a repeat gets -2; neither is 422", async () => { | |
| 237 | const first = await post("/api/v1/apps", { sentence: "Keep notes" }, rw); | |
| 238 | expect(first.status).toBe(201); | |
| 239 | const a = (await first.json()) as { app: { slug: string }; repo: { name: string } }; | |
| 240 | const again = await post("/api/v1/apps", { sentence: "Keep notes" }, rw); | |
| 241 | expect(again.status).toBe(201); | |
| 242 | const b = (await again.json()) as { repo: { name: string } }; | |
| 243 | expect(b.repo.name).toBe(`${a.repo.name}-2`); | |
| 244 | ||
| 245 | const both = await post("/api/v1/apps", { name: "named-wins", sentence: "Keep notes" }, rw); | |
| 246 | expect(((await both.json()) as { repo: { name: string } }).repo.name).toBe("named-wins"); | |
| 247 | ||
| 248 | const neither = await post("/api/v1/apps", {}, rw); | |
| 249 | expect(neither.status).toBe(422); | |
| 250 | const nothing = await post("/api/v1/apps", { sentence: "!!!" }, rw); | |
| 251 | expect(nothing.status).toBe(422); | |
| 252 | expect(((await nothing.json()) as { error: string }).error).toBe("no_name"); | |
| 253 | }); | |
| 254 | ||
| 236 | 255 | test("POST /apps makes the app, puts the agent on the crew, assigns the first issue", async () => { |
| 237 | 256 | const made = await post( |
| 238 | 257 | "/api/v1/apps", |
test/mcp.test.ts +10
| @@ -1658,6 +1658,16 @@ describe("the forge over MCP", () => { | ||
| 1658 | 1658 | expect((await tool(rw, "apps") as any).value.some((r: any) => r.app === "alice/readonly")).toBe(false); |
| 1659 | 1659 | }, 30_000); |
| 1660 | 1660 | |
| 1661 | test("new_app with a sentence and no name derives the name; name wins when both are given", async () => { | |
| 1662 | const first = (await tool(rw, "new_app", { sentence: "Keep notes" })) as any; | |
| 1663 | expect(first.error).toBeUndefined(); | |
| 1664 | const again = (await tool(rw, "new_app", { sentence: "Keep notes" })) as any; | |
| 1665 | expect(again.value.repo).toBe(`${first.value.repo}-2`); | |
| 1666 | const named = (await tool(rw, "new_app", { name: "wins", sentence: "Something else" })) as any; | |
| 1667 | expect(named.value.repo).toBe("alice/wins"); | |
| 1668 | expect((await tool(rw, "new_app", { sentence: "!!!" })).error).toContain("no_name"); | |
| 1669 | }, 30_000); | |
| 1670 | ||
| 1661 | 1671 | test("new_app with pick lets the planner name the agent", async () => { |
| 1662 | 1672 | const picked = await createAgent(app, alice, { username: "pickbot", brief: "builds" }); |
| 1663 | 1673 | if (!picked.ok) throw new Error(String(picked.error)); |
new above
offline