Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/src/agent-status-model.ts
Snapshot at 34e3175
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