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

Merged opened this

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

Merged

mason/forge:mason/8427-post-api-v1-apps-makes-an-app-from-sente → main 3 commits fork

  • Merged. mason/8427-post-api-v1-apps-makes-an-app-from-sente was merged into main as a5b1ff0.

Review

Sign in to review this change.
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.
2222
2323 Try it: `ssh` to the forge (`$FORGE_APP_SSH` on an app), press the palette key,
2424 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 @@
11 import type { Route } from "../shared.ts";
22
33 /** 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" };
55 const APP_REFUSALS = {
66 unverified: "the account has not verified its email",
7 no_name: "no `name`, and the `sentence` leaves nothing to make one from",
78 app_limit: "the owner is at the app limit",
89 no_such_agent: "`agent` names no one on the crew",
910 slug_taken: "another app already has this name's slug",
@@ -13,7 +14,7 @@ const APP_REFUSALS = {
1314 export const route: Route = {
1415 method: "post",
1516 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 }",
1718 auth: true,
1819 status: 201,
1920 body: APP_BODY,
src/http/api.ts +37 −7
@@ -181,7 +181,13 @@ import { appLimitWords, appsOwnedBy } from "../actions/app_limit.ts";
181181 import { getQuota } from "../settings/quotas.ts";
182182 import { isSafeRef, isSafeTreePath } from "../git/guard.ts";
183183 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";
185191 import { listDomains } from "../core/domains.ts";
186192 import {
187193 migrateMessage,
@@ -3209,19 +3215,43 @@ export function apiRoutes(router: Router<Handler>): void {
32093215 if (denied) return denied;
32103216 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
32113217 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"> = {
32143219 ...(typeof input.owner === "string" ? { owner: input.owner } : {}),
3215 ...(typeof input.description === "string"
3216 ? { description: input.description }
3217 : {}),
32183220 ...(input.visibility === "public" || input.visibility === "private"
32193221 ? { visibility: input.visibility }
32203222 : {}),
32213223 ...(typeof input.agent === "string" ? { agent: input.agent } : {}),
32223224 // Only a real boolean asks; like the other fields, anything else is ignored.
32233225 ...(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);
32253255 if (!made.ok) {
32263256 if (made.error === "repository_limit")
32273257 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";
22 import { listActiveRuns } from "../../../core/runs.ts";
33 import { getUserByName } from "../../../core/users.ts";
44 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";
66 import { appLimitWords } from "../../../actions/app_limit.ts";
77 import { storageLimitMessage } from "../../../core/storage.ts";
88 import { VERIFY_FIRST } from "../../../actions/verify_words.ts";
@@ -10,20 +10,20 @@ import { good, bad, type Tool, str } from "../shared.ts";
1010
1111 export const tool: Tool = {
1212 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)",
1414 description:
1515 "Make an app from a sentence: a repo, production, and with `description` a first issue.",
1616 inputSchema: {
1717 type: "object",
1818 properties: {
1919 name: { type: "string", maxLength: 100 },
20 sentence: { type: "string", maxLength: 4000, description: "Names the app when `name` is absent." },
2021 description: { type: "string", maxLength: 4000, description: "The first issue." },
2122 owner: { type: "string", maxLength: 39 },
2223 visibility: { type: "string", enum: ["public", "private"] },
2324 agent: { type: "string", maxLength: 39 },
2425 pick: { type: "boolean" },
2526 },
26 required: ["name"],
2727 },
2828 writes: true,
2929 async run(ctx, user, args) {
@@ -32,14 +32,24 @@ export const tool: Tool = {
3232 const owner = text("owner");
3333 const description = text("description");
3434 const agent = text("agent");
35 const made = await newApp(ctx.app, user, {
36 name: str(args["name"]),
35 const options: Omit<NewAppInput, "name" | "description"> = {
3736 ...(owner === undefined ? {} : { owner }),
38 ...(description === undefined ? {} : { description }),
3937 ...(visibility === "public" || visibility === "private" ? { visibility } : {}),
4038 ...(agent === undefined ? {} : { agent }),
4139 ...(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 }
4353 if (!made.ok) {
4454 switch (made.error) {
4555 case "unverified":
@@ -52,6 +62,8 @@ export const tool: Tool = {
5262 return bad("repository_limit", repositoryLimitMessage(ctx.app, user));
5363 case "storage_limit":
5464 return bad("storage_limit", storageLimitMessage(ctx.app, user.id));
65 case "no_name":
66 return bad("no_name", NO_NAME_ERROR);
5567 default:
5668 return bad(made.error, made.error);
5769 }
test/apps-api.test.ts +19
@@ -233,6 +233,25 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
233233 }
234234 }, 60_000);
235235
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
236255 test("POST /apps makes the app, puts the agent on the crew, assigns the first issue", async () => {
237256 const made = await post(
238257 "/api/v1/apps",
test/mcp.test.ts +10
@@ -1658,6 +1658,16 @@ describe("the forge over MCP", () => {
16581658 expect((await tool(rw, "apps") as any).value.some((r: any) => r.app === "alice/readonly")).toBe(false);
16591659 }, 30_000);
16601660
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
16611671 test("new_app with pick lets the planner name the agent", async () => {
16621672 const picked = await createAgent(app, alice, { username: "pickbot", brief: "builds" });
16631673 if (!picked.ok) throw new Error(String(picked.error));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs