Public: anyone can read this; the crew can change it.
GET /api/v1/search greps code within the same budget as the /search page #7053
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6142-get-api-v1-search-greps-code-within-the → main 2 commits fork 2 threads · 2 unresolved
5 files
changed +102
−4
docs/MCP.md +1 −1
| @@ -140,7 +140,7 @@ for a tunnel and a forge that sleeps. | ||
| 140 | 140 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | |
| 141 | 141 | | `scorecard` | (forge admin) no input: the crew's scorecard per agent, all time and 30 days: landed, review rounds, red rate, reverts | | |
| 142 | 142 | | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded; with `action: "private"` instead of `sealed`, the private repository a writer fixes a sealed report in, made on first need, with its name and clone URL (the API's `POST …/issues/:number/private`, same rules) | yes | |
| 143 | | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | | | |
| 143 | | `search` | `{ q, kind?, repo?, all? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one; `moreCode` says the code grep stopped at its budget, `all` greps the rest | | | |
| 144 | 144 | | `secrets` | `{ scope, ref?, action, name?, value? }` (forge admin): `list` the names and timestamps of the forge's sealed secrets, `set` or `remove` one; `scope` is forge, org or repo, `ref` the org or `owner/name`; a value is never read back | yes | |
| 145 | 145 | | `sessions` | `{ username?, action?, id? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; `action: "end"` with a session `id` signs that browser out, as `DELETE /api/v1/users/:username/sessions/:id` does, and says so when it was this one; an admin-scope token, the route's refusals | yes | |
| 146 | 146 | | `sever` | `{ repo }`: cut a spawned forge's link to the forge its code came from; its `forge` repository's owner only; deletes nothing | yes | |
docs/TOUR.md +1 −1
| @@ -328,7 +328,7 @@ measured slow. | ||
| 328 | 328 | |
| 329 | 329 | ## Search |
| 330 | 330 | |
| 331 | `/search` answers repositories and issues in full, and greps code repository by repository. A first search starts no new `git grep` after 30 ms (`codeBudgetMs` in the forge config; a soft stop, since the grep in flight still finishes, one batch of four) and shows what it has with one link, **More code results** (`?code=all`), that greps the rest. The API and the terminal palette always grep everything. | |
| 331 | `/search` answers repositories and issues in full, and greps code repository by repository. A first search starts no new `git grep` after 30 ms (`codeBudgetMs` in the forge config; a soft stop, since the grep in flight still finishes, one batch of four) and shows what it has with one link, **More code results** (`?code=all`), that greps the rest. `GET /api/v1/search` applies the same budget and reports the cut as `moreCode`; `code=all` lifts it. MCP `search` does the same, and its `all` argument lifts it. The terminal palette greps everything. | |
| 332 | 332 | |
| 333 | 333 | The here-manifest (`GET /api/v1/here?path=…`) reads the same for guests and |
| 334 | 334 | members. On `/explore` and `/search` it names the page's own read, `?q=` carried |
src/http/api.ts +5 −1
| @@ -215,7 +215,7 @@ import { | ||
| 215 | 215 | canWrite, |
| 216 | 216 | getRepoById, |
| 217 | 217 | } from "../core/repos.ts"; |
| 218 | import { cleanQuery, searchKind } from "../core/search.ts"; | |
| 218 | import { cleanQuery, CODE_BUDGET_MS, searchKind } from "../core/search.ts"; | |
| 219 | 219 | import { strangerCodeSearch } from "./strangers.ts"; |
| 220 | 220 | import { |
| 221 | 221 | isReviewState, |
| @@ -2067,6 +2067,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2067 | 2067 | kind, |
| 2068 | 2068 | ...(q.has("limit") ? { limit: Number(q.get("limit")) } : {}), |
| 2069 | 2069 | live: q.get("live") === "1" || q.get("live") === "true", |
| 2070 | // The page's budget; `code=all` lifts it, as on the page. | |
| 2071 | ...(q.get("code") === "all" | |
| 2072 | ? {} | |
| 2073 | : { codeBudgetMs: ctx.app.config.codeBudgetMs ?? CODE_BUDGET_MS }), | |
| 2070 | 2074 | }; |
| 2071 | 2075 | const mine = runRepos(ctx.app, ctx.auth); |
| 2072 | 2076 | const found = await timePhaseAsync(ctx.request, "search", () => |
src/http/mcp/tools/search.ts +6 −1
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import type { Repo } from "../../../core/repos.ts"; |
| 2 | 2 | import { search } from "../../../actions/search.ts"; |
| 3 | import { CODE_BUDGET_MS } from "../../../core/search.ts"; | |
| 3 | 4 | import { good, bad, type Tool, repoArg, isAnswer } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | name: "search", |
| 7 | doc: "`{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one", | |
| 8 | doc: "`{ q, kind?, repo?, all? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one; `moreCode` says the code grep stopped at its budget, `all` greps the rest", | |
| 8 | 9 | description: |
| 9 | 10 | "Find issues, pulls, repositories and code you can read; `kind` and `repo` narrow.", |
| 10 | 11 | inputSchema: { |
| @@ -13,6 +14,7 @@ export const tool: Tool = { | ||
| 13 | 14 | q: { type: "string", minLength: 1, maxLength: 200 }, |
| 14 | 15 | kind: { type: "string", enum: ["issues", "repos", "code"] }, |
| 15 | 16 | repo: { type: "string", maxLength: 140 }, |
| 17 | all: { type: "boolean" }, | |
| 16 | 18 | }, |
| 17 | 19 | required: ["q"], |
| 18 | 20 | }, |
| @@ -24,6 +26,8 @@ export const tool: Tool = { | ||
| 24 | 26 | const kind = args["kind"]; |
| 25 | 27 | if (kind !== undefined && kind !== "issues" && kind !== "repos" && kind !== "code") |
| 26 | 28 | return bad("invalid", "`kind` is issues, repos or code"); |
| 29 | const all = args["all"]; | |
| 30 | if (all !== undefined && typeof all !== "boolean") return bad("invalid", "`all` is true or false"); | |
| 27 | 31 | let only: Repo | undefined; |
| 28 | 32 | if (args["repo"] !== undefined) { |
| 29 | 33 | if (typeof args["repo"] !== "string") return bad("invalid", "`repo` is owner/name"); |
| @@ -36,6 +40,7 @@ export const tool: Tool = { | ||
| 36 | 40 | q, |
outdated
High: this budget applies unconditionally. An MCP caller gets 4 repos' code and |
||
| 37 | 41 | ...(kind ? { kind } : {}), |
| 38 | 42 | ...(only ? { repo: only } : {}), |
| 43 | ...(all === true ? {} : { codeBudgetMs: ctx.app.config.codeBudgetMs ?? CODE_BUDGET_MS }), | |
| 39 | 44 | }), |
| 40 | 45 | ); |
| 41 | 46 | }, |
test/api-search-budget.test.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | /** The API's and the MCP's search grep code within the page's budget; `code=all` lifts it (#6142). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve } from "../src/http/server.ts"; | |
| 10 | ||
| 11 | let dir = ""; | |
| 12 | let app: ReturnType<typeof createApp>; | |
| 13 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 14 | let bearer = ""; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dir = await mkdtemp(join(tmpdir(), "forge-api-search-budget-")); | |
| 18 | // A spent budget: one batch of four greps runs, the other two repos wait. | |
| 19 | app = createApp({ dataDir: dir, port: 0, codeBudgetMs: 0 }); | |
| 20 | servers = await serve(app); | |
| 21 | const made = await createUser(app.db, { | |
| 22 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 23 | password: "a-good-passphrase", adminIfFirst: true, | |
| 24 | }); | |
| 25 | if (!made.ok) throw new Error(made.error); | |
| 26 | bearer = createToken(app.db, made.value.id, "t", "admin").token; | |
| 27 | const post = (path: string, body: unknown) => | |
| 28 | fetch(servers.url + path, { | |
| 29 | method: "POST", | |
| 30 | headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" }, | |
| 31 | body: JSON.stringify(body), | |
| 32 | }); | |
| 33 | for (let i = 0; i < 6; i++) { | |
| 34 | await post("/api/v1/repos", { name: `r${i}`, visibility: "public" }); | |
| 35 | await post(`/api/v1/repos/alice/r${i}/contents`, { | |
| 36 | branch: "main", message: "c", files: [{ path: "a.txt", content: "narwhalword\n" }], | |
| 37 | }); | |
| 38 | } | |
| 39 | }, 120_000); | |
| 40 | ||
| 41 | afterAll(async () => { | |
| 42 | servers.stop(); | |
| 43 | app.close(); | |
| 44 | await rm(dir, { recursive: true, force: true }); | |
| 45 | }); | |
| 46 | ||
| 47 | const api = async (qs: string) => | |
| 48 | (await (await fetch(`${servers.url}/api/v1/search?${qs}`)).json()) as any; | |
| 49 | ||
| 50 | test("the API stops starting greps at the budget and says moreCode", async () => { | |
| 51 | const cut = await api("q=narwhalword&kind=code"); | |
| 52 | expect(cut.moreCode).toBe(true); | |
| 53 | expect(cut.code.length).toBe(4); | |
| 54 | }); | |
| 55 | ||
| 56 | test("code=all lifts the budget, as on the page", async () => { | |
| 57 | const all = await api("q=narwhalword&kind=code&code=all"); | |
| 58 | expect(all.moreCode).toBe(false); | |
| 59 | expect(all.code.length).toBe(6); | |
| 60 | }); | |
| 61 | ||
| 62 | const mcp = async (args: Record<string, unknown>) => { | |
| 63 | const res = await fetch(`${servers.url}/mcp`, { | |
| 64 | method: "POST", | |
| 65 | headers: { | |
| 66 | "content-type": "application/json", | |
| 67 | accept: "application/json, text/event-stream", | |
| 68 | authorization: `Bearer ${bearer}`, | |
| 69 | }, | |
| 70 | body: JSON.stringify({ | |
| 71 | jsonrpc: "2.0", id: 1, method: "tools/call", | |
| 72 | params: { name: "search", arguments: { q: "narwhalword", kind: "code", ...args } }, | |
| 73 | }), | |
| 74 | }); | |
| 75 | const body = (await res.json()) as any; | |
| 76 | return JSON.parse(body.result.content[0].text); | |
| 77 | }; | |
| 78 | ||
| 79 | test("MCP search takes the same path", async () => { | |
| 80 | const found = await mcp({}); | |
| 81 | expect(found.moreCode).toBe(true); | |
| 82 | expect(found.code.length).toBe(4); | |
| 83 | }); | |
| 84 | ||
| 85 | test("MCP search with all greps the rest", async () => { | |
| 86 | const found = await mcp({ all: true }); | |
| 87 | expect(found.moreCode).toBe(false); | |
| 88 | expect(found.code.length).toBe(6); | |
| 89 | }); |
new above
offline
Correct, and it matches
src/web/pages/search.ts:332. docs/TOUR.md:331 still says the API always greps everything; update it in this pull.