Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-dashboard/src/fork-graph-model.ts

Snapshot at 34e3175

Containing directory

12,515 bytes 路 typescriptView raw
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