@desplega.ai/agent-swarm 1.80.2 → 1.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +3 -0
  2. package/openapi.json +486 -29
  3. package/package.json +3 -3
  4. package/plugin/commands/user-management.md +85 -46
  5. package/plugin/pi-skills/user-management/SKILL.md +85 -46
  6. package/src/agentmail/handlers.ts +25 -3
  7. package/src/agentmail/types.ts +1 -0
  8. package/src/be/db.ts +33 -109
  9. package/src/be/migrations/067_users_first_class.sql +185 -0
  10. package/src/be/migrations/068_profile_changed_event_type.sql +56 -0
  11. package/src/be/unmapped-identities.ts +98 -0
  12. package/src/be/users.ts +531 -0
  13. package/src/github/handlers.ts +67 -7
  14. package/src/gitlab/handlers.ts +73 -5
  15. package/src/http/operator-actor.ts +59 -0
  16. package/src/http/users.ts +611 -21
  17. package/src/http/webhooks.ts +9 -0
  18. package/src/http/workflows.ts +2 -15
  19. package/src/linear/oauth.ts +61 -1
  20. package/src/linear/sync.ts +134 -21
  21. package/src/slack/actions.ts +8 -2
  22. package/src/slack/assistant.ts +12 -9
  23. package/src/slack/enrich.ts +162 -0
  24. package/src/slack/handlers.ts +11 -19
  25. package/src/tests/agentmail-handlers.test.ts +166 -0
  26. package/src/tests/github-handlers.test.ts +290 -0
  27. package/src/tests/gitlab-handlers.test.ts +293 -1
  28. package/src/tests/http-api-integration.test.ts +8 -4
  29. package/src/tests/http-users.test.ts +605 -0
  30. package/src/tests/linear-sync-identity.test.ts +427 -0
  31. package/src/tests/mcp-tools-user.test.ts +292 -0
  32. package/src/tests/slack-identity-resolution.test.ts +349 -0
  33. package/src/tests/user-identity.test.ts +351 -81
  34. package/src/tests/workflow-triggers-v2.test.ts +261 -20
  35. package/src/tools/manage-user.ts +119 -24
  36. package/src/tools/resolve-user.ts +43 -29
  37. package/src/types.ts +26 -4
  38. package/src/utils/secret-scrubber.ts +5 -0
  39. package/src/workflows/input.ts +7 -2
  40. package/src/workflows/triggers.ts +89 -9
@@ -0,0 +1,166 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlinkSync } from "node:fs";
3
+ import { handleMessageReceived } from "../agentmail/handlers";
4
+ import type { AgentMailWebhookPayload } from "../agentmail/types";
5
+ import {
6
+ closeDb,
7
+ createAgent,
8
+ createUser,
9
+ getAllUsers,
10
+ getDb,
11
+ getTaskById,
12
+ initDb,
13
+ } from "../be/db";
14
+ import { findUserByEmail } from "../be/users";
15
+
16
+ const TEST_DB_PATH = "./test-agentmail-handlers.sqlite";
17
+
18
+ function eventsFor(userId: string): Array<{
19
+ eventType: string;
20
+ actor: string;
21
+ beforeJson: string | null;
22
+ afterJson: string | null;
23
+ }> {
24
+ return getDb()
25
+ .prepare<
26
+ { eventType: string; actor: string; beforeJson: string | null; afterJson: string | null },
27
+ string
28
+ >(
29
+ "SELECT eventType, actor, beforeJson, afterJson FROM user_identity_events WHERE userId = ? ORDER BY createdAt ASC, rowid ASC",
30
+ )
31
+ .all(userId);
32
+ }
33
+
34
+ function makePayload(opts: {
35
+ from: string;
36
+ eventId?: string;
37
+ threadId?: string;
38
+ messageId?: string;
39
+ inboxId?: string;
40
+ subject?: string;
41
+ text?: string;
42
+ }): AgentMailWebhookPayload {
43
+ return {
44
+ type: "event",
45
+ event_type: "message.received",
46
+ event_id: opts.eventId ?? `evt_${Math.random().toString(36).slice(2)}`,
47
+ message: {
48
+ message_id: opts.messageId ?? `msg_${Math.random().toString(36).slice(2)}`,
49
+ thread_id: opts.threadId ?? `thr_${Math.random().toString(36).slice(2)}`,
50
+ inbox_id: opts.inboxId ?? "bot@swarm.dev",
51
+ organization_id: "org_test",
52
+ from_: opts.from,
53
+ to: ["bot@swarm.dev"],
54
+ cc: [],
55
+ bcc: [],
56
+ reply_to: [],
57
+ subject: opts.subject ?? "Test",
58
+ preview: "",
59
+ text: opts.text ?? "Hello",
60
+ html: null,
61
+ labels: [],
62
+ attachments: [],
63
+ in_reply_to: null,
64
+ references: [],
65
+ timestamp: new Date().toISOString(),
66
+ created_at: new Date().toISOString(),
67
+ updated_at: new Date().toISOString(),
68
+ },
69
+ };
70
+ }
71
+
72
+ beforeAll(() => {
73
+ for (const suffix of ["", "-wal", "-shm"]) {
74
+ try {
75
+ unlinkSync(`${TEST_DB_PATH}${suffix}`);
76
+ } catch {}
77
+ }
78
+ initDb(TEST_DB_PATH);
79
+ // Ensure a lead exists so handler's findLeadAgent() returns truthy and a
80
+ // task gets created on the "no inbox mapping" path.
81
+ createAgent({ name: "LeadAgent", isLead: true, status: "idle" });
82
+ });
83
+
84
+ afterAll(() => {
85
+ closeDb();
86
+ for (const suffix of ["", "-wal", "-shm"]) {
87
+ try {
88
+ unlinkSync(`${TEST_DB_PATH}${suffix}`);
89
+ } catch {}
90
+ }
91
+ });
92
+
93
+ describe("handleMessageReceived — identity auto-link via findOrCreateUserByEmail", () => {
94
+ test("UNKNOWN sender → users row auto-created, identity_added event emitted, task requestedByUserId populated", async () => {
95
+ const before = getAllUsers().length;
96
+ const result = await handleMessageReceived(
97
+ makePayload({ from: "Alice Newcomer <alice.newcomer@example.com>" }),
98
+ );
99
+ expect(result.created).toBe(true);
100
+ expect(result.taskId).toBeDefined();
101
+
102
+ const user = findUserByEmail("alice.newcomer@example.com");
103
+ expect(user).not.toBeNull();
104
+ expect(user!.name).toBe("Alice Newcomer");
105
+ expect(getAllUsers().length).toBe(before + 1);
106
+
107
+ const events = eventsFor(user!.id);
108
+ expect(events.map((e) => e.eventType)).toEqual(["identity_added"]);
109
+ expect(events[0]!.actor).toBe("system:webhook:agentmail");
110
+
111
+ const task = getTaskById(result.taskId!);
112
+ expect(task).not.toBeNull();
113
+ expect(task!.requestedByUserId).toBe(user!.id);
114
+ });
115
+
116
+ test("KNOWN sender (existing users.email) → no duplicate row, auto_merge event, task requestedByUserId populated", async () => {
117
+ const existing = createUser({ name: "Bob Existing", email: "bob.existing@example.com" });
118
+ const beforeCount = getAllUsers().length;
119
+
120
+ const result = await handleMessageReceived(makePayload({ from: "bob.existing@example.com" }));
121
+ expect(result.created).toBe(true);
122
+ expect(result.taskId).toBeDefined();
123
+ expect(getAllUsers().length).toBe(beforeCount);
124
+
125
+ const events = eventsFor(existing.id);
126
+ expect(events.map((e) => e.eventType)).toContain("auto_merge");
127
+
128
+ const task = getTaskById(result.taskId!);
129
+ expect(task!.requestedByUserId).toBe(existing.id);
130
+ });
131
+
132
+ test("sender matching emailAliases (not primary email) resolves via json_each-style alias path", async () => {
133
+ const existing = createUser({
134
+ name: "Carol Alias",
135
+ email: "carol@example.com",
136
+ emailAliases: ["carol.alt@example.com", "c.alias@example.com"],
137
+ });
138
+ const beforeCount = getAllUsers().length;
139
+
140
+ const result = await handleMessageReceived(
141
+ makePayload({ from: "Carol Alt <carol.alt@example.com>" }),
142
+ );
143
+ expect(result.created).toBe(true);
144
+ expect(getAllUsers().length).toBe(beforeCount);
145
+
146
+ const events = eventsFor(existing.id);
147
+ expect(events.map((e) => e.eventType)).toContain("auto_merge");
148
+
149
+ const task = getTaskById(result.taskId!);
150
+ expect(task!.requestedByUserId).toBe(existing.id);
151
+ });
152
+
153
+ test("sender with no extractable email → task created, requestedByUserId remains undefined, no findOrCreateUserByEmail side-effect", async () => {
154
+ const beforeCount = getAllUsers().length;
155
+
156
+ const result = await handleMessageReceived(
157
+ makePayload({ from: "Unknown Sender (no address)" }),
158
+ );
159
+ // Handler still creates a task (lead routing path), but with no user resolved.
160
+ expect(result.created).toBe(true);
161
+ expect(getAllUsers().length).toBe(beforeCount);
162
+
163
+ const task = getTaskById(result.taskId!);
164
+ expect(task!.requestedByUserId).toBeFalsy();
165
+ });
166
+ });
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Identity resolution tests for GitHub webhook handlers.
3
+ *
4
+ * Covers the step-3 rewire: every handler now goes through
5
+ * `findUserByExternalId('github', sender.login)` + the kv-backed unmapped
6
+ * tracker. No email auto-link path exists (Q17.A — GitHub never exposes
7
+ * email reliably via webhook or App-installation token).
8
+ *
9
+ * Test matrix:
10
+ * - PR event from a known github user → requestedByUserId populated, no kv writes
11
+ * - PR event from unknown user → requestedByUserId undefined, kv :meta + :count = 1
12
+ * - Repeat PR from same unknown user → :count = 2
13
+ * - Issue, comment, review events follow the same pattern
14
+ * - No `enrichUserFromIntegration('github', ...)` helper is invoked (no
15
+ * module-level email-fetch path exists at all).
16
+ */
17
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
+ import { unlink } from "node:fs/promises";
19
+ import { closeDb, createAgent, createUser, deleteKv, getDb, getKv, initDb } from "../be/db";
20
+ import { linkIdentity } from "../be/users";
21
+ import {
22
+ handleComment,
23
+ handleIssue,
24
+ handlePullRequest,
25
+ handlePullRequestReview,
26
+ } from "../github/handlers";
27
+ import { GITHUB_BOT_NAME } from "../github/mentions";
28
+ import type {
29
+ CommentEvent,
30
+ IssueEvent,
31
+ PullRequestEvent,
32
+ PullRequestReviewEvent,
33
+ } from "../github/types";
34
+
35
+ const TEST_DB_PATH = "./test-github-handlers.sqlite";
36
+ const UNMAPPED_NAMESPACE = "integration:unmapped:github";
37
+ const SYSTEM_ACTOR = { kind: "system" as const, id: "test:setup" };
38
+
39
+ // ── Setup ──
40
+
41
+ beforeAll(async () => {
42
+ await unlink(TEST_DB_PATH).catch(() => {});
43
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
44
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
45
+ initDb(TEST_DB_PATH);
46
+ createAgent({
47
+ id: "lead-gh-handlers",
48
+ name: "GitHubHandlersTestLead",
49
+ status: "idle",
50
+ isLead: true,
51
+ });
52
+ });
53
+
54
+ afterAll(async () => {
55
+ closeDb();
56
+ await unlink(TEST_DB_PATH).catch(() => {});
57
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
58
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
59
+ });
60
+
61
+ // Clear unmapped kv rows + tasks between tests to keep assertions independent.
62
+ beforeEach(() => {
63
+ const db = getDb();
64
+ db.prepare("DELETE FROM kv_entries WHERE namespace = ?").run(UNMAPPED_NAMESPACE);
65
+ db.prepare("DELETE FROM agent_tasks").run();
66
+ });
67
+
68
+ // ── Helpers ──
69
+
70
+ const BASE_REPO = { full_name: "test/repo", html_url: "https://github.com/test/repo" };
71
+ const BASE_PR = {
72
+ number: 1,
73
+ title: "Test PR",
74
+ body: null as string | null,
75
+ html_url: "https://github.com/test/repo/pull/1",
76
+ user: { login: "anonymous" },
77
+ head: { ref: "feature", sha: "abc1234567890" },
78
+ base: { ref: "main" },
79
+ merged: false,
80
+ merged_by: undefined,
81
+ };
82
+
83
+ function makePREvent(senderLogin: string, prNumber = 1): PullRequestEvent {
84
+ return {
85
+ action: "opened",
86
+ pull_request: { ...BASE_PR, number: prNumber, title: `PR #${prNumber}` },
87
+ repository: BASE_REPO,
88
+ sender: { login: senderLogin },
89
+ };
90
+ }
91
+
92
+ function makeIssueEvent(senderLogin: string, issueNumber = 10): IssueEvent {
93
+ return {
94
+ action: "opened",
95
+ issue: {
96
+ number: issueNumber,
97
+ title: `Issue #${issueNumber}`,
98
+ body: null,
99
+ html_url: `https://github.com/test/repo/issues/${issueNumber}`,
100
+ user: { login: senderLogin },
101
+ },
102
+ repository: BASE_REPO,
103
+ sender: { login: senderLogin },
104
+ };
105
+ }
106
+
107
+ function makeCommentEvent(senderLogin: string, body: string): CommentEvent {
108
+ return {
109
+ action: "created",
110
+ comment: {
111
+ id: 999,
112
+ body,
113
+ html_url: "https://github.com/test/repo/issues/10#issuecomment-999",
114
+ user: { login: senderLogin },
115
+ },
116
+ issue: { number: 10, title: "Test Issue", html_url: "https://github.com/test/repo/issues/10" },
117
+ repository: BASE_REPO,
118
+ sender: { login: senderLogin },
119
+ };
120
+ }
121
+
122
+ function makeReviewEvent(senderLogin: string): PullRequestReviewEvent {
123
+ return {
124
+ action: "submitted",
125
+ review: {
126
+ id: 1,
127
+ body: "Looks good",
128
+ state: "approved",
129
+ html_url: "https://github.com/test/repo/pull/99#pullrequestreview-1",
130
+ user: { login: senderLogin },
131
+ submitted_at: "2026-01-01T00:00:00Z",
132
+ },
133
+ pull_request: {
134
+ number: 99,
135
+ title: "Bot PR",
136
+ body: null,
137
+ html_url: "https://github.com/test/repo/pull/99",
138
+ user: { login: GITHUB_BOT_NAME },
139
+ head: { ref: "feature" },
140
+ base: { ref: "main" },
141
+ },
142
+ repository: BASE_REPO,
143
+ sender: { login: senderLogin },
144
+ };
145
+ }
146
+
147
+ function getMappedUserTaskCount(userId: string): number {
148
+ const row = getDb()
149
+ .prepare<{ n: number }, string>(
150
+ "SELECT COUNT(*) AS n FROM agent_tasks WHERE requestedByUserId = ?",
151
+ )
152
+ .get(userId);
153
+ return row?.n ?? 0;
154
+ }
155
+
156
+ // ── Known sender → mapped requestedByUserId, no unmapped writes ──
157
+
158
+ describe("known github sender", () => {
159
+ test("PR event from a mapped user populates requestedByUserId and writes no kv rows", async () => {
160
+ const user = createUser({ name: "Mapped User", email: "mapped@example.com" });
161
+ linkIdentity(user.id, "github", "mapped-login", SYSTEM_ACTOR);
162
+
163
+ const result = await handlePullRequest(makePREvent("mapped-login", 100));
164
+ // Even if the PR doesn't create a task (no mention), the sender resolution
165
+ // side effects are what we're testing — assert no kv writes happened.
166
+ expect(result.created).toBeDefined();
167
+ expect(getKv(UNMAPPED_NAMESPACE, "mapped-login:meta")).toBeNull();
168
+ expect(getKv(UNMAPPED_NAMESPACE, "mapped-login:count")).toBeNull();
169
+ });
170
+
171
+ test("PR with bot assignment from mapped user puts user id on the task", async () => {
172
+ const user = createUser({ name: "Mapped Assigner", email: "assigner@example.com" });
173
+ linkIdentity(user.id, "github", "assigner", SYSTEM_ACTOR);
174
+
175
+ const event: PullRequestEvent = {
176
+ action: "assigned",
177
+ pull_request: { ...BASE_PR, number: 200, title: "Bot PR" },
178
+ repository: BASE_REPO,
179
+ sender: { login: "assigner" },
180
+ assignee: { login: GITHUB_BOT_NAME, id: 1 },
181
+ };
182
+ const result = await handlePullRequest(event);
183
+ expect(result.created).toBe(true);
184
+ expect(getMappedUserTaskCount(user.id)).toBe(1);
185
+
186
+ // Mapped sender → no unmapped kv writes.
187
+ expect(getKv(UNMAPPED_NAMESPACE, "assigner:meta")).toBeNull();
188
+ expect(getKv(UNMAPPED_NAMESPACE, "assigner:count")).toBeNull();
189
+ });
190
+ });
191
+
192
+ // ── Unknown sender → unmapped kv tracker ──
193
+
194
+ describe("unknown github sender", () => {
195
+ test("PR event from unknown user writes :meta + :count = 1", async () => {
196
+ await handlePullRequest(makePREvent("ghost-login", 300));
197
+
198
+ const meta = getKv(UNMAPPED_NAMESPACE, "ghost-login:meta");
199
+ expect(meta).not.toBeNull();
200
+ expect(meta?.valueType).toBe("json");
201
+ const metaValue = meta?.value as {
202
+ lastSeenAt: string;
203
+ sampleEventType: string;
204
+ sampleContext: string;
205
+ };
206
+ expect(metaValue.sampleEventType).toBe("pull_request");
207
+ expect(metaValue.sampleContext).toContain("PR #300");
208
+
209
+ const count = getKv(UNMAPPED_NAMESPACE, "ghost-login:count");
210
+ expect(count?.valueType).toBe("integer");
211
+ expect(count?.value).toBe(1);
212
+ });
213
+
214
+ test("repeated PR events from same unknown user atomically increment count", async () => {
215
+ await handlePullRequest(makePREvent("repeater", 400));
216
+ await handlePullRequest(makePREvent("repeater", 401));
217
+
218
+ const count = getKv(UNMAPPED_NAMESPACE, "repeater:count");
219
+ expect(count?.value).toBe(2);
220
+ });
221
+
222
+ test("issue event from unknown user writes sampleEventType = 'issues'", async () => {
223
+ await handleIssue(makeIssueEvent("issue-ghost", 50));
224
+
225
+ const meta = getKv(UNMAPPED_NAMESPACE, "issue-ghost:meta");
226
+ const metaValue = meta?.value as { sampleEventType: string; sampleContext: string };
227
+ expect(metaValue.sampleEventType).toBe("issues");
228
+ expect(metaValue.sampleContext).toContain("Issue #50");
229
+ });
230
+
231
+ test("comment event from unknown user writes sampleEventType = 'issue_comment'", async () => {
232
+ // Need a bot mention to avoid early-return — handleComment still runs the
233
+ // sender resolution before the mention check, though, so the kv write
234
+ // happens either way.
235
+ await handleComment(
236
+ makeCommentEvent("comment-ghost", "just a comment without mention"),
237
+ "issue_comment",
238
+ );
239
+
240
+ const meta = getKv(UNMAPPED_NAMESPACE, "comment-ghost:meta");
241
+ const metaValue = meta?.value as { sampleEventType: string; sampleContext: string };
242
+ expect(metaValue.sampleEventType).toBe("issue_comment");
243
+ expect(metaValue.sampleContext).toContain("just a comment");
244
+ });
245
+
246
+ test("review event from unknown user writes sampleEventType = 'pull_request_review'", async () => {
247
+ await handlePullRequestReview(makeReviewEvent("review-ghost"));
248
+
249
+ const meta = getKv(UNMAPPED_NAMESPACE, "review-ghost:meta");
250
+ const metaValue = meta?.value as { sampleEventType: string; sampleContext: string };
251
+ expect(metaValue.sampleEventType).toBe("pull_request_review");
252
+ expect(metaValue.sampleContext).toContain("Review on PR #99");
253
+ expect(metaValue.sampleContext).toContain("approved");
254
+ });
255
+
256
+ test("sampleContext is truncated to 100 characters", async () => {
257
+ const longBody = "x".repeat(200);
258
+ await handleComment(makeCommentEvent("trunc-ghost", longBody), "issue_comment");
259
+
260
+ const meta = getKv(UNMAPPED_NAMESPACE, "trunc-ghost:meta");
261
+ const metaValue = meta?.value as { sampleContext: string };
262
+ expect(metaValue.sampleContext.length).toBeLessThanOrEqual(100);
263
+ });
264
+ });
265
+
266
+ // ── Negative: no email-enrichment helper exists for GitHub ──
267
+
268
+ describe("no github email enrichment", () => {
269
+ test("handlers module exports no `enrichUserFromIntegration`-style helper", async () => {
270
+ // Q17.A — there is intentionally NO email auto-link cascade for GitHub.
271
+ // Confirm the module surface stays clean.
272
+ const mod = await import("../github/handlers");
273
+ const exported = Object.keys(mod);
274
+ expect(exported.some((name) => /enrich.*github/i.test(name))).toBe(false);
275
+ expect(exported.some((name) => /github.*enrich/i.test(name))).toBe(false);
276
+ });
277
+
278
+ test("kv entries are cleaned up by deleteKv (operator triage flow)", async () => {
279
+ await handlePullRequest(makePREvent("triage-target", 500));
280
+ expect(getKv(UNMAPPED_NAMESPACE, "triage-target:meta")).not.toBeNull();
281
+
282
+ // Simulate the operator triage action that removes the kv entry after
283
+ // mapping the identity manually (step-9 UI will do this).
284
+ deleteKv(UNMAPPED_NAMESPACE, "triage-target:meta");
285
+ deleteKv(UNMAPPED_NAMESPACE, "triage-target:count");
286
+
287
+ expect(getKv(UNMAPPED_NAMESPACE, "triage-target:meta")).toBeNull();
288
+ expect(getKv(UNMAPPED_NAMESPACE, "triage-target:count")).toBeNull();
289
+ });
290
+ });