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.

719 linesCodeBlameRaw
1/**
2 * Coordinating work, as the site shows it: who is on which work item (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 Sessions 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 30 minutes. One that went quiet comes back when it is heard from again." },
84};
85
86// ── Ending a session by hand ────────────────────────────────────────────
87
88/** How the reason of a session ended by hand starts (g1t_contracts::work::ENDED_BY_HAND). */
89export const ENDED_BY_HAND = "ended by hand by @";
90
91/**
92 * Whether the viewer may end a session by hand, for one its harness left
93 * behind: one not ended yet, their own, or anyone's when they have write
94 * access (`canPush`) to the repository. The work service checks the same.
95 * One g1t runs on the workspace's Anthropic account is stopped from its
96 * page instead, which stops the run too.
97 */
98export function mayEndSession(session: Pick<WorkSession, "state" | "actor" | "harness">, viewerId: string | null | undefined, canPush: boolean): boolean {
99 if (!viewerId || session.state === "ended" || session.harness === "claude_managed_agents") return false;
100 return session.actor.id === viewerId || canPush;
101}
102
103/** Who may end which session from a list: the viewer, and whether they may end anyone's. */
104export type SessionEnder = { viewerId: string | null; canPush: boolean };
105
106/** Why a session ended, in words: "timed out", "ended by hand by @ana", or what its harness said. */
107export function endReasonText(reason: string | null | undefined): string | null {
108 if (!reason) return null;
109 return reason === "timed_out" ? "timed out" : reason;
110}
111
112// ── Session titles ──────────────────────────────────────────────────────
113
114/** The most of a prompt's intent that titles a session. */
115export const TITLE_LIMIT = 80;
116
117/** A turn the harness starts itself, as a tag: `<command-name>`, `<task-notification>`, one with attributes. */
118const HARNESS_TAG = /^\s*<[a-z][\w-]*(?:\s[^>]*)?>/i;
119
120/** What an editor adds ahead of a prompt, a whole block at a time: `<ide_opened_file>…</ide_opened_file>`. */
121const IDE_CONTEXT = /^\s*<(ide_[a-z_]+|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/i;
122
123/**
124 * What a prompt is for, as a session's title: its first line or sentence,
125 * without code blocks or markdown, whitespace collapsed, clipped at
126 * TITLE_LIMIT on a word with "…". Null for a slash command, a turn the
127 * harness starts itself, or nothing to say. The work service gives a
128 * session its provisional title the same way, from its first prompt
129 * (services/work/src/coordination/titles.rs `intent_title`).
130 */
131export function intentTitle(prompt: string | null | undefined): string | null {
132 let text = String(prompt ?? "").replace(/^\uFEFF/, "");
133 // What the harness pasted in or attached is not what the prompt is for:
134 // pasted text (Claude Code's <pasted_content id="…">…</pasted_content>)
135 // and image placeholders ([Image #1], [Image: source: …]) go first.
136 text = text.replace(/<pasted_content\b[^>]*>[\s\S]*?(?:<\/pasted_content[^>]*>|$)/gi, "\n").replace(/\[Image(?: #\d+)?(?::[^\]\n]*)?\]/g, " ");
137 // Context an editor puts before what was typed (VS Code's
138 // <ide_opened_file>, <ide_selection>) is not a turn the harness started.
139 while (IDE_CONTEXT.test(text)) text = text.replace(IDE_CONTEXT, "");
140 if (/^\s*\//.test(text) || HARNESS_TAG.test(text)) return null;
141 text = text
142 .replace(/```[\s\S]*?(```|$)/g, "\n")
143 .replace(/~~~[\s\S]*?(~~~|$)/g, "\n")
144 .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
145 .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
146 .replace(/`([^`]*)`/g, "$1");
147 const line = text
148 .split(/\r?\n/)
149 .map((l) =>
150 l
151 .replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)+/, "")
152 .replace(/(\*\*|__|~~)(.+?)\1/g, "$2")
153 .trim(),
154 )
155 .find((l) => /[\p{L}\p{N}]/u.test(l));
156 if (!line) return null;
157 let intent = line
158 .replace(/\s+/g, " ")
159 .split(/(?<=[.!?])\s+(?=\S)/)[0]
160 .trim();
161 if (intent.length > TITLE_LIMIT) {
162 const cut = intent.slice(0, TITLE_LIMIT - 1);
163 const space = cut.lastIndexOf(" ");
164 intent = `${(space > TITLE_LIMIT / 2 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-–—]+$/, "")}…`;
165 } else {
166 intent = intent.replace(/[\s,;:.]+$/, "");
167 }
168 if (intent.length < 2) return null;
169 return intent[0].toLocaleUpperCase() + intent.slice(1);
170}
171
172/**
173 * What a session is called, never nothing: its title, else what its first
174 * prompt was for, else the branch it is on, else "Untitled session".
175 * `firstPrompt` stands in for the session's own when given.
176 */
177export function sessionTitle(
178 session: Pick<WorkSession, "title" | "branch" | "firstPrompt">,
179 firstPrompt: string | null | undefined = session.firstPrompt,
180): string {
181 // A title that is only a harness tag (an early plugin's from a pasted prompt) reads as none.
182 const title = session.title?.trim();
183 if (title && !HARNESS_TAG.test(title)) return title;
184 const intent = intentTitle(firstPrompt);
185 if (intent) return intent;
186 return session.branch ? `Session on ${session.branch}` : "Untitled session";
187}
188
189/**
190 * Whether what a session is called is still provisional: what its first
191 * prompt asked, until g1t settles a title from all it did. Shown muted. A
192 * subagent goes by its task, which is never provisional.
193 */
194export function titleIsProvisional(session: Pick<WorkSession, "title" | "titleSource" | "parentId">): boolean {
195 if (session.parentId) return false;
196 return session.titleSource === "prompt" || (!session.title?.trim() && session.titleSource == null);
197}
198
199/** Why a title reads as it does, for a hint on it. */
200export function titleHint(session: Pick<WorkSession, "title" | "titleSource" | "parentId">): string | undefined {
201 if (session.parentId) return undefined;
202 switch (session.titleSource) {
203 case "person":
204 return "Named by a person: g1t keeps this title.";
205 case "agent":
206 return "Named by its agent.";
207 case "item":
208 case "pull":
209 case "summary":
210 return "Named by g1t from what the session did.";
211 default:
212 return titleIsProvisional(session) ? "From its first prompt, for now: g1t names it once what it is doing is clear." : undefined;
213 }
214}
215
216// ── Live now ────────────────────────────────────────────────────────────
217
218/** One work item being worked on: its claims and the sessions on it. */
219export type LiveItem = { item: number; claims: Claim[]; sessions: WorkSession[] };
220
221/** Sessions on no work item yet, together: on one pull request, on one branch of their own, or on neither. */
222export type LiveGroup =
223 | { kind: "pull"; pull: number; sessions: WorkSession[] }
224 | { kind: "branch"; branch: string; sessions: WorkSession[] }
225 | { kind: "none"; sessions: WorkSession[] };
226
227/**
228 * Whether a session has done nothing since it started: no title, no work item
229 * or pull request, never heard from again. Opening a Claude Code tab starts
230 * one before anything is typed in it, and it is nothing to show yet.
231 */
232export function startedOnly(session: WorkSession): boolean {
233 return !session.title && session.item == null && session.pull == null && session.lastSeenAt === session.startedAt;
234}
235
236/**
237 * The sessions Live now lists, and those it folds away: one not heard from
238 * for 15 minutes (idle) is someone's open tab more often than work going
239 * on, so it waits under the list until it is heard from or times out. One
240 * that has done nothing since it started is in neither.
241 */
242export function awakeAndIdle(sessions: WorkSession[]): { awake: WorkSession[]; idle: WorkSession[] } {
243 const awake: WorkSession[] = [];
244 const idle: WorkSession[] = [];
245 for (const session of sessions) {
246 if (!startedOnly(session)) (session.state === "idle" ? idle : awake).push(session);
247 }
248 idle.sort((a, b) => Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt));
249 return { awake, idle };
250}
251
252const latestSeen = (sessions: WorkSession[]) => Math.max(0, ...sessions.map((session) => Date.parse(session.lastSeenAt)));
253
254/**
255 * Active claims and sessions grouped by the work item they are on, the item
256 * with the most recent activity first. Sessions on no work item are grouped
257 * after them (`groups`): by their pull request, else by their branch when
258 * it is not `defaultBranch`, the most recently heard from first; the rest,
259 * on no branch of their own, last together. `loose` is every session on no
260 * work item, as the groups hold them.
261 */
262export function liveByItem(
263 claims: Claim[],
264 sessions: WorkSession[],
265 defaultBranch?: string | null,
266): { items: LiveItem[]; groups: LiveGroup[]; loose: WorkSession[] } {
267 const byItem = new Map<number, LiveItem>();
268 const at = (item: number) => {
269 let group = byItem.get(item);
270 if (!group) byItem.set(item, (group = { item, claims: [], sessions: [] }));
271 return group;
272 };
273 for (const claim of claims) at(claim.item).claims.push(claim);
274 const byPull = new Map<number, LiveGroup & { kind: "pull" }>();
275 const byBranch = new Map<string, LiveGroup & { kind: "branch" }>();
276 const rest: WorkSession[] = [];
277 for (const session of sessions) {
278 if (session.item != null) at(session.item).sessions.push(session);
279 else if (session.pull != null) {
280 const group = byPull.get(session.pull) ?? { kind: "pull" as const, pull: session.pull, sessions: [] };
281 byPull.set(session.pull, group);
282 group.sessions.push(session);
283 } else if (session.branch && session.branch !== defaultBranch) {
284 const group = byBranch.get(session.branch) ?? { kind: "branch" as const, branch: session.branch, sessions: [] };
285 byBranch.set(session.branch, group);
286 group.sessions.push(session);
287 } else rest.push(session);
288 }
289 const latest = (group: LiveItem) => Math.max(latestSeen(group.sessions), ...group.claims.map((claim) => Date.parse(claim.createdAt)));
290 const recent = (a: LiveGroup, b: LiveGroup) => latestSeen(b.sessions) - latestSeen(a.sessions);
291 const groups: LiveGroup[] = [...byPull.values(), ...byBranch.values()].sort(recent);
292 if (rest.length > 0) groups.push({ kind: "none", sessions: rest });
293 for (const group of groups) group.sessions.sort((a, b) => Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt));
294 return {
295 items: [...byItem.values()].sort((a, b) => latest(b) - latest(a) || a.item - b.item),
296 groups,
297 loose: groups.flatMap((group) => group.sessions),
298 };
299}
300
301// ── Decisions ───────────────────────────────────────────────────────────
302
303export const CONFIDENCE: Record<DecisionConfidence, { label: string; hint: string; tone: "neutral" | "info" | "success" }> = {
304 claimed: { label: "Claimed", hint: "Someone said so: an agent or a person recorded it.", tone: "neutral" },
305 tested: { label: "Tested", hint: "A check or the merge queue proved it.", tone: "info" },
306 approved: { label: "Approved", hint: "A person approved it.", tone: "success" },
307};
308
309export const DECISION_KINDS: Record<Decision["kind"], string> = {
310 proposal: "Proposal",
311 claim: "Claim",
312 integration_failure: "Failed together",
313 integration_passed: "Passed together",
314 fix: "Fix",
315 approval: "Approval",
316 merge: "Merge",
317 note: "Note",
318};
319
320/** The check a decision's evidence names. */
321export type EvidenceCheck = { name: string; conclusion: string; url: string | null };
322
323/** What a decision rests on, read from its evidence as far as it says. */
324export type Evidence = {
325 commit: string | null;
326 check: EvidenceCheck | null;
327 /** The pull requests tested together, in queue order. */
328 combination: number[];
329 /** Those ahead it fails with. */
330 suspects: number[];
331 bisected: boolean;
332};
333
334function numbers(value: unknown): number[] {
335 return Array.isArray(value) ? value.filter((n): n is number => typeof n === "number" && Number.isInteger(n)) : [];
336}
337
338export function evidenceOf(decision: Pick<Decision, "evidence">): Evidence {
339 const raw = (decision.evidence ?? {}) as Record<string, unknown>;
340 const check = raw.check && typeof raw.check === "object" ? (raw.check as Record<string, unknown>) : null;
341 return {
342 commit: typeof raw.commit === "string" ? raw.commit : null,
343 check:
344 check && typeof check.name === "string"
345 ? {
346 name: check.name,
347 conclusion: typeof check.conclusion === "string" ? check.conclusion : "failure",
348 url: typeof check.url === "string" && check.url ? check.url : null,
349 }
350 : null,
351 combination: numbers(raw.combination),
352 suspects: numbers(raw.suspects),
353 bisected: raw.bisected === true,
354 };
355}
356
357/** Where a check's address leads on the site: its path when it is g1t's own, else the address as it is. */
358export function checkHref(url: string | null): string | null {
359 if (!url) return null;
360 try {
361 const parsed = new URL(url);
362 if (parsed.hostname === "g1t.sh" || parsed.hostname.endsWith(".g1t.sh")) return parsed.pathname + parsed.search;
363 return url;
364 } catch {
365 return url.startsWith("/") ? url : null;
366 }
367}
368
369/**
370 * The integration failure a pull request still answers for: its latest
371 * `integration_failure`, unless it passed in the queue or merged since.
372 * `decisions` in any order.
373 */
374export function standingFailure(decisions: Decision[], pull?: number): Decision | null {
375 const mine = decisions
376 .filter((decision) => pull == null || decision.pull === pull)
377 .filter((decision) => ["integration_failure", "integration_passed", "merge"].includes(decision.kind))
378 .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
379 return mine[0]?.kind === "integration_failure" ? mine[0] : null;
380}
381
382/** The decisions after a failure that answer it: a fix recorded since, and a combination that then passed. */
383export function resolutionOf(decisions: Decision[], failure: Decision): { fix: Decision | null; passed: Decision | null } {
384 const after = decisions
385 .filter((decision) => Date.parse(decision.createdAt) > Date.parse(failure.createdAt))
386 .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
387 return {
388 fix: after.find((decision) => decision.kind === "fix" && (decision.pull === failure.pull || decision.item === failure.item)) ?? null,
389 passed: after.find((decision) => decision.kind === "integration_passed" && decision.pull === failure.pull) ?? null,
390 };
391}
392
393// ── The feed ────────────────────────────────────────────────────────────
394
395/** One line of the coordination feed: what happened, in words, and what it links to. */
396export type FeedLine = {
397 id: string;
398 time: string;
399 type: CoordinationType;
400 /** Who did it, by username; null for g1t itself. */
401 who: string | null;
402 /** The harness it came from, when it says. */
403 harness: Harness | null;
404 /** The sentence, after the name. Numbers in it are written `#N`. */
405 text: string;
406 /** The work item or pull request it is about. */
407 number: number | null;
408 /** The harness session it is about, by id, for a link to its page. */
409 session?: string;
410 /** The repository it happened in, `owner/name`, in a feed across a workspace's. */
411 repo?: string;
412 tone: "neutral" | "accent" | "success" | "warn" | "danger" | "info";
413};
414
415const list = (all: number[]) => all.map((n) => `#${n}`).join(", ");
416const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
417
418/** A session's pending updates as lines of the feed, oldest first. */
419export function pendingLines(updates: { id: string; kind: string; data: unknown; at: string }[]): FeedLine[] {
420 return updates.flatMap((update) => {
421 if (!(COORDINATION_TYPES as readonly string[]).includes(update.kind)) return [];
422 const event = { id: update.id, type: update.kind, time: update.at, source: "work", repoId: null, actor: null, data: update.data } as G1tEvent;
423 return feedLine(event) ?? [];
424 });
425}
426
427/** A coordination event as a line of the feed, or null for one it does not show. */
428export function feedLine(event: G1tEvent): FeedLine | null {
429 const base = { id: event.id, time: event.time, harness: null as Harness | null, number: null as number | null };
430 const data = event.data as Record<string, unknown> & { number?: number };
431 const number = typeof data.number === "number" ? data.number : null;
432 switch (event.type) {
433 case "work.claimed": {
434 const { claim, others = [], tookOver } = event.data;
435 const also = others.length > 0 ? ` (${others.map((other) => other.actor.username).join(", ")} ${plural(others.length, "is", "are")} on it too)` : "";
436 return {
437 ...base,
438 type: event.type,
439 who: claim.actor.username,
440 harness: claim.harness,
441 text: `${tookOver ? "took over" : "claimed"} #${claim.item}${also}`,
442 number: claim.item,
443 tone: others.length > 0 ? "warn" : "accent",
444 };
445 }
446 case "work.released": {
447 const { claim, reason } = event.data;
448 return {
449 ...base,
450 type: event.type,
451 who: claim.actor.username,
452 harness: claim.harness,
453 text: reason === "item_closed" ? `no longer holds #${claim.item}: the work item closed` : `released #${claim.item}${reason ? `: ${reason}` : ""}`,
454 number: claim.item,
455 tone: "neutral",
456 };
457 }
458 case "work.claim_expired": {
459 const { claim } = event.data;
460 return {
461 ...base,
462 type: event.type,
463 who: claim.actor.username,
464 harness: claim.harness,
465 text: `let the claim on #${claim.item} expire`,
466 number: claim.item,
467 tone: "neutral",
468 };
469 }
470 case "work.handoff_requested": {
471 const { claim, to, note } = event.data;
472 return {
473 ...base,
474 type: event.type,
475 who: claim.actor.username,
476 harness: claim.harness,
477 text: `handed #${claim.item} off to ${to ?? "anyone"}${note ? `: ${note}` : ""}`,
478 number: claim.item,
479 tone: "info",
480 };
481 }
482 case "session.started":
483 case "session.ended": {
484 const { session } = event.data;
485 const on = session.item != null ? ` on #${session.item}` : session.pull != null ? ` on #${session.pull}` : "";
486 const why = event.type === "session.ended" && session.endReason === "timed_out" ? " (timed out)" : "";
487 // Ended by hand: by whoever ran it, or by someone with write access for them.
488 const byHand = event.type === "session.ended" && session.endReason?.startsWith(ENDED_BY_HAND) ? session.endReason.slice(ENDED_BY_HAND.length) : null;
489 if (byHand) {
490 const own = byHand === session.actor.username;
491 return {
492 ...base,
493 type: event.type,
494 who: byHand,
495 harness: own ? session.harness : null,
496 text: own ? `ended a session${on} by hand` : `ended ${session.actor.username}'s session${on} by hand`,
497 number: session.item ?? session.pull,
498 tone: "neutral",
499 };
500 }
501 return {
502 ...base,
503 type: event.type,
504 who: session.actor.username,
505 harness: session.harness,
506 text: `${event.type === "session.started" ? "started a session" : "ended a session"}${on}${why}`,
507 number: session.item ?? session.pull,
508 tone: event.type === "session.started" ? "accent" : "neutral",
509 };
510 }
511 case "dependency.changed": {
512 const { item, added, removed, cleared, remaining, mergedPull } = event.data;
513 const parts = [
514 added.length > 0 && `now waits on ${list(added)}`,
515 removed.length > 0 && `no longer waits on ${list(removed)}`,
516 cleared.length > 0 && `${list(cleared)} ${mergedPull ? `landed (#${mergedPull} merged)` : "closed"}`,
517 ].filter(Boolean);
518 // What it still waits on, unless that is only what was just added.
519 const said = remaining.every((n) => added.includes(n)) && parts.length > 0;
520 const left = remaining.length === 0 ? "unblocked" : said ? null : `still waits on ${list(remaining)}`;
521 return {
522 ...base,
523 type: event.type,
524 who: null,
525 text: `#${item} ${[...parts, left].filter(Boolean).join("; ")}`,
526 number: item,
527 tone: remaining.length === 0 ? "success" : "info",
528 };
529 }
530 case "overlap.detected": {
531 if ("sessionId" in event.data) {
532 const { sessionId, otherActor, files, item } = event.data;
533 return {
534 ...base,
535 type: event.type,
536 who: otherActor,
537 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` : ""}`,
538 number: item ?? number,
539 session: sessionId,
540 tone: "warn",
541 };
542 }
543 const { pull, otherPull, files } = event.data;
544 return {
545 ...base,
546 type: event.type,
547 who: null,
548 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` : ""}`,
549 number: pull,
550 tone: "warn",
551 };
552 }
553 case "integration.failed": {
554 const { pull, combination, check, suspects } = event.data;
555 const together = combination.length > 1 ? ` combined as ${list(combination)}` : " on its own";
556 const with_ = suspects.length > 0 ? `; fails with ${list(suspects)}` : "";
557 return {
558 ...base,
559 type: event.type,
560 who: null,
561 text: `#${pull} failed in the merge queue${together}: ${check.name}${with_}`,
562 number: pull,
563 tone: "danger",
564 };
565 }
566 case "integration.passed": {
567 const { pull, combination } = event.data;
568 return {
569 ...base,
570 type: event.type,
571 who: null,
572 text: combination.length > 1 ? `#${pull} passed in the merge queue combined as ${list(combination)}` : `#${pull} passed in the merge queue`,
573 number: pull,
574 tone: "success",
575 };
576 }
577 case "decision.recorded": {
578 const { decision } = event.data;
579 const kind = DECISION_KINDS[decision.kind as Decision["kind"]]?.toLowerCase() ?? decision.kind;
580 return {
581 ...base,
582 type: event.type,
583 who: decision.actor,
584 text: `recorded ${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}, ${decision.confidence}: ${decision.summary}`,
585 number: decision.pull ?? decision.item ?? number,
586 tone: decision.kind === "integration_failure" ? "danger" : decision.confidence === "approved" ? "success" : "neutral",
587 };
588 }
589 // Steps of a session as it goes: for the pages showing it, not the feed.
590 case "session.progressed":
591 default:
592 return null;
593 }
594}
595
596// ── Session trees ───────────────────────────────────────────────────────
597
598/** A session with its subagents under it, and theirs under them. */
599export type SessionNode = { session: WorkSession; children: SessionNode[] };
600
601/**
602 * Sessions as trees, by `parentId`: the roots in the order given, each
603 * with its subagents in the order given. One whose parent is not in the
604 * list is a root of its own.
605 */
606export function sessionTree(sessions: WorkSession[]): SessionNode[] {
607 const nodes = new Map<string, SessionNode>();
608 for (const session of sessions) if (!nodes.has(session.id)) nodes.set(session.id, { session, children: [] });
609 const roots: SessionNode[] = [];
610 for (const node of nodes.values()) {
611 const parent = node.session.parentId ? nodes.get(node.session.parentId) : undefined;
612 // A parent that is the session itself or under it (a cycle) does not count.
613 if (parent && !contains(node, parent)) parent.children.push(node);
614 else roots.push(node);
615 }
616 return roots;
617}
618
619/** The sessions of several lists, each once (as the first list to have it says), in the order first given. */
620export function mergeSessions(...lists: (WorkSession[] | null | undefined)[]): WorkSession[] {
621 const seen = new Map<string, WorkSession>();
622 for (const list of lists) for (const session of list ?? []) if (!seen.has(session.id)) seen.set(session.id, session);
623 return [...seen.values()];
624}
625
626function contains(node: SessionNode, target: SessionNode): boolean {
627 return node === target || node.children.some((child) => contains(child, target));
628}
629
630/** How many sessions are under a tree's root, at any depth. */
631export function treeSize(node: SessionNode): number {
632 return node.children.reduce((total, child) => total + 1 + treeSize(child), 0);
633}
634
635/** How a session was tied to a work item or pull request, in a few words. */
636export const VIA_LABELS: Record<LinkVia, string> = {
637 attached: "attached",
638 claim: "claimed",
639 branch: "its branch",
640 commit: "its commit",
641 trailer: "commit trailer",
642 opened: "opened it",
643 ref: "named it",
644};
645
646export function viaLabel(via: LinkVia): string {
647 return VIA_LABELS[via] ?? via;
648}
649
650/** What each kind of thing a session touched is called. */
651export const TOUCH_KINDS: Record<TouchKind, string> = {
652 file: "Files",
653 commit: "Commits",
654 branch: "Branches",
655 pull: "Pull requests",
656 item: "Work items",
657};
658
659/** What was touched, grouped by kind in TOUCH_KINDS' order, each value once, in the order given. */
660export function touchedByKind(touched: { kind: TouchKind; value: string }[]): { kind: TouchKind; values: string[] }[] {
661 return (Object.keys(TOUCH_KINDS) as TouchKind[])
662 .map((kind) => ({ kind, values: [...new Set(touched.filter((one) => one.kind === kind).map((one) => one.value))] }))
663 .filter((group) => group.values.length > 0);
664}
665
666/** "12s", "4m", "1h 20m": how long a session ran, or has run until `now`. */
667export function duration(session: Pick<WorkSession, "startedAt" | "endedAt" | "lastSeenAt" | "state">, now: number): string {
668 const end = session.endedAt ? Date.parse(session.endedAt) : session.state === "ended" ? Date.parse(session.lastSeenAt) : now;
669 const ms = Math.max(0, end - Date.parse(session.startedAt));
670 return ms < MINUTE ? `${Math.floor(ms / 1000)}s` : span(ms);
671}
672
673/** Splits text at `#N`, for linking each number. */
674export function numberedParts(text: string): (string | number)[] {
675 const parts: (string | number)[] = [];
676 let last = 0;
677 for (const match of text.matchAll(/#(\d+)\b/g)) {
678 if (match.index > last) parts.push(text.slice(last, match.index));
679 parts.push(Number(match[1]));
680 last = match.index + match[0].length;
681 }
682 if (last < text.length) parts.push(text.slice(last));
683 return parts;
684}
685
686/** `blocked_by` as typed: numbers, with or without `#`, by commas or spaces; null when any is not one. */
687export function parseBlockedBy(text: string): number[] | null {
688 const words = text
689 .split(/[\s,]+/)
690 .map((word) => word.trim().replace(/^#/, ""))
691 .filter(Boolean);
692 if (words.some((word) => !/^\d+$/.test(word) || Number(word) <= 0)) return null;
693 return [...new Set(words.map(Number))];
694}
695
696/** Where a pull request a work item's decisions name is, by its number: null when it is not known. */
697export type PullHref = (number: number) => string | null;
698
699/**
700 * Where each of a work item's pull requests is, from its repositories and
701 * their pull requests: `/<owner>/<repo>/pull/<n>` in the one repository
702 * that has it. A number none of them has (or that two have, from before
703 * numbers were the workspace's) is not linked, rather than linked to the
704 * wrong repository. A deleted repository, with no path, has none.
705 */
706export function pullHrefs(repos: readonly { path: string; pulls: readonly { number: number }[] }[]): PullHref {
707 const where = new Map<number, string | null>();
708 for (const repo of repos) {
709 if (!repo.path) continue;
710 for (const pull of repo.pulls) {
711 const seen = where.get(pull.number);
712 where.set(pull.number, seen === undefined || seen === repo.path ? repo.path : null);
713 }
714 }
715 return (number) => {
716 const path = where.get(number);
717 return path ? `/${path}/pull/${number}` : null;
718 };
719}