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.

110 linesCodeBlameRaw
1/**
2 * Sessions as the site shows them across a workspace: what each is called
3 * and where it opens, the filters Sessions keeps in its address, and live
4 * ones apart from the rest. Pure, so it is tested under Node.
5 */
6import type { Harness, SessionKind, WorkspaceSession } from "@g1t/contracts";
7
8import { HARNESS_NAMES, sessionTitle } from "./coordination.ts";
9import { sessionPath } from "./work-items.ts";
10
11/** How each kind is named in a filter. */
12export const KIND_NAMES: Record<SessionKind, string> = {
13 harness: "People's harnesses",
14 hosted: "Started on g1t",
15 agent: "g1t's agent",
16 recorded: "Recorded on pull requests",
17};
18
19/** What a session is called, never nothing. */
20export function workspaceSessionTitle(session: WorkspaceSession): string {
21 if (session.session) {
22 const own = session.session;
23 if (!own.title?.trim() && own.parentId) return [own.agentType ?? "Subagent", own.task].filter(Boolean).join(": ");
24 return sessionTitle(own);
25 }
26 return session.title?.trim() || session.pullTitle?.trim() || (session.pull != null ? `Pull request #${session.pull}` : "Untitled session");
27}
28
29/** What runs it, in a few words: the harness, g1t's agent, or the agent that recorded it. */
30export function runsIn(session: Pick<WorkspaceSession, "kind" | "harness" | "agent">): string {
31 if (session.kind === "agent") return "g1t's agent";
32 if (session.harness) return HARNESS_NAMES[session.harness] ?? session.harness;
33 return session.agent ? `${session.agent}, recorded` : "Recorded";
34}
35
36/**
37 * Where a session opens: a harness or hosted session's own page; g1t's
38 * agent and a recorded session are their pull request's Session tab; one
39 * with neither opens nowhere.
40 */
41export function sessionHref(workspace: string, session: Pick<WorkspaceSession, "kind" | "id" | "repo" | "pull">): string | null {
42 if (session.kind === "harness" || session.kind === "hosted") return sessionPath(workspace, session.id);
43 return session.pull != null ? `/${session.repo}/pull/${session.pull}/session` : null;
44}
45
46/** The filters Sessions keeps in its address. */
47export type SessionsFilters = {
48 /** `recent`: every session in one list; otherwise Live now, then what ended. */
49 state: "live" | "recent";
50 repo: string | null;
51 person: string | null;
52 harness: Harness | null;
53 kind: SessionKind | null;
54};
55
56const HARNESSES = Object.keys(HARNESS_NAMES) as Harness[];
57const KINDS = Object.keys(KIND_NAMES) as SessionKind[];
58
59export function sessionsFilters(query: URLSearchParams): SessionsFilters {
60 const harness = query.get("harness");
61 const kind = query.get("kind");
62 const text = (key: string) => query.get(key)?.trim().replace(/^@/, "") || null;
63 return {
64 state: query.get("state") === "recent" ? "recent" : "live",
65 repo: text("repo"),
66 person: text("person"),
67 harness: harness && (HARNESSES as string[]).includes(harness) ? (harness as Harness) : null,
68 kind: kind && (KINDS as string[]).includes(kind) ? (kind as SessionKind) : null,
69 };
70}
71
72/** The address of Sessions with `filters`, leaving out what is as it is by default. */
73export function sessionsQuery(filters: Partial<SessionsFilters>): string {
74 const query = new URLSearchParams();
75 if (filters.state === "recent") query.set("state", "recent");
76 for (const key of ["repo", "person", "harness", "kind"] as const) {
77 const value = filters[key];
78 if (value) query.set(key, value);
79 }
80 const text = query.toString();
81 return text ? `?${text}` : "";
82}
83
84/** Whether any filter narrows the list. */
85export function narrowed(filters: SessionsFilters): boolean {
86 return Boolean(filters.repo || filters.person || filters.harness || filters.kind);
87}
88
89/**
90 * Live sessions apart: those going now (active first, then idle, each the
91 * most recently heard from first) and those that ended.
92 */
93export function splitLive(sessions: WorkspaceSession[]): { active: WorkspaceSession[]; idle: WorkspaceSession[]; ended: WorkspaceSession[] } {
94 const by = (state: WorkspaceSession["state"]) =>
95 sessions.filter((session) => session.state === state).sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt));
96 return { active: by("active"), idle: by("idle"), ended: by("ended") };
97}
98
99/** The people running sessions in a list, each once, most active first, for its filter. */
100export function peopleIn(sessions: WorkspaceSession[]): string[] {
101 const counts = new Map<string, number>();
102 for (const session of sessions) if (session.actor) counts.set(session.actor, (counts.get(session.actor) ?? 0) + 1);
103 return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name);
104}
105
106/** `acme/web` as the workspace `acme` names it: `web`. */
107export function repoName(path: string, workspace: string): string {
108 const [owner, name] = path.split("/");
109 return owner?.toLowerCase() === workspace.toLowerCase() && name ? name : path;
110}