Public: anyone can read this; the crew can change it.

/map answers under its budget, cached as docs/CACHING.md says #2771

Merged opened this

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

Merged

scout/forge:scout/2716-map-answers-under-its-budget-cached-as-d → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/2716-map-answers-under-its-budget-cached-as-d was merged into main as 95fe44a.

Review

Sign in to review this change.
7 files changed +480 −67
docs/CACHING.md +34 −17
@@ -81,6 +81,16 @@ The machine is shared, so latency varies from run to run: an earlier run of
8181 were the same in both runs. Treat latency as an order of magnitude and the
8282 statement count as the exact figure.
8383
84 After #2716 (same fixture, same machine, `--n 30`, warm; `/` unchanged):
85
86 | request | p50 ms | p95 ms | SQL / request |
87 | ------------------- | -----: | -----: | ------------: |
88 | `GET /map` owner | 26.7 | 34.5 | 25.0 |
89 | `GET /map` stranger | 25.2 | 27.6 | 33.0 |
90
91 What is left is rendering the ~1 MB of SVG (25 ms), which rule 3 keeps
92 per request.
93
8494 ### Where the time goes
8595
8696 - **`/map`: `layout` of the big lane alone takes 408 ms of the ~490 ms
@@ -131,8 +141,10 @@ statement count as the exact figure.
131141 status. The per-repository map page and the API read the same entry.
132142 - **Key:** `lane:{repoId}:{head}:{scope}:{utcDay}`.
133143 - `head` is the repository's `MAX(event.seq)`. One grouped read covers
134 every lane on the page:
135 `SELECT repo_id, MAX(seq) FROM event WHERE repo_id IN (…) GROUP BY repo_id`.
144 every lane on the page: `SELECT repo.id, (SELECT MAX(seq) FROM event
145 WHERE repo_id = repo.id) FROM repo WHERE repo.id IN (…)`. (A grouped
146 `MAX … GROUP BY` scans every event of every listed repository, 17 ms
147 on the fixture; the correlated form is one index seek each, 0.3 ms.)
136148 - `scope` comes from `sealedScope(db, repo, viewer)`, worked out fresh:
137149 `all` for a writer or the crew, `own:{userId}` for any other signed-in
138150 active account, `none` for no one. Every part of a lane that reads
@@ -273,30 +285,35 @@ or stops saying what it did, this doc goes red before the cache goes stale.
273285
274286 ## Gaps: writes that change a lane without an event on its repository
275287
276 The 60-second max age covers these until they append an event. #2716
277 closes each one, either by appending an event with the repository's id or
278 by adding the input to the key:
288 Closed by #2716, except the clock:
279289
280290 - **A thought raising an issue** (`raiseIssue` from `crew/triage.ts`):
281 it writes `issue_raise`, and the only event is the forge-wide
282 `thought.received`. The ready order moves silently.
283 - **A fleet "move limb"** (`moveLimb` from `fleet/verbs.ts`): it writes
284 `limb_place` through core. `fleet.operated` carries no repository id.
285 - **A username change** (`user.updated`, no repository): the workers'
286 names in a lane header.
291 `crew/triage` now appends `issue.raised` with the repository's id beside
292 it, so `head` moves.
293 - **A fleet "move limb"** (`moveLimb` from `fleet/verbs.ts`): the verb
294 already appends `limb.moved` with the repository's id.
295 - **A username change** (`user.updated`, no repository): the lane keeps its
296 workers' ids, never their names. The names are read on every request in
297 one grouped read.
287298 - **The clock:** "deferred" and the spend day turn over with time. The
288 `utcDay` in the key covers the spend; the max age covers the rest.
299 `utcDay` in the key covers the spend; the 60 s max age covers the rest.
289300
290301 ## Budgets
291302
292303 These are what #2716 and #2717 test against, on the forge-sized fixture,
293304 warm:
294305
295 - **`/map`: p95 ≤ 50 ms on the bench, and at most 2 + 2 × lanes
296 statements.** That is one grouped head read, one grouped access read,
297 and two reads per lane for what is never cached. The test asserts the
298 statement count, which does not depend on the machine. Its wall-clock
299 bound is 200 ms p95, the floor `test/flake.test.ts` allows a request.
306 - **`/map`: p95 ≤ 50 ms on the bench, and a statement count that does not
307 grow with the plan.** Measured warm (#2716): 25 statements for the owner
308 (10 lanes) and 33 for a stranger (8 lanes), against 2,685 and 2,637.
309 About 25 of them are the page around the lanes (session, settings, the
310 repository lists the chrome reads); each lane adds at most two (its head
311 is one grouped read for all of them, and `canWrite`'s owner lookup is
312 one per lane for a viewer who is not an admin). The first draft of this
313 budget, 2 + 2 × lanes, left the page's own chrome out. The test
314 (`test/map-cache.test.ts`) asserts 26 + 2 × lanes, which does not depend
315 on the machine. Its wall-clock bound is 200 ms p95, the floor
316 `test/flake.test.ts` allows a request; the 50 ms is read off the bench.
300317 - **`/`: p95 ≤ 50 ms on the bench, and a statement count that does not
301318 grow with the feed's candidates** (the `test/counts.test.ts` pattern).
302319 The test's wall-clock bound is 200 ms, for the same reason.
src/core/runs.ts +19
@@ -1143,6 +1143,25 @@ export function listActiveRuns(
11431143 .map(toRun);
11441144 }
11451145
1146 /** Which of these repositories have an agent run queued or running: one grouped read. */
1147 export function reposWithActiveRuns(
1148 db: Database,
1149 repoIds: readonly string[],
1150 ): Set<string> {
1151 const unique = [...new Set(repoIds)];
1152 if (unique.length === 0) return new Set();
1153 return new Set(
1154 db
1155 .query<{ repo_id: string }, string[]>(
1156 `SELECT DISTINCT repo_id FROM run
1157 WHERE kind = 'agent' AND state IN ('queued','running')
1158 AND repo_id IN (${unique.map((_, n) => `?${n + 1}`).join(",")})`,
1159 )
1160 .all(...unique)
1161 .map((r) => r.repo_id),
1162 );
1163 }
1164
11461165 /** What is waiting to start, by kind: one grouped query, however many runs there are. */
11471166 export function queueDepth(
11481167 db: Database,
src/crew/triage.ts +2
@@ -272,6 +272,8 @@ export async function placeThought(
272272 reply = `You asked this on ${said}; #${number} is open but deferred behind the Later gate, so it is not raised.`;
273273 } else if (issue?.state === "open") {
274274 raiseIssue(app.db, issue.id, now);
275 // The ready order moved: say so on the repository, so a cached map misses.
276 app.bus.append({ kind: "issue.raised", repoId: repo.id, payload: { number: issue.number } });
275277 const done = addComment(app, by, repo, issue, `Asked again; raised in the ready order.\n\n${ref}\n\n${quote(text)}`);
276278 if (!done.ok) log.warn("could not comment", { issue: issue.number, error: String(done.error) });
277279 reply = `You asked this on ${said}; #${number} is open${issue.assigneeId ? " and being built" : ""}. Raised it.`;
src/web/map_cache.ts added +127
@@ -0,0 +1,127 @@
1 /**
2 * What `/map` keeps between requests, as docs/CACHING.md designs it (#2716):
3 * a layout by the graph it was drawn from, and a lane by the repository's
4 * latest event, the viewer's sealed scope and the UTC day.
5 *
6 * Access is never in here: the caller works out which repositories a viewer
7 * may read and which sealed issues they may see on every request, and the
8 * answer goes into the key. Nothing here is truth; every entry can be
9 * dropped and the only cost is one rebuild.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import { ViewCache } from "../view/cache.ts";
14 import type { Graph } from "../core/dependencies.ts";
15
16 /** A lane is rebuilt at least this often: covers inputs that move without an event. */
17 export const LANE_TTL_MS = 60_000;
18 /** An unused layout goes after an hour; the cap is what bounds memory. */
19 const LAYOUT_TTL_MS = 60 * 60_000;
20
21 const lanes = new ViewCache({ maxEntries: 128 });
22 const layouts = new ViewCache({ maxEntries: 256 });
23
24 /** What `sealedScope` computed, as a string that goes into a key. */
25 export function scopeKey(scope: { sealed?: true; sealedBy?: string }): string {
26 return scope.sealed === true
27 ? "all"
28 : scope.sealedBy === undefined
29 ? "none"
30 : `own:${scope.sealedBy}`;
31 }
32
33 /**
34 * Each repository's latest event sequence, in one grouped read from the
35 * `event_repo_seq` index. Read from the database each time, so a write by
36 * another process moves it too. A repository with no event is 0.
37 */
38 export function repoHeads(
39 db: Database,
40 repoIds: readonly string[],
41 ): Map<string, number> {
42 const heads = new Map<string, number>(repoIds.map((id) => [id, 0]));
43 if (repoIds.length === 0) return heads;
44 // A grouped MAX would scan every event of every listed repository; one
45 // correlated MAX per row is a single index seek each, in one statement.
46 const rows = db
47 .query<{ id: string; head: number | null }, string[]>(
48 `SELECT repo.id AS id,
49 (SELECT MAX(seq) FROM event WHERE event.repo_id = repo.id) AS head
50 FROM repo WHERE repo.id IN (${repoIds.map((_, n) => `?${n + 1}`).join(",")})`,
51 )
52 .all(...repoIds);
53 for (const row of rows) heads.set(row.id, row.head ?? 0);
54 return heads;
55 }
56
57 const utcDay = (now: Date): string => now.toISOString().slice(0, 10);
58
59 /**
60 * A lane's cached build, or `build()`'s result kept for next time. `null`
61 * is a cached answer too: a repository with nothing to draw.
62 */
63 export function cachedLane<T>(
64 key: { repoId: string; head: number; scope: string; now: Date },
65 build: () => T,
66 ): T {
67 const k = `lane:${key.repoId}:${key.head}:${key.scope}:${utcDay(key.now)}`;
68 const kept = lanes.get<{ value: T }>(k);
69 if (kept !== undefined) return kept.value;
70 const value = build();
71 lanes.set(k, { value }, LANE_TTL_MS);
72 return value;
73 }
74
75 /** The key is the graph's own input: issues in order and the edges between. */
76 export function layoutKey(graph: Pick<Graph, "issues" | "edges">): string {
77 const hash = new Bun.CryptoHasher("sha256");
78 for (const i of graph.issues) hash.update(`${i.id}:${i.number}\n`);
79 hash.update("--\n");
80 for (const e of graph.edges) hash.update(`${e.blockedById}>${e.issueId}\n`);
81 return `layout:${hash.digest("hex")}`;
82 }
83
84 /**
85 * `draw(graph)` once per distinct graph. Only ids, levels and slots are kept;
86 * every placed issue is put back from the request's own graph, so an edited
87 * title shows on the next view.
88 */
89 export function cachedLayout<
90 P extends { readonly issue: { readonly id: string } },
91 L extends { readonly nodes: readonly P[] },
92 >(graph: Graph, draw: (graph: Graph) => L): L {
93 const key = layoutKey(graph);
94 const kept = layouts.get<{ nodes: { id: string }[] }>(key);
95 if (kept !== undefined) {
96 const byId = new Map(graph.issues.map((i) => [i.id, i]));
97 return {
98 ...kept,
99 nodes: kept.nodes.map(({ id, ...rest }) => ({
100 ...rest,
101 issue: byId.get(id),
102 })),
103 } as unknown as L;
104 }
105 const drawn = draw(graph);
106 layouts.set(
107 key,
108 {
109 ...drawn,
110 nodes: drawn.nodes.map(({ issue, ...rest }) => ({ ...rest, id: issue.id })),
111 },
112 LAYOUT_TTL_MS,
113 );
114 return drawn;
115 }
116
117 /** Test seam: start from nothing. */
118 export function clearMapCache(): void {
119 lanes.clear();
120 layouts.clear();
121 }
122
123 /** What the tests read to know a request was served from the cache. */
124 export const mapCacheStats = (): {
125 lanes: ReturnType<ViewCache["stats"]>;
126 layouts: ReturnType<ViewCache["stats"]>;
127 } => ({ lanes: lanes.stats(), layouts: layouts.stats() });
src/web/pages/map.ts +95 −50
@@ -36,13 +36,14 @@ import {
3636 type Ranked,
3737 } from "../../core/dependencies.ts";
3838 import type { Issue } from "../../core/issues.ts";
39 import { cachedLane, cachedLayout, repoHeads, scopeKey } from "../map_cache.ts";
3940 import { holdsFor } from "../../core/holds.ts";
4041 import {
4142 getRepoById,
4243 listReposVisibleTo,
4344 type Repo,
4445 } from "../../core/repos.ts";
45 import { listActiveRuns, type Run, type RunState } from "../../core/runs.ts";
46 import { listActiveRuns, reposWithActiveRuns, type Run, type RunState } from "../../core/runs.ts";
4647 import { getUsersByIds, type User } from "../../core/users.ts";
4748 import type { Ctx } from "../../http/context.ts";
4849 import {
@@ -1412,7 +1413,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
14121413 const ready = readyNow(graph);
14131414 const blocked = blockedOn(graph);
14141415 const critical = criticalPath(graph);
1415 const placed = layout(graph);
1416 const placed = cachedLayout(graph, layout);
14161417 const marks = marksFor(db, repo.id, graph, listActiveRuns(db, [repo.id]));
14171418 const readySet = new Set(ready.map((r) => r.issue.id));
14181419 const counts = countWords(
@@ -1613,6 +1614,63 @@ export interface Lane {
16131614 readonly status: AutonomyStatus | undefined;
16141615 }
16151616
1617 /** A lane as kept: everything but the repository and the workers' names. */
1618 type KeptLane = Omit<Lane, "repo" | "workers"> & {
1619 /** The runs' actors, running ones first; their names are read per request. */
1620 readonly workerIds: string[];
1621 };
1622
1623 /**
1624 * One repository's lane for one scope, or null when it has nothing to draw.
1625 * Every read of issues here respects `scope`, so the entry it becomes is
1626 * the same for everyone whose scope computes to the same key.
1627 */
1628 function buildLane(
1629 app: App,
1630 repo: Repo,
1631 viewer: User | undefined,
1632 scope: ReturnType<typeof sealedScope>,
1633 ): KeptLane | null {
1634 const db = app.db;
1635 // A run on a sealed report is not shown to who may not see the report.
1636 const flying = listActiveRuns(db, [repo.id]).filter((r) =>
1637 mayViewId(db, repo, r.issueId, viewer),
1638 );
1639 if ((repo.counts?.openIssues ?? 0) === 0 && flying.length === 0) return null;
1640 const graph = repoGraph(db, repo.id, scope);
1641 const marks = marksFor(db, repo.id, graph, flying);
1642 const ready = readyNow(graph);
1643 const readySet = new Set(ready.map((r) => r.issue.id));
1644 const workerIds = [
1645 ...new Set(
1646 [...flying]
1647 .sort(
1648 (a, b) =>
1649 (a.state === "running" ? -1 : 0) - (b.state === "running" ? -1 : 0),
1650 )
1651 .map((r) => r.actorId)
1652 .filter((id): id is string => id !== null),
1653 ),
1654 ];
1655 const counts = countWords(
1656 graph.issues.map((i) => wordFor(i, readySet, marks)),
1657 );
1658 return {
1659 graph,
1660 placed: cachedLayout(graph, layout),
1661 marks,
1662 ready,
1663 critical: criticalPath(graph),
1664 counts,
1665 inFlight: flying.length,
1666 workerIds,
1667 // Nothing in flight and no card that says ready: a parked or proposed
1668 // issue is not one a free slot would take.
1669 idle: flying.length === 0 && counts.ready === 0,
1670 status: repo.autonomy === "off" ? undefined : autonomyStatus(app, repo),
1671 };
1672 }
1673
16161674 /**
16171675 * One lane per repository the viewer can see that has something to draw:
16181676 * an open issue, or a run in flight. The rest are left out rather than
@@ -1622,62 +1680,49 @@ export interface Lane {
16221680 */
16231681 export function globalLanes(app: App, viewer: User | undefined): Lane[] {
16241682 const db = app.db;
1683 const now = new Date();
1684 // Access first, every request, and into the key (docs/CACHING.md rule 1).
16251685 const repos = listReposVisibleTo(db, viewer, { limit: 200 });
1626 const runs = listActiveRuns(
1686 const heads = repoHeads(
16271687 db,
16281688 repos.map((r) => r.id),
16291689 );
1690 const running = reposWithActiveRuns(db, repos.map((r) => r.id));
1691 const built: { repo: Repo; lane: KeptLane }[] = [];
1692 for (const repo of repos) {
1693 // Nothing open and nobody running: no lane for anyone, so no scope to work out.
1694 if ((repo.counts?.openIssues ?? 0) === 0 && !running.has(repo.id)) continue;
1695 const scope = sealedScope(db, repo, viewer);
1696 const lane = cachedLane(
1697 {
1698 repoId: repo.id,
1699 head: heads.get(repo.id) ?? 0,
1700 scope: scopeKey(scope),
1701 now,
1702 },
1703 () => buildLane(app, repo, viewer, scope),
1704 );
1705 if (lane !== null) built.push({ repo, lane });
1706 }
1707 // Names are read now, not kept: a renamed account shows on the next view.
16301708 const actors = getUsersByIds(
16311709 db,
1632 runs.map((r) => r.actorId).filter((id): id is string => id !== null),
1710 built.flatMap(({ lane }) => lane.workerIds),
16331711 );
1634 const byRepo = new Map<string, Run[]>();
1635 for (const run of runs)
1636 byRepo.set(run.repoId, [...(byRepo.get(run.repoId) ?? []), run]);
1637 const lanes: Lane[] = [];
1638 for (const repo of repos) {
1639 // A run on a sealed report is not shown to who may not see the report.
1640 const flying = (byRepo.get(repo.id) ?? []).filter((r) =>
1641 mayViewId(db, repo, r.issueId, viewer),
1642 );
1643 if ((repo.counts?.openIssues ?? 0) === 0 && flying.length === 0) continue;
1644 const graph = repoGraph(db, repo.id, sealedScope(db, repo, viewer));
1645 const marks = marksFor(db, repo.id, graph, flying);
1646 const ready = readyNow(graph);
1647 const readySet = new Set(ready.map((r) => r.issue.id));
1648 const workers = [
1649 ...new Set(
1650 [...flying]
1651 .sort(
1652 (a, b) =>
1653 (a.state === "running" ? -1 : 0) -
1654 (b.state === "running" ? -1 : 0),
1655 )
1656 .map((r) =>
1657 r.actorId === null ? undefined : actors.get(r.actorId)?.username,
1658 )
1659 .filter((n): n is string => n !== undefined),
1660 ),
1661 ];
1662 const counts = countWords(
1663 graph.issues.map((i) => wordFor(i, readySet, marks)),
1664 );
1665 lanes.push({
1712 const lanes: Lane[] = built.map(({ repo, lane }) => {
1713 const { workerIds, ...rest } = lane;
1714 return {
1715 ...rest,
16661716 repo,
1667 graph,
1668 placed: layout(graph),
1669 marks,
1670 ready,
1671 critical: criticalPath(graph),
1672 counts,
1673 inFlight: flying.length,
1674 workers,
1675 // Nothing in flight and no card that says ready: a parked or proposed
1676 // issue is not one a free slot would take.
1677 idle: flying.length === 0 && counts.ready === 0,
1678 status: repo.autonomy === "off" ? undefined : autonomyStatus(app, repo),
1679 });
1680 }
1717 workers: [
1718 ...new Set(
1719 workerIds
1720 .map((id) => actors.get(id)?.username)
1721 .filter((n): n is string => n !== undefined),
1722 ),
1723 ],
1724 };
1725 });
16811726 // Where the crew is first, then what has work ready, idle lanes last;
16821727 // by name within each, so the page reads the same twice.
16831728 return lanes.sort(
test/map-cache.test.ts added +187
@@ -0,0 +1,187 @@
1 /**
2 * /map is built from the cache docs/CACHING.md designs (#2716): fast and
3 * cheap when warm, never stale after a write the doc names, and never a
4 * way for one viewer to read what another may see.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtempSync, rmSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { openIssue, setIssueSealed, setIssueState } from "../src/actions/issues.ts";
12 import { getIssue } from "../src/core/issues.ts";
13 import { getRepo, type Repo } from "../src/core/repos.ts";
14 import { getUserByName, type User } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { clearMapCache, layoutKey } from "../src/web/map_cache.ts";
17 import { layout } from "../src/web/pages/map.ts";
18 import { repoGraph } from "../src/core/dependencies.ts";
19 import { FORGE_SIZE, seedFixture, type Fixture, type Shape } from "../bench/map-home.ts";
20 import { countStatements } from "../bench/run.ts";
21
22 const SMALL: Shape = {
23 repos: 6,
24 bigIssues: 40,
25 bigOpen: 12,
26 bigGraph: 20,
27 bigEdges: 24,
28 bigPulls: 2,
29 lanes: [5, 3, 2],
30 privateLanes: 1,
31 sealed: 1,
32 runs: 2,
33 comments: 1,
34 events: 50,
35 };
36
37 /** What the page around the lanes reads, however many lanes there are. */
38 const CHROME = 26;
39
40 const dirs: string[] = [];
41 const fixtures: Fixture[] = [];
42 afterAll(() => {
43 for (const f of fixtures) f.app.close();
44 for (const d of dirs) rmSync(d, { recursive: true, force: true });
45 });
46
47 async function boot(shape: Shape): Promise<{ f: Fixture; servers: Servers }> {
48 const dir = mkdtempSync(join(tmpdir(), "forge-map-cache-"));
49 dirs.push(dir);
50 clearMapCache();
51 const f = await seedFixture(dir, shape);
52 fixtures.push(f);
53 return { f, servers: await serve(f.app) };
54 }
55
56 const get = async (servers: Servers, path: string, cookie: string): Promise<string> =>
57 (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
58
59 const lanesIn = (page: string): number => page.split('class="map-lane"').length - 1;
60
61 describe("/map on the forge-sized fixture, warm", () => {
62 let servers: Servers;
63 let f: Fixture;
64 beforeAll(async () => {
65 ({ f, servers } = await boot(FORGE_SIZE));
66 }, 120_000);
67 afterAll(() => servers.stop());
68
69 for (const who of ["owner", "stranger"] as const) {
70 test(`${who}: p95 within the wall-clock floor, statements within a fixed cost + 2 per lane`, async () => {
71 const cookie = who === "owner" ? f.ownerCookie : f.strangerCookie;
72 const warm = await get(servers, "/map", cookie);
73 const lanes = lanesIn(warm);
74 expect(lanes).toBeGreaterThan(5);
75 const counter = countStatements(f.app.db);
76 const times: number[] = [];
77 let statements = 0;
78 try {
79 for (let n = 0; n < 20; n++) {
80 counter.reset();
81 const started = performance.now();
82 await get(servers, "/map", cookie);
83 times.push(performance.now() - started);
84 statements = Math.max(statements, counter.count);
85 }
86 } finally {
87 counter.stop();
88 }
89 times.sort((a, b) => a - b);
90 const p95 = times[Math.ceil(times.length * 0.95) - 1]!;
91 console.log(`${who}: ${lanes} lanes, p95 ${p95.toFixed(1)} ms, ${statements} statements`);
92 // 200 ms is the floor test/flake.test.ts allows a request; the budget
93 // in the doc (50 ms) is read off bench/map-home.ts, not asserted here.
94 expect(p95).toBeLessThan(200);
95 // The page's own chrome costs a fixed ~25 statements (session, settings,
96 // the repository lists); a lane costs at most two. Cold, it was ~2,700.
97 expect(statements).toBeLessThanOrEqual(CHROME + 2 * lanes);
98 }, 60_000);
99 }
100 });
101
102 describe("/map after a write", () => {
103 let servers: Servers;
104 let f: Fixture;
105 let owner: User;
106 let stranger: User;
107 const repoOf = (name: string): Repo => getRepo(f.app.db, "keeper", name)!;
108 beforeAll(async () => {
109 ({ f, servers } = await boot(SMALL));
110 owner = getUserByName(f.app.db, "keeper")!;
111 stranger = getUserByName(f.app.db, "passerby")!;
112 }, 60_000);
113 afterAll(() => servers.stop());
114
115 test("an issue opened, then closed, shows on the next /map", async () => {
116 const repo = repoOf("plan-0");
117 await get(servers, "/map", f.ownerCookie);
118 const opened = openIssue(f.app, owner, repo, { title: "Zebra crossing" });
119 expect(opened.ok).toBe(true);
120 if (!opened.ok) return;
121 expect(await get(servers, "/map", f.ownerCookie)).toContain("Zebra crossing");
122 const issue = getIssue(f.app.db, repo.id, opened.value.number)!;
123 const closed = setIssueState(f.app, owner, repo, issue, "closed");
124 expect(closed.ok).toBe(true);
125 expect(await get(servers, "/map", f.ownerCookie)).not.toContain("Zebra crossing");
126 });
127
128 test("an edited title shows although the layout is kept", async () => {
129 const repo = repoOf("plan-1");
130 const before = await get(servers, "/map", f.ownerCookie);
131 expect(before).toContain("Plan 1 step 0");
132 f.app.db
133 .query("UPDATE issue SET title = 'Renamed step' WHERE repo_id = ?1 AND title = 'Plan 1 step 0'")
134 .run(repo.id);
135 // A title is not part of the layout's key, so the layout is the same entry.
136 f.app.bus.append({ kind: "issue.edited", repoId: repo.id, payload: {} });
137 const after = await get(servers, "/map", f.ownerCookie);
138 expect(after).toContain("Renamed step");
139 expect(after).not.toContain("Plan 1 step 0");
140 });
141
142 test("a sealed issue and a private lane never reach a stranger once the owner has warmed them", async () => {
143 const pub = repoOf("plan-0");
144 const sealed = openIssue(f.app, owner, pub, { title: "Hidden hole in login", sealed: true });
145 expect(sealed.ok).toBe(true);
146 const [privateRepo] = f.privateRepos;
147 const privateName = privateRepo!.split("/")[1]!;
148 const privateTitle = "Plan 2 step 0";
149
150 const warmed = await get(servers, "/map", f.ownerCookie);
151 expect(warmed).toContain("Hidden hole in login");
152 expect(warmed).toContain(privateTitle);
153
154 for (const path of ["/map", "/api/v1/map"]) {
155 const seen = await get(servers, path, f.strangerCookie);
156 expect(seen).not.toContain("Hidden hole in login");
157 expect(seen).not.toContain(privateTitle);

This assertion cannot fail in a way that matters. Remove it, or assert that the stranger's cached lane differs from the owner's.

158 expect(seen).not.toContain(privateName);
159 }
160 // And the other way: the stranger warming first does not hide it from the owner.
161 expect(await get(servers, "/map", f.ownerCookie)).toContain("Hidden hole in login");
162 // Unsealing is a write the doc names: the stranger sees it next.
163 if (sealed.ok) setIssueSealed(f.app, owner, pub, sealed.value, false);
164 expect(await get(servers, "/map", f.strangerCookie)).toContain("Hidden hole in login");
165 expect(stranger.id).not.toBe(owner.id);
166 });
167 });
168
169 describe("the layout key", () => {
170 test("is the graph's own ids and edges, never its titles", async () => {
171 const { f, servers } = await boot(SMALL);
172 try {
173 const repo = getRepo(f.app.db, "keeper", "forge")!;
174 const graph = repoGraph(f.app.db, repo.id, { sealed: true });
175 const key = layoutKey(graph);
176 const retitled = {
177 ...graph,
178 issues: graph.issues.map((i) => ({ ...i, title: `${i.title}!` })),
179 };
180 expect(layoutKey(retitled)).toBe(key);
181 expect(layoutKey({ ...graph, edges: graph.edges.slice(1) })).not.toBe(key);
182 expect(layout(graph).nodes.length).toBe(graph.issues.length);
183 } finally {
184 servers.stop();
185 }
186 }, 60_000);
187 });
test/triage.test.ts +16
@@ -251,6 +251,22 @@ describe("triage", () => {
251251 }
252252 });
253253
254 test("raising an issue for a repeated ask is an event on its repository", async () => {
255 app.config.decider = stub({ build: ["work", 0.9, "none", "P7"] });
256 const text = "show the build time next to each pull";
257 const first = await placeThought(app, fresh(), alice, think(text));
258 const number = first.placed[0]!.number;
259 const raisedEvents = () =>
260 app.db
261 .query<{ c: number }, [string]>("SELECT COUNT(*) c FROM event WHERE kind = 'issue.raised' AND repo_id = ?1")
262 .get(repo.id)!.c;
263 const before = raisedEvents();
264 const again = await placeThought(app, fresh(), alice, think(text));
265 expect(again.reply).toContain("Raised it");
266 expect(raisedEvents()).toBe(before + 1);
267 expect(number).toBeGreaterThan(0);

number > 0 proves nothing about the new event. The before + 1 check above it already covers the behaviour, so drop this line.

268 });
269
254270 test("a repeat of an ask that was deferred says so and does not claim to raise it", async () => {
255271 app.config.decider = stub({ build: ["work", 0.9, "none", "P7"], scrap: ["reversal", 0.9, "#0"] });
256272 const text = "build a heatmap of commits per hour";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs