@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.
22 files+414−220/22 viewed
| 130 | 130 | introduction, no welcome, nothing charged to its budget until you ask it | |
| 131 | 131 | something. Invite a workspace agent to the channels its team works in. | |
| 132 | 132 | ||
| 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 | + | ||
| 133 | 143 | ### Example: hire Margo from the QA template | |
| 134 | 144 | ||
| 135 | 145 | <Steps> |
| 174 | 174 | Nobody joins a workspace without saying yes. With an existing account, | |
| 175 | 175 | accepting on the invite's page is that yes: you land in the workspace (or | |
| 176 | 176 | 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). | |
| 178 | 180 | ||
| 179 | 181 | A new account made from an invite that names a workspace is invited to | |
| 180 | 182 | it: once the account's address is confirmed, g1t takes you to |
| 78 | 78 | | `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`. | | |
| 79 | 79 | | `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`. | | |
| 80 | 80 | | `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. | | |
| 81 | 82 | | `repo.archived`, `repo.unarchived` | It was made read-only, or writable again. | | |
| 82 | 83 | | `repo.deleted`, `repo.restored`, `repo.purged` | It was deleted, restored within its 30 days, or removed for good. | | |
| 83 | 84 | | `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`). | |
| 388 | 388 | the workspace, with a one-time welcome. See | |
| 389 | 389 | [using an invite](/guides/authentication/#using-an-invite). | |
| 390 | 390 | ||
| 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 | + | ||
| 391 | 405 | Every invitation sent is on **Invitations**, | |
| 392 | 406 | `g1t.sh/<workspace>/-/members/invitations` (**Workspace → Access → | |
| 393 | 407 | Invitations**, owners only): a table of who it is for, who sent it, the |
Binary or large file; its contents are not shown.
| 74 | 74 | events: ["deployment.created", "deployment_status.created", "deployment.succeeded", "deployment.failed"], | |
| 75 | 75 | }, | |
| 76 | 76 | { 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"] }, | |
| 78 | 78 | { | |
| 79 | 79 | title: "Teams", | |
| 80 | 80 | events: [ |
| 916 | 916 | pub slug: String, | |
| 917 | 917 | } | |
| 918 | 918 | ||
| 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 | + | ||
| 919 | 949 | /// `invite.created`: someone (or staff) made an invite. Never the code or | |
| 920 | 950 | /// the address it is for. | |
| 921 | 951 | #[derive(Debug, Serialize, serde::Deserialize)] | |
| ⋯ | |||
| 1384 | 1414 | } | |
| 1385 | 1415 | ||
| 1386 | 1416 | #[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] | |
| 1387 | 1445 | fn reads_the_published_payload() { | |
| 1388 | 1446 | let data = serde_json::json!({ "workspaceId": "wsp_1", "from": "a", "to": "b" }); | |
| 1389 | 1447 | let event: WorkspaceRenamed = serde_json::from_value(data).unwrap(); | |
| 195 | 195 | ("SUBSCRIBER_DOCS", &["git.push", "pull.merged"]), | |
| 196 | 196 | // Agents' routines that run on events: a pull request ready for | |
| 197 | 197 | // 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). | |
| 200 | 202 | ( | |
| 201 | 203 | "SUBSCRIBER_AGENTS", | |
| 202 | 204 | &[ | |
| ⋯ | |||
| 207 | 209 | "checks.completed", | |
| 208 | 210 | "issue.opened", | |
| 209 | 211 | "deployment.failed", | |
| 212 | + | "workspace.member_joined", | |
| 210 | 213 | ], | |
| 211 | 214 | ), | |
| 212 | 215 | ]; | |
| ⋯ | |||
| 317 | 320 | assert!(routed("SUBSCRIBER_DOCS", "pull.merged")); | |
| 318 | 321 | assert!(!routed("SUBSCRIBER_DOCS", "pull.opened")); | |
| 319 | 322 | assert!(routed("SUBSCRIBER_AGENTS", "git.push")); | |
| 323 | + | assert!(routed("SUBSCRIBER_AGENTS", "workspace.member_joined")); | |
| 320 | 324 | assert!(!routed("SUBSCRIBER_AGENTS", "comment.created")); | |
| 325 | + | assert!(!routed("SUBSCRIBER_AGENTS", "workspace.updated")); | |
| 321 | 326 | // Every subscriber follows what moves or removes a repository. | |
| 322 | 327 | for (binding, _) in ROUTES { | |
| 323 | 328 | for kind in LIFECYCLE { | |
| 15 | 15 | use crate::{User, Viewer}; | |
| 16 | 16 | ||
| 17 | 17 | /// 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] = [ | |
| 19 | 19 | "git.push", | |
| 20 | 20 | "branch.renamed", | |
| 21 | 21 | "repo.created", | |
| ⋯ | |||
| 42 | 42 | "team.repo_added", | |
| 43 | 43 | "team.repo_role_changed", | |
| 44 | 44 | "team.repo_removed", | |
| 45 | + | "workspace.member_joined", | |
| 45 | 46 | "ruleset.created", | |
| 46 | 47 | "ruleset.updated", | |
| 47 | 48 | "ruleset.deleted", | |
| 67 | 67 | export type TeamRequested = { team: string; notified: string[]; assigned: string[] }; | |
| 68 | 68 | ||
| 69 | 69 | /** 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 | + | ||
| 70 | 84 | export type TeamChangedData = { | |
| 71 | 85 | workspace: string; | |
| 72 | 86 | teamId: string; | |
| ⋯ | |||
| 162 | 176 | "user.deleted": { userId: string; username: string }; | |
| 163 | 177 | /** A workspace was made, or its name, description or icon changed. */ | |
| 164 | 178 | "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; | |
| 165 | 186 | /** Someone, or g1t staff, made an invite. Never the code or the address. */ | |
| 166 | 187 | "invite.created": { inviteId: string; inviterId: string | null; workspaceId: string | null; bound: boolean }; | |
| 167 | 188 | /** An invite was used: by a new account, or by an account joining a workspace. */ | |
| 35 | 35 | "team.repo_added", | |
| 36 | 36 | "team.repo_role_changed", | |
| 37 | 37 | "team.repo_removed", | |
| 38 | + | "workspace.member_joined", | |
| 38 | 39 | "ruleset.created", | |
| 39 | 40 | "ruleset.updated", | |
| 40 | 41 | "ruleset.deleted", |
| 59 | 59 | import { readPolicy } from "./policy.ts"; | |
| 60 | 60 | import { runDue } from "./routines.ts"; | |
| 61 | 61 | import { onEvents } from "./triggers.ts"; | |
| 62 | + | import { welcome } from "./welcome.ts"; | |
| 62 | 63 | import { cardAction } from "./cards.ts"; | |
| 63 | 64 | import { type SessionEnv, sweep } from "./sessions.ts"; | |
| 64 | 65 | import { EFFORT_NAMES, checkDue, effortCostsOf, markResolved, outcomesSince, readRecommendations, recommendationRow, sinceWindow, toRecommendation } from "./recommend.ts"; | |
| ⋯ | |||
| 1063 | 1064 | return opened.finish(await answer(service, match[1], args)); | |
| 1064 | 1065 | }, | |
| 1065 | 1066 | ||
| 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). */ | |
| 1067 | 1068 | async queue(batch: MessageBatch<unknown>, env: Env): Promise<void> { | |
| 1068 | 1069 | const events = batch.messages.map((message) => message.body as G1tEvent); | |
| 1069 | 1070 | // A push to a default branch: skill libraries that follow that repository read it again (./skill-library.ts). | |
| 1070 | 1071 | 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 | + | ]); | |
| 1072 | 1078 | batch.ackAll(); | |
| 1073 | 1079 | }, | |
| 1074 | 1080 | ||
| 105 | 105 | assert.deepEqual(turns([], "agt_ship"), []); | |
| 106 | 106 | }); | |
| 107 | 107 | ||
| 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 | + | ||
| 108 | 124 | test("the prompt says the agent's title, its teams, duties, and that subagents work inside sessions", () => { | |
| 109 | 125 | const prompt = systemPrompt({ | |
| 110 | 126 | ...base, |
| 360 | 360 | export type Turn = { role: "user" | "assistant"; content: string }; | |
| 361 | 361 | ||
| 362 | 362 | /** | |
| 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 | + | /** | |
| 363 | 378 | * The conversation as alternating turns: the agent's own messages are its | |
| 364 | 379 | * turns, everyone else's are one user turn each, labelled with who said | |
| 365 | 380 | * them. Consecutive turns of one side are merged, the first turn is always |
| 24 | 24 | import { CHAT_MAX_HOPS } from "../../../packages/contracts/src/chat.ts"; | |
| 25 | 25 | import type { Tokens } from "./budget.ts"; | |
| 26 | 26 | 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"; | |
| 28 | 28 | import { loadShelf, skillsSection, teamSlugs } from "./skills.ts"; | |
| 29 | 29 | import { readVersion } from "./skill-library.ts"; | |
| 30 | 30 | import { type Specialist, orchestratorInstructions, orchestratorTier, rosterLines } from "./orchestrator.ts"; | |
| ⋯ | |||
| 226 | 226 | effort?: string | null; | |
| 227 | 227 | }; | |
| 228 | 228 | ||
| 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 }; | |
| 231 | 236 | ||
| 232 | 237 | /** How a limit's refusal reads in chat, in the agent's voice. */ | |
| 233 | 238 | function noticeFor(reason: string, message: string): string { | |
| ⋯ | |||
| 252 | 257 | const db = env.DB; | |
| 253 | 258 | const row = await db.prepare("SELECT * FROM agents WHERE id = ?").bind(delivery.agent_id).first<Row>(); | |
| 254 | 259 | 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; | |
| 255 | 263 | const id = newId("arp", now.getTime()); | |
| 256 | 264 | const claimed = await db | |
| 257 | 265 | .prepare( | |
| ⋯ | |||
| 317 | 325 | ||
| 318 | 326 | /** Says once, in this conversation, why the agent cannot answer; a repeat within hours is kept back. */ | |
| 319 | 327 | 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 | + | } | |
| 320 | 333 | const since = new Date(now.getTime() - NOTICE_QUIET_MS).toISOString(); | |
| 321 | 334 | const recent = await db | |
| 322 | 335 | .prepare( | |
| ⋯ | |||
| 358 | 371 | members: conversationHere ? conversationHere.people + conversationHere.agents : null, | |
| 359 | 372 | }); | |
| 360 | 373 | 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); | |
| 362 | 376 | if (!conversation.length) return await finish({ status: "skipped", error: "nothing to answer" }); | |
| 363 | 377 | const author = askerIn(history, delivery); | |
| 364 | 378 | const askerName = delivery.asker?.username ?? author?.name ?? null; | |
| 187 | 187 | async acknowledge() { | |
| 188 | 188 | acknowledged = (async () => { | |
| 189 | 189 | 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; | |
| 191 | 192 | if (delivery.channel_kind === "dm") { | |
| 192 | 193 | const audience = await client.audience(workspace, channel); | |
| 193 | 194 | if (!audience.ok || !reactsIn("dm", audience.value.member_count)) return false; |
| 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 | + | }); |
| 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 | + | } |
| 31 | 31 | //! INVITE_TTL_DAYS, INVITE_STAFF_WORKSPACES (comma separated slugs). | |
| 32 | 32 | ||
| 33 | 33 | 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}; | |
| 35 | 35 | use g1t_contracts::identity::*; | |
| 36 | 36 | use g1t_contracts::time::{SQL_NOW, rfc3339}; | |
| 37 | 37 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id}; | |
| ⋯ | |||
| 1038 | 1038 | ]) | |
| 1039 | 1039 | .await?; | |
| 1040 | 1040 | 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; | |
| 1041 | 1043 | } | |
| 1042 | 1044 | } | |
| 1043 | 1045 | self.db | |
| 438 | 438 | if accepted.as_deref() != Some(now.as_str()) { | |
| 439 | 439 | return Ok(Outcome::fail(FailureCode::Conflict, NOT_OPEN)); | |
| 440 | 440 | } | |
| 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; | |
| 441 | 444 | if row.kind == "workspace" { | |
| 442 | 445 | // An existing account's invitation: using it is this. | |
| 443 | 446 | self.settled(&row, &a.user, false, Some(slug.clone())).await; |
| 6 | 6 | ||
| 7 | 7 | use g1t_contracts::access::BasePermission; | |
| 8 | 8 | use g1t_contracts::audit::Surface; | |
| 9 | + | use g1t_contracts::events::{JoinedHow, MemberJoined}; | |
| 9 | 10 | use g1t_contracts::identity::*; | |
| 10 | 11 | use g1t_contracts::members::{LeaveWorkspaceArgs, last_owner_refusal}; | |
| 11 | 12 | use g1t_contracts::teams::TeamCreation; | |
| ⋯ | |||
| 211 | 212 | "" => slug.clone(), | |
| 212 | 213 | name => name.chars().take(MAX_NAME_LENGTH).collect(), | |
| 213 | 214 | }; | |
| 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?)) | |
| 215 | 216 | } | |
| 216 | 217 | ||
| 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 | + | ||
| 217 | 227 | /// A new account's own workspace, named for its username, on the free | |
| 218 | 228 | /// plan as every new workspace is: so nobody is left without one. Made | |
| 219 | 229 | /// only when the username is free to use as a workspace's name (it | |
| ⋯ | |||
| 229 | 239 | { | |
| 230 | 240 | return Ok(None); | |
| 231 | 241 | } | |
| 232 | − | let made = self.insert_workspace(&user.id, slug.clone(), slug).await; | |
| 242 | + | let made = self.insert_workspace(user, slug.clone(), slug).await; | |
| 233 | 243 | match made { | |
| 234 | 244 | Ok(workspace) => Ok(Some(workspace)), | |
| 235 | 245 | // Taken a moment ago: the person makes one themselves. | |
| ⋯ | |||
| 238 | 248 | } | |
| 239 | 249 | } | |
| 240 | 250 | ||
| 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(); | |
| 243 | 256 | let now = now_ms(); | |
| 244 | 257 | let workspace = Workspace { | |
| 245 | 258 | id: new_id("wsp", now), | |
| ⋯ | |||
| 283 | 296 | ]) | |
| 284 | 297 | .await?; | |
| 285 | 298 | self.forget_deleted(&workspace.slug).await?; | |
| 299 | + | self.announce_member_joined(&workspace.id, &workspace.slug, user, Role::Owner, JoinedHow::Created).await; | |
| 286 | 300 | Ok(workspace) | |
| 287 | 301 | } | |
| 288 | 302 | ||
| ⋯ | |||
| 501 | 515 | } | |
| 502 | 516 | } | |
| 503 | 517 | ||
| 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 | + | ||
| 504 | 531 | #[cfg(test)] | |
| 505 | 532 | mod tests { | |
| 506 | 533 | use super::*; | |
| 507 | 534 | ||
| 508 | 535 | #[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] | |
| 509 | 559 | fn no_workspace_is_created_with_g1ts_names() { | |
| 510 | 560 | // What create_workspace takes the slug through, whatever its case. | |
| 511 | 561 | for slug in ["g1t", "G1T", " g1t-agent ", "G1T-Agent"] { | |
| 65 | 65 | ||
| 66 | 66 | /// The workspace an event belongs to when it is about no repository: a | |
| 67 | 67 | /// 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. | |
| 72 | 72 | 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"; | |
| 74 | 77 | if event.repo_id.as_deref().is_some_and(|id| !id.is_empty()) || !own { | |
| 75 | 78 | return None; | |
| 76 | 79 | } | |
| ⋯ | |||
| 253 | 256 | assert_eq!(workspace_scoped(&ruleset).as_deref(), Some("acme")); | |
| 254 | 257 | let own = repo_event("ruleset.created", json!({ "workspace": "acme", "repository": "acme/web" })); | |
| 255 | 258 | 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"); | |
| 256 | 264 | // Other events without a repository are not workspace events. | |
| 257 | 265 | let mut other = repo_event("issue.opened", json!({ "workspace": "acme" })); | |
| 258 | 266 | other.repo_id = None; | |