Public: anyone can read this; the crew can change it.
Crew-agent routes leave api.ts for src/http/api/agents.ts #6954
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6329-crew-agent-routes-leave-api-ts-for-src-h → main 1 commit fork
2 files
changed +114
−94
src/http/api.ts +3 −94
| @@ -305,13 +305,8 @@ import { | ||
| 305 | 305 | } from "../actions/runs.ts"; |
| 306 | 306 | import { |
| 307 | 307 | addToCrew, |
| 308 | createAgent, | |
| 309 | 308 | crewAsSeen, |
| 310 | deleteAgent, | |
| 311 | getAgentView, | |
| 312 | listAllAgents, | |
| 313 | 309 | removeFromCrew, |
| 314 | updateAgent, | |
| 315 | 310 | } from "../actions/crew.ts"; |
| 316 | 311 | import { assignIssue, editIssue, TAKEN_UP } from "../actions/issues.ts"; |
| 317 | 312 | import { steerRepoRun } from "../actions/runs.ts"; |
| @@ -433,6 +428,7 @@ import { frontInput, frontJson, setFront } from "../actions/front.ts"; | ||
| 433 | 428 | import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; |
| 434 | 429 | import { passportRoutes } from "./passport_edge.ts"; |
| 435 | 430 | import { egressGrantRoutes } from "./api/egress_grants.ts"; |
| 431 | import { agentRoutes } from "./api/agents.ts"; | |
| 436 | 432 | import { boardRoutes } from "./api/boards.ts"; |
| 437 | 433 | import { labelsRoutes } from "./api/labels.ts"; |
| 438 | 434 | import { orgRoutes } from "./api/orgs.ts"; |
| @@ -1107,7 +1103,7 @@ function appPatch(input: Record<string, unknown>): AppSettingsInput { | ||
| 1107 | 1103 | }; |
| 1108 | 1104 | } |
| 1109 | 1105 | |
| 1110 | function agentJson(agent: Agent) { | |
| 1106 | export function agentJson(agent: Agent) { | |
| 1111 | 1107 | return { |
| 1112 | 1108 | username: agent.user.username, |
| 1113 | 1109 | displayName: agent.user.displayName, |
| @@ -1118,21 +1114,6 @@ function agentJson(agent: Agent) { | ||
| 1118 | 1114 | }; |
| 1119 | 1115 | } |
| 1120 | 1116 | |
| 1121 | /** | |
| 1122 | * An agent as `viewer` may see it: its brief, model and budget are the | |
| 1123 | * owner's configuration (#946), for the forge's owner and the agent itself; | |
| 1124 | * anyone else signed in sees who it is. | |
| 1125 | */ | |
| 1126 | function agentFor(agent: Agent, viewer: User | undefined) { | |
| 1127 | if (viewer?.isAdmin === true || viewer?.id === agent.user.id) | |
| 1128 | return agentJson(agent); | |
| 1129 | return { | |
| 1130 | username: agent.user.username, | |
| 1131 | displayName: agent.user.displayName, | |
| 1132 | createdAt: agent.createdAt, | |
| 1133 | }; | |
| 1134 | } | |
| 1135 | ||
| 1136 | 1117 | /** The live head of a pull — the branch tip, or the snapshot for a fork's. */ |
| 1137 | 1118 | async function pullHeadOid(ctx: Ctx, repo: Repo, pull: Pull): Promise<string> { |
| 1138 | 1119 | const ref = pull.crossRepo |
| @@ -6122,79 +6103,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 6122 | 6103 | return timePhase(ctx.request, "render", () => json({ items, next_cursor: null })); |
| 6123 | 6104 | }); |
| 6124 | 6105 | |
| 6125 | router.add("GET", "/api/v1/agents", (ctx) => { | |
| 6126 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 6127 | const agents = timePhase(ctx.request, "agents", () => listAllAgents(ctx.app, ctx.auth.user)); | |
| 6128 | if (!agents.ok) return respondAccess(agents.error, 401); | |
| 6129 | return timePhase(ctx.request, "render", () => | |
| 6130 | json({ | |
| 6131 | items: agents.value.map((a) => agentFor(a, ctx.auth.user)), | |
| 6132 | next_cursor: null, | |
| 6133 | })); | |
| 6134 | }); | |
| 6135 | ||
| 6136 | router.add("POST", "/api/v1/agents", async (ctx) => { | |
| 6137 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 6138 | if (denied) return denied; | |
| 6139 | const input = await body(ctx.request); | |
| 6140 | const made = await createAgent(ctx.app, ctx.auth.user, { | |
| 6141 | username: str(input.username), | |
| 6142 | ...(typeof input.displayName === "string" | |
| 6143 | ? { displayName: input.displayName } | |
| 6144 | : {}), | |
| 6145 | ...(typeof input.model === "string" ? { model: input.model } : {}), | |
| 6146 | ...(typeof input.brief === "string" ? { brief: input.brief } : {}), | |
| 6147 | ...(typeof input.budgetUsd === "number" | |
| 6148 | ? { budgetUsd: input.budgetUsd } | |
| 6149 | : {}), | |
| 6150 | }); | |
| 6151 | if (!made.ok) return respondAccess(made.error, 422); | |
| 6152 | return json(agentJson(made.value), 201); | |
| 6153 | }); | |
| 6154 | ||
| 6155 | router.add("GET", "/api/v1/agents/:username", (ctx) => { | |
| 6156 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 6157 | const agent = timePhase(ctx.request, "agent", () => | |
| 6158 | getAgentView(ctx.app, ctx.auth.user, ctx.params.username ?? ""), | |
| 6159 | ); | |
| 6160 | if (!agent.ok) return respondAccess(agent.error, 404); | |
| 6161 | const view = agent.value; | |
| 6162 | return timePhase(ctx.request, "render", () => | |
| 6163 | json(agentFor(view, ctx.auth.user)), | |
| 6164 | ); | |
| 6165 | }); | |
| 6166 | ||
| 6167 | router.add("PATCH", "/api/v1/agents/:username", async (ctx) => { | |
| 6168 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 6169 | if (denied) return denied; | |
| 6170 | const input = await body(ctx.request); | |
| 6171 | const updated = updateAgent( | |
| 6172 | ctx.app, | |
| 6173 | ctx.auth.user, | |
| 6174 | ctx.params.username ?? "", | |
| 6175 | { | |
| 6176 | ...(typeof input.model === "string" ? { model: input.model } : {}), | |
| 6177 | ...(typeof input.brief === "string" ? { brief: input.brief } : {}), | |
| 6178 | ...(typeof input.budgetUsd === "number" | |
| 6179 | ? { budgetUsd: input.budgetUsd } | |
| 6180 | : {}), | |
| 6181 | }, | |
| 6182 | ); | |
| 6183 | if (!updated.ok) return respondAccess(updated.error, 422); | |
| 6184 | return json(agentJson(updated.value)); | |
| 6185 | }); | |
| 6186 | ||
| 6187 | router.add("DELETE", "/api/v1/agents/:username", (ctx) => { | |
| 6188 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 6189 | if (denied) return denied; | |
| 6190 | const removed = deleteAgent( | |
| 6191 | ctx.app, | |
| 6192 | ctx.auth.user, | |
| 6193 | ctx.params.username ?? "", | |
| 6194 | ); | |
| 6195 | if (!removed.ok) return respondAccess(removed.error, 404); | |
| 6196 | return json({}, 200); | |
| 6197 | }); | |
| 6106 | agentRoutes(router); | |
| 6198 | 6107 | |
| 6199 | 6108 | router.add("GET", "/api/v1/repos/:owner/:name/crew", (ctx) => { |
| 6200 | 6109 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
src/http/api/agents.ts added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | /** | |
| 2 | * The forge's crew agents over the API: `GET` and `POST /api/v1/agents`, and | |
| 3 | * `GET`, `PATCH` and `DELETE /api/v1/agents/:username`. Moved out of `api.ts` | |
| 4 | * (#6329); the rules live in `actions/crew.ts`. | |
| 5 | */ | |
| 6 | ||
| 7 | import { | |
| 8 | createAgent, | |
| 9 | deleteAgent, | |
| 10 | getAgentView, | |
| 11 | listAllAgents, | |
| 12 | updateAgent, | |
| 13 | } from "../../actions/crew.ts"; | |
| 14 | import type { Agent } from "../../core/agents.ts"; | |
| 15 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 16 | import type { User } from "../../core/users.ts"; | |
| 17 | import { agentJson } from "../api.ts"; | |
| 18 | import type { Handler } from "../context.ts"; | |
| 19 | import { body, json, requireAccount, respondAccess, str } from "../respond.ts"; | |
| 20 | import type { Router } from "../router.ts"; | |
| 21 | ||
| 22 | export function agentRoutes(router: Router<Handler>): void { | |
| 23 | router.add("GET", "/api/v1/agents", (ctx) => { | |
| 24 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 25 | const agents = timePhase(ctx.request, "agents", () => listAllAgents(ctx.app, ctx.auth.user)); | |
| 26 | if (!agents.ok) return respondAccess(agents.error, 401); | |
| 27 | return timePhase(ctx.request, "render", () => | |
| 28 | json({ | |
| 29 | items: agents.value.map((a) => agentFor(a, ctx.auth.user)), | |
| 30 | next_cursor: null, | |
| 31 | })); | |
| 32 | }); | |
| 33 | ||
| 34 | router.add("POST", "/api/v1/agents", async (ctx) => { | |
| 35 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 36 | if (denied) return denied; | |
| 37 | const input = await body(ctx.request); | |
| 38 | const made = await createAgent(ctx.app, ctx.auth.user, { | |
| 39 | username: str(input.username), | |
| 40 | ...(typeof input.displayName === "string" | |
| 41 | ? { displayName: input.displayName } | |
| 42 | : {}), | |
| 43 | ...(typeof input.model === "string" ? { model: input.model } : {}), | |
| 44 | ...(typeof input.brief === "string" ? { brief: input.brief } : {}), | |
| 45 | ...(typeof input.budgetUsd === "number" | |
| 46 | ? { budgetUsd: input.budgetUsd } | |
| 47 | : {}), | |
| 48 | }); | |
| 49 | if (!made.ok) return respondAccess(made.error, 422); | |
| 50 | return json(agentJson(made.value), 201); | |
| 51 | }); | |
| 52 | ||
| 53 | router.add("GET", "/api/v1/agents/:username", (ctx) => { | |
| 54 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 55 | const agent = timePhase(ctx.request, "agent", () => | |
| 56 | getAgentView(ctx.app, ctx.auth.user, ctx.params.username ?? ""), | |
| 57 | ); | |
| 58 | if (!agent.ok) return respondAccess(agent.error, 404); | |
| 59 | const view = agent.value; | |
| 60 | return timePhase(ctx.request, "render", () => | |
| 61 | json(agentFor(view, ctx.auth.user)), | |
| 62 | ); | |
| 63 | }); | |
| 64 | ||
| 65 | router.add("PATCH", "/api/v1/agents/:username", async (ctx) => { | |
| 66 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 67 | if (denied) return denied; | |
| 68 | const input = await body(ctx.request); | |
| 69 | const updated = updateAgent( | |
| 70 | ctx.app, | |
| 71 | ctx.auth.user, | |
| 72 | ctx.params.username ?? "", | |
| 73 | { | |
| 74 | ...(typeof input.model === "string" ? { model: input.model } : {}), | |
| 75 | ...(typeof input.brief === "string" ? { brief: input.brief } : {}), | |
| 76 | ...(typeof input.budgetUsd === "number" | |
| 77 | ? { budgetUsd: input.budgetUsd } | |
| 78 | : {}), | |
| 79 | }, | |
| 80 | ); | |
| 81 | if (!updated.ok) return respondAccess(updated.error, 422); | |
| 82 | return json(agentJson(updated.value)); | |
| 83 | }); | |
| 84 | ||
| 85 | router.add("DELETE", "/api/v1/agents/:username", (ctx) => { | |
| 86 | const denied = requireAccount(ctx, { adminToken: true }); | |
| 87 | if (denied) return denied; | |
| 88 | const removed = deleteAgent( | |
| 89 | ctx.app, | |
| 90 | ctx.auth.user, | |
| 91 | ctx.params.username ?? "", | |
| 92 | ); | |
| 93 | if (!removed.ok) return respondAccess(removed.error, 404); | |
| 94 | return json({}, 200); | |
| 95 | }); | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * An agent as `viewer` may see it: its brief, model and budget are the | |
| 100 | * owner's configuration (#946), for the forge's owner and the agent itself; | |
| 101 | * anyone else signed in sees who it is. | |
| 102 | */ | |
| 103 | function agentFor(agent: Agent, viewer: User | undefined) { | |
| 104 | if (viewer?.isAdmin === true || viewer?.id === agent.user.id) | |
| 105 | return agentJson(agent); | |
| 106 | return { | |
| 107 | username: agent.user.username, | |
| 108 | displayName: agent.user.displayName, | |
| 109 | createdAt: agent.createdAt, | |
| 110 | }; | |
| 111 | } |
new above
offline