| 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 | */ |
| 6 | import type { Harness, SessionKind, WorkspaceSession } from "@g1t/contracts"; |
| 7 | |
| 8 | import { HARNESS_NAMES, sessionTitle } from "./coordination.ts"; |
| 9 | import { sessionPath } from "./work-items.ts"; |
| 10 | |
| 11 | /** How each kind is named in a filter. */ |
| 12 | export 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. */ |
| 20 | export 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. */ |
| 30 | export 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 | */ |
| 41 | export 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. */ |
| 47 | export 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 | |
| 56 | const HARNESSES = Object.keys(HARNESS_NAMES) as Harness[]; |
| 57 | const KINDS = Object.keys(KIND_NAMES) as SessionKind[]; |
| 58 | |
| 59 | export 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. */ |
| 73 | export 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. */ |
| 85 | export 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 | */ |
| 93 | export 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. */ |
| 100 | export 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`. */ |
| 107 | export function repoName(path: string, workspace: string): string { |
| 108 | const [owner, name] = path.split("/"); |
| 109 | return owner?.toLowerCase() === workspace.toLowerCase() && name ? name : path; |
| 110 | } |