Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way | 1 | import type { Conclusion, Job } from "@g1t/contracts"; |
| 2 | ||
| 3 | /** | |
| 4 | * A run's jobs as a graph: grouped (a matrix's jobs into one node, a called | |
| 5 | * workflow's jobs into a box under the job that calls it), placed in | |
| 6 | * columns by how deep their `needs` go, ordered within a column so fewer | |
| 7 | * connectors cross, and joined by connectors. Pure, so the run page draws | |
| 8 | * it again from each refresh of its data. | |
| 9 | */ | |
| 10 | ||
| 11 | /** What the graph reads of a job. */ | |
| 12 | export type GraphJob = Pick<Job, "id" | "key" | "name" | "needs" | "status" | "conclusion" | "startedAt" | "finishedAt"> & | |
| 13 | Partial<Pick<Job, "environment" | "environmentUrl" | "uses">>; | |
| 14 | ||
| 15 | /** Where a node or a group stands, as the status icon shows it. */ | |
| 16 | export type Standing = { status: Job["status"]; conclusion: Conclusion | null }; | |
| 17 | ||
| 18 | /** One job; a matrix's jobs (one key, several jobs); or a job calling a workflow, with that workflow's jobs. */ | |
| 19 | export type Unit = | |
| 20 | | { kind: "job"; key: string; label: string; needs: string[]; job: GraphJob } | |
| 21 | | { kind: "matrix"; key: string; label: string; needs: string[]; jobs: GraphJob[] } | |
| 22 | | { kind: "call"; key: string; label: string; needs: string[]; uses: string | null; callers: GraphJob[]; units: Unit[] }; | |
| 23 | ||
| 24 | /** Sizes, in pixels. */ | |
| 25 | export const GRAPH = { | |
| 26 | nodeWidth: 220, | |
| 27 | nodeHeight: 44, | |
| 28 | /** The line a deployment's address takes. */ | |
| 29 | urlHeight: 18, | |
| 30 | /** A matrix's job, listed in its expanded node. */ | |
| 31 | rowHeight: 32, | |
| 32 | columnGap: 48, | |
| 33 | rowGap: 16, | |
| 34 | groupPad: 12, | |
| 35 | groupHead: 32, | |
| 36 | margin: 16, | |
| 37 | } as const; | |
| 38 | ||
| 39 | /** `test` from `test (ubuntu-latest, 20)`. */ | |
| 40 | export function baseName(name: string): string { | |
| 41 | return name.replace(/\s*\([^()]*\)$/, "") || name; | |
| 42 | } | |
| 43 | ||
| 44 | /** A called workflow's job by its own name: `test`, not `build / test`. */ | |
| 45 | function within(name: string, parent: string | null): string { | |
| 46 | return parent && name.startsWith(`${parent} / `) ? name.slice(parent.length + 3) : name; | |
| 47 | } | |
| 48 | ||
| 49 | function unique(values: string[]): string[] { | |
| 50 | return [...new Set(values)]; | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * The run's jobs as units, in the order they were listed. A called | |
| 55 | * workflow's jobs have keys under their caller's (`build/test`), and a | |
| 56 | * matrix's jobs share their key. | |
| 57 | */ | |
| 58 | export function groupJobs(jobs: GraphJob[], prefix = "", parent: string | null = null): Unit[] { | |
| 59 | const order: string[] = []; | |
| 60 | const direct = new Map<string, GraphJob[]>(); | |
| 61 | const nested = new Map<string, GraphJob[]>(); | |
| 62 | for (const job of jobs) { | |
| 63 | if (!job.key.startsWith(prefix)) continue; | |
| 64 | const rest = job.key.slice(prefix.length); | |
| 65 | if (!rest) continue; | |
| 66 | const segment = rest.split("/")[0]!; | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 67 | // A matrix's calls each have their jobs under `build[i]/`, in `build`'s box. |
| 68 | const key = prefix + segment.replace(/\[\d+\]$/, ""); | |
| A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way | 69 | if (!direct.has(key)) { |
| 70 | order.push(key); | |
| 71 | direct.set(key, []); | |
| 72 | nested.set(key, []); | |
| 73 | } | |
| 74 | (rest === segment ? direct : nested).get(key)!.push(job); | |
| 75 | } | |
| 76 | return order.map((key): Unit => { | |
| 77 | const own = direct.get(key)!; | |
| 78 | const inner = nested.get(key)!; | |
| 79 | const needs = unique(own.flatMap((job) => job.needs)); | |
| 80 | const label = own.length > 0 ? within(own.length > 1 ? baseName(own[0]!.name) : own[0]!.name, parent) : key.slice(prefix.length); | |
| 81 | if (inner.length > 0 || own.some((job) => job.uses)) { | |
| 82 | const callerName = own.length > 0 ? (own.length > 1 ? baseName(own[0]!.name) : own[0]!.name) : label; | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 83 | // One call, or one for each combination of the caller's matrix, |
| 84 | // whose jobs keep the combination in their names. | |
| 85 | const calls = unique(inner.map((job) => job.key.slice(0, job.key.indexOf("/", key.length) + 1))); | |
| A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way | 86 | return { |
| 87 | kind: "call", | |
| 88 | key, | |
| 89 | label, | |
| 90 | needs, | |
| 91 | uses: own.find((job) => job.uses)?.uses ?? null, | |
| 92 | callers: own, | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 93 | units: calls.flatMap((under) => groupJobs(inner, under, under === `${key}/` ? callerName : baseName(callerName))), |
| A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way | 94 | }; |
| 95 | } | |
| 96 | if (own.length > 1) return { kind: "matrix", key, label, needs, jobs: own }; | |
| 97 | return { kind: "job", key, label, needs, job: own[0]! }; | |
| 98 | }); | |
| 99 | } | |
| 100 | ||
| 101 | /** Every job of a unit, its called workflow's included. */ | |
| 102 | export function jobsOf(unit: Unit): GraphJob[] { | |
| 103 | if (unit.kind === "job") return [unit.job]; | |
| 104 | if (unit.kind === "matrix") return unit.jobs; | |
| 105 | return [...unit.callers, ...unit.units.flatMap(jobsOf)]; | |
| 106 | } | |
| 107 | ||
| 108 | /** Where several jobs stand together: running while any runs, else the worst way any ended. */ | |
| 109 | export function standingOf(jobs: GraphJob[]): Standing { | |
| 110 | if (jobs.length === 0) return { status: "waiting", conclusion: null }; | |
| 111 | if (jobs.every((job) => job.status === "completed")) { | |
| 112 | const ended = jobs.map((job) => job.conclusion); | |
| 113 | const conclusion: Conclusion = ended.includes("failure") | |
| 114 | ? "failure" | |
| 115 | : ended.includes("cancelled") | |
| 116 | ? "cancelled" | |
| 117 | : ended.includes("success") | |
| 118 | ? "success" | |
| 119 | : "skipped"; | |
| 120 | return { status: "completed", conclusion }; | |
| 121 | } | |
| 122 | const has = (status: Job["status"]) => jobs.some((job) => job.status === status); | |
| 123 | if (has("in_progress") || has("calling")) return { status: "in_progress", conclusion: null }; | |
| 124 | if (has("pending")) return { status: "pending", conclusion: null }; | |
| 125 | if (has("queued")) return { status: "queued", conclusion: null }; | |
| 126 | return { status: "waiting", conclusion: null }; | |
| 127 | } | |
| 128 | ||
| 129 | /** A unit's standing; a called workflow's is its caller's while it has one. */ | |
| 130 | export function unitStanding(unit: Unit): Standing { | |
| 131 | if (unit.kind === "call" && unit.callers.length > 0) return standingOf(unit.callers); | |
| 132 | return standingOf(jobsOf(unit)); | |
| 133 | } | |
| 134 | ||
| 135 | /** | |
| 136 | * Each unit's column: the longest path of `needs` from a unit needing | |
| 137 | * nothing. Needs of keys not among `units` are left out, and so is a need | |
| 138 | * that would close a cycle (a workflow cannot have one, but the graph does | |
| 139 | * not trust that). | |
| 140 | */ | |
| 141 | export function columns(units: Unit[]): Map<string, number> { | |
| 142 | const byKey = new Map(units.map((unit) => [unit.key, unit])); | |
| 143 | const depth = new Map<string, number>(); | |
| 144 | const visiting = new Set<string>(); | |
| 145 | const visit = (key: string): number => { | |
| 146 | const known = depth.get(key); | |
| 147 | if (known !== undefined) return known; | |
| 148 | if (visiting.has(key)) return -1; | |
| 149 | visiting.add(key); | |
| 150 | let column = 0; | |
| 151 | for (const need of byKey.get(key)!.needs) { | |
| 152 | if (need === key || !byKey.has(need)) continue; | |
| 153 | const before = visit(need); | |
| 154 | if (before >= 0) column = Math.max(column, before + 1); | |
| 155 | } | |
| 156 | visiting.delete(key); | |
| 157 | depth.set(key, column); | |
| 158 | return column; | |
| 159 | }; | |
| 160 | for (const unit of units) visit(unit.key); | |
| 161 | return depth; | |
| 162 | } | |
| 163 | ||
| 164 | /** The connectors of one level: from a need to what needs it, never backwards. */ | |
| 165 | function links(units: Unit[], column: Map<string, number>): [string, string][] { | |
| 166 | const out: [string, string][] = []; | |
| 167 | for (const unit of units) | |
| 168 | for (const need of unique(unit.needs)) | |
| 169 | if (need !== unit.key && column.has(need) && column.get(need)! < column.get(unit.key)!) out.push([need, unit.key]); | |
| 170 | return out; | |
| 171 | } | |
| 172 | ||
| 173 | /** | |
| 174 | * How many pairs of connectors cross, counting pairs that run between the | |
| 175 | * same two columns. | |
| 176 | */ | |
| 177 | export function crossings(order: string[][], edges: [string, string][]): number { | |
| 178 | const at = new Map<string, [number, number]>(); | |
| 179 | order.forEach((keys, column) => keys.forEach((key, row) => at.set(key, [column, row]))); | |
| 180 | let count = 0; | |
| 181 | for (let i = 0; i < edges.length; i++) | |
| 182 | for (let j = i + 1; j < edges.length; j++) { | |
| 183 | const [a, b] = edges[i]!.map((key) => at.get(key)!); | |
| 184 | const [c, d] = edges[j]!.map((key) => at.get(key)!); | |
| 185 | if (a![0] !== c![0] || b![0] !== d![0]) continue; | |
| 186 | if ((a![1] - c![1]) * (b![1] - d![1]) < 0) count++; | |
| 187 | } | |
| 188 | return count; | |
| 189 | } | |
| 190 | ||
| 191 | /** | |
| 192 | * Units per column, top to bottom: first as listed, then sorted by the | |
| 193 | * mean row of what they need (sweeping right) and of what needs them | |
| 194 | * (sweeping left), a few times, keeping the order with the fewest | |
| 195 | * crossings. | |
| 196 | */ | |
| 197 | export function orderColumns(units: Unit[], column: Map<string, number>, sweeps = 4): string[][] { | |
| 198 | const count = units.length === 0 ? 0 : Math.max(...column.values()) + 1; | |
| 199 | let order: string[][] = Array.from({ length: count }, () => []); | |
| 200 | for (const unit of units) order[column.get(unit.key)!]!.push(unit.key); | |
| 201 | const edges = links(units, column); | |
| 202 | const needsOf = new Map<string, string[]>(); | |
| 203 | const neededBy = new Map<string, string[]>(); | |
| 204 | for (const [from, to] of edges) { | |
| 205 | needsOf.set(to, [...(needsOf.get(to) ?? []), from]); | |
| 206 | neededBy.set(from, [...(neededBy.get(from) ?? []), to]); | |
| 207 | } | |
| 208 | let best = order.map((keys) => [...keys]); | |
| 209 | let fewest = crossings(best, edges); | |
| 210 | const rowOf = () => { | |
| 211 | const rows = new Map<string, number>(); | |
| 212 | order.forEach((keys) => keys.forEach((key, row) => rows.set(key, row))); | |
| 213 | return rows; | |
| 214 | }; | |
| 215 | const sortBy = (keys: string[], others: Map<string, string[]>, rows: Map<string, number>) => { | |
| 216 | const centre = (key: string, index: number) => { | |
| 217 | const near = others.get(key) ?? []; | |
| 218 | return near.length === 0 ? index : near.reduce((sum, other) => sum + rows.get(other)!, 0) / near.length; | |
| 219 | }; | |
| 220 | return keys | |
| 221 | .map((key, index) => ({ key, index, at: centre(key, index) })) | |
| 222 | .sort((x, y) => x.at - y.at || x.index - y.index) | |
| 223 | .map(({ key }) => key); | |
| 224 | }; | |
| 225 | for (let sweep = 0; sweep < sweeps && fewest > 0; sweep++) { | |
| 226 | const right = sweep % 2 === 0; | |
| 227 | const range = right ? [...order.keys()].slice(1) : [...order.keys()].reverse().slice(1); | |
| 228 | for (const index of range) order[index] = sortBy(order[index]!, right ? needsOf : neededBy, rowOf()); | |
| 229 | const now = crossings(order, edges); | |
| 230 | if (now < fewest) { | |
| 231 | fewest = now; | |
| 232 | best = order.map((keys) => [...keys]); | |
| 233 | } | |
| 234 | } | |
| 235 | return best; | |
| 236 | } | |
| 237 | ||
| 238 | /** A placed node: a job, or a matrix's jobs. */ | |
| 239 | export type PlacedNode = { | |
| 240 | unit: Extract<Unit, { kind: "job" | "matrix" }>; | |
| 241 | x: number; | |
| 242 | y: number; | |
| 243 | w: number; | |
| 244 | h: number; | |
| 245 | expanded: boolean; | |
| 246 | }; | |
| 247 | ||
| 248 | /** A placed box: a job calling a workflow, its jobs inside. */ | |
| 249 | export type PlacedGroup = { unit: Extract<Unit, { kind: "call" }>; x: number; y: number; w: number; h: number }; | |
| 250 | ||
| 251 | /** | |
| 252 | * `failed`: from a unit that failed. `active`: to a unit running now. | |
| 253 | * `idle`: the rest. | |
| 254 | */ | |
| 255 | export type EdgeState = "idle" | "active" | "failed"; | |
| 256 | export type PlacedEdge = { from: string; to: string; path: string; state: EdgeState }; | |
| 257 | ||
| 258 | export type GraphLayout = { width: number; height: number; nodes: PlacedNode[]; groups: PlacedGroup[]; edges: PlacedEdge[] }; | |
| 259 | ||
| 260 | type Level = { width: number; height: number; nodes: PlacedNode[]; groups: PlacedGroup[]; edges: PlacedEdge[] }; | |
| 261 | ||
| 262 | /** A job node's height: taller with a deployment's address. */ | |
| 263 | function jobHeight(job: GraphJob): number { | |
| 264 | return GRAPH.nodeHeight + (job.environmentUrl ? GRAPH.urlHeight : 0); | |
| 265 | } | |
| 266 | ||
| 267 | /** The address a matrix's jobs all deploy to, when they share one. */ | |
| 268 | export function sharedUrl(jobs: GraphJob[]): string | null { | |
| 269 | const url = jobs[0]?.environmentUrl ?? null; | |
| 270 | return url && jobs.every((job) => job.environmentUrl === url) ? url : null; | |
| 271 | } | |
| 272 | ||
| 273 | /** A rounded right-angled connector from a node's right edge to another's left. */ | |
| 274 | export function connector(x1: number, y1: number, x2: number, y2: number): string { | |
| 275 | if (Math.abs(y1 - y2) < 0.5) return `M${x1} ${y1}H${x2}`; | |
| 276 | // Turn in the gap before the target, so the connector leaves at its source's row. | |
| 277 | const mx = Math.max(x1 + 8, x2 - GRAPH.columnGap / 2); | |
| 278 | const r = Math.min(8, Math.abs(y2 - y1) / 2, mx - x1, x2 - mx); | |
| 279 | const down = y2 > y1 ? 1 : -1; | |
| 280 | return [ | |
| 281 | `M${x1} ${y1}`, | |
| 282 | `H${mx - r}`, | |
| 283 | `Q${mx} ${y1} ${mx} ${y1 + down * r}`, | |
| 284 | `V${y2 - down * r}`, | |
| 285 | `Q${mx} ${y2} ${mx + r} ${y2}`, | |
| 286 | `H${x2}`, | |
| 287 | ].join(""); | |
| 288 | } | |
| 289 | ||
| 290 | function edgeState(from: Unit, to: Unit): EdgeState { | |
| 291 | const source = unitStanding(from); | |
| 292 | if (source.status === "completed" && source.conclusion === "failure") return "failed"; | |
| 293 | const target = unitStanding(to); | |
| 294 | if (source.status === "completed" && target.status === "in_progress") return "active"; | |
| 295 | return "idle"; | |
| 296 | } | |
| 297 | ||
| 298 | function place(units: Unit[], expanded: ReadonlySet<string>, ox: number, oy: number): Level { | |
| 299 | const column = columns(units); | |
| 300 | const order = orderColumns(units, column); | |
| 301 | const byKey = new Map(units.map((unit) => [unit.key, unit])); | |
| 302 | const level: Level = { width: 0, height: 0, nodes: [], groups: [], edges: [] }; | |
| 303 | // Each unit's size; a called workflow's box holds its own level. | |
| 304 | const size = (unit: Unit): [number, number] => { | |
| 305 | if (unit.kind === "job") return [GRAPH.nodeWidth, jobHeight(unit.job)]; | |
| 306 | if (unit.kind === "matrix") | |
| 307 | return [ | |
| 308 | GRAPH.nodeWidth, | |
| 309 | GRAPH.nodeHeight + (sharedUrl(unit.jobs) ? GRAPH.urlHeight : 0) + (expanded.has(unit.key) ? unit.jobs.length * GRAPH.rowHeight + 6 : 0), | |
| 310 | ]; | |
| 311 | const content = place(unit.units, expanded, 0, 0); | |
| 312 | return [Math.max(GRAPH.nodeWidth, content.width) + GRAPH.groupPad * 2, GRAPH.groupHead + content.height + GRAPH.groupPad]; | |
| 313 | }; | |
| 314 | const sizes = new Map(units.map((unit) => [unit.key, size(unit)])); | |
| 315 | const box = new Map<string, { x: number; y: number; w: number; h: number }>(); | |
| 316 | let x = ox; | |
| 317 | for (const keys of order) { | |
| 318 | let y = oy; | |
| 319 | let widest = 0; | |
| 320 | for (const key of keys) { | |
| 321 | const [w, h] = sizes.get(key)!; | |
| 322 | box.set(key, { x, y, w, h }); | |
| 323 | y += h + GRAPH.rowGap; | |
| 324 | widest = Math.max(widest, w); | |
| 325 | } | |
| 326 | level.height = Math.max(level.height, y - GRAPH.rowGap - oy); | |
| 327 | x += widest + GRAPH.columnGap; | |
| 328 | } | |
| 329 | level.width = Math.max(0, x - GRAPH.columnGap - ox); | |
| 330 | for (const unit of units) { | |
| 331 | const at = box.get(unit.key)!; | |
| 332 | if (unit.kind === "call") { | |
| 333 | level.groups.push({ unit, ...at }); | |
| 334 | // Its jobs, moved into its box. | |
| 335 | const content = place(unit.units, expanded, at.x + GRAPH.groupPad, at.y + GRAPH.groupHead); | |
| 336 | level.nodes.push(...content.nodes); | |
| 337 | level.groups.push(...content.groups); | |
| 338 | level.edges.push(...content.edges); | |
| 339 | } else { | |
| 340 | level.nodes.push({ unit, ...at, expanded: unit.kind === "matrix" && expanded.has(unit.key) }); | |
| 341 | } | |
| 342 | } | |
| 343 | for (const [from, to] of links(units, column)) { | |
| 344 | const a = box.get(from)!; | |
| 345 | const b = box.get(to)!; | |
| 346 | // Connectors meet a node at the middle of its first line. | |
| 347 | const anchor = GRAPH.nodeHeight / 2; | |
| 348 | level.edges.push({ from, to, path: connector(a.x + a.w, a.y + anchor, b.x, b.y + anchor), state: edgeState(byKey.get(from)!, byKey.get(to)!) }); | |
| 349 | } | |
| 350 | return level; | |
| 351 | } | |
| 352 | ||
| 353 | /** The whole graph, placed: `expanded` names the matrices shown open. */ | |
| 354 | export function layoutRun(jobs: GraphJob[], expanded: ReadonlySet<string> = new Set()): GraphLayout { | |
| 355 | const level = place(groupJobs(jobs), expanded, GRAPH.margin, GRAPH.margin); | |
| 356 | return { | |
| 357 | width: level.width + GRAPH.margin * 2, | |
| 358 | height: level.height + GRAPH.margin * 2, | |
| 359 | nodes: level.nodes, | |
| 360 | groups: level.groups, | |
| 361 | edges: level.edges, | |
| 362 | }; | |
| 363 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.