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

packages/forkhorse-dashboard/src/model.ts

Snapshot at 34e3175

Containing directory

13,405 bytes · typescriptView raw
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