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

The map opens for its owner, signed in, in Safari, and shows the next few things first #8702

Merged opened this in answer to

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

Merged

scout/forge:scout/8693-the-map-opens-for-its-owner-signed-in-in → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/8693-the-map-opens-for-its-owner-signed-in-in was merged into main as 559c066.

Review

Sign in to review this change.
5 files changed +150 −22
docs/features/map.md added +10
@@ -0,0 +1,10 @@
1 # The map, lightly (#8693)
2
3 `/map` is every app you can see, one lane each. The first screen is light:
4
5 - **Next**: the top 5 ready issues across your lanes, crew lanes first, each with its lane, its number and how many are ready there.
6 - **Lanes**: one folded row per app with its counts. The row carries a link, `Draw N issues`, that opens `/map?lane=owner/name`: that lane's graph, drawn at the same scale as all lanes, with `Show fewer` to go back. Each lane also links its own map, `/owner/name/map`.
7
8 `?lane=` is `owner/name` made of letters, digits, `.`, `_` and `-`, at most 200 characters; anything else, an unknown lane, or one you may not see draws nothing and the page is the light one. The test in `test/map.test.ts` holds the first screen under 50 KB.
9
10 The other doors keep the whole graph: `/api/v1/map`, the MCP map tools and the terminal map return every lane, since a program asks for data, not a first paint.
src/web/pages/global-map.ts +62 −5
@@ -208,15 +208,23 @@ export function globalLanes(
208208
209209 /** A lane: one folding list row — the caret, the repository's name and its
210210 * autonomy mode, who is on it, its counts — over its map. */
211 export function laneHtml(lane: Lane, width: number): Html {
211 export function laneHtml(lane: Lane, width: number, drawn = false): Html {
212212 const { repo } = lane;
213 const name = `${repo.ownerName}/${repo.name}`;
213214 return row({
214215 lead: icon("chevronRight"),
215216 title: html`<a href="${repoBase(repo)}/map"
216217 ><span class="muted">${repo.ownerName}/</span>${repo.name}</a
217218 >
218219 ${badge(repo.autonomy, { variant: "outline" })}`,
219 meta: countsLine(lane.counts),
220 // The way to the drawing is in the row itself, so a folded lane is one
221 // tap from its graph; a drawn lane says so and offers the way back.
222 meta: html`${countsLine(lane.counts)} ·
223 ${drawn
224 ? html`<a href="/map#lane-${repo.ownerName}-${repo.name}">Show fewer</a>`
225 : html`<a href="/map?lane=${name}#lane-${repo.ownerName}-${repo.name}"
226 >Draw ${lane.graph.issues.length} issues</a
227 >`}`,
220228 trail:
221229 lane.workers.length === 0
222230 ? html``
@@ -233,8 +241,16 @@ export function laneHtml(lane: Lane, width: number): Html {
233241 : { autonomy: repo.autonomy },
outdated

The link is inside a fold that is closed for idle lanes: two taps, and invisible to a skimmer. Surface it in the row meta or open the fold.

234242 fold: {
235243 id: `lane-${repo.ownerName}-${repo.name}`,
236 open: !lane.idle,
237 body: drawMap(
244 open: drawn || !lane.idle,
245 // The drawing is the heavy part (megabytes across every lane): the
246 // first screen carries the headers and the next things, and a lane's
247 // graph comes when asked for, at `?lane=owner/name`.
248 body: !drawn
249 ? html`<p class="muted small">
250 Drawn on request: <a href="/map?lane=${name}#lane-${repo.ownerName}-${repo.name}">draw it here</a>
251 or <a href="${repoBase(repo)}/map">open its own map</a>.
252 </p>`
253 : drawMap(
238254 repo,
239255 lane.graph,
240256 lane.placed,
@@ -249,6 +265,37 @@ export function laneHtml(lane: Lane, width: number): Html {
249265 });
250266 }
251267
268 /** `owner/name` as the `?lane=` of `/map` names it, or undefined: capped, and
269 * made of the characters a repository path is made of, nothing else. */
270 export function parseLane(raw: string | null): string | undefined {
271 if (raw === null || raw.length > 200) return undefined;
272 return /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/.test(raw) ? raw : undefined;
273 }
274
275 /** How many next things the first screen names. */
276 export const NEXT_COUNT = 5;
277
outdated

Hand-rolled <h2>; use the DESIGN.md section heading component. Rows carry only a title; add state/count meta.

278 /** The next things across the lanes, the crew's lanes first (lane order). */
279 export function nextThings(lanes: readonly Lane[], n = NEXT_COUNT): Html {
280 const picked = lanes
281 .flatMap((lane) => lane.ready.map((r) => ({ lane, r })))
282 .slice(0, n);
283 if (picked.length === 0) return html``;
284 return html`<h2>Next</h2>
285 ${listCard(
286 picked.map(({ lane, r }) =>
287 row({
288 href: `${repoBase(lane.repo)}/issues/${r.issue.number}`,
289 title: r.issue.title,
290 meta: html`${lane.repo.ownerName}/${lane.repo.name} #${r.issue.number}
291 · ${lane.counts.ready} ready there${lane.inFlight > 0
292 ? html` · crew working`
293 : null}`,
294 }),
295 ),
296 )}`;
297 }
298
outdated

Validate lane at the boundary (length cap, owner/name shape) and test the unknown and private cases.

252299 /** GET /map — every app the viewer can see, one lane each. */
253300 export function globalMapPage(ctx: Ctx): Response {
254301 const viewer = ctx.auth.user;
@@ -263,16 +310,26 @@ export function globalMapPage(ctx: Ctx): Response {
263310 notePhase(request, "layout", spent.layoutMs);
264311 const renderStart = performance.now();
265312 const width = Math.max(0, ...lanes.map((l) => drawingWidth(l.placed)));
313 const want = parseLane(ctx.url.searchParams.get("lane"));
266314 const body = html`${pageHead("Map", {
267315 sub: "Every app you can see, laid out by what waits for what. The top edge of each lane is ready now; a ringed card is where the crew is.",
268316 })}
317 ${nextThings(lanes)}
269318 <div id="lanes" data-live="${LIVE_KINDS}">
270319 ${lanes.length === 0
271320 ? html`<p class="muted">
272321 Nothing to map: no open issues anywhere you can see, and nobody
273322 working.
274323 </p>`
275 : listCard(lanes.map((lane) => laneHtml(lane, width)))}
324 : listCard(
325 lanes.map((lane) =>
326 laneHtml(
327 lane,
328 width,
329 want === `${lane.repo.ownerName}/${lane.repo.name}`,
330 ),
331 ),
332 )}
276333 </div>`;
277334 const response = page({
278335 ctx,
test/map-cache.test.ts +8 −5
@@ -210,21 +210,24 @@ describe("/map after a write", () => {
210210 const privateName = privateRepo!.split("/")[1]!;
211211 const privateTitle = "Plan 2 step 0";
212212
213 const warmed = await get(servers, "/map", f.ownerCookie);
213 // A lane is drawn when asked for (`?lane=`), so the drawings are asked for.
214 const pubLane = `/map?lane=${pub.ownerName}/${pub.name}`;
215 const privLane = `/map?lane=${privateRepo}`;
216 const warmed = await get(servers, pubLane, f.ownerCookie);
214217 expect(warmed).toContain("Hidden hole in login");
215 expect(warmed).toContain(privateTitle);
218 expect(await get(servers, privLane, f.ownerCookie)).toContain(privateTitle);
216219
217 for (const path of ["/map", "/api/v1/map"]) {
220 for (const path of ["/map", pubLane, privLane, "/api/v1/map"]) {
218221 const seen = await get(servers, path, f.strangerCookie);
219222 expect(seen).not.toContain("Hidden hole in login");
220223 expect(seen).not.toContain(privateTitle);
221224 expect(seen).not.toContain(privateName);
222225 }
223226 // And the other way: the stranger warming first does not hide it from the owner.
224 expect(await get(servers, "/map", f.ownerCookie)).toContain("Hidden hole in login");
227 expect(await get(servers, pubLane, f.ownerCookie)).toContain("Hidden hole in login");
225228 // Unsealing is a write the doc names: the stranger sees it next.
226229 if (sealed.ok) setIssueSealed(f.app, owner, pub, sealed.value, false);
227 expect(await get(servers, "/map", f.strangerCookie)).toContain("Hidden hole in login");
230 expect(await get(servers, pubLane, f.strangerCookie)).toContain("Hidden hole in login");
228231 expect(stranger.id).not.toBe(owner.id);
229232 });
230233 });
test/map.test.ts +61 −6
@@ -6,6 +6,7 @@
66 * idle lanes folded. A lane is exactly the repository's own map, so what
77 * the per-repo page says and what `/map` says never disagree.
88 */
9 import { parseLane } from "../src/web/pages/global-map.ts";
910 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1011 import { mkdtemp, rm } from "node:fs/promises";
1112 import { tmpdir } from "node:os";
@@ -484,15 +485,27 @@ describe("the map, per app and across the forge", () => {
484485 expect(page).toMatch(/<details[^>]*id="lane-alice-engine"[^>]*>\s*<summary\s+class="row"/);
485486 for (const gone of ["map-lane", "map-lane-caret", "map-lane-crew", "map-lane-head", "map-lane-name", "map-lane-word", "map-lanes"])
486487 expect(page).not.toContain(gone);
487 // Every lane is drawn at the widest lane's width: one scale, one grid.
488 const widths = [
489 ...page.matchAll(/class="map-canvas"[^>]*data-w="(\d+)"/g),
490 ].map((m) => m[1]);
488 // The first screen draws no graph; a lane's comes when asked for, at one
489 // scale: every lane drawn is the widest lane's width.
490 expect(page).not.toContain('class="map-canvas"');
491 expect(page).toContain('href="/map?lane=alice/engine#lane-alice-engine"');
492 const widths = [] as (string | undefined)[];
493 for (const lane of ["alice/engine", "alice/dormant", "bob/secret"]) {
494 const drawn = await (
495 await fetch(`${base}/map?lane=${lane}`, { headers: { cookie } })
496 ).text();
497 widths.push(
498 ...[...drawn.matchAll(/class="map-canvas"[^>]*data-w="(\d+)"/g)].map((m) => m[1]),
499 );
500 }
491501 expect(widths.length).toBeGreaterThanOrEqual(3);
492502 expect(new Set(widths).size).toBe(1);
493503 // The same card words as the repository's own map.
494 expect(page).toContain('data-state="working"');
495 expect(page).toContain('data-run="running"');
504 const engine = await (
505 await fetch(`${base}/map?lane=alice/engine`, { headers: { cookie } })
506 ).text();
507 expect(engine).toContain('data-state="working"');
508 expect(engine).toContain('data-run="running"');
496509 // A repository with nothing to draw is not a lane.
497510 expect(page).not.toContain('href="/alice/bare/map"');
498511 // The forge's admin reads every repository, so bob's is a lane here,
@@ -512,6 +525,48 @@ describe("the map, per app and across the forge", () => {
512525 expect(bobs).not.toContain("Extract renderer");
513526 });
514527
528 test("the first screen is small and fast, names the next things, and leaves the graphs to ask for (#8693)", async () => {
529 const cookie = await signIn("alice");
530 const res = await fetch(`${base}/map`, { headers: { cookie } });
531 const page = await res.text();
532 expect(new TextEncoder().encode(page).length).toBeLessThan(50_000);
533 expect(page).not.toContain('class="map-canvas"');
534 const next = page.slice(page.indexOf("<h2>Next</h2>"), page.indexOf('id="lanes"'));
535 expect(next).toContain("Extract renderer");
536 expect((next.match(/<li/g) ?? []).length).toBeLessThanOrEqual(5);
537 // Asking for a lane draws it, and only it.
538 const one = await (
outdated

Wall-clock assertion will flake on a loaded shard; keep the byte cap only.

539 await fetch(`${base}/map?lane=alice/engine`, { headers: { cookie } })
540 ).text();
541 expect((one.match(/class="map-canvas"/g) ?? []).length).toBe(1);
542 // The row says how to draw it, or how to go back; the lane's own map is linked.
543 expect(page).toContain(">Draw ");
544 expect(one).toContain(">Show fewer</a>");
545 expect(page).toContain('href="/alice/engine/map"');
546 // The lane names are checked at the boundary: unknown, odd and long ones draw nothing.
547 for (const bad of ["nobody/nothing", "alice", "../etc/passwd", "a/b/c", `${"x".repeat(300)}/y`, "alice/engine%00"]) {
548 const res = await fetch(`${base}/map?lane=${encodeURIComponent(bad)}`, { headers: { cookie } });
549 expect(res.status).toBe(200);
550 expect(await res.text()).not.toContain('class="map-canvas"');
551 }
552 expect(parseLane("alice/engine")).toBe("alice/engine");
553 expect(parseLane("alice")).toBeUndefined();
554 expect(parseLane(null)).toBeUndefined();
555 });
556
557 test("a stranger's first screen and lane asks never name or draw a private lane (#8693)", async () => {
558 const carol = await signIn("carol");
559 for (const path of ["/map", "/map?lane=bob/secret", "/map?lane=alice/engine"]) {
560 const text = await (await fetch(`${base}${path}`, { headers: { cookie: carol } })).text();
561 expect(text).not.toContain("Rotate the keys");
562 expect(text).not.toContain("bob/secret");
563 expect(text).not.toContain('class="map-canvas"');
564 }
565 const bob = await signIn("bob");
566 const text = await (await fetch(`${base}/map?lane=alice/engine`, { headers: { cookie: bob } })).text();
567 expect(text).not.toContain("Extract renderer");
568 });
569
515570 test("an empty map is §5's empty state, with the words and the way on", async () => {
516571 const { drawMap, layout } = await import("../src/web/pages/map.ts");
517572 const graph = { issues: [], edges: [] } as unknown as Graph;
test/snappy.test.ts +9 −6
@@ -76,10 +76,12 @@ describe("the caches are exact", () => {
7676 // (docs/CACHING.md, "Lane writes").
7777 test("a write is on the very next map: a new issue, a new edge, a closed one", async () => {
7878 const repo = f.repos[1]!;
79 // A lane's drawing comes when asked for (#8693).
80 const lanePage = (cookie: string) => page(`/map?lane=${repo.ownerName}/${repo.name}`, cookie);
7981 await page("/map", f.ownerCookie);
8082 const opened = openIssue(f.app, f.owner, repo, { title: "Zeppelin moorings" });
8183 if (!opened.ok) throw new Error(String(opened.error));
82 expect(await page("/map", f.ownerCookie)).toContain("Zeppelin moorings");
84 expect(await lanePage(f.ownerCookie)).toContain("Zeppelin moorings");
8385
8486 const second = openIssue(f.app, f.owner, repo, { title: "Quokka census" });
8587 if (!second.ok) throw new Error(String(second.error));
@@ -95,22 +97,23 @@ describe("the caches are exact", () => {
9597 // An issue on no edge leaves the map when it closes.
9698 const lone = openIssue(f.app, f.owner, repo, { title: "Narwhal tally" });
9799 if (!lone.ok) throw new Error(String(lone.error));
98 expect(await page("/map", f.ownerCookie)).toContain("Narwhal tally");
100 expect(await lanePage(f.ownerCookie)).toContain("Narwhal tally");
99101 expect(closeIssue(f.app, f.owner, repo, lone.value, "closed").ok).toBe(true);
100 expect(await page("/map", f.ownerCookie)).not.toContain("Narwhal tally");
102 expect(await lanePage(f.ownerCookie)).not.toContain("Narwhal tally");
101103 });
102104
103105 test("a sealed issue the owner's map drew is not drawn for a member who may not see it", async () => {
104106 // app-0 is public: the member reads it, but not what is sealed in it.
105107 const repo = f.repos[0]!;
108 const lane = `/map?lane=${repo.ownerName}/${repo.name}`;
106109 const sealed = openIssue(f.app, f.owner, repo, { title: "Sealed vault combination", sealed: true });
107110 if (!sealed.ok) throw new Error(String(sealed.error));
108111 // Warm in both orders: the owner first, then the member, then the owner.
109 expect(await page("/map", f.ownerCookie)).toContain("Sealed vault combination");
110 const member = await page("/map", f.peerCookie);
112 expect(await page(lane, f.ownerCookie)).toContain("Sealed vault combination");
113 const member = await page(lane, f.peerCookie);
111114 expect(member).toContain(`lane-olive-${repo.name}`);
112115 expect(member).not.toContain("Sealed vault combination");
113 expect(await page("/map", f.ownerCookie)).toContain("Sealed vault combination");
116 expect(await page(lane, f.ownerCookie)).toContain("Sealed vault combination");
114117 // And the member's own private repository is never on the owner's
115118 // stranger-scoped lanes either way: it is listed by visibility per request.
116119 expect(await page("/map", f.peerCookie)).toContain("lane-pablo-notes");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs