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

The forge-wide map leaves map.ts for src/web/pages/global-map.ts #6318

Merged opened this

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

Merged

mason/forge:mason/6310-the-forge-wide-map-leaves-map-ts-for-src → main 1 commit fork 1 thread · 1 unresolved

  • Merged. mason/6310-the-forge-wide-map-leaves-map-ts-for-src was merged into main as 43ad7e2.

Review

Sign in to review this change.
5 files changed +320 −276
src/http/api.ts +2 −1
@@ -353,7 +353,8 @@ import { pillarView } from "../core/pillars.ts";
353353 import { askToLiveFor } from "../web/ask_to_live.ts";
354354 import { decisionStats, listDecisions } from "../core/decisions.ts";
355355 import { forReader } from "../crew/handover.ts";
356 import { globalMapJson, mapJson, marksFor } from "../web/pages/map.ts";
356 import { globalMapJson } from "../web/pages/global-map.ts";
357 import { mapJson, marksFor } from "../web/pages/map.ts";
357358 import { cachedMap } from "../web/map_snapshot.ts";
358359 import {
359360 milestoneData,
src/http/server.ts +2 −1
@@ -231,7 +231,8 @@ import {
231231 import { noteModerate } from "../web/studio/notes.ts";
232232 import { tellPage } from "../web/studio/tell.ts";
233233 import { tellHerePage } from "../web/studio/tell_here.ts";
234 import { globalMapPage, mapPage } from "../web/pages/map.ts";
234 import { globalMapPage } from "../web/pages/global-map.ts";
235 import { mapPage } from "../web/pages/map.ts";
235236 import {
236237 milestonePage,
237238 milestonesPage,
src/web/pages/global-map.ts added +304
@@ -0,0 +1,304 @@
1 /**
2 * The global map at `/map`: the repository map's drawing once per
3 * repository the viewer can see, one lane each, at one scale, so a column
4 * means the same thing in every lane. Its JSON at `/api/v1/map` is the same
5 * lanes as data. The drawing itself — layout, cards, edges, words — is
6 * map.ts's; this file only gathers the lanes and lays them out in a list.
7 */
8
9 import type { App } from "../../app.ts";
10 import { autonomyStatus, type AutonomyStatus } from "../../crew/autonomy.ts";
11 import {
12 criticalPath,
13 readyNow,
14 repoGraph,
15 type Graph,
16 type Ranked,
17 } from "../../core/dependencies.ts";
18 import type { Issue } from "../../core/issues.ts";
19 import { listReposVisibleTo, type Repo } from "../../core/repos.ts";
20 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
21 import { listActiveRuns, reposWithActiveRuns } from "../../core/runs.ts";
22 import { mayViewId, sealedScope } from "../../core/sealed.ts";
23 import { getUsersByIds, type User } from "../../core/users.ts";
24 import type { Ctx } from "../../http/context.ts";
25 import { pageHead, repoBase, signInFirst } from "../chrome.ts";
26 import { liveRegionsJs } from "../client.ts";
27 import { html, type Html } from "../html.ts";
28 import { page } from "../layout.ts";
29 import { cachedLane, cachedLayout, repoHeads, scopeKey } from "../map_cache.ts";
30 import { avatar, badge, icon, listCard, row } from "../ui.ts";
31 import {
32 countsLine,
33 countWords,
34 drawingWidth,
35 drawMap,
36 emphasisFor,
37 layout,
38 LIVE_KINDS,
39 mapCss,
40 mapJson,
41 mapViewJs,
42 marksFor,
43 wordFor,
44 type Layout,
45 type Marks,
46 type Word,
47 } from "./map.ts";
48
49 export interface Lane {
50 readonly repo: Repo;
51 readonly graph: Graph;
52 readonly placed: Layout;
53 readonly marks: Marks;
54 readonly ready: Ranked[];
55 readonly critical: Issue[];
56 readonly counts: Record<Word, number>;
57 /** Agent runs queued or running here now. */
58 readonly inFlight: number;
59 /** Who those runs are: usernames, the running ones first. */
60 readonly workers: string[];
61 /** Nothing in flight and nothing ready: the lane folds to its header. */
62 readonly idle: boolean;
63 /** Set when the repository runs itself; `off` has no spend to show. */
64 readonly status: AutonomyStatus | undefined;
65 }
66
67 /** A lane as kept: everything but the repository and the workers' names. */
68 type KeptLane = Omit<Lane, "repo" | "workers"> & {
69 /** The runs' actors, running ones first; their names are read per request. */
70 readonly workerIds: string[];
71 };
72
73 /** Milliseconds a request spent laying graphs out, inside the lanes' build. */
74 export interface Spent {
75 layoutMs: number;
76 }
77
78 function timed<T>(spent: Spent | undefined, run: () => T): T {
79 if (spent === undefined) return run();
80 const started = performance.now();
81 try {
82 return run();
83 } finally {
84 spent.layoutMs += performance.now() - started;
85 }
86 }
87
88 /**
89 * One repository's lane for one scope, or null when it has nothing to draw.
90 * Every read of issues here respects `scope`, so the entry it becomes is
91 * the same for everyone whose scope computes to the same key.
92 */
93 function buildLane(
94 app: App,
95 repo: Repo,
96 viewer: User | undefined,
97 scope: ReturnType<typeof sealedScope>,
98 spent?: Spent,
99 ): KeptLane | null {
100 const db = app.db;
101 // A run on a sealed report is not shown to who may not see the report.
102 const flying = listActiveRuns(db, [repo.id]).filter((r) =>
103 mayViewId(db, repo, r.issueId, viewer),
104 );
105 if ((repo.counts?.openIssues ?? 0) === 0 && flying.length === 0) return null;
106 const graph = repoGraph(db, repo.id, scope);
107 const marks = marksFor(db, repo.id, graph, flying);
108 const ready = readyNow(graph);
109 const readySet = new Set(ready.map((r) => r.issue.id));
110 const workerIds = [
111 ...new Set(
112 [...flying]
113 .sort(
114 (a, b) =>
115 (a.state === "running" ? -1 : 0) - (b.state === "running" ? -1 : 0),
116 )
117 .map((r) => r.actorId)
118 .filter((id): id is string => id !== null),
119 ),
120 ];
121 const counts = countWords(
122 graph.issues.map((i) => wordFor(i, readySet, marks)),
123 );
124 return {
125 graph,
126 placed: timed(spent, () => cachedLayout(graph, layout)),
127 marks,
128 ready,
129 critical: criticalPath(graph),
130 counts,
131 inFlight: flying.length,
132 workerIds,
133 // Nothing in flight and no card that says ready: a parked or proposed
134 // issue is not one a free slot would take.
135 idle: flying.length === 0 && counts.ready === 0,
136 status: repo.autonomy === "off" ? undefined : autonomyStatus(app, repo),
137 };
138 }
139
140 /**
141 * One lane per repository the viewer can see that has something to draw:
142 * an open issue, or a run in flight. The rest are left out rather than
143 * drawn empty, so the page is as long as the work and no longer. One read
144 * for every run in flight and one for who runs them; a graph and its marks
145 * only for the lanes that stay.
146 */
147 export function globalLanes(
148 app: App,
149 viewer: User | undefined,
150 spent?: Spent,
151 ): Lane[] {
152 const db = app.db;
153 const now = new Date();
154 // Access first, every request, and into the key (docs/CACHING.md rule 1).
155 const repos = listReposVisibleTo(db, viewer, { limit: 200, withoutArchived: true });
156 const heads = repoHeads(
157 db,
158 repos.map((r) => r.id),
159 );
160 const running = reposWithActiveRuns(db, repos.map((r) => r.id));
161 const built: { repo: Repo; lane: KeptLane }[] = [];
162 for (const repo of repos) {
163 // Nothing open and nobody running: no lane for anyone, so no scope to work out.
164 if ((repo.counts?.openIssues ?? 0) === 0 && !running.has(repo.id)) continue;
165 const scope = sealedScope(db, repo, viewer);
166 const lane = cachedLane(
167 {
168 repoId: repo.id,
169 head: heads.get(repo.id) ?? 0,
170 scope: scopeKey(scope),
171 now,
172 },
173 () => buildLane(app, repo, viewer, scope, spent),
174 );
175 if (lane !== null) built.push({ repo, lane });
176 }
177 // Names are read now, not kept: a renamed account shows on the next view.
178 const actors = getUsersByIds(
179 db,
180 built.flatMap(({ lane }) => lane.workerIds),
181 );
182 const lanes: Lane[] = built.map(({ repo, lane }) => {
183 const { workerIds, ...rest } = lane;
184 return {
185 ...rest,
186 repo,
187 workers: [
188 ...new Set(
189 workerIds
190 .map((id) => actors.get(id)?.username)
191 .filter((n): n is string => n !== undefined),
192 ),
193 ],
194 };
195 });
196 // Where the crew is first, then what has work ready, idle lanes last;
197 // by name within each, so the page reads the same twice.
198 return lanes.sort(
199 (a, b) =>
200 Number(b.inFlight > 0) - Number(a.inFlight > 0) ||
201 Number(a.idle) - Number(b.idle) ||
202 b.counts.ready - a.counts.ready ||
203 `${a.repo.ownerName}/${a.repo.name}`.localeCompare(
204 `${b.repo.ownerName}/${b.repo.name}`,
205 ),
206 );
207 }
208
209 /** A lane: one folding list row — the caret, the repository's name and its
210 * autonomy mode, who is on it, its counts — over its map. */
211 export function laneHtml(lane: Lane, width: number): Html {
212 const { repo } = lane;
213 return row({
214 lead: icon("chevronRight"),
215 title: html`<a href="${repoBase(repo)}/map"
216 ><span class="muted">${repo.ownerName}/</span>${repo.name}</a
217 >
218 ${badge(repo.autonomy, { variant: "outline" })}`,
219 meta: countsLine(lane.counts),
220 trail:
221 lane.workers.length === 0
222 ? html``
223 : html`<span title="Working: ${lane.workers.join(", ")}"
224 >${lane.workers.slice(0, 5).map((w) => html`${avatar(w)} `)}${lane
225 .workers.length > 5
226 ? html`<span class="muted small"
227 >+${lane.workers.length - 5}</span
228 >`
229 : null}</span
230 >`,
231 data: lane.idle
232 ? { autonomy: repo.autonomy, idle: "1" }
233 : { autonomy: repo.autonomy },
234 fold: {
235 id: `lane-${repo.ownerName}-${repo.name}`,
236 open: !lane.idle,
237 body: drawMap(
238 repo,
239 lane.graph,
240 lane.placed,
241 emphasisFor(lane.ready, lane.critical, lane.marks),
242 {
243 id: `map-canvas-${repo.ownerName}-${repo.name}`,
244 minWidth: width,
245 cap: 0.6,
246 },
247 ),
248 },
249 });
250 }
251
252 /** GET /map — every app the viewer can see, one lane each. */
253 export function globalMapPage(ctx: Ctx): Response {
254 const viewer = ctx.auth.user;
255 if (!viewer) return signInFirst(ctx);
256 const { request } = ctx;
257 notePhase(request, "session", takeSessionTime(request));
258 const spent: Spent = { layoutMs: 0 };
259 const lanesStart = performance.now();
260 const lanes = globalLanes(ctx.app, viewer, spent);
261 const lanesMs = performance.now() - lanesStart;
262 notePhase(request, "lanes", Math.max(0, lanesMs - spent.layoutMs));
263 notePhase(request, "layout", spent.layoutMs);
264 const renderStart = performance.now();
265 const width = Math.max(0, ...lanes.map((l) => drawingWidth(l.placed)));
266 const body = html`${pageHead("Map", {
267 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.",
268 })}
269 <div id="lanes" data-live="${LIVE_KINDS}">
270 ${lanes.length === 0
271 ? html`<p class="muted">
272 Nothing to map: no open issues anywhere you can see, and nobody
273 working.
274 </p>`
275 : listCard(lanes.map((lane) => laneHtml(lane, width)))}
276 </div>`;
277 const response = page({
278 ctx,
279 title: "Map",
280 body,
281 styleCss: mapCss,
282 scriptJs: `${liveRegionsJs}\n${mapViewJs}`,
283 });
284 notePhase(request, "render", performance.now() - renderStart);
285 return response;
286 }
287
288 /** GET /api/v1/map — the lanes as data, one entry per repository. */
289 export function globalMapJson(
290 app: App,
291 viewer: User | undefined,
292 ): Record<string, unknown> {
293 return {
294 repos: globalLanes(app, viewer).map((lane) => ({
295 ...mapJson(lane.repo, lane.graph, lane.marks),
296 autonomy: lane.repo.autonomy,
297 spentTodayUsd: lane.status?.spentTodayUsd ?? null,
298 ceilingUsd: lane.status?.ceilingUsd ?? null,
299 inFlight: lane.inFlight,
300 workers: lane.workers,
301 idle: lane.idle,
302 })),
303 };
304 }
src/web/pages/map.ts +8 −273
@@ -15,13 +15,12 @@
1515 *
1616 * Drawn on the server as SVG, laid out by hand: a plan bigger than the few
1717 * hundred issues this handles is not one a person reads. The global map at
18 * `/map` is the same drawing once per repository the viewer can see, one
19 * lane each, at one scale, so a column means the same thing in every lane.
18 * `/map` (global-map.ts) is the same drawing once per repository the viewer
19 * can see, one lane each.
2020 */
2121
22 import { mayViewId, sealedScope } from "../../core/sealed.ts";
22 import { sealedScope } from "../../core/sealed.ts";
2323 import type { Database } from "bun:sqlite";
24 import type { App } from "../../app.ts";
2524 import {
2625 blockedOn,
2726 criticalPath,
@@ -34,31 +33,26 @@ import {
3433 } from "../../core/dependencies.ts";
3534 import type { Issue } from "../../core/issues.ts";
3635 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
37 import { cachedLane, cachedLayout, repoHeads, scopeKey } from "../map_cache.ts";
36 import { cachedLayout } from "../map_cache.ts";
3837 import { holdsFor } from "../../core/holds.ts";
3938 import {
4039 getRepoById,
41 listReposVisibleTo,
4240 type Repo,
4341 } from "../../core/repos.ts";
44 import { listActiveRuns, reposWithActiveRuns, type Run, type RunState } from "../../core/runs.ts";
45 import { getUsersByIds, type User } from "../../core/users.ts";
42 import { listActiveRuns, type Run, type RunState } from "../../core/runs.ts";
4643 import type { Ctx } from "../../http/context.ts";
4744 import {
4845 crumbsFor,
4946 loadRepo,
5047 notFoundPage,
51 pageHead,
5248 repoBase,
5349 repoHeader,
5450 repoPalette,
55 signInFirst,
5651 } from "../chrome.ts";
5752 import { liveRegionsJs } from "../client.ts";
5853 import { html, type Html } from "../html.ts";
5954 import { page } from "../layout.ts";
60 import { avatar, badge, btn, card, empty, icon, listCard, row } from "../ui.ts";
61 import { autonomyStatus, type AutonomyStatus } from "../../crew/autonomy.ts";
55 import { badge, btn, card, empty, row } from "../ui.ts";
6256 import {
6357 parkedTwice,
6458 struckOut,
@@ -930,7 +924,7 @@ export function countWords(words: readonly Word[]): Record<Word, number> {
930924 return out;
931925 }
932926
933 function countsLine(
927 export function countsLine(
934928 counts: Record<Word, number>,
935929 toggle?: { readonly base: string; readonly showDone: boolean },
936930 ): Html {
@@ -1351,7 +1345,7 @@ export const mapViewJs = /* js */ `
13511345 })();
13521346 `;
13531347
1354 const LIVE_KINDS = "run.queued,run.started,run.finished,issue.*,pull.*";
1348 export const LIVE_KINDS = "run.queued,run.started,run.finished,issue.*,pull.*";
13551349
13561350 /** The empty state in the studio's own words, pointing at the app page's
13571351 * own box for telling the crew (`#tell`, `src/web/studio/app.ts`). */
@@ -1563,262 +1557,3 @@ export function mapJson(
15631557 }),
15641558 };
15651559 }
1566
1567 // ---- every app, one lane each ----
1568
1569 export interface Lane {
1570 readonly repo: Repo;
1571 readonly graph: Graph;
1572 readonly placed: Layout;
1573 readonly marks: Marks;
1574 readonly ready: Ranked[];
1575 readonly critical: Issue[];
1576 readonly counts: Record<Word, number>;
1577 /** Agent runs queued or running here now. */
1578 readonly inFlight: number;
1579 /** Who those runs are: usernames, the running ones first. */
1580 readonly workers: string[];
1581 /** Nothing in flight and nothing ready: the lane folds to its header. */
1582 readonly idle: boolean;
1583 /** Set when the repository runs itself; `off` has no spend to show. */
1584 readonly status: AutonomyStatus | undefined;
1585 }
1586
1587 /** A lane as kept: everything but the repository and the workers' names. */
1588 type KeptLane = Omit<Lane, "repo" | "workers"> & {
1589 /** The runs' actors, running ones first; their names are read per request. */
1590 readonly workerIds: string[];
1591 };
1592
1593 /** Milliseconds a request spent laying graphs out, inside the lanes' build. */
1594 export interface Spent {
1595 layoutMs: number;
1596 }
1597
1598 function timed<T>(spent: Spent | undefined, run: () => T): T {
1599 if (spent === undefined) return run();
1600 const started = performance.now();
1601 try {
1602 return run();
1603 } finally {
1604 spent.layoutMs += performance.now() - started;
1605 }
1606 }
1607
1608 /**
1609 * One repository's lane for one scope, or null when it has nothing to draw.
1610 * Every read of issues here respects `scope`, so the entry it becomes is
1611 * the same for everyone whose scope computes to the same key.
1612 */
1613 function buildLane(
1614 app: App,
1615 repo: Repo,
1616 viewer: User | undefined,
1617 scope: ReturnType<typeof sealedScope>,
1618 spent?: Spent,
1619 ): KeptLane | null {
1620 const db = app.db;
1621 // A run on a sealed report is not shown to who may not see the report.
1622 const flying = listActiveRuns(db, [repo.id]).filter((r) =>
1623 mayViewId(db, repo, r.issueId, viewer),
1624 );
1625 if ((repo.counts?.openIssues ?? 0) === 0 && flying.length === 0) return null;
1626 const graph = repoGraph(db, repo.id, scope);
1627 const marks = marksFor(db, repo.id, graph, flying);
1628 const ready = readyNow(graph);
1629 const readySet = new Set(ready.map((r) => r.issue.id));
1630 const workerIds = [
1631 ...new Set(
1632 [...flying]
1633 .sort(
1634 (a, b) =>
1635 (a.state === "running" ? -1 : 0) - (b.state === "running" ? -1 : 0),
1636 )
1637 .map((r) => r.actorId)
1638 .filter((id): id is string => id !== null),
1639 ),
1640 ];
1641 const counts = countWords(
1642 graph.issues.map((i) => wordFor(i, readySet, marks)),
1643 );
1644 return {
1645 graph,
1646 placed: timed(spent, () => cachedLayout(graph, layout)),
1647 marks,
1648 ready,
1649 critical: criticalPath(graph),
1650 counts,
1651 inFlight: flying.length,
1652 workerIds,
1653 // Nothing in flight and no card that says ready: a parked or proposed
1654 // issue is not one a free slot would take.
1655 idle: flying.length === 0 && counts.ready === 0,
1656 status: repo.autonomy === "off" ? undefined : autonomyStatus(app, repo),
1657 };
1658 }
1659
1660 /**
1661 * One lane per repository the viewer can see that has something to draw:
1662 * an open issue, or a run in flight. The rest are left out rather than
1663 * drawn empty, so the page is as long as the work and no longer. One read
1664 * for every run in flight and one for who runs them; a graph and its marks
1665 * only for the lanes that stay.
1666 */
1667 export function globalLanes(
1668 app: App,
1669 viewer: User | undefined,
1670 spent?: Spent,
1671 ): Lane[] {
1672 const db = app.db;
1673 const now = new Date();
1674 // Access first, every request, and into the key (docs/CACHING.md rule 1).
1675 const repos = listReposVisibleTo(db, viewer, { limit: 200, withoutArchived: true });
1676 const heads = repoHeads(
1677 db,
1678 repos.map((r) => r.id),
1679 );
1680 const running = reposWithActiveRuns(db, repos.map((r) => r.id));
1681 const built: { repo: Repo; lane: KeptLane }[] = [];
1682 for (const repo of repos) {
1683 // Nothing open and nobody running: no lane for anyone, so no scope to work out.
1684 if ((repo.counts?.openIssues ?? 0) === 0 && !running.has(repo.id)) continue;
1685 const scope = sealedScope(db, repo, viewer);
1686 const lane = cachedLane(
1687 {
1688 repoId: repo.id,
1689 head: heads.get(repo.id) ?? 0,
1690 scope: scopeKey(scope),
1691 now,
1692 },
1693 () => buildLane(app, repo, viewer, scope, spent),
1694 );
1695 if (lane !== null) built.push({ repo, lane });
1696 }
1697 // Names are read now, not kept: a renamed account shows on the next view.
1698 const actors = getUsersByIds(
1699 db,
1700 built.flatMap(({ lane }) => lane.workerIds),
1701 );
1702 const lanes: Lane[] = built.map(({ repo, lane }) => {
1703 const { workerIds, ...rest } = lane;
1704 return {
1705 ...rest,
1706 repo,
1707 workers: [
1708 ...new Set(
1709 workerIds
1710 .map((id) => actors.get(id)?.username)
1711 .filter((n): n is string => n !== undefined),
1712 ),
1713 ],
1714 };
1715 });
1716 // Where the crew is first, then what has work ready, idle lanes last;
1717 // by name within each, so the page reads the same twice.
1718 return lanes.sort(
1719 (a, b) =>
1720 Number(b.inFlight > 0) - Number(a.inFlight > 0) ||
1721 Number(a.idle) - Number(b.idle) ||
1722 b.counts.ready - a.counts.ready ||
1723 `${a.repo.ownerName}/${a.repo.name}`.localeCompare(
1724 `${b.repo.ownerName}/${b.repo.name}`,
1725 ),
1726 );
1727 }
1728
1729 /** A lane: one folding list row — the caret, the repository's name and its
1730 * autonomy mode, who is on it, its counts — over its map. */
1731 export function laneHtml(lane: Lane, width: number): Html {
1732 const { repo } = lane;
1733 return row({
1734 lead: icon("chevronRight"),
1735 title: html`<a href="${repoBase(repo)}/map"
1736 ><span class="muted">${repo.ownerName}/</span>${repo.name}</a
1737 >
1738 ${badge(repo.autonomy, { variant: "outline" })}`,
1739 meta: countsLine(lane.counts),
1740 trail:
1741 lane.workers.length === 0
1742 ? html``
1743 : html`<span title="Working: ${lane.workers.join(", ")}"
1744 >${lane.workers.slice(0, 5).map((w) => html`${avatar(w)} `)}${lane
1745 .workers.length > 5
1746 ? html`<span class="muted small"
1747 >+${lane.workers.length - 5}</span
1748 >`
1749 : null}</span
1750 >`,
1751 data: lane.idle
1752 ? { autonomy: repo.autonomy, idle: "1" }
1753 : { autonomy: repo.autonomy },
1754 fold: {
1755 id: `lane-${repo.ownerName}-${repo.name}`,
1756 open: !lane.idle,
1757 body: drawMap(
1758 repo,
1759 lane.graph,
1760 lane.placed,
1761 emphasisFor(lane.ready, lane.critical, lane.marks),
1762 {
1763 id: `map-canvas-${repo.ownerName}-${repo.name}`,
1764 minWidth: width,
1765 cap: 0.6,
1766 },
1767 ),
1768 },
1769 });
1770 }
1771
1772 /** GET /map — every app the viewer can see, one lane each. */
1773 export function globalMapPage(ctx: Ctx): Response {
1774 const viewer = ctx.auth.user;
1775 if (!viewer) return signInFirst(ctx);
1776 const { request } = ctx;
1777 notePhase(request, "session", takeSessionTime(request));
1778 const spent: Spent = { layoutMs: 0 };
1779 const lanesStart = performance.now();
1780 const lanes = globalLanes(ctx.app, viewer, spent);
1781 const lanesMs = performance.now() - lanesStart;
1782 notePhase(request, "lanes", Math.max(0, lanesMs - spent.layoutMs));
1783 notePhase(request, "layout", spent.layoutMs);
1784 const renderStart = performance.now();
1785 const width = Math.max(0, ...lanes.map((l) => drawingWidth(l.placed)));
1786 const body = html`${pageHead("Map", {
1787 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.",
1788 })}
1789 <div id="lanes" data-live="${LIVE_KINDS}">
1790 ${lanes.length === 0
1791 ? html`<p class="muted">
1792 Nothing to map: no open issues anywhere you can see, and nobody
1793 working.
1794 </p>`
1795 : listCard(lanes.map((lane) => laneHtml(lane, width)))}
1796 </div>`;
1797 const response = page({
1798 ctx,
1799 title: "Map",
1800 body,
1801 styleCss: mapCss,
1802 scriptJs: `${liveRegionsJs}\n${mapViewJs}`,
1803 });
1804 notePhase(request, "render", performance.now() - renderStart);
1805 return response;
1806 }
1807
1808 /** GET /api/v1/map — the lanes as data, one entry per repository. */
1809 export function globalMapJson(
1810 app: App,
1811 viewer: User | undefined,
1812 ): Record<string, unknown> {
1813 return {
1814 repos: globalLanes(app, viewer).map((lane) => ({
1815 ...mapJson(lane.repo, lane.graph, lane.marks),
1816 autonomy: lane.repo.autonomy,
1817 spentTodayUsd: lane.status?.spentTodayUsd ?? null,
1818 ceilingUsd: lane.status?.ceilingUsd ?? null,
1819 inFlight: lane.inFlight,
1820 workers: lane.workers,
1821 idle: lane.idle,
1822 })),
1823 };
1824 }
test/design-map.test.ts +4 −1
@@ -8,7 +8,10 @@ import { expect, test } from "bun:test";
88 import { readFileSync } from "node:fs";
99
1010 const doc = readFileSync("docs/design/map.md", "utf8");
11 const src = readFileSync("src/web/pages/map.ts", "utf8");
11 // The repository map and its forge-wide lanes (#6310) are one page source.

Right call. The marker test checks the page source, which now spans two files. It is not weakened: every marker still has to appear in one of the two.

12 const src = ["src/web/pages/map.ts", "src/web/pages/global-map.ts"]
13 .map((path) => readFileSync(path, "utf8"))
14 .join("\n");
1215
1316 const MARKERS: Record<string, string> = {
1417 "Map canvas": 'id: "map-canvas"',

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs