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

Merged opened this

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

Merged

scout/forge:scout/3758-search-greps-code-within-a-30-ms-budget → main 5 commits fork

  • Merged. scout/3758-search-greps-code-within-a-30-ms-budget was merged into main as 670808d.

Review

Sign in to review this change.
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**
601601 button for the person who ran it (kept for a day), after which the apps are back
602602 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).
603603
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
604608 ## Health
605609
606610 `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 {
5757 * @default true
5858 */
5959 withCode?: boolean;
60 /** Start no new grep after this many ms and report `moreCode`. @default unset: grep every repository */
61 codeBudgetMs?: number;
6062 }
6163
6264 /**
@@ -80,6 +82,7 @@ export async function search(
8082 issues: [],
8183 code: [],
8284 truncated: { repos: false, issues: false, code: false },
85 moreCode: false,
8386 };
8487 if (q === "") return empty;
8588
@@ -125,8 +128,9 @@ export async function search(
125128 visible,
126129 q,
127130 Math.min(limit * 5, MAX_CODE_HITS),
131 input.codeBudgetMs ?? Infinity,
128132 )
129 : { hits: [], truncated: false };
133 : { hits: [], truncated: false, more: false };
130134
131135 return {
132136 repos,
@@ -137,6 +141,7 @@ export async function search(
137141 issues: localIssueCount >= limit,
138142 code: code.truncated,
139143 },
144 moreCode: code.more,
140145 };
141146 }
142147
src/app.ts +2
@@ -40,6 +40,8 @@ export interface ForgeConfig {
4040 seedSignup?: boolean;
4141 /** The disk probe vitals reads; the volume under `dataDir` when unset. Tests give it a reading. */
4242 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;
4345 /** @default 3000 */
4446 port?: number;
4547 /** 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;
4343 export const MAX_HITS_PER_REPO = 20;
4444 /** A grep that has not answered in this long is abandoned, not waited on. */
4545 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
4649 /** Longest line of code echoed back; the rest is off-screen anyway. */
4750 const MAX_LINE_CHARS = 400;
4851
@@ -120,6 +123,8 @@ export interface SearchResults {
120123 readonly issues: boolean;
121124 readonly code: boolean;
122125 };
126 /** True when the code budget ran out with repositories still ungrepped. */
127 readonly moreCode: boolean;
123128 }
124129
125130 /**
@@ -480,11 +485,17 @@ export async function grepRepos(
480485 repos: readonly Repo[],
481486 query: string,
482487 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 }> {
484491 const hits: CodeHit[] = [];
485492 const considered = repos.slice(0, MAX_SEARCH_REPOS);
486493 const batch = 4;
494 const started = performance.now();
487495 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 }
488499 const slice = considered.slice(i, i + batch);
489500 const found = await Promise.all(
490501 slice.map((repo) =>
@@ -497,7 +508,7 @@ export async function grepRepos(
497508 );
498509 for (const [j, repo] of slice.entries()) {
499510 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 };
501512 hits.push({
502513 repo: { owner: repo.ownerName, name: repo.name },
503514 ...hit,
@@ -505,5 +516,5 @@ export async function grepRepos(
505516 }
506517 }
507518 }
508 return { hits, truncated: repos.length > MAX_SEARCH_REPOS };
519 return { hits, truncated: repos.length > MAX_SEARCH_REPOS, more: false };
509520 }
src/web/pages/search.ts +23 −2
@@ -18,6 +18,7 @@ import { getPull } from "../../core/pulls.ts";
1818 import { canWrite, enterableApps, getRepo } from "../../core/repos.ts";
1919 import {
2020 cleanQuery,
21 CODE_BUDGET_MS,
2122 searchKind,
2223 type CodeHit,
2324 type IssueHit,
@@ -57,6 +58,8 @@ interface Asked {
5758 readonly query: string;
5859 readonly kind: SearchKind;
5960 readonly live: boolean;
61 /** `?code=all`: grep every repository, not just what fits the budget. */
62 readonly codeAll: boolean;
6063 }
6164
6265 /** The apps among what the viewer can see, by id and by `owner/name`. One
@@ -258,12 +261,13 @@ const WORDS: readonly Words[] = [
258261
259262 function searchHref(
260263 a: Asked,
261 over: { kind?: SearchKind; live?: boolean } = {},
264 over: { kind?: SearchKind; live?: boolean; codeAll?: boolean } = {},
262265 ): string {
263266 const kind = over.kind ?? a.kind;
264267 const params = new URLSearchParams({ q: a.query });
265268 if (kind !== "all") params.set("kind", kind);
266269 if (over.live ?? a.live) params.set("live", "1");
270 if (over.codeAll ?? a.codeAll) params.set("code", "all");
267271 return `/search?${params.toString()}`;
268272 }
269273
@@ -272,6 +276,7 @@ function section(
272276 [kind, , title, emptyText]: Words,
273277 rows: Html[],
274278 truncated: boolean,
279 after: Html | null = null,
275280 ): Html {
276281 const emptyIcon =
277282 kind === "repos" ? "repo" : kind === "issues" ? "issue" : "code";
@@ -287,6 +292,7 @@ function section(
287292 ${icon(emptyIcon)}<span>${emptyText}</span>
288293 </p>`
289294 : listCard(rows)}
295 ${after}
290296 ${when(
291297 truncated,
292298 () =>
@@ -320,6 +326,9 @@ async function find(
320326 kind: a.kind,
321327 live: a.live,
322328 withCode,
329 ...(a.codeAll
330 ? {}
331 : { codeBudgetMs: ctx.app.config.codeBudgetMs ?? CODE_BUDGET_MS }),
323332 });
324333 return held !== undefined && !held.allowed
325334 ? { found, busy: held.retryAfter }
@@ -346,6 +355,7 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
346355 query: cleanQuery(params.get("q") ?? ""),
347356 kind: searchKind(params.get("kind")),
348357 live: params.get("live") === "1",
358 codeAll: params.get("code") === "all",
349359 };
350360
351361 const form = html`<form
@@ -455,6 +465,15 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
455465 ],
456466 };
457467
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
458477 const body = html`${pageHead(html`Results for “${a.query}”`)} ${form}
459478 <div class="filters">${chips}</div>
460479 ${liveToggle}
@@ -471,7 +490,9 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
471490 .filter(
472491 ([kind]) => kind !== "all" && (a.kind === "all" || a.kind === kind),
473492 )
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 )}`;
475496 const response = page({ ctx, title: `Search · ${a.query}`, body });
476497 notePhase(ctx.request, "render", performance.now() - rendering);
477498 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("&amp;", "&");
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&amp;kind=code&amp;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> =>
2929 (await response.json()) as T;
3030
3131 interface Results {
32 moreCode?: boolean;
3233 repos: (
3334 | {
3435 source: "local";
@@ -392,6 +393,7 @@ describe("search: one query, four sources", () => {
392393 repos: [],
393394 issues: [],
394395 code: [],
396 moreCode: false,
395397 truncated: { repos: false, issues: false, code: false },
396398 });
397399 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs