Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/src/fork-graph-model.ts
Snapshot at 34e3175
1import type { Activity, ProjectState, Review } from "./contracts.ts";
2import { agentIdentities, type AgentIdentity, type LaneMarkKind } from "./lane.tsx";
3
4export interface ForkEvent {
5 id: string;
6 kind: LaneMarkKind;
7 at: number;
8 label: string;
9}
10export interface ForkLane {
11 id: string;
12 agent: string;
13 packet: string;
14 repoName: string;
15 identity?: AgentIdentity;
16 claimedAt?: number;
17 start?: number;
18 end?: number;
19 review?: Review;
20 events: ForkEvent[];
21 state: string;
22 race?: string;
23 promotedAt?: number;
24 promoted?: ProjectState["ledger"][number];
25 releasedAt?: number;
26 earlierPushes: boolean;
27}
28export type WindowChoice = "recent" | "four" | "today" | "day";
29export const timestamp = (value?: string | null) => {
30 const parsed = Date.parse(value ?? "");
31 return Number.isFinite(parsed) ? parsed : undefined;
32};
33/** Default-branch pushes within one packet's inclusive claim/end interval. */
34export function packetPushes(
35 activity: Activity[], repoName: string, defaultBranch: string, start: number, end: number,
36): Activity[] {
37 return activity.filter((event) => {
38 const at = timestamp(event.recordedAt);
39 return event.repoName === repoName && event.ref === `refs/heads/${defaultBranch}` &&
40 !/^0+$/.test(event.after) && at !== undefined && at >= start && at <= end;
41 });
42}
43export function oldestLoadedActivity(activity: Activity[]): number {
44 return Math.min(Infinity, ...activity.map((event) => timestamp(event.recordedAt) ?? Infinity));
45}
46export const clockTime = (at: number) =>
47 new Date(at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
48/** Put the collision beside its pushing lane; unrelated lanes interrupt the connector. */
49export function collisionLink(pushingY: number, claimingY: number, laneYs: number[]) {
50 const top = Math.min(pushingY, claimingY);
51 const bottom = Math.max(pushingY, claimingY);
52 const crossings = laneYs.filter((y) => y > top && y < bottom).toSorted((a, b) => a - b);
53 const direction = Math.sign(claimingY - pushingY);
54 const nextY = direction > 0 ? (crossings[0] ?? claimingY) : (crossings.at(-1) ?? claimingY);
55 const ringY = pushingY + direction * Math.min(32, Math.abs(nextY - pushingY) / 2);
56 const segments: { top: number; height: number }[] = [];
57 let start = top;
58 for (const crossing of crossings) {
59 if (crossing - 8 > start) segments.push({ top: start, height: crossing - 8 - start });
60 start = crossing + 8;
61 }
62 if (bottom > start) segments.push({ top: start, height: bottom - start });
63 return { ringY, segments };
64}
65const HOUR = 3600000;
66const eventLabels: Record<LaneMarkKind, string> = {
67 claim: "claimed",
68 push: "push",
69 review: "review requested",
70 passed: "checks passed",
71 failed: "checks failed",
72 interrupted: "checks didn't finish",
73 approved: "approved",
74 promoted: "promoted to main",
75};
76export function forkGraph(state: ProjectState, now = Date.now(), choice: WindowChoice = "recent") {
77 const identities = agentIdentities(state.agents);
78 const lanes = new Map<string, ForkLane>();
79 const addLane = (agent: string, packet: string, repoName: string, suffix = "") => {
80 const id = `${packet}:${repoName}${suffix}`;
81 if (!lanes.has(id))
82 lanes.set(id, {
83 id,
84 agent,
85 packet,
86 repoName,
87 identity: identities.get(agent),
88 events: [],
89 state: "In progress",
90 earlierPushes: false,
91 });
92 return lanes.get(id)!;
93 };
94 const add = (
95 lane: ForkLane,
96 kind: LaneMarkKind,
97 value?: string | null,
98 id: string = kind,
99 label = eventLabels[kind],
100 ) => {
101 const at = timestamp(value);
102 if (at === undefined || at > now) return;
103 if (!lane.events.some((e) => e.kind === kind && e.at === at))
104 lane.events.push({ id: `${lane.id}:${id}:${at}`, kind, at, label });
105 };
106 for (const claim of state.claims.filter((c) => c.active)) {
107 const lane = addLane(claim.agent, claim.packet, claim.repoName);
108 lane.claimedAt = timestamp(claim.createdAt);
109 add(lane, "claim", claim.createdAt);
110 }
111 for (const race of state.races ?? [])
112 for (const entrant of race.entrants) {
113 const lane = addLane(entrant.agent, race.packet, entrant.repoName);
114 lane.race = race.packet;
115 lane.claimedAt = timestamp(entrant.claimedAt);
116 lane.releasedAt =
117 entrant.closedBy === "release" || entrant.closedBy === "owner"
118 ? timestamp(entrant.closedAt)
119 : undefined;
120 lane.promotedAt = timestamp(entrant.promotedAt);
121 lane.end = lane.promotedAt ?? timestamp(entrant.closedAt);
122 lane.state = entrant.promotedAt
123 ? "Promoted to main"
124 : lane.releasedAt
125 ? "Released"
126 : entrant.closedAt
127 ? "Closed at the finish"
128 : "Racing";
129 for (const [kind, value] of [
130 ["claim", entrant.claimedAt],
131 ["push", entrant.firstPushAt],
132 ["review", entrant.reviewRequestedAt],
133 ["passed", entrant.receiptPassedAt],
134 ["approved", entrant.approvedAt],
135 ["promoted", entrant.promotedAt],
136 ] as const)
137 add(lane, kind, value);
138 }
139 for (const entry of state.ledger) {
140 const review = state.reviews.find((r) => r.id === entry.review);
141 if (!review) continue; // The ledger proves main's dot even if its lane is no longer loaded.
142 const current = lanes.get(`${review.packet}:${review.repoName}`);
143 const historical =
144 current?.claimedAt !== undefined && current.claimedAt > (timestamp(entry.promotedAt) ?? now);
145 const lane = addLane(
146 review.agent,
147 review.packet,
148 review.repoName,
149 historical ? `:promotion:${entry.review}` : "",
150 );
151 lane.promotedAt = timestamp(entry.promotedAt);
152 lane.promoted = entry;
153 lane.end = timestamp(entry.promotedAt);
154 lane.state = "Promoted to main";
155 add(
156 lane,
157 "promoted",
158 entry.promotedAt,
159 entry.review,
160 `promoted to main ${entry.sha.slice(0, 7)}`,
161 );
162 }
163 for (const lane of lanes.values()) {
164 const reviews = state.reviews
165 .filter(
166 (r) =>
167 r.packet === lane.packet &&
168 r.repoName === lane.repoName &&
169 (timestamp(r.requestedAt) ?? 0) >= (lane.claimedAt ?? 0) &&
170 (timestamp(r.requestedAt) ?? 0) <= (lane.end ?? now),
171 )
172 .toSorted((a, b) => (timestamp(a.requestedAt) ?? 0) - (timestamp(b.requestedAt) ?? 0));
173 lane.review = reviews.at(-1);
174 for (const review of reviews) {
175 add(lane, "review", review.requestedAt, review.id);
176 add(lane, "approved", review.approvedAt, review.id);
177 const receipt = review.receipt;
178 if (receipt && receipt.status !== "running")
179 add(
180 lane,
181 receipt.status === "passed" ? "passed" : receipt.status === "failed" ? "failed" : "interrupted",
182 receipt.finishedAt,
183 `${review.id}:${receipt.digest}`,
184 `${receipt.status === "passed" ? "checks passed" : receipt.status === "timed-out" ? "checks timed out" : receipt.status === "infrastructure-error" ? "checks couldn't run" : "checks failed"}${receipt.checks
185 .filter((c) => c.outcome !== "passed")
186 .map((c) => ` 路 ${c.name}: ${c.outcome}`)
187 .join("")}`,
188 );
189 }
190 const latest = lane.review;
191 if (!lane.end && latest)
192 lane.state =
193 latest.receipt?.status === "running"
194 ? "Checks running"
195 : latest.status === "approved"
196 ? "Approved"
197 : latest.receipt?.status === "timed-out"
198 ? "Timed out"
199 : latest.receipt?.status === "infrastructure-error"
200 ? "Couldn't run"
201 : latest.receipt && latest.receipt.status !== "passed"
202 ? "Checks failed"
203 : "In review";
204 // Activity is Git pushes only. A fork reused for a later packet must not inherit earlier pushes.
205 const previousEnd = Math.max(
206 0,
207 ...state.ledger
208 .filter(
209 (e) =>
210 e.review !== lane.promoted?.review &&
211 state.reviews.some((r) => r.id === e.review && r.repoName === lane.repoName) &&
212 (timestamp(e.promotedAt) ?? Infinity) <
213 (lane.claimedAt ?? timestamp(latest?.requestedAt) ?? 0),
214 )
215 .map((e) => timestamp(e.promotedAt) ?? 0),
216 );
217 for (const activity of packetPushes(
218 state.recentActivity ?? [], lane.repoName, state.project.defaultBranch,
219 lane.claimedAt ?? previousEnd, lane.end ?? now,
220 )) {
221 add(lane, "push", activity.recordedAt, String(activity.sequence));
222 }
223 lane.events.sort((a, b) => a.at - b.at);
224 lane.start = lane.claimedAt ?? lane.events[0]?.at;
225 const oldestLoaded = oldestLoadedActivity(state.recentActivity ?? []);
226 lane.earlierPushes =
227 lane.claimedAt !== undefined && (oldestLoaded === Infinity || lane.claimedAt < oldestLoaded);
228 }
229 const midnight = new Date(now);
230 midnight.setHours(0, 0, 0, 0);
231 const liveTimes = [...lanes.values()]
232 .filter((l) => !l.end)
233 .flatMap((l) => l.events.map((e) => e.at))
234 .filter((at) => at >= now - 24 * HOUR);
235 const start =
236 choice === "four"
237 ? now - 4 * HOUR
238 : choice === "day"
239 ? now - 24 * HOUR
240 : choice === "today"
241 ? midnight.getTime()
242 : Math.max(now - 24 * HOUR, Math.min(now - 4 * HOUR, ...liveTimes));
243 const visible = [...lanes.values()]
244 .filter((l) => !l.end || l.end >= start)
245 .toSorted(
246 (a, b) =>
247 Number(!!a.race) - Number(!!b.race) ||
248 (a.race ?? "").localeCompare(b.race ?? "") ||
249 (a.claimedAt ?? a.start ?? now) - (b.claimedAt ?? b.start ?? now),
250 );
251 const live = visible.filter((l) => !l.end).length;
252 const races = new Set(visible.filter((l) => l.race).map((l) => l.race)).size;
253 const promotions = state.ledger.filter(
254 (e) =>
255 (timestamp(e.promotedAt) ?? 0) >= midnight.getTime() &&
256 (timestamp(e.promotedAt) ?? Infinity) <= now,
257 ).length;
258 const summary = [
259 live ? `${live} live claim${live === 1 ? "" : "s"}` : "",
260 races ? `${races} race${races === 1 ? "" : "s"}` : "",
261 promotions ? `${promotions} promotion${promotions === 1 ? "" : "s"} today` : "",
262 ]
263 .filter(Boolean)
264 .join(" 路 ");
265 return {
266 lanes: visible,
267 start,
268 now,
269 summary: summary || "No forks in flight",
270 live,
271 races,
272 promotions,
273 ledger: state.ledger.filter((e) => {
274 const at = timestamp(e.promotedAt);
275 return at !== undefined && at >= start && at <= now;
276 }),
277 };
278}
279
280/** Prefer the newest main label; older labels must fit without touching a chosen label. */
281export function mainShaLabels(marks: { id: string; at: number; x: number }[], width: number) {
282 const labels = new Map<string, number>();
283 const half = 30;
284 const nowSpace = 20;
285 if (width < 52 + nowSpace + half * 2) return labels;
286 for (const mark of marks.toSorted((a, b) => b.at - a.at)) {
287 const center = Math.max(12 + half, Math.min(width - 40 - nowSpace - half, mark.x));
288 if ([...labels.values()].every((x) => Math.abs(x - center) >= half * 2 + 12))
289 labels.set(mark.id, center);
290 }
291 return labels;
292}
293/** Visible cells retain the three newest events in chronological order. */
294export function tableEvents(events: ForkEvent[], kinds: string[]) {
295 const matching = events
296 .filter((event) => kinds.includes(event.kind))
297 .toSorted((a, b) => a.at - b.at);
298 return { shown: matching.slice(-3), earlier: matching.slice(0, -3) };
299}
300
301const significance: Record<LaneMarkKind, number> = {
302 claim: 0,
303 push: 1,
304 review: 2,
305 approved: 3,
306 promoted: 4,
307 passed: 5,
308 failed: 5,
309 interrupted: 5,
310};
311/** Adjacent dense events form one cluster; its tooltip retains every recorded event. */
312export function foldMarks(events: ForkEvent[], start: number, now: number, width: number) {
313 const groups: { event: ForkEvent; events: ForkEvent[]; x: number }[] = [];
314 for (const event of events
315 .filter((e) => e.at >= start && e.at <= now)
316 .toSorted((a, b) => a.at - b.at)) {
317 const x = ((event.at - start) / Math.max(1, now - start)) * width;
318 const last = groups.at(-1);
319 const previous = last?.events.at(-1);
320 if (last && previous && x - ((previous.at - start) / Math.max(1, now - start)) * width < 16) {
321 last.events.push(event);
322 if (significance[event.kind] >= significance[last.event.kind]) {
323 last.event = event;
324 last.x = x;
325 }
326 } else groups.push({ event, events: [event], x });
327 }
328 return groups;
329}
330