Public: anyone can read this; the crew can change it.
/search greps code within a 30 ms budget and offers "More code results": land pull #3732's branch with its last review asks #3767
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3758-search-greps-code-within-a-30-ms-budget → main 5 commits fork
8 files
changed +191
−6
README.md +4
| @@ -601,6 +601,10 @@ fleet's receipt: the page that answers lists what was archived and an **Undo** | ||
| 601 | 601 | button for the person who ran it (kept for a day), after which the apps are back |
| 602 | 602 | on the home. The terminal's dashboard archives and undoes the same way (below), and so does the MCP `archive` tool given `repos` (at most 50; it answers the `operation` id, which `limb` with `action: "undo"` reverts for a day); each door refreshes an open web home (the `fleet.operated` event). |
| 603 | 603 | |
| 604 | ## Search | |
| 605 | ||
| 606 | `/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. | |
| 607 | ||
| 604 | 608 | ## Health |
| 605 | 609 | |
| 606 | 610 | `GET /api/v1/health` (and the MCP `health` tool) answer a site admin with |
src/actions/search.ts +6 −1
| @@ -57,6 +57,8 @@ export interface SearchInput { | ||
| 57 | 57 | * @default true |
| 58 | 58 | */ |
| 59 | 59 | withCode?: boolean; |
| 60 | /** Start no new grep after this many ms and report `moreCode`. @default unset: grep every repository */ | |
| 61 | codeBudgetMs?: number; | |
| 60 | 62 | } |
| 61 | 63 | |
| 62 | 64 | /** |
| @@ -80,6 +82,7 @@ export async function search( | ||
| 80 | 82 | issues: [], |
| 81 | 83 | code: [], |
| 82 | 84 | truncated: { repos: false, issues: false, code: false }, |
| 85 | moreCode: false, | |
| 83 | 86 | }; |
| 84 | 87 | if (q === "") return empty; |
| 85 | 88 | |
| @@ -125,8 +128,9 @@ export async function search( | ||
| 125 | 128 | visible, |
| 126 | 129 | q, |
| 127 | 130 | Math.min(limit * 5, MAX_CODE_HITS), |
| 131 | input.codeBudgetMs ?? Infinity, | |
| 128 | 132 | ) |
| 129 | : { hits: [], truncated: false }; | |
| 133 | : { hits: [], truncated: false, more: false }; | |
| 130 | 134 | |
| 131 | 135 | return { |
| 132 | 136 | repos, |
| @@ -137,6 +141,7 @@ export async function search( | ||
| 137 | 141 | issues: localIssueCount >= limit, |
| 138 | 142 | code: code.truncated, |
| 139 | 143 | }, |
| 144 | moreCode: code.more, | |
| 140 | 145 | }; |
| 141 | 146 | } |
| 142 | 147 |
src/app.ts +2
| @@ -40,6 +40,8 @@ export interface ForgeConfig { | ||
| 40 | 40 | seedSignup?: boolean; |
| 41 | 41 | /** The disk probe vitals reads; the volume under `dataDir` when unset. Tests give it a reading. */ |
| 42 | 42 | disk?: (dir: string) => { freeBytes: number; totalBytes: number }; |
| 43 | /** How long the search page's first code search may keep starting greps, in ms. @default 30 (CODE_BUDGET_MS) */ | |
| 44 | codeBudgetMs?: number; | |
| 43 | 45 | /** @default 3000 */ |
| 44 | 46 | port?: number; |
| 45 | 47 | /** The hostname clone URLs are built from. @default "localhost" */ |
src/core/search.ts +14 −3
| @@ -43,6 +43,9 @@ export const MAX_CODE_HITS = 200; | ||
| 43 | 43 | export const MAX_HITS_PER_REPO = 20; |
| 44 | 44 | /** A grep that has not answered in this long is abandoned, not waited on. */ |
| 45 | 45 | export const GREP_TIMEOUT_MS = 3_000; |
| 46 | /** A first code search starts no new grep after this long; `?code=all` runs the rest. */ | |
| 47 | export const CODE_BUDGET_MS = 30; | |
| 48 | ||
| 46 | 49 | /** Longest line of code echoed back; the rest is off-screen anyway. */ |
| 47 | 50 | const MAX_LINE_CHARS = 400; |
| 48 | 51 | |
| @@ -120,6 +123,8 @@ export interface SearchResults { | ||
| 120 | 123 | readonly issues: boolean; |
| 121 | 124 | readonly code: boolean; |
| 122 | 125 | }; |
| 126 | /** True when the code budget ran out with repositories still ungrepped. */ | |
| 127 | readonly moreCode: boolean; | |
| 123 | 128 | } |
| 124 | 129 | |
| 125 | 130 | /** |
| @@ -480,11 +485,17 @@ export async function grepRepos( | ||
| 480 | 485 | repos: readonly Repo[], |
| 481 | 486 | query: string, |
| 482 | 487 | limit = MAX_CODE_HITS, |
| 483 | ): Promise<{ hits: CodeHit[]; truncated: boolean }> { | |
| 488 | /** No new batch starts after this many ms; the rest is reported as `more`. */ | |
| 489 | budgetMs = Infinity, | |
| 490 | ): Promise<{ hits: CodeHit[]; truncated: boolean; more: boolean }> { | |
| 484 | 491 | const hits: CodeHit[] = []; |
| 485 | 492 | const considered = repos.slice(0, MAX_SEARCH_REPOS); |
| 486 | 493 | const batch = 4; |
| 494 | const started = performance.now(); | |
| 487 | 495 | for (let i = 0; i < considered.length; i += batch) { |
| 496 | if (i > 0 && performance.now() - started >= budgetMs) { | |
| 497 | return { hits, truncated: repos.length > MAX_SEARCH_REPOS, more: true }; | |
| 498 | } | |
| 488 | 499 | const slice = considered.slice(i, i + batch); |
| 489 | 500 | const found = await Promise.all( |
| 490 | 501 | slice.map((repo) => |
| @@ -497,7 +508,7 @@ export async function grepRepos( | ||
| 497 | 508 | ); |
| 498 | 509 | for (const [j, repo] of slice.entries()) { |
| 499 | 510 | for (const hit of found[j] ?? []) { |
| 500 | if (hits.length >= limit) return { hits, truncated: true }; | |
| 511 | if (hits.length >= limit) return { hits, truncated: true, more: false }; | |
| 501 | 512 | hits.push({ |
| 502 | 513 | repo: { owner: repo.ownerName, name: repo.name }, |
| 503 | 514 | ...hit, |
| @@ -505,5 +516,5 @@ export async function grepRepos( | ||
| 505 | 516 | } |
| 506 | 517 | } |
| 507 | 518 | } |
| 508 | return { hits, truncated: repos.length > MAX_SEARCH_REPOS }; | |
| 519 | return { hits, truncated: repos.length > MAX_SEARCH_REPOS, more: false }; | |
| 509 | 520 | } |
src/web/pages/search.ts +23 −2
| @@ -18,6 +18,7 @@ import { getPull } from "../../core/pulls.ts"; | ||
| 18 | 18 | import { canWrite, enterableApps, getRepo } from "../../core/repos.ts"; |
| 19 | 19 | import { |
| 20 | 20 | cleanQuery, |
| 21 | CODE_BUDGET_MS, | |
| 21 | 22 | searchKind, |
| 22 | 23 | type CodeHit, |
| 23 | 24 | type IssueHit, |
| @@ -57,6 +58,8 @@ interface Asked { | ||
| 57 | 58 | readonly query: string; |
| 58 | 59 | readonly kind: SearchKind; |
| 59 | 60 | readonly live: boolean; |
| 61 | /** `?code=all`: grep every repository, not just what fits the budget. */ | |
| 62 | readonly codeAll: boolean; | |
| 60 | 63 | } |
| 61 | 64 | |
| 62 | 65 | /** The apps among what the viewer can see, by id and by `owner/name`. One |
| @@ -258,12 +261,13 @@ const WORDS: readonly Words[] = [ | ||
| 258 | 261 | |
| 259 | 262 | function searchHref( |
| 260 | 263 | a: Asked, |
| 261 | over: { kind?: SearchKind; live?: boolean } = {}, | |
| 264 | over: { kind?: SearchKind; live?: boolean; codeAll?: boolean } = {}, | |
| 262 | 265 | ): string { |
| 263 | 266 | const kind = over.kind ?? a.kind; |
| 264 | 267 | const params = new URLSearchParams({ q: a.query }); |
| 265 | 268 | if (kind !== "all") params.set("kind", kind); |
| 266 | 269 | if (over.live ?? a.live) params.set("live", "1"); |
| 270 | if (over.codeAll ?? a.codeAll) params.set("code", "all"); | |
| 267 | 271 | return `/search?${params.toString()}`; |
| 268 | 272 | } |
| 269 | 273 | |
| @@ -272,6 +276,7 @@ function section( | ||
| 272 | 276 | [kind, , title, emptyText]: Words, |
| 273 | 277 | rows: Html[], |
| 274 | 278 | truncated: boolean, |
| 279 | after: Html | null = null, | |
| 275 | 280 | ): Html { |
| 276 | 281 | const emptyIcon = |
| 277 | 282 | kind === "repos" ? "repo" : kind === "issues" ? "issue" : "code"; |
| @@ -287,6 +292,7 @@ function section( | ||
| 287 | 292 | ${icon(emptyIcon)}<span>${emptyText}</span> |
| 288 | 293 | </p>` |
| 289 | 294 | : listCard(rows)} |
| 295 | ${after} | |
| 290 | 296 | ${when( |
| 291 | 297 | truncated, |
| 292 | 298 | () => |
| @@ -320,6 +326,9 @@ async function find( | ||
| 320 | 326 | kind: a.kind, |
| 321 | 327 | live: a.live, |
| 322 | 328 | withCode, |
| 329 | ...(a.codeAll | |
| 330 | ? {} | |
| 331 | : { codeBudgetMs: ctx.app.config.codeBudgetMs ?? CODE_BUDGET_MS }), | |
| 323 | 332 | }); |
| 324 | 333 | return held !== undefined && !held.allowed |
| 325 | 334 | ? { found, busy: held.retryAfter } |
| @@ -346,6 +355,7 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 346 | 355 | query: cleanQuery(params.get("q") ?? ""), |
| 347 | 356 | kind: searchKind(params.get("kind")), |
| 348 | 357 | live: params.get("live") === "1", |
| 358 | codeAll: params.get("code") === "all", | |
| 349 | 359 | }; |
| 350 | 360 | |
| 351 | 361 | const form = html`<form |
| @@ -455,6 +465,15 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 455 | 465 | ], |
| 456 | 466 | }; |
| 457 | 467 | |
| 468 | const moreCode = when( | |
| 469 | found.moreCode, | |
| 470 | () => | |
| 471 | html`<p class="muted small mt" data-more-code> | |
| 472 | Code was searched in the first repositories only. | |
| 473 | <a href="${searchHref(a, { codeAll: true })}#code">More code results</a> | |
| 474 | </p>`, | |
| 475 | ); | |
| 476 | ||
| 458 | 477 | const body = html`${pageHead(html`Results for “${a.query}”`)} ${form} |
| 459 | 478 | <div class="filters">${chips}</div> |
| 460 | 479 | ${liveToggle} |
| @@ -471,7 +490,9 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 471 | 490 | .filter( |
| 472 | 491 | ([kind]) => kind !== "all" && (a.kind === "all" || a.kind === kind), |
| 473 | 492 | ) |
| 474 | .map((w) => section(a, w, ...rows[w[0]]()))}`; | |
| 493 | .map((w) => | |
| 494 | section(a, w, ...rows[w[0]](), w[0] === "code" ? moreCode : null), | |
| 495 | )}`; | |
| 475 | 496 | const response = page({ ctx, title: `Search · ${a.query}`, body }); |
| 476 | 497 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 477 | 498 | return response; |
test/search-budget.test.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** A first code search stops starting greps at its budget and offers the rest (#3385). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { cp, mkdir, mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { CODE_BUDGET_MS, grepCount, grepRepos } from "../src/core/search.ts"; | |
| 7 | import type { Repo } from "../src/core/repos.ts"; | |
| 8 | ||
| 9 | let root = ""; | |
| 10 | const repos: Repo[] = []; | |
| 11 | ||
| 12 | const git = (cwd: string, ...args: string[]) => { | |
| 13 | const r = Bun.spawnSync(["git", ...args], { | |
| 14 | cwd, | |
| 15 | env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }, | |
| 16 | }); | |
| 17 | if (r.exitCode !== 0) throw new Error(r.stderr.toString()); | |
| 18 | }; | |
| 19 | ||
| 20 | beforeAll(async () => { | |
| 21 | root = await mkdtemp(join(tmpdir(), "forge-search-budget-")); | |
| 22 | const work = join(root, "work"); | |
| 23 | await mkdir(work); | |
| 24 | git(work, "init", "-q", "-b", "main"); | |
| 25 | await Bun.write(join(work, "a.txt"), "budgetword\n"); | |
| 26 | git(work, "add", "a.txt"); | |
| 27 | git(work, "commit", "-q", "-m", "c"); | |
| 28 | await mkdir(join(root, "u")); | |
| 29 | git(root, "clone", "-q", "--bare", work, join(root, "u", "r0.git")); | |
| 30 | for (let i = 0; i < 200; i++) { | |
| 31 | if (i > 0) await cp(join(root, "u", "r0.git"), join(root, "u", `r${i}.git`), { recursive: true }); | |
| 32 | repos.push({ id: `b${i}`, ownerName: "u", name: `r${i}`, defaultBranch: "main" } as Repo); | |
| 33 | } | |
| 34 | }, 60_000); | |
| 35 | ||
| 36 | afterAll(() => rm(root, { recursive: true, force: true })); | |
| 37 | ||
| 38 | test("the budget is 30 ms", () => { | |
| 39 | expect(CODE_BUDGET_MS).toBe(30); | |
| 40 | }); | |
| 41 | ||
| 42 | test("a spent budget greps one batch of four, keeps what it has, and says more remains", async () => { | |
| 43 | const before = grepCount(); | |
| 44 | const cut = await grepRepos(root, repos, "budgetword", 200, 0); | |
| 45 | expect(grepCount() - before).toBe(4); | |
| 46 | expect(cut.more).toBe(true); | |
| 47 | expect(cut.hits.map((h) => h.repo.name)).toEqual(["r0", "r1", "r2", "r3"]); | |
| 48 | }); | |
| 49 | ||
| 50 | test("no budget greps every considered repo, and nothing is left over", async () => { | |
| 51 | const full = await grepRepos(root, repos, "budgetword", 200); | |
| 52 | expect(full.more).toBe(false); | |
| 53 | expect(full.hits.length).toBe(50); | |
| 54 | }); |
test/search-more-code.test.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** The search page offers the code it skipped, and `?code=all` runs it (#3385). */ | |
| 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 | interface Site { | |
| 12 | dir: string; | |
| 13 | app: ReturnType<typeof createApp>; | |
| 14 | servers: Awaited<ReturnType<typeof serve>>; | |
| 15 | } | |
| 16 | ||
| 17 | /** An app with six public repositories that all hold "zebrafish". */ | |
| 18 | async function site(codeBudgetMs: number): Promise<Site> { | |
| 19 | const dir = await mkdtemp(join(tmpdir(), "forge-search-more-")); | |
| 20 | const app = createApp({ dataDir: dir, port: 0, codeBudgetMs }); | |
| 21 | const servers = await serve(app); | |
| 22 | const made = await createUser(app.db, { | |
| 23 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 24 | password: "a-good-passphrase", adminIfFirst: true, | |
| 25 | }); | |
| 26 | if (!made.ok) throw new Error(made.error); | |
| 27 | const bearer = createToken(app.db, made.value.id, "t", "admin").token; | |
| 28 | const api = (path: string, body: unknown) => | |
| 29 | fetch(servers.url + path, { | |
| 30 | method: "POST", | |
| 31 | headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" }, | |
| 32 | body: JSON.stringify(body), | |
| 33 | }); | |
| 34 | for (let i = 0; i < 6; i++) { | |
| 35 | await api("/api/v1/repos", { name: `r${i}`, visibility: "public" }); | |
| 36 | await api(`/api/v1/repos/alice/r${i}/contents`, { | |
| 37 | branch: "main", message: "c", files: [{ path: "a.txt", content: "zebrafish\n" }], | |
| 38 | }); | |
| 39 | } | |
| 40 | return { dir, app, servers }; | |
| 41 | } | |
| 42 | ||
| 43 | let spent: Site; | |
| 44 | let roomy: Site; | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | spent = await site(0); | |
| 48 | roomy = await site(60_000); | |
| 49 | }, 120_000); | |
| 50 | ||
| 51 | afterAll(async () => { | |
| 52 | for (const s of [spent, roomy]) { | |
| 53 | s.servers.stop(); | |
| 54 | s.app.close(); | |
| 55 | await rm(s.dir, { recursive: true, force: true }); | |
| 56 | } | |
| 57 | }); | |
| 58 | ||
| 59 | const page = async (site: Site, qs: string) => | |
| 60 | (await fetch(`${site.servers.url}/search?${qs}`)).text(); | |
| 61 | const rowsOf = (html: string) => (html.match(/class="row code-hit"/g) ?? []).length; | |
| 62 | ||
| 63 | test("a spent budget shows what it has and one link that runs the rest", async () => { | |
| 64 | const html = await page(spent, "q=zebrafish&live=1"); | |
| 65 | expect(rowsOf(html)).toBe(4); | |
| 66 | expect(html.match(/data-more-code/g)?.length).toBe(1); | |
| 67 | const href = /<a href="([^"]+)">More code results/.exec(html)![1]!.replaceAll("&", "&"); | |
| 68 | expect(href).toBe("/search?q=zebrafish&live=1&code=all#code"); | |
| 69 | // The note sits inside the code section, after its rows. | |
| 70 | expect(html.indexOf("data-more-code")).toBeGreaterThan(html.lastIndexOf('class="row code-hit"')); | |
| 71 | ||
| 72 | const all = await page(spent, href.slice("/search?".length).replace(/#.*/, "")); | |
| 73 | expect(rowsOf(all)).toBe(6); | |
| 74 | expect(all).not.toContain("data-more-code"); | |
| 75 | }); | |
| 76 | ||
| 77 | test("the link keeps the kind it was asked for", async () => { | |
| 78 | const html = await page(spent, "q=zebrafish&kind=code"); | |
| 79 | expect(html).toContain("/search?q=zebrafish&kind=code&code=all#code"); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a roomy budget shows every row and offers nothing more", async () => { | |
| 83 | const html = await page(roomy, "q=zebrafish&live=1"); | |
| 84 | expect(rowsOf(html)).toBe(6); | |
| 85 | expect(html).not.toContain("data-more-code"); | |
| 86 | }); |
test/search.test.ts +2
| @@ -29,6 +29,7 @@ const read = async <T>(response: Response): Promise<T> => | ||
| 29 | 29 | (await response.json()) as T; |
| 30 | 30 | |
| 31 | 31 | interface Results { |
| 32 | moreCode?: boolean; | |
| 32 | 33 | repos: ( |
| 33 | 34 | | { |
| 34 | 35 | source: "local"; |
| @@ -392,6 +393,7 @@ describe("search: one query, four sources", () => { | ||
| 392 | 393 | repos: [], |
| 393 | 394 | issues: [], |
| 394 | 395 | code: [], |
| 396 | moreCode: false, | |
| 395 | 397 | truncated: { repos: false, issues: false, code: false }, |
| 396 | 398 | }); |
| 397 | 399 | } |
new above
offline