Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
467 linesCodeBlameRaw
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 LinkVia,
16 SessionState,
17 TouchKind,
18 WorkSession,
19} from "@g1t/contracts";
20
21/** How each harness is named. */
22export const HARNESS_NAMES: Record<Harness, string> = {
23 claude_code: "Claude Code",
24 claude_managed_agents: "Claude Managed Agents",
25 codex: "Codex",
26 cursor: "Cursor",
27 copilot: "Copilot",
28 other: "Another harness",
29};
30
31export function harnessName(harness: Harness | null | undefined): string | null {
32 return harness ? (HARNESS_NAMES[harness] ?? harness) : null;
33}
34
35/** The coordination events the Work page lists, newest first. */
36export const COORDINATION_TYPES = [
37 "work.claimed",
38 "work.released",
39 "work.claim_expired",
40 "work.handoff_requested",
41 "session.started",
42 "session.ended",
43 "dependency.changed",
44 "overlap.detected",
45 "integration.failed",
46 "integration.passed",
47 "decision.recorded",
48] as const satisfies readonly EventType[];
49
50export type CoordinationType = (typeof COORDINATION_TYPES)[number];
51
52// ── Time ────────────────────────────────────────────────────────────────
53
54const MINUTE = 60_000;
55
56/** "4m", "1h 20m", "2h": a span, for "expires in …". */
57export function span(ms: number): string {
58 const minutes = Math.max(0, Math.floor(ms / MINUTE));
59 if (minutes < 1) return "less than a minute";
60 if (minutes < 60) return `${minutes}m`;
61 const hours = Math.floor(minutes / 60);
62 const rest = minutes % 60;
63 return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
64}
65
66/** When an active claim lapses, in words; null for one that is not active. */
67export function expiresIn(claim: Pick<Claim, "state" | "expiresAt">, now: number): string | null {
68 if (claim.state !== "active") return null;
69 const left = new Date(claim.expiresAt).getTime() - now;
70 return left <= 0 ? "expiring now" : `expires in ${span(left)}`;
71}
72
73/** What became of a claim that is no longer active. */
74export const CLAIM_ENDED: Record<Exclude<ClaimState, "active">, string> = {
75 released: "Released",
76 expired: "Expired",
77 handed_off: "Handed off",
78};
79
80export const SESSION_STATES: Record<SessionState, { label: string; hint: string }> = {
81 active: { label: "Active", hint: "Heard from in the last 15 minutes." },
82 idle: { label: "Idle", hint: "Not heard from for 15 minutes. It may still come back." },
83 ended: { label: "Ended", hint: "Ended, or not heard from for 2 hours." },
84};
85
86// ── Live now ────────────────────────────────────────────────────────────
87
88/** One issue being worked on: its claims and the sessions on it. */
89export type LiveIssue = { issue: number; claims: Claim[]; sessions: WorkSession[] };
90
91/**
92 * Active claims and sessions grouped by the issue they are on, the issue
93 * with the most recent activity first; sessions on no issue last, together.
94 */
95export function liveByIssue(claims: Claim[], sessions: WorkSession[]): { issues: LiveIssue[]; loose: WorkSession[] } {
96 const byIssue = new Map<number, LiveIssue>();
97 const at = (issue: number) => {
98 let group = byIssue.get(issue);
99 if (!group) byIssue.set(issue, (group = { issue, claims: [], sessions: [] }));
100 return group;
101 };
102 for (const claim of claims) at(claim.issue).claims.push(claim);
103 const loose: WorkSession[] = [];
104 for (const session of sessions) {
105 if (session.issue != null) at(session.issue).sessions.push(session);
106 else loose.push(session);
107 }
108 const latest = (group: LiveIssue) =>
109 Math.max(
110 0,
111 ...group.claims.map((claim) => Date.parse(claim.createdAt)),
112 ...group.sessions.map((session) => Date.parse(session.lastSeenAt)),
113 );
114 return { issues: [...byIssue.values()].sort((a, b) => latest(b) - latest(a) || a.issue - b.issue), loose };
115}
116
117// ── Decisions ───────────────────────────────────────────────────────────
118
119export const CONFIDENCE: Record<DecisionConfidence, { label: string; hint: string; tone: "neutral" | "info" | "success" }> = {
120 claimed: { label: "Claimed", hint: "Someone said so: an agent or a person recorded it.", tone: "neutral" },
121 tested: { label: "Tested", hint: "A check or the merge queue proved it.", tone: "info" },
122 approved: { label: "Approved", hint: "A person approved it.", tone: "success" },
123};
124
125export const DECISION_KINDS: Record<Decision["kind"], string> = {
126 proposal: "Proposal",
127 claim: "Claim",
128 integration_failure: "Failed together",
129 integration_passed: "Passed together",
130 fix: "Fix",
131 approval: "Approval",
132 merge: "Merge",
133 note: "Note",
134};
135
136/** The check a decision's evidence names. */
137export type EvidenceCheck = { name: string; conclusion: string; url: string | null };
138
139/** What a decision rests on, read from its evidence as far as it says. */
140export type Evidence = {
141 commit: string | null;
142 check: EvidenceCheck | null;
143 /** The pull requests tested together, in queue order. */
144 combination: number[];
145 /** Those ahead it fails with. */
146 suspects: number[];
147 bisected: boolean;
148};
149
150function numbers(value: unknown): number[] {
151 return Array.isArray(value) ? value.filter((n): n is number => typeof n === "number" && Number.isInteger(n)) : [];
152}
153
154export function evidenceOf(decision: Pick<Decision, "evidence">): Evidence {
155 const raw = (decision.evidence ?? {}) as Record<string, unknown>;
156 const check = raw.check && typeof raw.check === "object" ? (raw.check as Record<string, unknown>) : null;
157 return {
158 commit: typeof raw.commit === "string" ? raw.commit : null,
159 check:
160 check && typeof check.name === "string"
161 ? {
162 name: check.name,
163 conclusion: typeof check.conclusion === "string" ? check.conclusion : "failure",
164 url: typeof check.url === "string" && check.url ? check.url : null,
165 }
166 : null,
167 combination: numbers(raw.combination),
168 suspects: numbers(raw.suspects),
169 bisected: raw.bisected === true,
170 };
171}
172
173/** Where a check's address leads on the site: its path when it is g1t's own, else the address as it is. */
174export function checkHref(url: string | null): string | null {
175 if (!url) return null;
176 try {
177 const parsed = new URL(url);
178 if (parsed.hostname === "g1t.sh" || parsed.hostname.endsWith(".g1t.sh")) return parsed.pathname + parsed.search;
179 return url;
180 } catch {
181 return url.startsWith("/") ? url : null;
182 }
183}
184
185/**
186 * The integration failure a pull request still answers for: its latest
187 * `integration_failure`, unless it passed in the queue or merged since.
188 * `decisions` in any order.
189 */
190export function standingFailure(decisions: Decision[], pull?: number): Decision | null {
191 const mine = decisions
192 .filter((decision) => pull == null || decision.pull === pull)
193 .filter((decision) => ["integration_failure", "integration_passed", "merge"].includes(decision.kind))
194 .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
195 return mine[0]?.kind === "integration_failure" ? mine[0] : null;
196}
197
198/** The decisions after a failure that answer it: a fix recorded since, and a combination that then passed. */
199export function resolutionOf(decisions: Decision[], failure: Decision): { fix: Decision | null; passed: Decision | null } {
200 const after = decisions
201 .filter((decision) => Date.parse(decision.createdAt) > Date.parse(failure.createdAt))
202 .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
203 return {
204 fix: after.find((decision) => decision.kind === "fix" && (decision.pull === failure.pull || decision.issue === failure.issue)) ?? null,
205 passed: after.find((decision) => decision.kind === "integration_passed" && decision.pull === failure.pull) ?? null,
206 };
207}
208
209// ── The feed ────────────────────────────────────────────────────────────
210
211/** One line of the coordination feed: what happened, in words, and what it links to. */
212export type FeedLine = {
213 id: string;
214 time: string;
215 type: CoordinationType;
216 /** Who did it, by username; null for g1t itself. */
217 who: string | null;
218 /** The harness it came from, when it says. */
219 harness: Harness | null;
220 /** The sentence, after the name. Numbers in it are written `#N`. */
221 text: string;
222 /** The issue or pull request it is about. */
223 number: number | null;
224 /** The harness session it is about, by id, for a link to its page. */
225 session?: string;
226 tone: "neutral" | "accent" | "success" | "warn" | "danger" | "info";
227};
228
229const list = (all: number[]) => all.map((n) => `#${n}`).join(", ");
230const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
231
232/** A coordination event as a line of the feed, or null for one it does not show. */
233export function feedLine(event: G1tEvent): FeedLine | null {
234 const base = { id: event.id, time: event.time, harness: null as Harness | null, number: null as number | null };
235 const data = event.data as Record<string, unknown> & { number?: number };
236 const number = typeof data.number === "number" ? data.number : null;
237 switch (event.type) {
238 case "work.claimed": {
239 const { claim, others = [], tookOver } = event.data;
240 const also = others.length > 0 ? ` (${others.map((other) => other.actor.username).join(", ")} ${plural(others.length, "is", "are")} on it too)` : "";
241 return {
242 ...base,
243 type: event.type,
244 who: claim.actor.username,
245 harness: claim.harness,
246 text: `${tookOver ? "took over" : "claimed"} #${claim.issue}${also}`,
247 number: claim.issue,
248 tone: others.length > 0 ? "warn" : "accent",
249 };
250 }
251 case "work.released": {
252 const { claim, reason } = event.data;
253 return {
254 ...base,
255 type: event.type,
256 who: claim.actor.username,
257 harness: claim.harness,
258 text: reason === "issue_closed" ? `no longer holds #${claim.issue}: the issue closed` : `released #${claim.issue}${reason ? `: ${reason}` : ""}`,
259 number: claim.issue,
260 tone: "neutral",
261 };
262 }
263 case "work.claim_expired": {
264 const { claim } = event.data;
265 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" };
266 }
267 case "work.handoff_requested": {
268 const { claim, to, note } = event.data;
269 return {
270 ...base,
271 type: event.type,
272 who: claim.actor.username,
273 harness: claim.harness,
274 text: `handed #${claim.issue} off to ${to ?? "anyone"}${note ? `: ${note}` : ""}`,
275 number: claim.issue,
276 tone: "info",
277 };
278 }
279 case "session.started":
280 case "session.ended": {
281 const { session } = event.data;
282 const on = session.issue != null ? ` on #${session.issue}` : session.pull != null ? ` on #${session.pull}` : "";
283 const why = event.type === "session.ended" && session.endReason === "timed_out" ? " (timed out)" : "";
284 return {
285 ...base,
286 type: event.type,
287 who: session.actor.username,
288 harness: session.harness,
289 text: `${event.type === "session.started" ? "started a session" : "ended a session"}${on}${why}`,
290 number: session.issue ?? session.pull,
291 tone: event.type === "session.started" ? "accent" : "neutral",
292 };
293 }
294 case "dependency.changed": {
295 const { issue, added, removed, cleared, remaining, mergedPull } = event.data;
296 const parts = [
297 added.length > 0 && `now waits on ${list(added)}`,
298 removed.length > 0 && `no longer waits on ${list(removed)}`,
299 cleared.length > 0 && `${list(cleared)} ${mergedPull ? `landed (#${mergedPull} merged)` : "closed"}`,
300 ].filter(Boolean);
301 // What it still waits on, unless that is only what was just added.
302 const said = remaining.every((n) => added.includes(n)) && parts.length > 0;
303 const left = remaining.length === 0 ? "unblocked" : said ? null : `still waits on ${list(remaining)}`;
304 return {
305 ...base,
306 type: event.type,
307 who: null,
308 text: `#${issue} ${[...parts, left].filter(Boolean).join("; ")}`,
309 number: issue,
310 tone: remaining.length === 0 ? "success" : "info",
311 };
312 }
313 case "overlap.detected": {
314 if ("sessionId" in event.data) {
315 const { sessionId, otherActor, files, issue } = event.data;
316 return {
317 ...base,
318 type: event.type,
319 who: otherActor,
320 text: `touched the same ${plural(files.length, "file", "files")} in a session as another session: ${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""}`,
321 number: issue ?? number,
322 session: sessionId,
323 tone: "warn",
324 };
325 }
326 const { pull, otherPull, files } = event.data;
327 return {
328 ...base,
329 type: event.type,
330 who: null,
331 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` : ""}`,
332 number: pull,
333 tone: "warn",
334 };
335 }
336 case "integration.failed": {
337 const { pull, combination, check, suspects } = event.data;
338 const together = combination.length > 1 ? ` combined as ${list(combination)}` : " on its own";
339 const with_ = suspects.length > 0 ? `; fails with ${list(suspects)}` : "";
340 return { ...base, type: event.type, who: null, text: `#${pull} failed in the merge queue${together}: ${check.name}${with_}`, number: pull, tone: "danger" };
341 }
342 case "integration.passed": {
343 const { pull, combination } = event.data;
344 return {
345 ...base,
346 type: event.type,
347 who: null,
348 text: combination.length > 1 ? `#${pull} passed in the merge queue combined as ${list(combination)}` : `#${pull} passed in the merge queue`,
349 number: pull,
350 tone: "success",
351 };
352 }
353 case "decision.recorded": {
354 const { decision } = event.data;
355 const kind = DECISION_KINDS[decision.kind as Decision["kind"]]?.toLowerCase() ?? decision.kind;
356 return {
357 ...base,
358 type: event.type,
359 who: decision.actor,
360 text: `recorded ${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}, ${decision.confidence}: ${decision.summary}`,
361 number: decision.pull ?? decision.issue ?? number,
362 tone: decision.kind === "integration_failure" ? "danger" : decision.confidence === "approved" ? "success" : "neutral",
363 };
364 }
365 // Steps of a session as it goes: for the pages showing it, not the feed.
366 case "session.progressed":
367 default:
368 return null;
369 }
370}
371
372// ── Session trees ───────────────────────────────────────────────────────
373
374/** A session with its subagents under it, and theirs under them. */
375export type SessionNode = { session: WorkSession; children: SessionNode[] };
376
377/**
378 * Sessions as trees, by `parentId`: the roots in the order given, each
379 * with its subagents in the order given. One whose parent is not in the
380 * list is a root of its own.
381 */
382export function sessionTree(sessions: WorkSession[]): SessionNode[] {
383 const nodes = new Map<string, SessionNode>();
384 for (const session of sessions) if (!nodes.has(session.id)) nodes.set(session.id, { session, children: [] });
385 const roots: SessionNode[] = [];
386 for (const node of nodes.values()) {
387 const parent = node.session.parentId ? nodes.get(node.session.parentId) : undefined;
388 // A parent that is the session itself or under it (a cycle) does not count.
389 if (parent && !contains(node, parent)) parent.children.push(node);
390 else roots.push(node);
391 }
392 return roots;
393}
394
395/** The sessions of several lists, each once (as the first list to have it says), in the order first given. */
396export function mergeSessions(...lists: (WorkSession[] | null | undefined)[]): WorkSession[] {
397 const seen = new Map<string, WorkSession>();
398 for (const list of lists) for (const session of list ?? []) if (!seen.has(session.id)) seen.set(session.id, session);
399 return [...seen.values()];
400}
401
402function contains(node: SessionNode, target: SessionNode): boolean {
403 return node === target || node.children.some((child) => contains(child, target));
404}
405
406/** How many sessions are under a tree's root, at any depth. */
407export function treeSize(node: SessionNode): number {
408 return node.children.reduce((total, child) => total + 1 + treeSize(child), 0);
409}
410
411/** How a session was tied to an issue or pull request, in a few words. */
412export const VIA_LABELS: Record<LinkVia, string> = {
413 attached: "attached",
414 claim: "claimed",
415 branch: "its branch",
416 commit: "its commit",
417 trailer: "commit trailer",
418 opened: "opened it",
419 ref: "named it",
420};
421
422export function viaLabel(via: LinkVia): string {
423 return VIA_LABELS[via] ?? via;
424}
425
426/** What each kind of thing a session touched is called. */
427export const TOUCH_KINDS: Record<TouchKind, string> = {
428 file: "Files",
429 commit: "Commits",
430 branch: "Branches",
431 pull: "Pull requests",
432 issue: "Issues",
433};
434
435/** What was touched, grouped by kind in TOUCH_KINDS' order, each value once, in the order given. */
436export function touchedByKind(touched: { kind: TouchKind; value: string }[]): { kind: TouchKind; values: string[] }[] {
437 return (Object.keys(TOUCH_KINDS) as TouchKind[])
438 .map((kind) => ({ kind, values: [...new Set(touched.filter((one) => one.kind === kind).map((one) => one.value))] }))
439 .filter((group) => group.values.length > 0);
440}
441
442/** "12s", "4m", "1h 20m": how long a session ran, or has run until `now`. */
443export function duration(session: Pick<WorkSession, "startedAt" | "endedAt" | "lastSeenAt" | "state">, now: number): string {
444 const end = session.endedAt ? Date.parse(session.endedAt) : session.state === "ended" ? Date.parse(session.lastSeenAt) : now;
445 const ms = Math.max(0, end - Date.parse(session.startedAt));
446 return ms < MINUTE ? `${Math.floor(ms / 1000)}s` : span(ms);
447}
448
449/** Splits text at `#N`, for linking each number. */
450export function numberedParts(text: string): (string | number)[] {
451 const parts: (string | number)[] = [];
452 let last = 0;
453 for (const match of text.matchAll(/#(\d+)\b/g)) {
454 if (match.index > last) parts.push(text.slice(last, match.index));
455 parts.push(Number(match[1]));
456 last = match.index + match[0].length;
457 }
458 if (last < text.length) parts.push(text.slice(last));
459 return parts;
460}
461
462/** `blocked_by` as typed: numbers, with or without `#`, by commas or spaces; null when any is not one. */
463export function parseBlockedBy(text: string): number[] | null {
464 const words = text.split(/[\s,]+/).map((word) => word.trim().replace(/^#/, "")).filter(Boolean);
465 if (words.some((word) => !/^\d+$/.test(word) || Number(word) <= 0)) return null;
466 return [...new Set(words.map(Number))];
467}