Public: anyone can read this; the crew can change it.
POST /api/v1/apps accepts `pick: true`, and its answer says which agent the plan picked and why #3735
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3680-post-api-v1-apps-accepts-pick-true-and-i → main 6 commits fork
4 files
changed +69
−1
docs/APPS.md +6
| @@ -204,6 +204,12 @@ What a person sees, in order: | ||
| 204 | 204 | Nothing above required a terminal. Everything above is also reachable from |
| 205 | 205 | one, and from the JSON API, because each step is an action. |
| 206 | 206 | |
| 207 | `POST /api/v1/apps` takes `name`, `owner`, `description`, `visibility`, `agent` | |
| 208 | and `pick`: `pick: true` turns the description into a checklist and lets the | |
| 209 | plan pick the agent (ignored when `agent` is given or there is no | |
| 210 | description). The 201 answer carries `plan: {agent, summary, confidence}` when | |
| 211 | a pick happened and `plan: null` otherwise, planning being unavailable included. | |
| 212 | ||
| 207 | 213 | ## How an app is declared |
| 208 | 214 | |
| 209 | 215 | The repository says how to run itself, in `forge.json` at its root: |
src/contract/openapi.ts +1 −1
| @@ -27,7 +27,7 @@ interface Route { | ||
| 27 | 27 | const LIST_QUERY = { state: "string", limit: "integer", cursor: "integer", label: "string", author: "string" }; |
| 28 | 28 | |
| 29 | 29 | /** What `POST /api/v1/apps` reads from its body, and the refusals it answers. */ |
| 30 | const APP_BODY = { name: "string", owner: "string", description: "string", visibility: "string", agent: "string" }; | |
| 30 | const APP_BODY = { name: "string", owner: "string", description: "string", visibility: "string", agent: "string", pick: "boolean" }; | |
| 31 | 31 | const APP_REFUSALS = { |
| 32 | 32 | unverified: "the account has not verified its email", |
| 33 | 33 | app_limit: "the owner is at the app limit", |
src/http/api.ts +3
| @@ -5261,6 +5261,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5261 | 5261 | ? { visibility: input.visibility } |
| 5262 | 5262 | : {}), |
| 5263 | 5263 | ...(typeof input.agent === "string" ? { agent: input.agent } : {}), |
| 5264 | // Only a real boolean asks; like the other fields, anything else is ignored. | |
| 5265 | ...(input.pick === true ? { pickAgent: true } : {}), | |
| 5264 | 5266 | }, (name, ms) => notePhase(ctx.request, name, ms)); |
| 5265 | 5267 | if (!made.ok) { |
| 5266 | 5268 | if (made.error === "repository_limit") |
| @@ -5286,6 +5288,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5286 | 5288 | appsRuntime(ctx.app), |
| 5287 | 5289 | ), |
| 5288 | 5290 | issue: made.value.issue ? withLabels(ctx, made.value.issue) : null, |
| 5291 | plan: made.value.plan ?? null, | |
| 5289 | 5292 | }, |
| 5290 | 5293 | 201, |
| 5291 | 5294 | ); |
test/apps-api.test.ts +59
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | import { chmodSync } from "node:fs"; | |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 10 | import { tmpdir } from "node:os"; |
| 10 | 11 | import { join } from "node:path"; |
| @@ -174,6 +175,64 @@ describe("apps, runs and the crew over the API", () => { | ||
| 174 | 175 | expect(login.status).toBe(401); |
| 175 | 176 | }); |
| 176 | 177 | |
| 178 | test("POST /apps with pick: true says which agent the plan picked; without a plan, plan is null", async () => { | |
| 179 | const dir = join(dataDir, "fake-plan"); | |
| 180 | await Bun.write( | |
| 181 | dir, | |
| 182 | `#!/bin/sh | |
| 183 | if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi | |
| 184 | printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"checklist\\":[\\"One\\"],\\"agent\\":\\"bot\\",\\"confidence\\":0.7}"}' | |
| 185 | `, | |
| 186 | ); | |
| 187 | chmodSync(dir, 0o755); | |
| 188 | const before = process.env["FORGE_CLAUDE"]; | |
| 189 | try { | |
| 190 | process.env["FORGE_CLAUDE"] = dir; | |
| 191 | const picked = await post( | |
| 192 | "/api/v1/apps", | |
| 193 | { name: "picked", description: "Keep notes", pick: true }, | |
| 194 | rw, | |
| 195 | ); | |
| 196 | expect(picked.status).toBe(201); | |
| 197 | const body = (await picked.json()) as { | |
| 198 | plan: { agent: string; summary: string; confidence: number } | null; | |
| 199 | issue: { assigneeName: string } | null; | |
| 200 | }; | |
| 201 | expect(body.plan).toEqual({ agent: "bot", summary: "Build it", confidence: 0.7 }); | |
| 202 | expect(body.issue?.assigneeName).toBe("bot"); | |
| 203 | ||
| 204 | // No pick asked, or a pick that is not the boolean true: no plan. | |
| 205 | for (const [name, extra] of [ | |
| 206 | ["nopick", {}], | |
| 207 | ["strpick", { pick: "true" }], | |
| 208 | ["withagent", { pick: true, agent: "bot" }], | |
| 209 | ] as const) { | |
| 210 | const res = await post( | |
| 211 | "/api/v1/apps", | |
| 212 | { name, description: "Keep notes", ...extra }, | |
| 213 | rw, | |
| 214 | ); | |
| 215 | expect(res.status).toBe(201); | |
| 216 | expect(((await res.json()) as { plan: unknown }).plan).toBeNull(); | |
| 217 | } | |
| 218 | ||
| 219 | // Planning unavailable: the app is still made, plan is null. | |
| 220 | process.env["FORGE_CLAUDE"] = join(dataDir, "no-such-binary"); | |
| 221 | const bare = await post( | |
| 222 | "/api/v1/apps", | |
| 223 | { name: "unplanned", description: "Keep notes", pick: true }, | |
| 224 | rw, | |
| 225 | ); | |
| 226 | expect(bare.status).toBe(201); | |
| 227 | const bareBody = (await bare.json()) as { app: { slug: string }; plan: unknown }; | |
| 228 | expect(bareBody.app.slug).toBe("unplanned"); | |
| 229 | expect(bareBody.plan).toBeNull(); | |
| 230 | } finally { | |
| 231 | if (before === undefined) delete process.env["FORGE_CLAUDE"]; | |
| 232 | else process.env["FORGE_CLAUDE"] = before; | |
| 233 | } | |
| 234 | }, 60_000); | |
| 235 | ||
| 177 | 236 | test("POST /apps makes the app, puts the agent on the crew, assigns the first issue", async () => { |
| 178 | 237 | const made = await post( |
| 179 | 238 | "/api/v1/apps", |
new above
offline