Skip to content
371 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'worktree-agent-aa68cf6ecc03dffaf'1/**
2 * Coordinating work, as the site shows it: who is on which issue (claims),
3 * which harness sessions are running, what the merge queue found when it
4 * combined changes, and the decision records behind the result. Pure: the
5 * pages and components/coordination.tsx draw what these say.
6 */
7import type {
8 Claim,
9 ClaimState,
10 Decision,
11 DecisionConfidence,
12 EventType,
13 G1tEvent,
14 Harness,
15 SessionState,
16 WorkSession,
17} from "@g1t/contracts";
18
19/** How each harness is named. */
20export const HARNESS_NAMES: Record<Harness, string> = {
21 claude_code: "Claude Code",
22 codex: "Codex",
23 cursor: "Cursor",
24 copilot: "Copilot",
25 other: "Another harness",
26};
27
28export function harnessName(harness: Harness | null | undefined): string | null {
29 return harness ? (HARNESS_NAMES[harness] ?? harness) : null;
30}
31
32/** The coordination events the Work page lists, newest first. */
33export const COORDINATION_TYPES = [
34 "work.claimed",
35 "work.released",
36 "work.claim_expired",
37 "work.handoff_requested",
38 "session.started",
39 "session.ended",
40 "dependency.changed",
41 "overlap.detected",
42 "integration.failed",
43 "integration.passed",
44 "decision.recorded",
45] as const satisfies readonly EventType[];
46
47export type CoordinationType = (typeof COORDINATION_TYPES)[number];
48
49// ── Time ────────────────────────────────────────────────────────────────
50
51const MINUTE = 60_000;
52
53/** "4m", "1h 20m", "2h": a span, for "expires in …". */
54export function span(ms: number): string {
55 const minutes = Math.max(0, Math.floor(ms / MINUTE));
56 if (minutes < 1) return "less than a minute";
57 if (minutes < 60) return `${minutes}m`;
58 const hours = Math.floor(minutes / 60);
59 const rest = minutes % 60;
60 return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
61}
62
63/** When an active claim lapses, in words; null for one that is not active. */
64export function expiresIn(claim: Pick<Claim, "state" | "expiresAt">, now: number): string | null {
65 if (claim.state !== "active") return null;
66 const left = new Date(claim.expiresAt).getTime() - now;
67 return left <= 0 ? "expiring now" : `expires in ${span(left)}`;
68}
69
70/** What became of a claim that is no longer active. */
71export const CLAIM_ENDED: Record<Exclude<ClaimState, "active">, string> = {
72 released: "Released",
73 expired: "Expired",
74 handed_off: "Handed off",
75};
76
77export const SESSION_STATES: Record<SessionState, { label: string; hint: string }> = {
78 active: { label: "Active", hint: "Heard from in the last 15 minutes." },
79 idle: { label: "Idle", hint: "Not heard from for 15 minutes. It may still come back." },
80 ended: { label: "Ended", hint: "Ended, or not heard from for 2 hours." },
81};
82
83// ── Live now ────────────────────────────────────────────────────────────
84
85/** One issue being worked on: its claims and the sessions on it. */
86export type LiveIssue = { issue: number; claims: Claim[]; sessions: WorkSession[] };
87
88/**
89 * Active claims and sessions grouped by the issue they are on, the issue
90 * with the most recent activity first; sessions on no issue last, together.
91 */
92export function liveByIssue(claims: Claim[], sessions: WorkSession[]): { issues: LiveIssue[]; loose: WorkSession[] } {
93 const byIssue = new Map<number, LiveIssue>();
94 const at = (issue: number) => {
95 let group = byIssue.get(issue);
96 if (!group) byIssue.set(issue, (group = { issue, claims: [], sessions: [] }));
97 return group;
98 };
99 for (const claim of claims) at(claim.issue).claims.push(claim);
100 const loose: WorkSession[] = [];
101 for (const session of sessions) {
102 if (session.issue != null) at(session.issue).sessions.push(session);
103 else loose.push(session);
104 }
105 const latest = (group: LiveIssue) =>
106 Math.max(
107 0,
108 ...group.claims.map((claim) => Date.parse(claim.createdAt)),
109 ...group.sessions.map((session) => Date.parse(session.lastSeenAt)),
110 );
111 return { issues: [...byIssue.values()].sort((a, b) => latest(b) - latest(a) || a.issue - b.issue), loose };
112}
113
114// ── Decisions ───────────────────────────────────────────────────────────
115
116export const CONFIDENCE: Record<DecisionConfidence, { label: string; hint: string; tone: "neutral" | "info" | "success" }> = {
117 claimed: { label: "Claimed", hint: "Someone said so: an agent or a person recorded it.", tone: "neutral" },
118 tested: { label: "Tested", hint: "A check or the merge queue proved it.", tone: "info" },
119 approved: { label: "Approved", hint: "A person approved it.", tone: "success" },
120};
121
122export const DECISION_KINDS: Record<Decision["kind"], string> = {
123 proposal: "Proposal",
124 claim: "Claim",
125 integration_failure: "Failed together",
126 integration_passed: "Passed together",
127 fix: "Fix",
128 approval: "Approval",
129 merge: "Merge",
130 note: "Note",
131};
132
133/** The check a decision's evidence names. */
134export type EvidenceCheck = { name: string; conclusion: string; url: string | null };
135
136/** What a decision rests on, read from its evidence as far as it says. */
137export type Evidence = {
138 commit: string | null;
139 check: EvidenceCheck | null;
140 /** The pull requests tested together, in queue order. */
141 combination: number[];
142 /** Those ahead it fails with. */
143 suspects: number[];
144 bisected: boolean;
145};
146
147function numbers(value: unknown): number[] {
148 return Array.isArray(value) ? value.filter((n): n is number => typeof n === "number" && Number.isInteger(n)) : [];
149}
150
151export function evidenceOf(decision: Pick<Decision, "evidence">): Evidence {
152 const raw = (decision.evidence ?? {}) as Record<string, unknown>;
153 const check = raw.check && typeof raw.check === "object" ? (raw.check as Record<string, unknown>) : null;
154 return {
155 commit: typeof raw.commit === "string" ? raw.commit : null,
156 check:
157 check && typeof check.name === "string"
158 ? {
159 name: check.name,
160 conclusion: typeof check.conclusion === "string" ? check.conclusion : "failure",
161 url: typeof check.url === "string" && check.url ? check.url : null,
162 }
163 : null,
164 combination: numbers(raw.combination),
165 suspects: numbers(raw.suspects),
166 bisected: raw.bisected === true,
167 };
168}
169
170/** Where a check's address leads on the site: its path when it is g1t's own, else the address as it is. */
171export function checkHref(url: string | null): string | null {
172 if (!url) return null;
173 try {
174 const parsed = new URL(url);
175 if (parsed.hostname === "g1t.sh" || parsed.hostname.endsWith(".g1t.sh")) return parsed.pathname + parsed.search;
176 return url;
177 } catch {
178 return url.startsWith("/") ? url : null;
179 }
180}
181
182/**
183 * The integration failure a pull request still answers for: its latest
184 * `integration_failure`, unless it passed in the queue or merged since.
185 * `decisions` in any order.
186 */
187export function standingFailure(decisions: Decision[], pull?: number): Decision | null {
188 const mine = decisions
189 .filter((decision) => pull == null || decision.pull === pull)
190 .filter((decision) => ["integration_failure", "integration_passed", "merge"].includes(decision.kind))
191 .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
192 return mine[0]?.kind === "integration_failure" ? mine[0] : null;
193}
194
195/** The decisions after a failure that answer it: a fix recorded since, and a combination that then passed. */
196export function resolutionOf(decisions: Decision[], failure: Decision): { fix: Decision | null; passed: Decision | null } {
197 const after = decisions
198 .filter((decision) => Date.parse(decision.createdAt) > Date.parse(failure.createdAt))
199 .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
200 return {
201 fix: after.find((decision) => decision.kind === "fix" && (decision.pull === failure.pull || decision.issue === failure.issue)) ?? null,
202 passed: after.find((decision) => decision.kind === "integration_passed" && decision.pull === failure.pull) ?? null,
203 };
204}
205
206// ── The feed ────────────────────────────────────────────────────────────
207
208/** One line of the coordination feed: what happened, in words, and what it links to. */
209export type FeedLine = {
210 id: string;
211 time: string;
212 type: CoordinationType;
213 /** Who did it, by username; null for g1t itself. */
214 who: string | null;
215 /** The harness it came from, when it says. */
216 harness: Harness | null;
217 /** The sentence, after the name. Numbers in it are written `#N`. */
218 text: string;
219 /** The issue or pull request it is about. */
220 number: number | null;
221 tone: "neutral" | "accent" | "success" | "warn" | "danger" | "info";
222};
223
224const list = (all: number[]) => all.map((n) => `#${n}`).join(", ");
225const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
226
227/** A coordination event as a line of the feed, or null for one it does not show. */
228export function feedLine(event: G1tEvent): FeedLine | null {
229 const base = { id: event.id, time: event.time, harness: null as Harness | null, number: null as number | null };
230 const data = event.data as Record<string, unknown> & { number?: number };
231 const number = typeof data.number === "number" ? data.number : null;
232 switch (event.type) {
233 case "work.claimed": {
234 const { claim, others = [], tookOver } = event.data;
235 const also = others.length > 0 ? ` (${others.map((other) => other.actor.username).join(", ")} ${plural(others.length, "is", "are")} on it too)` : "";
236 return {
237 ...base,
238 type: event.type,
239 who: claim.actor.username,
240 harness: claim.harness,
241 text: `${tookOver ? "took over" : "claimed"} #${claim.issue}${also}`,
242 number: claim.issue,
243 tone: others.length > 0 ? "warn" : "accent",
244 };
245 }
246 case "work.released": {
247 const { claim, reason } = event.data;
248 return {
249 ...base,
250 type: event.type,
251 who: claim.actor.username,
252 harness: claim.harness,
253 text: reason === "issue_closed" ? `no longer holds #${claim.issue}: the issue closed` : `released #${claim.issue}${reason ? `: ${reason}` : ""}`,
254 number: claim.issue,
255 tone: "neutral",
256 };
257 }
258 case "work.claim_expired": {
259 const { claim } = event.data;
260 return { ...base, type: event.type, who: claim.actor.username, harness: claim.harness, text: `let the claim on #${claim.issue} expire`, number: claim.issue, tone: "neutral" };
261 }
262 case "work.handoff_requested": {
263 const { claim, to, note } = event.data;
264 return {
265 ...base,
266 type: event.type,
267 who: claim.actor.username,
268 harness: claim.harness,
269 text: `handed #${claim.issue} off to ${to ?? "anyone"}${note ? `: ${note}` : ""}`,
270 number: claim.issue,
271 tone: "info",
272 };
273 }
274 case "session.started":
275 case "session.ended": {
276 const { session } = event.data;
277 const on = session.issue != null ? ` on #${session.issue}` : session.pull != null ? ` on #${session.pull}` : "";
278 const why = event.type === "session.ended" && session.endReason === "timed_out" ? " (timed out)" : "";
279 return {
280 ...base,
281 type: event.type,
282 who: session.actor.username,
283 harness: session.harness,
284 text: `${event.type === "session.started" ? "started a session" : "ended a session"}${on}${why}`,
285 number: session.issue ?? session.pull,
286 tone: event.type === "session.started" ? "accent" : "neutral",
287 };
288 }
289 case "dependency.changed": {
290 const { issue, added, removed, cleared, remaining, mergedPull } = event.data;
291 const parts = [
292 added.length > 0 && `now waits on ${list(added)}`,
293 removed.length > 0 && `no longer waits on ${list(removed)}`,
294 cleared.length > 0 && `${list(cleared)} ${mergedPull ? `landed (#${mergedPull} merged)` : "closed"}`,
295 ].filter(Boolean);
296 // What it still waits on, unless that is only what was just added.
297 const said = remaining.every((n) => added.includes(n)) && parts.length > 0;
298 const left = remaining.length === 0 ? "unblocked" : said ? null : `still waits on ${list(remaining)}`;
299 return {
300 ...base,
301 type: event.type,
302 who: null,
303 text: `#${issue} ${[...parts, left].filter(Boolean).join("; ")}`,
304 number: issue,
305 tone: remaining.length === 0 ? "success" : "info",
306 };
307 }
308 case "overlap.detected": {
309 const { pull, otherPull, files } = event.data;
310 return {
311 ...base,
312 type: event.type,
313 who: null,
314 text: `#${pull} and #${otherPull} change the same ${plural(files.length, "file", "files")}: ${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""}`,
315 number: pull,
316 tone: "warn",
317 };
318 }
319 case "integration.failed": {
320 const { pull, combination, check, suspects } = event.data;
321 const together = combination.length > 1 ? ` combined as ${list(combination)}` : " on its own";
322 const with_ = suspects.length > 0 ? `; fails with ${list(suspects)}` : "";
323 return { ...base, type: event.type, who: null, text: `#${pull} failed in the merge queue${together}: ${check.name}${with_}`, number: pull, tone: "danger" };
324 }
325 case "integration.passed": {
326 const { pull, combination } = event.data;
327 return {
328 ...base,
329 type: event.type,
330 who: null,
331 text: combination.length > 1 ? `#${pull} passed in the merge queue combined as ${list(combination)}` : `#${pull} passed in the merge queue`,
332 number: pull,
333 tone: "success",
334 };
335 }
336 case "decision.recorded": {
337 const { decision } = event.data;
338 const kind = DECISION_KINDS[decision.kind as Decision["kind"]]?.toLowerCase() ?? decision.kind;
339 return {
340 ...base,
341 type: event.type,
342 who: decision.actor,
343 text: `recorded ${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}, ${decision.confidence}: ${decision.summary}`,
344 number: decision.pull ?? decision.issue ?? number,
345 tone: decision.kind === "integration_failure" ? "danger" : decision.confidence === "approved" ? "success" : "neutral",
346 };
347 }
348 default:
349 return null;
350 }
351}
352
353/** Splits text at `#N`, for linking each number. */
354export function numberedParts(text: string): (string | number)[] {
355 const parts: (string | number)[] = [];
356 let last = 0;
357 for (const match of text.matchAll(/#(\d+)\b/g)) {
358 if (match.index > last) parts.push(text.slice(last, match.index));
359 parts.push(Number(match[1]));
360 last = match.index + match[0].length;
361 }
362 if (last < text.length) parts.push(text.slice(last));
363 return parts;
364}
365
366/** `blocked_by` as typed: numbers, with or without `#`, by commas or spaces; null when any is not one. */
367export function parseBlockedBy(text: string): number[] | null {
368 const words = text.split(/[\s,]+/).map((word) => word.trim().replace(/^#/, "")).filter(Boolean);
369 if (words.some((word) => !/^\d+$/.test(word) || Number(word) <= 0)) return null;
370 return [...new Set(words.map(Number))];
371}

This file's history is long; its oldest lines are credited to the oldest commit read.