Skip to content
121 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: a workspace's directory of everyone in it, people and agents,
3 * and each member's place in it: a title, who they report to, and what
4 * they own. Kept by identity beside membership; mirrors
5 * `crates/contracts/src/people.rs`. Agents come from the agents service,
6 * which teams name by id.
7 */
8import type { User } from "./identity";
9import type { Result } from "./result";
10import type { TeamChannel, TeamLead, TeamRef, TeamRole, TeamVisibility } from "./teams";
11
12/** The longest title. */
13export const MAX_TITLE_LENGTH = 80;
14/** The most things one person owns, as their profile lists them, and the longest of them. */
15export const MAX_OWNS = 8;
16export const MAX_OWNS_LENGTH = 60;
17
18/** One member, as the directory, their profile and the org chart show them. */
19export type DirectoryPerson = {
20 user_id: string;
21 username: string;
22 display_username?: string;
23 name: string | null;
24 avatar: string | null;
25 bio: string | null;
26 location: string | null;
27 pronouns: string | null;
28 /** An IANA time zone, for their local time. */
29 timezone: string | null;
30 role: "owner" | "member";
31 /** Their title in this workspace. */
32 title: string | null;
33 /** Who they report to, by username. */
34 manager: string | null;
35 /** What they own: a few short phrases. */
36 owns: string[];
37 joined_at: string;
38};
39
40export type TeamPersonRef = { username: string; role: TeamRole };
41
42/** A team as the directory and the org chart need it. */
43export type DirectoryTeam = {
44 slug: string;
45 name: string;
46 description: string | null;
47 visibility: TeamVisibility;
48 parent: TeamRef | null;
49 lead: TeamLead | null;
50 channel: TeamChannel | null;
51 budget_micros: number | null;
52 /** Its own people. */
53 people: TeamPersonRef[];
54 /** Agents added to it, by id. Agents whose home team it is are on it too (`agentsOnTeam`). */
55 agent_ids: string[];
56 repos_count: number;
57};
58
59export type PeopleDirectory = {
60 people: DirectoryPerson[];
61 teams: DirectoryTeam[];
62 /** What members get on every repository. */
63 base_permission: "none" | "read" | "write" | "admin";
64 /** Whether the viewer owns the workspace: they set anyone's title, manager and what they own. */
65 can_manage: boolean;
66};
67
68/** What changes on a member's profile; the rest stays. `manager: ""` clears it (owners only). */
69export type MemberProfileChanges = {
70 title?: string;
71 manager?: string;
72 owns?: string[];
73};
74
75export interface PeopleClient {
76 /** Everyone in a workspace and its teams, as the viewer may see them. Members only. */
77 peopleDirectory(viewer: User | null, workspace: string): Promise<Result<PeopleDirectory>>;
78 /** A member's title and what they own (themselves or an owner), and their manager (owners). */
79 setMemberProfile(actor: User, workspace: string, username: string, changes: MemberProfileChanges): Promise<Result<DirectoryPerson>>;
80}
81
82/**
83 * The agents on a team, by id: those added to it, then those whose home
84 * team it is (their profile names it).
85 */
86export function agentsOnTeam(team: { slug: string; agent_ids: readonly string[] }, agents: readonly { id: string; team: string | null }[]): string[] {
87 const ids = [...team.agent_ids];
88 for (const agent of agents) if (agent.team === team.slug && !ids.includes(agent.id)) ids.push(agent.id);
89 return ids;
90}
91
92/** What a team is made of. */
93export type TeamKind = "mixed" | "people" | "agents" | "empty";
94
95export function teamKind(people: number, agents: number): TeamKind {
96 if (people && agents) return "mixed";
97 if (people) return "people";
98 if (agents) return "agents";
99 return "empty";
100}
101
102/** "2 people and 1 agent", "People only", "Agents only", "No one yet". */
103export function teamKindLabel(people: number, agents: number): string {
104 const kind = teamKind(people, agents);
105 if (kind === "people") return "People only";
106 if (kind === "agents") return "Agents only";
107 if (kind === "empty") return "No one yet";
108 return `${people} ${people === 1 ? "person" : "people"} and ${agents} ${agents === 1 ? "agent" : "agents"}`;
109}
110
111/** What someone owns, as identity keeps it: trimmed, each once (ignoring case), at most eight. */
112export function cleanOwns(owns: readonly string[]): string[] {
113 const out: string[] = [];
114 for (const raw of owns) {
115 const phrase = raw.split(/\s+/).filter(Boolean).join(" ").slice(0, MAX_OWNS_LENGTH).replace(/[.,;]+$/, "").trim();
116 if (!phrase || out.some((kept) => kept.toLowerCase() === phrase.toLowerCase())) continue;
117 out.push(phrase);
118 if (out.length === MAX_OWNS) break;
119 }
120 return out;
121}