@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
@@ -1,13 +1,18 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import {
4
4
  closeDb,
5
5
  createAgent,
6
6
  createTaskExtended,
7
+ createUser,
8
+ deleteKv,
7
9
  findTaskByVcs,
10
+ getDb,
11
+ getKv,
8
12
  getTaskById,
9
13
  initDb,
10
14
  } from "../be/db";
15
+ import { findUserByExternalId, linkIdentity } from "../be/users";
11
16
  import { GITLAB_BOT_NAME } from "../gitlab/auth";
12
17
  import { handleIssue, handleMergeRequest, handleNote, handlePipeline } from "../gitlab/handlers";
13
18
  import type { IssueEvent, MergeRequestEvent, NoteEvent, PipelineEvent } from "../gitlab/types";
@@ -689,3 +694,290 @@ describe("handlePipeline", () => {
689
694
  expect(result.created).toBe(false);
690
695
  });
691
696
  });
697
+
698
+ // ═══════════════════════════════════════════════════════
699
+ // Identity resolution (step-4)
700
+ // ═══════════════════════════════════════════════════════
701
+
702
+ const UNMAPPED_NS = "integration:unmapped:gitlab";
703
+
704
+ function clearUnmapped(username: string) {
705
+ deleteKv(UNMAPPED_NS, `${username}:meta`);
706
+ deleteKv(UNMAPPED_NS, `${username}:count`);
707
+ }
708
+
709
+ function getUnmappedCount(username: string): number {
710
+ const row = getKv(UNMAPPED_NS, `${username}:count`);
711
+ if (!row) return 0;
712
+ if (row.valueType !== "integer") throw new Error("unexpected valueType");
713
+ return row.value as number;
714
+ }
715
+
716
+ function getUnmappedMeta(username: string): Record<string, unknown> | null {
717
+ const row = getKv(UNMAPPED_NS, `${username}:meta`);
718
+ if (!row) return null;
719
+ return row.value as Record<string, unknown>;
720
+ }
721
+
722
+ function countExternalIds(): number {
723
+ return (
724
+ getDb().prepare<{ c: number }, []>("SELECT COUNT(*) AS c FROM user_external_ids").get()?.c ?? 0
725
+ );
726
+ }
727
+
728
+ describe("identity resolution — MR handler", () => {
729
+ beforeEach(() => {
730
+ clearUnmapped("knownuser");
731
+ clearUnmapped("inlineuser");
732
+ clearUnmapped("ghostuser");
733
+ clearUnmapped("emptyemail");
734
+ });
735
+
736
+ test("known GitLab user → requestedByUserId populated, no unmapped entry", async () => {
737
+ const known = createUser({ name: "Known User" });
738
+ linkIdentity(known.id, "gitlab", "knownuser", { kind: "system", id: "test" });
739
+
740
+ const event = makeMREvent({
741
+ user: { id: 11, name: "Known User", username: "knownuser", avatar_url: "" },
742
+ object_attributes: {
743
+ id: 901,
744
+ iid: 901,
745
+ title: "Known MR",
746
+ description: `@${GITLAB_BOT_NAME} review`,
747
+ state: "opened",
748
+ action: "open",
749
+ source_branch: "feat-known",
750
+ target_branch: "main",
751
+ url: "https://gitlab.com/group/project/-/merge_requests/901",
752
+ last_commit: null,
753
+ author_id: 11,
754
+ },
755
+ });
756
+
757
+ const result = await handleMergeRequest(event);
758
+ expect(result.created).toBe(true);
759
+
760
+ const task = getTaskById(result.taskId!);
761
+ expect(task?.requestedByUserId).toBe(known.id);
762
+
763
+ expect(getUnmappedMeta("knownuser")).toBeNull();
764
+ expect(getUnmappedCount("knownuser")).toBe(0);
765
+ });
766
+
767
+ test("unknown user WITH inline email → auto-create user + link identity, no unmapped entry", async () => {
768
+ const beforeExt = countExternalIds();
769
+ expect(findUserByExternalId("gitlab", "inlineuser")).toBeNull();
770
+
771
+ const event = makeMREvent({
772
+ user: {
773
+ id: 12,
774
+ name: "Inline User",
775
+ username: "inlineuser",
776
+ avatar_url: "",
777
+ email: "inline@example.com",
778
+ },
779
+ object_attributes: {
780
+ id: 902,
781
+ iid: 902,
782
+ title: "Inline-email MR",
783
+ description: `@${GITLAB_BOT_NAME} please`,
784
+ state: "opened",
785
+ action: "open",
786
+ source_branch: "feat-inline",
787
+ target_branch: "main",
788
+ url: "https://gitlab.com/group/project/-/merge_requests/902",
789
+ last_commit: null,
790
+ author_id: 12,
791
+ },
792
+ });
793
+
794
+ const result = await handleMergeRequest(event);
795
+ expect(result.created).toBe(true);
796
+
797
+ const user = findUserByExternalId("gitlab", "inlineuser");
798
+ expect(user).not.toBeNull();
799
+ expect(user?.email).toBe("inline@example.com");
800
+ expect(user?.name).toBe("Inline User");
801
+
802
+ const task = getTaskById(result.taskId!);
803
+ expect(task?.requestedByUserId).toBe(user!.id);
804
+
805
+ // user_external_ids gained exactly one row for this auto-link.
806
+ expect(countExternalIds()).toBe(beforeExt + 1);
807
+
808
+ // No unmapped entry written.
809
+ expect(getUnmappedMeta("inlineuser")).toBeNull();
810
+ expect(getUnmappedCount("inlineuser")).toBe(0);
811
+ });
812
+
813
+ test("unknown user WITHOUT email → unmapped kv rows, requestedByUserId undefined", async () => {
814
+ const event = makeMREvent({
815
+ user: { id: 13, name: "Ghost", username: "ghostuser", avatar_url: "" },
816
+ object_attributes: {
817
+ id: 903,
818
+ iid: 903,
819
+ title: "Ghost MR",
820
+ description: `@${GITLAB_BOT_NAME} ping`,
821
+ state: "opened",
822
+ action: "open",
823
+ source_branch: "feat-ghost",
824
+ target_branch: "main",
825
+ url: "https://gitlab.com/group/project/-/merge_requests/903",
826
+ last_commit: null,
827
+ author_id: 13,
828
+ },
829
+ });
830
+
831
+ const result = await handleMergeRequest(event);
832
+ expect(result.created).toBe(true);
833
+
834
+ const task = getTaskById(result.taskId!);
835
+ expect(task?.requestedByUserId).toBeFalsy();
836
+
837
+ const meta = getUnmappedMeta("ghostuser");
838
+ expect(meta).not.toBeNull();
839
+ expect(meta?.sampleEventType).toBe("merge_request");
840
+ expect(typeof meta?.lastSeenAt).toBe("string");
841
+ expect((meta?.sampleContext as string).startsWith("MR !903:")).toBe(true);
842
+
843
+ expect(getUnmappedCount("ghostuser")).toBe(1);
844
+ });
845
+
846
+ test("repeat unmapped events bump count to 2", async () => {
847
+ for (let i = 0; i < 2; i++) {
848
+ const event = makeMREvent({
849
+ user: { id: 13, name: "Ghost", username: "ghostuser", avatar_url: "" },
850
+ object_attributes: {
851
+ id: 910 + i,
852
+ iid: 910 + i,
853
+ title: `Ghost MR ${i}`,
854
+ description: `@${GITLAB_BOT_NAME} ping`,
855
+ state: "opened",
856
+ action: "open",
857
+ source_branch: `feat-g-${i}`,
858
+ target_branch: "main",
859
+ url: `https://gitlab.com/group/project/-/merge_requests/${910 + i}`,
860
+ last_commit: null,
861
+ author_id: 13,
862
+ },
863
+ });
864
+ await handleMergeRequest(event);
865
+ }
866
+
867
+ expect(getUnmappedCount("ghostuser")).toBe(2);
868
+ });
869
+
870
+ test("empty-string email falls through to unmapped (Q17 manual-verify guard)", async () => {
871
+ const event = makeMREvent({
872
+ user: {
873
+ id: 14,
874
+ name: "Empty Email",
875
+ username: "emptyemail",
876
+ avatar_url: "",
877
+ email: "",
878
+ },
879
+ object_attributes: {
880
+ id: 920,
881
+ iid: 920,
882
+ title: "Empty email MR",
883
+ description: `@${GITLAB_BOT_NAME} review`,
884
+ state: "opened",
885
+ action: "open",
886
+ source_branch: "feat-empty",
887
+ target_branch: "main",
888
+ url: "https://gitlab.com/group/project/-/merge_requests/920",
889
+ last_commit: null,
890
+ author_id: 14,
891
+ },
892
+ });
893
+
894
+ const result = await handleMergeRequest(event);
895
+ expect(result.created).toBe(true);
896
+
897
+ // No auto-link should have occurred — no `user_external_ids` row for 'emptyemail'.
898
+ expect(findUserByExternalId("gitlab", "emptyemail")).toBeNull();
899
+
900
+ // Unmapped kv rows should be present.
901
+ expect(getUnmappedMeta("emptyemail")).not.toBeNull();
902
+ expect(getUnmappedCount("emptyemail")).toBe(1);
903
+ });
904
+ });
905
+
906
+ describe("identity resolution — Issue handler", () => {
907
+ beforeEach(() => {
908
+ clearUnmapped("issueghost");
909
+ });
910
+
911
+ test("unknown user WITHOUT email → unmapped entry tagged 'issue'", async () => {
912
+ const event = makeIssueEvent({
913
+ user: { id: 21, name: "Issue Ghost", username: "issueghost", avatar_url: "" },
914
+ object_attributes: {
915
+ id: 950,
916
+ iid: 950,
917
+ title: "Ghost issue",
918
+ description: `@${GITLAB_BOT_NAME} fix`,
919
+ state: "opened",
920
+ action: "open",
921
+ url: "https://gitlab.com/group/project/-/issues/950",
922
+ author_id: 21,
923
+ },
924
+ });
925
+
926
+ const result = await handleIssue(event);
927
+ expect(result.created).toBe(true);
928
+
929
+ const task = getTaskById(result.taskId!);
930
+ expect(task?.requestedByUserId).toBeFalsy();
931
+
932
+ const meta = getUnmappedMeta("issueghost");
933
+ expect(meta).not.toBeNull();
934
+ expect(meta?.sampleEventType).toBe("issue");
935
+ expect((meta?.sampleContext as string).startsWith("Issue #950:")).toBe(true);
936
+ expect(getUnmappedCount("issueghost")).toBe(1);
937
+ });
938
+ });
939
+
940
+ describe("identity resolution — Note handler", () => {
941
+ beforeEach(() => {
942
+ clearUnmapped("noteghost");
943
+ });
944
+
945
+ test("unknown user WITHOUT email → unmapped entry tagged 'note'", async () => {
946
+ const noteBody = `@${GITLAB_BOT_NAME} can you look at this comment that is somewhat longer than usual to verify the slice(0,100) truncation downstream`;
947
+ const event = makeNoteEvent({
948
+ user: { id: 31, name: "Note Ghost", username: "noteghost", avatar_url: "" },
949
+ object_attributes: {
950
+ id: 960,
951
+ note: noteBody,
952
+ noteable_type: "MergeRequest",
953
+ noteable_id: 100,
954
+ url: "https://gitlab.com/group/project/-/merge_requests/1#note_960",
955
+ author_id: 31,
956
+ type: null,
957
+ },
958
+ merge_request: {
959
+ id: 100,
960
+ iid: 960,
961
+ title: "Note Ghost MR",
962
+ description: "",
963
+ state: "opened",
964
+ action: "open",
965
+ source_branch: "feat-noteghost",
966
+ target_branch: "main",
967
+ url: "https://gitlab.com/group/project/-/merge_requests/960",
968
+ last_commit: null,
969
+ author_id: 31,
970
+ },
971
+ });
972
+
973
+ const result = await handleNote(event);
974
+ expect(result.created).toBe(true);
975
+
976
+ const meta = getUnmappedMeta("noteghost");
977
+ expect(meta).not.toBeNull();
978
+ expect(meta?.sampleEventType).toBe("note");
979
+ expect((meta?.sampleContext as string).length).toBeLessThanOrEqual(100);
980
+ expect((meta?.sampleContext as string).startsWith(`@${GITLAB_BOT_NAME}`)).toBe(true);
981
+ expect(getUnmappedCount("noteghost")).toBe(1);
982
+ });
983
+ });
@@ -1586,10 +1586,14 @@ describe("AgentMail Webhooks (with filters)", () => {
1586
1586
  expect(body).toEqual({ received: true });
1587
1587
  });
1588
1588
 
1589
- test("accepts second allowed inbox domain", async () => {
1590
- const { status } = await postWebhook(
1591
- makePayload({ inboxId: "support@y.xyz", from: "bob@b.com" }),
1592
- );
1589
+ test.each([
1590
+ "message.received",
1591
+ "message.received.unauthenticated",
1592
+ ])("accepts webhook for allowed event type '%s'", async (eventType) => {
1593
+ const { status } = await postWebhook({
1594
+ ...makePayload({ inboxId: "support@y.xyz", from: "bob@b.com" }),
1595
+ event_type: eventType,
1596
+ });
1593
1597
  expect(status).toBe(200);
1594
1598
  });
1595
1599