Skip to content
214 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.

People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.1/**
2 * People on the site: the directory (people and agents in one search),
3 * profiles, teams of any mix, and the org chart. Types only from the
4 * contracts, so this file runs under `node --test`.
5 */
6import type { DirectoryPerson, DirectoryTeam, PeopleDirectory, TeamLead, WorkspaceAgent } from "@g1t/contracts";
7
8/** An agent as People's pages need it. */
9export type PeopleAgent = Pick<
10 WorkspaceAgent,
11 "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "title" | "role" | "team" | "department" | "status" | "responsibilities" | "builtin" | "spent_month_micros"
12>;
13
14/** Just what People's pages read of an agent, so loaders send no more. */
15export function peopleAgent(agent: PeopleAgent): PeopleAgent {
16 const { id, handle, display_name, avatar, avatar_seed, title, role, team, department, status, responsibilities, builtin, spent_month_micros } = agent;
17 return { id, handle, display_name, avatar, avatar_seed, title, role, team, department, status, responsibilities, builtin, spent_month_micros };
18}
19
20/** Where a person's profile is. */
21export function personPath(workspace: string, username: string): string {
22 return `/${workspace}/-/people/${username}`;
23}
24
25/** Where an agent's People profile is. */
26export function agentPath(workspace: string, handle: string): string {
27 return `/${workspace}/-/people/agents/${handle}`;
28}
29
30/** The agents on a team, by id: those added to it, then those whose home team it is. */
31export function agentIdsOn(team: Pick<DirectoryTeam, "slug" | "agent_ids">, agents: readonly Pick<PeopleAgent, "id" | "team">[]): string[] {
32 const ids = [...team.agent_ids];
33 for (const agent of agents) if (agent.team === team.slug && !ids.includes(agent.id)) ids.push(agent.id);
34 return ids;
35}
36
37/** The agents on a team, in order, as found among `agents`. */
38export function agentsOn<A extends Pick<PeopleAgent, "id" | "team">>(team: Pick<DirectoryTeam, "slug" | "agent_ids">, agents: readonly A[]): A[] {
39 const byId = new Map(agents.map((agent) => [agent.id, agent]));
40 return agentIdsOn(team, agents)
41 .map((id) => byId.get(id))
42 .filter((agent): agent is A => !!agent);
43}
44
45/** The teams a person is on. */
46export function teamsOfPerson<T extends Pick<DirectoryTeam, "people">>(teams: readonly T[], username: string): T[] {
47 return teams.filter((team) => team.people.some((person) => person.username === username));
48}
49
50/** The teams an agent is on: added to, or its home team. */
51export function teamsOfAgent<T extends Pick<DirectoryTeam, "slug" | "agent_ids">>(teams: readonly T[], agent: Pick<PeopleAgent, "id" | "team">): T[] {
52 return teams.filter((team) => team.agent_ids.includes(agent.id) || team.slug === agent.team);
53}
54
55/** Whether `lead` is this person or this agent. */
56export function leads(lead: TeamLead | null, who: { username: string } | { id: string }): boolean {
57 if (!lead) return false;
58 if ("username" in who) return lead.kind === "user" && lead.username === who.username;
59 return lead.kind === "agent" && lead.agent_id === who.id;
60}
61
62/** A person's name as pages show it. */
63export function personName(person: Pick<DirectoryPerson, "username" | "name" | "display_username">): string {
64 return person.name?.trim() || person.display_username || person.username;
65}
66
67/** Whether every word of `query` is somewhere in `fields`. */
68export function matches(query: string, fields: readonly (string | null | undefined)[]): boolean {
69 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
70 if (!words.length) return true;
71 const text = fields.filter(Boolean).join(" ").toLowerCase();
72 return words.every((word) => text.includes(word));
73}
74
75export type DirectoryKind = "everyone" | "people" | "agents";
76
77export function directoryKind(value: string | null): DirectoryKind {
78 return value === "people" || value === "agents" ? value : "everyone";
79}
80
81/** One card in the directory: a person or an agent. */
82export type DirectoryEntry =
83 | { kind: "person"; person: DirectoryPerson; teams: DirectoryTeam[] }
84 | { kind: "agent"; agent: PeopleAgent; teams: DirectoryTeam[] };
85
86/**
87 * Everyone matching `query`, people first by name, then agents (the
88 * orchestrator first, then by name). People match on their name, title,
89 * teams, what they own, where they are and their bio; agents on their
90 * name, handle, title, role, teams and responsibilities.
91 */
92export function directoryEntries(directory: PeopleDirectory, agents: readonly PeopleAgent[], query: string, kind: DirectoryKind): DirectoryEntry[] {
93 const out: DirectoryEntry[] = [];
94 if (kind !== "agents") {
95 for (const person of directory.people) {
96 const teams = teamsOfPerson(directory.teams, person.username);
97 const fields = [person.username, person.name, person.title, person.location, person.bio, ...person.owns, ...teams.map((team) => team.name), person.role === "owner" ? "owner" : null];
98 if (matches(query, fields)) out.push({ kind: "person", person, teams });
99 }
100 }
101 if (kind !== "people") {
102 for (const agent of agents) {
103 const teams = teamsOfAgent(directory.teams, agent);
104 const fields = [agent.handle, agent.display_name, agent.title, agent.role, agent.department, ...agent.responsibilities, ...teams.map((team) => team.name), "agent"];
105 if (matches(query, fields)) out.push({ kind: "agent", agent, teams });
106 }
107 }
108 return out;
109}
110
111/** One person in the org chart, with who reports to them and the agents of the teams they lead. */
112export type OrgNode = { person: DirectoryPerson; agents: PeopleAgent[]; leads: DirectoryTeam[]; reports: OrgNode[] };
113
114export type OrgChart = {
115 /** Everyone at the top: no manager, or one who is no longer here. */
116 roots: OrgNode[];
117 /** Teams no person leads (an agent does, or no one), with their agents. */
118 unled: { team: DirectoryTeam; agents: PeopleAgent[] }[];
119};
120
121/**
122 * Reporting lines, with each team's agents beside the person who leads it.
123 * A loop (which identity refuses) would leave its people out of `roots`;
124 * they are put back at the top, so no one is ever missing.
125 */
126export function orgChart(directory: PeopleDirectory, agents: readonly PeopleAgent[]): OrgChart {
127 const known = new Set(directory.people.map((person) => person.username));
128 const byManager = new Map<string, DirectoryPerson[]>();
129 for (const person of directory.people) {
130 if (person.manager && known.has(person.manager) && person.manager !== person.username) {
131 byManager.set(person.manager, [...(byManager.get(person.manager) ?? []), person]);
132 }
133 }
134 const placed = new Set<string>();
135 const node = (person: DirectoryPerson): OrgNode => {
136 placed.add(person.username);
137 const led = directory.teams.filter((team) => leads(team.lead, { username: person.username }));
138 const theirAgents: PeopleAgent[] = [];
139 for (const team of led) for (const agent of agentsOn(team, agents)) if (!theirAgents.includes(agent)) theirAgents.push(agent);
140 const reports = (byManager.get(person.username) ?? []).filter((report) => !placed.has(report.username)).map(node);
141 return { person, agents: theirAgents, leads: led, reports };
142 };
143 const roots = directory.people.filter((person) => !person.manager || !known.has(person.manager)).map(node);
144 for (const person of directory.people) if (!placed.has(person.username)) roots.push(node(person));
145 const unled = directory.teams
146 .filter((team) => !team.lead || team.lead.kind === "agent")
147 .map((team) => ({ team, agents: agentsOn(team, agents) }))
148 .filter((entry) => entry.agents.length > 0);
149 return { roots, unled };
150}
151
152/** Everyone who reports to `username`, directly. */
153export function reportsOf(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] {
154 return people.filter((person) => person.manager === username && person.username !== username);
155}
156
157/** Who `username` could report to: anyone but themselves and the people under them. */
158export function managerChoices(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] {
159 const below = new Set<string>([username]);
160 let grew = true;
161 while (grew) {
162 grew = false;
163 for (const person of people) {
164 if (person.manager && below.has(person.manager) && !below.has(person.username)) {
165 below.add(person.username);
166 grew = true;
167 }
168 }
169 }
170 return people.filter((person) => !below.has(person.username));
171}
172
173/** What someone owns, from a form's text: one per line or comma. */
174export function ownsFromText(text: string): string[] {
175 return text
176 .split(/[\n,]+/)
177 .map((phrase) => phrase.trim())
178 .filter(Boolean);
179}
180
181/** A monthly budget in dollars from a form, as micro-dollars; 0 for none; null when it isn't a number. */
182export function budgetFromText(text: string): number | null {
183 const value = text.trim().replace(/^\$/, "").replace(/,/g, "");
184 if (!value) return 0;
185 const dollars = Number(value);
186 if (!Number.isFinite(dollars) || dollars < 0) return null;
187 return Math.round(dollars * 1_000_000);
188}
189
190/** The access to Code a member has, in words: their role, the base permission, and the teams that add to it. */
191export function codeAccessWords(
192 person: Pick<DirectoryPerson, "role">,
193 base: PeopleDirectory["base_permission"],
194 teams: readonly Pick<DirectoryTeam, "name" | "repos_count">[],
195): { summary: string; through: string[] } {
196 const summary =
197 person.role === "owner"
198 ? "Admin on every repository, as an owner"
199 : base === "none"
200 ? "Only repositories they're given a role on"
201 : `${base[0]!.toUpperCase()}${base.slice(1)} on every repository, the workspace's base permission`;
202 const through = teams
203 .filter((team) => team.repos_count > 0)
204 .map((team) => `Roles on ${team.repos_count} ${team.repos_count === 1 ? "repository" : "repositories"} through ${team.name}`);
205 return { summary, through };
206}
207
208/** The part of what an agent is told that is about one team: its heading and what follows, up to the next. */
209export function teamBlock(text: string | null, teamName: string): string | null {
210 if (!text) return null;
211 const blocks = text.split(/\n(?=### )/);
212 const found = blocks.find((block) => block.startsWith(`### ${teamName}\n`) || block.trim() === `### ${teamName}`);
213 return found ? found.trim() : null;
214}