Skip to content

Commit

@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.

syntaqxcommitted Parent3ef50f7Browse files
22 files+414−220/22 viewed
+10−0
130130 introduction, no welcome, nothing charged to its budget until you ask it
131131 something. Invite a workspace agent to the channels its team works in.
132132
133+The one exception is @g1t, the workspace's own orchestrator, when a
134+person joins the workspace: it opens its direct message with them and says
135+a short welcome there, who it is, what they can ask it, that agents answer
136+in a direct message and when @mentioned in a channel, and where Home,
137+Chat, Code and Artifacts are. That welcome is a reply like any other,
138+charged to @g1t's budget; when the workspace has no model it can use, it
139+sends a short fixed welcome instead. It happens once per person per
140+workspace, never for whoever created the workspace, and never for an
141+agent. No other agent speaks first.
142+
133143 ### Example: hire Margo from the QA template
134144
135145 <Steps>
+3−1
174174 Nobody joins a workspace without saying yes. With an existing account,
175175 accepting on the invite's page is that yes: you land in the workspace (or
176176 the repository) the invite was for, with a one-time "You're in" banner,
177−and it becomes the workspace your sidebar shows.
177+and it becomes the workspace your sidebar shows. The workspace's @g1t
178+says a short welcome in your direct message with it, once; see
179+[when someone joins](/guides/workspaces/#when-someone-joins).
178180
179181 A new account made from an invite that names a workspace is invited to
180182 it: once the account's address is confirmed, g1t takes you to
+1−0
7878 | `team.created`, `team.edited`, `team.deleted` | A [team](/guides/teams/) was created, changed or deleted. Sent to the workspace's webhooks. `data.workspace`, `data.team` (its slug), `data.team_id`, `data.name`, `data.visibility`, `data.parent`; on `team.edited`, `data.changes`. |
7979 | `team.member_added`, `team.member_role_changed`, `team.member_removed` | Someone joined or left a team, or became its maintainer or a member. Sent to the workspace's webhooks. `data.username`, `data.role` (`member` or `maintainer`), `data.previous_role`. |
8080 | `team.repo_added`, `team.repo_role_changed`, `team.repo_removed` | A team was given a role on the repository, had it changed, or lost it. `data.repo`, `data.repo_id`, `data.repo_role`, `data.previous_repo_role`. |
81+| `workspace.member_joined` | A person became a member of the [workspace](/guides/workspaces/#when-someone-joins). Sent to the workspace's webhooks. `data.workspace_id`, `data.workspace` (its slug), `data.user_id`, `data.username`, `data.role` (`owner` or `member`) and `data.how`: `invitation` (they accepted an invitation), `joined` (they used an invite code that names the workspace) or `created` (they made the workspace and are its first owner). Never for an agent. |
8182 | `repo.archived`, `repo.unarchived` | It was made read-only, or writable again. |
8283 | `repo.deleted`, `repo.restored`, `repo.purged` | It was deleted, restored within its 30 days, or removed for good. |
8384 | `issue.labeled`, `issue.unlabeled`, `pull.labeled`, `pull.unlabeled` | A [label](/guides/labels/) was put on an issue or a pull request, or taken off: one event for each label. `data.number` and `data.label` (`name` and `color`). |
+14−0
388388 the workspace, with a one-time welcome. See
389389 [using an invite](/guides/authentication/#using-an-invite).
390390
391+#### When someone joins
392+
393+The moment a person becomes a member, by accepting an invitation or by
394+using an invite code that names the workspace, the workspace's
395+[@g1t](/guides/agents/#after-that) opens its direct message with them in
396+Chat and says a short welcome: who it is, what they can ask it, that
397+agents answer in a direct message and when @mentioned in a channel, and
398+where Home, Chat, Code and Artifacts are. It is the only time an agent
399+speaks first, it happens once per person per workspace, and whoever
400+created the workspace gets none. The welcome is a reply like any other,
401+charged to @g1t's budget; with no model the workspace can use, it is a
402+short fixed message. The join is also an event, `workspace.member_joined`,
403+for the workspace's [webhooks](/guides/webhooks/#events).
404+
391405 Every invitation sent is on **Invitations**,
392406 `g1t.sh/<workspace>/-/members/invitations` (**Workspace → Access →
393407 Invitations**, owners only): a table of who it is for, who sent it, the
+0−0

Binary or large file; its contents are not shown.

+1−1
7474 events: ["deployment.created", "deployment_status.created", "deployment.succeeded", "deployment.failed"],
7575 },
7676 { title: "Agents", events: ["session.appended", "agent.asked"] },
77− { title: "Access", events: ["repo.collaborator_added", "repo.collaborator_removed", "repo.collaborator_role_changed"] },
77+ { title: "Access", events: ["workspace.member_joined", "repo.collaborator_added", "repo.collaborator_removed", "repo.collaborator_role_changed"] },
7878 {
7979 title: "Teams",
8080 events: [
+58−0
916916 pub slug: String,
917917 }
918918
919+/// How someone came to be a member of a workspace (`workspace.member_joined`).
920+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
921+#[serde(rename_all = "snake_case")]
922+pub enum JoinedHow {
923+ /// They accepted a workspace invitation sent to them by name.
924+ Invitation,
925+ /// They made the workspace, and are its first owner.
926+ Created,
927+ /// They used an invite code or link that named the workspace.
928+ Joined,
929+}
930+
931+/// `workspace.member_joined`: a person became a member of a workspace,
932+/// published by identity as the membership is written. `how` says by
933+/// which door; the workspace's creator is `created`, so a listener that
934+/// greets new members can leave them out (the agents service's @g1t
935+/// welcomes everyone else, once). Never published for an agent or a
936+/// token: only a person joins.
937+#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
938+#[serde(rename_all = "camelCase")]
939+pub struct MemberJoined {
940+ pub workspace_id: String,
941+ /// The workspace's slug.
942+ pub workspace: String,
943+ pub user_id: String,
944+ pub username: String,
945+ pub role: crate::Role,
946+ pub how: JoinedHow,
947+}
948+
919949 /// `invite.created`: someone (or staff) made an invite. Never the code or
920950 /// the address it is for.
921951 #[derive(Debug, Serialize, serde::Deserialize)]
13841414 }
13851415
13861416 #[test]
1417+ fn a_member_joining_says_by_which_door() {
1418+ let joined = MemberJoined {
1419+ workspace_id: "wsp_1".into(),
1420+ workspace: "acme".into(),
1421+ user_id: "usr_ada".into(),
1422+ username: "ada".into(),
1423+ role: crate::Role::Member,
1424+ how: JoinedHow::Invitation,
1425+ };
1426+ let data = serde_json::to_value(&joined).unwrap();
1427+ assert_eq!(
1428+ data,
1429+ serde_json::json!({
1430+ "workspaceId": "wsp_1",
1431+ "workspace": "acme",
1432+ "userId": "usr_ada",
1433+ "username": "ada",
1434+ "role": "member",
1435+ "how": "invitation",
1436+ })
1437+ );
1438+ let back: MemberJoined = serde_json::from_value(data).unwrap();
1439+ assert_eq!(back, joined);
1440+ assert_eq!(serde_json::to_value(JoinedHow::Created).unwrap(), "created");
1441+ assert_eq!(serde_json::to_value(JoinedHow::Joined).unwrap(), "joined");
1442+ }
1443+
1444+ #[test]
13871445 fn reads_the_published_payload() {
13881446 let data = serde_json::json!({ "workspaceId": "wsp_1", "from": "a", "to": "b" });
13891447 let event: WorkspaceRenamed = serde_json::from_value(data).unwrap();
+7−2
195195 ("SUBSCRIBER_DOCS", &["git.push", "pull.merged"]),
196196 // Agents' routines that run on events: a pull request ready for
197197 // review or merged, checks or a deploy failing, an issue opened
198− // (agents/src/triggers.ts); and pushes, which skill libraries that
199− // follow a repository read again (agents/src/skill-library.ts).
198+ // (agents/src/triggers.ts); pushes, which skill libraries that
199+ // follow a repository read again (agents/src/skill-library.ts); and
200+ // a person joining a workspace, whom its @g1t welcomes
201+ // (agents/src/welcome.ts).
200202 (
201203 "SUBSCRIBER_AGENTS",
202204 &[
207209 "checks.completed",
208210 "issue.opened",
209211 "deployment.failed",
212+ "workspace.member_joined",
210213 ],
211214 ),
212215 ];
317320 assert!(routed("SUBSCRIBER_DOCS", "pull.merged"));
318321 assert!(!routed("SUBSCRIBER_DOCS", "pull.opened"));
319322 assert!(routed("SUBSCRIBER_AGENTS", "git.push"));
323+ assert!(routed("SUBSCRIBER_AGENTS", "workspace.member_joined"));
320324 assert!(!routed("SUBSCRIBER_AGENTS", "comment.created"));
325+ assert!(!routed("SUBSCRIBER_AGENTS", "workspace.updated"));
321326 // Every subscriber follows what moves or removes a repository.
322327 for (binding, _) in ROUTES {
323328 for kind in LIFECYCLE {
+2−1
1515 use crate::{User, Viewer};
1616
1717 /// Every event a webhook can be sent, in the order people are shown them.
18−pub const EVENT_TYPES: [&str; 105] = [
18+pub const EVENT_TYPES: [&str; 106] = [
1919 "git.push",
2020 "branch.renamed",
2121 "repo.created",
4242 "team.repo_added",
4343 "team.repo_role_changed",
4444 "team.repo_removed",
45+ "workspace.member_joined",
4546 "ruleset.created",
4647 "ruleset.updated",
4748 "ruleset.deleted",
+21−0
6767 export type TeamRequested = { team: string; notified: string[]; assigned: string[] };
6868
6969 /** The payload of the `team.*` events; each sets the fields that apply. */
70+/** How someone came to be a member of a workspace (`workspace.member_joined`). */
71+export type JoinedHow = "invitation" | "created" | "joined";
72+
73+/** The payload of `workspace.member_joined`. Mirrors `MemberJoined` in events.rs. */
74+export type MemberJoinedData = {
75+ workspaceId: string;
76+ /** The workspace's slug. */
77+ workspace: string;
78+ userId: string;
79+ username: string;
80+ role: "owner" | "member";
81+ how: JoinedHow;
82+};
83+
7084 export type TeamChangedData = {
7185 workspace: string;
7286 teamId: string;
162176 "user.deleted": { userId: string; username: string };
163177 /** A workspace was made, or its name, description or icon changed. */
164178 "workspace.updated": { workspaceId: string; slug: string };
179+ /**
180+ * A person became a member of a workspace. `how` says by which door;
181+ * the workspace's creator is `created`, so a listener that greets new
182+ * members leaves them out (the agents service's @g1t welcomes everyone
183+ * else, once). Never for an agent or a token.
184+ */
185+ "workspace.member_joined": MemberJoinedData;
165186 /** Someone, or g1t staff, made an invite. Never the code or the address. */
166187 "invite.created": { inviteId: string; inviterId: string | null; workspaceId: string | null; bound: boolean };
167188 /** An invite was used: by a new account, or by an account joining a workspace. */
+1−0
3535 "team.repo_added",
3636 "team.repo_role_changed",
3737 "team.repo_removed",
38+ "workspace.member_joined",
3839 "ruleset.created",
3940 "ruleset.updated",
4041 "ruleset.deleted",
+8−2
5959 import { readPolicy } from "./policy.ts";
6060 import { runDue } from "./routines.ts";
6161 import { onEvents } from "./triggers.ts";
62+import { welcome } from "./welcome.ts";
6263 import { cardAction } from "./cards.ts";
6364 import { type SessionEnv, sweep } from "./sessions.ts";
6465 import { EFFORT_NAMES, checkDue, effortCostsOf, markResolved, outcomesSince, readRecommendations, recommendationRow, sinceWindow, toRecommendation } from "./recommend.ts";
10631064 return opened.finish(await answer(service, match[1], args));
10641065 },
10651066
1066− /** Events routines run on, from the events service (SUBSCRIBER_AGENTS). */
1067+ /** Events routines run on, and people joining whom @g1t welcomes, from the events service (SUBSCRIBER_AGENTS). */
10671068 async queue(batch: MessageBatch<unknown>, env: Env): Promise<void> {
10681069 const events = batch.messages.map((message) => message.body as G1tEvent);
10691070 // A push to a default branch: skill libraries that follow that repository read it again (./skill-library.ts).
10701071 const pushes = events.flatMap((event) => (event?.type === "git.push" && event.data?.defaultBranch && event.data.repoId ? [{ repoId: event.data.repoId }] : []));
1071− await Promise.all([onEvents(env as unknown as SessionEnv, events.filter((event) => event?.type !== "git.push")), pushes.length ? skillPushes(env as unknown as SessionEnv, pushes) : null]);
1072+ await Promise.all([
1073+ onEvents(env as unknown as SessionEnv, events.filter((event) => event?.type !== "git.push")),
1074+ pushes.length ? skillPushes(env as unknown as SessionEnv, pushes) : null,
1075+ // A person joined a workspace: its @g1t says welcome, once (./welcome.ts).
1076+ welcome(env as unknown as SessionEnv, events).catch((error: unknown) => console.error("agents: welcomes were not sent", String(error))),
1077+ ]);
10721078 batch.ackAll();
10731079 },
10741080
+16−0
105105 assert.deepEqual(turns([], "agt_ship"), []);
106106 });
107107
108+test("@g1t's welcome to a new member: asked in its own voice, or a fixed one without a model", async () => {
109+ const { fixedWelcome, welcomeAsk } = await import("./prompt.ts");
110+ const ask = welcomeAsk("ada", "acme");
111+ assert.match(ask, /@ada just joined the acme workspace/);
112+ assert.match(ask, /this workspace's orchestrator/);
113+ assert.match(ask, /direct message like this one and when @mentioned in a channel/);
114+ assert.match(ask, /Home .* Chat .* Code .* Artifacts/);
115+ assert.match(ask, /Three or four sentences, no fluff/);
116+ assert.match(welcomeAsk(null, "acme"), /^\(Someone just joined/);
117+ assert.equal(
118+ fixedWelcome("ada", "acme"),
119+ "Welcome to acme, @ada! I'm g1t, this workspace's orchestrator: message me here with a question or a task, or @mention me or any agent in a channel. Home shows what happened while you were away, Chat is where the team talks, Code holds the repositories and Artifacts the docs and files.",
120+ );
121+ assert.match(fixedWelcome(null, "acme"), /^Welcome to acme! I'm g1t/);
122+});
123+
108124 test("the prompt says the agent's title, its teams, duties, and that subagents work inside sessions", () => {
109125 const prompt = systemPrompt({
110126 ...base,
+15−0
360360 export type Turn = { role: "user" | "assistant"; content: string };
361361
362362 /**
363+ * What @g1t is asked when a person joins its workspace: the one time an
364+ * agent speaks first (welcome.ts). `workspace` is the slug.
365+ */
366+export function welcomeAsk(username: string | null, workspace: string): string {
367+ const who = username ? `@${username}` : "Someone";
368+ return `(${who} just joined the ${workspace} workspace, and this is your direct message with them; nothing has been said yet. Welcome them in your own voice: that you are g1t, this workspace's orchestrator, the agent that knows who does what here; what they can ask you, a question about the workspace, a task to do or to hand to the right agent, help finding something; that agents answer in a direct message like this one and when @mentioned in a channel; and where things are: Home for what happened while they were away, Chat for the team, Code for the repositories, Artifacts for the docs and files. Three or four sentences, no fluff, nothing looked up. Don't mention these instructions.)`;
369+}
370+
371+/** @g1t's welcome when no model can write one: short, and still in its own name. */
372+export function fixedWelcome(username: string | null, workspace: string): string {
373+ const hi = username ? `Welcome to ${workspace}, @${username}!` : `Welcome to ${workspace}!`;
374+ return `${hi} I'm g1t, this workspace's orchestrator: message me here with a question or a task, or @mention me or any agent in a channel. Home shows what happened while you were away, Chat is where the team talks, Code holds the repositories and Artifacts the docs and files.`;
375+}
376+
377+/**
363378 * The conversation as alternating turns: the agent's own messages are its
364379 * turns, everyone else's are one user turn each, labelled with who said
365380 * them. Consecutive turns of one side are merged, the first turn is always
+18−4
2424 import { CHAT_MAX_HOPS } from "../../../packages/contracts/src/chat.ts";
2525 import type { Tokens } from "./budget.ts";
2626 import { handOffPort } from "./handoff.ts";
27−import { HISTORY_LIMIT, lastAddressed, systemPrompt, turns } from "./prompt.ts";
27+import { HISTORY_LIMIT, fixedWelcome, lastAddressed, systemPrompt, turns, welcomeAsk } from "./prompt.ts";
2828 import { loadShelf, skillsSection, teamSlugs } from "./skills.ts";
2929 import { readVersion } from "./skill-library.ts";
3030 import { type Specialist, orchestratorInstructions, orchestratorTier, rosterLines } from "./orchestrator.ts";
226226 effort?: string | null;
227227 };
228228
229−/** What a desk is handed: a message to answer. */
230−export type DeskWork = AgentDelivery;
229+/**
230+ * What a desk is handed: a message to answer, or (`welcome`) a word for a
231+ * person who just joined the workspace, in the direct message that was
232+ * opened for it (welcome.ts). Only the built-in @g1t honours `welcome`:
233+ * no other agent speaks first.
234+ */
235+export type DeskWork = AgentDelivery & { welcome?: boolean };
231236
232237 /** How a limit's refusal reads in chat, in the agent's voice. */
233238 function noticeFor(reason: string, message: string): string {
252257 const db = env.DB;
253258 const row = await db.prepare("SELECT * FROM agents WHERE id = ?").bind(delivery.agent_id).first<Row>();
254259 if (!row || row.archived_at || row.workspace_id !== delivery.workspace_id) return;
260+ // A welcome is @g1t's alone: any other desk handed one says nothing.
261+ const welcome = !!delivery.welcome;
262+ if (welcome && !row.builtin) return;
255263 const id = newId("arp", now.getTime());
256264 const claimed = await db
257265 .prepare(
317325
318326 /** Says once, in this conversation, why the agent cannot answer; a repeat within hours is kept back. */
319327 const notice = async (message: string, reason: string) => {
328+ // A welcome is never an excuse: without a model, a fixed one.
329+ if (welcome) {
330+ const posted = await surface.post(fixedWelcome(delivery.asker?.username ?? null, delivery.workspace)).catch(() => null);
331+ return await finish({ status: "blocked", error: reason, reply_id: posted });
332+ }
320333 const since = new Date(now.getTime() - NOTICE_QUIET_MS).toISOString();
321334 const recent = await db
322335 .prepare(
358371 members: conversationHere ? conversationHere.people + conversationHere.agents : null,
359372 });
360373 if (refused) return await notice(refused, "personal_agent");
361− const conversation = turns(history, row.id);
374+ // A welcome has no conversation yet: @g1t is asked to welcome the person.
375+ const conversation = welcome ? [{ role: "user" as const, content: welcomeAsk(delivery.asker?.username ?? null, delivery.workspace) }] : turns(history, row.id);
362376 if (!conversation.length) return await finish({ status: "skipped", error: "nothing to answer" });
363377 const author = askerIn(history, delivery);
364378 const askerName = delivery.asker?.username ?? author?.name ?? null;
+2−1
187187 async acknowledge() {
188188 acknowledged = (async () => {
189189 try {
190− // A DM's people decide.
190+ // A welcome (welcome.ts, `welcome:` ids) answers no message, so there is nothing to react to; a DM's people decide the rest.
191+ if (delivery.message_id.startsWith("welcome:")) return false;
191192 if (delivery.channel_kind === "dm") {
192193 const audience = await client.audience(workspace, channel);
193194 if (!audience.ok || !reactsIn("dm", audience.value.member_count)) return false;
+46−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { G1tEvent } from "../../../packages/contracts/src/events.ts";
5+import { welcomeFor, welcomeMessageId } from "./welcome.ts";
6+
7+const joined = (data: Record<string, unknown>, type = "workspace.member_joined") =>
8+ ({ type, data }) as unknown as Pick<G1tEvent, "type" | "data">;
9+
10+const ada = { workspaceId: "wsp_1", workspace: "Acme", userId: "usr_ada", username: "ada", role: "member" };
11+
12+test("a person who accepted an invitation or used an invite code is welcomed", () => {
13+ assert.deepEqual(welcomeFor(joined({ ...ada, how: "invitation" })), {
14+ workspace_id: "wsp_1",
15+ workspace: "acme",
16+ user_id: "usr_ada",
17+ username: "ada",
18+ });
19+ assert.deepEqual(welcomeFor(joined({ ...ada, how: "joined", role: "owner" }))?.username, "ada");
20+});
21+
22+test("the workspace's creator gets no welcome, and nor does anyone an event leaves unnamed", () => {
23+ assert.equal(welcomeFor(joined({ ...ada, how: "created", role: "owner" })), null, "they made it");
24+ assert.equal(welcomeFor(joined({ ...ada })), null, "no door named");
25+ assert.equal(welcomeFor(joined({ ...ada, how: "added" })), null, "a door this version doesn't know");
26+ assert.equal(welcomeFor(joined({ ...ada, how: "invitation", userId: "" })), null);
27+ assert.equal(welcomeFor(joined({ ...ada, how: "invitation", workspace: " " })), null);
28+ assert.equal(welcomeFor(joined({ ...ada, how: "invitation", username: undefined })), null);
29+ assert.equal(welcomeFor(joined({ ...ada, how: "invitation" }, "team.member_added")), null, "a team is not the workspace");
30+ assert.equal(welcomeFor(joined({ ...ada, how: "invitation" }, "workspace.updated")), null);
31+ assert.equal(welcomeFor(null), null);
32+ assert.equal(welcomeFor({ type: "workspace.member_joined", data: undefined } as unknown as Pick<G1tEvent, "type" | "data">), null);
33+});
34+
35+test("a welcome is claimed under one id per person per workspace, which names no message in chat", () => {
36+ assert.equal(welcomeMessageId("wsp_1", "usr_ada"), "welcome:wsp_1:usr_ada");
37+ // The same event twice, or a second door into the same workspace, is the same claim.
38+ const first = welcomeFor(joined({ ...ada, how: "invitation" }))!;
39+ const again = welcomeFor(joined({ ...ada, how: "joined" }))!;
40+ assert.equal(welcomeMessageId(first.workspace_id, first.user_id), welcomeMessageId(again.workspace_id, again.user_id));
41+ // Another workspace, or another person, is its own welcome.
42+ assert.notEqual(welcomeMessageId("wsp_2", "usr_ada"), welcomeMessageId("wsp_1", "usr_ada"));
43+ assert.notEqual(welcomeMessageId("wsp_1", "usr_bo"), welcomeMessageId("wsp_1", "usr_ada"));
44+ // The prefix surface.ts reads, so no reaction is tried on a message that does not exist.
45+ assert.ok(welcomeMessageId("wsp_1", "usr_ada").startsWith("welcome:"));
46+});
+118−0
1+/**
2+ * @g1t welcomes a person who joins its workspace
3+ * (docs.g1t.sh/guides/agents/, "After that"): the one time an agent
4+ * speaks first. On `workspace.member_joined` from identity, the
5+ * workspace's built-in @g1t opens its direct message with the person and
6+ * says a short welcome there, as a reply billed like any other, once per
7+ * person per workspace. Never for the workspace's creator (`how` is
8+ * `created`), never for an agent or a token, and never twice: the desk
9+ * claims the welcome under one message id, `welcome:<workspace>:<user>`,
10+ * in `agent_replies`, whose unique index on (agent_id, message_id) lets a
11+ * repeated event through once.
12+ */
13+import type { ServiceBinding, User } from "@g1t/contracts";
14+
15+// By path, as the tests load this file under Node, which resolves no package index.
16+import { chatClient } from "../../../packages/contracts/src/chat.ts";
17+import { identityClient } from "../../../packages/contracts/src/clients.ts";
18+import type { G1tEvent, MemberJoinedData } from "../../../packages/contracts/src/events.ts";
19+import { askerAccess } from "../../../packages/contracts/src/workspace-agents.ts";
20+import { ensureBuiltin } from "./builtin.ts";
21+import type { Desk } from "./desk.ts";
22+import { type Row, periods, selectAgents } from "./store.ts";
23+
24+/** Someone to welcome: the person and the workspace they joined. */
25+export type Welcome = { workspace_id: string; workspace: string; user_id: string; username: string };
26+
27+/**
28+ * The message id a welcome is claimed under: one per person per workspace,
29+ * however often the event arrives. It names no message in chat, which
30+ * surface.ts knows by the `welcome:` prefix.
31+ */
32+export function welcomeMessageId(workspaceId: string, userId: string): string {
33+ return `welcome:${workspaceId}:${userId}`;
34+}
35+
36+function text(value: unknown): string | null {
37+ return typeof value === "string" && value.trim() ? value.trim() : null;
38+}
39+
40+/**
41+ * Who an event says to welcome, or null: only a person joining a
42+ * workspace by invitation or with an invite code (`how`), never its
43+ * creator, and never an event that leaves out who or where.
44+ */
45+export function welcomeFor(event: Pick<G1tEvent, "type" | "data"> | null | undefined): Welcome | null {
46+ if (!event || event.type !== "workspace.member_joined") return null;
47+ const data = (event.data ?? {}) as Partial<MemberJoinedData>;
48+ if (data.how !== "invitation" && data.how !== "joined") return null;
49+ const workspace_id = text(data.workspaceId);
50+ const workspace = text(data.workspace)?.toLowerCase() ?? null;
51+ const user_id = text(data.userId);
52+ const username = text(data.username);
53+ if (!workspace_id || !workspace || !user_id || !username) return null;
54+ return { workspace_id, workspace, user_id, username };
55+}
56+
57+export type WelcomeEnv = {
58+ DB: D1Database;
59+ CHAT: ServiceBinding;
60+ IDENTITY: ServiceBinding;
61+ DESKS: DurableObjectNamespace<Desk>;
62+};
63+
64+/**
65+ * Welcomes everyone a batch of events says joined a workspace. Returns
66+ * how many welcomes were handed to a desk; a failure for one person is
67+ * logged and the rest go on.
68+ */
69+export async function welcome(env: WelcomeEnv, events: G1tEvent[], now = new Date()): Promise<number> {
70+ let handed = 0;
71+ for (const event of events) {
72+ const joined = welcomeFor(event);
73+ if (!joined) continue;
74+ try {
75+ if (await greet(env, joined, now)) handed++;
76+ } catch (error) {
77+ console.error("agents: a new member was not welcomed", joined.workspace, joined.username, String(error));
78+ }
79+ }
80+ return handed;
81+}
82+
83+/**
84+ * Opens @g1t's direct message with the person and hands the desk the
85+ * welcome. False when there is nobody to welcome (no live account, or
86+ * not a person), no @g1t, or it has welcomed them already.
87+ */
88+async function greet(env: WelcomeEnv, joined: Welcome, now: Date): Promise<boolean> {
89+ const [person] = await identityClient(env.IDENTITY)
90+ .usersForAudience([joined.user_id])
91+ .catch(() => [] as User[]);
92+ if (!person || (person.kind ?? "user") !== "user") return false;
93+ await ensureBuiltin(env.DB, joined.workspace_id);
94+ const g1t = await env.DB.prepare(selectAgents("a.workspace_id = ?3 AND a.builtin = 1")).bind(...periods(now), joined.workspace_id).first<Row>();
95+ if (!g1t || g1t.archived_at) return false;
96+ // Said once. The desk's claim on this id is what holds; this read spares a repeat the DM and the wake.
97+ const messageId = welcomeMessageId(joined.workspace_id, joined.user_id);
98+ const said = await env.DB.prepare("SELECT 1 FROM agent_replies WHERE agent_id = ? AND message_id = ?").bind(g1t.id, messageId).first();
99+ if (said) return false;
100+ const dm = await chatClient(env.CHAT).openDm(joined.workspace, person, [{ kind: "agent", id: g1t.id }]);
101+ if (!dm.ok) throw new Error(dm.error.message);
102+ const desk = env.DESKS.get(env.DESKS.idFromName(g1t.id));
103+ await desk.take({
104+ workspace: joined.workspace,
105+ workspace_id: joined.workspace_id,
106+ channel_id: dm.value.id,
107+ channel_kind: "dm",
108+ channel_name: null,
109+ agent_id: g1t.id,
110+ message_id: messageId,
111+ thread_root: null,
112+ asked_by: person.id,
113+ hops: 0,
114+ asker: askerAccess(person, joined.workspace),
115+ welcome: true,
116+ });
117+ return true;
118+}
+3−1
3131 //! INVITE_TTL_DAYS, INVITE_STAFF_WORKSPACES (comma separated slugs).
3232
3333 use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
34−use g1t_contracts::events::{InviteCreated, InviteRedeemed, WaitlistRequested};
34+use g1t_contracts::events::{InviteCreated, InviteRedeemed, JoinedHow, WaitlistRequested};
3535 use g1t_contracts::identity::*;
3636 use g1t_contracts::time::{SQL_NOW, rfc3339};
3737 use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id};
10381038 ])
10391039 .await?;
10401040 joined = Some(slug.clone());
1041+ // They are a member now: the workspace's @g1t welcomes them (agents service).
1042+ self.announce_member_joined(workspace_id, slug, user, row.joins_as(), JoinedHow::Joined).await;
10411043 }
10421044 }
10431045 self.db
+3−0
438438 if accepted.as_deref() != Some(now.as_str()) {
439439 return Ok(Outcome::fail(FailureCode::Conflict, NOT_OPEN));
440440 }
441+ // They are a member now: the workspace's @g1t welcomes them (agents service).
442+ self.announce_member_joined(&workspace_id, &slug, &a.user, role, g1t_contracts::events::JoinedHow::Invitation)
443+ .await;
441444 if row.kind == "workspace" {
442445 // An existing account's invitation: using it is this.
443446 self.settled(&row, &a.user, false, Some(slug.clone())).await;
+54−4
66
77 use g1t_contracts::access::BasePermission;
88 use g1t_contracts::audit::Surface;
9+use g1t_contracts::events::{JoinedHow, MemberJoined};
910 use g1t_contracts::identity::*;
1011 use g1t_contracts::members::{LeaveWorkspaceArgs, last_owner_refusal};
1112 use g1t_contracts::teams::TeamCreation;
211212 "" => slug.clone(),
212213 name => name.chars().take(MAX_NAME_LENGTH).collect(),
213214 };
214− Ok(Outcome::Ok(self.insert_workspace(&a.user.id, slug, name).await?))
215+ Ok(Outcome::Ok(self.insert_workspace(&a.user, slug, name).await?))
215216 }
216217
218+ /// Tells other services a person became a member of a workspace
219+ /// (`workspace.member_joined`, with `how` saying by which door): the
220+ /// agents service's @g1t welcomes them, once. Best effort, after the
221+ /// membership is written; a failure is logged.
222+ pub(crate) async fn announce_member_joined(&self, workspace_id: &str, slug: &str, user: &User, role: Role, how: JoinedHow) {
223+ self.announce("workspace.member_joined", Some(&user.id), member_joined(workspace_id, slug, user, role, how))
224+ .await;
225+ }
226+
217227 /// A new account's own workspace, named for its username, on the free
218228 /// plan as every new workspace is: so nobody is left without one. Made
219229 /// only when the username is free to use as a workspace's name (it
229239 {
230240 return Ok(None);
231241 }
232− let made = self.insert_workspace(&user.id, slug.clone(), slug).await;
242+ let made = self.insert_workspace(user, slug.clone(), slug).await;
233243 match made {
234244 Ok(workspace) => Ok(Some(workspace)),
235245 // Taken a moment ago: the person makes one themselves.
238248 }
239249 }
240250
241− /// Makes a workspace `user_id` owns, once every check has passed.
242− async fn insert_workspace(&self, user_id: &str, slug: String, name: String) -> Result<Workspace> {
251+ /// Makes a workspace `user` owns, once every check has passed, and
252+ /// says so (`workspace.member_joined` as `created`: its maker gets
253+ /// no welcome).
254+ async fn insert_workspace(&self, user: &User, slug: String, name: String) -> Result<Workspace> {
255+ let user_id = user.id.as_str();
243256 let now = now_ms();
244257 let workspace = Workspace {
245258 id: new_id("wsp", now),
283296 ])
284297 .await?;
285298 self.forget_deleted(&workspace.slug).await?;
299+ self.announce_member_joined(&workspace.id, &workspace.slug, user, Role::Owner, JoinedHow::Created).await;
286300 Ok(workspace)
287301 }
288302
501515 }
502516 }
503517
518+/// The `workspace.member_joined` event for `user` becoming a member of the
519+/// workspace `slug` with `role`, by the door `how`.
520+pub(crate) fn member_joined(workspace_id: &str, slug: &str, user: &User, role: Role, how: JoinedHow) -> MemberJoined {
521+ MemberJoined {
522+ workspace_id: workspace_id.to_owned(),
523+ workspace: slug.to_lowercase(),
524+ user_id: user.id.clone(),
525+ username: user.username.clone(),
526+ role,
527+ how,
528+ }
529+}
530+
504531 #[cfg(test)]
505532 mod tests {
506533 use super::*;
507534
508535 #[test]
536+ fn a_member_joining_names_the_person_the_workspace_their_role_and_the_door() {
537+ let ada = User {
538+ id: "usr_ada".into(),
539+ username: "ada".into(),
540+ ..User::default()
541+ };
542+ let joined = member_joined("wsp_1", "Acme", &ada, Role::Member, JoinedHow::Invitation);
543+ assert_eq!(joined.workspace_id, "wsp_1");
544+ assert_eq!(joined.workspace, "acme", "the slug as every service keys it");
545+ assert_eq!(joined.user_id, "usr_ada");
546+ assert_eq!(joined.username, "ada");
547+ assert_eq!(joined.role, Role::Member);
548+ assert_eq!(joined.how, JoinedHow::Invitation);
549+ // The maker of a workspace is its first owner, and marked so: no welcome for them.
550+ let made = member_joined("wsp_2", "ada", &ada, Role::Owner, JoinedHow::Created);
551+ assert_eq!((made.role, made.how), (Role::Owner, JoinedHow::Created));
552+ let data = serde_json::to_value(&made).unwrap();
553+ assert_eq!(data["how"], "created");
554+ assert_eq!(data["role"], "owner");
555+ assert_eq!(data["workspaceId"], "wsp_2");
556+ }
557+
558+ #[test]
509559 fn no_workspace_is_created_with_g1ts_names() {
510560 // What create_workspace takes the slug through, whatever its case.
511561 for slug in ["g1t", "G1T", " g1t-agent ", "G1T-Agent"] {
+13−5
6565
6666 /// The workspace an event belongs to when it is about no repository: a
6767 /// package of the workspace's own, unlinked from any repository, one of
68−/// its teams, or one of its rulesets. Such an event goes to the
69−/// workspace's webhooks only. Events about a repository (a team given a
70−/// role on one among them, a repository's own ruleset), and every other
71−/// kind, are `None`: they are routed by their repository.
68+/// its teams, one of its rulesets, or someone joining it. Such an event
69+/// goes to the workspace's webhooks only. Events about a repository (a
70+/// team given a role on one among them, a repository's own ruleset), and
71+/// every other kind, are `None`: they are routed by their repository.
7272 pub fn workspace_scoped(event: &Event) -> Option<String> {
73− let own = event.kind.starts_with("package.") || event.kind.starts_with("team.") || event.kind.starts_with("ruleset.");
73+ let own = event.kind.starts_with("package.")
74+ || event.kind.starts_with("team.")
75+ || event.kind.starts_with("ruleset.")
76+ || event.kind == "workspace.member_joined";
7477 if event.repo_id.as_deref().is_some_and(|id| !id.is_empty()) || !own {
7578 return None;
7679 }
253256 assert_eq!(workspace_scoped(&ruleset).as_deref(), Some("acme"));
254257 let own = repo_event("ruleset.created", json!({ "workspace": "acme", "repository": "acme/web" }));
255258 assert_eq!(workspace_scoped(&own), None);
259+ // Someone joining the workspace is the workspace's.
260+ let mut joined = repo_event("workspace.member_joined", json!({ "workspaceId": "wsp_1", "workspace": "Acme", "userId": "usr_1", "username": "ada", "role": "member", "how": "invitation" }));
261+ joined.repo_id = None;
262+ assert_eq!(workspace_scoped(&joined).as_deref(), Some("acme"));
263+ assert_eq!(payload(&joined, "acme", None, Some("ada"))["data"]["user_id"], "usr_1", "snake_case as everything sent");
256264 // Other events without a repository are not workspace events.
257265 let mut other = repo_event("issue.opened", json!({ "workspace": "acme" }));
258266 other.repo_id = None;