Skip to content
363 linesCodeBlameRaw

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 way1import 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. */
12export 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. */
16export 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. */
19export 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. */
25export 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)`. */
40export 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`. */
45function within(name: string, parent: string | null): string {
46 return parent && name.startsWith(`${parent} / `) ? name.slice(parent.length + 3) : name;
47}
48
49function 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 */
58export 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 way69 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 way86 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 way94 };
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. */
102export 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. */
109export 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. */
130export 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 */
141export 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. */
165function 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 */
177export 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 */
197export 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. */
239export 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. */
249export 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 */
255export type EdgeState = "idle" | "active" | "failed";
256export type PlacedEdge = { from: string; to: string; path: string; state: EdgeState };
257
258export type GraphLayout = { width: number; height: number; nodes: PlacedNode[]; groups: PlacedGroup[]; edges: PlacedEdge[] };
259
260type Level = { width: number; height: number; nodes: PlacedNode[]; groups: PlacedGroup[]; edges: PlacedEdge[] };
261
262/** A job node's height: taller with a deployment's address. */
263function 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. */
268export 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. */
274export 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
290function 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
298function 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. */
354export 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.