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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6310-the-forge-wide-map-leaves-map-ts-for-src → main 1 commit fork 1 thread · 1 unresolved
5 files
changed +320
−276
src/http/api.ts +2 −1
| @@ -353,7 +353,8 @@ import { pillarView } from "../core/pillars.ts"; | ||
| 353 | 353 | import { askToLiveFor } from "../web/ask_to_live.ts"; |
| 354 | 354 | import { decisionStats, listDecisions } from "../core/decisions.ts"; |
| 355 | 355 | 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"; | |
| 357 | 358 | import { cachedMap } from "../web/map_snapshot.ts"; |
| 358 | 359 | import { |
| 359 | 360 | milestoneData, |
src/http/server.ts +2 −1
| @@ -231,7 +231,8 @@ import { | ||
| 231 | 231 | import { noteModerate } from "../web/studio/notes.ts"; |
| 232 | 232 | import { tellPage } from "../web/studio/tell.ts"; |
| 233 | 233 | 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"; | |
| 235 | 236 | import { |
| 236 | 237 | milestonePage, |
| 237 | 238 | 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 @@ | ||
| 15 | 15 | * |
| 16 | 16 | * Drawn on the server as SVG, laid out by hand: a plan bigger than the few |
| 17 | 17 | * 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. | |
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | import { mayViewId, sealedScope } from "../../core/sealed.ts"; | |
| 22 | import { sealedScope } from "../../core/sealed.ts"; | |
| 23 | 23 | import type { Database } from "bun:sqlite"; |
| 24 | import type { App } from "../../app.ts"; | |
| 25 | 24 | import { |
| 26 | 25 | blockedOn, |
| 27 | 26 | criticalPath, |
| @@ -34,31 +33,26 @@ import { | ||
| 34 | 33 | } from "../../core/dependencies.ts"; |
| 35 | 34 | import type { Issue } from "../../core/issues.ts"; |
| 36 | 35 | 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"; | |
| 38 | 37 | import { holdsFor } from "../../core/holds.ts"; |
| 39 | 38 | import { |
| 40 | 39 | getRepoById, |
| 41 | listReposVisibleTo, | |
| 42 | 40 | type Repo, |
| 43 | 41 | } 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"; | |
| 46 | 43 | import type { Ctx } from "../../http/context.ts"; |
| 47 | 44 | import { |
| 48 | 45 | crumbsFor, |
| 49 | 46 | loadRepo, |
| 50 | 47 | notFoundPage, |
| 51 | pageHead, | |
| 52 | 48 | repoBase, |
| 53 | 49 | repoHeader, |
| 54 | 50 | repoPalette, |
| 55 | signInFirst, | |
| 56 | 51 | } from "../chrome.ts"; |
| 57 | 52 | import { liveRegionsJs } from "../client.ts"; |
| 58 | 53 | import { html, type Html } from "../html.ts"; |
| 59 | 54 | 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"; | |
| 62 | 56 | import { |
| 63 | 57 | parkedTwice, |
| 64 | 58 | struckOut, |
| @@ -930,7 +924,7 @@ export function countWords(words: readonly Word[]): Record<Word, number> { | ||
| 930 | 924 | return out; |
| 931 | 925 | } |
| 932 | 926 | |
| 933 | function countsLine( | |
| 927 | export function countsLine( | |
| 934 | 928 | counts: Record<Word, number>, |
| 935 | 929 | toggle?: { readonly base: string; readonly showDone: boolean }, |
| 936 | 930 | ): Html { |
| @@ -1351,7 +1345,7 @@ export const mapViewJs = /* js */ ` | ||
| 1351 | 1345 | })(); |
| 1352 | 1346 | `; |
| 1353 | 1347 | |
| 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.*"; | |
| 1355 | 1349 | |
| 1356 | 1350 | /** The empty state in the studio's own words, pointing at the app page's |
| 1357 | 1351 | * own box for telling the crew (`#tell`, `src/web/studio/app.ts`). */ |
| @@ -1563,262 +1557,3 @@ export function mapJson( | ||
| 1563 | 1557 | }), |
| 1564 | 1558 | }; |
| 1565 | 1559 | } |
| 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"; | ||
| 8 | 8 | import { readFileSync } from "node:fs"; |
| 9 | 9 | |
| 10 | 10 | 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. | |
| 12 | const src = ["src/web/pages/map.ts", "src/web/pages/global-map.ts"] | |
| 13 | .map((path) => readFileSync(path, "utf8")) | |
| 14 | .join("\n"); | |
| 12 | 15 | |
| 13 | 16 | const MARKERS: Record<string, string> = { |
| 14 | 17 | "Map canvas": 'id: "map-canvas"', |
new above
offline
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.