@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
@@ -412,9 +412,18 @@ export async function handleWebhooks(
412
412
 
413
413
  try {
414
414
  switch (payload.event_type) {
415
+ case "message.received.unauthenticated":
416
+ console.warn(
417
+ `[AgentMail] Received unauthenticated message - treating as received event for inbox ${payload.message?.inbox_id ?? "unknown"}`,
418
+ );
419
+
420
+ await handleMessageReceived(payload);
421
+ break;
422
+
415
423
  case "message.received":
416
424
  await handleMessageReceived(payload);
417
425
  break;
426
+
418
427
  default:
419
428
  console.log(`[AgentMail] Ignoring event type: ${payload.event_type}`);
420
429
  }
@@ -341,24 +341,11 @@ export async function handleWorkflows(
341
341
  }
342
342
  const rawBody = Buffer.concat(chunks).toString();
343
343
 
344
- // Validate JSON before processing (but pass raw string for HMAC)
345
- try {
346
- if (rawBody) JSON.parse(rawBody);
347
- } catch {
348
- jsonError(res, "Invalid JSON body", 400);
349
- return true;
350
- }
351
-
352
- const signature =
353
- (req.headers["x-hub-signature-256"] as string | undefined) ??
354
- (req.headers["x-signature"] as string | undefined);
355
-
356
344
  try {
357
345
  const result = await handleWebhookTrigger(
358
346
  workflowId,
359
- rawBody, // Raw body string — used for HMAC verification + passed as triggerData
360
- signature,
361
- signature,
347
+ rawBody, // Raw body string — HMAC is verified against raw bytes; JSON parsing happens inside
348
+ req.headers, // Full header bag — signature header resolved per trigger config
362
349
  getExecutorRegistry(),
363
350
  );
364
351
  json(res, result, 201);
@@ -1,6 +1,10 @@
1
+ import { upsertKv } from "../be/db";
1
2
  import { getOAuthApp } from "../be/db-queries/oauth";
2
3
  import { buildAuthorizationUrl, exchangeCode, type OAuthProviderConfig } from "../oauth/wrapper";
3
4
 
5
+ /** kv namespace for the Linear bot's appUserId (Q21.C). Keyed by workspace ID. */
6
+ const APP_USER_ID_NAMESPACE = "integration:linear:bot-app-user-id";
7
+
4
8
  export function getLinearOAuthConfig(): OAuthProviderConfig | null {
5
9
  const app = getOAuthApp("linear");
6
10
  if (!app) return null;
@@ -31,7 +35,63 @@ export async function handleLinearCallback(
31
35
  ): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number; scope?: string }> {
32
36
  const config = getLinearOAuthConfig();
33
37
  if (!config) throw new Error("Linear OAuth not configured");
34
- return exchangeCode(config, code, state);
38
+ const result = await exchangeCode(config, code, state);
39
+
40
+ // Best-effort: capture the Linear bot's appUserId now that we have a fresh
41
+ // token (Q21.C). Persisted in kv_entries so webhook handlers can guard
42
+ // against the swarm hearing itself ("creator.id === storedAppUserId").
43
+ // Failure here is non-fatal — the guard is a no-op until the next refresh.
44
+ captureLinearAppUserId(result.accessToken).catch((err) => {
45
+ console.warn(
46
+ "[Linear] Failed to capture appUserId during OAuth completion (non-fatal):",
47
+ err instanceof Error ? err.message : err,
48
+ );
49
+ });
50
+
51
+ return result;
52
+ }
53
+
54
+ /**
55
+ * Query Linear's GraphQL `viewer` to find the OAuth-actor's user id and
56
+ * organization id, then persist it under `integration:linear:bot-app-user-id`.
57
+ *
58
+ * NOTE: when the OAuth app is installed with `actor=app`, Linear's `viewer`
59
+ * resolves to the synthetic app-user — the "bot identity" that emits
60
+ * AgentSessionEvent.created with `agentSession.creator.id === viewer.id`. That
61
+ * is precisely the value Q21.C asks us to compare against.
62
+ */
63
+ export async function captureLinearAppUserId(accessToken: string): Promise<void> {
64
+ const query = `query { viewer { id organization { id } } }`;
65
+ const res = await fetch("https://api.linear.app/graphql", {
66
+ method: "POST",
67
+ headers: {
68
+ "Content-Type": "application/json",
69
+ Authorization: `Bearer ${accessToken}`,
70
+ },
71
+ body: JSON.stringify({ query }),
72
+ });
73
+ if (!res.ok) {
74
+ throw new Error(`Linear viewer query failed: HTTP ${res.status}`);
75
+ }
76
+ const body = (await res.json()) as {
77
+ data?: { viewer?: { id?: string; organization?: { id?: string } } };
78
+ errors?: Array<{ message: string }>;
79
+ };
80
+ if (body.errors?.length) {
81
+ throw new Error(`Linear viewer query returned errors: ${body.errors[0]?.message ?? "?"}`);
82
+ }
83
+ const appUserId = body.data?.viewer?.id;
84
+ const workspaceId = body.data?.viewer?.organization?.id;
85
+ if (!appUserId) {
86
+ throw new Error("Linear viewer query returned no id");
87
+ }
88
+ upsertKv({
89
+ namespace: APP_USER_ID_NAMESPACE,
90
+ key: workspaceId && workspaceId !== "" ? workspaceId : "default",
91
+ value: appUserId,
92
+ valueType: "string",
93
+ expiresAt: null,
94
+ });
35
95
  }
36
96
 
37
97
  /**
@@ -1,4 +1,4 @@
1
- import { cancelTask, getAllAgents, getTaskById, resolveUser } from "../be/db";
1
+ import { cancelTask, getAllAgents, getKv, getTaskById, incrKv, upsertKv } from "../be/db";
2
2
  import { getOAuthTokens } from "../be/db-queries/oauth";
3
3
  import {
4
4
  createTrackerSync,
@@ -6,6 +6,7 @@ import {
6
6
  getTrackerSyncByExternalId,
7
7
  updateTrackerSync,
8
8
  } from "../be/db-queries/tracker";
9
+ import { findOrCreateUserByEmail, findUserByExternalId, linkIdentity } from "../be/users";
9
10
  import { ensureToken } from "../oauth/ensure-token";
10
11
  import { resolveTemplate } from "../prompts/resolver";
11
12
  import { linearContextKey } from "../tasks/context-key";
@@ -343,6 +344,100 @@ function findLeadAgent() {
343
344
  return agents.find((a) => a.isLead) ?? null;
344
345
  }
345
346
 
347
+ // ─── Identity resolution (Q21.A / Q21.C / Q17.B) ──────────────────────────
348
+ //
349
+ // Linear app config is currently agent-session-events-only — only
350
+ // AgentSessionEvent.created/prompted arrive. If subscriptions widen to
351
+ // Issue/Comment events later, handle system-actor case (per Q21.B / Q22).
352
+ // Identity primitives in src/be/users.ts are event-type-agnostic; only the
353
+ // extraction shape changes.
354
+
355
+ const UNMAPPED_NAMESPACE = "integration:unmapped:linear";
356
+ const UNMAPPED_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
357
+ const LINEAR_WEBHOOK_ACTOR = { kind: "system", id: "webhook:linear" } as const;
358
+
359
+ /** kv namespace for the Linear bot's appUserId (Q21.C). Keyed by workspace ID. */
360
+ const APP_USER_ID_NAMESPACE = "integration:linear:bot-app-user-id";
361
+
362
+ /**
363
+ * Read the bot's persisted appUserId for the given workspace (or the default
364
+ * slot). Returns null when not yet captured (early OAuth installs).
365
+ */
366
+ function getStoredAppUserId(workspaceId: string | null): string | null {
367
+ const key = workspaceId && workspaceId !== "" ? workspaceId : "default";
368
+ const entry = getKv(APP_USER_ID_NAMESPACE, key);
369
+ if (!entry) return null;
370
+ return typeof entry.value === "string" ? entry.value : null;
371
+ }
372
+
373
+ /**
374
+ * Cascade per Q17.B:
375
+ * 1. `findUserByExternalId('linear', linearUserId)` (fast path).
376
+ * 2. On miss + email present → `findOrCreateUserByEmail(email, {name})` then
377
+ * `linkIdentity(...)`.
378
+ * 3. Else record unmapped kv entries for operator triage.
379
+ *
380
+ * Q21.C bot-self-link guard: if `linearUserId === storedAppUserId` (the swarm
381
+ * hearing itself), short-circuit — no users row, no unmapped entry.
382
+ *
383
+ * Returns `undefined` when no mapping could be established — callers pass
384
+ * that straight to `requestedByUserId`.
385
+ */
386
+ function resolveLinearActor(
387
+ linearUserId: string,
388
+ email: string,
389
+ name: string,
390
+ workspaceId: string | null,
391
+ sampleEventType: string,
392
+ sampleContext: string | null,
393
+ ): string | undefined {
394
+ if (!linearUserId) {
395
+ // No identifier — nothing to map. We don't even know what to track as
396
+ // unmapped, so just return undefined.
397
+ return undefined;
398
+ }
399
+
400
+ // Q21.C bot-self-link guard.
401
+ const storedAppUserId = getStoredAppUserId(workspaceId);
402
+ if (storedAppUserId && linearUserId === storedAppUserId) {
403
+ return undefined;
404
+ }
405
+ if (!storedAppUserId) {
406
+ // Non-fatal: log once per call. Real guard re-engages after the next
407
+ // OAuth refresh or admin capture step persists the appUserId.
408
+ console.warn("[linear] appUserId not yet stored; bot-self-link guard disabled");
409
+ }
410
+
411
+ const existing = findUserByExternalId("linear", linearUserId);
412
+ if (existing) return existing.id;
413
+
414
+ const trimmedEmail = typeof email === "string" ? email.trim() : "";
415
+ if (trimmedEmail !== "") {
416
+ const { user: linked } = findOrCreateUserByEmail(
417
+ trimmedEmail,
418
+ { name: name?.trim() || undefined },
419
+ LINEAR_WEBHOOK_ACTOR,
420
+ );
421
+ linkIdentity(linked.id, "linear", linearUserId, LINEAR_WEBHOOK_ACTOR);
422
+ return linked.id;
423
+ }
424
+
425
+ // No mapping + no inline email → unmapped tracker (Q14/Q17.D).
426
+ upsertKv({
427
+ namespace: UNMAPPED_NAMESPACE,
428
+ key: `${linearUserId}:meta`,
429
+ value: {
430
+ lastSeenAt: new Date().toISOString(),
431
+ sampleEventType,
432
+ sampleContext: sampleContext ? sampleContext.slice(0, 100) : null,
433
+ },
434
+ valueType: "json",
435
+ expiresAt: Date.now() + UNMAPPED_TTL_MS,
436
+ });
437
+ incrKv(UNMAPPED_NAMESPACE, `${linearUserId}:count`, 1);
438
+ return undefined;
439
+ }
440
+
346
441
  /**
347
442
  * Handle AgentSession events from Linear.
348
443
  * These are fired when an issue is assigned to the Linear agent integration,
@@ -375,16 +470,26 @@ export async function handleAgentSessionEvent(event: Record<string, unknown>): P
375
470
  return;
376
471
  }
377
472
 
378
- // Extract actor identity from Linear webhook payload
379
- const actor = event.actor as Record<string, unknown> | undefined;
380
- const actorLinearId = actor ? String(actor.id ?? "") : "";
381
- const actorName = actor ? String(actor.name ?? "") : "";
382
- const actorEmail = actor ? String(actor.email ?? "") : "";
383
- const requestedByUserId = resolveUser({
384
- linearUserId: actorLinearId || undefined,
385
- email: actorEmail || undefined,
386
- name: actorName || undefined,
387
- })?.id;
473
+ // Extract actor identity from Linear webhook payload (Q21.A bug fix).
474
+ // The old code read a top-level `actor` field that does NOT exist in
475
+ // AgentSessionEvent payloads. The human is at agentSession.creator for
476
+ // the `created` action and agentActivity.user for `prompted`.
477
+ const session = event.agentSession as Record<string, unknown> | undefined;
478
+ const creator = session?.creator as Record<string, unknown> | undefined;
479
+ const linearUserId = creator ? String(creator.id ?? "") : "";
480
+ const actorEmail = creator ? String(creator.email ?? "") : "";
481
+ const actorName = creator ? String(creator.name ?? "") : "";
482
+ const workspaceId = (event.organizationId ?? event.workspaceId) as string | undefined;
483
+ const sampleContext =
484
+ (session?.comment as { body?: string } | undefined)?.body ?? issueTitle ?? null;
485
+ const requestedByUserId = resolveLinearActor(
486
+ linearUserId,
487
+ actorEmail,
488
+ actorName,
489
+ workspaceId ?? null,
490
+ "AgentSessionEvent.created",
491
+ sampleContext,
492
+ );
388
493
 
389
494
  // Check if we already track this issue
390
495
  const existing = getTrackerSyncByExternalId("linear", "task", issueId);
@@ -687,16 +792,24 @@ export async function handleAgentSessionPrompted(event: Record<string, unknown>)
687
792
  // Task is completed/failed/cancelled or doesn't exist — create a new follow-up task
688
793
  const lead = findLeadAgent();
689
794
 
690
- // Extract actor identity from Linear webhook payload
691
- const promptedActor = event.actor as Record<string, unknown> | undefined;
692
- const promptedActorLinearId = promptedActor ? String(promptedActor.id ?? "") : "";
693
- const promptedActorEmail = promptedActor ? String(promptedActor.email ?? "") : "";
694
- const promptedActorName = promptedActor ? String(promptedActor.name ?? "") : "";
695
- const promptedRequestedByUserId = resolveUser({
696
- linearUserId: promptedActorLinearId || undefined,
697
- email: promptedActorEmail || undefined,
698
- name: promptedActorName || undefined,
699
- })?.id;
795
+ // Extract actor identity from Linear webhook payload (Q21.A bug fix).
796
+ // For `prompted` action, the human is at `event.agentActivity.user`.
797
+ const activity = event.agentActivity as Record<string, unknown> | undefined;
798
+ const promptUser = activity?.user as Record<string, unknown> | undefined;
799
+ const promptedActorLinearId = promptUser ? String(promptUser.id ?? "") : "";
800
+ const promptedActorEmail = promptUser ? String(promptUser.email ?? "") : "";
801
+ const promptedActorName = promptUser ? String(promptUser.name ?? "") : "";
802
+ const promptedWorkspaceId = (event.organizationId ?? event.workspaceId) as string | undefined;
803
+ const promptedSampleContext =
804
+ (activity?.content as { body?: string } | undefined)?.body ?? userMessage ?? null;
805
+ const promptedRequestedByUserId = resolveLinearActor(
806
+ promptedActorLinearId,
807
+ promptedActorEmail,
808
+ promptedActorName,
809
+ promptedWorkspaceId ?? null,
810
+ "AgentSessionEvent.prompted",
811
+ promptedSampleContext,
812
+ );
700
813
 
701
814
  const followupResult = resolveTemplate("linear.issue.followup", {
702
815
  issue_identifier: issueIdentifier,
@@ -1,8 +1,9 @@
1
1
  import type { App } from "@slack/bolt";
2
- import { cancelTask, getAgentById, getLeadAgent, getTaskById, resolveUser } from "../be/db";
2
+ import { cancelTask, getAgentById, getLeadAgent, getTaskById } from "../be/db";
3
3
  import { slackContextKey } from "../tasks/context-key";
4
4
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
5
5
  import { buildCancelledBlocks, getTaskLink } from "./blocks";
6
+ import { resolveSlackUserId } from "./enrich";
6
7
 
7
8
  export function registerActionHandlers(app: App): void {
8
9
  // "View Full Logs" — URL button, just ack (Slack opens the link automatically)
@@ -67,7 +68,12 @@ export function registerActionHandlers(app: App): void {
67
68
  if (!originalTask || !originalTask.slackChannelId) return;
68
69
 
69
70
  const lead = getLeadAgent();
70
- const requestedByUserId = resolveUser({ slackUserId: body.user.id })?.id;
71
+ // Resolve via the shared cascade. Sample context = the modal callback ID
72
+ // so operators can see *which* modal-submit triggered an unmapped entry.
73
+ const requestedByUserId = await resolveSlackUserId(client, body.user.id, {
74
+ sampleEventType: "view_submission",
75
+ sampleContext: view.callback_id || "follow_up_submit",
76
+ });
71
77
  const followUpTask = createTaskWithSiblingAwareness(followUpText, {
72
78
  agentId: lead?.id,
73
79
  source: "slack",
@@ -1,13 +1,9 @@
1
1
  import { Assistant } from "@slack/bolt";
2
- import {
3
- getAgentWorkingOnThread,
4
- getLeadAgent,
5
- getMostRecentTaskInThread,
6
- resolveUser,
7
- } from "../be/db";
2
+ import { getAgentWorkingOnThread, getLeadAgent, getMostRecentTaskInThread } from "../be/db";
8
3
  import { resolveTemplate } from "../prompts/resolver";
9
4
  import { slackContextKey } from "../tasks/context-key";
10
5
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
6
+ import { resolveSlackUserId } from "./enrich";
11
7
  import { wasEventSeen } from "./event-dedup";
12
8
  import { bufferThreadMessage } from "./thread-buffer";
13
9
  // Side-effect import: registers all Slack event templates in the in-memory registry
@@ -41,7 +37,7 @@ export function createAssistant(): Assistant {
41
37
  await saveThreadContext();
42
38
  },
43
39
 
44
- userMessage: async ({ message, body, say, setStatus, setTitle, getThreadContext }) => {
40
+ userMessage: async ({ message, body, say, setStatus, setTitle, getThreadContext, client }) => {
45
41
  // Slack retries deliveries on 3s timeout / 5xx. Drop duplicates before
46
42
  // any task-creation work runs (DES-293).
47
43
  const eventId = body?.event_id;
@@ -76,8 +72,15 @@ export function createAssistant(): Assistant {
76
72
  const messageText = (msg.text as string) || "";
77
73
  const userId = (msg.user as string) || "";
78
74
 
79
- // Resolve canonical user identity (graceful null if not found)
80
- const requestedByUserId = userId ? resolveUser({ slackUserId: userId })?.id : undefined;
75
+ // Resolve canonical user identity via the shared cascade. On no-email,
76
+ // the cascade records the user in the kv unmapped tracker; this handler
77
+ // proceeds without a `requestedByUserId`.
78
+ const requestedByUserId = userId
79
+ ? await resolveSlackUserId(client, userId, {
80
+ sampleEventType: "assistant_message",
81
+ sampleContext: messageText,
82
+ })
83
+ : undefined;
81
84
 
82
85
  // 1. Check if an agent is already working in this thread
83
86
  const workingAgent = getAgentWorkingOnThread(channelId, threadTs);
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Slack-side identity enrichment + resolution cascade.
3
+ *
4
+ * Two helpers — together they replace the in-process email-lookup Map that
5
+ * previously lived in `src/slack/handlers.ts` (Q17.E):
6
+ *
7
+ * * `enrichSlackUserEmail(slackUserId)` — kv-backed cache of the user's
8
+ * email + display name from `client.users.info`. 24h TTL on success;
9
+ * failures are NEVER cached so rate-limit recovery just works on retry.
10
+ *
11
+ * * `resolveSlackUserId(client, slackUserId, ctx)` — the three-step cascade
12
+ * each Slack webhook entry point uses to map a Slack user to a canonical
13
+ * `users.id`:
14
+ *
15
+ * 1. `findUserByExternalId('slack', slackUserId)` — fast path.
16
+ * 2. On miss: enrich → `findOrCreateUserByEmail` + `linkIdentity`.
17
+ * 3. On no-email: record into the kv unmapped tracker. Returns
18
+ * `undefined` — task creation proceeds without a `requestedByUserId`.
19
+ *
20
+ * Both helpers are API-side (live under `src/slack/` which is API-only) — they
21
+ * may import from `src/be/`.
22
+ */
23
+
24
+ import type { WebClient } from "@slack/web-api";
25
+ import { getKv, upsertKv } from "../be/db";
26
+ import { recordUnmappedIdentity } from "../be/unmapped-identities";
27
+ import {
28
+ findOrCreateUserByEmail,
29
+ findUserByExternalId,
30
+ type IdentityActor,
31
+ linkIdentity,
32
+ } from "../be/users";
33
+
34
+ const ENRICHMENT_NAMESPACE = "integration:user-enrichment:slack";
35
+ const ENRICHMENT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
36
+
37
+ /**
38
+ * Persisted enrichment payload. `email: null` cases are NEVER stored — see
39
+ * Q17.E. We keep `name` so future People-page renders don't need to refetch.
40
+ */
41
+ interface EnrichedSlackUser {
42
+ email: string;
43
+ name: string | null;
44
+ fetchedAt: string;
45
+ }
46
+
47
+ /**
48
+ * Fetch a Slack user's email, caching successful results for 24h in the
49
+ * `integration:user-enrichment:slack` kv namespace.
50
+ *
51
+ * Returns `null` (without caching) when:
52
+ * * `client.users.info` throws (network / 5xx / rate-limit).
53
+ * * The profile is missing `profile.email` (bot accounts, restricted users).
54
+ *
55
+ * Caching null would defeat retry-on-recovery, so we intentionally skip the
56
+ * cache write on every failure path.
57
+ */
58
+ export async function enrichSlackUserEmail(
59
+ client: WebClient,
60
+ slackUserId: string,
61
+ ): Promise<string | null> {
62
+ // Cache hit — return the persisted email straight through.
63
+ const cached = getKv(ENRICHMENT_NAMESPACE, slackUserId);
64
+ if (cached !== null) {
65
+ const payload = cached.value as EnrichedSlackUser;
66
+ if (payload?.email) {
67
+ return payload.email;
68
+ }
69
+ // Defensive: if a stale row landed without email somehow, fall through to
70
+ // refetch rather than handing back `null` from cache.
71
+ }
72
+
73
+ // Cache miss — hit the Slack API.
74
+ let email: string | null = null;
75
+ let name: string | null = null;
76
+ try {
77
+ const result = await client.users.info({ user: slackUserId });
78
+ email = result.user?.profile?.email ?? null;
79
+ name = result.user?.profile?.real_name ?? result.user?.real_name ?? null;
80
+ } catch (error) {
81
+ console.error(`[Slack] enrichSlackUserEmail failed for ${slackUserId}:`, error);
82
+ return null;
83
+ }
84
+
85
+ if (!email) {
86
+ // Q17.E: do NOT cache the no-email case.
87
+ return null;
88
+ }
89
+
90
+ upsertKv({
91
+ namespace: ENRICHMENT_NAMESPACE,
92
+ key: slackUserId,
93
+ value: {
94
+ email,
95
+ name,
96
+ fetchedAt: new Date().toISOString(),
97
+ } satisfies EnrichedSlackUser,
98
+ valueType: "json",
99
+ expiresAt: Date.now() + ENRICHMENT_TTL_MS,
100
+ });
101
+
102
+ return email;
103
+ }
104
+
105
+ /** Audit-trail actor for the auto-link cascade. */
106
+ const SLACK_WEBHOOK_ACTOR: IdentityActor = { kind: "system", id: "webhook:slack" };
107
+
108
+ /**
109
+ * Three-step cascade used by every Slack webhook entry point to map a Slack
110
+ * user ID to a canonical `users.id`:
111
+ *
112
+ * 1. Look up the existing `(slack, <userId>)` mapping in `user_external_ids`.
113
+ * 2. If missing, enrich the Slack profile to extract an email; if found,
114
+ * auto-link via `findOrCreateUserByEmail` + `linkIdentity`. Emits an
115
+ * `auto_merge` (existing user) or `identity_added` (new user) event,
116
+ * followed by an `identity_added` event for the Slack alias itself.
117
+ * 3. If no email is recoverable, record into the kv unmapped tracker so the
118
+ * operator can triage manually. Returns `undefined` — task creation
119
+ * proceeds without `requestedByUserId`.
120
+ *
121
+ * `eventContext` shapes the sample written to the unmapped tracker — the
122
+ * sample is truncated to 100 chars inside `recordUnmappedIdentity`.
123
+ */
124
+ export async function resolveSlackUserId(
125
+ client: WebClient,
126
+ slackUserId: string,
127
+ eventContext: { sampleEventType: string; sampleContext: string },
128
+ ): Promise<string | undefined> {
129
+ // 1. Fast path — existing alias.
130
+ const existing = findUserByExternalId("slack", slackUserId);
131
+ if (existing) return existing.id;
132
+
133
+ // 2. Enrich → auto-link by email.
134
+ const email = await enrichSlackUserEmail(client, slackUserId);
135
+ if (email) {
136
+ // Pull the cached name back out for the user-row hints. The kv read is
137
+ // cheap (single primary-key lookup) and avoids a second `users.info`.
138
+ const cached = getKv(ENRICHMENT_NAMESPACE, slackUserId);
139
+ const name = (cached?.value as EnrichedSlackUser | undefined)?.name ?? undefined;
140
+
141
+ const { user } = findOrCreateUserByEmail(email, { name }, SLACK_WEBHOOK_ACTOR);
142
+
143
+ // Link the Slack identity to whichever user we resolved to. PK collision
144
+ // on `(slack, <id>)` shouldn't happen — we just confirmed no existing
145
+ // mapping in step 1 — but guard defensively so a race doesn't 500 the
146
+ // webhook.
147
+ try {
148
+ linkIdentity(user.id, "slack", slackUserId, SLACK_WEBHOOK_ACTOR);
149
+ } catch (error) {
150
+ console.warn(
151
+ `[Slack] linkIdentity('slack', ${slackUserId}) failed — likely a concurrent enroll`,
152
+ error,
153
+ );
154
+ }
155
+
156
+ return user.id;
157
+ }
158
+
159
+ // 3. No email — track as unmapped.
160
+ recordUnmappedIdentity("slack", slackUserId, eventContext);
161
+ return undefined;
162
+ }
@@ -6,13 +6,13 @@ import {
6
6
  getLeadAgent,
7
7
  getMostRecentTaskInThread,
8
8
  getTasksByAgentId,
9
- resolveUser,
10
9
  } from "../be/db";
11
10
  import { resolveTemplate } from "../prompts/resolver";
12
11
  import { slackContextKey } from "../tasks/context-key";
13
12
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
14
13
  import { workflowEventBus } from "../workflows/event-bus";
15
14
  import { buildTreeBlocks, type TreeNode } from "./blocks";
15
+ import { enrichSlackUserEmail, resolveSlackUserId } from "./enrich";
16
16
  import { wasEventSeen } from "./event-dedup";
17
17
  import type { SlackFile } from "./files";
18
18
  import { extractTaskFromMessage, hasOtherUserMention, routeMessage } from "./router";
@@ -34,9 +34,6 @@ const allowedUserIds = (process.env.SLACK_ALLOWED_USER_IDS || "")
34
34
 
35
35
  const filteringEnabled = allowedEmailDomains.length > 0 || allowedUserIds.length > 0;
36
36
 
37
- // Cache for user email lookups (to avoid repeated API calls)
38
- const userEmailCache = new Map<string, string | null>();
39
-
40
37
  /**
41
38
  * Configuration for user filtering.
42
39
  */
@@ -110,19 +107,8 @@ async function isUserAllowed(client: WebClient, userId: string): Promise<boolean
110
107
  return false;
111
108
  }
112
109
 
113
- // Check email domain
114
- let email = userEmailCache.get(userId);
115
- if (email === undefined) {
116
- try {
117
- const result = await client.users.info({ user: userId });
118
- email = result.user?.profile?.email || null;
119
- userEmailCache.set(userId, email);
120
- } catch (error) {
121
- console.error(`[Slack] Failed to fetch user email for ${userId}:`, error);
122
- userEmailCache.set(userId, null);
123
- email = null;
124
- }
125
- }
110
+ // Check email domain — uses kv-backed enrichment (24h TTL, only success cached).
111
+ const email = await enrichSlackUserEmail(client, userId);
126
112
 
127
113
  if (!email) {
128
114
  console.log(`[Slack] User ${userId} has no email, denying access`);
@@ -391,8 +377,14 @@ export function registerMessageHandler(app: App): void {
391
377
  return;
392
378
  }
393
379
 
394
- // Resolve canonical user identity (graceful null if not found)
395
- const requestedByUserId = resolveUser({ slackUserId: msg.user })?.id;
380
+ // Resolve canonical user identity via the three-step cascade:
381
+ // fast-path alias lookup enrich+auto-link by email → unmapped tracker.
382
+ // Returns undefined when no email is recoverable; task creation proceeds
383
+ // without `requestedByUserId` and the operator triages from the Unmapped tab.
384
+ const requestedByUserId = await resolveSlackUserId(client, msg.user, {
385
+ sampleEventType: "message",
386
+ sampleContext: msg.text ?? "",
387
+ });
396
388
 
397
389
  // Emit workflow trigger event for Slack messages
398
390
  workflowEventBus.emit("slack.message", {