Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/src/model.ts
Snapshot at 34e3175
1import { usagePromotionCaption } from "./usage.ts";
2import type {
3 Activity,
4 BlockerView,
5 WireBlocker,
6 Brand,
7 ProjectOption,
8 ProjectState,
9 Review,
10 StateResponse,
11} from "./contracts.ts";
12
13export interface ViewModel {
14 brand: Brand;
15 projects: ProjectOption[];
16 project: string;
17 fixture: boolean;
18 state?: ProjectState;
19 activity: Activity[];
20 busy: boolean;
21 /** Reads keep the last good snapshot on screen; busy is reserved for owner writes. */
22 refreshing?: boolean;
23 stale?: boolean;
24 error?: string;
25 notice?: string;
26 noticeSha?: string;
27 noticeReview?: string;
28 updatedAt?: string;
29 livePaused?: boolean;
30}
31
32/** Exact head and digest are visible; the server remains the authority for every action. */
33export function reviewGate(
34 review: Review,
35 state: ProjectState,
36 owner = true,
37): { approve: boolean; promote: boolean; reason: string } {
38 const cause = reviewState(review, state);
39 if (cause.kind !== "current")
40 return { approve: false, promote: false, reason: cause.caption || cause.label };
41 // A missing fork or invalid approval binding cannot authorize an owner write.
42 if (!state.forks.some((fork) => fork.repoName === review.repoName) || review.status === "stale")
43 return { approve: false, promote: false, reason: "Refresh and request review again." };
44 const requireReceipt = state.project.settings?.requireReceipt;
45 if (requireReceipt === undefined)
46 return {
47 approve: false,
48 promote: false,
49 reason: "Receipt settings unavailable. Refresh before reviewing.",
50 };
51 // Failed before-promote code requires a fix; interrupted runs may be tried again.
52 if (
53 requireReceipt &&
54 review.status === "pending" &&
55 (!review.receiptDigest || review.receipt?.status !== "passed")
56 ) {
57 return { approve: false, promote: false, reason: receiptGateCaption(review, true) };
58 }
59 if (
60 requireReceipt &&
61 review.receipt?.trigger === "before-promote" &&
62 review.receipt.status === "failed"
63 ) {
64 return { approve: false, promote: false, reason: receiptGateCaption(review, true) };
65 }
66 const blocked = state.blockers.find((blocker) => !blocker.resolved && blockerGatesReview(blocker, review));
67 if (blocked)
68 return { approve: false, promote: false, reason: `Blocked: this fork pushed into ${blocked.packet}'s claimed paths. ${owner ? "Mark the collision resolved first." : "Waiting for the owner."}` };
69 const usageReason = usagePromotionCaption(review, state);
70 return {
71 approve: review.status === "pending",
72 promote: review.status === "approved" && !usageReason,
73 reason: usageReason || receiptGateCaption(review, requireReceipt),
74 };
75}
76
77/** Shared queue/action visibility; a pending outcome and a required re-check hide Promote. */
78export function promotionAvailable(review: Review, state: ProjectState, owner = true): boolean {
79 const rechecking = state.project.settings?.requireReceipt === true &&
80 review.receipt?.trigger === "before-promote" && review.receipt.status === "running";
81 return owner && !state.promotionPending && !rechecking &&
82 reviewQueue(state).some((r) => r.id === review.id) && reviewGate(review, state, owner).promote;
83}
84
85export type ReviewState = {
86 kind: "current" | "promoted" | "returned" | "fork-moved" | "main-moved" | "out-of-date";
87 label: string;
88 caption: string;
89 banner?: string;
90 mainSha?: string;
91};
92
93/** First proven cause wins; main movement does not depend on core's stale flag. */
94export function reviewState(review: Review, state: ProjectState): ReviewState {
95 if (review.status === "promoted") return { kind: "promoted", label: "Promoted", caption: "" };
96 if (review.returned) return { kind: "returned", label: "Returned", caption: "" };
97 const fork = state.forks.find((entry) => entry.repoName === review.repoName);
98 if (fork && fork.head !== review.sha)
99 return {
100 kind: "fork-moved",
101 label: "Fork moved",
102 caption: "The agent must request review again.",
103 banner: "The fork moved after this review was requested. The agent must request review again.",
104 };
105 const latest = state.ledger.toSorted((a, b) => a.promotedAt.localeCompare(b.promotedAt)).at(-1);
106 if (
107 latest &&
108 review.base &&
109 latest.sha !== review.base &&
110 !state.reconcilerAlerts.some((alert) => alert.id === "current-head") &&
111 state.promotionPending === null
112 )
113 return {
114 kind: "main-moved",
115 label: "Main moved",
116 mainSha: latest.sha,
117 caption: "The agent must rebase and request review again.",
118 banner: `main moved to ${latest.sha.slice(0, 7)} after this review was requested. The agent must rebase and request review again.`,
119 };
120 if (review.stale === true)
121 return {
122 kind: "out-of-date",
123 label: "Out of date",
124 caption: "The agent must request review again.",
125 banner: "This review no longer applies. The agent must request review again.",
126 };
127 return { kind: "current", label: review.status === "approved" ? "Approved" : "In review", caption: "" };
128}
129
130/** Refuse incomplete snapshots instead of silently filling missing live data with fixtures. */
131export function assertState(value: ProjectState): void {
132 if (
133 !value?.project?.id ||
134 (value.canonicalHead !== undefined && typeof value.canonicalHead !== "string") ||
135 ![
136 value.agents,
137 value.forks,
138 value.packets,
139 value.claims,
140 value.blockers,
141 value.reviews,
142 value.ledger,
143 value.reconcilerAlerts,
144 ].every(Array.isArray)
145 ) {
146 throw new Error(
147 "The state response does not match the dashboard contract. Check the host adapter.",
148 );
149 }
150}
151
152/** Map the concrete FH-005 wire fields once; embedding capabilities can return the same view. */
153export function stateView(wire: StateResponse): ProjectState {
154 if (
155 !wire?.project?.id ||
156 ![
157 wire.agents,
158 wire.forks,
159 wire.packets,
160 wire.claims,
161 wire.blockers,
162 wire.reviews,
163 wire.ledger,
164 wire.reconcilerAlerts,
165 ].every(Array.isArray)
166 ) {
167 throw new Error(
168 "The state response does not match the dashboard contract. Check the host adapter.",
169 );
170 }
171 return {
172 project: wire.project,
173 recentActivity: wire.activity,
174 agents: wire.agents,
175 races: wire.races,
176 forks: wire.forks,
177 packets: wire.packets.map((packet) => ({ ...packet, archived: packet.archived ?? null })),
178 claims: wire.claims.map((claim) => ({ ...claim, active: true })),
179 blockers: wire.blockers.map(blockerView),
180 reviews: wire.reviews.map((review) =>
181 reviewView(review, wire.project.settings?.requireReceipt),
182 ),
183 ledger: wire.ledger.map((entry) => ({
184 id: entry.id,
185 before: entry.before,
186 review: entry.review,
187 sha: entry.after,
188 promotedAt: entry.recordedAt,
189 receiptDigest: entry.receiptDigest,
190 race: entry.race,
191 })),
192 reconcilerAlerts: wire.reconcilerAlerts.map((alert) => ({
193 ...alert,
194 sha: alert.after,
195 message: alert.reason,
196 })),
197 promotionPending: wire.promotionPending,
198 };
199}
200
201/** Keep unsuperseded stale rows visible, but count only actionable review states. */
202export function reviewQueue(state: ProjectState): Review[] {
203 return state.reviews.filter(
204 (review) =>
205 !isArchived(state, review.packet) &&
206 review.status !== "promoted" &&
207 !(
208 review.status === "stale" &&
209 state.reviews.some(
210 (other) =>
211 other.packet === review.packet &&
212 other.id !== review.id &&
213 (other.status === "promoted" ||
214 Date.parse(other.requestedAt ?? "") > Date.parse(review.requestedAt ?? "")),
215 )
216 ),
217 );
218}
219
220/** Farrier receipt copy, with advisory semantics from the 16:55 ruling. */
221export function receiptGateCaption(review: Review, required: boolean): string {
222 const status = review.receipt?.status;
223 if (!required) {
224 if (!status) return "Receipts aren't required in this project.";
225 if (status === "running")
226 return "Checks are running. They're advisory here, so you can approve now.";
227 return status === "passed" ? "" : "Advisory: this doesn't block approval.";
228 }
229 if (review.status === "approved" && review.receipt?.trigger === "before-promote") {
230 if (status === "running") return "";
231 if (status === "timed-out" || status === "infrastructure-error")
232 return "Promote runs the checks again.";
233 }
234 switch (status) {
235 case "passed":
236 return "";
237 case "failed":
238 return "Checks failed. The agent must push a fix.";
239 case "timed-out":
240 return "Checks timed out. A new push runs them again.";
241 case "infrastructure-error":
242 return "Checks couldn't start. A new push runs them again.";
243 default:
244 return "Waiting for checks.";
245 }
246}
247
248function receiptTimestamp(review: Review & { receipt: NonNullable<Review["receipt"]> }): number {
249 const value = Date.parse(review.receipt.finishedAt ?? review.receipt.startedAt);
250 return Number.isFinite(value) ? value : 0;
251}
252
253/** Latest public summaries remain reachable even after their reviews leave the queue. */
254export function recentReceipts(
255 state: ProjectState,
256): (Review & { receipt: NonNullable<Review["receipt"]> })[] {
257 return state.reviews
258 .filter(
259 (review): review is Review & { receipt: NonNullable<Review["receipt"]> } => !!review.receipt,
260 )
261 .toSorted((a, b) => receiptTimestamp(b) - receiptTimestamp(a) || a.id.localeCompare(b.id))
262 .slice(0, 5);
263}
264
265/** A ledger digest identifies the historical receipt, never merely the latest review for a packet. */
266export function promotionReceipt(
267 state: ProjectState,
268 entry: ProjectState["ledger"][number],
269): Review | undefined {
270 if (!entry.receiptDigest) return;
271 return state.reviews.find(
272 (review) =>
273 review.id === entry.review &&
274 review.sha === entry.sha &&
275 review.receipt?.digest === entry.receiptDigest &&
276 review.receipt?.status !== "running",
277 );
278}
279
280/** Shared public review projection keeps detail and queue approval bindings identical. */
281export function reviewView(
282 review: StateResponse["reviews"][number],
283 requireReceipt?: boolean,
284): Review {
285 return {
286 ...review,
287 requestedAt: review.createdAt,
288 status: review.promoted
289 ? "promoted"
290 : review.stale ||
291 (review.approval !== null &&
292 (review.approval.sha !== review.sha ||
293 (requireReceipt !== false &&
294 review.receipt?.trigger !== "before-promote" &&
295 review.approval.receiptDigest !== review.receiptDigest)))
296 ? "stale"
297 : review.approval
298 ? "approved"
299 : "pending",
300 };
301}
302
303/** Archive hides work projections; historical receipts and ledger still use the full state. */
304export function isArchived(state: ProjectState, packet: string): boolean {
305 return !!state.packets.find((entry) => entry.id === packet)?.archived;
306}
307export function workPackets(state: ProjectState, showArchived = false) {
308 const active = state.packets.filter((packet) => !packet.archived);
309 if (!showArchived) return active;
310 const archived = state.packets
311 .filter((packet) => packet.archived)
312 .toSorted((a, b) => b.archived!.at.localeCompare(a.archived!.at) || a.id.localeCompare(b.id));
313 return [...active, ...archived];
314}
315
316/** Mirrors FH-050 against raw core flags, never derived stale/display states. */
317export function canArchive(state: ProjectState, packet: string): boolean {
318 return (
319 state.packets.some((entry) => entry.id === packet && !entry.archived) &&
320 !state.claims.some((claim) => claim.packet === packet && claim.active) &&
321 !state.races?.some(
322 (race) => race.packet === packet && race.entrants.some((entrant) => entrant.closedAt === null),
323 ) &&
324 !state.reviews.some(
325 (review) => review.packet === packet &&
326 !review.promoted && !review.stale && !review.returned,
327 ) &&
328 !state.blockers.some(
329 (blocker) => !blocker.resolved &&
330 (blocker.packet === packet || blocker.pushingPacket === packet),
331 )
332 );
333}
334
335/** One projection for state and review detail; legacy missing resolution remains open. */
336export function blockerView(blocker: WireBlocker): BlockerView {
337 return {
338 ...blocker,
339 agent: blocker.pushingAgent,
340 reason: `Push from ${blocker.pushingAgent} overlaps ${blocker.claimingAgent}'s live claim.`,
341 resolved: blocker.resolved != null,
342 resolution: blocker.resolved ?? null,
343 at: blocker.at ?? null,
344 pushingPacket: blocker.pushingPacket ?? null,
345 };
346}
347
348/** Match core's pushing-fork gate, including legacy/unattributed pushes. */
349export function blockerGatesReview(blocker: BlockerView, review: Review): boolean {
350 return blocker.repoName === review.repoName &&
351 (blocker.pushingPacket == null || blocker.pushingPacket === review.packet);
352}
353export function blockedLabel(blocker: BlockerView): string {
354 return blocker.pushingPacket ?? `reviews from ${blocker.repoName ?? "the pushing fork"}`;
355}
356export function blockerPacket(blocker: BlockerView): string {
357 return blocker.pushingPacket ?? blocker.packet;
358}
359export function collisionHistory(blockers: BlockerView[]): BlockerView[] {
360 return blockers.toSorted((a, b) => Number(a.resolved) - Number(b.resolved) ||
361 (Date.parse(b.resolved ? b.resolution?.at ?? "" : b.at ?? "") || 0) -
362 (Date.parse(a.resolved ? a.resolution?.at ?? "" : a.at ?? "") || 0));
363}
364