@agent-team-foundation/first-tree-hub 0.12.8 → 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.
- package/dist/{bootstrap-BCZC1ki6.mjs → bootstrap-CW73oEYn.mjs} +1 -1
- package/dist/cli/index.mjs +7 -7
- package/dist/{client-bR8nwHaV-OxnjyKOk.mjs → client-BViGcaUC-CZb2Svgh.mjs} +133 -8
- package/dist/{client-DNEtPEBu-BtHkUya2.mjs → client-DNiLcPEq-db3YS57z.mjs} +2 -2
- package/dist/{dist-CnjqakXS.mjs → dist-B1GHzMLc.mjs} +107 -35
- package/dist/drizzle/0041_notifications_dedup_key.sql +29 -0
- package/dist/drizzle/0042_notifications_drop_legacy_types.sql +36 -0
- package/dist/drizzle/meta/_journal.json +14 -0
- package/dist/{feishu-DrnBbl8T.mjs → feishu-30vUx69l.mjs} +1 -1
- package/dist/index.mjs +5 -5
- package/dist/{invitation-C299fxkP-KKslbta2.mjs → invitation-C299fxkP-CZgGbsN_.mjs} +1 -1
- package/dist/{saas-connect-CLcon-De.mjs → saas-connect-Fgnnnola.mjs} +1300 -686
- package/dist/web/assets/{index-BPMrSv_A.js → index-B7FIVwrn.js} +1 -1
- package/dist/web/assets/index-DJbUySaH.css +1 -0
- package/dist/web/assets/index-DiDfVdIH.js +421 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DxAYxUpz.css +0 -1
- package/dist/web/assets/index-ntmzuk5X.js +0 -421
|
@@ -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-
|
|
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
|
|
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-
|
|
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(
|
|
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;
|
|
@@ -3254,7 +3313,9 @@ You are running inside **Agent Hub**, a messaging platform for agent teams.
|
|
|
3254
3313
|
- **Your final text response is automatically delivered** to the chat — just respond normally
|
|
3255
3314
|
- For **proactive communication** (sending to other agents, other chats, or structured data),
|
|
3256
3315
|
use the \`first-tree-hub\` CLI below
|
|
3257
|
-
- **Use your judgment about when to respond.** Not every message requires
|
|
3316
|
+
- **Use your judgment about when to respond.** Not every message requires
|
|
3317
|
+
a reply — if you have nothing new for the recipient, output nothing and
|
|
3318
|
+
the runtime will end the turn silently.
|
|
3258
3319
|
Your role and responsibilities are injected via the Hub-managed system prompt.
|
|
3259
3320
|
|
|
3260
3321
|
## Environment Variables
|
|
@@ -4842,7 +4903,6 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4842
4903
|
try {
|
|
4843
4904
|
await sessionCtx.forwardResult(resultText);
|
|
4844
4905
|
sessionCtx.log("Result forwarded to chat");
|
|
4845
|
-
sessionCtx.reportSessionCompletion();
|
|
4846
4906
|
sessionCtx.emitEvent({
|
|
4847
4907
|
kind: "turn_end",
|
|
4848
4908
|
payload: { status: "success" }
|
|
@@ -5435,7 +5495,6 @@ const createCodexHandler = (config) => {
|
|
|
5435
5495
|
kind: "turn_end",
|
|
5436
5496
|
payload: { status: succeeded ? "success" : "error" }
|
|
5437
5497
|
});
|
|
5438
|
-
if (succeeded && accumulated.trim()) sessionCtx.reportSessionCompletion();
|
|
5439
5498
|
sessionCtx.setRuntimeState("idle");
|
|
5440
5499
|
if (queuedMessages.length > 0 && !drainScheduled) {
|
|
5441
5500
|
drainScheduled = true;
|
|
@@ -5858,6 +5917,11 @@ function createResultSink(deps) {
|
|
|
5858
5917
|
return { mentions: [trigger.senderId] };
|
|
5859
5918
|
}
|
|
5860
5919
|
return async function forwardResult(text) {
|
|
5920
|
+
if (text.trim().length === 0) {
|
|
5921
|
+
deps.clearTrigger();
|
|
5922
|
+
deps.log("silent turn: agent produced empty output, skipping delivery");
|
|
5923
|
+
return;
|
|
5924
|
+
}
|
|
5861
5925
|
const trigger = deps.getTrigger();
|
|
5862
5926
|
deps.clearTrigger();
|
|
5863
5927
|
const metadata = await buildMetadata(trigger);
|
|
@@ -6452,9 +6516,6 @@ var SessionManager = class {
|
|
|
6452
6516
|
emitEvent: (event) => {
|
|
6453
6517
|
this.config.onSessionEvent?.(chatId, event);
|
|
6454
6518
|
},
|
|
6455
|
-
reportSessionCompletion: () => {
|
|
6456
|
-
this.config.onSessionCompletion?.(chatId);
|
|
6457
|
-
},
|
|
6458
6519
|
forwardResult,
|
|
6459
6520
|
buildAgentEnv: (parentEnv) => buildAgentEnv(parentEnv, envCtx),
|
|
6460
6521
|
formatInboundContent: (message) => formatInboundContent(message, participants),
|
|
@@ -6691,8 +6752,7 @@ var AgentSlot = class {
|
|
|
6691
6752
|
ackEntry,
|
|
6692
6753
|
onStateChange: (chatId, state) => this.reportSessionState(chatId, state),
|
|
6693
6754
|
onRuntimeStateChange: (state) => this.reportRuntimeState(state),
|
|
6694
|
-
onSessionEvent: (chatId, event) => this.reportSessionEvent(chatId, event)
|
|
6695
|
-
onSessionCompletion: (chatId) => this.reportSessionCompletion(chatId)
|
|
6755
|
+
onSessionEvent: (chatId, event) => this.reportSessionEvent(chatId, event)
|
|
6696
6756
|
});
|
|
6697
6757
|
const onCommand = (cmd) => {
|
|
6698
6758
|
if (cmd.agentId === this.config.agentId && this.sessionManager) this.sessionManager.handleCommand(cmd.chatId, cmd.type).catch((err) => {
|
|
@@ -6740,9 +6800,6 @@ var AgentSlot = class {
|
|
|
6740
6800
|
reportSessionEvent(chatId, event) {
|
|
6741
6801
|
this.clientConnection.reportSessionEvent(this.config.agentId, chatId, event);
|
|
6742
6802
|
}
|
|
6743
|
-
reportSessionCompletion(chatId) {
|
|
6744
|
-
this.clientConnection.reportSessionCompletion(this.config.agentId, chatId);
|
|
6745
|
-
}
|
|
6746
6803
|
fullStateSync() {
|
|
6747
6804
|
if (!this.sessionManager) return;
|
|
6748
6805
|
for (const { chatId, state } of this.sessionManager.getSessionStates()) this.clientConnection.reportSessionState(this.config.agentId, chatId, state);
|
|
@@ -9436,7 +9493,7 @@ async function onboardCreate(args) {
|
|
|
9436
9493
|
}
|
|
9437
9494
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
9438
9495
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
9439
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
9496
|
+
const { bindFeishuBot } = await import("./feishu-30vUx69l.mjs").then((n) => n.r);
|
|
9440
9497
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
9441
9498
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
9442
9499
|
else {
|
|
@@ -10649,7 +10706,7 @@ function createFeedbackHandler(config) {
|
|
|
10649
10706
|
return { handle };
|
|
10650
10707
|
}
|
|
10651
10708
|
//#endregion
|
|
10652
|
-
//#region ../server/dist/app-
|
|
10709
|
+
//#region ../server/dist/app-B1wsm8zG.mjs
|
|
10653
10710
|
var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
|
|
10654
10711
|
init_esm();
|
|
10655
10712
|
var __defProp = Object.defineProperty;
|
|
@@ -11034,7 +11091,7 @@ async function deleteAdapterConfig(db, id) {
|
|
|
11034
11091
|
const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
|
|
11035
11092
|
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
11036
11093
|
}
|
|
11037
|
-
const log$
|
|
11094
|
+
const log$7 = createLogger$1("Adapters");
|
|
11038
11095
|
function parseId(raw) {
|
|
11039
11096
|
const id = Number(raw);
|
|
11040
11097
|
if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
|
|
@@ -11060,7 +11117,7 @@ async function adapterRoutes(app) {
|
|
|
11060
11117
|
const existing = await getAdapterConfig(app.db, id);
|
|
11061
11118
|
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
11062
11119
|
const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
|
|
11063
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11120
|
+
app.adapterManager.reload().catch((err) => log$7.error({ err }, "adapter reload failed after update"));
|
|
11064
11121
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11065
11122
|
return {
|
|
11066
11123
|
...config,
|
|
@@ -11074,7 +11131,7 @@ async function adapterRoutes(app) {
|
|
|
11074
11131
|
const existing = await getAdapterConfig(app.db, id);
|
|
11075
11132
|
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
11076
11133
|
await deleteAdapterConfig(app.db, id);
|
|
11077
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11134
|
+
app.adapterManager.reload().catch((err) => log$7.error({ err }, "adapter reload failed after delete"));
|
|
11078
11135
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11079
11136
|
return reply.status(204).send();
|
|
11080
11137
|
});
|
|
@@ -11090,7 +11147,7 @@ function requireAgent(request) {
|
|
|
11090
11147
|
if (!agent) throw new UnauthorizedError("Agent authentication required");
|
|
11091
11148
|
return agent;
|
|
11092
11149
|
}
|
|
11093
|
-
const log$
|
|
11150
|
+
const log$6 = createLogger$1("AgentChatsRoute");
|
|
11094
11151
|
function serializeChat(chat) {
|
|
11095
11152
|
return {
|
|
11096
11153
|
...chat,
|
|
@@ -11153,7 +11210,7 @@ async function agentChatRoutes(app) {
|
|
|
11153
11210
|
app.post("/:chatId/participants", async (request, reply) => {
|
|
11154
11211
|
const identity = requireAgent(request);
|
|
11155
11212
|
if (request.body !== null && typeof request.body === "object" && "mode" in request.body) {
|
|
11156
|
-
log$
|
|
11213
|
+
log$6.warn({
|
|
11157
11214
|
code: "MODE_FIELD_DEPRECATED",
|
|
11158
11215
|
chatId: request.params.chatId,
|
|
11159
11216
|
senderAgentId: identity.uuid,
|
|
@@ -11376,6 +11433,20 @@ async function validateDelegateMentionTarget(db, targetUuid, sourceOrgId) {
|
|
|
11376
11433
|
if (target.organizationId !== sourceOrgId) throw new BadRequestError("delegateMention target must belong to the same organization as the agent");
|
|
11377
11434
|
}
|
|
11378
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
|
+
/**
|
|
11379
11450
|
* Pick the first admin member in the org for internal system agents. Throws
|
|
11380
11451
|
* if the org has no admin — the caller should surface the error so an admin
|
|
11381
11452
|
* is created before the system tries to register more agents.
|
|
@@ -11415,7 +11486,10 @@ async function createAgent(db, data, options = {}) {
|
|
|
11415
11486
|
type: data.type
|
|
11416
11487
|
});
|
|
11417
11488
|
await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
|
|
11418
|
-
if (data.delegateMention)
|
|
11489
|
+
if (data.delegateMention) {
|
|
11490
|
+
assertDelegateMentionAllowed(data.type);
|
|
11491
|
+
await validateDelegateMentionTarget(db, data.delegateMention, orgId);
|
|
11492
|
+
}
|
|
11419
11493
|
const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
|
|
11420
11494
|
if (org && org.maxAgents > 0) {
|
|
11421
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.`);
|
|
@@ -11560,10 +11634,16 @@ async function updateAgent(db, uuid, data) {
|
|
|
11560
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.");
|
|
11561
11635
|
}
|
|
11562
11636
|
const updates = { updatedAt: /* @__PURE__ */ new Date() };
|
|
11563
|
-
if (data.type !== void 0)
|
|
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
|
+
}
|
|
11564
11641
|
if (data.displayName !== void 0) updates.displayName = data.displayName;
|
|
11565
11642
|
if (data.delegateMention !== void 0) {
|
|
11566
|
-
if (data.delegateMention !== null)
|
|
11643
|
+
if (data.delegateMention !== null) {
|
|
11644
|
+
assertDelegateMentionAllowed(data.type ?? agent.type);
|
|
11645
|
+
await validateDelegateMentionTarget(db, data.delegateMention, agent.organizationId);
|
|
11646
|
+
}
|
|
11567
11647
|
updates.delegateMention = data.delegateMention;
|
|
11568
11648
|
}
|
|
11569
11649
|
if (data.visibility !== void 0) updates.visibility = data.visibility;
|
|
@@ -11674,7 +11754,7 @@ async function deleteAgent(db, uuid) {
|
|
|
11674
11754
|
if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
11675
11755
|
return agent;
|
|
11676
11756
|
}
|
|
11677
|
-
const log$
|
|
11757
|
+
const log$5 = createLogger$1("AgentFeishuBot");
|
|
11678
11758
|
async function agentFeishuBotRoutes(app) {
|
|
11679
11759
|
/**
|
|
11680
11760
|
* PUT /agent/me/feishu-bot
|
|
@@ -11702,7 +11782,7 @@ async function agentFeishuBotRoutes(app) {
|
|
|
11702
11782
|
},
|
|
11703
11783
|
status: "active"
|
|
11704
11784
|
}, app.config.secrets.encryptionKey);
|
|
11705
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11785
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after self-service bind"));
|
|
11706
11786
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11707
11787
|
return reply.status(current ? 200 : 201).send({
|
|
11708
11788
|
...config,
|
|
@@ -11719,7 +11799,7 @@ async function agentFeishuBotRoutes(app) {
|
|
|
11719
11799
|
const current = (await listAdapterConfigs(app.db)).find((c) => c.agentId === identity.uuid && c.platform === "feishu");
|
|
11720
11800
|
if (!current) return reply.status(204).send();
|
|
11721
11801
|
await deleteAdapterConfig(app.db, current.id);
|
|
11722
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11802
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after self-service unbind"));
|
|
11723
11803
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11724
11804
|
return reply.status(204).send();
|
|
11725
11805
|
});
|
|
@@ -12350,7 +12430,7 @@ async function collectTargetInboxes(db, chatId, inReplyTo) {
|
|
|
12350
12430
|
}
|
|
12351
12431
|
return [...set];
|
|
12352
12432
|
}
|
|
12353
|
-
const log$
|
|
12433
|
+
const log$4 = createLogger$1("AgentMessages");
|
|
12354
12434
|
const editMessageSchema = z.object({
|
|
12355
12435
|
format: z.string().optional(),
|
|
12356
12436
|
content: z.unknown()
|
|
@@ -12378,7 +12458,7 @@ function agentMessageWriteRateLimit(max) {
|
|
|
12378
12458
|
keyGenerator: (req) => {
|
|
12379
12459
|
const agentId = req.agent?.uuid;
|
|
12380
12460
|
if (agentId) return `agent:${agentId}`;
|
|
12381
|
-
log$
|
|
12461
|
+
log$4.warn({
|
|
12382
12462
|
ip: req.ip,
|
|
12383
12463
|
route: req.routeOptions?.url ?? req.url
|
|
12384
12464
|
}, "rate-limit keyGenerator fell back to IP — req.agent missing on a route under /agent (hook order regression?)");
|
|
@@ -12411,7 +12491,7 @@ async function agentMessageRoutes(app) {
|
|
|
12411
12491
|
await assertParticipant(app.db, request.params.chatId, identity.uuid);
|
|
12412
12492
|
const body = editMessageSchema.parse(request.body);
|
|
12413
12493
|
const msg = await editMessage(app.db, request.params.chatId, request.params.messageId, identity.uuid, body);
|
|
12414
|
-
app.adapterManager.editOutboundMessage(msg.id, msg.format, msg.content).catch((err) => log$
|
|
12494
|
+
app.adapterManager.editOutboundMessage(msg.id, msg.format, msg.content).catch((err) => log$4.error({
|
|
12415
12495
|
err,
|
|
12416
12496
|
messageId: msg.id
|
|
12417
12497
|
}, "failed to edit outbound message"));
|
|
@@ -12590,35 +12670,84 @@ const notifications = pgTable("notifications", {
|
|
|
12590
12670
|
chatId: text("chat_id"),
|
|
12591
12671
|
message: text("message").notNull(),
|
|
12592
12672
|
read: boolean("read").notNull().default(false),
|
|
12673
|
+
dedupKey: text("dedup_key"),
|
|
12593
12674
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
12594
12675
|
}, (table) => [
|
|
12595
12676
|
index("idx_notifications_org_created").on(table.organizationId, table.createdAt),
|
|
12596
12677
|
index("idx_notifications_agent").on(table.agentId),
|
|
12597
|
-
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`)
|
|
12598
12680
|
]);
|
|
12599
|
-
let
|
|
12681
|
+
let localBroadcaster = null;
|
|
12682
|
+
let crossInstanceBroadcaster = null;
|
|
12600
12683
|
function registerAdminBroadcaster(fn) {
|
|
12601
|
-
|
|
12684
|
+
localBroadcaster = fn;
|
|
12685
|
+
}
|
|
12686
|
+
function registerCrossInstanceBroadcaster(fn) {
|
|
12687
|
+
crossInstanceBroadcaster = fn;
|
|
12602
12688
|
}
|
|
12603
12689
|
function broadcastToAdmins(payload) {
|
|
12604
|
-
if (!
|
|
12690
|
+
if (!localBroadcaster) return;
|
|
12605
12691
|
try {
|
|
12606
|
-
|
|
12692
|
+
localBroadcaster(payload);
|
|
12607
12693
|
} catch {}
|
|
12608
12694
|
}
|
|
12609
|
-
/**
|
|
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
|
+
*/
|
|
12610
12726
|
async function createNotification(db, data) {
|
|
12611
12727
|
const id = uuidv7();
|
|
12612
|
-
const
|
|
12728
|
+
const row = (await db.insert(notifications).values({
|
|
12613
12729
|
id,
|
|
12614
12730
|
organizationId: data.organizationId,
|
|
12615
12731
|
type: data.type,
|
|
12616
12732
|
severity: data.severity,
|
|
12617
12733
|
agentId: data.agentId ?? null,
|
|
12618
12734
|
chatId: data.chatId ?? null,
|
|
12619
|
-
message: data.message
|
|
12620
|
-
|
|
12621
|
-
|
|
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;
|
|
12622
12751
|
const notification = {
|
|
12623
12752
|
id: row.id,
|
|
12624
12753
|
organizationId: row.organizationId,
|
|
@@ -12651,18 +12780,15 @@ async function listNotifications(db, orgId, memberId, query) {
|
|
|
12651
12780
|
items: [],
|
|
12652
12781
|
nextCursor: null
|
|
12653
12782
|
};
|
|
12654
|
-
const
|
|
12783
|
+
const targetLimit = query.limit;
|
|
12784
|
+
const conditions = [eq(notifications.organizationId, orgId), buildVisibilityCondition([...visibleAgents])];
|
|
12655
12785
|
if (query.cursor) conditions.push(lt(notifications.createdAt, new Date(query.cursor)));
|
|
12656
12786
|
if (query.severity) conditions.push(eq(notifications.severity, query.severity));
|
|
12657
12787
|
if (query.read !== void 0) conditions.push(eq(notifications.read, query.read));
|
|
12658
12788
|
if (query.agentId) conditions.push(eq(notifications.agentId, query.agentId));
|
|
12659
|
-
const
|
|
12660
|
-
const
|
|
12661
|
-
const
|
|
12662
|
-
const rawLimit = Math.min(targetLimit * overscanFactor + 1, 400);
|
|
12663
|
-
const visible = (await db.select().from(notifications).where(where).orderBy(desc(notifications.createdAt)).limit(rawLimit)).filter((n) => n.agentId === null || visibleAgents.has(n.agentId));
|
|
12664
|
-
const hasMore = visible.length > targetLimit;
|
|
12665
|
-
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;
|
|
12666
12792
|
const last = items[items.length - 1];
|
|
12667
12793
|
const nextCursor = hasMore && last ? last.createdAt.toISOString() : null;
|
|
12668
12794
|
return {
|
|
@@ -12673,6 +12799,16 @@ async function listNotifications(db, orgId, memberId, query) {
|
|
|
12673
12799
|
nextCursor
|
|
12674
12800
|
};
|
|
12675
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
|
+
}
|
|
12676
12812
|
/** Mark a single notification as read, scoped to organization + visible agents. */
|
|
12677
12813
|
async function markRead(db, notificationId, orgId, memberId) {
|
|
12678
12814
|
const [existing] = await db.select({
|
|
@@ -12689,16 +12825,17 @@ async function markRead(db, notificationId, orgId, memberId) {
|
|
|
12689
12825
|
/** Mark all notifications visible to this member as read. */
|
|
12690
12826
|
async function markAllRead(db, orgId, memberId) {
|
|
12691
12827
|
const visible = await loadVisibleAgentIds$1(db, orgId, memberId);
|
|
12692
|
-
|
|
12693
|
-
|
|
12694
|
-
|
|
12695
|
-
|
|
12696
|
-
|
|
12697
|
-
|
|
12698
|
-
|
|
12699
|
-
|
|
12700
|
-
|
|
12701
|
-
|
|
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));
|
|
12702
12839
|
}
|
|
12703
12840
|
/**
|
|
12704
12841
|
* Shared visibility predicate. Mirrors
|
|
@@ -12732,62 +12869,81 @@ async function resolveAgentContext(db, agentId) {
|
|
|
12732
12869
|
clientLabel
|
|
12733
12870
|
};
|
|
12734
12871
|
}
|
|
12735
|
-
async function resolveChatContext(db, chatId) {
|
|
12736
|
-
const [chat] = await db.select({ topic: chats.topic }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
12737
|
-
const shortId = chatId.slice(0, 8);
|
|
12738
|
-
return { chatLabel: chat?.topic && chat.topic.trim().length > 0 ? chat.topic.trim() : `Chat ${shortId}` };
|
|
12739
|
-
}
|
|
12740
12872
|
/**
|
|
12741
|
-
* Compose a human-readable message for each notification type.
|
|
12742
|
-
*
|
|
12743
|
-
*
|
|
12744
|
-
* - Session-scoped events → subject is the chat (topic / "Chat xxxxxxxx")
|
|
12745
|
-
* - Client-scoped events → subject is the computer (hostname / clientId)
|
|
12746
|
-
* - 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.
|
|
12747
12876
|
*/
|
|
12748
|
-
function composeMessage(type, agentCtx
|
|
12877
|
+
function composeMessage(type, agentCtx) {
|
|
12749
12878
|
const agent = agentCtx.agentName;
|
|
12750
12879
|
const computer = agentCtx.clientLabel ?? "Unknown computer";
|
|
12751
|
-
const chat = chatCtx?.chatLabel ?? null;
|
|
12752
12880
|
switch (type) {
|
|
12753
|
-
case "session_completed": return chat ? `${chat} completed` : `${agent} completed a task`;
|
|
12754
|
-
case "session_error": return chat ? `${chat} hit an error` : `${agent} hit a session error`;
|
|
12755
|
-
case "agent_disconnected": return `Computer ${computer} disconnected`;
|
|
12756
|
-
case "agent_connected": return `Computer ${computer} reconnected`;
|
|
12757
12881
|
case "agent_stale": return `Computer ${computer} is unresponsive`;
|
|
12758
12882
|
case "agent_error": return `${agent} entered error state`;
|
|
12759
12883
|
case "agent_blocked": return `${agent} is blocked`;
|
|
12760
|
-
case "agent_needs_decision": return chat ? `${agent} needs a decision in ${chat}` : `${agent} needs a decision`;
|
|
12761
12884
|
default: return `${agent} event`;
|
|
12762
12885
|
}
|
|
12763
12886
|
}
|
|
12764
12887
|
/**
|
|
12765
|
-
* Convenience: create a notification for an agent event, resolving org
|
|
12766
|
-
* agent display name
|
|
12767
|
-
*
|
|
12768
|
-
*
|
|
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.
|
|
12769
12898
|
*
|
|
12770
12899
|
* Fire-and-forget — errors are swallowed so event producers never fail just
|
|
12771
12900
|
* because the notification pipeline is unhealthy.
|
|
12772
12901
|
*/
|
|
12773
|
-
async function notifyAgentEvent(db, agentId, type, severity,
|
|
12902
|
+
async function notifyAgentEvent(db, agentId, type, severity, options = {}) {
|
|
12774
12903
|
try {
|
|
12775
12904
|
const agentCtx = await resolveAgentContext(db, agentId);
|
|
12776
12905
|
if (!agentCtx) return;
|
|
12777
|
-
const message = composeMessage(type, agentCtx
|
|
12906
|
+
const message = composeMessage(type, agentCtx);
|
|
12907
|
+
const dedupKey = options.dedupKey === void 0 ? `agent:${agentId}:fault` : options.dedupKey;
|
|
12778
12908
|
await createNotification(db, {
|
|
12779
12909
|
organizationId: agentCtx.organizationId,
|
|
12780
12910
|
type,
|
|
12781
12911
|
severity,
|
|
12782
12912
|
agentId,
|
|
12783
|
-
chatId: chatId ?? null,
|
|
12784
12913
|
clientId: agentCtx.clientId,
|
|
12785
|
-
message
|
|
12914
|
+
message,
|
|
12915
|
+
dedupKey
|
|
12786
12916
|
});
|
|
12787
12917
|
} catch {}
|
|
12788
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
|
+
}
|
|
12789
12945
|
function pushToAdminWs(notification) {
|
|
12790
|
-
|
|
12946
|
+
broadcastAdminsCrossInstance({
|
|
12791
12947
|
type: "notification",
|
|
12792
12948
|
organizationId: notification.organizationId,
|
|
12793
12949
|
agentId: notification.agentId ?? null,
|
|
@@ -12931,35 +13087,6 @@ const wsMessageSchema = z.object({
|
|
|
12931
13087
|
agentId: z.string().optional(),
|
|
12932
13088
|
ref: z.string().optional()
|
|
12933
13089
|
});
|
|
12934
|
-
/**
|
|
12935
|
-
* Client WebSocket: one WS per client, multiple agents multiplexed.
|
|
12936
|
-
*
|
|
12937
|
-
* Protocol (unified-user-token milestone):
|
|
12938
|
-
* 1. Client connects; server waits up to {@link WS_AUTH_FRAME_TIMEOUT_MS}
|
|
12939
|
-
* for the first `auth` frame carrying a member access JWT.
|
|
12940
|
-
* Failure ⇒ server sends `auth:rejected` and closes (code 4401).
|
|
12941
|
-
* 2. `client:register` — bind the client_id to the authenticated user.
|
|
12942
|
-
* 3. `agent:bind` — run Rule R-RUN (no token); populate presence.
|
|
12943
|
-
* 4. `session:state` / `runtime:state` / `session:event` / `session:completion` / `heartbeat`.
|
|
12944
|
-
* 5. `agent:unbind` — stop multiplexing for a specific agent.
|
|
12945
|
-
*
|
|
12946
|
-
* When the JWT is about to expire the server sends `auth:expired` so the
|
|
12947
|
-
* SDK can refresh and reconnect without silently half-opening the socket.
|
|
12948
|
-
*/
|
|
12949
|
-
/** Notification cooldown: prevents duplicate notifications for same (agentId, type) within window. */
|
|
12950
|
-
const NOTIFICATION_COOLDOWN_MS = 3e5;
|
|
12951
|
-
const notificationCooldowns = /* @__PURE__ */ new Map();
|
|
12952
|
-
function shouldNotify(agentId, notificationType) {
|
|
12953
|
-
const key = `${agentId}:${notificationType}`;
|
|
12954
|
-
const now = Date.now();
|
|
12955
|
-
const lastSent = notificationCooldowns.get(key);
|
|
12956
|
-
if (lastSent && now - lastSent < NOTIFICATION_COOLDOWN_MS) return false;
|
|
12957
|
-
notificationCooldowns.set(key, now);
|
|
12958
|
-
if (notificationCooldowns.size > 1e3) {
|
|
12959
|
-
for (const [k, ts] of notificationCooldowns) if (now - ts > NOTIFICATION_COOLDOWN_MS) notificationCooldowns.delete(k);
|
|
12960
|
-
}
|
|
12961
|
-
return true;
|
|
12962
|
-
}
|
|
12963
13090
|
function sendRejected(socket, ref, reason) {
|
|
12964
13091
|
socket.send(JSON.stringify({
|
|
12965
13092
|
type: "agent:bind:rejected",
|
|
@@ -13373,6 +13500,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13373
13500
|
runtimeType: bindRequest.runtimeType,
|
|
13374
13501
|
runtimeVersion: bindRequest.runtimeVersion
|
|
13375
13502
|
});
|
|
13503
|
+
markAgentFaultsResolved(app.db, agent.id).catch(() => {});
|
|
13376
13504
|
bindAgentToClient(clientId, agent.id);
|
|
13377
13505
|
boundAgents.set(agent.id, {
|
|
13378
13506
|
agentId: agent.id,
|
|
@@ -13482,8 +13610,9 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13482
13610
|
organizationId: boundAgentInfo.organizationId,
|
|
13483
13611
|
notifier
|
|
13484
13612
|
});
|
|
13485
|
-
if (payload.runtimeState === "error"
|
|
13486
|
-
else if (payload.runtimeState === "blocked"
|
|
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(() => {});
|
|
13487
13616
|
} else if (type === "session:event") {
|
|
13488
13617
|
const agentId = parsed.data.agentId;
|
|
13489
13618
|
if (!agentId || !boundAgents.has(agentId)) {
|
|
@@ -13504,17 +13633,6 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13504
13633
|
}));
|
|
13505
13634
|
}
|
|
13506
13635
|
});
|
|
13507
|
-
} else if (type === "session:completion") {
|
|
13508
|
-
const agentId = parsed.data.agentId;
|
|
13509
|
-
if (!agentId || !boundAgents.has(agentId)) {
|
|
13510
|
-
socket.send(JSON.stringify({
|
|
13511
|
-
type: "error",
|
|
13512
|
-
message: "Agent not bound"
|
|
13513
|
-
}));
|
|
13514
|
-
return;
|
|
13515
|
-
}
|
|
13516
|
-
const payload = sessionCompletionMessageSchema.parse(msg);
|
|
13517
|
-
if (shouldNotify(agentId, `session_completed:${payload.chatId}`)) notifyAgentEvent(app.db, agentId, "session_completed", "low", payload.chatId).catch(() => {});
|
|
13518
13636
|
} else if (type === "inbox:ack") {
|
|
13519
13637
|
const payloadResult = inboxAckFrameSchema.safeParse(msg);
|
|
13520
13638
|
if (!payloadResult.success) {
|
|
@@ -13568,7 +13686,6 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13568
13686
|
notifier.unsubscribe(info.inboxId, socket);
|
|
13569
13687
|
if (getAgentClientId(agentId) === clientId) try {
|
|
13570
13688
|
await unbindAgent(app.db, agentId);
|
|
13571
|
-
if (shouldNotify(agentId, "agent_disconnected")) notifyAgentEvent(app.db, agentId, "agent_disconnected", "medium").catch(() => {});
|
|
13572
13689
|
} catch {}
|
|
13573
13690
|
}
|
|
13574
13691
|
boundAgents.clear();
|
|
@@ -14604,6 +14721,83 @@ async function bindInstallationToOrg(db, installationId, hubOrganizationId) {
|
|
|
14604
14721
|
return true;
|
|
14605
14722
|
}
|
|
14606
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
|
+
/**
|
|
14607
14801
|
* Lookup the installation bound to a Hub team. Used by Settings →
|
|
14608
14802
|
* Integrations to render the connected-account panel. Returns null when
|
|
14609
14803
|
* no install is bound.
|
|
@@ -15259,10 +15453,9 @@ async function bootstrapConfigRoutes(_app) {
|
|
|
15259
15453
|
* Public endpoint — returns bootstrap prerequisites for CLI auto-discovery.
|
|
15260
15454
|
*
|
|
15261
15455
|
* `allowedOrg` used to surface here from the global `github.allowedOrg`
|
|
15262
|
-
* config; it is now
|
|
15263
|
-
* endpoint can't resolve an
|
|
15264
|
-
*
|
|
15265
|
-
* `/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`.
|
|
15266
15459
|
*/
|
|
15267
15460
|
_app.get("/config", async () => {
|
|
15268
15461
|
return { allowedOrg: null };
|
|
@@ -15595,7 +15788,7 @@ async function getCallerEngagement(db, chatId, agentId) {
|
|
|
15595
15788
|
* (speaker → "participant" / watcher → "watching"); the user
|
|
15596
15789
|
* state row supplies the unread counter (COALESCE → 0 when
|
|
15597
15790
|
* row is missing).
|
|
15598
|
-
* - Filter `parent_chat_id IS NULL` (
|
|
15791
|
+
* - Filter `parent_chat_id IS NULL` (nested chats not surfaced in v1).
|
|
15599
15792
|
* - Filter `c.organization_id = ?` to defend against historical
|
|
15600
15793
|
* cross-org pollution rows that may still reference the caller
|
|
15601
15794
|
* (see fix/cross-org-direct-chat-pollution).
|
|
@@ -15663,9 +15856,11 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
|
15663
15856
|
chatId: chatMembership.chatId,
|
|
15664
15857
|
agentId: chatMembership.agentId,
|
|
15665
15858
|
displayName: agents.displayName,
|
|
15666
|
-
type: agents.type
|
|
15667
|
-
|
|
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")));
|
|
15668
15862
|
const participantsByChat = /* @__PURE__ */ new Map();
|
|
15863
|
+
const workingByChat = /* @__PURE__ */ new Map();
|
|
15669
15864
|
for (const p of participantRows) {
|
|
15670
15865
|
const list = participantsByChat.get(p.chatId) ?? [];
|
|
15671
15866
|
list.push({
|
|
@@ -15674,6 +15869,11 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
|
15674
15869
|
type: p.type
|
|
15675
15870
|
});
|
|
15676
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
|
+
}
|
|
15677
15877
|
}
|
|
15678
15878
|
const firstMessageRows = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
15679
15879
|
chatId: messages.chatId,
|
|
@@ -15701,7 +15901,8 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
|
15701
15901
|
lastMessagePreview: r.last_message_preview,
|
|
15702
15902
|
unreadMentionCount: r.unread_mention_count,
|
|
15703
15903
|
canReply: isSpeaker,
|
|
15704
|
-
engagementStatus: r.engagement_status
|
|
15904
|
+
engagementStatus: r.engagement_status,
|
|
15905
|
+
workingAgentIds: workingByChat.get(r.chat_id) ?? []
|
|
15705
15906
|
};
|
|
15706
15907
|
}),
|
|
15707
15908
|
nextCursor
|
|
@@ -16149,15 +16350,13 @@ const organizationSettings = pgTable("organization_settings", {
|
|
|
16149
16350
|
* registry of valid namespaces and their storage / input / output schemas
|
|
16150
16351
|
* lives in `@agent-team-foundation/first-tree-hub-shared`.
|
|
16151
16352
|
*
|
|
16152
|
-
* Read path: storage row →
|
|
16153
|
-
* Write path: input → validate →
|
|
16353
|
+
* Read path: storage row → output (mask)
|
|
16354
|
+
* Write path: input → validate → merge with current storage → upsert (in tx)
|
|
16154
16355
|
*
|
|
16155
|
-
* The generic getter returns the masked output.
|
|
16156
|
-
*
|
|
16157
|
-
*
|
|
16158
|
-
*
|
|
16159
|
-
* call-sites and limits secret exposure to one explicit code path per
|
|
16160
|
-
* 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).
|
|
16161
16360
|
*/
|
|
16162
16361
|
function assertNamespace(ns) {
|
|
16163
16362
|
if (!isOrgSettingNamespace(ns)) throw new BadRequestError(`Unknown organization-settings namespace: "${ns}"`);
|
|
@@ -16170,19 +16369,15 @@ async function fetchStorageRow(db, orgId, namespace) {
|
|
|
16170
16369
|
function emptyStorage(namespace) {
|
|
16171
16370
|
return ORG_SETTINGS_NAMESPACES$1[namespace].storage.parse({});
|
|
16172
16371
|
}
|
|
16173
|
-
function ensureEncrypted(value, encryptionKey) {
|
|
16174
|
-
return isEncryptedValue(value) ? value : encryptValue(value, encryptionKey);
|
|
16175
|
-
}
|
|
16176
16372
|
/**
|
|
16177
16373
|
* Merge a validated input into the current storage row for a namespace.
|
|
16178
|
-
* Secret fields are encrypted here.
|
|
16179
16374
|
*
|
|
16180
16375
|
* Input semantics per nullish field:
|
|
16181
16376
|
* `undefined` → unchanged
|
|
16182
16377
|
* `null` → cleared
|
|
16183
16378
|
* value → set / replace (already validated as non-empty by the input schema)
|
|
16184
16379
|
*/
|
|
16185
|
-
function applyInputDelta(namespace, current, input
|
|
16380
|
+
function applyInputDelta(namespace, current, input) {
|
|
16186
16381
|
if (namespace === "context_tree") {
|
|
16187
16382
|
const cur = current;
|
|
16188
16383
|
const inp = input;
|
|
@@ -16191,11 +16386,6 @@ function applyInputDelta(namespace, current, input, encryptionKey) {
|
|
|
16191
16386
|
branch: inp.branch === void 0 ? cur.branch : inp.branch ?? "main"
|
|
16192
16387
|
};
|
|
16193
16388
|
}
|
|
16194
|
-
if (namespace === "github_integration") {
|
|
16195
|
-
const cur = current;
|
|
16196
|
-
const inp = input;
|
|
16197
|
-
return { webhookSecretCipher: inp.webhookSecret === void 0 ? cur.webhookSecretCipher : inp.webhookSecret === null ? void 0 : ensureEncrypted(inp.webhookSecret, encryptionKey) };
|
|
16198
|
-
}
|
|
16199
16389
|
if (namespace === "source_repos") {
|
|
16200
16390
|
const cur = current;
|
|
16201
16391
|
const inp = input;
|
|
@@ -16205,9 +16395,7 @@ function applyInputDelta(namespace, current, input, encryptionKey) {
|
|
|
16205
16395
|
}
|
|
16206
16396
|
/**
|
|
16207
16397
|
* Project the storage row into the API output for a namespace, masking
|
|
16208
|
-
* any secret fields.
|
|
16209
|
-
* empty string here — the route layer enriches it with the resolved
|
|
16210
|
-
* `server.publicUrl` (the service stays config-agnostic).
|
|
16398
|
+
* any secret fields.
|
|
16211
16399
|
*/
|
|
16212
16400
|
function toOutput(namespace, storage) {
|
|
16213
16401
|
if (namespace === "context_tree") {
|
|
@@ -16217,13 +16405,6 @@ function toOutput(namespace, storage) {
|
|
|
16217
16405
|
branch: s.branch
|
|
16218
16406
|
};
|
|
16219
16407
|
}
|
|
16220
|
-
if (namespace === "github_integration") {
|
|
16221
|
-
const s = storage;
|
|
16222
|
-
return {
|
|
16223
|
-
webhookSecretConfigured: typeof s.webhookSecretCipher === "string" && s.webhookSecretCipher.length > 0,
|
|
16224
|
-
webhookUrl: ""
|
|
16225
|
-
};
|
|
16226
|
-
}
|
|
16227
16408
|
if (namespace === "source_repos") return { repos: storage.repos };
|
|
16228
16409
|
return namespace;
|
|
16229
16410
|
}
|
|
@@ -16244,17 +16425,6 @@ async function getOrgContextTree(db, orgId) {
|
|
|
16244
16425
|
return await fetchStorageRow(db, orgId, "context_tree") ?? emptyStorage("context_tree");
|
|
16245
16426
|
}
|
|
16246
16427
|
/**
|
|
16247
|
-
* Decrypt and return the plaintext GitHub webhook secret for an org.
|
|
16248
|
-
* Returns `null` when the org has not configured one. The only intended
|
|
16249
|
-
* caller is the webhook route's signature verifier — the result must
|
|
16250
|
-
* never leak through HTTP responses or logs. (#4)
|
|
16251
|
-
*/
|
|
16252
|
-
async function getDecryptedGithubWebhookSecret(db, orgId, encryptionKey) {
|
|
16253
|
-
const cipher = (await fetchStorageRow(db, orgId, "github_integration"))?.webhookSecretCipher;
|
|
16254
|
-
if (!cipher) return null;
|
|
16255
|
-
return isEncryptedValue(cipher) ? decryptValue(cipher, encryptionKey) : cipher;
|
|
16256
|
-
}
|
|
16257
|
-
/**
|
|
16258
16428
|
* Upsert a setting. Returns the masked output of the resulting row.
|
|
16259
16429
|
*
|
|
16260
16430
|
* The fetch + merge + upsert sequence runs inside a single transaction so
|
|
@@ -16270,7 +16440,7 @@ async function putOrgSetting(db, orgId, namespace, rawInput, options) {
|
|
|
16270
16440
|
const txDb = tx;
|
|
16271
16441
|
const [org] = await txDb.select({ id: organizations.id }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
|
|
16272
16442
|
if (!org) throw new NotFoundError(`Organization "${orgId}" not found`);
|
|
16273
|
-
const merged = applyInputDelta(namespace, await fetchStorageRow(txDb, orgId, namespace) ?? emptyStorage(namespace), input
|
|
16443
|
+
const merged = applyInputDelta(namespace, await fetchStorageRow(txDb, orgId, namespace) ?? emptyStorage(namespace), input);
|
|
16274
16444
|
const validated = ORG_SETTINGS_NAMESPACES$1[namespace].storage.parse(merged);
|
|
16275
16445
|
await tx.insert(organizationSettings).values({
|
|
16276
16446
|
organizationId: orgId,
|
|
@@ -17400,7 +17570,7 @@ async function healthzRoutes(app) {
|
|
|
17400
17570
|
* `api/orgs/invitations.ts` (Class B, admin-gated).
|
|
17401
17571
|
*/
|
|
17402
17572
|
async function publicInvitationRoutes(app) {
|
|
17403
|
-
const { previewInvitation } = await import("./invitation-C299fxkP-
|
|
17573
|
+
const { previewInvitation } = await import("./invitation-C299fxkP-CZgGbsN_.mjs");
|
|
17404
17574
|
app.get("/:token/preview", async (request, reply) => {
|
|
17405
17575
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
17406
17576
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -17666,7 +17836,7 @@ async function meRoutes(app) {
|
|
|
17666
17836
|
*/
|
|
17667
17837
|
app.get("/me/pinned-agents", async (request) => {
|
|
17668
17838
|
const { userId } = requireUser(request);
|
|
17669
|
-
const { listMyPinnedAgents } = await import("./client-
|
|
17839
|
+
const { listMyPinnedAgents } = await import("./client-DNiLcPEq-db3YS57z.mjs");
|
|
17670
17840
|
return listMyPinnedAgents(app.db, { userId });
|
|
17671
17841
|
});
|
|
17672
17842
|
/**
|
|
@@ -17918,7 +18088,7 @@ async function orgAdapterStatusRoutes(app) {
|
|
|
17918
18088
|
return app.adapterManager.getBotStatuses().filter((s) => visibleIds.has(s.configId));
|
|
17919
18089
|
});
|
|
17920
18090
|
}
|
|
17921
|
-
const log$
|
|
18091
|
+
const log$3 = createLogger$1("OrgAdapters");
|
|
17922
18092
|
/** Class B — `/api/v1/orgs/:orgId/adapters`. */
|
|
17923
18093
|
async function orgAdapterRoutes(app) {
|
|
17924
18094
|
app.get("/", async (request) => {
|
|
@@ -17934,7 +18104,7 @@ async function orgAdapterRoutes(app) {
|
|
|
17934
18104
|
const body = createAdapterConfigSchema.parse(request.body);
|
|
17935
18105
|
await assertAgentManageableByUser(app.db, scope.userId, body.agentId);
|
|
17936
18106
|
const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
|
|
17937
|
-
app.adapterManager.reload().catch((err) => log$
|
|
18107
|
+
app.adapterManager.reload().catch((err) => log$3.error({ err }, "adapter reload failed after create"));
|
|
17938
18108
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
17939
18109
|
return reply.status(201).send({
|
|
17940
18110
|
...config,
|
|
@@ -18457,6 +18627,10 @@ async function orgNotificationRoutes(app) {
|
|
|
18457
18627
|
const query = notificationQuerySchema.parse(request.query);
|
|
18458
18628
|
return listNotifications(app.db, scope.organizationId, scope.memberId, query);
|
|
18459
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
|
+
});
|
|
18460
18634
|
app.post("/:id/read", async (request) => {
|
|
18461
18635
|
const scope = await requireOrgMembership(request, app.db);
|
|
18462
18636
|
const result = await markRead(app.db, request.params.id, scope.organizationId, scope.memberId);
|
|
@@ -18517,24 +18691,20 @@ async function orgSessionRoutes(app) {
|
|
|
18517
18691
|
* GET gating is per-namespace via `readPolicy` in the registry: namespaces
|
|
18518
18692
|
* with no secret fields (`context_tree`, `source_repos`) are readable by
|
|
18519
18693
|
* any active org member, so an invitee can see what tree and repos the
|
|
18520
|
-
* team is bound to before joining the chat.
|
|
18521
|
-
*
|
|
18522
|
-
*
|
|
18523
|
-
* 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.
|
|
18524
18697
|
*/
|
|
18525
18698
|
async function orgSettingsRoutes(app) {
|
|
18526
18699
|
app.get("/:namespace", async (request) => {
|
|
18527
18700
|
const namespace = parseNamespace(request.params.namespace);
|
|
18528
18701
|
const scope = ORG_SETTINGS_NAMESPACES$1[namespace].readPolicy === "member" ? await requireOrgMembership(request, app.db) : await requireOrgAdmin(request, app.db);
|
|
18529
|
-
return
|
|
18702
|
+
return getOrgSetting(app.db, scope.organizationId, namespace);
|
|
18530
18703
|
});
|
|
18531
18704
|
app.put("/:namespace", { config: { otelRecordBody: true } }, async (request) => {
|
|
18532
18705
|
const scope = await requireOrgAdmin(request, app.db);
|
|
18533
18706
|
const namespace = parseNamespace(request.params.namespace);
|
|
18534
|
-
return
|
|
18535
|
-
updatedBy: scope.userId,
|
|
18536
|
-
encryptionKey: app.config.secrets.encryptionKey
|
|
18537
|
-
}), scope.organizationId, app.config.server.publicUrl);
|
|
18707
|
+
return putOrgSetting(app.db, scope.organizationId, namespace, request.body, { updatedBy: scope.userId });
|
|
18538
18708
|
});
|
|
18539
18709
|
app.delete("/:namespace", async (request, reply) => {
|
|
18540
18710
|
const scope = await requireOrgAdmin(request, app.db);
|
|
@@ -18547,27 +18717,6 @@ function parseNamespace(raw) {
|
|
|
18547
18717
|
if (!isOrgSettingNamespace(raw)) throw new BadRequestError(`Unknown organization-settings namespace: "${raw}"`);
|
|
18548
18718
|
return raw;
|
|
18549
18719
|
}
|
|
18550
|
-
/**
|
|
18551
|
-
* Resolve namespace-specific server-config-derived fields. The service
|
|
18552
|
-
* layer stays config-agnostic — namespace knowledge that needs `app.config`
|
|
18553
|
-
* lives here. Currently only `github_integration.webhookUrl` qualifies.
|
|
18554
|
-
*
|
|
18555
|
-
* If `server.publicUrl` is unset on the Hub, `webhookUrl` is left as `""`
|
|
18556
|
-
* so the UI can render a "contact your site administrator" notice rather
|
|
18557
|
-
* than fall back to `window.location.origin` (which is wrong behind a
|
|
18558
|
-
* reverse proxy). (#12)
|
|
18559
|
-
*/
|
|
18560
|
-
function enrichOutput(namespace, out, orgId, publicUrl) {
|
|
18561
|
-
if (namespace === "github_integration") {
|
|
18562
|
-
const o = out;
|
|
18563
|
-
const webhookUrl = publicUrl ? `${publicUrl.replace(/\/+$/, "")}/api/v1/webhooks/github/${orgId}` : "";
|
|
18564
|
-
return {
|
|
18565
|
-
...o,
|
|
18566
|
-
webhookUrl
|
|
18567
|
-
};
|
|
18568
|
-
}
|
|
18569
|
-
return out;
|
|
18570
|
-
}
|
|
18571
18720
|
async function loadVisibleAgentIds(db, organizationId, memberId) {
|
|
18572
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))));
|
|
18573
18722
|
return new Set(rows.map((r) => r.id));
|
|
@@ -18788,6 +18937,128 @@ async function sessionRoutes(app) {
|
|
|
18788
18937
|
});
|
|
18789
18938
|
});
|
|
18790
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
|
+
}
|
|
18791
19062
|
function isRecord(value) {
|
|
18792
19063
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18793
19064
|
}
|
|
@@ -18797,10 +19068,10 @@ function repoFullName(payload) {
|
|
|
18797
19068
|
const repo = isRecord(payload.repository) ? payload.repository : null;
|
|
18798
19069
|
return typeof repo?.full_name === "string" && repo.full_name.length > 0 ? repo.full_name : null;
|
|
18799
19070
|
}
|
|
18800
|
-
function readNumber(value) {
|
|
19071
|
+
function readNumber$1(value) {
|
|
18801
19072
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
18802
19073
|
}
|
|
18803
|
-
function readString(value) {
|
|
19074
|
+
function readString$1(value) {
|
|
18804
19075
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
18805
19076
|
}
|
|
18806
19077
|
/**
|
|
@@ -18821,51 +19092,68 @@ function extractEventEntity(eventType, payload) {
|
|
|
18821
19092
|
const repo = repoFullName(payload);
|
|
18822
19093
|
if (!repo) return null;
|
|
18823
19094
|
switch (eventType) {
|
|
18824
|
-
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
|
+
}
|
|
18825
19106
|
case "issue_comment": {
|
|
18826
19107
|
const issue = isRecord(payload.issue) ? payload.issue : null;
|
|
18827
|
-
const number = readNumber(issue?.number);
|
|
19108
|
+
const number = readNumber$1(issue?.number);
|
|
18828
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
|
+
};
|
|
18829
19117
|
return {
|
|
18830
19118
|
type: "issue",
|
|
18831
19119
|
key: `${repo}#${number}`,
|
|
18832
|
-
title: readString(issue?.title) ?? void 0,
|
|
18833
|
-
url: readString(issue?.html_url) ?? void 0
|
|
19120
|
+
title: readString$1(issue?.title) ?? void 0,
|
|
19121
|
+
url: readString$1(issue?.html_url) ?? void 0
|
|
18834
19122
|
};
|
|
18835
19123
|
}
|
|
18836
19124
|
case "pull_request":
|
|
18837
19125
|
case "pull_request_review":
|
|
18838
19126
|
case "pull_request_review_comment": {
|
|
18839
19127
|
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
18840
|
-
const number = readNumber(pr?.number);
|
|
19128
|
+
const number = readNumber$1(pr?.number);
|
|
18841
19129
|
if (number === null) return null;
|
|
18842
19130
|
return {
|
|
18843
19131
|
type: "pull_request",
|
|
18844
19132
|
key: `${repo}#${number}`,
|
|
18845
|
-
title: readString(pr?.title) ?? void 0,
|
|
18846
|
-
url: readString(pr?.html_url) ?? void 0
|
|
19133
|
+
title: readString$1(pr?.title) ?? void 0,
|
|
19134
|
+
url: readString$1(pr?.html_url) ?? void 0
|
|
18847
19135
|
};
|
|
18848
19136
|
}
|
|
18849
19137
|
case "discussion":
|
|
18850
19138
|
case "discussion_comment": {
|
|
18851
19139
|
const disc = isRecord(payload.discussion) ? payload.discussion : null;
|
|
18852
|
-
const number = readNumber(disc?.number);
|
|
19140
|
+
const number = readNumber$1(disc?.number);
|
|
18853
19141
|
if (number === null) return null;
|
|
18854
19142
|
return {
|
|
18855
19143
|
type: "discussion",
|
|
18856
19144
|
key: `${repo}#discussion-${number}`,
|
|
18857
|
-
title: readString(disc?.title) ?? void 0,
|
|
18858
|
-
url: readString(disc?.html_url) ?? void 0
|
|
19145
|
+
title: readString$1(disc?.title) ?? void 0,
|
|
19146
|
+
url: readString$1(disc?.html_url) ?? void 0
|
|
18859
19147
|
};
|
|
18860
19148
|
}
|
|
18861
19149
|
case "commit_comment": {
|
|
18862
19150
|
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
18863
|
-
const sha = readString(comment?.commit_id);
|
|
19151
|
+
const sha = readString$1(comment?.commit_id);
|
|
18864
19152
|
if (!sha) return null;
|
|
18865
19153
|
return {
|
|
18866
19154
|
type: "commit",
|
|
18867
19155
|
key: `${repo}@${sha}`,
|
|
18868
|
-
url: readString(comment?.html_url) ?? void 0
|
|
19156
|
+
url: readString$1(comment?.html_url) ?? void 0
|
|
18869
19157
|
};
|
|
18870
19158
|
}
|
|
18871
19159
|
default: return null;
|
|
@@ -18952,92 +19240,6 @@ function formatEntityTitle(entity, eventType, action) {
|
|
|
18952
19240
|
if (entity.title && entity.title.length > 0) return `${head}: ${entity.title}`;
|
|
18953
19241
|
return head;
|
|
18954
19242
|
}
|
|
18955
|
-
const SILENT_EVENT_TYPES = new Set([
|
|
18956
|
-
"workflow_run",
|
|
18957
|
-
"workflow_job",
|
|
18958
|
-
"check_run",
|
|
18959
|
-
"check_suite",
|
|
18960
|
-
"status",
|
|
18961
|
-
"push",
|
|
18962
|
-
"create",
|
|
18963
|
-
"delete",
|
|
18964
|
-
"fork",
|
|
18965
|
-
"watch",
|
|
18966
|
-
"release",
|
|
18967
|
-
"label",
|
|
18968
|
-
"label_created",
|
|
18969
|
-
"label_deleted",
|
|
18970
|
-
"reaction",
|
|
18971
|
-
"member",
|
|
18972
|
-
"membership",
|
|
18973
|
-
"team",
|
|
18974
|
-
"team_add",
|
|
18975
|
-
"organization",
|
|
18976
|
-
"org_block",
|
|
18977
|
-
"project",
|
|
18978
|
-
"project_card",
|
|
18979
|
-
"project_column"
|
|
18980
|
-
]);
|
|
18981
|
-
/**
|
|
18982
|
-
* Per-event-type action-level filters. Frequent low-signal actions that would
|
|
18983
|
-
* otherwise spam an entity chat. `synchronize` (PR branch push) is the most
|
|
18984
|
-
* common offender — it fires on every commit push to a PR branch and never
|
|
18985
|
-
* carries new conversation.
|
|
18986
|
-
*/
|
|
18987
|
-
const SILENT_ACTIONS = {
|
|
18988
|
-
issues: new Set([
|
|
18989
|
-
"labeled",
|
|
18990
|
-
"unlabeled",
|
|
18991
|
-
"milestoned",
|
|
18992
|
-
"demilestoned",
|
|
18993
|
-
"pinned",
|
|
18994
|
-
"unpinned"
|
|
18995
|
-
]),
|
|
18996
|
-
pull_request: new Set([
|
|
18997
|
-
"labeled",
|
|
18998
|
-
"unlabeled",
|
|
18999
|
-
"auto_merge_enabled",
|
|
19000
|
-
"auto_merge_disabled",
|
|
19001
|
-
"synchronize"
|
|
19002
|
-
])
|
|
19003
|
-
};
|
|
19004
|
-
/** True iff the event should be silently 200-OKed without further routing. */
|
|
19005
|
-
function shouldSilent(eventType, payload) {
|
|
19006
|
-
if (SILENT_EVENT_TYPES.has(eventType)) return true;
|
|
19007
|
-
if (!isRecord(payload)) return false;
|
|
19008
|
-
if (readString((isRecord(payload.sender) ? payload.sender : null)?.type) === "Bot") return true;
|
|
19009
|
-
const action = readString(payload.action);
|
|
19010
|
-
if (!action) return false;
|
|
19011
|
-
return SILENT_ACTIONS[eventType]?.has(action) ?? false;
|
|
19012
|
-
}
|
|
19013
|
-
/**
|
|
19014
|
-
* GitHub-specific webhook entity → chat clustering (Phase 0).
|
|
19015
|
-
*
|
|
19016
|
-
* Each `(organization, human_agent, delegate_agent, entity)` tuple resolves to
|
|
19017
|
-
* exactly one chat. Future external sources (Linear, Slack, …) get their own
|
|
19018
|
-
* tables — their entity models differ enough that a generic table would slip
|
|
19019
|
-
* back into untyped jsonb.
|
|
19020
|
-
*
|
|
19021
|
-
* `bound_via` distinguishes the first-touch row (`direct`) from a row written
|
|
19022
|
-
* by the `Fixes #N` linker (`fixes_link`). Routing logic ignores the
|
|
19023
|
-
* distinction; it exists for audit and future strategy tweaks.
|
|
19024
|
-
*/
|
|
19025
|
-
const githubEntityChatMappings = pgTable("github_entity_chat_mappings", {
|
|
19026
|
-
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
19027
|
-
humanAgentId: text("human_agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
19028
|
-
delegateAgentId: text("delegate_agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
19029
|
-
entityType: text("entity_type").notNull(),
|
|
19030
|
-
entityKey: text("entity_key").notNull(),
|
|
19031
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
19032
|
-
boundAt: timestamp("bound_at", { withTimezone: true }).notNull().defaultNow(),
|
|
19033
|
-
boundVia: text("bound_via").notNull()
|
|
19034
|
-
}, (table) => [primaryKey({ columns: [
|
|
19035
|
-
table.organizationId,
|
|
19036
|
-
table.humanAgentId,
|
|
19037
|
-
table.delegateAgentId,
|
|
19038
|
-
table.entityType,
|
|
19039
|
-
table.entityKey
|
|
19040
|
-
] }), index("idx_github_entity_chat_mappings_chat").on(table.chatId)]);
|
|
19041
19243
|
/**
|
|
19042
19244
|
* Resolve which chat a GitHub event for (human, delegate, entity) belongs to.
|
|
19043
19245
|
*
|
|
@@ -19155,160 +19357,693 @@ async function createEntityChat(db, humanAgentId, delegateAgentId, entity, event
|
|
|
19155
19357
|
metadata
|
|
19156
19358
|
})).id };
|
|
19157
19359
|
}
|
|
19158
|
-
const log$
|
|
19159
|
-
function verifySignature(secret, rawBody, signatureHeader) {
|
|
19160
|
-
const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
|
|
19161
|
-
const expectedBuf = Buffer.from(expected, "utf8");
|
|
19162
|
-
const receivedBuf = Buffer.from(signatureHeader, "utf8");
|
|
19163
|
-
if (expectedBuf.length !== receivedBuf.length || !timingSafeEqual(expectedBuf, receivedBuf)) throw new UnauthorizedError("Invalid webhook signature");
|
|
19164
|
-
}
|
|
19165
|
-
/** Extract unique @mentions from text. Returns lowercase usernames.
|
|
19166
|
-
* Excludes email patterns (user@example.com) and team mentions (@org/team). */
|
|
19167
|
-
function extractMentions$1(text) {
|
|
19168
|
-
if (!text) return [];
|
|
19169
|
-
const re = /(?<!\w)@([a-zA-Z0-9][\w-]*)(\/)?/g;
|
|
19170
|
-
const names = /* @__PURE__ */ new Set();
|
|
19171
|
-
for (const m of text.matchAll(re)) {
|
|
19172
|
-
if (m[2]) continue;
|
|
19173
|
-
names.add(m[1].toLowerCase());
|
|
19174
|
-
}
|
|
19175
|
-
return [...names];
|
|
19176
|
-
}
|
|
19177
|
-
/** Extract mentions from structural payload fields (not free-form text).
|
|
19178
|
-
* GitHub's `pull_request.review_requested` puts the targeted reviewer in
|
|
19179
|
-
* `requested_reviewer.login`, not in any text body — `extractMentions` would
|
|
19180
|
-
* miss it. Team requests use `requested_team` instead, which we deliberately
|
|
19181
|
-
* skip to stay consistent with `extractMentions` ignoring `@org/team`. */
|
|
19182
|
-
function extractStructuralMentions(eventType, payload) {
|
|
19183
|
-
if (!isRecord(payload)) return [];
|
|
19184
|
-
if (eventType !== "pull_request") return [];
|
|
19185
|
-
if (payload.action !== "review_requested") return [];
|
|
19186
|
-
const reviewer = isRecord(payload.requested_reviewer) ? payload.requested_reviewer : null;
|
|
19187
|
-
const login = typeof reviewer?.login === "string" ? reviewer.login : null;
|
|
19188
|
-
return login ? [login.toLowerCase()] : [];
|
|
19189
|
-
}
|
|
19190
|
-
const DELEGATE_VERDICT_MESSAGES = {
|
|
19191
|
-
ok: "delegate_mention target eligible",
|
|
19192
|
-
not_found: "delegate_mention target not found, skipping",
|
|
19193
|
-
cross_org: "delegate_mention target belongs to another org, skipping",
|
|
19194
|
-
inactive: "delegate_mention target not active, skipping"
|
|
19195
|
-
};
|
|
19196
|
-
function evaluateDelegateTarget(target, sourceOrgId) {
|
|
19197
|
-
if (!target) return "not_found";
|
|
19198
|
-
if (target.organizationId !== sourceOrgId) return "cross_org";
|
|
19199
|
-
if (target.status !== "active") return "inactive";
|
|
19200
|
-
return "ok";
|
|
19201
|
-
}
|
|
19360
|
+
const log$2 = createLogger$1("GithubDelivery");
|
|
19202
19361
|
/**
|
|
19203
|
-
*
|
|
19362
|
+
* Stage 3 — actually emit one card per audience target.
|
|
19204
19363
|
*
|
|
19205
|
-
*
|
|
19206
|
-
*
|
|
19207
|
-
*
|
|
19208
|
-
*
|
|
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.
|
|
19209
19370
|
*
|
|
19210
|
-
* The
|
|
19211
|
-
*
|
|
19212
|
-
*
|
|
19213
|
-
|
|
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;
|
|
19410
|
+
}
|
|
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) {
|
|
19469
|
+
if (!text) return [];
|
|
19470
|
+
const names = /* @__PURE__ */ new Set();
|
|
19471
|
+
for (const m of text.matchAll(MENTION_REGEX$1)) {
|
|
19472
|
+
if (m[2]) continue;
|
|
19473
|
+
const login = m[1];
|
|
19474
|
+
if (!login) continue;
|
|
19475
|
+
names.add(login.toLowerCase());
|
|
19476
|
+
}
|
|
19477
|
+
return [...names];
|
|
19478
|
+
}
|
|
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;
|
|
19520
|
+
}
|
|
19521
|
+
/**
|
|
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, …).
|
|
19525
|
+
*
|
|
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.
|
|
19214
19529
|
*/
|
|
19215
|
-
|
|
19216
|
-
if (
|
|
19217
|
-
const
|
|
19218
|
-
|
|
19219
|
-
|
|
19220
|
-
|
|
19221
|
-
|
|
19222
|
-
|
|
19223
|
-
|
|
19224
|
-
|
|
19225
|
-
|
|
19226
|
-
|
|
19227
|
-
|
|
19228
|
-
|
|
19229
|
-
|
|
19230
|
-
|
|
19231
|
-
|
|
19232
|
-
|
|
19233
|
-
|
|
19234
|
-
|
|
19235
|
-
|
|
19236
|
-
|
|
19237
|
-
|
|
19238
|
-
|
|
19239
|
-
|
|
19240
|
-
|
|
19241
|
-
|
|
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
|
+
};
|
|
19242
19608
|
}
|
|
19243
|
-
|
|
19244
|
-
|
|
19245
|
-
|
|
19246
|
-
|
|
19247
|
-
|
|
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 {
|
|
19248
19622
|
entity,
|
|
19249
|
-
|
|
19250
|
-
|
|
19251
|
-
|
|
19252
|
-
|
|
19253
|
-
|
|
19254
|
-
|
|
19255
|
-
|
|
19256
|
-
|
|
19257
|
-
|
|
19258
|
-
|
|
19259
|
-
|
|
19260
|
-
|
|
19261
|
-
|
|
19262
|
-
|
|
19263
|
-
|
|
19264
|
-
|
|
19265
|
-
|
|
19266
|
-
|
|
19267
|
-
|
|
19268
|
-
|
|
19269
|
-
|
|
19270
|
-
|
|
19271
|
-
|
|
19272
|
-
|
|
19273
|
-
|
|
19274
|
-
|
|
19275
|
-
|
|
19276
|
-
|
|
19277
|
-
|
|
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
|
|
19278
19769
|
},
|
|
19279
|
-
|
|
19280
|
-
|
|
19281
|
-
|
|
19282
|
-
|
|
19283
|
-
|
|
19284
|
-
|
|
19285
|
-
|
|
19286
|
-
|
|
19287
|
-
|
|
19288
|
-
|
|
19289
|
-
|
|
19290
|
-
|
|
19291
|
-
|
|
19292
|
-
|
|
19293
|
-
|
|
19294
|
-
|
|
19295
|
-
|
|
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
|
+
};
|
|
19296
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;
|
|
19297
19828
|
}
|
|
19298
|
-
return routed;
|
|
19299
19829
|
}
|
|
19300
|
-
|
|
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) {
|
|
19301
20032
|
app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_request, body, done) => {
|
|
19302
20033
|
done(null, body);
|
|
19303
20034
|
});
|
|
19304
|
-
const
|
|
19305
|
-
|
|
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: {
|
|
19306
20044
|
max: webhookMax,
|
|
19307
20045
|
timeWindow: "1 minute"
|
|
19308
20046
|
} } }, async (request, reply) => {
|
|
19309
|
-
const { orgId } = request.params;
|
|
19310
|
-
const webhookSecret = await getDecryptedGithubWebhookSecret(app.db, orgId, app.config.secrets.encryptionKey);
|
|
19311
|
-
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." });
|
|
19312
20047
|
const rawBody = request.body;
|
|
19313
20048
|
if (!Buffer.isBuffer(rawBody)) throw new BadRequestError("Expected raw body buffer");
|
|
19314
20049
|
const signatureHeader = request.headers["x-hub-signature-256"];
|
|
@@ -19326,19 +20061,73 @@ async function githubWebhookRoutes(app) {
|
|
|
19326
20061
|
ok: true,
|
|
19327
20062
|
event: "ping"
|
|
19328
20063
|
});
|
|
19329
|
-
if (
|
|
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({
|
|
19330
20102
|
ok: true,
|
|
19331
20103
|
event: eventType,
|
|
19332
|
-
|
|
20104
|
+
ignored: "suspended"
|
|
19333
20105
|
});
|
|
20106
|
+
const source = {
|
|
20107
|
+
kind: "github-app-installation",
|
|
20108
|
+
installationId,
|
|
20109
|
+
organizationId: installation.hubOrganizationId
|
|
20110
|
+
};
|
|
19334
20111
|
const deliveryHeader = request.headers["x-github-delivery"];
|
|
19335
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
|
+
}
|
|
19336
20125
|
if (deliveryId) {
|
|
19337
20126
|
if (!await claimEvent(app.db, deliveryId, "github")) {
|
|
19338
20127
|
log$1.info({
|
|
19339
20128
|
deliveryId,
|
|
19340
20129
|
eventType
|
|
19341
|
-
}, "duplicate
|
|
20130
|
+
}, "duplicate delivery, skipping");
|
|
19342
20131
|
return reply.status(200).send({
|
|
19343
20132
|
ok: true,
|
|
19344
20133
|
event: eventType,
|
|
@@ -19347,217 +20136,36 @@ async function githubWebhookRoutes(app) {
|
|
|
19347
20136
|
}
|
|
19348
20137
|
}
|
|
19349
20138
|
try {
|
|
19350
|
-
const
|
|
19351
|
-
|
|
19352
|
-
|
|
19353
|
-
|
|
19354
|
-
|
|
19355
|
-
|
|
19356
|
-
|
|
19357
|
-
|
|
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);
|
|
19358
20153
|
return reply.status(200).send({
|
|
19359
20154
|
ok: true,
|
|
19360
20155
|
event: eventType,
|
|
19361
|
-
|
|
20156
|
+
...stats
|
|
19362
20157
|
});
|
|
19363
20158
|
} catch (err) {
|
|
19364
20159
|
if (deliveryId) await unclaimEvent(app.db, deliveryId, "github").catch((unclaimErr) => {
|
|
19365
20160
|
log$1.error({
|
|
19366
20161
|
err: unclaimErr,
|
|
19367
20162
|
deliveryId
|
|
19368
|
-
}, "failed to unclaim
|
|
20163
|
+
}, "failed to unclaim delivery after handler error");
|
|
19369
20164
|
});
|
|
19370
20165
|
throw err;
|
|
19371
20166
|
}
|
|
19372
20167
|
});
|
|
19373
20168
|
}
|
|
19374
|
-
/** Extract text body from any GitHub webhook event for @mention scanning. */
|
|
19375
|
-
function extractEventText(eventType, payload) {
|
|
19376
|
-
if (!isRecord(payload)) return null;
|
|
19377
|
-
switch (eventType) {
|
|
19378
|
-
case "issues": {
|
|
19379
|
-
const issue = isRecord(payload.issue) ? payload.issue : null;
|
|
19380
|
-
return typeof issue?.body === "string" ? issue.body : null;
|
|
19381
|
-
}
|
|
19382
|
-
case "issue_comment":
|
|
19383
|
-
case "pull_request_review_comment":
|
|
19384
|
-
case "commit_comment":
|
|
19385
|
-
case "discussion_comment": {
|
|
19386
|
-
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
19387
|
-
return typeof comment?.body === "string" ? comment.body : null;
|
|
19388
|
-
}
|
|
19389
|
-
case "pull_request": {
|
|
19390
|
-
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
19391
|
-
return typeof pr?.body === "string" ? pr.body : null;
|
|
19392
|
-
}
|
|
19393
|
-
case "pull_request_review": {
|
|
19394
|
-
const review = isRecord(payload.review) ? payload.review : null;
|
|
19395
|
-
return typeof review?.body === "string" ? review.body : null;
|
|
19396
|
-
}
|
|
19397
|
-
case "discussion": {
|
|
19398
|
-
const disc = isRecord(payload.discussion) ? payload.discussion : null;
|
|
19399
|
-
return typeof disc?.body === "string" ? disc.body : null;
|
|
19400
|
-
}
|
|
19401
|
-
default: return null;
|
|
19402
|
-
}
|
|
19403
|
-
}
|
|
19404
|
-
/** Extract context info from any GitHub webhook event for delegation messages. */
|
|
19405
|
-
function extractEventContext(eventType, payload) {
|
|
19406
|
-
if (!isRecord(payload)) return null;
|
|
19407
|
-
const repo = isRecord(payload.repository) ? payload.repository : null;
|
|
19408
|
-
const sender = isRecord(payload.sender) ? payload.sender : null;
|
|
19409
|
-
const repository = typeof repo?.full_name === "string" ? repo.full_name : "";
|
|
19410
|
-
const senderLogin = typeof sender?.login === "string" ? sender.login : "";
|
|
19411
|
-
const action = typeof payload.action === "string" ? payload.action : void 0;
|
|
19412
|
-
switch (eventType) {
|
|
19413
|
-
case "issues": {
|
|
19414
|
-
const issue = isRecord(payload.issue) ? payload.issue : null;
|
|
19415
|
-
if (!issue) return null;
|
|
19416
|
-
return {
|
|
19417
|
-
event: "issues",
|
|
19418
|
-
action,
|
|
19419
|
-
repository,
|
|
19420
|
-
sender: senderLogin,
|
|
19421
|
-
title: `Issue #${issue.number}: ${issue.title}`,
|
|
19422
|
-
body: typeof issue.body === "string" ? issue.body : "",
|
|
19423
|
-
url: typeof issue.html_url === "string" ? issue.html_url : ""
|
|
19424
|
-
};
|
|
19425
|
-
}
|
|
19426
|
-
case "issue_comment": {
|
|
19427
|
-
const issue = isRecord(payload.issue) ? payload.issue : null;
|
|
19428
|
-
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
19429
|
-
if (!issue || !comment) return null;
|
|
19430
|
-
return {
|
|
19431
|
-
event: "issue_comment",
|
|
19432
|
-
action,
|
|
19433
|
-
repository,
|
|
19434
|
-
sender: senderLogin,
|
|
19435
|
-
title: `Issue #${issue.number}: ${issue.title}`,
|
|
19436
|
-
body: typeof comment.body === "string" ? comment.body : "",
|
|
19437
|
-
url: typeof comment.html_url === "string" ? comment.html_url : ""
|
|
19438
|
-
};
|
|
19439
|
-
}
|
|
19440
|
-
case "pull_request": {
|
|
19441
|
-
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
19442
|
-
if (!pr) return null;
|
|
19443
|
-
return {
|
|
19444
|
-
event: "pull_request",
|
|
19445
|
-
action,
|
|
19446
|
-
repository,
|
|
19447
|
-
sender: senderLogin,
|
|
19448
|
-
title: `PR #${pr.number}: ${pr.title}`,
|
|
19449
|
-
body: typeof pr.body === "string" ? pr.body : "",
|
|
19450
|
-
url: typeof pr.html_url === "string" ? pr.html_url : ""
|
|
19451
|
-
};
|
|
19452
|
-
}
|
|
19453
|
-
case "pull_request_review": {
|
|
19454
|
-
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
19455
|
-
const review = isRecord(payload.review) ? payload.review : null;
|
|
19456
|
-
if (!pr || !review) return null;
|
|
19457
|
-
return {
|
|
19458
|
-
event: "pull_request_review",
|
|
19459
|
-
action,
|
|
19460
|
-
repository,
|
|
19461
|
-
sender: senderLogin,
|
|
19462
|
-
title: `PR #${pr.number}: ${pr.title}`,
|
|
19463
|
-
body: typeof review.body === "string" ? review.body : "",
|
|
19464
|
-
url: typeof review.html_url === "string" ? review.html_url : ""
|
|
19465
|
-
};
|
|
19466
|
-
}
|
|
19467
|
-
case "pull_request_review_comment": {
|
|
19468
|
-
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
19469
|
-
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
19470
|
-
if (!pr || !comment) return null;
|
|
19471
|
-
return {
|
|
19472
|
-
event: "pull_request_review_comment",
|
|
19473
|
-
action,
|
|
19474
|
-
repository,
|
|
19475
|
-
sender: senderLogin,
|
|
19476
|
-
title: `PR #${pr.number}: ${pr.title}`,
|
|
19477
|
-
body: typeof comment.body === "string" ? comment.body : "",
|
|
19478
|
-
url: typeof comment.html_url === "string" ? comment.html_url : ""
|
|
19479
|
-
};
|
|
19480
|
-
}
|
|
19481
|
-
case "discussion": {
|
|
19482
|
-
const disc = isRecord(payload.discussion) ? payload.discussion : null;
|
|
19483
|
-
if (!disc) return null;
|
|
19484
|
-
return {
|
|
19485
|
-
event: "discussion",
|
|
19486
|
-
action,
|
|
19487
|
-
repository,
|
|
19488
|
-
sender: senderLogin,
|
|
19489
|
-
title: typeof disc.title === "string" ? disc.title : "",
|
|
19490
|
-
body: typeof disc.body === "string" ? disc.body : "",
|
|
19491
|
-
url: typeof disc.html_url === "string" ? disc.html_url : ""
|
|
19492
|
-
};
|
|
19493
|
-
}
|
|
19494
|
-
case "discussion_comment": {
|
|
19495
|
-
const disc = isRecord(payload.discussion) ? payload.discussion : null;
|
|
19496
|
-
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
19497
|
-
if (!disc || !comment) return null;
|
|
19498
|
-
return {
|
|
19499
|
-
event: "discussion_comment",
|
|
19500
|
-
action,
|
|
19501
|
-
repository,
|
|
19502
|
-
sender: senderLogin,
|
|
19503
|
-
title: typeof disc.title === "string" ? disc.title : "",
|
|
19504
|
-
body: typeof comment.body === "string" ? comment.body : "",
|
|
19505
|
-
url: typeof comment.html_url === "string" ? comment.html_url : ""
|
|
19506
|
-
};
|
|
19507
|
-
}
|
|
19508
|
-
case "commit_comment": {
|
|
19509
|
-
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
19510
|
-
if (!comment) return null;
|
|
19511
|
-
return {
|
|
19512
|
-
event: "commit_comment",
|
|
19513
|
-
action,
|
|
19514
|
-
repository,
|
|
19515
|
-
sender: senderLogin,
|
|
19516
|
-
title: "Commit comment",
|
|
19517
|
-
body: typeof comment.body === "string" ? comment.body : "",
|
|
19518
|
-
url: typeof comment.html_url === "string" ? comment.html_url : ""
|
|
19519
|
-
};
|
|
19520
|
-
}
|
|
19521
|
-
default: return null;
|
|
19522
|
-
}
|
|
19523
|
-
}
|
|
19524
|
-
/**
|
|
19525
|
-
* Run mention delegation for a given event type and payload.
|
|
19526
|
-
* Only called after action gating confirms this is a "new content" event.
|
|
19527
|
-
*/
|
|
19528
|
-
async function handleMentionDelegation(app, organizationId, eventType, payload) {
|
|
19529
|
-
const textMentions = extractMentions$1(extractEventText(eventType, payload));
|
|
19530
|
-
const structuralMentions = extractStructuralMentions(eventType, payload);
|
|
19531
|
-
const mentions = [...new Set([...textMentions, ...structuralMentions])];
|
|
19532
|
-
if (mentions.length === 0) return 0;
|
|
19533
|
-
const ctx = extractEventContext(eventType, payload);
|
|
19534
|
-
if (!ctx) return 0;
|
|
19535
|
-
const entity = extractEventEntity(eventType, payload);
|
|
19536
|
-
if (!entity) {
|
|
19537
|
-
log$1.warn({ eventType }, "mention extracted but no entity resolvable; skipping fan-out");
|
|
19538
|
-
return 0;
|
|
19539
|
-
}
|
|
19540
|
-
return routeMentionDelegations(app, organizationId, mentions, ctx, entity, eventType === "pull_request" && ctx.repository.length > 0 ? parseFixesRefs(ctx.body, ctx.repository) : []);
|
|
19541
|
-
}
|
|
19542
|
-
/** Actions that represent new/changed content (worth scanning for @mentions).
|
|
19543
|
-
* Note: `pull_request.review_requested` doesn't carry an @mention in any
|
|
19544
|
-
* text body — the reviewer is in `requested_reviewer.login`. We pick it up
|
|
19545
|
-
* via `extractStructuralMentions`. The complementary `review_request_removed`
|
|
19546
|
-
* is intentionally omitted to avoid notifying the reviewer twice. */
|
|
19547
|
-
const MENTION_ACTIONS = {
|
|
19548
|
-
issues: ["opened", "edited"],
|
|
19549
|
-
issue_comment: ["created"],
|
|
19550
|
-
pull_request: [
|
|
19551
|
-
"opened",
|
|
19552
|
-
"edited",
|
|
19553
|
-
"review_requested"
|
|
19554
|
-
],
|
|
19555
|
-
pull_request_review: ["submitted"],
|
|
19556
|
-
pull_request_review_comment: ["created"],
|
|
19557
|
-
discussion: ["created", "edited"],
|
|
19558
|
-
discussion_comment: ["created"],
|
|
19559
|
-
commit_comment: ["created"]
|
|
19560
|
-
};
|
|
19561
20169
|
/**
|
|
19562
20170
|
* Boot-time configuration sanity checks. Called from `buildApp` so BOTH
|
|
19563
20171
|
* server entry points are covered:
|
|
@@ -21012,6 +21620,12 @@ async function buildApp(config) {
|
|
|
21012
21620
|
...sslOptions(config.database.url)
|
|
21013
21621
|
});
|
|
21014
21622
|
const notifier = createNotifier(listenClient);
|
|
21623
|
+
registerCrossInstanceBroadcaster((payload) => {
|
|
21624
|
+
notifier.notifyAdminBroadcast(payload).catch(() => {});
|
|
21625
|
+
});
|
|
21626
|
+
notifier.onAdminBroadcast((payload) => {
|
|
21627
|
+
broadcastToAdmins(payload);
|
|
21628
|
+
});
|
|
21015
21629
|
await app.register(websocket, { options: { maxPayload: config.ws?.maxPayload ?? 65536 } });
|
|
21016
21630
|
const corsOrigin = config.cors?.origin;
|
|
21017
21631
|
const isDev = process.env.NODE_ENV !== "production";
|
|
@@ -21091,7 +21705,7 @@ async function buildApp(config) {
|
|
|
21091
21705
|
await app.register(healthzRoutes);
|
|
21092
21706
|
await app.register(namePlugin("apiV1Scope", async (api) => {
|
|
21093
21707
|
await api.register(healthRoutes);
|
|
21094
|
-
await api.register(
|
|
21708
|
+
await api.register(githubAppWebhookRoutes, { prefix: "/webhooks" });
|
|
21095
21709
|
await api.register(authRoutes, { prefix: "/auth" });
|
|
21096
21710
|
await api.register(githubOauthRoutes, { prefix: "/auth/github" });
|
|
21097
21711
|
await api.register(publicInvitationRoutes, { prefix: "/invitations" });
|