@agent-team-foundation/first-tree-hub 0.12.9 → 0.12.10

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.
@@ -1,11 +1,11 @@
1
1
  import { a as __toCommonJS, o as __toESM, t as __commonJSMin } from "./chunk-BSw8zbkd.mjs";
2
2
  import { A as FIRST_TREE_HUB_ATTR, C as stampOrgScope, D as untrustedAttrs, E as startWsConnectionSpan, M as require_pino, O as withSpan, S as stampChatResource, _ as rootLogger$1, a as buildRateLimitError, c as currentTraceId, g as reportErrorToRoot, i as bodyCaptureOnSendHook, j as redactUrl, k as withWsMessageSpan, l as decodeJwtForTrace, m as observabilityPlugin, n as applyLoggerConfig, o as classifyJoseError, r as attachRequestContext, s as createLogger$1, t as adapterAttrs, u as endWsConnectionSpan, x as stampAgentResource, y as setWsConnectionAttrs } from "./observability-BAScT_5S-BcW9HgkG.mjs";
3
- import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-BCZC1ki6.mjs";
3
+ import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-CW73oEYn.mjs";
4
4
  import { a as print, i as blank, n as CLI_USER_AGENT, r as COMMAND_VERSION, s as status, t as cliFetch } from "./cli-fetch--tiwKm5S.mjs";
5
- import { $ as patchOnboardingSchema, A as defaultRuntimeConfigPayload, B as inboxDeliverFrameSchema$1, C as createAdapterMappingSchema, Ct as updateOrganizationSchema, D as createMemberSchema, E as createMeChatSchema, F as githubCallbackQuerySchema, G as joinByInvitationSchema, H as isOrgSettingNamespace, I as githubDevCallbackQuerySchema, J as messageSourceSchema$1, K as listMeChatsQuerySchema, L as githubStartQuerySchema, M as dryRunAgentRuntimeConfigSchema, O as createOrgFromMeSchema, P as githubAppInstallationClaimBodySchema, Q as patchChatEngagementSchema, R as imageInlineContentSchema, S as createAdapterConfigSchema, St as updateMemberSchema, T as createChatSchema, U as isRedactedEnvValue, V as inboxPollQuerySchema, W as isReservedAgentName$1, X as onboardingEventSchema, Y as notificationQuerySchema, Z as paginationQuerySchema, _ as chatMetadataSchema$1, _t as updateAdapterConfigSchema, a as AGENT_VISIBILITY, at as safeRedirectPath, b as connectTokenExchangeSchema, bt as updateChatSchema, c as MENTION_REGEX, ct as sendMessageSchema, d as addMeChatParticipantsSchema, dt as sessionEventMessageSchema, f as addParticipantSchema, ft as sessionEventSchema$1, g as agentTypeSchema$1, gt as submitQuestionAnswerSchema, h as agentRuntimeConfigPayloadSchema$1, ht as stripCode, i as AGENT_STATUSES, it as runtimeStateMessageSchema, j as delegateFeishuUserSchema, l as ORG_SETTINGS_NAMESPACES$1, lt as sendToAgentSchema, m as agentPinnedMessageSchema$1, mt as sessionStateMessageSchema, n as AGENT_NAME_REGEX$1, nt as rebindAgentSchema, o as CHAT_ENGAGEMENT_STATUSES, p as agentBindRequestSchema, pt as sessionReconcileRequestSchema, q as loginSchema, r as AGENT_SELECTOR_HEADER$1, rt as refreshTokenSchema, st as selfServiceFeishuBotSchema, t as AGENT_BIND_REJECT_REASONS, u as WS_AUTH_FRAME_TIMEOUT_MS, ut as sessionCompletionMessageSchema, vt as updateAgentRuntimeConfigSchema, w as createAgentSchema, wt as wsAuthFrameSchema, x as contextTreeSnapshotSchema, xt as updateClientCapabilitiesSchema, y as clientRegisterSchema, yt as updateAgentSchema, z as inboxAckFrameSchema } from "./dist-CnjqakXS.mjs";
5
+ import { $ as onboardingEventSchema, A as createOrgFromMeSchema, B as githubStartQuerySchema, C as contextTreeSnapshotSchema, Ct as updateClientCapabilitiesSchema, D as createChatSchema, E as createAgentSchema, Et as wsAuthFrameSchema, G as isOrgSettingNamespace, H as inboxAckFrameSchema, I as githubAppInstallationClaimBodySchema, J as joinByInvitationSchema, K as isRedactedEnvValue, L as githubAppInstallationPermissionsSchema$1, M as defaultRuntimeConfigPayload, N as delegateFeishuUserSchema, O as createMeChatSchema, P as dryRunAgentRuntimeConfigSchema, Q as notificationQuerySchema, R as githubCallbackQuerySchema, S as connectTokenExchangeSchema, St as updateChatSchema, T as createAdapterMappingSchema, Tt as updateOrganizationSchema, U as inboxDeliverFrameSchema$1, V as imageInlineContentSchema, W as inboxPollQuerySchema, X as loginSchema, Y as listMeChatsQuerySchema, Z as messageSourceSchema$1, _ as agentRuntimeConfigPayloadSchema$1, _t as stripCode, a as AGENT_TYPES, at as rebindAgentSchema, bt as updateAgentRuntimeConfigSchema, ct as safeRedirectPath, d as ORG_SETTINGS_NAMESPACES$1, dt as sendMessageSchema, et as paginationQuerySchema, f as WS_AUTH_FRAME_TIMEOUT_MS, ft as sendToAgentSchema, g as agentPinnedMessageSchema$1, gt as sessionStateMessageSchema, h as agentBindRequestSchema, ht as sessionReconcileRequestSchema, i as AGENT_STATUSES, k as createMemberSchema, l as MENTION_REGEX, m as addParticipantSchema, mt as sessionEventSchema$1, n as AGENT_NAME_REGEX$1, nt as patchOnboardingSchema, o as AGENT_VISIBILITY, ot as refreshTokenSchema, p as addMeChatParticipantsSchema, pt as sessionEventMessageSchema, q as isReservedAgentName$1, r as AGENT_SELECTOR_HEADER$1, s as CHAT_ENGAGEMENT_STATUSES, st as runtimeStateMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as patchChatEngagementSchema, u as NOTIFICATION_TYPES, ut as selfServiceFeishuBotSchema, v as agentTypeSchema$1, vt as submitQuestionAnswerSchema, w as createAdapterConfigSchema, wt as updateMemberSchema, x as clientRegisterSchema, xt as updateAgentSchema, y as chatMetadataSchema$1, yt as updateAdapterConfigSchema, z as githubDevCallbackQuerySchema } from "./dist-B1GHzMLc.mjs";
6
6
  import { a as ConflictError, c as UnauthorizedError, i as ClientUserMismatchError$1, l as organizations, n as BadRequestError, o as ForbiddenError, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as users } from "./errors-CF5evtJt-B0NTIVPt.mjs";
7
7
  import { n as init_esm, r as trace, t as esm_exports } from "./esm-iadMkGbV.mjs";
8
- import { $ as notifyRecipients, A as getPresence, B as listAgentsManagedByUser, C as ensureParticipant, D as getChatDetail, E as getCachedAudience, F as joinAsParticipant, G as listClients, H as listChatParticipantsWithNames, I as joinChat, K as listClientsForOrgAdmin, L as leaveAsParticipant, M as heartbeatInstance, N as inboxEntries, O as getClient, P as invalidateChatAudience, Q as messages, R as leaveChat, S as ensureCanJoin, T as getActivityOverview, U as listChats, V as listAgentsWithRuntime, W as listChatsForMember, X as markSupersededByChat, Y as markStaleAgents, Z as members, _ as createChat, _t as unbindAgent, a as agentVisibilityCondition, at as registerClient, b as disconnectClient, c as assertParticipant, ct as resolveChatMembership, d as chatMembership, dt as sendToAgent$1, et as pendingQuestions, f as chats, ft as serverInstances, g as clients, gt as touchAgent, h as cleanupStalePresence, ht as submitAnswer, i as agentPresence, it as registerChatMessageDispatcher, j as heartbeatClient, k as getOnlineCount, l as bindAgent, lt as retireClient, m as cleanupStaleClients, mt as setRuntimeState, n as addParticipant, nt as recomputeWatchersForAgent, o as agents, ot as removeParticipant, p as claimClient, pt as setOffline, q as listMessages, r as agentChatSessions, rt as recomputeWatchersForMember, s as assertClientOwner, st as resetActivity, t as addChatParticipants, tt as recomputeChatWatchers, u as changeChatType, ut as sendMessage, v as createNotifier, vt as updateClientCapabilities, w as findOrCreateDirectChat, x as editMessage, y as deriveAuthState, yt as upsertSessionState, z as listActiveAgentsPinnedToClient } from "./client-OMwJMCQt-R1T06ZH6.mjs";
8
+ import { $ as notifyRecipients, A as getPresence, B as listAgentsManagedByUser, C as ensureParticipant, D as getChatDetail, E as getCachedAudience, F as joinAsParticipant, G as listClients, H as listChatParticipantsWithNames, I as joinChat, K as listClientsForOrgAdmin, L as leaveAsParticipant, M as heartbeatInstance, N as inboxEntries, O as getClient, P as invalidateChatAudience, Q as messages, R as leaveChat, S as ensureCanJoin, T as getActivityOverview, U as listChats, V as listAgentsWithRuntime, W as listChatsForMember, X as markSupersededByChat, Y as markStaleAgents, Z as members, _ as createChat, _t as unbindAgent, a as agentVisibilityCondition, at as registerClient, b as disconnectClient, c as assertParticipant, ct as resolveChatMembership, d as chatMembership, dt as sendToAgent$1, et as pendingQuestions, f as chats, ft as serverInstances, g as clients, gt as touchAgent, h as cleanupStalePresence, ht as submitAnswer, i as agentPresence, it as registerChatMessageDispatcher, j as heartbeatClient, k as getOnlineCount, l as bindAgent, lt as retireClient, m as cleanupStaleClients, mt as setRuntimeState, n as addParticipant, nt as recomputeWatchersForAgent, o as agents, ot as removeParticipant, p as claimClient, pt as setOffline, q as listMessages, r as agentChatSessions, rt as recomputeWatchersForMember, s as assertClientOwner, st as resetActivity, t as addChatParticipants, tt as recomputeChatWatchers, u as changeChatType, ut as sendMessage, v as createNotifier, vt as updateClientCapabilities, w as findOrCreateDirectChat, x as editMessage, y as deriveAuthState, yt as upsertSessionState, z as listActiveAgentsPinnedToClient } from "./client-BViGcaUC-CZb2Svgh.mjs";
9
9
  import { a as invitationRedemptions, c as recordRedemption, i as getActiveInvitation, l as rotateInvitation, n as ensureActiveInvitation, o as invitations, r as findActiveByToken, t as buildInviteUrl, u as uuidv7 } from "./invitation-Bg0TRiyx-BsZH4GCS.mjs";
10
10
  import { createRequire } from "node:module";
11
11
  import { ZodError, z } from "zod";
@@ -759,11 +759,7 @@ const chatMetadataSchema = z.discriminatedUnion("source", [githubChatMetadataSch
759
759
  * sneak through `{ source: "github" }` without the required fields.
760
760
  */
761
761
  const optionalChatMetadataSchema = z.union([z.object({}).strict(), chatMetadataSchema]);
762
- const chatTypeSchema = z.enum([
763
- "direct",
764
- "group",
765
- "thread"
766
- ]);
762
+ const chatTypeSchema = z.enum(["direct", "group"]);
767
763
  const chatEngagementStatusSchema = z.enum([
768
764
  "active",
769
765
  "archived",
@@ -1277,7 +1273,8 @@ const meChatRowSchema = z.object({
1277
1273
  lastMessagePreview: z.string().nullable(),
1278
1274
  unreadMentionCount: z.number().int(),
1279
1275
  canReply: z.boolean(),
1280
- engagementStatus: chatEngagementStatusSchema
1276
+ engagementStatus: chatEngagementStatusSchema,
1277
+ workingAgentIds: z.array(z.string())
1281
1278
  });
1282
1279
  z.object({
1283
1280
  rows: z.array(meChatRowSchema),
@@ -1377,15 +1374,101 @@ memberSchema.extend({
1377
1374
  displayName: z.string(),
1378
1375
  password: z.string()
1379
1376
  });
1377
+ /**
1378
+ * Origin of a normalized webhook event. After the GitHub App ingestion
1379
+ * cutover this is single-form (App installations only); the type stays
1380
+ * a structured object so future non-GitHub sources can be added by
1381
+ * widening it into a discriminated union without churning callers.
1382
+ */
1383
+ const webhookSourceSchema = z.object({
1384
+ kind: z.literal("github-app-installation"),
1385
+ installationId: z.number().int(),
1386
+ organizationId: z.string().min(1)
1387
+ });
1388
+ const involveReasonSchema = z.enum([
1389
+ "mentioned",
1390
+ "review_requested",
1391
+ "assigned"
1392
+ ]);
1393
+ const normalizedEventKindSchema = z.enum([
1394
+ "opened",
1395
+ "edited",
1396
+ "closed",
1397
+ "merged",
1398
+ "reopened",
1399
+ "commented",
1400
+ "review_requested",
1401
+ "reviewed",
1402
+ "review_comment",
1403
+ "synchronized",
1404
+ "commit_commented",
1405
+ "assigned",
1406
+ "other"
1407
+ ]);
1408
+ const normalizedEntitySchema = z.object({
1409
+ type: githubEntityTypeSchema,
1410
+ repo: z.string().min(1),
1411
+ key: z.string().min(1),
1412
+ title: z.string().optional(),
1413
+ url: z.string().optional()
1414
+ });
1415
+ const normalizedActorSchema = z.object({
1416
+ githubLogin: z.string().min(1),
1417
+ isBot: z.boolean()
1418
+ });
1419
+ const normalizedInvolveSchema = z.object({
1420
+ githubLogin: z.string().min(1),
1421
+ reason: involveReasonSchema
1422
+ });
1423
+ const normalizedSurfaceSchema = z.object({
1424
+ title: z.string(),
1425
+ body: z.string(),
1426
+ url: z.string()
1427
+ });
1428
+ const normalizedRelatedRefSchema = z.object({
1429
+ type: z.literal("issue"),
1430
+ key: z.string().min(1)
1431
+ });
1432
+ z.object({
1433
+ source: webhookSourceSchema,
1434
+ deliveryId: z.string().nullable(),
1435
+ rawEventType: z.string().min(1),
1436
+ rawAction: z.string().nullable(),
1437
+ entity: normalizedEntitySchema,
1438
+ actor: normalizedActorSchema,
1439
+ kind: normalizedEventKindSchema,
1440
+ involves: z.array(normalizedInvolveSchema),
1441
+ surface: normalizedSurfaceSchema,
1442
+ relatedRefs: z.array(normalizedRelatedRefSchema)
1443
+ });
1444
+ const githubEventCardReasonSchema = z.enum([
1445
+ "mentioned",
1446
+ "review_requested",
1447
+ "assigned",
1448
+ "subscribed"
1449
+ ]);
1450
+ z.object({
1451
+ type: z.literal("github_event"),
1452
+ reason: githubEventCardReasonSchema,
1453
+ event: z.string().min(1),
1454
+ action: z.string().nullable(),
1455
+ kind: normalizedEventKindSchema,
1456
+ repository: z.string(),
1457
+ sender: z.string(),
1458
+ title: z.string(),
1459
+ body: z.string(),
1460
+ url: z.string(),
1461
+ entity: z.object({
1462
+ type: githubEntityTypeSchema,
1463
+ key: z.string().min(1),
1464
+ url: z.string().nullable()
1465
+ }),
1466
+ mentionedUser: z.string().optional()
1467
+ });
1380
1468
  const notificationTypeSchema = z.enum([
1381
1469
  "agent_error",
1382
- "session_error",
1383
- "agent_needs_decision",
1384
1470
  "agent_blocked",
1385
- "agent_stale",
1386
- "agent_disconnected",
1387
- "agent_connected",
1388
- "session_completed"
1471
+ "agent_stale"
1389
1472
  ]);
1390
1473
  const notificationSeveritySchema = z.enum([
1391
1474
  "high",
@@ -1502,12 +1585,6 @@ const orgContextTreeOutputSchema = z.object({
1502
1585
  repo: z.string().optional(),
1503
1586
  branch: z.string().optional()
1504
1587
  });
1505
- const orgGithubIntegrationStorageSchema = z.object({ webhookSecretCipher: z.string().optional() });
1506
- const orgGithubIntegrationInputSchema = z.object({ webhookSecret: z.string().min(1).nullish() });
1507
- const orgGithubIntegrationOutputSchema = z.object({
1508
- webhookSecretConfigured: z.boolean(),
1509
- webhookUrl: z.string()
1510
- });
1511
1588
  const orgSourceReposStorageSchema = z.object({ repos: z.array(z.object({
1512
1589
  url: repoUrlSchema,
1513
1590
  defaultBranch: z.string().optional()
@@ -1527,12 +1604,6 @@ const ORG_SETTINGS_NAMESPACES = {
1527
1604
  output: orgContextTreeOutputSchema,
1528
1605
  readPolicy: "member"
1529
1606
  },
1530
- github_integration: {
1531
- storage: orgGithubIntegrationStorageSchema,
1532
- input: orgGithubIntegrationInputSchema,
1533
- output: orgGithubIntegrationOutputSchema,
1534
- readPolicy: "admin"
1535
- },
1536
1607
  source_repos: {
1537
1608
  storage: orgSourceReposStorageSchema,
1538
1609
  input: orgSourceReposInputSchema,
@@ -1737,10 +1808,6 @@ z.object({
1737
1808
  chatId: z.string(),
1738
1809
  event: sessionEventSchema
1739
1810
  });
1740
- z.object({
1741
- agentId: z.string(),
1742
- chatId: z.string()
1743
- });
1744
1811
  z.object({
1745
1812
  type: z.literal("session:reconcile"),
1746
1813
  agentId: z.string().min(1),
@@ -1947,7 +2014,7 @@ defineConfig({
1947
2014
  rateLimit: optional({
1948
2015
  max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
1949
2016
  loginMax: field(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
1950
- webhookMax: field(z.number().default(60), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" }),
2017
+ webhookMax: field(z.number().default(600), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" }),
1951
2018
  contextTreeSnapshotMax: field(z.number().default(6), { env: "FIRST_TREE_HUB_RATE_LIMIT_CONTEXT_TREE_SNAPSHOT_MAX" }),
1952
2019
  agentMessageMax: field(z.number().default(30), { env: "FIRST_TREE_HUB_RATE_LIMIT_AGENT_MESSAGE_MAX" })
1953
2020
  }),
@@ -2538,14 +2605,6 @@ var ClientConnection = class extends EventEmitter {
2538
2605
  event
2539
2606
  }));
2540
2607
  }
2541
- reportSessionCompletion(agentId, chatId) {
2542
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
2543
- this.ws.send(JSON.stringify({
2544
- type: "session:completion",
2545
- agentId,
2546
- chatId
2547
- }));
2548
- }
2549
2608
  /** Ask the server which of the supplied chatIds the client should drop. */
2550
2609
  sendSessionReconcile(agentId, chatIds) {
2551
2610
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
@@ -4844,7 +4903,6 @@ const createClaudeCodeHandler = (config) => {
4844
4903
  try {
4845
4904
  await sessionCtx.forwardResult(resultText);
4846
4905
  sessionCtx.log("Result forwarded to chat");
4847
- sessionCtx.reportSessionCompletion();
4848
4906
  sessionCtx.emitEvent({
4849
4907
  kind: "turn_end",
4850
4908
  payload: { status: "success" }
@@ -5437,7 +5495,6 @@ const createCodexHandler = (config) => {
5437
5495
  kind: "turn_end",
5438
5496
  payload: { status: succeeded ? "success" : "error" }
5439
5497
  });
5440
- if (succeeded && accumulated.trim()) sessionCtx.reportSessionCompletion();
5441
5498
  sessionCtx.setRuntimeState("idle");
5442
5499
  if (queuedMessages.length > 0 && !drainScheduled) {
5443
5500
  drainScheduled = true;
@@ -6459,9 +6516,6 @@ var SessionManager = class {
6459
6516
  emitEvent: (event) => {
6460
6517
  this.config.onSessionEvent?.(chatId, event);
6461
6518
  },
6462
- reportSessionCompletion: () => {
6463
- this.config.onSessionCompletion?.(chatId);
6464
- },
6465
6519
  forwardResult,
6466
6520
  buildAgentEnv: (parentEnv) => buildAgentEnv(parentEnv, envCtx),
6467
6521
  formatInboundContent: (message) => formatInboundContent(message, participants),
@@ -6698,8 +6752,7 @@ var AgentSlot = class {
6698
6752
  ackEntry,
6699
6753
  onStateChange: (chatId, state) => this.reportSessionState(chatId, state),
6700
6754
  onRuntimeStateChange: (state) => this.reportRuntimeState(state),
6701
- onSessionEvent: (chatId, event) => this.reportSessionEvent(chatId, event),
6702
- onSessionCompletion: (chatId) => this.reportSessionCompletion(chatId)
6755
+ onSessionEvent: (chatId, event) => this.reportSessionEvent(chatId, event)
6703
6756
  });
6704
6757
  const onCommand = (cmd) => {
6705
6758
  if (cmd.agentId === this.config.agentId && this.sessionManager) this.sessionManager.handleCommand(cmd.chatId, cmd.type).catch((err) => {
@@ -6747,9 +6800,6 @@ var AgentSlot = class {
6747
6800
  reportSessionEvent(chatId, event) {
6748
6801
  this.clientConnection.reportSessionEvent(this.config.agentId, chatId, event);
6749
6802
  }
6750
- reportSessionCompletion(chatId) {
6751
- this.clientConnection.reportSessionCompletion(this.config.agentId, chatId);
6752
- }
6753
6803
  fullStateSync() {
6754
6804
  if (!this.sessionManager) return;
6755
6805
  for (const { chatId, state } of this.sessionManager.getSessionStates()) this.clientConnection.reportSessionState(this.config.agentId, chatId, state);
@@ -9443,7 +9493,7 @@ async function onboardCreate(args) {
9443
9493
  }
9444
9494
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
9445
9495
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
9446
- const { bindFeishuBot } = await import("./feishu-DrnBbl8T.mjs").then((n) => n.r);
9496
+ const { bindFeishuBot } = await import("./feishu-30vUx69l.mjs").then((n) => n.r);
9447
9497
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
9448
9498
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
9449
9499
  else {
@@ -10656,7 +10706,7 @@ function createFeedbackHandler(config) {
10656
10706
  return { handle };
10657
10707
  }
10658
10708
  //#endregion
10659
- //#region ../server/dist/app-BpskScln.mjs
10709
+ //#region ../server/dist/app-B1wsm8zG.mjs
10660
10710
  var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
10661
10711
  init_esm();
10662
10712
  var __defProp = Object.defineProperty;
@@ -11041,7 +11091,7 @@ async function deleteAdapterConfig(db, id) {
11041
11091
  const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
11042
11092
  if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
11043
11093
  }
11044
- const log$6 = createLogger$1("Adapters");
11094
+ const log$7 = createLogger$1("Adapters");
11045
11095
  function parseId(raw) {
11046
11096
  const id = Number(raw);
11047
11097
  if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
@@ -11067,7 +11117,7 @@ async function adapterRoutes(app) {
11067
11117
  const existing = await getAdapterConfig(app.db, id);
11068
11118
  await assertAgentManageableByUser(app.db, userId, existing.agentId);
11069
11119
  const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
11070
- app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after update"));
11120
+ app.adapterManager.reload().catch((err) => log$7.error({ err }, "adapter reload failed after update"));
11071
11121
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
11072
11122
  return {
11073
11123
  ...config,
@@ -11081,7 +11131,7 @@ async function adapterRoutes(app) {
11081
11131
  const existing = await getAdapterConfig(app.db, id);
11082
11132
  await assertAgentManageableByUser(app.db, userId, existing.agentId);
11083
11133
  await deleteAdapterConfig(app.db, id);
11084
- app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after delete"));
11134
+ app.adapterManager.reload().catch((err) => log$7.error({ err }, "adapter reload failed after delete"));
11085
11135
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
11086
11136
  return reply.status(204).send();
11087
11137
  });
@@ -11097,7 +11147,7 @@ function requireAgent(request) {
11097
11147
  if (!agent) throw new UnauthorizedError("Agent authentication required");
11098
11148
  return agent;
11099
11149
  }
11100
- const log$5 = createLogger$1("AgentChatsRoute");
11150
+ const log$6 = createLogger$1("AgentChatsRoute");
11101
11151
  function serializeChat(chat) {
11102
11152
  return {
11103
11153
  ...chat,
@@ -11160,7 +11210,7 @@ async function agentChatRoutes(app) {
11160
11210
  app.post("/:chatId/participants", async (request, reply) => {
11161
11211
  const identity = requireAgent(request);
11162
11212
  if (request.body !== null && typeof request.body === "object" && "mode" in request.body) {
11163
- log$5.warn({
11213
+ log$6.warn({
11164
11214
  code: "MODE_FIELD_DEPRECATED",
11165
11215
  chatId: request.params.chatId,
11166
11216
  senderAgentId: identity.uuid,
@@ -11383,6 +11433,20 @@ async function validateDelegateMentionTarget(db, targetUuid, sourceOrgId) {
11383
11433
  if (target.organizationId !== sourceOrgId) throw new BadRequestError("delegateMention target must belong to the same organization as the agent");
11384
11434
  }
11385
11435
  /**
11436
+ * Service-layer guard: `delegateMention` is only available for `human` agents.
11437
+ * Mirrors the Web UI in `identity-section.tsx`, which only renders the
11438
+ * delegate-mention selector when `agent.type === "human"`. Without this
11439
+ * server-side check, CLI / Admin API / internal scripts could write
11440
+ * delegateMention onto non-human rows, silently re-enabling the
11441
+ * autonomous-agent-self-mention path that resolveAudience would then fan
11442
+ * out. Called from `createAgent` / `updateAgent` before
11443
+ * `validateDelegateMentionTarget` so a wrong source type fails fast without
11444
+ * the target lookup round-trip.
11445
+ */
11446
+ function assertDelegateMentionAllowed(sourceType) {
11447
+ if (sourceType !== AGENT_TYPES.HUMAN) throw new BadRequestError("delegateMention can only be set on human agents");
11448
+ }
11449
+ /**
11386
11450
  * Pick the first admin member in the org for internal system agents. Throws
11387
11451
  * if the org has no admin — the caller should surface the error so an admin
11388
11452
  * is created before the system tries to register more agents.
@@ -11422,7 +11486,10 @@ async function createAgent(db, data, options = {}) {
11422
11486
  type: data.type
11423
11487
  });
11424
11488
  await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
11425
- if (data.delegateMention) await validateDelegateMentionTarget(db, data.delegateMention, orgId);
11489
+ if (data.delegateMention) {
11490
+ assertDelegateMentionAllowed(data.type);
11491
+ await validateDelegateMentionTarget(db, data.delegateMention, orgId);
11492
+ }
11426
11493
  const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
11427
11494
  if (org && org.maxAgents > 0) {
11428
11495
  if (((await db.select({ value: count() }).from(agents).where(and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED))))[0]?.value ?? 0) >= org.maxAgents) throw new ForbiddenError(`Organization "${orgId}" has reached its agent limit (${org.maxAgents}). Upgrade your plan or delete unused agents.`);
@@ -11567,10 +11634,16 @@ async function updateAgent(db, uuid, data) {
11567
11634
  if (agent.clientId !== null && agent.clientId !== data.clientId) throw new BadRequestError("clientId is immutable through this entry — cross-client moves go through rebindAgent (PATCH /agents/:uuid/rebind), which runs owner / org / capability checks atomically.");
11568
11635
  }
11569
11636
  const updates = { updatedAt: /* @__PURE__ */ new Date() };
11570
- if (data.type !== void 0) updates.type = data.type;
11637
+ if (data.type !== void 0) {
11638
+ if (data.type !== AGENT_TYPES.HUMAN && agent.delegateMention !== null && data.delegateMention !== null) throw new BadRequestError("Cannot change type away from `human` while delegateMention is set — clear delegateMention in the same patch.");
11639
+ updates.type = data.type;
11640
+ }
11571
11641
  if (data.displayName !== void 0) updates.displayName = data.displayName;
11572
11642
  if (data.delegateMention !== void 0) {
11573
- if (data.delegateMention !== null) await validateDelegateMentionTarget(db, data.delegateMention, agent.organizationId);
11643
+ if (data.delegateMention !== null) {
11644
+ assertDelegateMentionAllowed(data.type ?? agent.type);
11645
+ await validateDelegateMentionTarget(db, data.delegateMention, agent.organizationId);
11646
+ }
11574
11647
  updates.delegateMention = data.delegateMention;
11575
11648
  }
11576
11649
  if (data.visibility !== void 0) updates.visibility = data.visibility;
@@ -11681,7 +11754,7 @@ async function deleteAgent(db, uuid) {
11681
11754
  if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
11682
11755
  return agent;
11683
11756
  }
11684
- const log$4 = createLogger$1("AgentFeishuBot");
11757
+ const log$5 = createLogger$1("AgentFeishuBot");
11685
11758
  async function agentFeishuBotRoutes(app) {
11686
11759
  /**
11687
11760
  * PUT /agent/me/feishu-bot
@@ -11709,7 +11782,7 @@ async function agentFeishuBotRoutes(app) {
11709
11782
  },
11710
11783
  status: "active"
11711
11784
  }, app.config.secrets.encryptionKey);
11712
- app.adapterManager.reload().catch((err) => log$4.error({ err }, "adapter reload failed after self-service bind"));
11785
+ app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after self-service bind"));
11713
11786
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
11714
11787
  return reply.status(current ? 200 : 201).send({
11715
11788
  ...config,
@@ -11726,7 +11799,7 @@ async function agentFeishuBotRoutes(app) {
11726
11799
  const current = (await listAdapterConfigs(app.db)).find((c) => c.agentId === identity.uuid && c.platform === "feishu");
11727
11800
  if (!current) return reply.status(204).send();
11728
11801
  await deleteAdapterConfig(app.db, current.id);
11729
- app.adapterManager.reload().catch((err) => log$4.error({ err }, "adapter reload failed after self-service unbind"));
11802
+ app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after self-service unbind"));
11730
11803
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
11731
11804
  return reply.status(204).send();
11732
11805
  });
@@ -12357,7 +12430,7 @@ async function collectTargetInboxes(db, chatId, inReplyTo) {
12357
12430
  }
12358
12431
  return [...set];
12359
12432
  }
12360
- const log$3 = createLogger$1("AgentMessages");
12433
+ const log$4 = createLogger$1("AgentMessages");
12361
12434
  const editMessageSchema = z.object({
12362
12435
  format: z.string().optional(),
12363
12436
  content: z.unknown()
@@ -12385,7 +12458,7 @@ function agentMessageWriteRateLimit(max) {
12385
12458
  keyGenerator: (req) => {
12386
12459
  const agentId = req.agent?.uuid;
12387
12460
  if (agentId) return `agent:${agentId}`;
12388
- log$3.warn({
12461
+ log$4.warn({
12389
12462
  ip: req.ip,
12390
12463
  route: req.routeOptions?.url ?? req.url
12391
12464
  }, "rate-limit keyGenerator fell back to IP — req.agent missing on a route under /agent (hook order regression?)");
@@ -12418,7 +12491,7 @@ async function agentMessageRoutes(app) {
12418
12491
  await assertParticipant(app.db, request.params.chatId, identity.uuid);
12419
12492
  const body = editMessageSchema.parse(request.body);
12420
12493
  const msg = await editMessage(app.db, request.params.chatId, request.params.messageId, identity.uuid, body);
12421
- app.adapterManager.editOutboundMessage(msg.id, msg.format, msg.content).catch((err) => log$3.error({
12494
+ app.adapterManager.editOutboundMessage(msg.id, msg.format, msg.content).catch((err) => log$4.error({
12422
12495
  err,
12423
12496
  messageId: msg.id
12424
12497
  }, "failed to edit outbound message"));
@@ -12597,35 +12670,84 @@ const notifications = pgTable("notifications", {
12597
12670
  chatId: text("chat_id"),
12598
12671
  message: text("message").notNull(),
12599
12672
  read: boolean("read").notNull().default(false),
12673
+ dedupKey: text("dedup_key"),
12600
12674
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
12601
12675
  }, (table) => [
12602
12676
  index("idx_notifications_org_created").on(table.organizationId, table.createdAt),
12603
12677
  index("idx_notifications_agent").on(table.agentId),
12604
- index("idx_notifications_org_read").on(table.organizationId, table.read)
12678
+ index("idx_notifications_org_read").on(table.organizationId, table.read),
12679
+ uniqueIndex("uq_notifications_org_dedup_unread").on(table.organizationId, table.dedupKey).where(sql`read = false AND dedup_key IS NOT NULL`)
12605
12680
  ]);
12606
- let registered = null;
12681
+ let localBroadcaster = null;
12682
+ let crossInstanceBroadcaster = null;
12607
12683
  function registerAdminBroadcaster(fn) {
12608
- registered = fn;
12684
+ localBroadcaster = fn;
12685
+ }
12686
+ function registerCrossInstanceBroadcaster(fn) {
12687
+ crossInstanceBroadcaster = fn;
12609
12688
  }
12610
12689
  function broadcastToAdmins(payload) {
12611
- if (!registered) return;
12690
+ if (!localBroadcaster) return;
12612
12691
  try {
12613
- registered(payload);
12692
+ localBroadcaster(payload);
12614
12693
  } catch {}
12615
12694
  }
12616
- /** Create a notification, persist it, and fire-and-forget push to all channels. */
12695
+ /**
12696
+ * Fan out to every admin socket across every server instance via PG NOTIFY.
12697
+ * Falls back to single-instance fanout when no cross-instance broadcaster is
12698
+ * registered (e.g. unit tests that don't boot a notifier).
12699
+ */
12700
+ function broadcastAdminsCrossInstance(payload) {
12701
+ if (crossInstanceBroadcaster) {
12702
+ try {
12703
+ crossInstanceBroadcaster(payload);
12704
+ } catch {}
12705
+ return;
12706
+ }
12707
+ broadcastToAdmins(payload);
12708
+ }
12709
+ /**
12710
+ * Create a notification, persist it, and fire-and-forget push to all channels.
12711
+ *
12712
+ * Dedup contract (when `dedupKey` is set and an unread row already exists):
12713
+ * - **severity escalates monotonically** — `high` never drops back to
12714
+ * `medium`, `medium` never drops back to `low`. Prevents the bell badge
12715
+ * from understating a degrading agent (stale=medium first, then
12716
+ * error=high arriving — the row sticks at high).
12717
+ * - **type and message take the latest event's values** — so the row
12718
+ * reflects the most recent observation in the UI ("entered error state"
12719
+ * replaces "is unresponsive" once the runtime starts reporting error).
12720
+ * - **createdAt is preserved** so the bell ordering still tracks "when did
12721
+ * this incident open" rather than "when was the last observation".
12722
+ *
12723
+ * Rows without a `dedupKey` never hit the partial unique index and keep the
12724
+ * legacy always-insert behaviour.
12725
+ */
12617
12726
  async function createNotification(db, data) {
12618
12727
  const id = uuidv7();
12619
- const [row] = await db.insert(notifications).values({
12728
+ const row = (await db.insert(notifications).values({
12620
12729
  id,
12621
12730
  organizationId: data.organizationId,
12622
12731
  type: data.type,
12623
12732
  severity: data.severity,
12624
12733
  agentId: data.agentId ?? null,
12625
12734
  chatId: data.chatId ?? null,
12626
- message: data.message
12627
- }).returning();
12628
- if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
12735
+ message: data.message,
12736
+ dedupKey: data.dedupKey ?? null
12737
+ }).onConflictDoUpdate({
12738
+ target: [notifications.organizationId, notifications.dedupKey],
12739
+ set: {
12740
+ severity: sql`CASE
12741
+ WHEN ${notifications.severity} = 'high' OR excluded.severity = 'high' THEN 'high'
12742
+ WHEN ${notifications.severity} = 'medium' OR excluded.severity = 'medium' THEN 'medium'
12743
+ ELSE 'low'
12744
+ END`,
12745
+ type: sql`excluded.type`,
12746
+ message: sql`excluded.message`
12747
+ },
12748
+ targetWhere: sql`${notifications.read} = false AND ${notifications.dedupKey} IS NOT NULL`
12749
+ }).returning())[0];
12750
+ if (!row) return null;
12629
12751
  const notification = {
12630
12752
  id: row.id,
12631
12753
  organizationId: row.organizationId,
@@ -12658,18 +12780,15 @@ async function listNotifications(db, orgId, memberId, query) {
12658
12780
  items: [],
12659
12781
  nextCursor: null
12660
12782
  };
12661
- const conditions = [eq(notifications.organizationId, orgId)];
12783
+ const targetLimit = query.limit;
12784
+ const conditions = [eq(notifications.organizationId, orgId), buildVisibilityCondition([...visibleAgents])];
12662
12785
  if (query.cursor) conditions.push(lt(notifications.createdAt, new Date(query.cursor)));
12663
12786
  if (query.severity) conditions.push(eq(notifications.severity, query.severity));
12664
12787
  if (query.read !== void 0) conditions.push(eq(notifications.read, query.read));
12665
12788
  if (query.agentId) conditions.push(eq(notifications.agentId, query.agentId));
12666
- const where = and(...conditions);
12667
- const overscanFactor = 4;
12668
- const targetLimit = query.limit;
12669
- const rawLimit = Math.min(targetLimit * overscanFactor + 1, 400);
12670
- const visible = (await db.select().from(notifications).where(where).orderBy(desc(notifications.createdAt)).limit(rawLimit)).filter((n) => n.agentId === null || visibleAgents.has(n.agentId));
12671
- const hasMore = visible.length > targetLimit;
12672
- const items = hasMore ? visible.slice(0, targetLimit) : visible;
12789
+ const rows = await db.select().from(notifications).where(and(...conditions)).orderBy(desc(notifications.createdAt)).limit(targetLimit + 1);
12790
+ const hasMore = rows.length > targetLimit;
12791
+ const items = hasMore ? rows.slice(0, targetLimit) : rows;
12673
12792
  const last = items[items.length - 1];
12674
12793
  const nextCursor = hasMore && last ? last.createdAt.toISOString() : null;
12675
12794
  return {
@@ -12680,6 +12799,16 @@ async function listNotifications(db, orgId, memberId, query) {
12680
12799
  nextCursor
12681
12800
  };
12682
12801
  }
12802
+ /**
12803
+ * Return the unread notification count for this member's visible agents.
12804
+ * Single `SELECT COUNT(*)` — no row fetch — so the topbar bell badge can
12805
+ * surface an accurate number (>100) without paying for a list query.
12806
+ */
12807
+ async function unreadCount(db, orgId, memberId) {
12808
+ const visibleAgents = await loadVisibleAgentIds$1(db, orgId, memberId);
12809
+ const [row] = await db.select({ count: sql`count(*)` }).from(notifications).where(and(eq(notifications.organizationId, orgId), eq(notifications.read, false), buildVisibilityCondition([...visibleAgents])));
12810
+ return Number(row?.count ?? "0");
12811
+ }
12683
12812
  /** Mark a single notification as read, scoped to organization + visible agents. */
12684
12813
  async function markRead(db, notificationId, orgId, memberId) {
12685
12814
  const [existing] = await db.select({
@@ -12696,16 +12825,17 @@ async function markRead(db, notificationId, orgId, memberId) {
12696
12825
  /** Mark all notifications visible to this member as read. */
12697
12826
  async function markAllRead(db, orgId, memberId) {
12698
12827
  const visible = await loadVisibleAgentIds$1(db, orgId, memberId);
12699
- const idsToMark = (await db.select({
12700
- id: notifications.id,
12701
- agentId: notifications.agentId
12702
- }).from(notifications).where(and(eq(notifications.organizationId, orgId), eq(notifications.read, false))).limit(1e3)).filter((n) => n.agentId === null || visible.has(n.agentId)).map((n) => n.id);
12703
- if (idsToMark.length === 0) return;
12704
- const batchSize = 200;
12705
- for (let i = 0; i < idsToMark.length; i += batchSize) {
12706
- const batch = idsToMark.slice(i, i + batchSize);
12707
- await db.update(notifications).set({ read: true }).where(and(eq(notifications.organizationId, orgId), eq(notifications.read, false), inArray(notifications.id, batch)));
12708
- }
12828
+ await db.update(notifications).set({ read: true }).where(and(eq(notifications.organizationId, orgId), eq(notifications.read, false), buildVisibilityCondition([...visible])));
12829
+ }
12830
+ /**
12831
+ * SQL fragment matching every notification visible to a member: org-wide
12832
+ * (`agent_id IS NULL`) plus rows tied to an agent in the member's visible
12833
+ * set. Centralised so listNotifications, markAllRead, and unreadCount can
12834
+ * never drift apart.
12835
+ */
12836
+ function buildVisibilityCondition(visibleAgentIds) {
12837
+ if (visibleAgentIds.length === 0) return isNull(notifications.agentId);
12838
+ return or(isNull(notifications.agentId), inArray(notifications.agentId, visibleAgentIds));
12709
12839
  }
12710
12840
  /**
12711
12841
  * Shared visibility predicate. Mirrors
@@ -12739,62 +12869,81 @@ async function resolveAgentContext(db, agentId) {
12739
12869
  clientLabel
12740
12870
  };
12741
12871
  }
12742
- async function resolveChatContext(db, chatId) {
12743
- const [chat] = await db.select({ topic: chats.topic }).from(chats).where(eq(chats.id, chatId)).limit(1);
12744
- const shortId = chatId.slice(0, 8);
12745
- return { chatLabel: chat?.topic && chat.topic.trim().length > 0 ? chat.topic.trim() : `Chat ${shortId}` };
12746
- }
12747
12872
  /**
12748
- * Compose a human-readable message for each notification type.
12749
- *
12750
- * Keep subjects consistent with what the dashboard shows the member:
12751
- * - Session-scoped events → subject is the chat (topic / "Chat xxxxxxxx")
12752
- * - Client-scoped events → subject is the computer (hostname / clientId)
12753
- * - Agent-scoped events → subject is the agent display name
12873
+ * Compose a human-readable message for each notification type. The full set
12874
+ * is fault-scoped (error / blocked / stale) — completion events are not
12875
+ * notifications because the conversation list already surfaces them.
12754
12876
  */
12755
- function composeMessage(type, agentCtx, chatCtx) {
12877
+ function composeMessage(type, agentCtx) {
12756
12878
  const agent = agentCtx.agentName;
12757
12879
  const computer = agentCtx.clientLabel ?? "Unknown computer";
12758
- const chat = chatCtx?.chatLabel ?? null;
12759
12880
  switch (type) {
12760
- case "session_completed": return chat ? `${chat} completed` : `${agent} completed a task`;
12761
- case "session_error": return chat ? `${chat} hit an error` : `${agent} hit a session error`;
12762
- case "agent_disconnected": return `Computer ${computer} disconnected`;
12763
- case "agent_connected": return `Computer ${computer} reconnected`;
12764
12881
  case "agent_stale": return `Computer ${computer} is unresponsive`;
12765
12882
  case "agent_error": return `${agent} entered error state`;
12766
12883
  case "agent_blocked": return `${agent} is blocked`;
12767
- case "agent_needs_decision": return chat ? `${agent} needs a decision in ${chat}` : `${agent} needs a decision`;
12768
12884
  default: return `${agent} event`;
12769
12885
  }
12770
12886
  }
12771
12887
  /**
12772
- * Convenience: create a notification for an agent event, resolving org,
12773
- * agent display name, computer hostname, and chat topic automatically.
12774
- * Callers supply the event type and severity; the message text is generated
12775
- * here so language/phrasing is centralized (see {@link composeMessage}).
12888
+ * Convenience: create a notification for an agent event, resolving org and
12889
+ * agent display name automatically. The message text is generated here so
12890
+ * language/phrasing is centralized (see {@link composeMessage}).
12891
+ *
12892
+ * Default dedup_key when none is supplied: `agent:{agentId}:fault`. All three
12893
+ * current fault types (error / blocked / stale) collapse onto one unread row
12894
+ * per agent — a single agent that goes error AND then stale should not double
12895
+ * the badge for the same underlying problem. Pair with
12896
+ * {@link markAgentFaultsResolved}, which closes the row when the agent
12897
+ * recovers.
12776
12898
  *
12777
12899
  * Fire-and-forget — errors are swallowed so event producers never fail just
12778
12900
  * because the notification pipeline is unhealthy.
12779
12901
  */
12780
- async function notifyAgentEvent(db, agentId, type, severity, chatId) {
12902
+ async function notifyAgentEvent(db, agentId, type, severity, options = {}) {
12781
12903
  try {
12782
12904
  const agentCtx = await resolveAgentContext(db, agentId);
12783
12905
  if (!agentCtx) return;
12784
- const message = composeMessage(type, agentCtx, chatId ? await resolveChatContext(db, chatId) : null);
12906
+ const message = composeMessage(type, agentCtx);
12907
+ const dedupKey = options.dedupKey === void 0 ? `agent:${agentId}:fault` : options.dedupKey;
12785
12908
  await createNotification(db, {
12786
12909
  organizationId: agentCtx.organizationId,
12787
12910
  type,
12788
12911
  severity,
12789
12912
  agentId,
12790
- chatId: chatId ?? null,
12791
12913
  clientId: agentCtx.clientId,
12792
- message
12914
+ message,
12915
+ dedupKey
12793
12916
  });
12794
12917
  } catch {}
12795
12918
  }
12919
+ /**
12920
+ * Mark every unread fault-scoped notification for this agent as read. Called
12921
+ * when the agent recovers — either by rebinding (offline → online) or by
12922
+ * reporting a healthy runtime state (error/blocked → idle/working). Without
12923
+ * this, a transient incident leaves its notification row in "unread" forever
12924
+ * and the badge never clears even though the underlying problem is gone.
12925
+ *
12926
+ * Fire-and-forget — same rationale as {@link notifyAgentEvent}: presence /
12927
+ * runtime-state callers must not fail just because notification bookkeeping
12928
+ * is unhealthy.
12929
+ *
12930
+ * Note on badge freshness: this UPDATEs the DB but does not push an event
12931
+ * across admin WS. The bell refetches on its own next push or reconnect, so
12932
+ * the badge may lag the actual state by up to one push cycle. Adding a
12933
+ * dedicated `notification:read` envelope was deferred — it adds a new event
12934
+ * shape on both sides for a sub-second cosmetic difference.
12935
+ */
12936
+ async function markAgentFaultsResolved(db, agentId) {
12937
+ try {
12938
+ await db.update(notifications).set({ read: true }).where(and(eq(notifications.agentId, agentId), eq(notifications.read, false), inArray(notifications.type, [
12939
+ NOTIFICATION_TYPES.AGENT_ERROR,
12940
+ NOTIFICATION_TYPES.AGENT_BLOCKED,
12941
+ NOTIFICATION_TYPES.AGENT_STALE
12942
+ ])));
12943
+ } catch {}
12944
+ }
12796
12945
  function pushToAdminWs(notification) {
12797
- broadcastToAdmins({
12946
+ broadcastAdminsCrossInstance({
12798
12947
  type: "notification",
12799
12948
  organizationId: notification.organizationId,
12800
12949
  agentId: notification.agentId ?? null,
@@ -12938,35 +13087,6 @@ const wsMessageSchema = z.object({
12938
13087
  agentId: z.string().optional(),
12939
13088
  ref: z.string().optional()
12940
13089
  });
12941
- /**
12942
- * Client WebSocket: one WS per client, multiple agents multiplexed.
12943
- *
12944
- * Protocol (unified-user-token milestone):
12945
- * 1. Client connects; server waits up to {@link WS_AUTH_FRAME_TIMEOUT_MS}
12946
- * for the first `auth` frame carrying a member access JWT.
12947
- * Failure ⇒ server sends `auth:rejected` and closes (code 4401).
12948
- * 2. `client:register` — bind the client_id to the authenticated user.
12949
- * 3. `agent:bind` — run Rule R-RUN (no token); populate presence.
12950
- * 4. `session:state` / `runtime:state` / `session:event` / `session:completion` / `heartbeat`.
12951
- * 5. `agent:unbind` — stop multiplexing for a specific agent.
12952
- *
12953
- * When the JWT is about to expire the server sends `auth:expired` so the
12954
- * SDK can refresh and reconnect without silently half-opening the socket.
12955
- */
12956
- /** Notification cooldown: prevents duplicate notifications for same (agentId, type) within window. */
12957
- const NOTIFICATION_COOLDOWN_MS = 3e5;
12958
- const notificationCooldowns = /* @__PURE__ */ new Map();
12959
- function shouldNotify(agentId, notificationType) {
12960
- const key = `${agentId}:${notificationType}`;
12961
- const now = Date.now();
12962
- const lastSent = notificationCooldowns.get(key);
12963
- if (lastSent && now - lastSent < NOTIFICATION_COOLDOWN_MS) return false;
12964
- notificationCooldowns.set(key, now);
12965
- if (notificationCooldowns.size > 1e3) {
12966
- for (const [k, ts] of notificationCooldowns) if (now - ts > NOTIFICATION_COOLDOWN_MS) notificationCooldowns.delete(k);
12967
- }
12968
- return true;
12969
- }
12970
13090
  function sendRejected(socket, ref, reason) {
12971
13091
  socket.send(JSON.stringify({
12972
13092
  type: "agent:bind:rejected",
@@ -13380,6 +13500,7 @@ function clientWsRoutes(notifier, instanceId) {
13380
13500
  runtimeType: bindRequest.runtimeType,
13381
13501
  runtimeVersion: bindRequest.runtimeVersion
13382
13502
  });
13503
+ markAgentFaultsResolved(app.db, agent.id).catch(() => {});
13383
13504
  bindAgentToClient(clientId, agent.id);
13384
13505
  boundAgents.set(agent.id, {
13385
13506
  agentId: agent.id,
@@ -13489,8 +13610,9 @@ function clientWsRoutes(notifier, instanceId) {
13489
13610
  organizationId: boundAgentInfo.organizationId,
13490
13611
  notifier
13491
13612
  });
13492
- if (payload.runtimeState === "error" && shouldNotify(agentId, "agent_error")) notifyAgentEvent(app.db, agentId, "agent_error", "high").catch(() => {});
13493
- else if (payload.runtimeState === "blocked" && shouldNotify(agentId, "agent_blocked")) notifyAgentEvent(app.db, agentId, "agent_blocked", "medium").catch(() => {});
13613
+ if (payload.runtimeState === "error") notifyAgentEvent(app.db, agentId, "agent_error", "high").catch(() => {});
13614
+ else if (payload.runtimeState === "blocked") notifyAgentEvent(app.db, agentId, "agent_blocked", "medium").catch(() => {});
13615
+ else if (payload.runtimeState === "idle" || payload.runtimeState === "working") markAgentFaultsResolved(app.db, agentId).catch(() => {});
13494
13616
  } else if (type === "session:event") {
13495
13617
  const agentId = parsed.data.agentId;
13496
13618
  if (!agentId || !boundAgents.has(agentId)) {
@@ -13511,17 +13633,6 @@ function clientWsRoutes(notifier, instanceId) {
13511
13633
  }));
13512
13634
  }
13513
13635
  });
13514
- } else if (type === "session:completion") {
13515
- const agentId = parsed.data.agentId;
13516
- if (!agentId || !boundAgents.has(agentId)) {
13517
- socket.send(JSON.stringify({
13518
- type: "error",
13519
- message: "Agent not bound"
13520
- }));
13521
- return;
13522
- }
13523
- const payload = sessionCompletionMessageSchema.parse(msg);
13524
- if (shouldNotify(agentId, `session_completed:${payload.chatId}`)) notifyAgentEvent(app.db, agentId, "session_completed", "low", payload.chatId).catch(() => {});
13525
13636
  } else if (type === "inbox:ack") {
13526
13637
  const payloadResult = inboxAckFrameSchema.safeParse(msg);
13527
13638
  if (!payloadResult.success) {
@@ -13575,7 +13686,6 @@ function clientWsRoutes(notifier, instanceId) {
13575
13686
  notifier.unsubscribe(info.inboxId, socket);
13576
13687
  if (getAgentClientId(agentId) === clientId) try {
13577
13688
  await unbindAgent(app.db, agentId);
13578
- if (shouldNotify(agentId, "agent_disconnected")) notifyAgentEvent(app.db, agentId, "agent_disconnected", "medium").catch(() => {});
13579
13689
  } catch {}
13580
13690
  }
13581
13691
  boundAgents.clear();
@@ -14611,6 +14721,83 @@ async function bindInstallationToOrg(db, installationId, hubOrganizationId) {
14611
14721
  return true;
14612
14722
  }
14613
14723
  /**
14724
+ * Webhook handler for `installation: suspend`. Sets `suspended_at` to the
14725
+ * timestamp GitHub put on `installation.suspended_at`.
14726
+ *
14727
+ * Out-of-order safety (codex P1-7): GitHub doesn't guarantee delivery
14728
+ * order and redelivers on failure, so a *stale* `suspend` event could
14729
+ * arrive after a newer one. The conditional UPDATE only writes when the
14730
+ * row is currently unsuspended OR carries an *earlier* `suspended_at` —
14731
+ * a stale re-suspend with an older timestamp is a no-op.
14732
+ *
14733
+ * (Limitation: once an `unsuspend` has cleared `suspended_at` to NULL we
14734
+ * no longer know *when* that happened, so a stale `suspend` arriving after
14735
+ * an `unsuspend` would still re-suspend. Proper handling would need a
14736
+ * dedicated lifecycle-sequence column; in practice suspend/unsuspend are
14737
+ * minutes-apart human actions, well outside any realistic reorder window.)
14738
+ */
14739
+ async function markInstallationSuspended(db, installationId, suspendedAt) {
14740
+ await db.update(githubAppInstallations).set({
14741
+ suspendedAt,
14742
+ updatedAt: /* @__PURE__ */ new Date()
14743
+ }).where(and(eq(githubAppInstallations.installationId, installationId), or(isNull(githubAppInstallations.suspendedAt), lt(githubAppInstallations.suspendedAt, suspendedAt))));
14744
+ }
14745
+ /**
14746
+ * Webhook handler for `installation: unsuspend`. Clears `suspended_at`.
14747
+ *
14748
+ * `unsuspendedAt` is the time we received the event (GitHub's `unsuspend`
14749
+ * payload, unlike `suspend`, carries no event timestamp). The conditional
14750
+ * UPDATE only clears when the current `suspended_at` predates that — i.e.
14751
+ * a stale `unsuspend` that lost the race to a newer `suspend` won't undo
14752
+ * it. A row that's already unsuspended (`suspended_at IS NULL`) is left
14753
+ * alone (the `< unsuspendedAt` comparison is NULL → no match), which is
14754
+ * the desired no-op.
14755
+ */
14756
+ async function markInstallationUnsuspended(db, installationId, unsuspendedAt) {
14757
+ await db.update(githubAppInstallations).set({
14758
+ suspendedAt: null,
14759
+ updatedAt: /* @__PURE__ */ new Date()
14760
+ }).where(and(eq(githubAppInstallations.installationId, installationId), lt(githubAppInstallations.suspendedAt, unsuspendedAt)));
14761
+ }
14762
+ /**
14763
+ * Webhook handler for `installation: deleted`. Removes the row outright —
14764
+ * the user uninstalled the App from their account; the row has no value.
14765
+ *
14766
+ * The earlier 60-s `createdAt`-based grace window (added in C.12 codex P1-7
14767
+ * to guard against delayed `deleted` events clobbering fresh re-installs)
14768
+ * was reverted after a post-Phase-C codex challenge flagged a worse bug:
14769
+ * a real install + immediate uninstall (within 60 s) became permanent —
14770
+ * the handler returned a 200 no-op so GitHub never redelivered, and the
14771
+ * Hub-side row lived forever even though the App was gone upstream.
14772
+ *
14773
+ * The original race the grace was meant to solve doesn't actually exist:
14774
+ * GitHub mints a fresh `installation.id` per install, so a delayed
14775
+ * `deleted` for id N cannot wipe a fresh re-install (which has id M ≠ N).
14776
+ * Same-id replays are handled by the `processed_events` dedup table.
14777
+ *
14778
+ * The remaining "stale `created` after `deleted` resurrects the row" risk
14779
+ * is a pre-existing hole in `upsertInstallationFromMetadata` (not
14780
+ * introduced by Phase C). Tracked as a Phase D follow-up — the upsert
14781
+ * path needs a `last_lifecycle_event_at` column or tombstone to be
14782
+ * order-safe.
14783
+ *
14784
+ * Note: deleting the org on the Hub side is the inverse case — handled
14785
+ * by the `ON DELETE SET NULL` FK on `hub_organization_id`, which keeps
14786
+ * the installation row alive so a future rebind can recover.
14787
+ */
14788
+ async function deleteInstallationByGithubId(db, installationId) {
14789
+ await db.delete(githubAppInstallations).where(eq(githubAppInstallations.installationId, installationId));
14790
+ }
14791
+ /**
14792
+ * Lookup an installation by GitHub-side id. Used by the webhook router
14793
+ * to resolve `installation.id` → `hub_organization_id` so downstream
14794
+ * event handlers (issues, PRs) know which Hub team the event belongs to.
14795
+ */
14796
+ async function findInstallationByGithubId(db, installationId) {
14797
+ const [row] = await db.select().from(githubAppInstallations).where(eq(githubAppInstallations.installationId, installationId)).limit(1);
14798
+ return row ?? null;
14799
+ }
14800
+ /**
14614
14801
  * Lookup the installation bound to a Hub team. Used by Settings →
14615
14802
  * Integrations to render the connected-account panel. Returns null when
14616
14803
  * no install is bound.
@@ -15266,10 +15453,9 @@ async function bootstrapConfigRoutes(_app) {
15266
15453
  * Public endpoint — returns bootstrap prerequisites for CLI auto-discovery.
15267
15454
  *
15268
15455
  * `allowedOrg` used to surface here from the global `github.allowedOrg`
15269
- * config; it is now a per-org setting (see issue #255). A public bootstrap
15270
- * endpoint can't resolve an org without a caller, so the field is
15271
- * surfaced as `null` and consumers should fetch the per-org value via
15272
- * `/api/v1/orgs/:orgId/settings/github_integration` after auth.
15456
+ * config; it is now per-installation state on `github_app_installations`
15457
+ * (see issue #255). A public bootstrap endpoint can't resolve an
15458
+ * installation without a caller, so the field is surfaced as `null`.
15273
15459
  */
15274
15460
  _app.get("/config", async () => {
15275
15461
  return { allowedOrg: null };
@@ -15602,7 +15788,7 @@ async function getCallerEngagement(db, chatId, agentId) {
15602
15788
  * (speaker → "participant" / watcher → "watching"); the user
15603
15789
  * state row supplies the unread counter (COALESCE → 0 when
15604
15790
  * row is missing).
15605
- * - Filter `parent_chat_id IS NULL` (threads excluded in v1).
15791
+ * - Filter `parent_chat_id IS NULL` (nested chats not surfaced in v1).
15606
15792
  * - Filter `c.organization_id = ?` to defend against historical
15607
15793
  * cross-org pollution rows that may still reference the caller
15608
15794
  * (see fix/cross-org-direct-chat-pollution).
@@ -15670,9 +15856,11 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15670
15856
  chatId: chatMembership.chatId,
15671
15857
  agentId: chatMembership.agentId,
15672
15858
  displayName: agents.displayName,
15673
- type: agents.type
15674
- }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
15859
+ type: agents.type,
15860
+ runtimeState: agentPresence.runtimeState
15861
+ }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).leftJoin(agentPresence, eq(agentPresence.agentId, chatMembership.agentId)).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
15675
15862
  const participantsByChat = /* @__PURE__ */ new Map();
15863
+ const workingByChat = /* @__PURE__ */ new Map();
15676
15864
  for (const p of participantRows) {
15677
15865
  const list = participantsByChat.get(p.chatId) ?? [];
15678
15866
  list.push({
@@ -15681,6 +15869,11 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15681
15869
  type: p.type
15682
15870
  });
15683
15871
  participantsByChat.set(p.chatId, list);
15872
+ if (p.runtimeState === "working") {
15873
+ const working = workingByChat.get(p.chatId) ?? [];
15874
+ working.push(p.agentId);
15875
+ workingByChat.set(p.chatId, working);
15876
+ }
15684
15877
  }
15685
15878
  const firstMessageRows = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
15686
15879
  chatId: messages.chatId,
@@ -15708,7 +15901,8 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
15708
15901
  lastMessagePreview: r.last_message_preview,
15709
15902
  unreadMentionCount: r.unread_mention_count,
15710
15903
  canReply: isSpeaker,
15711
- engagementStatus: r.engagement_status
15904
+ engagementStatus: r.engagement_status,
15905
+ workingAgentIds: workingByChat.get(r.chat_id) ?? []
15712
15906
  };
15713
15907
  }),
15714
15908
  nextCursor
@@ -16156,15 +16350,13 @@ const organizationSettings = pgTable("organization_settings", {
16156
16350
  * registry of valid namespaces and their storage / input / output schemas
16157
16351
  * lives in `@agent-team-foundation/first-tree-hub-shared`.
16158
16352
  *
16159
- * Read path: storage row → decrypt secrets → output (mask)
16160
- * Write path: input → validate → encrypt secrets → merge with current storage → upsert (in tx)
16353
+ * Read path: storage row → output (mask)
16354
+ * Write path: input → validate → merge with current storage → upsert (in tx)
16161
16355
  *
16162
- * The generic getter returns the masked output. Callers needing plaintext
16163
- * for a specific secret use a purpose-built helper (e.g.
16164
- * `getDecryptedGithubWebhookSecret`) rather than the generic storage shape
16165
- * this avoids a `…Cipher` field name silently holding plaintext at
16166
- * call-sites and limits secret exposure to one explicit code path per
16167
- * secret. (#4)
16356
+ * The generic getter returns the masked output. Per-namespace plaintext
16357
+ * accessors live alongside this module when a secret needs to leave the
16358
+ * encrypted-at-rest boundary (none today after the github_integration
16359
+ * webhook secret was retired DP12).
16168
16360
  */
16169
16361
  function assertNamespace(ns) {
16170
16362
  if (!isOrgSettingNamespace(ns)) throw new BadRequestError(`Unknown organization-settings namespace: "${ns}"`);
@@ -16177,19 +16369,15 @@ async function fetchStorageRow(db, orgId, namespace) {
16177
16369
  function emptyStorage(namespace) {
16178
16370
  return ORG_SETTINGS_NAMESPACES$1[namespace].storage.parse({});
16179
16371
  }
16180
- function ensureEncrypted(value, encryptionKey) {
16181
- return isEncryptedValue(value) ? value : encryptValue(value, encryptionKey);
16182
- }
16183
16372
  /**
16184
16373
  * Merge a validated input into the current storage row for a namespace.
16185
- * Secret fields are encrypted here.
16186
16374
  *
16187
16375
  * Input semantics per nullish field:
16188
16376
  * `undefined` → unchanged
16189
16377
  * `null` → cleared
16190
16378
  * value → set / replace (already validated as non-empty by the input schema)
16191
16379
  */
16192
- function applyInputDelta(namespace, current, input, encryptionKey) {
16380
+ function applyInputDelta(namespace, current, input) {
16193
16381
  if (namespace === "context_tree") {
16194
16382
  const cur = current;
16195
16383
  const inp = input;
@@ -16198,11 +16386,6 @@ function applyInputDelta(namespace, current, input, encryptionKey) {
16198
16386
  branch: inp.branch === void 0 ? cur.branch : inp.branch ?? "main"
16199
16387
  };
16200
16388
  }
16201
- if (namespace === "github_integration") {
16202
- const cur = current;
16203
- const inp = input;
16204
- return { webhookSecretCipher: inp.webhookSecret === void 0 ? cur.webhookSecretCipher : inp.webhookSecret === null ? void 0 : ensureEncrypted(inp.webhookSecret, encryptionKey) };
16205
- }
16206
16389
  if (namespace === "source_repos") {
16207
16390
  const cur = current;
16208
16391
  const inp = input;
@@ -16212,9 +16395,7 @@ function applyInputDelta(namespace, current, input, encryptionKey) {
16212
16395
  }
16213
16396
  /**
16214
16397
  * Project the storage row into the API output for a namespace, masking
16215
- * any secret fields. `webhookUrl` for `github_integration` is left as an
16216
- * empty string here — the route layer enriches it with the resolved
16217
- * `server.publicUrl` (the service stays config-agnostic).
16398
+ * any secret fields.
16218
16399
  */
16219
16400
  function toOutput(namespace, storage) {
16220
16401
  if (namespace === "context_tree") {
@@ -16224,13 +16405,6 @@ function toOutput(namespace, storage) {
16224
16405
  branch: s.branch
16225
16406
  };
16226
16407
  }
16227
- if (namespace === "github_integration") {
16228
- const s = storage;
16229
- return {
16230
- webhookSecretConfigured: typeof s.webhookSecretCipher === "string" && s.webhookSecretCipher.length > 0,
16231
- webhookUrl: ""
16232
- };
16233
- }
16234
16408
  if (namespace === "source_repos") return { repos: storage.repos };
16235
16409
  return namespace;
16236
16410
  }
@@ -16251,17 +16425,6 @@ async function getOrgContextTree(db, orgId) {
16251
16425
  return await fetchStorageRow(db, orgId, "context_tree") ?? emptyStorage("context_tree");
16252
16426
  }
16253
16427
  /**
16254
- * Decrypt and return the plaintext GitHub webhook secret for an org.
16255
- * Returns `null` when the org has not configured one. The only intended
16256
- * caller is the webhook route's signature verifier — the result must
16257
- * never leak through HTTP responses or logs. (#4)
16258
- */
16259
- async function getDecryptedGithubWebhookSecret(db, orgId, encryptionKey) {
16260
- const cipher = (await fetchStorageRow(db, orgId, "github_integration"))?.webhookSecretCipher;
16261
- if (!cipher) return null;
16262
- return isEncryptedValue(cipher) ? decryptValue(cipher, encryptionKey) : cipher;
16263
- }
16264
- /**
16265
16428
  * Upsert a setting. Returns the masked output of the resulting row.
16266
16429
  *
16267
16430
  * The fetch + merge + upsert sequence runs inside a single transaction so
@@ -16277,7 +16440,7 @@ async function putOrgSetting(db, orgId, namespace, rawInput, options) {
16277
16440
  const txDb = tx;
16278
16441
  const [org] = await txDb.select({ id: organizations.id }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
16279
16442
  if (!org) throw new NotFoundError(`Organization "${orgId}" not found`);
16280
- const merged = applyInputDelta(namespace, await fetchStorageRow(txDb, orgId, namespace) ?? emptyStorage(namespace), input, options.encryptionKey);
16443
+ const merged = applyInputDelta(namespace, await fetchStorageRow(txDb, orgId, namespace) ?? emptyStorage(namespace), input);
16281
16444
  const validated = ORG_SETTINGS_NAMESPACES$1[namespace].storage.parse(merged);
16282
16445
  await tx.insert(organizationSettings).values({
16283
16446
  organizationId: orgId,
@@ -17407,7 +17570,7 @@ async function healthzRoutes(app) {
17407
17570
  * `api/orgs/invitations.ts` (Class B, admin-gated).
17408
17571
  */
17409
17572
  async function publicInvitationRoutes(app) {
17410
- const { previewInvitation } = await import("./invitation-C299fxkP-KKslbta2.mjs");
17573
+ const { previewInvitation } = await import("./invitation-C299fxkP-CZgGbsN_.mjs");
17411
17574
  app.get("/:token/preview", async (request, reply) => {
17412
17575
  if (!request.params.token) throw new UnauthorizedError("Token required");
17413
17576
  const preview = await previewInvitation(app.db, request.params.token);
@@ -17673,7 +17836,7 @@ async function meRoutes(app) {
17673
17836
  */
17674
17837
  app.get("/me/pinned-agents", async (request) => {
17675
17838
  const { userId } = requireUser(request);
17676
- const { listMyPinnedAgents } = await import("./client-CjGIGddS-BrpazWa3.mjs");
17839
+ const { listMyPinnedAgents } = await import("./client-DNiLcPEq-db3YS57z.mjs");
17677
17840
  return listMyPinnedAgents(app.db, { userId });
17678
17841
  });
17679
17842
  /**
@@ -17925,7 +18088,7 @@ async function orgAdapterStatusRoutes(app) {
17925
18088
  return app.adapterManager.getBotStatuses().filter((s) => visibleIds.has(s.configId));
17926
18089
  });
17927
18090
  }
17928
- const log$2 = createLogger$1("OrgAdapters");
18091
+ const log$3 = createLogger$1("OrgAdapters");
17929
18092
  /** Class B — `/api/v1/orgs/:orgId/adapters`. */
17930
18093
  async function orgAdapterRoutes(app) {
17931
18094
  app.get("/", async (request) => {
@@ -17941,7 +18104,7 @@ async function orgAdapterRoutes(app) {
17941
18104
  const body = createAdapterConfigSchema.parse(request.body);
17942
18105
  await assertAgentManageableByUser(app.db, scope.userId, body.agentId);
17943
18106
  const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
17944
- app.adapterManager.reload().catch((err) => log$2.error({ err }, "adapter reload failed after create"));
18107
+ app.adapterManager.reload().catch((err) => log$3.error({ err }, "adapter reload failed after create"));
17945
18108
  app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
17946
18109
  return reply.status(201).send({
17947
18110
  ...config,
@@ -18464,6 +18627,10 @@ async function orgNotificationRoutes(app) {
18464
18627
  const query = notificationQuerySchema.parse(request.query);
18465
18628
  return listNotifications(app.db, scope.organizationId, scope.memberId, query);
18466
18629
  });
18630
+ app.get("/unread-count", async (request) => {
18631
+ const scope = await requireOrgMembership(request, app.db);
18632
+ return { count: await unreadCount(app.db, scope.organizationId, scope.memberId) };
18633
+ });
18467
18634
  app.post("/:id/read", async (request) => {
18468
18635
  const scope = await requireOrgMembership(request, app.db);
18469
18636
  const result = await markRead(app.db, request.params.id, scope.organizationId, scope.memberId);
@@ -18524,24 +18691,20 @@ async function orgSessionRoutes(app) {
18524
18691
  * GET gating is per-namespace via `readPolicy` in the registry: namespaces
18525
18692
  * with no secret fields (`context_tree`, `source_repos`) are readable by
18526
18693
  * any active org member, so an invitee can see what tree and repos the
18527
- * team is bound to before joining the chat. Namespaces whose masked output
18528
- * still leaks a `…Configured` boolean (`github_integration`) stay
18529
- * admin-only. PUT and DELETE are always admin-only regardless of
18530
- * namespace — non-admins must never mutate org-wide config.
18694
+ * team is bound to before joining the chat. PUT and DELETE are always
18695
+ * admin-only regardless of namespace non-admins must never mutate
18696
+ * org-wide config.
18531
18697
  */
18532
18698
  async function orgSettingsRoutes(app) {
18533
18699
  app.get("/:namespace", async (request) => {
18534
18700
  const namespace = parseNamespace(request.params.namespace);
18535
18701
  const scope = ORG_SETTINGS_NAMESPACES$1[namespace].readPolicy === "member" ? await requireOrgMembership(request, app.db) : await requireOrgAdmin(request, app.db);
18536
- return enrichOutput(namespace, await getOrgSetting(app.db, scope.organizationId, namespace), scope.organizationId, app.config.server.publicUrl);
18702
+ return getOrgSetting(app.db, scope.organizationId, namespace);
18537
18703
  });
18538
18704
  app.put("/:namespace", { config: { otelRecordBody: true } }, async (request) => {
18539
18705
  const scope = await requireOrgAdmin(request, app.db);
18540
18706
  const namespace = parseNamespace(request.params.namespace);
18541
- return enrichOutput(namespace, await putOrgSetting(app.db, scope.organizationId, namespace, request.body, {
18542
- updatedBy: scope.userId,
18543
- encryptionKey: app.config.secrets.encryptionKey
18544
- }), scope.organizationId, app.config.server.publicUrl);
18707
+ return putOrgSetting(app.db, scope.organizationId, namespace, request.body, { updatedBy: scope.userId });
18545
18708
  });
18546
18709
  app.delete("/:namespace", async (request, reply) => {
18547
18710
  const scope = await requireOrgAdmin(request, app.db);
@@ -18554,27 +18717,6 @@ function parseNamespace(raw) {
18554
18717
  if (!isOrgSettingNamespace(raw)) throw new BadRequestError(`Unknown organization-settings namespace: "${raw}"`);
18555
18718
  return raw;
18556
18719
  }
18557
- /**
18558
- * Resolve namespace-specific server-config-derived fields. The service
18559
- * layer stays config-agnostic — namespace knowledge that needs `app.config`
18560
- * lives here. Currently only `github_integration.webhookUrl` qualifies.
18561
- *
18562
- * If `server.publicUrl` is unset on the Hub, `webhookUrl` is left as `""`
18563
- * so the UI can render a "contact your site administrator" notice rather
18564
- * than fall back to `window.location.origin` (which is wrong behind a
18565
- * reverse proxy). (#12)
18566
- */
18567
- function enrichOutput(namespace, out, orgId, publicUrl) {
18568
- if (namespace === "github_integration") {
18569
- const o = out;
18570
- const webhookUrl = publicUrl ? `${publicUrl.replace(/\/+$/, "")}/api/v1/webhooks/github/${orgId}` : "";
18571
- return {
18572
- ...o,
18573
- webhookUrl
18574
- };
18575
- }
18576
- return out;
18577
- }
18578
18720
  async function loadVisibleAgentIds(db, organizationId, memberId) {
18579
18721
  const rows = await db.select({ id: agents.uuid }).from(agents).where(and(eq(agents.organizationId, organizationId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId))));
18580
18722
  return new Set(rows.map((r) => r.id));
@@ -18795,6 +18937,128 @@ async function sessionRoutes(app) {
18795
18937
  });
18796
18938
  });
18797
18939
  }
18940
+ /**
18941
+ * GitHub-specific webhook entity → chat clustering (Phase 0).
18942
+ *
18943
+ * Each `(organization, human_agent, delegate_agent, entity)` tuple resolves to
18944
+ * exactly one chat. Future external sources (Linear, Slack, …) get their own
18945
+ * tables — their entity models differ enough that a generic table would slip
18946
+ * back into untyped jsonb.
18947
+ *
18948
+ * `bound_via` distinguishes the first-touch row (`direct`) from a row written
18949
+ * by the `Fixes #N` linker (`fixes_link`). Routing logic ignores the
18950
+ * distinction; it exists for audit and future strategy tweaks.
18951
+ */
18952
+ const githubEntityChatMappings = pgTable("github_entity_chat_mappings", {
18953
+ organizationId: text("organization_id").notNull().references(() => organizations.id),
18954
+ humanAgentId: text("human_agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
18955
+ delegateAgentId: text("delegate_agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
18956
+ entityType: text("entity_type").notNull(),
18957
+ entityKey: text("entity_key").notNull(),
18958
+ chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
18959
+ boundAt: timestamp("bound_at", { withTimezone: true }).notNull().defaultNow(),
18960
+ boundVia: text("bound_via").notNull()
18961
+ }, (table) => [primaryKey({ columns: [
18962
+ table.organizationId,
18963
+ table.humanAgentId,
18964
+ table.delegateAgentId,
18965
+ table.entityType,
18966
+ table.entityKey
18967
+ ] }), index("idx_github_entity_chat_mappings_chat").on(table.chatId)]);
18968
+ function evaluateDelegateTarget(target, sourceOrgId) {
18969
+ if (!target) return "not_found";
18970
+ if (target.organizationId !== sourceOrgId) return "cross_org";
18971
+ if (target.status !== "active") return "inactive";
18972
+ return "ok";
18973
+ }
18974
+ async function identifyActor(db, organizationId, actor, appSlug) {
18975
+ if (actor.isBot && appSlug && actor.githubLogin.toLowerCase() === `${appSlug.toLowerCase()}[bot]`) return { kind: "our-app-bot" };
18976
+ const [agentRow] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, organizationId), eq(sql`lower(${agents.name})`, actor.githubLogin.toLowerCase()))).limit(1);
18977
+ if (agentRow) return {
18978
+ kind: "agent",
18979
+ agentId: agentRow.uuid
18980
+ };
18981
+ return { kind: "external" };
18982
+ }
18983
+ /**
18984
+ * Compute the Stage 2 audience for a normalized event.
18985
+ *
18986
+ * audience = subscribed ∪ involved
18987
+ *
18988
+ * `subscribed` reads every `(human, delegate)` row already bound to
18989
+ * `(org, entity)` in `github_entity_chat_mappings`. `involved` walks
18990
+ * `event.involves` and for each login that resolves to an org-local
18991
+ * `delegate_mention`-configured agent whose target is eligible AND isn't
18992
+ * already subscribed, appends a `new` row.
18993
+ *
18994
+ * Echo filtering runs after the union: when the actor maps to an agent in
18995
+ * this org, rows where the actor is either the human or the delegate side
18996
+ * are dropped so the agent's own action doesn't bounce back. When the actor
18997
+ * is our App's bot user (DP13), the whole audience is suppressed.
18998
+ */
18999
+ async function resolveAudience(db, event, appSlug) {
19000
+ const organizationId = event.source.organizationId;
19001
+ const subscribed = (await db.select({
19002
+ humanAgentId: githubEntityChatMappings.humanAgentId,
19003
+ delegateAgentId: githubEntityChatMappings.delegateAgentId,
19004
+ chatId: githubEntityChatMappings.chatId
19005
+ }).from(githubEntityChatMappings).where(and(eq(githubEntityChatMappings.organizationId, organizationId), eq(githubEntityChatMappings.entityType, event.entity.type), eq(githubEntityChatMappings.entityKey, event.entity.key)))).map((row) => ({
19006
+ humanAgentId: row.humanAgentId,
19007
+ delegateAgentId: row.delegateAgentId,
19008
+ kind: "existing",
19009
+ chatId: row.chatId,
19010
+ involveReason: null,
19011
+ involveLogin: null
19012
+ }));
19013
+ const subscribedKeys = new Set(subscribed.map((s) => `${s.humanAgentId} ${s.delegateAgentId}`));
19014
+ const involved = [];
19015
+ if (event.involves.length > 0) {
19016
+ const candidateLogins = event.involves.map((i) => i.githubLogin.toLowerCase());
19017
+ const reasonByLogin = /* @__PURE__ */ new Map();
19018
+ for (const i of event.involves) reasonByLogin.set(i.githubLogin.toLowerCase(), i.reason);
19019
+ const candidates = await db.select({
19020
+ id: agents.uuid,
19021
+ name: agents.name,
19022
+ delegateMention: agents.delegateMention,
19023
+ status: agents.status
19024
+ }).from(agents).where(and(eq(agents.organizationId, organizationId), isNotNull(agents.delegateMention), inArray(sql`lower(${agents.name})`, candidateLogins)));
19025
+ const delegateIds = /* @__PURE__ */ new Set();
19026
+ for (const c of candidates) if (c.delegateMention) delegateIds.add(c.delegateMention);
19027
+ const delegateRows = delegateIds.size > 0 ? await db.select({
19028
+ id: agents.uuid,
19029
+ organizationId: agents.organizationId,
19030
+ status: agents.status
19031
+ }).from(agents).where(inArray(agents.uuid, [...delegateIds])) : [];
19032
+ const delegateById = /* @__PURE__ */ new Map();
19033
+ for (const row of delegateRows) delegateById.set(row.id, {
19034
+ organizationId: row.organizationId,
19035
+ status: row.status
19036
+ });
19037
+ for (const c of candidates) {
19038
+ if (c.status !== "active" || !c.delegateMention || !c.name) continue;
19039
+ if (evaluateDelegateTarget(delegateById.get(c.delegateMention), organizationId) !== "ok") continue;
19040
+ const key = `${c.id} ${c.delegateMention}`;
19041
+ if (subscribedKeys.has(key)) continue;
19042
+ const candidateLogin = c.name.toLowerCase();
19043
+ const reason = reasonByLogin.get(candidateLogin);
19044
+ if (!reason) continue;
19045
+ involved.push({
19046
+ humanAgentId: c.id,
19047
+ delegateAgentId: c.delegateMention,
19048
+ kind: "new",
19049
+ chatId: null,
19050
+ involveReason: reason,
19051
+ involveLogin: candidateLogin
19052
+ });
19053
+ }
19054
+ }
19055
+ const audience = [...subscribed, ...involved];
19056
+ if (audience.length === 0) return audience;
19057
+ const actor = await identifyActor(db, organizationId, event.actor, appSlug);
19058
+ if (actor.kind === "our-app-bot") return [];
19059
+ if (actor.kind === "agent") return audience.filter((a) => a.kind === "new" || a.humanAgentId !== actor.agentId && a.delegateAgentId !== actor.agentId);
19060
+ return audience;
19061
+ }
18798
19062
  function isRecord(value) {
18799
19063
  return typeof value === "object" && value !== null && !Array.isArray(value);
18800
19064
  }
@@ -18804,10 +19068,10 @@ function repoFullName(payload) {
18804
19068
  const repo = isRecord(payload.repository) ? payload.repository : null;
18805
19069
  return typeof repo?.full_name === "string" && repo.full_name.length > 0 ? repo.full_name : null;
18806
19070
  }
18807
- function readNumber(value) {
19071
+ function readNumber$1(value) {
18808
19072
  return typeof value === "number" && Number.isFinite(value) ? value : null;
18809
19073
  }
18810
- function readString(value) {
19074
+ function readString$1(value) {
18811
19075
  return typeof value === "string" && value.length > 0 ? value : null;
18812
19076
  }
18813
19077
  /**
@@ -18828,51 +19092,68 @@ function extractEventEntity(eventType, payload) {
18828
19092
  const repo = repoFullName(payload);
18829
19093
  if (!repo) return null;
18830
19094
  switch (eventType) {
18831
- case "issues":
19095
+ case "issues": {
19096
+ const issue = isRecord(payload.issue) ? payload.issue : null;
19097
+ const number = readNumber$1(issue?.number);
19098
+ if (number === null) return null;
19099
+ return {
19100
+ type: "issue",
19101
+ key: `${repo}#${number}`,
19102
+ title: readString$1(issue?.title) ?? void 0,
19103
+ url: readString$1(issue?.html_url) ?? void 0
19104
+ };
19105
+ }
18832
19106
  case "issue_comment": {
18833
19107
  const issue = isRecord(payload.issue) ? payload.issue : null;
18834
- const number = readNumber(issue?.number);
19108
+ const number = readNumber$1(issue?.number);
18835
19109
  if (number === null) return null;
19110
+ const prInfo = isRecord(issue?.pull_request) ? issue.pull_request : null;
19111
+ if (prInfo) return {
19112
+ type: "pull_request",
19113
+ key: `${repo}#${number}`,
19114
+ title: readString$1(issue?.title) ?? void 0,
19115
+ url: readString$1(prInfo.html_url) ?? readString$1(issue?.html_url) ?? void 0
19116
+ };
18836
19117
  return {
18837
19118
  type: "issue",
18838
19119
  key: `${repo}#${number}`,
18839
- title: readString(issue?.title) ?? void 0,
18840
- url: readString(issue?.html_url) ?? void 0
19120
+ title: readString$1(issue?.title) ?? void 0,
19121
+ url: readString$1(issue?.html_url) ?? void 0
18841
19122
  };
18842
19123
  }
18843
19124
  case "pull_request":
18844
19125
  case "pull_request_review":
18845
19126
  case "pull_request_review_comment": {
18846
19127
  const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
18847
- const number = readNumber(pr?.number);
19128
+ const number = readNumber$1(pr?.number);
18848
19129
  if (number === null) return null;
18849
19130
  return {
18850
19131
  type: "pull_request",
18851
19132
  key: `${repo}#${number}`,
18852
- title: readString(pr?.title) ?? void 0,
18853
- url: readString(pr?.html_url) ?? void 0
19133
+ title: readString$1(pr?.title) ?? void 0,
19134
+ url: readString$1(pr?.html_url) ?? void 0
18854
19135
  };
18855
19136
  }
18856
19137
  case "discussion":
18857
19138
  case "discussion_comment": {
18858
19139
  const disc = isRecord(payload.discussion) ? payload.discussion : null;
18859
- const number = readNumber(disc?.number);
19140
+ const number = readNumber$1(disc?.number);
18860
19141
  if (number === null) return null;
18861
19142
  return {
18862
19143
  type: "discussion",
18863
19144
  key: `${repo}#discussion-${number}`,
18864
- title: readString(disc?.title) ?? void 0,
18865
- url: readString(disc?.html_url) ?? void 0
19145
+ title: readString$1(disc?.title) ?? void 0,
19146
+ url: readString$1(disc?.html_url) ?? void 0
18866
19147
  };
18867
19148
  }
18868
19149
  case "commit_comment": {
18869
19150
  const comment = isRecord(payload.comment) ? payload.comment : null;
18870
- const sha = readString(comment?.commit_id);
19151
+ const sha = readString$1(comment?.commit_id);
18871
19152
  if (!sha) return null;
18872
19153
  return {
18873
19154
  type: "commit",
18874
19155
  key: `${repo}@${sha}`,
18875
- url: readString(comment?.html_url) ?? void 0
19156
+ url: readString$1(comment?.html_url) ?? void 0
18876
19157
  };
18877
19158
  }
18878
19159
  default: return null;
@@ -18959,92 +19240,6 @@ function formatEntityTitle(entity, eventType, action) {
18959
19240
  if (entity.title && entity.title.length > 0) return `${head}: ${entity.title}`;
18960
19241
  return head;
18961
19242
  }
18962
- const SILENT_EVENT_TYPES = new Set([
18963
- "workflow_run",
18964
- "workflow_job",
18965
- "check_run",
18966
- "check_suite",
18967
- "status",
18968
- "push",
18969
- "create",
18970
- "delete",
18971
- "fork",
18972
- "watch",
18973
- "release",
18974
- "label",
18975
- "label_created",
18976
- "label_deleted",
18977
- "reaction",
18978
- "member",
18979
- "membership",
18980
- "team",
18981
- "team_add",
18982
- "organization",
18983
- "org_block",
18984
- "project",
18985
- "project_card",
18986
- "project_column"
18987
- ]);
18988
- /**
18989
- * Per-event-type action-level filters. Frequent low-signal actions that would
18990
- * otherwise spam an entity chat. `synchronize` (PR branch push) is the most
18991
- * common offender — it fires on every commit push to a PR branch and never
18992
- * carries new conversation.
18993
- */
18994
- const SILENT_ACTIONS = {
18995
- issues: new Set([
18996
- "labeled",
18997
- "unlabeled",
18998
- "milestoned",
18999
- "demilestoned",
19000
- "pinned",
19001
- "unpinned"
19002
- ]),
19003
- pull_request: new Set([
19004
- "labeled",
19005
- "unlabeled",
19006
- "auto_merge_enabled",
19007
- "auto_merge_disabled",
19008
- "synchronize"
19009
- ])
19010
- };
19011
- /** True iff the event should be silently 200-OKed without further routing. */
19012
- function shouldSilent(eventType, payload) {
19013
- if (SILENT_EVENT_TYPES.has(eventType)) return true;
19014
- if (!isRecord(payload)) return false;
19015
- if (readString((isRecord(payload.sender) ? payload.sender : null)?.type) === "Bot") return true;
19016
- const action = readString(payload.action);
19017
- if (!action) return false;
19018
- return SILENT_ACTIONS[eventType]?.has(action) ?? false;
19019
- }
19020
- /**
19021
- * GitHub-specific webhook entity → chat clustering (Phase 0).
19022
- *
19023
- * Each `(organization, human_agent, delegate_agent, entity)` tuple resolves to
19024
- * exactly one chat. Future external sources (Linear, Slack, …) get their own
19025
- * tables — their entity models differ enough that a generic table would slip
19026
- * back into untyped jsonb.
19027
- *
19028
- * `bound_via` distinguishes the first-touch row (`direct`) from a row written
19029
- * by the `Fixes #N` linker (`fixes_link`). Routing logic ignores the
19030
- * distinction; it exists for audit and future strategy tweaks.
19031
- */
19032
- const githubEntityChatMappings = pgTable("github_entity_chat_mappings", {
19033
- organizationId: text("organization_id").notNull().references(() => organizations.id),
19034
- humanAgentId: text("human_agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
19035
- delegateAgentId: text("delegate_agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
19036
- entityType: text("entity_type").notNull(),
19037
- entityKey: text("entity_key").notNull(),
19038
- chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
19039
- boundAt: timestamp("bound_at", { withTimezone: true }).notNull().defaultNow(),
19040
- boundVia: text("bound_via").notNull()
19041
- }, (table) => [primaryKey({ columns: [
19042
- table.organizationId,
19043
- table.humanAgentId,
19044
- table.delegateAgentId,
19045
- table.entityType,
19046
- table.entityKey
19047
- ] }), index("idx_github_entity_chat_mappings_chat").on(table.chatId)]);
19048
19243
  /**
19049
19244
  * Resolve which chat a GitHub event for (human, delegate, entity) belongs to.
19050
19245
  *
@@ -19162,160 +19357,693 @@ async function createEntityChat(db, humanAgentId, delegateAgentId, entity, event
19162
19357
  metadata
19163
19358
  })).id };
19164
19359
  }
19165
- const log$1 = createLogger$1("GithubWebhook");
19166
- function verifySignature(secret, rawBody, signatureHeader) {
19167
- const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
19168
- const expectedBuf = Buffer.from(expected, "utf8");
19169
- const receivedBuf = Buffer.from(signatureHeader, "utf8");
19170
- if (expectedBuf.length !== receivedBuf.length || !timingSafeEqual(expectedBuf, receivedBuf)) throw new UnauthorizedError("Invalid webhook signature");
19360
+ const log$2 = createLogger$1("GithubDelivery");
19361
+ /**
19362
+ * Stage 3 actually emit one card per audience target.
19363
+ *
19364
+ * `existing` targets carry their chatId and short-circuit straight to the
19365
+ * send. `new` targets go through `resolveTargetChat` which performs the
19366
+ * §4.4 direct / fixes_link / fresh-chat lookup and writes the mapping row.
19367
+ * Each target's row is delivered + dispatched independently so a single
19368
+ * failure (e.g. cross-org rejection on chat creation) doesn't poison the
19369
+ * whole audience — the loop logs and continues.
19370
+ *
19371
+ * The card `reason` is `subscribed` for existing rows and the new target's
19372
+ * `involveReason` for involved rows; the surface event payload is the same
19373
+ * either way.
19374
+ */
19375
+ async function deliverNormalizedEvent(app, event, audience) {
19376
+ const stats = {
19377
+ delivered: 0,
19378
+ newChats: 0
19379
+ };
19380
+ for (const target of audience) try {
19381
+ const resolved = await resolveChatFor(app, event, target);
19382
+ if (resolved.created) stats.newChats += 1;
19383
+ const card = buildCard(event, target);
19384
+ const mentionedUser = card.mentionedUser ?? void 0;
19385
+ const { message, recipients } = await sendMessage(app.db, resolved.chatId, target.humanAgentId, {
19386
+ format: "card",
19387
+ content: card,
19388
+ metadata: {
19389
+ source: "github",
19390
+ event: event.rawEventType,
19391
+ action: event.rawAction,
19392
+ entityType: event.entity.type,
19393
+ entityKey: event.entity.key,
19394
+ reason: card.reason,
19395
+ ...mentionedUser ? { mentionedUser } : {}
19396
+ }
19397
+ });
19398
+ notifyRecipients(app.notifier, recipients, message.id);
19399
+ stats.delivered += 1;
19400
+ } catch (err) {
19401
+ log$2.error({
19402
+ err,
19403
+ humanAgent: target.humanAgentId,
19404
+ delegateAgent: target.delegateAgentId,
19405
+ entityType: event.entity.type,
19406
+ entityKey: event.entity.key
19407
+ }, "failed to deliver normalized github event to target");
19408
+ }
19409
+ return stats;
19171
19410
  }
19172
- /** Extract unique @mentions from text. Returns lowercase usernames.
19173
- * Excludes email patterns (user@example.com) and team mentions (@org/team). */
19174
- function extractMentions$1(text) {
19411
+ async function resolveChatFor(app, event, target) {
19412
+ if (target.kind === "existing") {
19413
+ if (!target.chatId) throw new Error("audience target kind=existing must carry chatId");
19414
+ return {
19415
+ chatId: target.chatId,
19416
+ created: false
19417
+ };
19418
+ }
19419
+ const entity = {
19420
+ type: event.entity.type,
19421
+ key: event.entity.key,
19422
+ title: event.entity.title,
19423
+ url: event.entity.url
19424
+ };
19425
+ const relatedEntities = event.relatedRefs.map((ref) => ({
19426
+ type: "issue",
19427
+ key: ref.key
19428
+ }));
19429
+ const resolved = await resolveTargetChat(app.db, {
19430
+ organizationId: event.source.organizationId,
19431
+ humanAgentId: target.humanAgentId,
19432
+ delegateAgentId: target.delegateAgentId,
19433
+ entity,
19434
+ relatedEntities,
19435
+ eventType: event.rawEventType,
19436
+ action: event.rawAction ?? ""
19437
+ });
19438
+ return {
19439
+ chatId: resolved.chatId,
19440
+ created: resolved.created
19441
+ };
19442
+ }
19443
+ function buildCard(event, target) {
19444
+ const card = {
19445
+ type: "github_event",
19446
+ reason: target.kind === "existing" ? "subscribed" : target.involveReason ?? "mentioned",
19447
+ event: event.rawEventType,
19448
+ action: event.rawAction,
19449
+ kind: event.kind,
19450
+ repository: event.entity.repo,
19451
+ sender: event.actor.githubLogin,
19452
+ title: event.surface.title,
19453
+ body: event.surface.body,
19454
+ url: event.surface.url,
19455
+ entity: {
19456
+ type: event.entity.type,
19457
+ key: event.entity.key,
19458
+ url: event.entity.url ?? null
19459
+ }
19460
+ };
19461
+ if (target.involveLogin) card.mentionedUser = target.involveLogin;
19462
+ return card;
19463
+ }
19464
+ const MENTION_REGEX$1 = /(?<!\w)@([a-zA-Z0-9][\w-]*)(\/)?/g;
19465
+ /** Lower-cased unique @mention logins from free-form text. Skips team
19466
+ * mentions (`@org/team`) to match the agent-name lookup downstream
19467
+ * (`agents.name` doesn't carry slashes). */
19468
+ function extractMentions$1(text) {
19175
19469
  if (!text) return [];
19176
- const re = /(?<!\w)@([a-zA-Z0-9][\w-]*)(\/)?/g;
19177
19470
  const names = /* @__PURE__ */ new Set();
19178
- for (const m of text.matchAll(re)) {
19471
+ for (const m of text.matchAll(MENTION_REGEX$1)) {
19179
19472
  if (m[2]) continue;
19180
- names.add(m[1].toLowerCase());
19473
+ const login = m[1];
19474
+ if (!login) continue;
19475
+ names.add(login.toLowerCase());
19181
19476
  }
19182
19477
  return [...names];
19183
19478
  }
19184
- /** Extract mentions from structural payload fields (not free-form text).
19185
- * GitHub's `pull_request.review_requested` puts the targeted reviewer in
19186
- * `requested_reviewer.login`, not in any text body — `extractMentions` would
19187
- * miss it. Team requests use `requested_team` instead, which we deliberately
19188
- * skip to stay consistent with `extractMentions` ignoring `@org/team`. */
19189
- function extractStructuralMentions(eventType, payload) {
19190
- if (!isRecord(payload)) return [];
19191
- if (eventType !== "pull_request") return [];
19192
- if (payload.action !== "review_requested") return [];
19193
- const reviewer = isRecord(payload.requested_reviewer) ? payload.requested_reviewer : null;
19194
- const login = typeof reviewer?.login === "string" ? reviewer.login : null;
19195
- return login ? [login.toLowerCase()] : [];
19196
- }
19197
- const DELEGATE_VERDICT_MESSAGES = {
19198
- ok: "delegate_mention target eligible",
19199
- not_found: "delegate_mention target not found, skipping",
19200
- cross_org: "delegate_mention target belongs to another org, skipping",
19201
- inactive: "delegate_mention target not active, skipping"
19202
- };
19203
- function evaluateDelegateTarget(target, sourceOrgId) {
19204
- if (!target) return "not_found";
19205
- if (target.organizationId !== sourceOrgId) return "cross_org";
19206
- if (target.status !== "active") return "inactive";
19207
- return "ok";
19479
+ function readString(value) {
19480
+ return typeof value === "string" && value.length > 0 ? value : null;
19481
+ }
19482
+ function readNumber(value) {
19483
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
19484
+ }
19485
+ function readStringArray(value) {
19486
+ if (!Array.isArray(value)) return [];
19487
+ const out = [];
19488
+ for (const item of value) if (isRecord(item)) {
19489
+ const login = readString(item.login);
19490
+ if (login) out.push(login.toLowerCase());
19491
+ }
19492
+ return out;
19493
+ }
19494
+ function buildInvolves(items) {
19495
+ const seen = /* @__PURE__ */ new Set();
19496
+ const out = [];
19497
+ for (const group of items) for (const login of group.logins) {
19498
+ const key = login.toLowerCase();
19499
+ if (seen.has(key)) continue;
19500
+ seen.add(key);
19501
+ out.push({
19502
+ githubLogin: key,
19503
+ reason: group.reason
19504
+ });
19505
+ }
19506
+ return out;
19507
+ }
19508
+ function entitySurfacePrefix(entity) {
19509
+ switch (entity.type) {
19510
+ case "pull_request": return "PR";
19511
+ case "issue": return "Issue";
19512
+ case "discussion": return "Discussion";
19513
+ case "commit": return "Commit";
19514
+ }
19515
+ }
19516
+ function entitySurfaceTitle(entity, number) {
19517
+ const prefix = entitySurfacePrefix(entity);
19518
+ const head = number !== null ? `${prefix} #${number}` : prefix;
19519
+ return entity.title ? `${head}: ${entity.title}` : head;
19208
19520
  }
19209
19521
  /**
19210
- * Route @mentions to delegate agents.
19522
+ * Stage 1 pure normalization. Returns the structured event for downstream
19523
+ * audience + delivery, or `null` for events we deliberately drop (silent /
19524
+ * out-of-scope event types and actions, malformed payloads, …).
19211
19525
  *
19212
- * For each mentioned GitHub user who maps to an agent with `delegate_mention`
19213
- * configured, resolve which chat the event belongs to (via §4.4's
19214
- * entity-clustering rules) and post a card from the human-bound agent to its
19215
- * delegate.
19216
- *
19217
- * The entity argument is the §4.2 entity for the current event; `relatedRefs`
19218
- * is the parsed `Fixes #N` list (empty for non-PR events). Both are
19219
- * pre-computed by the caller so the heavy parsing doesn't run once per
19220
- * mention.
19526
+ * Pure function: no DB, no chat, no network. Caller is expected to hand the
19527
+ * raw payload, the wire event type from `x-github-event`, and the source +
19528
+ * deliveryId already resolved by the route handler.
19221
19529
  */
19222
- async function routeMentionDelegations(app, organizationId, mentionedNames, ctx, entity, relatedRefs) {
19223
- if (mentionedNames.length === 0) return 0;
19224
- const delegates = await app.db.select({
19225
- id: agents.uuid,
19226
- name: agents.name,
19227
- delegateMention: agents.delegateMention,
19228
- status: agents.status
19229
- }).from(agents).where(and(eq(agents.organizationId, organizationId), inArray(agents.name, mentionedNames), isNotNull(agents.delegateMention)));
19230
- let routed = 0;
19231
- for (const agent of delegates) {
19232
- if (agent.status !== "active" || !agent.delegateMention) continue;
19233
- const [target] = await app.db.select({
19234
- id: agents.uuid,
19235
- status: agents.status,
19236
- organizationId: agents.organizationId
19237
- }).from(agents).where(eq(agents.uuid, agent.delegateMention)).limit(1);
19238
- const verdict = evaluateDelegateTarget(target, organizationId);
19239
- if (verdict !== "ok") {
19240
- log$1.warn({
19241
- targetAgent: agent.delegateMention,
19242
- sourceAgent: agent.name,
19243
- sourceOrg: organizationId,
19244
- targetOrg: target?.organizationId,
19245
- targetStatus: target?.status,
19246
- verdict
19247
- }, DELEGATE_VERDICT_MESSAGES[verdict]);
19248
- continue;
19530
+ function normalizeGithubEvent(eventType, payload, source, deliveryId) {
19531
+ if (!isRecord(payload)) return null;
19532
+ const senderRec = isRecord(payload.sender) ? payload.sender : null;
19533
+ const senderLogin = readString(senderRec?.login);
19534
+ if (!senderLogin) return null;
19535
+ const senderIsBot = readString(senderRec?.type) === "Bot";
19536
+ const repo = readString((isRecord(payload.repository) ? payload.repository : null)?.full_name);
19537
+ if (!repo) return null;
19538
+ const action = readString(payload.action);
19539
+ const rule = buildRule(eventType, action, payload, repo);
19540
+ if (!rule) return null;
19541
+ return {
19542
+ source,
19543
+ deliveryId,
19544
+ rawEventType: eventType,
19545
+ rawAction: action,
19546
+ entity: {
19547
+ type: rule.entity.type,
19548
+ repo,
19549
+ key: rule.entity.key,
19550
+ title: rule.entity.title,
19551
+ url: rule.entity.url
19552
+ },
19553
+ actor: {
19554
+ githubLogin: senderLogin,
19555
+ isBot: senderIsBot
19556
+ },
19557
+ kind: rule.kind,
19558
+ involves: rule.involves,
19559
+ surface: rule.surface,
19560
+ relatedRefs: rule.relatedRefs
19561
+ };
19562
+ }
19563
+ function buildRule(eventType, action, payload, repo) {
19564
+ switch (eventType) {
19565
+ case "pull_request": return buildPullRequestRule(action, payload, repo);
19566
+ case "pull_request_review": return buildPullRequestReviewRule(action, payload);
19567
+ case "pull_request_review_comment": return buildPullRequestReviewCommentRule(action, payload);
19568
+ case "issue_comment": return buildIssueCommentRule(action, payload);
19569
+ case "issues": return buildIssuesRule(action, payload);
19570
+ case "discussion": return buildDiscussionRule(action, payload);
19571
+ case "discussion_comment": return buildDiscussionCommentRule(action, payload);
19572
+ case "commit_comment": return buildCommitCommentRule(action, payload);
19573
+ default: return null;
19574
+ }
19575
+ }
19576
+ function buildPullRequestRule(action, payload, repo) {
19577
+ const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
19578
+ if (!pr) return null;
19579
+ const entity = extractEventEntity("pull_request", payload);
19580
+ if (!entity) return null;
19581
+ const number = readNumber(pr.number);
19582
+ const body = readString(pr.body) ?? "";
19583
+ const surface = {
19584
+ title: entitySurfaceTitle(entity, number),
19585
+ body,
19586
+ url: readString(pr.html_url) ?? ""
19587
+ };
19588
+ switch (action) {
19589
+ case "opened": {
19590
+ const assigneeLogins = readStringArray(pr.assignees);
19591
+ const mentionLogins = extractMentions$1(body);
19592
+ return {
19593
+ entity,
19594
+ kind: "opened",
19595
+ involves: buildInvolves([{
19596
+ logins: assigneeLogins,
19597
+ reason: "assigned"
19598
+ }, {
19599
+ logins: mentionLogins,
19600
+ reason: "mentioned"
19601
+ }]),
19602
+ surface,
19603
+ relatedRefs: parseFixesRefs(body, repo).map((ref) => ({
19604
+ type: "issue",
19605
+ key: ref.key
19606
+ }))
19607
+ };
19249
19608
  }
19250
- try {
19251
- const resolved = await resolveTargetChat(app.db, {
19252
- organizationId,
19253
- humanAgentId: agent.id,
19254
- delegateAgentId: agent.delegateMention,
19609
+ case "edited": return {
19610
+ entity,
19611
+ kind: "edited",
19612
+ involves: buildInvolves([{
19613
+ logins: extractMentions$1(body),
19614
+ reason: "mentioned"
19615
+ }]),
19616
+ surface,
19617
+ relatedRefs: []
19618
+ };
19619
+ case "review_requested": {
19620
+ const reviewerLogin = readString((isRecord(payload.requested_reviewer) ? payload.requested_reviewer : null)?.login);
19621
+ return {
19255
19622
  entity,
19256
- relatedEntities: relatedRefs,
19257
- eventType: ctx.event,
19258
- action: ctx.action ?? ""
19259
- });
19260
- log$1.info({
19261
- chatId: resolved.chatId,
19262
- entityType: entity.type,
19263
- entityKey: entity.key,
19264
- boundVia: resolved.boundVia,
19265
- created: resolved.created,
19266
- humanAgent: agent.name
19267
- }, "resolved entity chat");
19268
- const { message: msg, recipients } = await sendMessage(app.db, resolved.chatId, agent.id, {
19269
- format: "card",
19270
- content: {
19271
- type: "github_mention",
19272
- mentionedUser: agent.name,
19273
- event: ctx.event,
19274
- action: ctx.action,
19275
- repository: ctx.repository,
19276
- sender: ctx.sender,
19277
- title: ctx.title,
19278
- body: ctx.body,
19279
- url: ctx.url,
19280
- entity: {
19281
- type: entity.type,
19282
- key: entity.key,
19283
- url: entity.url ?? null
19284
- }
19623
+ kind: "review_requested",
19624
+ involves: buildInvolves([{
19625
+ logins: reviewerLogin ? [reviewerLogin.toLowerCase()] : [],
19626
+ reason: "review_requested"
19627
+ }]),
19628
+ surface,
19629
+ relatedRefs: []
19630
+ };
19631
+ }
19632
+ case "ready_for_review": {
19633
+ const reviewerLogins = readStringArray(pr.requested_reviewers);
19634
+ if (reviewerLogins.length === 0) return null;
19635
+ return {
19636
+ entity,
19637
+ kind: "review_requested",
19638
+ involves: buildInvolves([{
19639
+ logins: reviewerLogins,
19640
+ reason: "review_requested"
19641
+ }]),
19642
+ surface,
19643
+ relatedRefs: []
19644
+ };
19645
+ }
19646
+ case "assigned": {
19647
+ const assigneeLogin = readString((isRecord(payload.assignee) ? payload.assignee : null)?.login);
19648
+ if (!assigneeLogin) return null;
19649
+ return {
19650
+ entity,
19651
+ kind: "assigned",
19652
+ involves: buildInvolves([{
19653
+ logins: [assigneeLogin.toLowerCase()],
19654
+ reason: "assigned"
19655
+ }]),
19656
+ surface,
19657
+ relatedRefs: []
19658
+ };
19659
+ }
19660
+ case "synchronize": return {
19661
+ entity,
19662
+ kind: "synchronized",
19663
+ involves: [],
19664
+ surface,
19665
+ relatedRefs: []
19666
+ };
19667
+ default: return null;
19668
+ }
19669
+ }
19670
+ function buildPullRequestReviewRule(action, payload) {
19671
+ if (action !== "submitted" && action !== "dismissed" && action !== "edited") return null;
19672
+ const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
19673
+ const review = isRecord(payload.review) ? payload.review : null;
19674
+ if (!pr || !review) return null;
19675
+ const entity = extractEventEntity("pull_request_review", payload);
19676
+ if (!entity) return null;
19677
+ const number = readNumber(pr.number);
19678
+ const body = readString(review.body) ?? "";
19679
+ return {
19680
+ entity,
19681
+ kind: "reviewed",
19682
+ involves: buildInvolves([{
19683
+ logins: extractMentions$1(body),
19684
+ reason: "mentioned"
19685
+ }]),
19686
+ surface: {
19687
+ title: entitySurfaceTitle(entity, number),
19688
+ body,
19689
+ url: readString(review.html_url) ?? ""
19690
+ },
19691
+ relatedRefs: []
19692
+ };
19693
+ }
19694
+ function buildPullRequestReviewCommentRule(action, payload) {
19695
+ if (action !== "created" && action !== "edited") return null;
19696
+ const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
19697
+ const comment = isRecord(payload.comment) ? payload.comment : null;
19698
+ if (!pr || !comment) return null;
19699
+ const entity = extractEventEntity("pull_request_review_comment", payload);
19700
+ if (!entity) return null;
19701
+ const number = readNumber(pr.number);
19702
+ const body = readString(comment.body) ?? "";
19703
+ return {
19704
+ entity,
19705
+ kind: "review_comment",
19706
+ involves: buildInvolves([{
19707
+ logins: extractMentions$1(body),
19708
+ reason: "mentioned"
19709
+ }]),
19710
+ surface: {
19711
+ title: entitySurfaceTitle(entity, number),
19712
+ body,
19713
+ url: readString(comment.html_url) ?? ""
19714
+ },
19715
+ relatedRefs: []
19716
+ };
19717
+ }
19718
+ function buildIssueCommentRule(action, payload) {
19719
+ if (action !== "created" && action !== "edited") return null;
19720
+ const issue = isRecord(payload.issue) ? payload.issue : null;
19721
+ const comment = isRecord(payload.comment) ? payload.comment : null;
19722
+ if (!issue || !comment) return null;
19723
+ const entity = extractEventEntity("issue_comment", payload);
19724
+ if (!entity) return null;
19725
+ const number = readNumber(issue.number);
19726
+ const body = readString(comment.body) ?? "";
19727
+ return {
19728
+ entity,
19729
+ kind: "commented",
19730
+ involves: buildInvolves([{
19731
+ logins: extractMentions$1(body),
19732
+ reason: "mentioned"
19733
+ }]),
19734
+ surface: {
19735
+ title: entitySurfaceTitle(entity, number),
19736
+ body,
19737
+ url: readString(comment.html_url) ?? ""
19738
+ },
19739
+ relatedRefs: []
19740
+ };
19741
+ }
19742
+ function buildIssuesRule(action, payload) {
19743
+ const issue = isRecord(payload.issue) ? payload.issue : null;
19744
+ if (!issue) return null;
19745
+ const entity = extractEventEntity("issues", payload);
19746
+ if (!entity) return null;
19747
+ const number = readNumber(issue.number);
19748
+ const body = readString(issue.body) ?? "";
19749
+ const url = readString(issue.html_url) ?? "";
19750
+ const title = entitySurfaceTitle(entity, number);
19751
+ switch (action) {
19752
+ case "opened": {
19753
+ const assigneeLogins = readStringArray(issue.assignees);
19754
+ const mentionLogins = extractMentions$1(body);
19755
+ return {
19756
+ entity,
19757
+ kind: "opened",
19758
+ involves: buildInvolves([{
19759
+ logins: assigneeLogins,
19760
+ reason: "assigned"
19761
+ }, {
19762
+ logins: mentionLogins,
19763
+ reason: "mentioned"
19764
+ }]),
19765
+ surface: {
19766
+ title,
19767
+ body,
19768
+ url
19285
19769
  },
19286
- metadata: {
19287
- source: "github",
19288
- event: "mention_delegation",
19289
- mentionedUser: agent.name,
19290
- action: ctx.action,
19291
- entityType: entity.type,
19292
- entityKey: entity.key
19293
- }
19294
- });
19295
- notifyRecipients(app.notifier, recipients, msg.id);
19296
- routed++;
19297
- } catch (err) {
19298
- log$1.error({
19299
- err,
19300
- sourceAgent: agent.name,
19301
- targetAgent: agent.delegateMention
19302
- }, "failed to route mention delegation");
19770
+ relatedRefs: []
19771
+ };
19772
+ }
19773
+ case "edited": return {
19774
+ entity,
19775
+ kind: "edited",
19776
+ involves: buildInvolves([{
19777
+ logins: extractMentions$1(body),
19778
+ reason: "mentioned"
19779
+ }]),
19780
+ surface: {
19781
+ title,
19782
+ body,
19783
+ url
19784
+ },
19785
+ relatedRefs: []
19786
+ };
19787
+ case "assigned": {
19788
+ const login = readString((isRecord(payload.assignee) ? payload.assignee : null)?.login);
19789
+ if (!login) return null;
19790
+ return {
19791
+ entity,
19792
+ kind: "assigned",
19793
+ involves: buildInvolves([{
19794
+ logins: [login.toLowerCase()],
19795
+ reason: "assigned"
19796
+ }]),
19797
+ surface: {
19798
+ title,
19799
+ body,
19800
+ url
19801
+ },
19802
+ relatedRefs: []
19803
+ };
19303
19804
  }
19805
+ case "closed": return {
19806
+ entity,
19807
+ kind: "closed",
19808
+ involves: [],
19809
+ surface: {
19810
+ title,
19811
+ body,
19812
+ url
19813
+ },
19814
+ relatedRefs: []
19815
+ };
19816
+ case "reopened": return {
19817
+ entity,
19818
+ kind: "reopened",
19819
+ involves: [],
19820
+ surface: {
19821
+ title,
19822
+ body,
19823
+ url
19824
+ },
19825
+ relatedRefs: []
19826
+ };
19827
+ default: return null;
19304
19828
  }
19305
- return routed;
19306
19829
  }
19307
- async function githubWebhookRoutes(app) {
19830
+ function buildDiscussionRule(action, payload) {
19831
+ const disc = isRecord(payload.discussion) ? payload.discussion : null;
19832
+ if (!disc) return null;
19833
+ const entity = extractEventEntity("discussion", payload);
19834
+ if (!entity) return null;
19835
+ const number = readNumber(disc.number);
19836
+ const body = readString(disc.body) ?? "";
19837
+ const url = readString(disc.html_url) ?? "";
19838
+ const title = entitySurfaceTitle(entity, number);
19839
+ switch (action) {
19840
+ case "created": return {
19841
+ entity,
19842
+ kind: "opened",
19843
+ involves: buildInvolves([{
19844
+ logins: extractMentions$1(body),
19845
+ reason: "mentioned"
19846
+ }]),
19847
+ surface: {
19848
+ title,
19849
+ body,
19850
+ url
19851
+ },
19852
+ relatedRefs: []
19853
+ };
19854
+ case "edited": return {
19855
+ entity,
19856
+ kind: "edited",
19857
+ involves: buildInvolves([{
19858
+ logins: extractMentions$1(body),
19859
+ reason: "mentioned"
19860
+ }]),
19861
+ surface: {
19862
+ title,
19863
+ body,
19864
+ url
19865
+ },
19866
+ relatedRefs: []
19867
+ };
19868
+ case "closed": return {
19869
+ entity,
19870
+ kind: "closed",
19871
+ involves: [],
19872
+ surface: {
19873
+ title,
19874
+ body,
19875
+ url
19876
+ },
19877
+ relatedRefs: []
19878
+ };
19879
+ case "reopened": return {
19880
+ entity,
19881
+ kind: "reopened",
19882
+ involves: [],
19883
+ surface: {
19884
+ title,
19885
+ body,
19886
+ url
19887
+ },
19888
+ relatedRefs: []
19889
+ };
19890
+ case "answered":
19891
+ case "unanswered": return {
19892
+ entity,
19893
+ kind: "other",
19894
+ involves: [],
19895
+ surface: {
19896
+ title,
19897
+ body,
19898
+ url
19899
+ },
19900
+ relatedRefs: []
19901
+ };
19902
+ default: return null;
19903
+ }
19904
+ }
19905
+ function buildDiscussionCommentRule(action, payload) {
19906
+ if (action !== "created" && action !== "edited") return null;
19907
+ const disc = isRecord(payload.discussion) ? payload.discussion : null;
19908
+ const comment = isRecord(payload.comment) ? payload.comment : null;
19909
+ if (!disc || !comment) return null;
19910
+ const entity = extractEventEntity("discussion_comment", payload);
19911
+ if (!entity) return null;
19912
+ const number = readNumber(disc.number);
19913
+ const body = readString(comment.body) ?? "";
19914
+ return {
19915
+ entity,
19916
+ kind: "commented",
19917
+ involves: buildInvolves([{
19918
+ logins: extractMentions$1(body),
19919
+ reason: "mentioned"
19920
+ }]),
19921
+ surface: {
19922
+ title: entitySurfaceTitle(entity, number),
19923
+ body,
19924
+ url: readString(comment.html_url) ?? ""
19925
+ },
19926
+ relatedRefs: []
19927
+ };
19928
+ }
19929
+ function buildCommitCommentRule(action, payload) {
19930
+ if (action !== "created") return null;
19931
+ const comment = isRecord(payload.comment) ? payload.comment : null;
19932
+ if (!comment) return null;
19933
+ const entity = extractEventEntity("commit_comment", payload);
19934
+ if (!entity) return null;
19935
+ const body = readString(comment.body) ?? "";
19936
+ return {
19937
+ entity,
19938
+ kind: "commit_commented",
19939
+ involves: buildInvolves([{
19940
+ logins: extractMentions$1(body),
19941
+ reason: "mentioned"
19942
+ }]),
19943
+ surface: {
19944
+ title: entitySurfaceTitle(entity, null),
19945
+ body,
19946
+ url: readString(comment.html_url) ?? ""
19947
+ },
19948
+ relatedRefs: []
19949
+ };
19950
+ }
19951
+ const log$1 = createLogger$1("GithubAppWebhook");
19952
+ function verifySignature(secret, rawBody, signatureHeader) {
19953
+ const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
19954
+ const expectedBuf = Buffer.from(expected, "utf8");
19955
+ const receivedBuf = Buffer.from(signatureHeader, "utf8");
19956
+ if (expectedBuf.length !== receivedBuf.length || !timingSafeEqual(expectedBuf, receivedBuf)) throw new UnauthorizedError("Invalid webhook signature");
19957
+ }
19958
+ function readInstallationId(payload) {
19959
+ if (!isRecord(payload)) return null;
19960
+ const id = (isRecord(payload.installation) ? payload.installation : null)?.id;
19961
+ return typeof id === "number" && Number.isFinite(id) ? id : null;
19962
+ }
19963
+ function parseInstallationMetadata(installation) {
19964
+ const id = installation.id;
19965
+ if (typeof id !== "number" || !Number.isFinite(id)) return null;
19966
+ const account = isRecord(installation.account) ? installation.account : null;
19967
+ if (!account) return null;
19968
+ const accountId = account.id;
19969
+ const accountLogin = account.login;
19970
+ const accountType = account.type;
19971
+ if (typeof accountId !== "number" || typeof accountLogin !== "string") return null;
19972
+ if (accountType !== "User" && accountType !== "Organization") return null;
19973
+ const permissionsParsed = githubAppInstallationPermissionsSchema$1.safeParse(installation.permissions);
19974
+ return {
19975
+ id,
19976
+ accountType,
19977
+ accountLogin,
19978
+ accountGithubId: accountId,
19979
+ permissions: permissionsParsed.success ? permissionsParsed.data : {},
19980
+ events: Array.isArray(installation.events) ? installation.events.filter((e) => typeof e === "string") : [],
19981
+ suspendedAt: typeof installation.suspended_at === "string" ? installation.suspended_at : null
19982
+ };
19983
+ }
19984
+ async function handleInstallationLifecycle(app, eventType, payload) {
19985
+ if (!isRecord(payload)) return "ignored:malformed";
19986
+ if (eventType === "installation_repositories") return "noop";
19987
+ const action = typeof payload.action === "string" ? payload.action : null;
19988
+ const installation = isRecord(payload.installation) ? payload.installation : null;
19989
+ const installationId = installation && typeof installation.id === "number" ? installation.id : null;
19990
+ if (!installation || installationId === null) return "ignored:malformed";
19991
+ switch (action) {
19992
+ case "created":
19993
+ case "new_permissions_accepted": {
19994
+ const metadata = parseInstallationMetadata(installation);
19995
+ if (!metadata) return "ignored:malformed";
19996
+ await upsertInstallationFromMetadata(app.db, { installation: metadata });
19997
+ return action;
19998
+ }
19999
+ case "deleted":
20000
+ await deleteInstallationByGithubId(app.db, installationId);
20001
+ return "deleted";
20002
+ case "suspend": {
20003
+ const suspendedAtRaw = installation.suspended_at;
20004
+ const suspendedAt = typeof suspendedAtRaw === "string" ? new Date(suspendedAtRaw) : /* @__PURE__ */ new Date();
20005
+ await markInstallationSuspended(app.db, installationId, suspendedAt);
20006
+ return "suspended";
20007
+ }
20008
+ case "unsuspend":
20009
+ await markInstallationUnsuspended(app.db, installationId, /* @__PURE__ */ new Date());
20010
+ return "unsuspended";
20011
+ default: return "ignored:unknown-action";
20012
+ }
20013
+ }
20014
+ /**
20015
+ * GitHub App webhook ingestion — single SaaS-wide endpoint. Replaces the
20016
+ * legacy `/webhooks/github/:orgId` per-org endpoint. Wiring:
20017
+ *
20018
+ * 1. HMAC verify (server-level App webhook secret, NOT per-org)
20019
+ * 2. ping → 200 fast-path
20020
+ * 3. installation / installation_repositories → lifecycle handler, NOT
20021
+ * the normalize pipeline (these events shouldn't fan out as cards)
20022
+ * 4. other events → installation.id → hub_organization_id reverse-lookup,
20023
+ * then Stage 1 normalize → claimEvent → Stage 2 audience → Stage 3
20024
+ * deliver. unclaimEvent on handler failure so GitHub's retry has a
20025
+ * chance to clear.
20026
+ *
20027
+ * Routes return 200 for "ignored" cases (no installation context, not
20028
+ * bound, suspended, duplicate delivery) so GitHub doesn't accumulate
20029
+ * retries for events the operator can't act on.
20030
+ */
20031
+ async function githubAppWebhookRoutes(app) {
19308
20032
  app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_request, body, done) => {
19309
20033
  done(null, body);
19310
20034
  });
19311
- const webhookMax = app.config.rateLimit?.webhookMax ?? 60;
19312
- app.post("/github/:orgId", { config: { rateLimit: {
20035
+ const appConfig = app.config.oauth?.githubApp;
20036
+ if (!appConfig?.webhookSecret) {
20037
+ app.post("/github-app", async (_request, reply) => reply.status(501).send({ error: "GitHub App webhook is not configured for this Hub deployment." }));
20038
+ return;
20039
+ }
20040
+ const webhookSecret = appConfig.webhookSecret;
20041
+ const appSlug = appConfig.slug ?? null;
20042
+ const webhookMax = app.config.rateLimit?.webhookMax ?? 600;
20043
+ app.post("/github-app", { config: { rateLimit: {
19313
20044
  max: webhookMax,
19314
20045
  timeWindow: "1 minute"
19315
20046
  } } }, async (request, reply) => {
19316
- const { orgId } = request.params;
19317
- const webhookSecret = await getDecryptedGithubWebhookSecret(app.db, orgId, app.config.secrets.encryptionKey);
19318
- if (!webhookSecret) return reply.status(501).send({ error: "GitHub webhook is not configured for this organization. An admin must set the webhook secret in Team settings." });
19319
20047
  const rawBody = request.body;
19320
20048
  if (!Buffer.isBuffer(rawBody)) throw new BadRequestError("Expected raw body buffer");
19321
20049
  const signatureHeader = request.headers["x-hub-signature-256"];
@@ -19333,19 +20061,73 @@ async function githubWebhookRoutes(app) {
19333
20061
  ok: true,
19334
20062
  event: "ping"
19335
20063
  });
19336
- if (shouldSilent(eventType, payload)) return reply.status(200).send({
20064
+ if (eventType === "installation" || eventType === "installation_repositories") {
20065
+ const lifecycle = await handleInstallationLifecycle(app, eventType, payload);
20066
+ return reply.status(200).send({
20067
+ ok: true,
20068
+ event: eventType,
20069
+ lifecycle
20070
+ });
20071
+ }
20072
+ const installationId = readInstallationId(payload);
20073
+ if (installationId === null) return reply.status(200).send({
20074
+ ok: true,
20075
+ event: eventType,
20076
+ ignored: "no installation context"
20077
+ });
20078
+ const installation = await findInstallationByGithubId(app.db, installationId);
20079
+ if (!installation) {
20080
+ log$1.warn({
20081
+ installationId,
20082
+ eventType
20083
+ }, "installation not seen, skipping");
20084
+ return reply.status(200).send({
20085
+ ok: true,
20086
+ event: eventType,
20087
+ ignored: "installation not seen"
20088
+ });
20089
+ }
20090
+ if (!installation.hubOrganizationId) {
20091
+ log$1.warn({
20092
+ installationId,
20093
+ eventType
20094
+ }, "installation not bound to any hub org, skipping");
20095
+ return reply.status(200).send({
20096
+ ok: true,
20097
+ event: eventType,
20098
+ ignored: "installation not bound"
20099
+ });
20100
+ }
20101
+ if (installation.suspendedAt !== null) return reply.status(200).send({
19337
20102
  ok: true,
19338
20103
  event: eventType,
19339
- silent: true
20104
+ ignored: "suspended"
19340
20105
  });
20106
+ const source = {
20107
+ kind: "github-app-installation",
20108
+ installationId,
20109
+ organizationId: installation.hubOrganizationId
20110
+ };
19341
20111
  const deliveryHeader = request.headers["x-github-delivery"];
19342
20112
  const deliveryId = typeof deliveryHeader === "string" && deliveryHeader.length > 0 ? deliveryHeader : null;
20113
+ const event = normalizeGithubEvent(eventType, payload, source, deliveryId);
20114
+ if (!event) {
20115
+ log$1.debug({
20116
+ eventType,
20117
+ action: isRecord(payload) ? payload.action : null
20118
+ }, "Stage 1 returned null");
20119
+ return reply.status(200).send({
20120
+ ok: true,
20121
+ event: eventType,
20122
+ handled: false
20123
+ });
20124
+ }
19343
20125
  if (deliveryId) {
19344
20126
  if (!await claimEvent(app.db, deliveryId, "github")) {
19345
20127
  log$1.info({
19346
20128
  deliveryId,
19347
20129
  eventType
19348
- }, "duplicate GitHub delivery, skipping");
20130
+ }, "duplicate delivery, skipping");
19349
20131
  return reply.status(200).send({
19350
20132
  ok: true,
19351
20133
  event: eventType,
@@ -19354,217 +20136,36 @@ async function githubWebhookRoutes(app) {
19354
20136
  }
19355
20137
  }
19356
20138
  try {
19357
- const action = isRecord(payload) && typeof payload.action === "string" ? payload.action : void 0;
19358
- const allowedActions = MENTION_ACTIONS[eventType];
19359
- if (!allowedActions || !action || !allowedActions.includes(action)) return reply.status(200).send({
19360
- ok: true,
19361
- event: eventType,
19362
- handled: false
19363
- });
19364
- const mentionsRouted = await handleMentionDelegation(app, orgId, eventType, payload);
20139
+ const audience = await resolveAudience(app.db, event, appSlug);
20140
+ if (audience.length === 0) {
20141
+ log$1.info({
20142
+ entityType: event.entity.type,
20143
+ entityKey: event.entity.key,
20144
+ actor: event.actor.githubLogin
20145
+ }, "audience empty, skipping");
20146
+ return reply.status(200).send({
20147
+ ok: true,
20148
+ event: eventType,
20149
+ audience: 0
20150
+ });
20151
+ }
20152
+ const stats = await deliverNormalizedEvent(app, event, audience);
19365
20153
  return reply.status(200).send({
19366
20154
  ok: true,
19367
20155
  event: eventType,
19368
- mentionsRouted
20156
+ ...stats
19369
20157
  });
19370
20158
  } catch (err) {
19371
20159
  if (deliveryId) await unclaimEvent(app.db, deliveryId, "github").catch((unclaimErr) => {
19372
20160
  log$1.error({
19373
20161
  err: unclaimErr,
19374
20162
  deliveryId
19375
- }, "failed to unclaim GitHub delivery after handler error");
20163
+ }, "failed to unclaim delivery after handler error");
19376
20164
  });
19377
20165
  throw err;
19378
20166
  }
19379
20167
  });
19380
20168
  }
19381
- /** Extract text body from any GitHub webhook event for @mention scanning. */
19382
- function extractEventText(eventType, payload) {
19383
- if (!isRecord(payload)) return null;
19384
- switch (eventType) {
19385
- case "issues": {
19386
- const issue = isRecord(payload.issue) ? payload.issue : null;
19387
- return typeof issue?.body === "string" ? issue.body : null;
19388
- }
19389
- case "issue_comment":
19390
- case "pull_request_review_comment":
19391
- case "commit_comment":
19392
- case "discussion_comment": {
19393
- const comment = isRecord(payload.comment) ? payload.comment : null;
19394
- return typeof comment?.body === "string" ? comment.body : null;
19395
- }
19396
- case "pull_request": {
19397
- const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
19398
- return typeof pr?.body === "string" ? pr.body : null;
19399
- }
19400
- case "pull_request_review": {
19401
- const review = isRecord(payload.review) ? payload.review : null;
19402
- return typeof review?.body === "string" ? review.body : null;
19403
- }
19404
- case "discussion": {
19405
- const disc = isRecord(payload.discussion) ? payload.discussion : null;
19406
- return typeof disc?.body === "string" ? disc.body : null;
19407
- }
19408
- default: return null;
19409
- }
19410
- }
19411
- /** Extract context info from any GitHub webhook event for delegation messages. */
19412
- function extractEventContext(eventType, payload) {
19413
- if (!isRecord(payload)) return null;
19414
- const repo = isRecord(payload.repository) ? payload.repository : null;
19415
- const sender = isRecord(payload.sender) ? payload.sender : null;
19416
- const repository = typeof repo?.full_name === "string" ? repo.full_name : "";
19417
- const senderLogin = typeof sender?.login === "string" ? sender.login : "";
19418
- const action = typeof payload.action === "string" ? payload.action : void 0;
19419
- switch (eventType) {
19420
- case "issues": {
19421
- const issue = isRecord(payload.issue) ? payload.issue : null;
19422
- if (!issue) return null;
19423
- return {
19424
- event: "issues",
19425
- action,
19426
- repository,
19427
- sender: senderLogin,
19428
- title: `Issue #${issue.number}: ${issue.title}`,
19429
- body: typeof issue.body === "string" ? issue.body : "",
19430
- url: typeof issue.html_url === "string" ? issue.html_url : ""
19431
- };
19432
- }
19433
- case "issue_comment": {
19434
- const issue = isRecord(payload.issue) ? payload.issue : null;
19435
- const comment = isRecord(payload.comment) ? payload.comment : null;
19436
- if (!issue || !comment) return null;
19437
- return {
19438
- event: "issue_comment",
19439
- action,
19440
- repository,
19441
- sender: senderLogin,
19442
- title: `Issue #${issue.number}: ${issue.title}`,
19443
- body: typeof comment.body === "string" ? comment.body : "",
19444
- url: typeof comment.html_url === "string" ? comment.html_url : ""
19445
- };
19446
- }
19447
- case "pull_request": {
19448
- const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
19449
- if (!pr) return null;
19450
- return {
19451
- event: "pull_request",
19452
- action,
19453
- repository,
19454
- sender: senderLogin,
19455
- title: `PR #${pr.number}: ${pr.title}`,
19456
- body: typeof pr.body === "string" ? pr.body : "",
19457
- url: typeof pr.html_url === "string" ? pr.html_url : ""
19458
- };
19459
- }
19460
- case "pull_request_review": {
19461
- const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
19462
- const review = isRecord(payload.review) ? payload.review : null;
19463
- if (!pr || !review) return null;
19464
- return {
19465
- event: "pull_request_review",
19466
- action,
19467
- repository,
19468
- sender: senderLogin,
19469
- title: `PR #${pr.number}: ${pr.title}`,
19470
- body: typeof review.body === "string" ? review.body : "",
19471
- url: typeof review.html_url === "string" ? review.html_url : ""
19472
- };
19473
- }
19474
- case "pull_request_review_comment": {
19475
- const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
19476
- const comment = isRecord(payload.comment) ? payload.comment : null;
19477
- if (!pr || !comment) return null;
19478
- return {
19479
- event: "pull_request_review_comment",
19480
- action,
19481
- repository,
19482
- sender: senderLogin,
19483
- title: `PR #${pr.number}: ${pr.title}`,
19484
- body: typeof comment.body === "string" ? comment.body : "",
19485
- url: typeof comment.html_url === "string" ? comment.html_url : ""
19486
- };
19487
- }
19488
- case "discussion": {
19489
- const disc = isRecord(payload.discussion) ? payload.discussion : null;
19490
- if (!disc) return null;
19491
- return {
19492
- event: "discussion",
19493
- action,
19494
- repository,
19495
- sender: senderLogin,
19496
- title: typeof disc.title === "string" ? disc.title : "",
19497
- body: typeof disc.body === "string" ? disc.body : "",
19498
- url: typeof disc.html_url === "string" ? disc.html_url : ""
19499
- };
19500
- }
19501
- case "discussion_comment": {
19502
- const disc = isRecord(payload.discussion) ? payload.discussion : null;
19503
- const comment = isRecord(payload.comment) ? payload.comment : null;
19504
- if (!disc || !comment) return null;
19505
- return {
19506
- event: "discussion_comment",
19507
- action,
19508
- repository,
19509
- sender: senderLogin,
19510
- title: typeof disc.title === "string" ? disc.title : "",
19511
- body: typeof comment.body === "string" ? comment.body : "",
19512
- url: typeof comment.html_url === "string" ? comment.html_url : ""
19513
- };
19514
- }
19515
- case "commit_comment": {
19516
- const comment = isRecord(payload.comment) ? payload.comment : null;
19517
- if (!comment) return null;
19518
- return {
19519
- event: "commit_comment",
19520
- action,
19521
- repository,
19522
- sender: senderLogin,
19523
- title: "Commit comment",
19524
- body: typeof comment.body === "string" ? comment.body : "",
19525
- url: typeof comment.html_url === "string" ? comment.html_url : ""
19526
- };
19527
- }
19528
- default: return null;
19529
- }
19530
- }
19531
- /**
19532
- * Run mention delegation for a given event type and payload.
19533
- * Only called after action gating confirms this is a "new content" event.
19534
- */
19535
- async function handleMentionDelegation(app, organizationId, eventType, payload) {
19536
- const textMentions = extractMentions$1(extractEventText(eventType, payload));
19537
- const structuralMentions = extractStructuralMentions(eventType, payload);
19538
- const mentions = [...new Set([...textMentions, ...structuralMentions])];
19539
- if (mentions.length === 0) return 0;
19540
- const ctx = extractEventContext(eventType, payload);
19541
- if (!ctx) return 0;
19542
- const entity = extractEventEntity(eventType, payload);
19543
- if (!entity) {
19544
- log$1.warn({ eventType }, "mention extracted but no entity resolvable; skipping fan-out");
19545
- return 0;
19546
- }
19547
- return routeMentionDelegations(app, organizationId, mentions, ctx, entity, eventType === "pull_request" && ctx.repository.length > 0 ? parseFixesRefs(ctx.body, ctx.repository) : []);
19548
- }
19549
- /** Actions that represent new/changed content (worth scanning for @mentions).
19550
- * Note: `pull_request.review_requested` doesn't carry an @mention in any
19551
- * text body — the reviewer is in `requested_reviewer.login`. We pick it up
19552
- * via `extractStructuralMentions`. The complementary `review_request_removed`
19553
- * is intentionally omitted to avoid notifying the reviewer twice. */
19554
- const MENTION_ACTIONS = {
19555
- issues: ["opened", "edited"],
19556
- issue_comment: ["created"],
19557
- pull_request: [
19558
- "opened",
19559
- "edited",
19560
- "review_requested"
19561
- ],
19562
- pull_request_review: ["submitted"],
19563
- pull_request_review_comment: ["created"],
19564
- discussion: ["created", "edited"],
19565
- discussion_comment: ["created"],
19566
- commit_comment: ["created"]
19567
- };
19568
20169
  /**
19569
20170
  * Boot-time configuration sanity checks. Called from `buildApp` so BOTH
19570
20171
  * server entry points are covered:
@@ -21019,6 +21620,12 @@ async function buildApp(config) {
21019
21620
  ...sslOptions(config.database.url)
21020
21621
  });
21021
21622
  const notifier = createNotifier(listenClient);
21623
+ registerCrossInstanceBroadcaster((payload) => {
21624
+ notifier.notifyAdminBroadcast(payload).catch(() => {});
21625
+ });
21626
+ notifier.onAdminBroadcast((payload) => {
21627
+ broadcastToAdmins(payload);
21628
+ });
21022
21629
  await app.register(websocket, { options: { maxPayload: config.ws?.maxPayload ?? 65536 } });
21023
21630
  const corsOrigin = config.cors?.origin;
21024
21631
  const isDev = process.env.NODE_ENV !== "production";
@@ -21098,7 +21705,7 @@ async function buildApp(config) {
21098
21705
  await app.register(healthzRoutes);
21099
21706
  await app.register(namePlugin("apiV1Scope", async (api) => {
21100
21707
  await api.register(healthRoutes);
21101
- await api.register(githubWebhookRoutes, { prefix: "/webhooks" });
21708
+ await api.register(githubAppWebhookRoutes, { prefix: "/webhooks" });
21102
21709
  await api.register(authRoutes, { prefix: "/auth" });
21103
21710
  await api.register(githubOauthRoutes, { prefix: "/auth/github" });
21104
21711
  await api.register(publicInvitationRoutes, { prefix: "/invitations" });