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

packages/forkhorse-dashboard/src/agent-status-model.ts

Snapshot at 34e3175

Containing directory

5,860 bytes · typescriptView raw
1import type { Activity, ProjectState } from "./contracts.ts";
2import { oldestLoadedActivity, packetPushes, timestamp } from "./fork-graph-model.ts";
3import { agentIdentities, type AgentIdentity } from "./lane.tsx";
4import { reviewState } from "./model.ts";
5
6export interface AgentClaimStatus {
7  claim: ProjectState["claims"][number];
8  priority: number;
9  label: string;
10  tone: "warning" | "danger" | "info" | "success" | "brand";
11  waiting: "owner" | "checks" | "agent";
12  caption?: string;
13  quietSince?: number;
14}
15export interface AgentStatusRow {
16  agent: ProjectState["agents"][number];
17  identity?: AgentIdentity;
18  claims: AgentClaimStatus[];
19  status?: AgentClaimStatus;
20  fork?: ProjectState["forks"][number];
21  lastPush?: Activity;
22  missingPush: "No push yet" | "Not in loaded activity";
23}
24const RECENT = 30 * 60_000;
25
26/** First proven match wins. Claims and loaded pushes are evidence, never a heartbeat. */
27export function claimStatus(
28  state: ProjectState, claim: ProjectState["claims"][number], activity: Activity[], now = Date.now(),
29): AgentClaimStatus {
30  const result = (priority: number, label: string, tone: AgentClaimStatus["tone"],
31    waiting: AgentClaimStatus["waiting"], caption?: string): AgentClaimStatus =>
32    ({ claim, priority, label, tone, waiting, caption });
33  const pushed = packetPushes(activity, claim.repoName, state.project.defaultBranch,
34    timestamp(claim.createdAt) ?? 0, now).toSorted((a, b) =>
35    (timestamp(b.recordedAt) ?? 0) - (timestamp(a.recordedAt) ?? 0) || b.sequence - a.sequence)[0];
36  const pushAt = timestamp(pushed?.recordedAt) ?? -Infinity;
37  const review = state.reviews.filter((entry) => entry.packet === claim.packet &&
38    entry.agent === claim.agent && entry.repoName === claim.repoName &&
39    (timestamp(entry.requestedAt) ?? Infinity) >= (timestamp(claim.createdAt) ?? 0))
40    .toSorted((a, b) => (timestamp(b.requestedAt) ?? 0) - (timestamp(a.requestedAt) ?? 0))[0];
41  // Match the Work/review gate: a claimant's own packet is not the blocked pushing fork.
42  if (state.blockers.some((blocker) => !blocker.resolved && blocker.repoName === claim.repoName &&
43    (blocker.pushingPacket == null || blocker.pushingPacket === claim.packet)))
44    return result(1, "Blocked", "warning", "owner", "Resolve the collision");
45  const sameHead = !review || state.forks.find((fork) => fork.repoName === claim.repoName)?.head === review.sha;
46  if (review?.returned && sameHead && pushAt <= (timestamp(review.returned.at) ?? Infinity))
47    return result(2, "Returned", "danger", "agent", "Push a fix");
48  const receipt = review?.receipt;
49  if (receipt && sameHead && pushAt <= (timestamp(receipt.finishedAt) ?? timestamp(review?.requestedAt) ?? Infinity)) {
50    const failed = {
51      failed: ["Checks failed", "danger"],
52      "timed-out": ["Timed out", "warning"],
53      "infrastructure-error": ["Couldn't run", "warning"],
54    } as const;
55    if (receipt.status in failed) {
56      const [label, tone] = failed[receipt.status as keyof typeof failed];
57      return result(3, label, tone, "agent", "Push a fix or request review again");
58    }
59  }
60  const current = review && sameHead && reviewState(review, state).kind === "current" &&
61    review.status !== "stale" && pushAt <= (timestamp(review.requestedAt) ?? Infinity);
62  if (current && receipt?.status === "running")
63    return result(4, "Checks running", "info", "checks");
64  if (current && review.status === "pending" &&
65    (receipt?.status === "passed" || state.project.settings?.requireReceipt === false))
66    return result(5, "In review", "info", "owner", "Review");
67  if (current && review.status === "approved")
68    return result(6, "Approved", "success", "owner", "Promote");
69  // The oldest loaded event bounds what we can say about an absence of pushes.
70  if (Math.max(pushAt, timestamp(claim.createdAt) ?? -Infinity) < now - RECENT &&
71    oldestLoadedActivity(activity) <= now - RECENT)
72    return { ...result(8, "Claimed, no recent push", "warning", "agent"),
73      quietSince: timestamp(pushed?.recordedAt) ?? timestamp(claim.createdAt) };
74  const racing = state.races?.some((race) => race.packet === claim.packet && race.status === "open" &&
75    race.entrants.some((entrant) => entrant.agent === claim.agent && entrant.repoName === claim.repoName && !entrant.closedAt));
76  return result(7, racing ? "Racing" : "In progress", "brand", "agent");
77}
78
79/** One row per enrolled identity; urgency chooses the packet, rank chooses the row order. */
80export function agentStatusRows(state: ProjectState, activity = state.recentActivity ?? [], now = Date.now()): AgentStatusRow[] {
81  const identities = agentIdentities(state.agents);
82  const oldest = oldestLoadedActivity(activity);
83  return state.agents.map((agent) => {
84    const claims = state.claims.filter((claim) => claim.active && claim.agent === agent.name)
85      .map((claim) => claimStatus(state, claim, activity, now))
86      .toSorted((a, b) => a.priority - b.priority || a.claim.packet.localeCompare(b.claim.packet));
87    const forks = state.forks.filter((fork) => fork.agent === agent.name);
88    const lastPush = activity.filter((event) =>
89      forks.some((fork) => fork.repoName === event.repoName) && !/^0+$/.test(event.after) &&
90      (timestamp(event.recordedAt) ?? Infinity) <= now)
91      .toSorted((a, b) => (timestamp(b.recordedAt) ?? 0) - (timestamp(a.recordedAt) ?? 0) || b.sequence - a.sequence)[0];
92    return { agent, identity: identities.get(agent.name), claims, status: claims[0], lastPush,
93      fork: forks.find((fork) => fork.repoName === claims[0]?.claim.repoName) ?? forks[0],
94      missingPush: claims.length && claims.every((entry) => (timestamp(entry.claim.createdAt) ?? -Infinity) > oldest)
95        ? "No push yet" as const : "Not in loaded activity" as const };
96  }).toSorted((a, b) => (a.identity?.number ?? Infinity) - (b.identity?.number ?? Infinity));
97}
98