@agent-team-foundation/first-tree-hub 0.12.9 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{bootstrap-BCZC1ki6.mjs → bootstrap-Cya2OoHz.mjs} +7 -7
- package/dist/cli/index.mjs +268 -480
- package/dist/{client-OMwJMCQt-R1T06ZH6.mjs → client-BH4CmUL0-CybE3kuP.mjs} +92 -8
- package/dist/{client-CjGIGddS-BrpazWa3.mjs → client-h4KZ3b9o-CQyibXig.mjs} +3 -3
- package/dist/{dist-CnjqakXS.mjs → dist-C8yStx2L.mjs} +160 -36
- package/dist/drizzle/0041_notifications_dedup_key.sql +29 -0
- package/dist/drizzle/0042_notifications_drop_legacy_types.sql +36 -0
- package/dist/drizzle/0043_onboarding_completed_at.sql +32 -0
- package/dist/drizzle/meta/_journal.json +21 -0
- package/dist/{errors-CF5evtJt-B0NTIVPt.mjs → errors-LPcARA4K-Dbrptiyz.mjs} +2 -1
- package/dist/{feishu-DrnBbl8T.mjs → feishu-D_vnqC6a.mjs} +1 -1
- package/dist/index.mjs +7 -7
- package/dist/invitation-CNv7gfFF-D93KQte0.mjs +4 -0
- package/dist/{invitation-Bg0TRiyx-BsZH4GCS.mjs → invitation-DZO4NX3P-BPxTeHf-.mjs} +2 -2
- package/dist/{saas-connect-CXZhK485.mjs → saas-connect-Bb5LR4y6.mjs} +1499 -716
- package/dist/web/assets/{index-BPMrSv_A.js → index-CJcRUZ8l.js} +1 -1
- package/dist/web/assets/index-DL_9NFkt.js +421 -0
- package/dist/web/assets/index-DaWEZnjh.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/invitation-C299fxkP-KKslbta2.mjs +0 -4
- package/dist/web/assets/index-DxAYxUpz.css +0 -1
- package/dist/web/assets/index-ntmzuk5X.js +0 -421
|
@@ -1,12 +1,12 @@
|
|
|
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-Cya2OoHz.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
|
|
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-
|
|
5
|
+
import { $ as notificationQuerySchema, A as createMemberSchema, B as githubDevCallbackQuerySchema, C as connectTokenExchangeSchema, Ct as updateChatSchema, D as createAgentSchema, Dt as wsAuthFrameSchema, E as createAdapterMappingSchema, Et as updateOrganizationSchema, F as dryRunAgentRuntimeConfigSchema, G as inboxPollQuerySchema, H as imageInlineContentSchema, J as isReservedAgentName$1, K as isOrgSettingNamespace, L as githubAppInstallationClaimBodySchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as messageSourceSchema$1, R as githubAppInstallationPermissionsSchema$1, S as clientRegisterSchema, St as updateAgentSchema, T as createAdapterConfigSchema, Tt as updateMemberSchema, U as inboxAckFrameSchema, V as githubStartQuerySchema, W as inboxDeliverFrameSchema$1, X as listMeChatsQuerySchema, Y as joinByInvitationSchema, Z as loginSchema, _ as agentPinnedMessageSchema$1, _t as sessionStateMessageSchema, a as AGENT_TYPES, b as chatMetadataSchema$1, bt as updateAdapterConfigSchema, ct as runtimeStateMessageSchema, d as NOTIFICATION_TYPES, dt as selfServiceFeishuBotSchema, et as onboardingEventSchema, f as ORG_SETTINGS_NAMESPACES$1, ft as sendMessageSchema, g as agentBindRequestSchema, gt as sessionReconcileRequestSchema, h as addParticipantSchema, ht as sessionEventSchema$1, i as AGENT_STATUSES, j as createOrgFromMeSchema, k as createMeChatSchema, lt as safeRedirectPath, m as addMeChatParticipantsSchema, mt as sessionEventMessageSchema, n as AGENT_NAME_REGEX$1, nt as patchChatEngagementSchema, o as AGENT_VISIBILITY, ot as rebindAgentSchema, p as WS_AUTH_FRAME_TIMEOUT_MS, pt as sendToAgentSchema, q as isRedactedEnvValue, r as AGENT_SELECTOR_HEADER$1, rt as patchOnboardingSchema, s as CHAT_ENGAGEMENT_STATUSES, st as refreshTokenSchema, t as AGENT_BIND_REJECT_REASONS, tt as paginationQuerySchema, u as MENTION_REGEX, v as agentRuntimeConfigPayloadSchema$1, vt as stripCode, w as contextTreeSnapshotSchema, wt as updateClientCapabilitiesSchema, xt as updateAgentRuntimeConfigSchema, y as agentTypeSchema$1, yt as submitQuestionAnswerSchema, z as githubCallbackQuerySchema } from "./dist-C8yStx2L.mjs";
|
|
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-LPcARA4K-Dbrptiyz.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-
|
|
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-
|
|
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-BH4CmUL0-CybE3kuP.mjs";
|
|
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-DZO4NX3P-BPxTeHf-.mjs";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import { ZodError, z } from "zod";
|
|
12
12
|
import { basename, delimiter, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
@@ -386,6 +386,7 @@ z.object({
|
|
|
386
386
|
const PROMPT_APPEND_MAX_LENGTH = 32e3;
|
|
387
387
|
const MCP_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
388
388
|
const ENV_KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
|
389
|
+
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/;
|
|
389
390
|
const promptConfigSchema = z.object({ append: z.string().max(PROMPT_APPEND_MAX_LENGTH).default("") });
|
|
390
391
|
const mcpStdioServerSchema = z.object({
|
|
391
392
|
name: z.string().regex(MCP_NAME_PATTERN, "MCP name must match /^[a-z0-9][a-z0-9_-]{0,63}$/i"),
|
|
@@ -415,10 +416,38 @@ const envEntrySchema = z.object({
|
|
|
415
416
|
value: z.string(),
|
|
416
417
|
sensitive: z.boolean().default(false)
|
|
417
418
|
});
|
|
419
|
+
function hasControlCharacters(value) {
|
|
420
|
+
for (let idx = 0; idx < value.length; idx++) {
|
|
421
|
+
const code = value.charCodeAt(idx);
|
|
422
|
+
if (code <= 31 || code === 127) return true;
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
function getRepoLocalPathSafetyError(localPath) {
|
|
427
|
+
if (localPath.length === 0) return "Git repo local path must not be empty";
|
|
428
|
+
if (localPath.trim() !== localPath) return "Git repo local path must not have leading or trailing whitespace";
|
|
429
|
+
if (hasControlCharacters(localPath)) return "Git repo local path must not contain control characters";
|
|
430
|
+
if (localPath.includes("\\")) return "Git repo local path must use forward slashes";
|
|
431
|
+
if (localPath.startsWith("/") || WINDOWS_DRIVE_PATH_PATTERN.test(localPath)) return "Git repo local path must be relative";
|
|
432
|
+
const segments = localPath.split("/");
|
|
433
|
+
for (const segment of segments) {
|
|
434
|
+
if (!segment) return "Git repo local path must not contain empty path segments";
|
|
435
|
+
if (segment === "." || segment === "..") return "Git repo local path must not contain dot segments";
|
|
436
|
+
if (segment.trim() !== segment) return "Git repo local path segments must not have leading or trailing whitespace";
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
418
440
|
const gitRepoSchema = z.object({
|
|
419
441
|
url: z.string().min(1),
|
|
420
442
|
ref: z.string().min(1).optional(),
|
|
421
|
-
localPath: z.string().min(1).
|
|
443
|
+
localPath: z.string().min(1).superRefine((localPath, ctx) => {
|
|
444
|
+
const safetyError = getRepoLocalPathSafetyError(localPath);
|
|
445
|
+
if (!safetyError) return;
|
|
446
|
+
ctx.addIssue({
|
|
447
|
+
code: z.ZodIssueCode.custom,
|
|
448
|
+
message: safetyError
|
|
449
|
+
});
|
|
450
|
+
}).optional()
|
|
422
451
|
});
|
|
423
452
|
/**
|
|
424
453
|
* Untagged base shape — 5 user-tunable fields, no `kind` discriminator.
|
|
@@ -759,11 +788,7 @@ const chatMetadataSchema = z.discriminatedUnion("source", [githubChatMetadataSch
|
|
|
759
788
|
* sneak through `{ source: "github" }` without the required fields.
|
|
760
789
|
*/
|
|
761
790
|
const optionalChatMetadataSchema = z.union([z.object({}).strict(), chatMetadataSchema]);
|
|
762
|
-
const chatTypeSchema = z.enum([
|
|
763
|
-
"direct",
|
|
764
|
-
"group",
|
|
765
|
-
"thread"
|
|
766
|
-
]);
|
|
791
|
+
const chatTypeSchema = z.enum(["direct", "group"]);
|
|
767
792
|
const chatEngagementStatusSchema = z.enum([
|
|
768
793
|
"active",
|
|
769
794
|
"archived",
|
|
@@ -1265,6 +1290,26 @@ const meChatParticipantSchema = z.object({
|
|
|
1265
1290
|
displayName: z.string(),
|
|
1266
1291
|
type: z.string()
|
|
1267
1292
|
});
|
|
1293
|
+
/**
|
|
1294
|
+
* Live activity hint surfaced in the conversation row's time slot. Derived
|
|
1295
|
+
* server-side from the latest `session_events` row for the chat. See
|
|
1296
|
+
* `MeChatRow.liveActivity` for the lifecycle rules.
|
|
1297
|
+
*
|
|
1298
|
+
* `kind` is intentionally narrower than the full `sessionEventKind` enum:
|
|
1299
|
+
* `turn_end` / `error` produce `liveActivity: null` rather than a live
|
|
1300
|
+
* indicator.
|
|
1301
|
+
*/
|
|
1302
|
+
const liveActivityKindSchema = z.enum([
|
|
1303
|
+
"tool_call",
|
|
1304
|
+
"thinking",
|
|
1305
|
+
"assistant_text"
|
|
1306
|
+
]);
|
|
1307
|
+
const liveActivitySchema = z.object({
|
|
1308
|
+
agentId: z.string(),
|
|
1309
|
+
kind: liveActivityKindSchema,
|
|
1310
|
+
label: z.string(),
|
|
1311
|
+
startedAt: z.string()
|
|
1312
|
+
});
|
|
1268
1313
|
const meChatRowSchema = z.object({
|
|
1269
1314
|
chatId: z.string(),
|
|
1270
1315
|
type: z.string(),
|
|
@@ -1277,7 +1322,9 @@ const meChatRowSchema = z.object({
|
|
|
1277
1322
|
lastMessagePreview: z.string().nullable(),
|
|
1278
1323
|
unreadMentionCount: z.number().int(),
|
|
1279
1324
|
canReply: z.boolean(),
|
|
1280
|
-
engagementStatus: chatEngagementStatusSchema
|
|
1325
|
+
engagementStatus: chatEngagementStatusSchema,
|
|
1326
|
+
engagedAgentIds: z.array(z.string()),
|
|
1327
|
+
liveActivity: liveActivitySchema.nullable()
|
|
1281
1328
|
});
|
|
1282
1329
|
z.object({
|
|
1283
1330
|
rows: z.array(meChatRowSchema),
|
|
@@ -1377,15 +1424,101 @@ memberSchema.extend({
|
|
|
1377
1424
|
displayName: z.string(),
|
|
1378
1425
|
password: z.string()
|
|
1379
1426
|
});
|
|
1427
|
+
/**
|
|
1428
|
+
* Origin of a normalized webhook event. After the GitHub App ingestion
|
|
1429
|
+
* cutover this is single-form (App installations only); the type stays
|
|
1430
|
+
* a structured object so future non-GitHub sources can be added by
|
|
1431
|
+
* widening it into a discriminated union without churning callers.
|
|
1432
|
+
*/
|
|
1433
|
+
const webhookSourceSchema = z.object({
|
|
1434
|
+
kind: z.literal("github-app-installation"),
|
|
1435
|
+
installationId: z.number().int(),
|
|
1436
|
+
organizationId: z.string().min(1)
|
|
1437
|
+
});
|
|
1438
|
+
const involveReasonSchema = z.enum([
|
|
1439
|
+
"mentioned",
|
|
1440
|
+
"review_requested",
|
|
1441
|
+
"assigned"
|
|
1442
|
+
]);
|
|
1443
|
+
const normalizedEventKindSchema = z.enum([
|
|
1444
|
+
"opened",
|
|
1445
|
+
"edited",
|
|
1446
|
+
"closed",
|
|
1447
|
+
"merged",
|
|
1448
|
+
"reopened",
|
|
1449
|
+
"commented",
|
|
1450
|
+
"review_requested",
|
|
1451
|
+
"reviewed",
|
|
1452
|
+
"review_comment",
|
|
1453
|
+
"synchronized",
|
|
1454
|
+
"commit_commented",
|
|
1455
|
+
"assigned",
|
|
1456
|
+
"other"
|
|
1457
|
+
]);
|
|
1458
|
+
const normalizedEntitySchema = z.object({
|
|
1459
|
+
type: githubEntityTypeSchema,
|
|
1460
|
+
repo: z.string().min(1),
|
|
1461
|
+
key: z.string().min(1),
|
|
1462
|
+
title: z.string().optional(),
|
|
1463
|
+
url: z.string().optional()
|
|
1464
|
+
});
|
|
1465
|
+
const normalizedActorSchema = z.object({
|
|
1466
|
+
githubLogin: z.string().min(1),
|
|
1467
|
+
isBot: z.boolean()
|
|
1468
|
+
});
|
|
1469
|
+
const normalizedInvolveSchema = z.object({
|
|
1470
|
+
githubLogin: z.string().min(1),
|
|
1471
|
+
reason: involveReasonSchema
|
|
1472
|
+
});
|
|
1473
|
+
const normalizedSurfaceSchema = z.object({
|
|
1474
|
+
title: z.string(),
|
|
1475
|
+
body: z.string(),
|
|
1476
|
+
url: z.string()
|
|
1477
|
+
});
|
|
1478
|
+
const normalizedRelatedRefSchema = z.object({
|
|
1479
|
+
type: z.literal("issue"),
|
|
1480
|
+
key: z.string().min(1)
|
|
1481
|
+
});
|
|
1482
|
+
z.object({
|
|
1483
|
+
source: webhookSourceSchema,
|
|
1484
|
+
deliveryId: z.string().nullable(),
|
|
1485
|
+
rawEventType: z.string().min(1),
|
|
1486
|
+
rawAction: z.string().nullable(),
|
|
1487
|
+
entity: normalizedEntitySchema,
|
|
1488
|
+
actor: normalizedActorSchema,
|
|
1489
|
+
kind: normalizedEventKindSchema,
|
|
1490
|
+
involves: z.array(normalizedInvolveSchema),
|
|
1491
|
+
surface: normalizedSurfaceSchema,
|
|
1492
|
+
relatedRefs: z.array(normalizedRelatedRefSchema)
|
|
1493
|
+
});
|
|
1494
|
+
const githubEventCardReasonSchema = z.enum([
|
|
1495
|
+
"mentioned",
|
|
1496
|
+
"review_requested",
|
|
1497
|
+
"assigned",
|
|
1498
|
+
"subscribed"
|
|
1499
|
+
]);
|
|
1500
|
+
z.object({
|
|
1501
|
+
type: z.literal("github_event"),
|
|
1502
|
+
reason: githubEventCardReasonSchema,
|
|
1503
|
+
event: z.string().min(1),
|
|
1504
|
+
action: z.string().nullable(),
|
|
1505
|
+
kind: normalizedEventKindSchema,
|
|
1506
|
+
repository: z.string(),
|
|
1507
|
+
sender: z.string(),
|
|
1508
|
+
title: z.string(),
|
|
1509
|
+
body: z.string(),
|
|
1510
|
+
url: z.string(),
|
|
1511
|
+
entity: z.object({
|
|
1512
|
+
type: githubEntityTypeSchema,
|
|
1513
|
+
key: z.string().min(1),
|
|
1514
|
+
url: z.string().nullable()
|
|
1515
|
+
}),
|
|
1516
|
+
mentionedUser: z.string().optional()
|
|
1517
|
+
});
|
|
1380
1518
|
const notificationTypeSchema = z.enum([
|
|
1381
1519
|
"agent_error",
|
|
1382
|
-
"session_error",
|
|
1383
|
-
"agent_needs_decision",
|
|
1384
1520
|
"agent_blocked",
|
|
1385
|
-
"agent_stale"
|
|
1386
|
-
"agent_disconnected",
|
|
1387
|
-
"agent_connected",
|
|
1388
|
-
"session_completed"
|
|
1521
|
+
"agent_stale"
|
|
1389
1522
|
]);
|
|
1390
1523
|
const notificationSeveritySchema = z.enum([
|
|
1391
1524
|
"high",
|
|
@@ -1502,12 +1635,6 @@ const orgContextTreeOutputSchema = z.object({
|
|
|
1502
1635
|
repo: z.string().optional(),
|
|
1503
1636
|
branch: z.string().optional()
|
|
1504
1637
|
});
|
|
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
1638
|
const orgSourceReposStorageSchema = z.object({ repos: z.array(z.object({
|
|
1512
1639
|
url: repoUrlSchema,
|
|
1513
1640
|
defaultBranch: z.string().optional()
|
|
@@ -1527,12 +1654,6 @@ const ORG_SETTINGS_NAMESPACES = {
|
|
|
1527
1654
|
output: orgContextTreeOutputSchema,
|
|
1528
1655
|
readPolicy: "member"
|
|
1529
1656
|
},
|
|
1530
|
-
github_integration: {
|
|
1531
|
-
storage: orgGithubIntegrationStorageSchema,
|
|
1532
|
-
input: orgGithubIntegrationInputSchema,
|
|
1533
|
-
output: orgGithubIntegrationOutputSchema,
|
|
1534
|
-
readPolicy: "admin"
|
|
1535
|
-
},
|
|
1536
1657
|
source_repos: {
|
|
1537
1658
|
storage: orgSourceReposStorageSchema,
|
|
1538
1659
|
input: orgSourceReposInputSchema,
|
|
@@ -1737,10 +1858,6 @@ z.object({
|
|
|
1737
1858
|
chatId: z.string(),
|
|
1738
1859
|
event: sessionEventSchema
|
|
1739
1860
|
});
|
|
1740
|
-
z.object({
|
|
1741
|
-
agentId: z.string(),
|
|
1742
|
-
chatId: z.string()
|
|
1743
|
-
});
|
|
1744
1861
|
z.object({
|
|
1745
1862
|
type: z.literal("session:reconcile"),
|
|
1746
1863
|
agentId: z.string().min(1),
|
|
@@ -1947,7 +2064,7 @@ defineConfig({
|
|
|
1947
2064
|
rateLimit: optional({
|
|
1948
2065
|
max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
|
|
1949
2066
|
loginMax: field(z.number().default(5), { env: "FIRST_TREE_HUB_RATE_LIMIT_LOGIN_MAX" }),
|
|
1950
|
-
webhookMax: field(z.number().default(
|
|
2067
|
+
webhookMax: field(z.number().default(600), { env: "FIRST_TREE_HUB_RATE_LIMIT_WEBHOOK_MAX" }),
|
|
1951
2068
|
contextTreeSnapshotMax: field(z.number().default(6), { env: "FIRST_TREE_HUB_RATE_LIMIT_CONTEXT_TREE_SNAPSHOT_MAX" }),
|
|
1952
2069
|
agentMessageMax: field(z.number().default(30), { env: "FIRST_TREE_HUB_RATE_LIMIT_AGENT_MESSAGE_MAX" })
|
|
1953
2070
|
}),
|
|
@@ -2538,14 +2655,6 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2538
2655
|
event
|
|
2539
2656
|
}));
|
|
2540
2657
|
}
|
|
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
2658
|
/** Ask the server which of the supplied chatIds the client should drop. */
|
|
2550
2659
|
sendSessionReconcile(agentId, chatIds) {
|
|
2551
2660
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
@@ -3250,7 +3359,7 @@ You are running inside **Agent Hub**, a messaging platform for agent teams.
|
|
|
3250
3359
|
|
|
3251
3360
|
- Messages from other team members arrive as your prompt input
|
|
3252
3361
|
- Each message includes a \`[From: <agent-name>]\` header — that name is also
|
|
3253
|
-
what you pass back to \`
|
|
3362
|
+
what you pass back to \`chat send\` to reply to or address that agent
|
|
3254
3363
|
- **Your final text response is automatically delivered** to the chat — just respond normally
|
|
3255
3364
|
- For **proactive communication** (sending to other agents, other chats, or structured data),
|
|
3256
3365
|
use the \`first-tree-hub\` CLI below
|
|
@@ -3274,7 +3383,7 @@ The \`first-tree-hub\` CLI reads these automatically — no extra setup needed.
|
|
|
3274
3383
|
|
|
3275
3384
|
## Sending Messages
|
|
3276
3385
|
|
|
3277
|
-
Use the \`first-tree-hub
|
|
3386
|
+
Use the \`first-tree-hub chat send\` CLI — it reads the env vars above and
|
|
3278
3387
|
attaches the \`Authorization\` + \`X-Agent-Id\` headers automatically:
|
|
3279
3388
|
|
|
3280
3389
|
\`\`\`bash
|
|
@@ -3286,28 +3395,38 @@ attaches the \`Authorization\` + \`X-Agent-Id\` headers automatically:
|
|
|
3286
3395
|
# the case in a group chat where someone @-mentioned you to talk to them),
|
|
3287
3396
|
# the message stays in that chat. Otherwise it falls back to a direct chat
|
|
3288
3397
|
# between you and the recipient. You don't need to think about which.
|
|
3289
|
-
first-tree-hub
|
|
3398
|
+
first-tree-hub chat send <agentName> "your message"
|
|
3290
3399
|
|
|
3291
3400
|
# Send into a specific chat by id — use this only when you explicitly want
|
|
3292
3401
|
# to address a chat your current session is NOT bound to.
|
|
3293
|
-
first-tree-hub
|
|
3402
|
+
first-tree-hub chat send --chat <chatId> "your message"
|
|
3294
3403
|
|
|
3295
3404
|
# Send markdown (default format is text)
|
|
3296
|
-
first-tree-hub
|
|
3405
|
+
first-tree-hub chat send <agentName> -f markdown "**bold** message"
|
|
3297
3406
|
|
|
3298
3407
|
# Reply to a specific message
|
|
3299
|
-
first-tree-hub
|
|
3408
|
+
first-tree-hub chat send <agentName> --reply-to <messageId> "reply content"
|
|
3300
3409
|
|
|
3301
3410
|
# Pipe long content via stdin (recommended for special characters)
|
|
3302
|
-
echo "long message body" | first-tree-hub
|
|
3411
|
+
echo "long message body" | first-tree-hub chat send <agentName>
|
|
3303
3412
|
\`\`\`
|
|
3304
3413
|
|
|
3305
|
-
> Agent uuids appear in \`
|
|
3306
|
-
> but they are NOT accepted by \`
|
|
3414
|
+
> Agent uuids appear in \`chat list\`, chat history, and participant lists,
|
|
3415
|
+
> but they are NOT accepted by \`chat send\` — always use the name.
|
|
3307
3416
|
|
|
3308
3417
|
For content with quotes, \`$\`, backticks, or newlines, prefer stdin to avoid shell escaping issues.
|
|
3309
3418
|
`;
|
|
3310
3419
|
}
|
|
3420
|
+
function resolveGitRepoTargetPath(workspace, localPath) {
|
|
3421
|
+
const safetyError = getRepoLocalPathSafetyError(localPath);
|
|
3422
|
+
if (safetyError) throw new Error(`Unsafe git repo localPath "${localPath}": ${safetyError}`);
|
|
3423
|
+
const workspaceRoot = resolve(workspace);
|
|
3424
|
+
const targetPath = resolve(workspaceRoot, localPath);
|
|
3425
|
+
const relativeTarget = relative(workspaceRoot, targetPath);
|
|
3426
|
+
const escapesWorkspace = relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`);
|
|
3427
|
+
if (!relativeTarget || escapesWorkspace || isAbsolute(relativeTarget)) throw new Error(`Unsafe git repo localPath "${localPath}": resolved path escapes the session workspace`);
|
|
3428
|
+
return targetPath;
|
|
3429
|
+
}
|
|
3311
3430
|
const DEFAULT_CLONE_TIMEOUT_MS = 300 * 1e3;
|
|
3312
3431
|
const FETCH_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
|
|
3313
3432
|
const SESSION_BRANCH_PREFIX = "hub-session";
|
|
@@ -4844,7 +4963,6 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4844
4963
|
try {
|
|
4845
4964
|
await sessionCtx.forwardResult(resultText);
|
|
4846
4965
|
sessionCtx.log("Result forwarded to chat");
|
|
4847
|
-
sessionCtx.reportSessionCompletion();
|
|
4848
4966
|
sessionCtx.emitEvent({
|
|
4849
4967
|
kind: "turn_end",
|
|
4850
4968
|
payload: { status: "success" }
|
|
@@ -4936,7 +5054,7 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4936
5054
|
if (!gitMirrorManager || !payload?.gitRepos?.length) return;
|
|
4937
5055
|
for (const repo of payload.gitRepos) {
|
|
4938
5056
|
const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
|
|
4939
|
-
const targetPath =
|
|
5057
|
+
const targetPath = resolveGitRepoTargetPath(workspace, localPath);
|
|
4940
5058
|
sessionCtx.log(`Git: preparing ${repo.url} → ${localPath}${repo.ref ? ` @ ${repo.ref}` : ""}`);
|
|
4941
5059
|
const mirror = await gitMirrorManager.ensureMirror(repo.url);
|
|
4942
5060
|
if (mirror.cloned) sessionCtx.log(`Git: cloned ${repo.url} in ${mirror.elapsedMs}ms`);
|
|
@@ -5147,7 +5265,7 @@ function buildCodexThreadOptions(payload, workspaceCwd) {
|
|
|
5147
5265
|
for (const repo of payload.gitRepos) {
|
|
5148
5266
|
const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
|
|
5149
5267
|
if (!localPath) continue;
|
|
5150
|
-
additionalDirectories.push(
|
|
5268
|
+
additionalDirectories.push(resolveGitRepoTargetPath(workspaceCwd, localPath));
|
|
5151
5269
|
}
|
|
5152
5270
|
const opts = {
|
|
5153
5271
|
workingDirectory: workspaceCwd,
|
|
@@ -5244,7 +5362,7 @@ const createCodexHandler = (config) => {
|
|
|
5244
5362
|
for (const repo of payload.gitRepos) {
|
|
5245
5363
|
const localPath = repo.localPath ?? deriveRepoLocalPath(repo.url);
|
|
5246
5364
|
if (!localPath) continue;
|
|
5247
|
-
const targetPath =
|
|
5365
|
+
const targetPath = resolveGitRepoTargetPath(workspaceCwd, localPath);
|
|
5248
5366
|
if (existsSync(targetPath)) continue;
|
|
5249
5367
|
try {
|
|
5250
5368
|
await gitMirrorManager.ensureMirror(repo.url);
|
|
@@ -5437,7 +5555,6 @@ const createCodexHandler = (config) => {
|
|
|
5437
5555
|
kind: "turn_end",
|
|
5438
5556
|
payload: { status: succeeded ? "success" : "error" }
|
|
5439
5557
|
});
|
|
5440
|
-
if (succeeded && accumulated.trim()) sessionCtx.reportSessionCompletion();
|
|
5441
5558
|
sessionCtx.setRuntimeState("idle");
|
|
5442
5559
|
if (queuedMessages.length > 0 && !drainScheduled) {
|
|
5443
5560
|
drainScheduled = true;
|
|
@@ -6459,9 +6576,6 @@ var SessionManager = class {
|
|
|
6459
6576
|
emitEvent: (event) => {
|
|
6460
6577
|
this.config.onSessionEvent?.(chatId, event);
|
|
6461
6578
|
},
|
|
6462
|
-
reportSessionCompletion: () => {
|
|
6463
|
-
this.config.onSessionCompletion?.(chatId);
|
|
6464
|
-
},
|
|
6465
6579
|
forwardResult,
|
|
6466
6580
|
buildAgentEnv: (parentEnv) => buildAgentEnv(parentEnv, envCtx),
|
|
6467
6581
|
formatInboundContent: (message) => formatInboundContent(message, participants),
|
|
@@ -6698,8 +6812,7 @@ var AgentSlot = class {
|
|
|
6698
6812
|
ackEntry,
|
|
6699
6813
|
onStateChange: (chatId, state) => this.reportSessionState(chatId, state),
|
|
6700
6814
|
onRuntimeStateChange: (state) => this.reportRuntimeState(state),
|
|
6701
|
-
onSessionEvent: (chatId, event) => this.reportSessionEvent(chatId, event)
|
|
6702
|
-
onSessionCompletion: (chatId) => this.reportSessionCompletion(chatId)
|
|
6815
|
+
onSessionEvent: (chatId, event) => this.reportSessionEvent(chatId, event)
|
|
6703
6816
|
});
|
|
6704
6817
|
const onCommand = (cmd) => {
|
|
6705
6818
|
if (cmd.agentId === this.config.agentId && this.sessionManager) this.sessionManager.handleCommand(cmd.chatId, cmd.type).catch((err) => {
|
|
@@ -6747,9 +6860,6 @@ var AgentSlot = class {
|
|
|
6747
6860
|
reportSessionEvent(chatId, event) {
|
|
6748
6861
|
this.clientConnection.reportSessionEvent(this.config.agentId, chatId, event);
|
|
6749
6862
|
}
|
|
6750
|
-
reportSessionCompletion(chatId) {
|
|
6751
|
-
this.clientConnection.reportSessionCompletion(this.config.agentId, chatId);
|
|
6752
|
-
}
|
|
6753
6863
|
fullStateSync() {
|
|
6754
6864
|
if (!this.sessionManager) return;
|
|
6755
6865
|
for (const { chatId, state } of this.sessionManager.getSessionStates()) this.clientConnection.reportSessionState(this.config.agentId, chatId, state);
|
|
@@ -7226,7 +7336,7 @@ function fail(code, message, exitCode = 1) {
|
|
|
7226
7336
|
//#endregion
|
|
7227
7337
|
//#region src/core/agent-messaging.ts
|
|
7228
7338
|
/**
|
|
7229
|
-
* Resolve `replyTo` envelope fields for `
|
|
7339
|
+
* Resolve `replyTo` envelope fields for `chat send`. When the CLI is invoked
|
|
7230
7340
|
* from inside a claude-code session (the handler exports
|
|
7231
7341
|
* `FIRST_TREE_HUB_CHAT_ID` + `FIRST_TREE_HUB_INBOX_ID`), default the reply
|
|
7232
7342
|
* target to the calling session's own chat so the peer's reply routes back
|
|
@@ -7518,7 +7628,7 @@ function rotateClientIdWithBackup(configDir) {
|
|
|
7518
7628
|
}
|
|
7519
7629
|
/**
|
|
7520
7630
|
* Shared handler for `CLIENT_ORG_MISMATCH` across CLI entry points
|
|
7521
|
-
* (`client start` and `
|
|
7631
|
+
* (`client start` and `connect <token> --no-service`). Prompts interactively,
|
|
7522
7632
|
* rotates the local clientId, and always exits the current process — the
|
|
7523
7633
|
* runtime is already poisoned (wrong clientId in memory), so continuing
|
|
7524
7634
|
* in-band is not safe. Service-supervised (managed) runs skip the prompt and
|
|
@@ -8281,7 +8391,7 @@ function installLaunchd() {
|
|
|
8281
8391
|
lastBootstrapErr = res;
|
|
8282
8392
|
if (attempt < 2) sleepSync(1e3);
|
|
8283
8393
|
}
|
|
8284
|
-
if (lastBootstrapErr) throw new Error(`launchctl bootstrap failed: ${lastBootstrapErr.stderr || `exit ${lastBootstrapErr.code ?? "unknown"}`}\n Command: launchctl bootstrap ${target} ${plistPath}\n Recovery: \`launchctl bootout ${target}/${LAUNCHD_LABEL}\` then \`first-tree-hub
|
|
8394
|
+
if (lastBootstrapErr) throw new Error(`launchctl bootstrap failed: ${lastBootstrapErr.stderr || `exit ${lastBootstrapErr.code ?? "unknown"}`}\n Command: launchctl bootstrap ${target} ${plistPath}\n Recovery: \`launchctl bootout ${target}/${LAUNCHD_LABEL}\` then \`first-tree-hub connect <token>\`.`);
|
|
8285
8395
|
const enableRes = runCapture("launchctl", ["enable", `${target}/${LAUNCHD_LABEL}`], 5e3);
|
|
8286
8396
|
if (!enableRes.ok) print.line(` warning: launchctl enable: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n`);
|
|
8287
8397
|
const { state, pid, detail } = launchdState();
|
|
@@ -8441,7 +8551,7 @@ function installSystemd() {
|
|
|
8441
8551
|
"--now",
|
|
8442
8552
|
SYSTEMD_UNIT
|
|
8443
8553
|
], 1e4);
|
|
8444
|
-
if (!enableRes.ok) throw new Error(`systemctl --user enable --now ${SYSTEMD_UNIT} failed: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n Recovery: \`systemctl --user stop ${SYSTEMD_UNIT}\` then \`first-tree-hub
|
|
8554
|
+
if (!enableRes.ok) throw new Error(`systemctl --user enable --now ${SYSTEMD_UNIT} failed: ${enableRes.stderr || `exit ${enableRes.code ?? "unknown"}`}\n Recovery: \`systemctl --user stop ${SYSTEMD_UNIT}\` then \`first-tree-hub connect <token>\`.`);
|
|
8445
8555
|
const { state, pid, detail } = systemdState();
|
|
8446
8556
|
return {
|
|
8447
8557
|
platform: "systemd",
|
|
@@ -8953,7 +9063,7 @@ function checkBackgroundService() {
|
|
|
8953
9063
|
return {
|
|
8954
9064
|
label: "Background service",
|
|
8955
9065
|
ok: false,
|
|
8956
|
-
detail: "not installed — re-run `first-tree-hub
|
|
9066
|
+
detail: "not installed — re-run `first-tree-hub connect <token>` to install"
|
|
8957
9067
|
};
|
|
8958
9068
|
}
|
|
8959
9069
|
async function checkWebSocket() {
|
|
@@ -9247,7 +9357,7 @@ function runHomeMigration() {
|
|
|
9247
9357
|
}
|
|
9248
9358
|
print.line(`[first-tree-hub] Copied client home to new layout: ${result.from} → ${result.to}\n (Legacy directory preserved as a backup — delete it manually once you've verified the new location works.)\n`);
|
|
9249
9359
|
if (process.argv.includes("--no-interactive")) {
|
|
9250
|
-
print.line("[first-tree-hub] Note: running as background service — skipped auto re-register to avoid self-termination.\n Service paths will refresh on the next `first-tree-hub
|
|
9360
|
+
print.line("[first-tree-hub] Note: running as background service — skipped auto re-register to avoid self-termination.\n Service paths will refresh on the next `first-tree-hub connect <token>`.\n");
|
|
9251
9361
|
return;
|
|
9252
9362
|
}
|
|
9253
9363
|
const status = getClientServiceStatus();
|
|
@@ -9257,7 +9367,7 @@ function runHomeMigration() {
|
|
|
9257
9367
|
print.line(`[first-tree-hub] Re-registered background service with new home paths.\n`);
|
|
9258
9368
|
} catch (err) {
|
|
9259
9369
|
const msg = err instanceof Error ? err.message : String(err);
|
|
9260
|
-
print.line(`[first-tree-hub] WARNING: home migration succeeded but re-registering the background service failed: ${msg}\n Re-run \`first-tree-hub
|
|
9370
|
+
print.line(`[first-tree-hub] WARNING: home migration succeeded but re-registering the background service failed: ${msg}\n Re-run \`first-tree-hub connect <token>\` to refresh service paths.\n`);
|
|
9261
9371
|
}
|
|
9262
9372
|
}
|
|
9263
9373
|
//#endregion
|
|
@@ -9289,7 +9399,7 @@ async function onboardCheck(args) {
|
|
|
9289
9399
|
key: "connect",
|
|
9290
9400
|
label: "Signed in",
|
|
9291
9401
|
status: "missing_required",
|
|
9292
|
-
hint: "Run `first-tree-hub
|
|
9402
|
+
hint: "Run `first-tree-hub connect <token>` first"
|
|
9293
9403
|
});
|
|
9294
9404
|
try {
|
|
9295
9405
|
const serverUrl = resolveServerUrl(args.server);
|
|
@@ -9443,7 +9553,7 @@ async function onboardCreate(args) {
|
|
|
9443
9553
|
}
|
|
9444
9554
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
9445
9555
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
9446
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
9556
|
+
const { bindFeishuBot } = await import("./feishu-D_vnqC6a.mjs").then((n) => n.r);
|
|
9447
9557
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
9448
9558
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
9449
9559
|
else {
|
|
@@ -9530,13 +9640,13 @@ async function promptMissingFields(options) {
|
|
|
9530
9640
|
* add (there's nothing sensible to key the local dir on).
|
|
9531
9641
|
*/
|
|
9532
9642
|
async function promptAddAgent(opts = {}) {
|
|
9533
|
-
if (loadCredentials() === null) throw new Error("Not connected. Run `first-tree-hub
|
|
9643
|
+
if (loadCredentials() === null) throw new Error("Not connected. Run `first-tree-hub connect <token>` first.");
|
|
9534
9644
|
let serverUrl;
|
|
9535
9645
|
try {
|
|
9536
9646
|
serverUrl = resolveServerUrl(process.env.FIRST_TREE_HUB_SERVER_URL);
|
|
9537
9647
|
} catch (err) {
|
|
9538
9648
|
const msg = err instanceof Error ? err.message : String(err);
|
|
9539
|
-
throw new Error(`${msg} Run \`first-tree-hub
|
|
9649
|
+
throw new Error(`${msg} Run \`first-tree-hub connect <token>\` or set FIRST_TREE_HUB_SERVER_URL.`);
|
|
9540
9650
|
}
|
|
9541
9651
|
const agentId = opts.agentId ?? await input({
|
|
9542
9652
|
message: "Agent UUID on the Hub:",
|
|
@@ -10656,7 +10766,7 @@ function createFeedbackHandler(config) {
|
|
|
10656
10766
|
return { handle };
|
|
10657
10767
|
}
|
|
10658
10768
|
//#endregion
|
|
10659
|
-
//#region ../server/dist/app-
|
|
10769
|
+
//#region ../server/dist/app-DQVUb4ZY.mjs
|
|
10660
10770
|
var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
|
|
10661
10771
|
init_esm();
|
|
10662
10772
|
var __defProp = Object.defineProperty;
|
|
@@ -11041,7 +11151,7 @@ async function deleteAdapterConfig(db, id) {
|
|
|
11041
11151
|
const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
|
|
11042
11152
|
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
11043
11153
|
}
|
|
11044
|
-
const log$
|
|
11154
|
+
const log$7 = createLogger$1("Adapters");
|
|
11045
11155
|
function parseId(raw) {
|
|
11046
11156
|
const id = Number(raw);
|
|
11047
11157
|
if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
|
|
@@ -11067,7 +11177,7 @@ async function adapterRoutes(app) {
|
|
|
11067
11177
|
const existing = await getAdapterConfig(app.db, id);
|
|
11068
11178
|
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
11069
11179
|
const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
|
|
11070
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11180
|
+
app.adapterManager.reload().catch((err) => log$7.error({ err }, "adapter reload failed after update"));
|
|
11071
11181
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11072
11182
|
return {
|
|
11073
11183
|
...config,
|
|
@@ -11081,7 +11191,7 @@ async function adapterRoutes(app) {
|
|
|
11081
11191
|
const existing = await getAdapterConfig(app.db, id);
|
|
11082
11192
|
await assertAgentManageableByUser(app.db, userId, existing.agentId);
|
|
11083
11193
|
await deleteAdapterConfig(app.db, id);
|
|
11084
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11194
|
+
app.adapterManager.reload().catch((err) => log$7.error({ err }, "adapter reload failed after delete"));
|
|
11085
11195
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11086
11196
|
return reply.status(204).send();
|
|
11087
11197
|
});
|
|
@@ -11097,7 +11207,7 @@ function requireAgent(request) {
|
|
|
11097
11207
|
if (!agent) throw new UnauthorizedError("Agent authentication required");
|
|
11098
11208
|
return agent;
|
|
11099
11209
|
}
|
|
11100
|
-
const log$
|
|
11210
|
+
const log$6 = createLogger$1("AgentChatsRoute");
|
|
11101
11211
|
function serializeChat(chat) {
|
|
11102
11212
|
return {
|
|
11103
11213
|
...chat,
|
|
@@ -11160,7 +11270,7 @@ async function agentChatRoutes(app) {
|
|
|
11160
11270
|
app.post("/:chatId/participants", async (request, reply) => {
|
|
11161
11271
|
const identity = requireAgent(request);
|
|
11162
11272
|
if (request.body !== null && typeof request.body === "object" && "mode" in request.body) {
|
|
11163
|
-
log$
|
|
11273
|
+
log$6.warn({
|
|
11164
11274
|
code: "MODE_FIELD_DEPRECATED",
|
|
11165
11275
|
chatId: request.params.chatId,
|
|
11166
11276
|
senderAgentId: identity.uuid,
|
|
@@ -11356,7 +11466,7 @@ async function resolveAgentClient(db, data) {
|
|
|
11356
11466
|
userId: clients.userId
|
|
11357
11467
|
}).from(clients).where(eq(clients.id, data.clientId)).limit(1);
|
|
11358
11468
|
if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
|
|
11359
|
-
if (!client.userId) throw new BadRequestError(`Client "${data.clientId}" has not been claimed by a user yet. Have the operator run \`first-tree-hub
|
|
11469
|
+
if (!client.userId) throw new BadRequestError(`Client "${data.clientId}" has not been claimed by a user yet. Have the operator run \`first-tree-hub connect <token>\` on that machine before pinning an agent to it.`);
|
|
11360
11470
|
if (client.userId !== manager.userId) throw new ForbiddenError(`Client "${data.clientId}" is not owned by the manager's user — pick a client belonging to that user.`);
|
|
11361
11471
|
return client.id;
|
|
11362
11472
|
}
|
|
@@ -11383,6 +11493,20 @@ async function validateDelegateMentionTarget(db, targetUuid, sourceOrgId) {
|
|
|
11383
11493
|
if (target.organizationId !== sourceOrgId) throw new BadRequestError("delegateMention target must belong to the same organization as the agent");
|
|
11384
11494
|
}
|
|
11385
11495
|
/**
|
|
11496
|
+
* Service-layer guard: `delegateMention` is only available for `human` agents.
|
|
11497
|
+
* Mirrors the Web UI in `identity-section.tsx`, which only renders the
|
|
11498
|
+
* delegate-mention selector when `agent.type === "human"`. Without this
|
|
11499
|
+
* server-side check, CLI / Admin API / internal scripts could write
|
|
11500
|
+
* delegateMention onto non-human rows, silently re-enabling the
|
|
11501
|
+
* autonomous-agent-self-mention path that resolveAudience would then fan
|
|
11502
|
+
* out. Called from `createAgent` / `updateAgent` before
|
|
11503
|
+
* `validateDelegateMentionTarget` so a wrong source type fails fast without
|
|
11504
|
+
* the target lookup round-trip.
|
|
11505
|
+
*/
|
|
11506
|
+
function assertDelegateMentionAllowed(sourceType) {
|
|
11507
|
+
if (sourceType !== AGENT_TYPES.HUMAN) throw new BadRequestError("delegateMention can only be set on human agents");
|
|
11508
|
+
}
|
|
11509
|
+
/**
|
|
11386
11510
|
* Pick the first admin member in the org for internal system agents. Throws
|
|
11387
11511
|
* if the org has no admin — the caller should surface the error so an admin
|
|
11388
11512
|
* is created before the system tries to register more agents.
|
|
@@ -11422,7 +11546,10 @@ async function createAgent(db, data, options = {}) {
|
|
|
11422
11546
|
type: data.type
|
|
11423
11547
|
});
|
|
11424
11548
|
await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
|
|
11425
|
-
if (data.delegateMention)
|
|
11549
|
+
if (data.delegateMention) {
|
|
11550
|
+
assertDelegateMentionAllowed(data.type);
|
|
11551
|
+
await validateDelegateMentionTarget(db, data.delegateMention, orgId);
|
|
11552
|
+
}
|
|
11426
11553
|
const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
|
|
11427
11554
|
if (org && org.maxAgents > 0) {
|
|
11428
11555
|
if (((await db.select({ value: count() }).from(agents).where(and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED))))[0]?.value ?? 0) >= org.maxAgents) throw new ForbiddenError(`Organization "${orgId}" has reached its agent limit (${org.maxAgents}). Upgrade your plan or delete unused agents.`);
|
|
@@ -11567,10 +11694,16 @@ async function updateAgent(db, uuid, data) {
|
|
|
11567
11694
|
if (agent.clientId !== null && agent.clientId !== data.clientId) throw new BadRequestError("clientId is immutable through this entry — cross-client moves go through rebindAgent (PATCH /agents/:uuid/rebind), which runs owner / org / capability checks atomically.");
|
|
11568
11695
|
}
|
|
11569
11696
|
const updates = { updatedAt: /* @__PURE__ */ new Date() };
|
|
11570
|
-
if (data.type !== void 0)
|
|
11697
|
+
if (data.type !== void 0) {
|
|
11698
|
+
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.");
|
|
11699
|
+
updates.type = data.type;
|
|
11700
|
+
}
|
|
11571
11701
|
if (data.displayName !== void 0) updates.displayName = data.displayName;
|
|
11572
11702
|
if (data.delegateMention !== void 0) {
|
|
11573
|
-
if (data.delegateMention !== null)
|
|
11703
|
+
if (data.delegateMention !== null) {
|
|
11704
|
+
assertDelegateMentionAllowed(data.type ?? agent.type);
|
|
11705
|
+
await validateDelegateMentionTarget(db, data.delegateMention, agent.organizationId);
|
|
11706
|
+
}
|
|
11574
11707
|
updates.delegateMention = data.delegateMention;
|
|
11575
11708
|
}
|
|
11576
11709
|
if (data.visibility !== void 0) updates.visibility = data.visibility;
|
|
@@ -11681,7 +11814,7 @@ async function deleteAgent(db, uuid) {
|
|
|
11681
11814
|
if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
11682
11815
|
return agent;
|
|
11683
11816
|
}
|
|
11684
|
-
const log$
|
|
11817
|
+
const log$5 = createLogger$1("AgentFeishuBot");
|
|
11685
11818
|
async function agentFeishuBotRoutes(app) {
|
|
11686
11819
|
/**
|
|
11687
11820
|
* PUT /agent/me/feishu-bot
|
|
@@ -11709,7 +11842,7 @@ async function agentFeishuBotRoutes(app) {
|
|
|
11709
11842
|
},
|
|
11710
11843
|
status: "active"
|
|
11711
11844
|
}, app.config.secrets.encryptionKey);
|
|
11712
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11845
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after self-service bind"));
|
|
11713
11846
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11714
11847
|
return reply.status(current ? 200 : 201).send({
|
|
11715
11848
|
...config,
|
|
@@ -11726,7 +11859,7 @@ async function agentFeishuBotRoutes(app) {
|
|
|
11726
11859
|
const current = (await listAdapterConfigs(app.db)).find((c) => c.agentId === identity.uuid && c.platform === "feishu");
|
|
11727
11860
|
if (!current) return reply.status(204).send();
|
|
11728
11861
|
await deleteAdapterConfig(app.db, current.id);
|
|
11729
|
-
app.adapterManager.reload().catch((err) => log$
|
|
11862
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after self-service unbind"));
|
|
11730
11863
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11731
11864
|
return reply.status(204).send();
|
|
11732
11865
|
});
|
|
@@ -12357,7 +12490,7 @@ async function collectTargetInboxes(db, chatId, inReplyTo) {
|
|
|
12357
12490
|
}
|
|
12358
12491
|
return [...set];
|
|
12359
12492
|
}
|
|
12360
|
-
const log$
|
|
12493
|
+
const log$4 = createLogger$1("AgentMessages");
|
|
12361
12494
|
const editMessageSchema = z.object({
|
|
12362
12495
|
format: z.string().optional(),
|
|
12363
12496
|
content: z.unknown()
|
|
@@ -12385,7 +12518,7 @@ function agentMessageWriteRateLimit(max) {
|
|
|
12385
12518
|
keyGenerator: (req) => {
|
|
12386
12519
|
const agentId = req.agent?.uuid;
|
|
12387
12520
|
if (agentId) return `agent:${agentId}`;
|
|
12388
|
-
log$
|
|
12521
|
+
log$4.warn({
|
|
12389
12522
|
ip: req.ip,
|
|
12390
12523
|
route: req.routeOptions?.url ?? req.url
|
|
12391
12524
|
}, "rate-limit keyGenerator fell back to IP — req.agent missing on a route under /agent (hook order regression?)");
|
|
@@ -12418,7 +12551,7 @@ async function agentMessageRoutes(app) {
|
|
|
12418
12551
|
await assertParticipant(app.db, request.params.chatId, identity.uuid);
|
|
12419
12552
|
const body = editMessageSchema.parse(request.body);
|
|
12420
12553
|
const msg = await editMessage(app.db, request.params.chatId, request.params.messageId, identity.uuid, body);
|
|
12421
|
-
app.adapterManager.editOutboundMessage(msg.id, msg.format, msg.content).catch((err) => log$
|
|
12554
|
+
app.adapterManager.editOutboundMessage(msg.id, msg.format, msg.content).catch((err) => log$4.error({
|
|
12422
12555
|
err,
|
|
12423
12556
|
messageId: msg.id
|
|
12424
12557
|
}, "failed to edit outbound message"));
|
|
@@ -12597,35 +12730,84 @@ const notifications = pgTable("notifications", {
|
|
|
12597
12730
|
chatId: text("chat_id"),
|
|
12598
12731
|
message: text("message").notNull(),
|
|
12599
12732
|
read: boolean("read").notNull().default(false),
|
|
12733
|
+
dedupKey: text("dedup_key"),
|
|
12600
12734
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
12601
12735
|
}, (table) => [
|
|
12602
12736
|
index("idx_notifications_org_created").on(table.organizationId, table.createdAt),
|
|
12603
12737
|
index("idx_notifications_agent").on(table.agentId),
|
|
12604
|
-
index("idx_notifications_org_read").on(table.organizationId, table.read)
|
|
12738
|
+
index("idx_notifications_org_read").on(table.organizationId, table.read),
|
|
12739
|
+
uniqueIndex("uq_notifications_org_dedup_unread").on(table.organizationId, table.dedupKey).where(sql`read = false AND dedup_key IS NOT NULL`)
|
|
12605
12740
|
]);
|
|
12606
|
-
let
|
|
12741
|
+
let localBroadcaster = null;
|
|
12742
|
+
let crossInstanceBroadcaster = null;
|
|
12607
12743
|
function registerAdminBroadcaster(fn) {
|
|
12608
|
-
|
|
12744
|
+
localBroadcaster = fn;
|
|
12745
|
+
}
|
|
12746
|
+
function registerCrossInstanceBroadcaster(fn) {
|
|
12747
|
+
crossInstanceBroadcaster = fn;
|
|
12609
12748
|
}
|
|
12610
12749
|
function broadcastToAdmins(payload) {
|
|
12611
|
-
if (!
|
|
12750
|
+
if (!localBroadcaster) return;
|
|
12612
12751
|
try {
|
|
12613
|
-
|
|
12752
|
+
localBroadcaster(payload);
|
|
12614
12753
|
} catch {}
|
|
12615
12754
|
}
|
|
12616
|
-
/**
|
|
12755
|
+
/**
|
|
12756
|
+
* Fan out to every admin socket across every server instance via PG NOTIFY.
|
|
12757
|
+
* Falls back to single-instance fanout when no cross-instance broadcaster is
|
|
12758
|
+
* registered (e.g. unit tests that don't boot a notifier).
|
|
12759
|
+
*/
|
|
12760
|
+
function broadcastAdminsCrossInstance(payload) {
|
|
12761
|
+
if (crossInstanceBroadcaster) {
|
|
12762
|
+
try {
|
|
12763
|
+
crossInstanceBroadcaster(payload);
|
|
12764
|
+
} catch {}
|
|
12765
|
+
return;
|
|
12766
|
+
}
|
|
12767
|
+
broadcastToAdmins(payload);
|
|
12768
|
+
}
|
|
12769
|
+
/**
|
|
12770
|
+
* Create a notification, persist it, and fire-and-forget push to all channels.
|
|
12771
|
+
*
|
|
12772
|
+
* Dedup contract (when `dedupKey` is set and an unread row already exists):
|
|
12773
|
+
* - **severity escalates monotonically** — `high` never drops back to
|
|
12774
|
+
* `medium`, `medium` never drops back to `low`. Prevents the bell badge
|
|
12775
|
+
* from understating a degrading agent (stale=medium first, then
|
|
12776
|
+
* error=high arriving — the row sticks at high).
|
|
12777
|
+
* - **type and message take the latest event's values** — so the row
|
|
12778
|
+
* reflects the most recent observation in the UI ("entered error state"
|
|
12779
|
+
* replaces "is unresponsive" once the runtime starts reporting error).
|
|
12780
|
+
* - **createdAt is preserved** so the bell ordering still tracks "when did
|
|
12781
|
+
* this incident open" rather than "when was the last observation".
|
|
12782
|
+
*
|
|
12783
|
+
* Rows without a `dedupKey` never hit the partial unique index and keep the
|
|
12784
|
+
* legacy always-insert behaviour.
|
|
12785
|
+
*/
|
|
12617
12786
|
async function createNotification(db, data) {
|
|
12618
12787
|
const id = uuidv7();
|
|
12619
|
-
const
|
|
12788
|
+
const row = (await db.insert(notifications).values({
|
|
12620
12789
|
id,
|
|
12621
12790
|
organizationId: data.organizationId,
|
|
12622
12791
|
type: data.type,
|
|
12623
12792
|
severity: data.severity,
|
|
12624
12793
|
agentId: data.agentId ?? null,
|
|
12625
12794
|
chatId: data.chatId ?? null,
|
|
12626
|
-
message: data.message
|
|
12627
|
-
|
|
12628
|
-
|
|
12795
|
+
message: data.message,
|
|
12796
|
+
dedupKey: data.dedupKey ?? null
|
|
12797
|
+
}).onConflictDoUpdate({
|
|
12798
|
+
target: [notifications.organizationId, notifications.dedupKey],
|
|
12799
|
+
set: {
|
|
12800
|
+
severity: sql`CASE
|
|
12801
|
+
WHEN ${notifications.severity} = 'high' OR excluded.severity = 'high' THEN 'high'
|
|
12802
|
+
WHEN ${notifications.severity} = 'medium' OR excluded.severity = 'medium' THEN 'medium'
|
|
12803
|
+
ELSE 'low'
|
|
12804
|
+
END`,
|
|
12805
|
+
type: sql`excluded.type`,
|
|
12806
|
+
message: sql`excluded.message`
|
|
12807
|
+
},
|
|
12808
|
+
targetWhere: sql`${notifications.read} = false AND ${notifications.dedupKey} IS NOT NULL`
|
|
12809
|
+
}).returning())[0];
|
|
12810
|
+
if (!row) return null;
|
|
12629
12811
|
const notification = {
|
|
12630
12812
|
id: row.id,
|
|
12631
12813
|
organizationId: row.organizationId,
|
|
@@ -12658,18 +12840,15 @@ async function listNotifications(db, orgId, memberId, query) {
|
|
|
12658
12840
|
items: [],
|
|
12659
12841
|
nextCursor: null
|
|
12660
12842
|
};
|
|
12661
|
-
const
|
|
12843
|
+
const targetLimit = query.limit;
|
|
12844
|
+
const conditions = [eq(notifications.organizationId, orgId), buildVisibilityCondition([...visibleAgents])];
|
|
12662
12845
|
if (query.cursor) conditions.push(lt(notifications.createdAt, new Date(query.cursor)));
|
|
12663
12846
|
if (query.severity) conditions.push(eq(notifications.severity, query.severity));
|
|
12664
12847
|
if (query.read !== void 0) conditions.push(eq(notifications.read, query.read));
|
|
12665
12848
|
if (query.agentId) conditions.push(eq(notifications.agentId, query.agentId));
|
|
12666
|
-
const
|
|
12667
|
-
const
|
|
12668
|
-
const
|
|
12669
|
-
const rawLimit = Math.min(targetLimit * overscanFactor + 1, 400);
|
|
12670
|
-
const visible = (await db.select().from(notifications).where(where).orderBy(desc(notifications.createdAt)).limit(rawLimit)).filter((n) => n.agentId === null || visibleAgents.has(n.agentId));
|
|
12671
|
-
const hasMore = visible.length > targetLimit;
|
|
12672
|
-
const items = hasMore ? visible.slice(0, targetLimit) : visible;
|
|
12849
|
+
const rows = await db.select().from(notifications).where(and(...conditions)).orderBy(desc(notifications.createdAt)).limit(targetLimit + 1);
|
|
12850
|
+
const hasMore = rows.length > targetLimit;
|
|
12851
|
+
const items = hasMore ? rows.slice(0, targetLimit) : rows;
|
|
12673
12852
|
const last = items[items.length - 1];
|
|
12674
12853
|
const nextCursor = hasMore && last ? last.createdAt.toISOString() : null;
|
|
12675
12854
|
return {
|
|
@@ -12680,6 +12859,16 @@ async function listNotifications(db, orgId, memberId, query) {
|
|
|
12680
12859
|
nextCursor
|
|
12681
12860
|
};
|
|
12682
12861
|
}
|
|
12862
|
+
/**
|
|
12863
|
+
* Return the unread notification count for this member's visible agents.
|
|
12864
|
+
* Single `SELECT COUNT(*)` — no row fetch — so the topbar bell badge can
|
|
12865
|
+
* surface an accurate number (>100) without paying for a list query.
|
|
12866
|
+
*/
|
|
12867
|
+
async function unreadCount(db, orgId, memberId) {
|
|
12868
|
+
const visibleAgents = await loadVisibleAgentIds$1(db, orgId, memberId);
|
|
12869
|
+
const [row] = await db.select({ count: sql`count(*)` }).from(notifications).where(and(eq(notifications.organizationId, orgId), eq(notifications.read, false), buildVisibilityCondition([...visibleAgents])));
|
|
12870
|
+
return Number(row?.count ?? "0");
|
|
12871
|
+
}
|
|
12683
12872
|
/** Mark a single notification as read, scoped to organization + visible agents. */
|
|
12684
12873
|
async function markRead(db, notificationId, orgId, memberId) {
|
|
12685
12874
|
const [existing] = await db.select({
|
|
@@ -12696,16 +12885,17 @@ async function markRead(db, notificationId, orgId, memberId) {
|
|
|
12696
12885
|
/** Mark all notifications visible to this member as read. */
|
|
12697
12886
|
async function markAllRead(db, orgId, memberId) {
|
|
12698
12887
|
const visible = await loadVisibleAgentIds$1(db, orgId, memberId);
|
|
12699
|
-
|
|
12700
|
-
|
|
12701
|
-
|
|
12702
|
-
|
|
12703
|
-
|
|
12704
|
-
|
|
12705
|
-
|
|
12706
|
-
|
|
12707
|
-
|
|
12708
|
-
|
|
12888
|
+
await db.update(notifications).set({ read: true }).where(and(eq(notifications.organizationId, orgId), eq(notifications.read, false), buildVisibilityCondition([...visible])));
|
|
12889
|
+
}
|
|
12890
|
+
/**
|
|
12891
|
+
* SQL fragment matching every notification visible to a member: org-wide
|
|
12892
|
+
* (`agent_id IS NULL`) plus rows tied to an agent in the member's visible
|
|
12893
|
+
* set. Centralised so listNotifications, markAllRead, and unreadCount can
|
|
12894
|
+
* never drift apart.
|
|
12895
|
+
*/
|
|
12896
|
+
function buildVisibilityCondition(visibleAgentIds) {
|
|
12897
|
+
if (visibleAgentIds.length === 0) return isNull(notifications.agentId);
|
|
12898
|
+
return or(isNull(notifications.agentId), inArray(notifications.agentId, visibleAgentIds));
|
|
12709
12899
|
}
|
|
12710
12900
|
/**
|
|
12711
12901
|
* Shared visibility predicate. Mirrors
|
|
@@ -12739,62 +12929,81 @@ async function resolveAgentContext(db, agentId) {
|
|
|
12739
12929
|
clientLabel
|
|
12740
12930
|
};
|
|
12741
12931
|
}
|
|
12742
|
-
async function resolveChatContext(db, chatId) {
|
|
12743
|
-
const [chat] = await db.select({ topic: chats.topic }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
12744
|
-
const shortId = chatId.slice(0, 8);
|
|
12745
|
-
return { chatLabel: chat?.topic && chat.topic.trim().length > 0 ? chat.topic.trim() : `Chat ${shortId}` };
|
|
12746
|
-
}
|
|
12747
12932
|
/**
|
|
12748
|
-
* Compose a human-readable message for each notification type.
|
|
12749
|
-
*
|
|
12750
|
-
*
|
|
12751
|
-
* - Session-scoped events → subject is the chat (topic / "Chat xxxxxxxx")
|
|
12752
|
-
* - Client-scoped events → subject is the computer (hostname / clientId)
|
|
12753
|
-
* - Agent-scoped events → subject is the agent display name
|
|
12933
|
+
* Compose a human-readable message for each notification type. The full set
|
|
12934
|
+
* is fault-scoped (error / blocked / stale) — completion events are not
|
|
12935
|
+
* notifications because the conversation list already surfaces them.
|
|
12754
12936
|
*/
|
|
12755
|
-
function composeMessage(type, agentCtx
|
|
12937
|
+
function composeMessage(type, agentCtx) {
|
|
12756
12938
|
const agent = agentCtx.agentName;
|
|
12757
12939
|
const computer = agentCtx.clientLabel ?? "Unknown computer";
|
|
12758
|
-
const chat = chatCtx?.chatLabel ?? null;
|
|
12759
12940
|
switch (type) {
|
|
12760
|
-
case "session_completed": return chat ? `${chat} completed` : `${agent} completed a task`;
|
|
12761
|
-
case "session_error": return chat ? `${chat} hit an error` : `${agent} hit a session error`;
|
|
12762
|
-
case "agent_disconnected": return `Computer ${computer} disconnected`;
|
|
12763
|
-
case "agent_connected": return `Computer ${computer} reconnected`;
|
|
12764
12941
|
case "agent_stale": return `Computer ${computer} is unresponsive`;
|
|
12765
12942
|
case "agent_error": return `${agent} entered error state`;
|
|
12766
12943
|
case "agent_blocked": return `${agent} is blocked`;
|
|
12767
|
-
case "agent_needs_decision": return chat ? `${agent} needs a decision in ${chat}` : `${agent} needs a decision`;
|
|
12768
12944
|
default: return `${agent} event`;
|
|
12769
12945
|
}
|
|
12770
12946
|
}
|
|
12771
12947
|
/**
|
|
12772
|
-
* Convenience: create a notification for an agent event, resolving org
|
|
12773
|
-
* agent display name
|
|
12774
|
-
*
|
|
12775
|
-
*
|
|
12948
|
+
* Convenience: create a notification for an agent event, resolving org and
|
|
12949
|
+
* agent display name automatically. The message text is generated here so
|
|
12950
|
+
* language/phrasing is centralized (see {@link composeMessage}).
|
|
12951
|
+
*
|
|
12952
|
+
* Default dedup_key when none is supplied: `agent:{agentId}:fault`. All three
|
|
12953
|
+
* current fault types (error / blocked / stale) collapse onto one unread row
|
|
12954
|
+
* per agent — a single agent that goes error AND then stale should not double
|
|
12955
|
+
* the badge for the same underlying problem. Pair with
|
|
12956
|
+
* {@link markAgentFaultsResolved}, which closes the row when the agent
|
|
12957
|
+
* recovers.
|
|
12776
12958
|
*
|
|
12777
12959
|
* Fire-and-forget — errors are swallowed so event producers never fail just
|
|
12778
12960
|
* because the notification pipeline is unhealthy.
|
|
12779
12961
|
*/
|
|
12780
|
-
async function notifyAgentEvent(db, agentId, type, severity,
|
|
12962
|
+
async function notifyAgentEvent(db, agentId, type, severity, options = {}) {
|
|
12781
12963
|
try {
|
|
12782
12964
|
const agentCtx = await resolveAgentContext(db, agentId);
|
|
12783
12965
|
if (!agentCtx) return;
|
|
12784
|
-
const message = composeMessage(type, agentCtx
|
|
12966
|
+
const message = composeMessage(type, agentCtx);
|
|
12967
|
+
const dedupKey = options.dedupKey === void 0 ? `agent:${agentId}:fault` : options.dedupKey;
|
|
12785
12968
|
await createNotification(db, {
|
|
12786
12969
|
organizationId: agentCtx.organizationId,
|
|
12787
12970
|
type,
|
|
12788
12971
|
severity,
|
|
12789
12972
|
agentId,
|
|
12790
|
-
chatId: chatId ?? null,
|
|
12791
12973
|
clientId: agentCtx.clientId,
|
|
12792
|
-
message
|
|
12974
|
+
message,
|
|
12975
|
+
dedupKey
|
|
12793
12976
|
});
|
|
12794
12977
|
} catch {}
|
|
12795
12978
|
}
|
|
12979
|
+
/**
|
|
12980
|
+
* Mark every unread fault-scoped notification for this agent as read. Called
|
|
12981
|
+
* when the agent recovers — either by rebinding (offline → online) or by
|
|
12982
|
+
* reporting a healthy runtime state (error/blocked → idle/working). Without
|
|
12983
|
+
* this, a transient incident leaves its notification row in "unread" forever
|
|
12984
|
+
* and the badge never clears even though the underlying problem is gone.
|
|
12985
|
+
*
|
|
12986
|
+
* Fire-and-forget — same rationale as {@link notifyAgentEvent}: presence /
|
|
12987
|
+
* runtime-state callers must not fail just because notification bookkeeping
|
|
12988
|
+
* is unhealthy.
|
|
12989
|
+
*
|
|
12990
|
+
* Note on badge freshness: this UPDATEs the DB but does not push an event
|
|
12991
|
+
* across admin WS. The bell refetches on its own next push or reconnect, so
|
|
12992
|
+
* the badge may lag the actual state by up to one push cycle. Adding a
|
|
12993
|
+
* dedicated `notification:read` envelope was deferred — it adds a new event
|
|
12994
|
+
* shape on both sides for a sub-second cosmetic difference.
|
|
12995
|
+
*/
|
|
12996
|
+
async function markAgentFaultsResolved(db, agentId) {
|
|
12997
|
+
try {
|
|
12998
|
+
await db.update(notifications).set({ read: true }).where(and(eq(notifications.agentId, agentId), eq(notifications.read, false), inArray(notifications.type, [
|
|
12999
|
+
NOTIFICATION_TYPES.AGENT_ERROR,
|
|
13000
|
+
NOTIFICATION_TYPES.AGENT_BLOCKED,
|
|
13001
|
+
NOTIFICATION_TYPES.AGENT_STALE
|
|
13002
|
+
])));
|
|
13003
|
+
} catch {}
|
|
13004
|
+
}
|
|
12796
13005
|
function pushToAdminWs(notification) {
|
|
12797
|
-
|
|
13006
|
+
broadcastAdminsCrossInstance({
|
|
12798
13007
|
type: "notification",
|
|
12799
13008
|
organizationId: notification.organizationId,
|
|
12800
13009
|
agentId: notification.agentId ?? null,
|
|
@@ -12815,8 +13024,9 @@ async function pushToWebhook(notification) {
|
|
|
12815
13024
|
}
|
|
12816
13025
|
/**
|
|
12817
13026
|
* Session events — structured event stream per (agent, chat) session.
|
|
12818
|
-
* `kind` is 'tool_call' | 'error'
|
|
12819
|
-
*
|
|
13027
|
+
* `kind` is one of `'tool_call' | 'error' | 'assistant_text' | 'thinking'
|
|
13028
|
+
* | 'turn_end'`; payload shape per kind is enforced by the service layer
|
|
13029
|
+
* via Zod (no FK / CHECK on this table per project rule).
|
|
12820
13030
|
*
|
|
12821
13031
|
* `seq` is monotonic per (agent_id, chat_id). The single-writer invariant
|
|
12822
13032
|
* in the client-side session-manager guarantees ordering; the service wraps
|
|
@@ -12938,35 +13148,6 @@ const wsMessageSchema = z.object({
|
|
|
12938
13148
|
agentId: z.string().optional(),
|
|
12939
13149
|
ref: z.string().optional()
|
|
12940
13150
|
});
|
|
12941
|
-
/**
|
|
12942
|
-
* Client WebSocket: one WS per client, multiple agents multiplexed.
|
|
12943
|
-
*
|
|
12944
|
-
* Protocol (unified-user-token milestone):
|
|
12945
|
-
* 1. Client connects; server waits up to {@link WS_AUTH_FRAME_TIMEOUT_MS}
|
|
12946
|
-
* for the first `auth` frame carrying a member access JWT.
|
|
12947
|
-
* Failure ⇒ server sends `auth:rejected` and closes (code 4401).
|
|
12948
|
-
* 2. `client:register` — bind the client_id to the authenticated user.
|
|
12949
|
-
* 3. `agent:bind` — run Rule R-RUN (no token); populate presence.
|
|
12950
|
-
* 4. `session:state` / `runtime:state` / `session:event` / `session:completion` / `heartbeat`.
|
|
12951
|
-
* 5. `agent:unbind` — stop multiplexing for a specific agent.
|
|
12952
|
-
*
|
|
12953
|
-
* When the JWT is about to expire the server sends `auth:expired` so the
|
|
12954
|
-
* SDK can refresh and reconnect without silently half-opening the socket.
|
|
12955
|
-
*/
|
|
12956
|
-
/** Notification cooldown: prevents duplicate notifications for same (agentId, type) within window. */
|
|
12957
|
-
const NOTIFICATION_COOLDOWN_MS = 3e5;
|
|
12958
|
-
const notificationCooldowns = /* @__PURE__ */ new Map();
|
|
12959
|
-
function shouldNotify(agentId, notificationType) {
|
|
12960
|
-
const key = `${agentId}:${notificationType}`;
|
|
12961
|
-
const now = Date.now();
|
|
12962
|
-
const lastSent = notificationCooldowns.get(key);
|
|
12963
|
-
if (lastSent && now - lastSent < NOTIFICATION_COOLDOWN_MS) return false;
|
|
12964
|
-
notificationCooldowns.set(key, now);
|
|
12965
|
-
if (notificationCooldowns.size > 1e3) {
|
|
12966
|
-
for (const [k, ts] of notificationCooldowns) if (now - ts > NOTIFICATION_COOLDOWN_MS) notificationCooldowns.delete(k);
|
|
12967
|
-
}
|
|
12968
|
-
return true;
|
|
12969
|
-
}
|
|
12970
13151
|
function sendRejected(socket, ref, reason) {
|
|
12971
13152
|
socket.send(JSON.stringify({
|
|
12972
13153
|
type: "agent:bind:rejected",
|
|
@@ -13380,6 +13561,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13380
13561
|
runtimeType: bindRequest.runtimeType,
|
|
13381
13562
|
runtimeVersion: bindRequest.runtimeVersion
|
|
13382
13563
|
});
|
|
13564
|
+
markAgentFaultsResolved(app.db, agent.id).catch(() => {});
|
|
13383
13565
|
bindAgentToClient(clientId, agent.id);
|
|
13384
13566
|
boundAgents.set(agent.id, {
|
|
13385
13567
|
agentId: agent.id,
|
|
@@ -13489,8 +13671,9 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13489
13671
|
organizationId: boundAgentInfo.organizationId,
|
|
13490
13672
|
notifier
|
|
13491
13673
|
});
|
|
13492
|
-
if (payload.runtimeState === "error"
|
|
13493
|
-
else if (payload.runtimeState === "blocked"
|
|
13674
|
+
if (payload.runtimeState === "error") notifyAgentEvent(app.db, agentId, "agent_error", "high").catch(() => {});
|
|
13675
|
+
else if (payload.runtimeState === "blocked") notifyAgentEvent(app.db, agentId, "agent_blocked", "medium").catch(() => {});
|
|
13676
|
+
else if (payload.runtimeState === "idle" || payload.runtimeState === "working") markAgentFaultsResolved(app.db, agentId).catch(() => {});
|
|
13494
13677
|
} else if (type === "session:event") {
|
|
13495
13678
|
const agentId = parsed.data.agentId;
|
|
13496
13679
|
if (!agentId || !boundAgents.has(agentId)) {
|
|
@@ -13501,9 +13684,11 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13501
13684
|
return;
|
|
13502
13685
|
}
|
|
13503
13686
|
const payload = sessionEventMessageSchema.parse(msg);
|
|
13687
|
+
const boundInfo = boundAgents.get(agentId);
|
|
13504
13688
|
chainSessionOp(agentId, payload.chatId, async () => {
|
|
13505
13689
|
try {
|
|
13506
13690
|
await appendEvent(app.db, agentId, payload.chatId, payload.event);
|
|
13691
|
+
if (boundInfo) notifier.notifySessionEvent(agentId, payload.chatId, payload.event.kind, boundInfo.organizationId).catch(() => {});
|
|
13507
13692
|
} catch (err) {
|
|
13508
13693
|
socket.send(JSON.stringify({
|
|
13509
13694
|
type: "error",
|
|
@@ -13511,17 +13696,6 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13511
13696
|
}));
|
|
13512
13697
|
}
|
|
13513
13698
|
});
|
|
13514
|
-
} else if (type === "session:completion") {
|
|
13515
|
-
const agentId = parsed.data.agentId;
|
|
13516
|
-
if (!agentId || !boundAgents.has(agentId)) {
|
|
13517
|
-
socket.send(JSON.stringify({
|
|
13518
|
-
type: "error",
|
|
13519
|
-
message: "Agent not bound"
|
|
13520
|
-
}));
|
|
13521
|
-
return;
|
|
13522
|
-
}
|
|
13523
|
-
const payload = sessionCompletionMessageSchema.parse(msg);
|
|
13524
|
-
if (shouldNotify(agentId, `session_completed:${payload.chatId}`)) notifyAgentEvent(app.db, agentId, "session_completed", "low", payload.chatId).catch(() => {});
|
|
13525
13699
|
} else if (type === "inbox:ack") {
|
|
13526
13700
|
const payloadResult = inboxAckFrameSchema.safeParse(msg);
|
|
13527
13701
|
if (!payloadResult.success) {
|
|
@@ -13575,7 +13749,6 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13575
13749
|
notifier.unsubscribe(info.inboxId, socket);
|
|
13576
13750
|
if (getAgentClientId(agentId) === clientId) try {
|
|
13577
13751
|
await unbindAgent(app.db, agentId);
|
|
13578
|
-
if (shouldNotify(agentId, "agent_disconnected")) notifyAgentEvent(app.db, agentId, "agent_disconnected", "medium").catch(() => {});
|
|
13579
13752
|
} catch {}
|
|
13580
13753
|
}
|
|
13581
13754
|
boundAgents.clear();
|
|
@@ -13717,7 +13890,7 @@ async function agentRoutes(app) {
|
|
|
13717
13890
|
};
|
|
13718
13891
|
if (health === "disconnected") return reply.status(200).send({
|
|
13719
13892
|
status: "offline",
|
|
13720
|
-
message: "Agent is not connected.
|
|
13893
|
+
message: "Agent is not connected. Connect the client with: first-tree-hub connect <token>",
|
|
13721
13894
|
connection
|
|
13722
13895
|
});
|
|
13723
13896
|
if (health === "stale") return reply.status(200).send({
|
|
@@ -14611,6 +14784,83 @@ async function bindInstallationToOrg(db, installationId, hubOrganizationId) {
|
|
|
14611
14784
|
return true;
|
|
14612
14785
|
}
|
|
14613
14786
|
/**
|
|
14787
|
+
* Webhook handler for `installation: suspend`. Sets `suspended_at` to the
|
|
14788
|
+
* timestamp GitHub put on `installation.suspended_at`.
|
|
14789
|
+
*
|
|
14790
|
+
* Out-of-order safety (codex P1-7): GitHub doesn't guarantee delivery
|
|
14791
|
+
* order and redelivers on failure, so a *stale* `suspend` event could
|
|
14792
|
+
* arrive after a newer one. The conditional UPDATE only writes when the
|
|
14793
|
+
* row is currently unsuspended OR carries an *earlier* `suspended_at` —
|
|
14794
|
+
* a stale re-suspend with an older timestamp is a no-op.
|
|
14795
|
+
*
|
|
14796
|
+
* (Limitation: once an `unsuspend` has cleared `suspended_at` to NULL we
|
|
14797
|
+
* no longer know *when* that happened, so a stale `suspend` arriving after
|
|
14798
|
+
* an `unsuspend` would still re-suspend. Proper handling would need a
|
|
14799
|
+
* dedicated lifecycle-sequence column; in practice suspend/unsuspend are
|
|
14800
|
+
* minutes-apart human actions, well outside any realistic reorder window.)
|
|
14801
|
+
*/
|
|
14802
|
+
async function markInstallationSuspended(db, installationId, suspendedAt) {
|
|
14803
|
+
await db.update(githubAppInstallations).set({
|
|
14804
|
+
suspendedAt,
|
|
14805
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
14806
|
+
}).where(and(eq(githubAppInstallations.installationId, installationId), or(isNull(githubAppInstallations.suspendedAt), lt(githubAppInstallations.suspendedAt, suspendedAt))));
|
|
14807
|
+
}
|
|
14808
|
+
/**
|
|
14809
|
+
* Webhook handler for `installation: unsuspend`. Clears `suspended_at`.
|
|
14810
|
+
*
|
|
14811
|
+
* `unsuspendedAt` is the time we received the event (GitHub's `unsuspend`
|
|
14812
|
+
* payload, unlike `suspend`, carries no event timestamp). The conditional
|
|
14813
|
+
* UPDATE only clears when the current `suspended_at` predates that — i.e.
|
|
14814
|
+
* a stale `unsuspend` that lost the race to a newer `suspend` won't undo
|
|
14815
|
+
* it. A row that's already unsuspended (`suspended_at IS NULL`) is left
|
|
14816
|
+
* alone (the `< unsuspendedAt` comparison is NULL → no match), which is
|
|
14817
|
+
* the desired no-op.
|
|
14818
|
+
*/
|
|
14819
|
+
async function markInstallationUnsuspended(db, installationId, unsuspendedAt) {
|
|
14820
|
+
await db.update(githubAppInstallations).set({
|
|
14821
|
+
suspendedAt: null,
|
|
14822
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
14823
|
+
}).where(and(eq(githubAppInstallations.installationId, installationId), lt(githubAppInstallations.suspendedAt, unsuspendedAt)));
|
|
14824
|
+
}
|
|
14825
|
+
/**
|
|
14826
|
+
* Webhook handler for `installation: deleted`. Removes the row outright —
|
|
14827
|
+
* the user uninstalled the App from their account; the row has no value.
|
|
14828
|
+
*
|
|
14829
|
+
* The earlier 60-s `createdAt`-based grace window (added in C.12 codex P1-7
|
|
14830
|
+
* to guard against delayed `deleted` events clobbering fresh re-installs)
|
|
14831
|
+
* was reverted after a post-Phase-C codex challenge flagged a worse bug:
|
|
14832
|
+
* a real install + immediate uninstall (within 60 s) became permanent —
|
|
14833
|
+
* the handler returned a 200 no-op so GitHub never redelivered, and the
|
|
14834
|
+
* Hub-side row lived forever even though the App was gone upstream.
|
|
14835
|
+
*
|
|
14836
|
+
* The original race the grace was meant to solve doesn't actually exist:
|
|
14837
|
+
* GitHub mints a fresh `installation.id` per install, so a delayed
|
|
14838
|
+
* `deleted` for id N cannot wipe a fresh re-install (which has id M ≠ N).
|
|
14839
|
+
* Same-id replays are handled by the `processed_events` dedup table.
|
|
14840
|
+
*
|
|
14841
|
+
* The remaining "stale `created` after `deleted` resurrects the row" risk
|
|
14842
|
+
* is a pre-existing hole in `upsertInstallationFromMetadata` (not
|
|
14843
|
+
* introduced by Phase C). Tracked as a Phase D follow-up — the upsert
|
|
14844
|
+
* path needs a `last_lifecycle_event_at` column or tombstone to be
|
|
14845
|
+
* order-safe.
|
|
14846
|
+
*
|
|
14847
|
+
* Note: deleting the org on the Hub side is the inverse case — handled
|
|
14848
|
+
* by the `ON DELETE SET NULL` FK on `hub_organization_id`, which keeps
|
|
14849
|
+
* the installation row alive so a future rebind can recover.
|
|
14850
|
+
*/
|
|
14851
|
+
async function deleteInstallationByGithubId(db, installationId) {
|
|
14852
|
+
await db.delete(githubAppInstallations).where(eq(githubAppInstallations.installationId, installationId));
|
|
14853
|
+
}
|
|
14854
|
+
/**
|
|
14855
|
+
* Lookup an installation by GitHub-side id. Used by the webhook router
|
|
14856
|
+
* to resolve `installation.id` → `hub_organization_id` so downstream
|
|
14857
|
+
* event handlers (issues, PRs) know which Hub team the event belongs to.
|
|
14858
|
+
*/
|
|
14859
|
+
async function findInstallationByGithubId(db, installationId) {
|
|
14860
|
+
const [row] = await db.select().from(githubAppInstallations).where(eq(githubAppInstallations.installationId, installationId)).limit(1);
|
|
14861
|
+
return row ?? null;
|
|
14862
|
+
}
|
|
14863
|
+
/**
|
|
14614
14864
|
* Lookup the installation bound to a Hub team. Used by Settings →
|
|
14615
14865
|
* Integrations to render the connected-account panel. Returns null when
|
|
14616
14866
|
* no install is bound.
|
|
@@ -15266,10 +15516,9 @@ async function bootstrapConfigRoutes(_app) {
|
|
|
15266
15516
|
* Public endpoint — returns bootstrap prerequisites for CLI auto-discovery.
|
|
15267
15517
|
*
|
|
15268
15518
|
* `allowedOrg` used to surface here from the global `github.allowedOrg`
|
|
15269
|
-
* config; it is now
|
|
15270
|
-
* endpoint can't resolve an
|
|
15271
|
-
*
|
|
15272
|
-
* `/api/v1/orgs/:orgId/settings/github_integration` after auth.
|
|
15519
|
+
* config; it is now per-installation state on `github_app_installations`
|
|
15520
|
+
* (see issue #255). A public bootstrap endpoint can't resolve an
|
|
15521
|
+
* installation without a caller, so the field is surfaced as `null`.
|
|
15273
15522
|
*/
|
|
15274
15523
|
_app.get("/config", async () => {
|
|
15275
15524
|
return { allowedOrg: null };
|
|
@@ -15602,7 +15851,7 @@ async function getCallerEngagement(db, chatId, agentId) {
|
|
|
15602
15851
|
* (speaker → "participant" / watcher → "watching"); the user
|
|
15603
15852
|
* state row supplies the unread counter (COALESCE → 0 when
|
|
15604
15853
|
* row is missing).
|
|
15605
|
-
* - Filter `parent_chat_id IS NULL` (
|
|
15854
|
+
* - Filter `parent_chat_id IS NULL` (nested chats not surfaced in v1).
|
|
15606
15855
|
* - Filter `c.organization_id = ?` to defend against historical
|
|
15607
15856
|
* cross-org pollution rows that may still reference the caller
|
|
15608
15857
|
* (see fix/cross-org-direct-chat-pollution).
|
|
@@ -15670,9 +15919,11 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
|
15670
15919
|
chatId: chatMembership.chatId,
|
|
15671
15920
|
agentId: chatMembership.agentId,
|
|
15672
15921
|
displayName: agents.displayName,
|
|
15673
|
-
type: agents.type
|
|
15674
|
-
|
|
15922
|
+
type: agents.type,
|
|
15923
|
+
sessionState: agentChatSessions.state
|
|
15924
|
+
}).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).leftJoin(agentChatSessions, and(eq(agentChatSessions.agentId, chatMembership.agentId), eq(agentChatSessions.chatId, chatMembership.chatId))).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
|
|
15675
15925
|
const participantsByChat = /* @__PURE__ */ new Map();
|
|
15926
|
+
const engagedByChat = /* @__PURE__ */ new Map();
|
|
15676
15927
|
for (const p of participantRows) {
|
|
15677
15928
|
const list = participantsByChat.get(p.chatId) ?? [];
|
|
15678
15929
|
list.push({
|
|
@@ -15681,7 +15932,13 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
|
15681
15932
|
type: p.type
|
|
15682
15933
|
});
|
|
15683
15934
|
participantsByChat.set(p.chatId, list);
|
|
15935
|
+
if (p.sessionState === "active") {
|
|
15936
|
+
const engaged = engagedByChat.get(p.chatId) ?? [];
|
|
15937
|
+
engaged.push(p.agentId);
|
|
15938
|
+
engagedByChat.set(p.chatId, engaged);
|
|
15939
|
+
}
|
|
15684
15940
|
}
|
|
15941
|
+
const liveActivityByChat = await deriveLiveActivity(db, chatIds);
|
|
15685
15942
|
const firstMessageRows = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
15686
15943
|
chatId: messages.chatId,
|
|
15687
15944
|
content: messages.content
|
|
@@ -15708,13 +15965,99 @@ async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
|
15708
15965
|
lastMessagePreview: r.last_message_preview,
|
|
15709
15966
|
unreadMentionCount: r.unread_mention_count,
|
|
15710
15967
|
canReply: isSpeaker,
|
|
15711
|
-
engagementStatus: r.engagement_status
|
|
15968
|
+
engagementStatus: r.engagement_status,
|
|
15969
|
+
engagedAgentIds: engagedByChat.get(r.chat_id) ?? [],
|
|
15970
|
+
liveActivity: liveActivityByChat.get(r.chat_id) ?? null
|
|
15712
15971
|
};
|
|
15713
15972
|
}),
|
|
15714
15973
|
nextCursor
|
|
15715
15974
|
};
|
|
15716
15975
|
}
|
|
15717
15976
|
/**
|
|
15977
|
+
* Per-chat live activity, derived from the most recent `session_events` row.
|
|
15978
|
+
*
|
|
15979
|
+
* Returns a chatId → LiveActivity map; chats with no activity (or where the
|
|
15980
|
+
* latest event is terminal / stale) are absent from the map (caller treats
|
|
15981
|
+
* absence as null).
|
|
15982
|
+
*/
|
|
15983
|
+
async function deriveLiveActivity(db, chatIds) {
|
|
15984
|
+
if (chatIds.length === 0) return /* @__PURE__ */ new Map();
|
|
15985
|
+
const chatIdInClause = sql.join(chatIds.map((id) => sql`${id}`), sql`, `);
|
|
15986
|
+
const rows = (await db.execute(sql`
|
|
15987
|
+
SELECT acs.agent_id AS agent_id,
|
|
15988
|
+
acs.chat_id AS chat_id,
|
|
15989
|
+
e.kind AS kind,
|
|
15990
|
+
e.payload AS payload,
|
|
15991
|
+
e.created_at AS created_at
|
|
15992
|
+
FROM agent_chat_sessions acs
|
|
15993
|
+
CROSS JOIN LATERAL (
|
|
15994
|
+
SELECT kind, payload, created_at, seq
|
|
15995
|
+
FROM session_events se
|
|
15996
|
+
WHERE se.agent_id = acs.agent_id
|
|
15997
|
+
AND se.chat_id = acs.chat_id
|
|
15998
|
+
ORDER BY se.seq DESC
|
|
15999
|
+
LIMIT 1
|
|
16000
|
+
) e
|
|
16001
|
+
WHERE acs.chat_id IN (${chatIdInClause})
|
|
16002
|
+
AND acs.state <> 'evicted'
|
|
16003
|
+
`)).map((r) => ({
|
|
16004
|
+
agent_id: r.agent_id,
|
|
16005
|
+
chat_id: r.chat_id,
|
|
16006
|
+
kind: r.kind,
|
|
16007
|
+
payload: r.payload,
|
|
16008
|
+
created_at: r.created_at
|
|
16009
|
+
}));
|
|
16010
|
+
const now = Date.now();
|
|
16011
|
+
const byChat = /* @__PURE__ */ new Map();
|
|
16012
|
+
for (const row of rows) {
|
|
16013
|
+
const activity = toLiveActivity(row);
|
|
16014
|
+
if (!activity) continue;
|
|
16015
|
+
const createdAtMs = new Date(row.created_at).getTime();
|
|
16016
|
+
if (now - createdAtMs > 6e4) continue;
|
|
16017
|
+
const existing = byChat.get(row.chat_id);
|
|
16018
|
+
if (!existing || createdAtMs > existing.createdAtMs) byChat.set(row.chat_id, {
|
|
16019
|
+
activity,
|
|
16020
|
+
createdAtMs
|
|
16021
|
+
});
|
|
16022
|
+
}
|
|
16023
|
+
const out = /* @__PURE__ */ new Map();
|
|
16024
|
+
for (const [chatId, { activity }] of byChat) out.set(chatId, activity);
|
|
16025
|
+
return out;
|
|
16026
|
+
}
|
|
16027
|
+
/**
|
|
16028
|
+
* Translate a `session_events` row into a `LiveActivity`, or null when the
|
|
16029
|
+
* kind is terminal (`turn_end` / `error`) or unrecognised. Pure & exported
|
|
16030
|
+
* for unit testing.
|
|
16031
|
+
*/
|
|
16032
|
+
function toLiveActivity(row) {
|
|
16033
|
+
const startedAt = new Date(row.created_at).toISOString();
|
|
16034
|
+
switch (row.kind) {
|
|
16035
|
+
case "tool_call": {
|
|
16036
|
+
const payload = row.payload ?? {};
|
|
16037
|
+
const label = typeof payload.name === "string" && payload.name.length > 0 ? payload.name : "Tool";
|
|
16038
|
+
return {
|
|
16039
|
+
agentId: row.agent_id,
|
|
16040
|
+
kind: "tool_call",
|
|
16041
|
+
label,
|
|
16042
|
+
startedAt
|
|
16043
|
+
};
|
|
16044
|
+
}
|
|
16045
|
+
case "thinking": return {
|
|
16046
|
+
agentId: row.agent_id,
|
|
16047
|
+
kind: "thinking",
|
|
16048
|
+
label: "Thinking",
|
|
16049
|
+
startedAt
|
|
16050
|
+
};
|
|
16051
|
+
case "assistant_text": return {
|
|
16052
|
+
agentId: row.agent_id,
|
|
16053
|
+
kind: "assistant_text",
|
|
16054
|
+
label: "Writing",
|
|
16055
|
+
startedAt
|
|
16056
|
+
};
|
|
16057
|
+
default: return null;
|
|
16058
|
+
}
|
|
16059
|
+
}
|
|
16060
|
+
/**
|
|
15718
16061
|
* Title resolution priority:
|
|
15719
16062
|
*
|
|
15720
16063
|
* 1. `chat.topic` (manual, set via `PATCH /chats/:chatId`)
|
|
@@ -16156,15 +16499,13 @@ const organizationSettings = pgTable("organization_settings", {
|
|
|
16156
16499
|
* registry of valid namespaces and their storage / input / output schemas
|
|
16157
16500
|
* lives in `@agent-team-foundation/first-tree-hub-shared`.
|
|
16158
16501
|
*
|
|
16159
|
-
* Read path: storage row →
|
|
16160
|
-
* Write path: input → validate →
|
|
16502
|
+
* Read path: storage row → output (mask)
|
|
16503
|
+
* Write path: input → validate → merge with current storage → upsert (in tx)
|
|
16161
16504
|
*
|
|
16162
|
-
* The generic getter returns the masked output.
|
|
16163
|
-
*
|
|
16164
|
-
*
|
|
16165
|
-
*
|
|
16166
|
-
* call-sites and limits secret exposure to one explicit code path per
|
|
16167
|
-
* secret. (#4)
|
|
16505
|
+
* The generic getter returns the masked output. Per-namespace plaintext
|
|
16506
|
+
* accessors live alongside this module when a secret needs to leave the
|
|
16507
|
+
* encrypted-at-rest boundary (none today after the github_integration
|
|
16508
|
+
* webhook secret was retired — DP12).
|
|
16168
16509
|
*/
|
|
16169
16510
|
function assertNamespace(ns) {
|
|
16170
16511
|
if (!isOrgSettingNamespace(ns)) throw new BadRequestError(`Unknown organization-settings namespace: "${ns}"`);
|
|
@@ -16177,19 +16518,15 @@ async function fetchStorageRow(db, orgId, namespace) {
|
|
|
16177
16518
|
function emptyStorage(namespace) {
|
|
16178
16519
|
return ORG_SETTINGS_NAMESPACES$1[namespace].storage.parse({});
|
|
16179
16520
|
}
|
|
16180
|
-
function ensureEncrypted(value, encryptionKey) {
|
|
16181
|
-
return isEncryptedValue(value) ? value : encryptValue(value, encryptionKey);
|
|
16182
|
-
}
|
|
16183
16521
|
/**
|
|
16184
16522
|
* Merge a validated input into the current storage row for a namespace.
|
|
16185
|
-
* Secret fields are encrypted here.
|
|
16186
16523
|
*
|
|
16187
16524
|
* Input semantics per nullish field:
|
|
16188
16525
|
* `undefined` → unchanged
|
|
16189
16526
|
* `null` → cleared
|
|
16190
16527
|
* value → set / replace (already validated as non-empty by the input schema)
|
|
16191
16528
|
*/
|
|
16192
|
-
function applyInputDelta(namespace, current, input
|
|
16529
|
+
function applyInputDelta(namespace, current, input) {
|
|
16193
16530
|
if (namespace === "context_tree") {
|
|
16194
16531
|
const cur = current;
|
|
16195
16532
|
const inp = input;
|
|
@@ -16198,11 +16535,6 @@ function applyInputDelta(namespace, current, input, encryptionKey) {
|
|
|
16198
16535
|
branch: inp.branch === void 0 ? cur.branch : inp.branch ?? "main"
|
|
16199
16536
|
};
|
|
16200
16537
|
}
|
|
16201
|
-
if (namespace === "github_integration") {
|
|
16202
|
-
const cur = current;
|
|
16203
|
-
const inp = input;
|
|
16204
|
-
return { webhookSecretCipher: inp.webhookSecret === void 0 ? cur.webhookSecretCipher : inp.webhookSecret === null ? void 0 : ensureEncrypted(inp.webhookSecret, encryptionKey) };
|
|
16205
|
-
}
|
|
16206
16538
|
if (namespace === "source_repos") {
|
|
16207
16539
|
const cur = current;
|
|
16208
16540
|
const inp = input;
|
|
@@ -16212,9 +16544,7 @@ function applyInputDelta(namespace, current, input, encryptionKey) {
|
|
|
16212
16544
|
}
|
|
16213
16545
|
/**
|
|
16214
16546
|
* Project the storage row into the API output for a namespace, masking
|
|
16215
|
-
* any secret fields.
|
|
16216
|
-
* empty string here — the route layer enriches it with the resolved
|
|
16217
|
-
* `server.publicUrl` (the service stays config-agnostic).
|
|
16547
|
+
* any secret fields.
|
|
16218
16548
|
*/
|
|
16219
16549
|
function toOutput(namespace, storage) {
|
|
16220
16550
|
if (namespace === "context_tree") {
|
|
@@ -16224,13 +16554,6 @@ function toOutput(namespace, storage) {
|
|
|
16224
16554
|
branch: s.branch
|
|
16225
16555
|
};
|
|
16226
16556
|
}
|
|
16227
|
-
if (namespace === "github_integration") {
|
|
16228
|
-
const s = storage;
|
|
16229
|
-
return {
|
|
16230
|
-
webhookSecretConfigured: typeof s.webhookSecretCipher === "string" && s.webhookSecretCipher.length > 0,
|
|
16231
|
-
webhookUrl: ""
|
|
16232
|
-
};
|
|
16233
|
-
}
|
|
16234
16557
|
if (namespace === "source_repos") return { repos: storage.repos };
|
|
16235
16558
|
return namespace;
|
|
16236
16559
|
}
|
|
@@ -16251,17 +16574,6 @@ async function getOrgContextTree(db, orgId) {
|
|
|
16251
16574
|
return await fetchStorageRow(db, orgId, "context_tree") ?? emptyStorage("context_tree");
|
|
16252
16575
|
}
|
|
16253
16576
|
/**
|
|
16254
|
-
* Decrypt and return the plaintext GitHub webhook secret for an org.
|
|
16255
|
-
* Returns `null` when the org has not configured one. The only intended
|
|
16256
|
-
* caller is the webhook route's signature verifier — the result must
|
|
16257
|
-
* never leak through HTTP responses or logs. (#4)
|
|
16258
|
-
*/
|
|
16259
|
-
async function getDecryptedGithubWebhookSecret(db, orgId, encryptionKey) {
|
|
16260
|
-
const cipher = (await fetchStorageRow(db, orgId, "github_integration"))?.webhookSecretCipher;
|
|
16261
|
-
if (!cipher) return null;
|
|
16262
|
-
return isEncryptedValue(cipher) ? decryptValue(cipher, encryptionKey) : cipher;
|
|
16263
|
-
}
|
|
16264
|
-
/**
|
|
16265
16577
|
* Upsert a setting. Returns the masked output of the resulting row.
|
|
16266
16578
|
*
|
|
16267
16579
|
* The fetch + merge + upsert sequence runs inside a single transaction so
|
|
@@ -16277,7 +16589,7 @@ async function putOrgSetting(db, orgId, namespace, rawInput, options) {
|
|
|
16277
16589
|
const txDb = tx;
|
|
16278
16590
|
const [org] = await txDb.select({ id: organizations.id }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
|
|
16279
16591
|
if (!org) throw new NotFoundError(`Organization "${orgId}" not found`);
|
|
16280
|
-
const merged = applyInputDelta(namespace, await fetchStorageRow(txDb, orgId, namespace) ?? emptyStorage(namespace), input
|
|
16592
|
+
const merged = applyInputDelta(namespace, await fetchStorageRow(txDb, orgId, namespace) ?? emptyStorage(namespace), input);
|
|
16281
16593
|
const validated = ORG_SETTINGS_NAMESPACES$1[namespace].storage.parse(merged);
|
|
16282
16594
|
await tx.insert(organizationSettings).values({
|
|
16283
16595
|
organizationId: orgId,
|
|
@@ -17407,7 +17719,7 @@ async function healthzRoutes(app) {
|
|
|
17407
17719
|
* `api/orgs/invitations.ts` (Class B, admin-gated).
|
|
17408
17720
|
*/
|
|
17409
17721
|
async function publicInvitationRoutes(app) {
|
|
17410
|
-
const { previewInvitation } = await import("./invitation-
|
|
17722
|
+
const { previewInvitation } = await import("./invitation-CNv7gfFF-D93KQte0.mjs");
|
|
17411
17723
|
app.get("/:token/preview", async (request, reply) => {
|
|
17412
17724
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
17413
17725
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -17477,7 +17789,8 @@ async function meRoutes(app) {
|
|
|
17477
17789
|
username: users.username,
|
|
17478
17790
|
displayName: users.displayName,
|
|
17479
17791
|
avatarUrl: users.avatarUrl,
|
|
17480
|
-
onboardingDismissedAt: users.onboardingDismissedAt
|
|
17792
|
+
onboardingDismissedAt: users.onboardingDismissedAt,
|
|
17793
|
+
onboardingCompletedAt: users.onboardingCompletedAt
|
|
17481
17794
|
}).from(users).where(eq(users.id, userId)).limit(1);
|
|
17482
17795
|
const memberships = await listActiveMemberships(app.db, userId);
|
|
17483
17796
|
const defaultMembership = pickDefaultMembership(memberships.map((m) => ({
|
|
@@ -17505,7 +17818,8 @@ async function meRoutes(app) {
|
|
|
17505
17818
|
})),
|
|
17506
17819
|
onboarding: {
|
|
17507
17820
|
step: onboardingStep,
|
|
17508
|
-
dismissedAt: user?.onboardingDismissedAt ? user.onboardingDismissedAt.toISOString() : null
|
|
17821
|
+
dismissedAt: user?.onboardingDismissedAt ? user.onboardingDismissedAt.toISOString() : null,
|
|
17822
|
+
completedAt: user?.onboardingCompletedAt ? user.onboardingCompletedAt.toISOString() : null
|
|
17509
17823
|
},
|
|
17510
17824
|
inviteUrl
|
|
17511
17825
|
};
|
|
@@ -17531,6 +17845,25 @@ async function meRoutes(app) {
|
|
|
17531
17845
|
return reply.status(200).send({ dismissedAt: u?.onboardingDismissedAt ? u.onboardingDismissedAt.toISOString() : null });
|
|
17532
17846
|
});
|
|
17533
17847
|
/**
|
|
17848
|
+
* POST /me/onboarding-completed — stamp the terminal-state column when
|
|
17849
|
+
* the user walks Step 3 to success (admin Continue, invitee Confirm /
|
|
17850
|
+
* Continue). Distinct from PATCH /me/onboarding { dismissed: true },
|
|
17851
|
+
* which only hides the stepper UI. Once stamped, the web sidebar drops
|
|
17852
|
+
* the Settings → Onboarding entry point and /settings/onboarding
|
|
17853
|
+
* redirects, so the wizard cannot re-enter.
|
|
17854
|
+
*
|
|
17855
|
+
* Idempotent: only writes when the column is still NULL — re-calling on
|
|
17856
|
+
* an already-completed user is a no-op rather than resetting the stamp.
|
|
17857
|
+
*/
|
|
17858
|
+
app.post("/me/onboarding-completed", async (request, reply) => {
|
|
17859
|
+
const { userId } = requireUser(request);
|
|
17860
|
+
if ((await app.db.update(users).set({ onboardingCompletedAt: /* @__PURE__ */ new Date() }).where(and(eq(users.id, userId), isNull(users.onboardingCompletedAt))).returning({ id: users.id })).length > 0) app.log.info({
|
|
17861
|
+
event: "onboarding.completed",
|
|
17862
|
+
userId
|
|
17863
|
+
}, "onboarding funnel: setup completed");
|
|
17864
|
+
return reply.status(200).send({ ok: true });
|
|
17865
|
+
});
|
|
17866
|
+
/**
|
|
17534
17867
|
* POST /me/onboarding/events — web-side onboarding funnel reporter.
|
|
17535
17868
|
* Server-side milestones (`team_created` at OAuth, `dismissed` on PATCH)
|
|
17536
17869
|
* are emitted directly; this endpoint surfaces the web-driven ones into
|
|
@@ -17673,7 +18006,7 @@ async function meRoutes(app) {
|
|
|
17673
18006
|
*/
|
|
17674
18007
|
app.get("/me/pinned-agents", async (request) => {
|
|
17675
18008
|
const { userId } = requireUser(request);
|
|
17676
|
-
const { listMyPinnedAgents } = await import("./client-
|
|
18009
|
+
const { listMyPinnedAgents } = await import("./client-h4KZ3b9o-CQyibXig.mjs");
|
|
17677
18010
|
return listMyPinnedAgents(app.db, { userId });
|
|
17678
18011
|
});
|
|
17679
18012
|
/**
|
|
@@ -17925,7 +18258,7 @@ async function orgAdapterStatusRoutes(app) {
|
|
|
17925
18258
|
return app.adapterManager.getBotStatuses().filter((s) => visibleIds.has(s.configId));
|
|
17926
18259
|
});
|
|
17927
18260
|
}
|
|
17928
|
-
const log$
|
|
18261
|
+
const log$3 = createLogger$1("OrgAdapters");
|
|
17929
18262
|
/** Class B — `/api/v1/orgs/:orgId/adapters`. */
|
|
17930
18263
|
async function orgAdapterRoutes(app) {
|
|
17931
18264
|
app.get("/", async (request) => {
|
|
@@ -17941,7 +18274,7 @@ async function orgAdapterRoutes(app) {
|
|
|
17941
18274
|
const body = createAdapterConfigSchema.parse(request.body);
|
|
17942
18275
|
await assertAgentManageableByUser(app.db, scope.userId, body.agentId);
|
|
17943
18276
|
const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
|
|
17944
|
-
app.adapterManager.reload().catch((err) => log$
|
|
18277
|
+
app.adapterManager.reload().catch((err) => log$3.error({ err }, "adapter reload failed after create"));
|
|
17945
18278
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
17946
18279
|
return reply.status(201).send({
|
|
17947
18280
|
...config,
|
|
@@ -18464,6 +18797,10 @@ async function orgNotificationRoutes(app) {
|
|
|
18464
18797
|
const query = notificationQuerySchema.parse(request.query);
|
|
18465
18798
|
return listNotifications(app.db, scope.organizationId, scope.memberId, query);
|
|
18466
18799
|
});
|
|
18800
|
+
app.get("/unread-count", async (request) => {
|
|
18801
|
+
const scope = await requireOrgMembership(request, app.db);
|
|
18802
|
+
return { count: await unreadCount(app.db, scope.organizationId, scope.memberId) };
|
|
18803
|
+
});
|
|
18467
18804
|
app.post("/:id/read", async (request) => {
|
|
18468
18805
|
const scope = await requireOrgMembership(request, app.db);
|
|
18469
18806
|
const result = await markRead(app.db, request.params.id, scope.organizationId, scope.memberId);
|
|
@@ -18524,24 +18861,20 @@ async function orgSessionRoutes(app) {
|
|
|
18524
18861
|
* GET gating is per-namespace via `readPolicy` in the registry: namespaces
|
|
18525
18862
|
* with no secret fields (`context_tree`, `source_repos`) are readable by
|
|
18526
18863
|
* any active org member, so an invitee can see what tree and repos the
|
|
18527
|
-
* team is bound to before joining the chat.
|
|
18528
|
-
*
|
|
18529
|
-
*
|
|
18530
|
-
* namespace — non-admins must never mutate org-wide config.
|
|
18864
|
+
* team is bound to before joining the chat. PUT and DELETE are always
|
|
18865
|
+
* admin-only regardless of namespace — non-admins must never mutate
|
|
18866
|
+
* org-wide config.
|
|
18531
18867
|
*/
|
|
18532
18868
|
async function orgSettingsRoutes(app) {
|
|
18533
18869
|
app.get("/:namespace", async (request) => {
|
|
18534
18870
|
const namespace = parseNamespace(request.params.namespace);
|
|
18535
18871
|
const scope = ORG_SETTINGS_NAMESPACES$1[namespace].readPolicy === "member" ? await requireOrgMembership(request, app.db) : await requireOrgAdmin(request, app.db);
|
|
18536
|
-
return
|
|
18872
|
+
return getOrgSetting(app.db, scope.organizationId, namespace);
|
|
18537
18873
|
});
|
|
18538
18874
|
app.put("/:namespace", { config: { otelRecordBody: true } }, async (request) => {
|
|
18539
18875
|
const scope = await requireOrgAdmin(request, app.db);
|
|
18540
18876
|
const namespace = parseNamespace(request.params.namespace);
|
|
18541
|
-
return
|
|
18542
|
-
updatedBy: scope.userId,
|
|
18543
|
-
encryptionKey: app.config.secrets.encryptionKey
|
|
18544
|
-
}), scope.organizationId, app.config.server.publicUrl);
|
|
18877
|
+
return putOrgSetting(app.db, scope.organizationId, namespace, request.body, { updatedBy: scope.userId });
|
|
18545
18878
|
});
|
|
18546
18879
|
app.delete("/:namespace", async (request, reply) => {
|
|
18547
18880
|
const scope = await requireOrgAdmin(request, app.db);
|
|
@@ -18554,27 +18887,6 @@ function parseNamespace(raw) {
|
|
|
18554
18887
|
if (!isOrgSettingNamespace(raw)) throw new BadRequestError(`Unknown organization-settings namespace: "${raw}"`);
|
|
18555
18888
|
return raw;
|
|
18556
18889
|
}
|
|
18557
|
-
/**
|
|
18558
|
-
* Resolve namespace-specific server-config-derived fields. The service
|
|
18559
|
-
* layer stays config-agnostic — namespace knowledge that needs `app.config`
|
|
18560
|
-
* lives here. Currently only `github_integration.webhookUrl` qualifies.
|
|
18561
|
-
*
|
|
18562
|
-
* If `server.publicUrl` is unset on the Hub, `webhookUrl` is left as `""`
|
|
18563
|
-
* so the UI can render a "contact your site administrator" notice rather
|
|
18564
|
-
* than fall back to `window.location.origin` (which is wrong behind a
|
|
18565
|
-
* reverse proxy). (#12)
|
|
18566
|
-
*/
|
|
18567
|
-
function enrichOutput(namespace, out, orgId, publicUrl) {
|
|
18568
|
-
if (namespace === "github_integration") {
|
|
18569
|
-
const o = out;
|
|
18570
|
-
const webhookUrl = publicUrl ? `${publicUrl.replace(/\/+$/, "")}/api/v1/webhooks/github/${orgId}` : "";
|
|
18571
|
-
return {
|
|
18572
|
-
...o,
|
|
18573
|
-
webhookUrl
|
|
18574
|
-
};
|
|
18575
|
-
}
|
|
18576
|
-
return out;
|
|
18577
|
-
}
|
|
18578
18890
|
async function loadVisibleAgentIds(db, organizationId, memberId) {
|
|
18579
18891
|
const rows = await db.select({ id: agents.uuid }).from(agents).where(and(eq(agents.organizationId, organizationId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId))));
|
|
18580
18892
|
return new Set(rows.map((r) => r.id));
|
|
@@ -18615,6 +18927,12 @@ function orgWsRoutes(notifier, jwtSecret) {
|
|
|
18615
18927
|
...payload
|
|
18616
18928
|
});
|
|
18617
18929
|
});
|
|
18930
|
+
notifier.onSessionEvent((payload) => {
|
|
18931
|
+
broadcastOrgScoped({
|
|
18932
|
+
type: "session:event",
|
|
18933
|
+
...payload
|
|
18934
|
+
});
|
|
18935
|
+
});
|
|
18618
18936
|
notifier.onChatMessage(({ chatId }) => {
|
|
18619
18937
|
dispatchChatMessage(chatId);
|
|
18620
18938
|
});
|
|
@@ -18795,6 +19113,128 @@ async function sessionRoutes(app) {
|
|
|
18795
19113
|
});
|
|
18796
19114
|
});
|
|
18797
19115
|
}
|
|
19116
|
+
/**
|
|
19117
|
+
* GitHub-specific webhook entity → chat clustering (Phase 0).
|
|
19118
|
+
*
|
|
19119
|
+
* Each `(organization, human_agent, delegate_agent, entity)` tuple resolves to
|
|
19120
|
+
* exactly one chat. Future external sources (Linear, Slack, …) get their own
|
|
19121
|
+
* tables — their entity models differ enough that a generic table would slip
|
|
19122
|
+
* back into untyped jsonb.
|
|
19123
|
+
*
|
|
19124
|
+
* `bound_via` distinguishes the first-touch row (`direct`) from a row written
|
|
19125
|
+
* by the `Fixes #N` linker (`fixes_link`). Routing logic ignores the
|
|
19126
|
+
* distinction; it exists for audit and future strategy tweaks.
|
|
19127
|
+
*/
|
|
19128
|
+
const githubEntityChatMappings = pgTable("github_entity_chat_mappings", {
|
|
19129
|
+
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
19130
|
+
humanAgentId: text("human_agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
19131
|
+
delegateAgentId: text("delegate_agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
19132
|
+
entityType: text("entity_type").notNull(),
|
|
19133
|
+
entityKey: text("entity_key").notNull(),
|
|
19134
|
+
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
19135
|
+
boundAt: timestamp("bound_at", { withTimezone: true }).notNull().defaultNow(),
|
|
19136
|
+
boundVia: text("bound_via").notNull()
|
|
19137
|
+
}, (table) => [primaryKey({ columns: [
|
|
19138
|
+
table.organizationId,
|
|
19139
|
+
table.humanAgentId,
|
|
19140
|
+
table.delegateAgentId,
|
|
19141
|
+
table.entityType,
|
|
19142
|
+
table.entityKey
|
|
19143
|
+
] }), index("idx_github_entity_chat_mappings_chat").on(table.chatId)]);
|
|
19144
|
+
function evaluateDelegateTarget(target, sourceOrgId) {
|
|
19145
|
+
if (!target) return "not_found";
|
|
19146
|
+
if (target.organizationId !== sourceOrgId) return "cross_org";
|
|
19147
|
+
if (target.status !== "active") return "inactive";
|
|
19148
|
+
return "ok";
|
|
19149
|
+
}
|
|
19150
|
+
async function identifyActor(db, organizationId, actor, appSlug) {
|
|
19151
|
+
if (actor.isBot && appSlug && actor.githubLogin.toLowerCase() === `${appSlug.toLowerCase()}[bot]`) return { kind: "our-app-bot" };
|
|
19152
|
+
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);
|
|
19153
|
+
if (agentRow) return {
|
|
19154
|
+
kind: "agent",
|
|
19155
|
+
agentId: agentRow.uuid
|
|
19156
|
+
};
|
|
19157
|
+
return { kind: "external" };
|
|
19158
|
+
}
|
|
19159
|
+
/**
|
|
19160
|
+
* Compute the Stage 2 audience for a normalized event.
|
|
19161
|
+
*
|
|
19162
|
+
* audience = subscribed ∪ involved
|
|
19163
|
+
*
|
|
19164
|
+
* `subscribed` reads every `(human, delegate)` row already bound to
|
|
19165
|
+
* `(org, entity)` in `github_entity_chat_mappings`. `involved` walks
|
|
19166
|
+
* `event.involves` and for each login that resolves to an org-local
|
|
19167
|
+
* `delegate_mention`-configured agent whose target is eligible AND isn't
|
|
19168
|
+
* already subscribed, appends a `new` row.
|
|
19169
|
+
*
|
|
19170
|
+
* Echo filtering runs after the union: when the actor maps to an agent in
|
|
19171
|
+
* this org, rows where the actor is either the human or the delegate side
|
|
19172
|
+
* are dropped so the agent's own action doesn't bounce back. When the actor
|
|
19173
|
+
* is our App's bot user (DP13), the whole audience is suppressed.
|
|
19174
|
+
*/
|
|
19175
|
+
async function resolveAudience(db, event, appSlug) {
|
|
19176
|
+
const organizationId = event.source.organizationId;
|
|
19177
|
+
const subscribed = (await db.select({
|
|
19178
|
+
humanAgentId: githubEntityChatMappings.humanAgentId,
|
|
19179
|
+
delegateAgentId: githubEntityChatMappings.delegateAgentId,
|
|
19180
|
+
chatId: githubEntityChatMappings.chatId
|
|
19181
|
+
}).from(githubEntityChatMappings).where(and(eq(githubEntityChatMappings.organizationId, organizationId), eq(githubEntityChatMappings.entityType, event.entity.type), eq(githubEntityChatMappings.entityKey, event.entity.key)))).map((row) => ({
|
|
19182
|
+
humanAgentId: row.humanAgentId,
|
|
19183
|
+
delegateAgentId: row.delegateAgentId,
|
|
19184
|
+
kind: "existing",
|
|
19185
|
+
chatId: row.chatId,
|
|
19186
|
+
involveReason: null,
|
|
19187
|
+
involveLogin: null
|
|
19188
|
+
}));
|
|
19189
|
+
const subscribedKeys = new Set(subscribed.map((s) => `${s.humanAgentId} ${s.delegateAgentId}`));
|
|
19190
|
+
const involved = [];
|
|
19191
|
+
if (event.involves.length > 0) {
|
|
19192
|
+
const candidateLogins = event.involves.map((i) => i.githubLogin.toLowerCase());
|
|
19193
|
+
const reasonByLogin = /* @__PURE__ */ new Map();
|
|
19194
|
+
for (const i of event.involves) reasonByLogin.set(i.githubLogin.toLowerCase(), i.reason);
|
|
19195
|
+
const candidates = await db.select({
|
|
19196
|
+
id: agents.uuid,
|
|
19197
|
+
name: agents.name,
|
|
19198
|
+
delegateMention: agents.delegateMention,
|
|
19199
|
+
status: agents.status
|
|
19200
|
+
}).from(agents).where(and(eq(agents.organizationId, organizationId), isNotNull(agents.delegateMention), inArray(sql`lower(${agents.name})`, candidateLogins)));
|
|
19201
|
+
const delegateIds = /* @__PURE__ */ new Set();
|
|
19202
|
+
for (const c of candidates) if (c.delegateMention) delegateIds.add(c.delegateMention);
|
|
19203
|
+
const delegateRows = delegateIds.size > 0 ? await db.select({
|
|
19204
|
+
id: agents.uuid,
|
|
19205
|
+
organizationId: agents.organizationId,
|
|
19206
|
+
status: agents.status
|
|
19207
|
+
}).from(agents).where(inArray(agents.uuid, [...delegateIds])) : [];
|
|
19208
|
+
const delegateById = /* @__PURE__ */ new Map();
|
|
19209
|
+
for (const row of delegateRows) delegateById.set(row.id, {
|
|
19210
|
+
organizationId: row.organizationId,
|
|
19211
|
+
status: row.status
|
|
19212
|
+
});
|
|
19213
|
+
for (const c of candidates) {
|
|
19214
|
+
if (c.status !== "active" || !c.delegateMention || !c.name) continue;
|
|
19215
|
+
if (evaluateDelegateTarget(delegateById.get(c.delegateMention), organizationId) !== "ok") continue;
|
|
19216
|
+
const key = `${c.id} ${c.delegateMention}`;
|
|
19217
|
+
if (subscribedKeys.has(key)) continue;
|
|
19218
|
+
const candidateLogin = c.name.toLowerCase();
|
|
19219
|
+
const reason = reasonByLogin.get(candidateLogin);
|
|
19220
|
+
if (!reason) continue;
|
|
19221
|
+
involved.push({
|
|
19222
|
+
humanAgentId: c.id,
|
|
19223
|
+
delegateAgentId: c.delegateMention,
|
|
19224
|
+
kind: "new",
|
|
19225
|
+
chatId: null,
|
|
19226
|
+
involveReason: reason,
|
|
19227
|
+
involveLogin: candidateLogin
|
|
19228
|
+
});
|
|
19229
|
+
}
|
|
19230
|
+
}
|
|
19231
|
+
const audience = [...subscribed, ...involved];
|
|
19232
|
+
if (audience.length === 0) return audience;
|
|
19233
|
+
const actor = await identifyActor(db, organizationId, event.actor, appSlug);
|
|
19234
|
+
if (actor.kind === "our-app-bot") return [];
|
|
19235
|
+
if (actor.kind === "agent") return audience.filter((a) => a.kind === "new" || a.humanAgentId !== actor.agentId && a.delegateAgentId !== actor.agentId);
|
|
19236
|
+
return audience;
|
|
19237
|
+
}
|
|
18798
19238
|
function isRecord(value) {
|
|
18799
19239
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18800
19240
|
}
|
|
@@ -18804,10 +19244,10 @@ function repoFullName(payload) {
|
|
|
18804
19244
|
const repo = isRecord(payload.repository) ? payload.repository : null;
|
|
18805
19245
|
return typeof repo?.full_name === "string" && repo.full_name.length > 0 ? repo.full_name : null;
|
|
18806
19246
|
}
|
|
18807
|
-
function readNumber(value) {
|
|
19247
|
+
function readNumber$1(value) {
|
|
18808
19248
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
18809
19249
|
}
|
|
18810
|
-
function readString(value) {
|
|
19250
|
+
function readString$1(value) {
|
|
18811
19251
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
18812
19252
|
}
|
|
18813
19253
|
/**
|
|
@@ -18828,51 +19268,68 @@ function extractEventEntity(eventType, payload) {
|
|
|
18828
19268
|
const repo = repoFullName(payload);
|
|
18829
19269
|
if (!repo) return null;
|
|
18830
19270
|
switch (eventType) {
|
|
18831
|
-
case "issues":
|
|
18832
|
-
case "issue_comment": {
|
|
19271
|
+
case "issues": {
|
|
18833
19272
|
const issue = isRecord(payload.issue) ? payload.issue : null;
|
|
18834
|
-
const number = readNumber(issue?.number);
|
|
19273
|
+
const number = readNumber$1(issue?.number);
|
|
18835
19274
|
if (number === null) return null;
|
|
18836
19275
|
return {
|
|
18837
19276
|
type: "issue",
|
|
18838
19277
|
key: `${repo}#${number}`,
|
|
18839
|
-
title: readString(issue?.title) ?? void 0,
|
|
18840
|
-
url: readString(issue?.html_url) ?? void 0
|
|
19278
|
+
title: readString$1(issue?.title) ?? void 0,
|
|
19279
|
+
url: readString$1(issue?.html_url) ?? void 0
|
|
18841
19280
|
};
|
|
18842
19281
|
}
|
|
18843
|
-
case "
|
|
18844
|
-
|
|
18845
|
-
|
|
18846
|
-
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
18847
|
-
const number = readNumber(pr?.number);
|
|
19282
|
+
case "issue_comment": {
|
|
19283
|
+
const issue = isRecord(payload.issue) ? payload.issue : null;
|
|
19284
|
+
const number = readNumber$1(issue?.number);
|
|
18848
19285
|
if (number === null) return null;
|
|
18849
|
-
|
|
19286
|
+
const prInfo = isRecord(issue?.pull_request) ? issue.pull_request : null;
|
|
19287
|
+
if (prInfo) return {
|
|
18850
19288
|
type: "pull_request",
|
|
18851
19289
|
key: `${repo}#${number}`,
|
|
18852
|
-
title: readString(
|
|
18853
|
-
url: readString(
|
|
19290
|
+
title: readString$1(issue?.title) ?? void 0,
|
|
19291
|
+
url: readString$1(prInfo.html_url) ?? readString$1(issue?.html_url) ?? void 0
|
|
18854
19292
|
};
|
|
18855
|
-
|
|
19293
|
+
return {
|
|
19294
|
+
type: "issue",
|
|
19295
|
+
key: `${repo}#${number}`,
|
|
19296
|
+
title: readString$1(issue?.title) ?? void 0,
|
|
19297
|
+
url: readString$1(issue?.html_url) ?? void 0
|
|
19298
|
+
};
|
|
19299
|
+
}
|
|
19300
|
+
case "pull_request":
|
|
19301
|
+
case "pull_request_review":
|
|
19302
|
+
case "pull_request_review_comment": {
|
|
19303
|
+
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
19304
|
+
const number = readNumber$1(pr?.number);
|
|
19305
|
+
if (number === null) return null;
|
|
19306
|
+
return {
|
|
19307
|
+
type: "pull_request",
|
|
19308
|
+
key: `${repo}#${number}`,
|
|
19309
|
+
title: readString$1(pr?.title) ?? void 0,
|
|
19310
|
+
url: readString$1(pr?.html_url) ?? void 0
|
|
19311
|
+
};
|
|
19312
|
+
}
|
|
18856
19313
|
case "discussion":
|
|
18857
19314
|
case "discussion_comment": {
|
|
18858
19315
|
const disc = isRecord(payload.discussion) ? payload.discussion : null;
|
|
18859
|
-
const number = readNumber(disc?.number);
|
|
19316
|
+
const number = readNumber$1(disc?.number);
|
|
18860
19317
|
if (number === null) return null;
|
|
18861
19318
|
return {
|
|
18862
19319
|
type: "discussion",
|
|
18863
19320
|
key: `${repo}#discussion-${number}`,
|
|
18864
|
-
title: readString(disc?.title) ?? void 0,
|
|
18865
|
-
url: readString(disc?.html_url) ?? void 0
|
|
19321
|
+
title: readString$1(disc?.title) ?? void 0,
|
|
19322
|
+
url: readString$1(disc?.html_url) ?? void 0
|
|
18866
19323
|
};
|
|
18867
19324
|
}
|
|
18868
19325
|
case "commit_comment": {
|
|
18869
19326
|
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
18870
|
-
const sha = readString(comment?.commit_id);
|
|
19327
|
+
const sha = readString$1(comment?.commit_id);
|
|
18871
19328
|
if (!sha) return null;
|
|
18872
19329
|
return {
|
|
18873
19330
|
type: "commit",
|
|
18874
19331
|
key: `${repo}@${sha}`,
|
|
18875
|
-
url: readString(comment?.html_url) ?? void 0
|
|
19332
|
+
url: readString$1(comment?.html_url) ?? void 0
|
|
18876
19333
|
};
|
|
18877
19334
|
}
|
|
18878
19335
|
default: return null;
|
|
@@ -18959,92 +19416,6 @@ function formatEntityTitle(entity, eventType, action) {
|
|
|
18959
19416
|
if (entity.title && entity.title.length > 0) return `${head}: ${entity.title}`;
|
|
18960
19417
|
return head;
|
|
18961
19418
|
}
|
|
18962
|
-
const SILENT_EVENT_TYPES = new Set([
|
|
18963
|
-
"workflow_run",
|
|
18964
|
-
"workflow_job",
|
|
18965
|
-
"check_run",
|
|
18966
|
-
"check_suite",
|
|
18967
|
-
"status",
|
|
18968
|
-
"push",
|
|
18969
|
-
"create",
|
|
18970
|
-
"delete",
|
|
18971
|
-
"fork",
|
|
18972
|
-
"watch",
|
|
18973
|
-
"release",
|
|
18974
|
-
"label",
|
|
18975
|
-
"label_created",
|
|
18976
|
-
"label_deleted",
|
|
18977
|
-
"reaction",
|
|
18978
|
-
"member",
|
|
18979
|
-
"membership",
|
|
18980
|
-
"team",
|
|
18981
|
-
"team_add",
|
|
18982
|
-
"organization",
|
|
18983
|
-
"org_block",
|
|
18984
|
-
"project",
|
|
18985
|
-
"project_card",
|
|
18986
|
-
"project_column"
|
|
18987
|
-
]);
|
|
18988
|
-
/**
|
|
18989
|
-
* Per-event-type action-level filters. Frequent low-signal actions that would
|
|
18990
|
-
* otherwise spam an entity chat. `synchronize` (PR branch push) is the most
|
|
18991
|
-
* common offender — it fires on every commit push to a PR branch and never
|
|
18992
|
-
* carries new conversation.
|
|
18993
|
-
*/
|
|
18994
|
-
const SILENT_ACTIONS = {
|
|
18995
|
-
issues: new Set([
|
|
18996
|
-
"labeled",
|
|
18997
|
-
"unlabeled",
|
|
18998
|
-
"milestoned",
|
|
18999
|
-
"demilestoned",
|
|
19000
|
-
"pinned",
|
|
19001
|
-
"unpinned"
|
|
19002
|
-
]),
|
|
19003
|
-
pull_request: new Set([
|
|
19004
|
-
"labeled",
|
|
19005
|
-
"unlabeled",
|
|
19006
|
-
"auto_merge_enabled",
|
|
19007
|
-
"auto_merge_disabled",
|
|
19008
|
-
"synchronize"
|
|
19009
|
-
])
|
|
19010
|
-
};
|
|
19011
|
-
/** True iff the event should be silently 200-OKed without further routing. */
|
|
19012
|
-
function shouldSilent(eventType, payload) {
|
|
19013
|
-
if (SILENT_EVENT_TYPES.has(eventType)) return true;
|
|
19014
|
-
if (!isRecord(payload)) return false;
|
|
19015
|
-
if (readString((isRecord(payload.sender) ? payload.sender : null)?.type) === "Bot") return true;
|
|
19016
|
-
const action = readString(payload.action);
|
|
19017
|
-
if (!action) return false;
|
|
19018
|
-
return SILENT_ACTIONS[eventType]?.has(action) ?? false;
|
|
19019
|
-
}
|
|
19020
|
-
/**
|
|
19021
|
-
* GitHub-specific webhook entity → chat clustering (Phase 0).
|
|
19022
|
-
*
|
|
19023
|
-
* Each `(organization, human_agent, delegate_agent, entity)` tuple resolves to
|
|
19024
|
-
* exactly one chat. Future external sources (Linear, Slack, …) get their own
|
|
19025
|
-
* tables — their entity models differ enough that a generic table would slip
|
|
19026
|
-
* back into untyped jsonb.
|
|
19027
|
-
*
|
|
19028
|
-
* `bound_via` distinguishes the first-touch row (`direct`) from a row written
|
|
19029
|
-
* by the `Fixes #N` linker (`fixes_link`). Routing logic ignores the
|
|
19030
|
-
* distinction; it exists for audit and future strategy tweaks.
|
|
19031
|
-
*/
|
|
19032
|
-
const githubEntityChatMappings = pgTable("github_entity_chat_mappings", {
|
|
19033
|
-
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
19034
|
-
humanAgentId: text("human_agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
19035
|
-
delegateAgentId: text("delegate_agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
19036
|
-
entityType: text("entity_type").notNull(),
|
|
19037
|
-
entityKey: text("entity_key").notNull(),
|
|
19038
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
19039
|
-
boundAt: timestamp("bound_at", { withTimezone: true }).notNull().defaultNow(),
|
|
19040
|
-
boundVia: text("bound_via").notNull()
|
|
19041
|
-
}, (table) => [primaryKey({ columns: [
|
|
19042
|
-
table.organizationId,
|
|
19043
|
-
table.humanAgentId,
|
|
19044
|
-
table.delegateAgentId,
|
|
19045
|
-
table.entityType,
|
|
19046
|
-
table.entityKey
|
|
19047
|
-
] }), index("idx_github_entity_chat_mappings_chat").on(table.chatId)]);
|
|
19048
19419
|
/**
|
|
19049
19420
|
* Resolve which chat a GitHub event for (human, delegate, entity) belongs to.
|
|
19050
19421
|
*
|
|
@@ -19162,160 +19533,693 @@ async function createEntityChat(db, humanAgentId, delegateAgentId, entity, event
|
|
|
19162
19533
|
metadata
|
|
19163
19534
|
})).id };
|
|
19164
19535
|
}
|
|
19165
|
-
const log$
|
|
19166
|
-
|
|
19167
|
-
|
|
19168
|
-
|
|
19169
|
-
|
|
19170
|
-
|
|
19536
|
+
const log$2 = createLogger$1("GithubDelivery");
|
|
19537
|
+
/**
|
|
19538
|
+
* Stage 3 — actually emit one card per audience target.
|
|
19539
|
+
*
|
|
19540
|
+
* `existing` targets carry their chatId and short-circuit straight to the
|
|
19541
|
+
* send. `new` targets go through `resolveTargetChat` which performs the
|
|
19542
|
+
* §4.4 direct / fixes_link / fresh-chat lookup and writes the mapping row.
|
|
19543
|
+
* Each target's row is delivered + dispatched independently so a single
|
|
19544
|
+
* failure (e.g. cross-org rejection on chat creation) doesn't poison the
|
|
19545
|
+
* whole audience — the loop logs and continues.
|
|
19546
|
+
*
|
|
19547
|
+
* The card `reason` is `subscribed` for existing rows and the new target's
|
|
19548
|
+
* `involveReason` for involved rows; the surface event payload is the same
|
|
19549
|
+
* either way.
|
|
19550
|
+
*/
|
|
19551
|
+
async function deliverNormalizedEvent(app, event, audience) {
|
|
19552
|
+
const stats = {
|
|
19553
|
+
delivered: 0,
|
|
19554
|
+
newChats: 0
|
|
19555
|
+
};
|
|
19556
|
+
for (const target of audience) try {
|
|
19557
|
+
const resolved = await resolveChatFor(app, event, target);
|
|
19558
|
+
if (resolved.created) stats.newChats += 1;
|
|
19559
|
+
const card = buildCard(event, target);
|
|
19560
|
+
const mentionedUser = card.mentionedUser ?? void 0;
|
|
19561
|
+
const { message, recipients } = await sendMessage(app.db, resolved.chatId, target.humanAgentId, {
|
|
19562
|
+
format: "card",
|
|
19563
|
+
content: card,
|
|
19564
|
+
metadata: {
|
|
19565
|
+
source: "github",
|
|
19566
|
+
event: event.rawEventType,
|
|
19567
|
+
action: event.rawAction,
|
|
19568
|
+
entityType: event.entity.type,
|
|
19569
|
+
entityKey: event.entity.key,
|
|
19570
|
+
reason: card.reason,
|
|
19571
|
+
...mentionedUser ? { mentionedUser } : {}
|
|
19572
|
+
}
|
|
19573
|
+
});
|
|
19574
|
+
notifyRecipients(app.notifier, recipients, message.id);
|
|
19575
|
+
stats.delivered += 1;
|
|
19576
|
+
} catch (err) {
|
|
19577
|
+
log$2.error({
|
|
19578
|
+
err,
|
|
19579
|
+
humanAgent: target.humanAgentId,
|
|
19580
|
+
delegateAgent: target.delegateAgentId,
|
|
19581
|
+
entityType: event.entity.type,
|
|
19582
|
+
entityKey: event.entity.key
|
|
19583
|
+
}, "failed to deliver normalized github event to target");
|
|
19584
|
+
}
|
|
19585
|
+
return stats;
|
|
19586
|
+
}
|
|
19587
|
+
async function resolveChatFor(app, event, target) {
|
|
19588
|
+
if (target.kind === "existing") {
|
|
19589
|
+
if (!target.chatId) throw new Error("audience target kind=existing must carry chatId");
|
|
19590
|
+
return {
|
|
19591
|
+
chatId: target.chatId,
|
|
19592
|
+
created: false
|
|
19593
|
+
};
|
|
19594
|
+
}
|
|
19595
|
+
const entity = {
|
|
19596
|
+
type: event.entity.type,
|
|
19597
|
+
key: event.entity.key,
|
|
19598
|
+
title: event.entity.title,
|
|
19599
|
+
url: event.entity.url
|
|
19600
|
+
};
|
|
19601
|
+
const relatedEntities = event.relatedRefs.map((ref) => ({
|
|
19602
|
+
type: "issue",
|
|
19603
|
+
key: ref.key
|
|
19604
|
+
}));
|
|
19605
|
+
const resolved = await resolveTargetChat(app.db, {
|
|
19606
|
+
organizationId: event.source.organizationId,
|
|
19607
|
+
humanAgentId: target.humanAgentId,
|
|
19608
|
+
delegateAgentId: target.delegateAgentId,
|
|
19609
|
+
entity,
|
|
19610
|
+
relatedEntities,
|
|
19611
|
+
eventType: event.rawEventType,
|
|
19612
|
+
action: event.rawAction ?? ""
|
|
19613
|
+
});
|
|
19614
|
+
return {
|
|
19615
|
+
chatId: resolved.chatId,
|
|
19616
|
+
created: resolved.created
|
|
19617
|
+
};
|
|
19618
|
+
}
|
|
19619
|
+
function buildCard(event, target) {
|
|
19620
|
+
const card = {
|
|
19621
|
+
type: "github_event",
|
|
19622
|
+
reason: target.kind === "existing" ? "subscribed" : target.involveReason ?? "mentioned",
|
|
19623
|
+
event: event.rawEventType,
|
|
19624
|
+
action: event.rawAction,
|
|
19625
|
+
kind: event.kind,
|
|
19626
|
+
repository: event.entity.repo,
|
|
19627
|
+
sender: event.actor.githubLogin,
|
|
19628
|
+
title: event.surface.title,
|
|
19629
|
+
body: event.surface.body,
|
|
19630
|
+
url: event.surface.url,
|
|
19631
|
+
entity: {
|
|
19632
|
+
type: event.entity.type,
|
|
19633
|
+
key: event.entity.key,
|
|
19634
|
+
url: event.entity.url ?? null
|
|
19635
|
+
}
|
|
19636
|
+
};
|
|
19637
|
+
if (target.involveLogin) card.mentionedUser = target.involveLogin;
|
|
19638
|
+
return card;
|
|
19171
19639
|
}
|
|
19172
|
-
|
|
19173
|
-
|
|
19640
|
+
const MENTION_REGEX$1 = /(?<!\w)@([a-zA-Z0-9][\w-]*)(\/)?/g;
|
|
19641
|
+
/** Lower-cased unique @mention logins from free-form text. Skips team
|
|
19642
|
+
* mentions (`@org/team`) to match the agent-name lookup downstream
|
|
19643
|
+
* (`agents.name` doesn't carry slashes). */
|
|
19174
19644
|
function extractMentions$1(text) {
|
|
19175
19645
|
if (!text) return [];
|
|
19176
|
-
const re = /(?<!\w)@([a-zA-Z0-9][\w-]*)(\/)?/g;
|
|
19177
19646
|
const names = /* @__PURE__ */ new Set();
|
|
19178
|
-
for (const m of text.matchAll(
|
|
19647
|
+
for (const m of text.matchAll(MENTION_REGEX$1)) {
|
|
19179
19648
|
if (m[2]) continue;
|
|
19180
|
-
|
|
19649
|
+
const login = m[1];
|
|
19650
|
+
if (!login) continue;
|
|
19651
|
+
names.add(login.toLowerCase());
|
|
19181
19652
|
}
|
|
19182
19653
|
return [...names];
|
|
19183
19654
|
}
|
|
19184
|
-
|
|
19185
|
-
|
|
19186
|
-
|
|
19187
|
-
|
|
19188
|
-
|
|
19189
|
-
|
|
19190
|
-
|
|
19191
|
-
if (
|
|
19192
|
-
|
|
19193
|
-
const
|
|
19194
|
-
|
|
19195
|
-
|
|
19196
|
-
}
|
|
19197
|
-
|
|
19198
|
-
|
|
19199
|
-
|
|
19200
|
-
|
|
19201
|
-
|
|
19202
|
-
|
|
19203
|
-
|
|
19204
|
-
|
|
19205
|
-
|
|
19206
|
-
|
|
19207
|
-
|
|
19655
|
+
function readString(value) {
|
|
19656
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
19657
|
+
}
|
|
19658
|
+
function readNumber(value) {
|
|
19659
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
19660
|
+
}
|
|
19661
|
+
function readStringArray(value) {
|
|
19662
|
+
if (!Array.isArray(value)) return [];
|
|
19663
|
+
const out = [];
|
|
19664
|
+
for (const item of value) if (isRecord(item)) {
|
|
19665
|
+
const login = readString(item.login);
|
|
19666
|
+
if (login) out.push(login.toLowerCase());
|
|
19667
|
+
}
|
|
19668
|
+
return out;
|
|
19669
|
+
}
|
|
19670
|
+
function buildInvolves(items) {
|
|
19671
|
+
const seen = /* @__PURE__ */ new Set();
|
|
19672
|
+
const out = [];
|
|
19673
|
+
for (const group of items) for (const login of group.logins) {
|
|
19674
|
+
const key = login.toLowerCase();
|
|
19675
|
+
if (seen.has(key)) continue;
|
|
19676
|
+
seen.add(key);
|
|
19677
|
+
out.push({
|
|
19678
|
+
githubLogin: key,
|
|
19679
|
+
reason: group.reason
|
|
19680
|
+
});
|
|
19681
|
+
}
|
|
19682
|
+
return out;
|
|
19683
|
+
}
|
|
19684
|
+
function entitySurfacePrefix(entity) {
|
|
19685
|
+
switch (entity.type) {
|
|
19686
|
+
case "pull_request": return "PR";
|
|
19687
|
+
case "issue": return "Issue";
|
|
19688
|
+
case "discussion": return "Discussion";
|
|
19689
|
+
case "commit": return "Commit";
|
|
19690
|
+
}
|
|
19691
|
+
}
|
|
19692
|
+
function entitySurfaceTitle(entity, number) {
|
|
19693
|
+
const prefix = entitySurfacePrefix(entity);
|
|
19694
|
+
const head = number !== null ? `${prefix} #${number}` : prefix;
|
|
19695
|
+
return entity.title ? `${head}: ${entity.title}` : head;
|
|
19208
19696
|
}
|
|
19209
19697
|
/**
|
|
19210
|
-
*
|
|
19211
|
-
*
|
|
19212
|
-
*
|
|
19213
|
-
* configured, resolve which chat the event belongs to (via §4.4's
|
|
19214
|
-
* entity-clustering rules) and post a card from the human-bound agent to its
|
|
19215
|
-
* delegate.
|
|
19698
|
+
* Stage 1 — pure normalization. Returns the structured event for downstream
|
|
19699
|
+
* audience + delivery, or `null` for events we deliberately drop (silent /
|
|
19700
|
+
* out-of-scope event types and actions, malformed payloads, …).
|
|
19216
19701
|
*
|
|
19217
|
-
*
|
|
19218
|
-
*
|
|
19219
|
-
*
|
|
19220
|
-
* mention.
|
|
19702
|
+
* Pure function: no DB, no chat, no network. Caller is expected to hand the
|
|
19703
|
+
* raw payload, the wire event type from `x-github-event`, and the source +
|
|
19704
|
+
* deliveryId already resolved by the route handler.
|
|
19221
19705
|
*/
|
|
19222
|
-
|
|
19223
|
-
if (
|
|
19224
|
-
const
|
|
19225
|
-
|
|
19226
|
-
|
|
19227
|
-
|
|
19228
|
-
|
|
19229
|
-
|
|
19230
|
-
|
|
19231
|
-
|
|
19232
|
-
|
|
19233
|
-
|
|
19234
|
-
|
|
19235
|
-
|
|
19236
|
-
|
|
19237
|
-
|
|
19238
|
-
|
|
19239
|
-
|
|
19240
|
-
|
|
19241
|
-
|
|
19242
|
-
|
|
19243
|
-
|
|
19244
|
-
|
|
19245
|
-
|
|
19246
|
-
|
|
19247
|
-
|
|
19248
|
-
|
|
19706
|
+
function normalizeGithubEvent(eventType, payload, source, deliveryId) {
|
|
19707
|
+
if (!isRecord(payload)) return null;
|
|
19708
|
+
const senderRec = isRecord(payload.sender) ? payload.sender : null;
|
|
19709
|
+
const senderLogin = readString(senderRec?.login);
|
|
19710
|
+
if (!senderLogin) return null;
|
|
19711
|
+
const senderIsBot = readString(senderRec?.type) === "Bot";
|
|
19712
|
+
const repo = readString((isRecord(payload.repository) ? payload.repository : null)?.full_name);
|
|
19713
|
+
if (!repo) return null;
|
|
19714
|
+
const action = readString(payload.action);
|
|
19715
|
+
const rule = buildRule(eventType, action, payload, repo);
|
|
19716
|
+
if (!rule) return null;
|
|
19717
|
+
return {
|
|
19718
|
+
source,
|
|
19719
|
+
deliveryId,
|
|
19720
|
+
rawEventType: eventType,
|
|
19721
|
+
rawAction: action,
|
|
19722
|
+
entity: {
|
|
19723
|
+
type: rule.entity.type,
|
|
19724
|
+
repo,
|
|
19725
|
+
key: rule.entity.key,
|
|
19726
|
+
title: rule.entity.title,
|
|
19727
|
+
url: rule.entity.url
|
|
19728
|
+
},
|
|
19729
|
+
actor: {
|
|
19730
|
+
githubLogin: senderLogin,
|
|
19731
|
+
isBot: senderIsBot
|
|
19732
|
+
},
|
|
19733
|
+
kind: rule.kind,
|
|
19734
|
+
involves: rule.involves,
|
|
19735
|
+
surface: rule.surface,
|
|
19736
|
+
relatedRefs: rule.relatedRefs
|
|
19737
|
+
};
|
|
19738
|
+
}
|
|
19739
|
+
function buildRule(eventType, action, payload, repo) {
|
|
19740
|
+
switch (eventType) {
|
|
19741
|
+
case "pull_request": return buildPullRequestRule(action, payload, repo);
|
|
19742
|
+
case "pull_request_review": return buildPullRequestReviewRule(action, payload);
|
|
19743
|
+
case "pull_request_review_comment": return buildPullRequestReviewCommentRule(action, payload);
|
|
19744
|
+
case "issue_comment": return buildIssueCommentRule(action, payload);
|
|
19745
|
+
case "issues": return buildIssuesRule(action, payload);
|
|
19746
|
+
case "discussion": return buildDiscussionRule(action, payload);
|
|
19747
|
+
case "discussion_comment": return buildDiscussionCommentRule(action, payload);
|
|
19748
|
+
case "commit_comment": return buildCommitCommentRule(action, payload);
|
|
19749
|
+
default: return null;
|
|
19750
|
+
}
|
|
19751
|
+
}
|
|
19752
|
+
function buildPullRequestRule(action, payload, repo) {
|
|
19753
|
+
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
19754
|
+
if (!pr) return null;
|
|
19755
|
+
const entity = extractEventEntity("pull_request", payload);
|
|
19756
|
+
if (!entity) return null;
|
|
19757
|
+
const number = readNumber(pr.number);
|
|
19758
|
+
const body = readString(pr.body) ?? "";
|
|
19759
|
+
const surface = {
|
|
19760
|
+
title: entitySurfaceTitle(entity, number),
|
|
19761
|
+
body,
|
|
19762
|
+
url: readString(pr.html_url) ?? ""
|
|
19763
|
+
};
|
|
19764
|
+
switch (action) {
|
|
19765
|
+
case "opened": {
|
|
19766
|
+
const assigneeLogins = readStringArray(pr.assignees);
|
|
19767
|
+
const mentionLogins = extractMentions$1(body);
|
|
19768
|
+
return {
|
|
19769
|
+
entity,
|
|
19770
|
+
kind: "opened",
|
|
19771
|
+
involves: buildInvolves([{
|
|
19772
|
+
logins: assigneeLogins,
|
|
19773
|
+
reason: "assigned"
|
|
19774
|
+
}, {
|
|
19775
|
+
logins: mentionLogins,
|
|
19776
|
+
reason: "mentioned"
|
|
19777
|
+
}]),
|
|
19778
|
+
surface,
|
|
19779
|
+
relatedRefs: parseFixesRefs(body, repo).map((ref) => ({
|
|
19780
|
+
type: "issue",
|
|
19781
|
+
key: ref.key
|
|
19782
|
+
}))
|
|
19783
|
+
};
|
|
19249
19784
|
}
|
|
19250
|
-
|
|
19251
|
-
|
|
19252
|
-
|
|
19253
|
-
|
|
19254
|
-
|
|
19785
|
+
case "edited": return {
|
|
19786
|
+
entity,
|
|
19787
|
+
kind: "edited",
|
|
19788
|
+
involves: buildInvolves([{
|
|
19789
|
+
logins: extractMentions$1(body),
|
|
19790
|
+
reason: "mentioned"
|
|
19791
|
+
}]),
|
|
19792
|
+
surface,
|
|
19793
|
+
relatedRefs: []
|
|
19794
|
+
};
|
|
19795
|
+
case "review_requested": {
|
|
19796
|
+
const reviewerLogin = readString((isRecord(payload.requested_reviewer) ? payload.requested_reviewer : null)?.login);
|
|
19797
|
+
return {
|
|
19255
19798
|
entity,
|
|
19256
|
-
|
|
19257
|
-
|
|
19258
|
-
|
|
19259
|
-
|
|
19260
|
-
|
|
19261
|
-
|
|
19262
|
-
|
|
19263
|
-
|
|
19264
|
-
|
|
19265
|
-
|
|
19266
|
-
|
|
19267
|
-
|
|
19268
|
-
|
|
19269
|
-
|
|
19270
|
-
|
|
19271
|
-
|
|
19272
|
-
|
|
19273
|
-
|
|
19274
|
-
|
|
19275
|
-
|
|
19276
|
-
|
|
19277
|
-
|
|
19278
|
-
|
|
19279
|
-
|
|
19280
|
-
|
|
19281
|
-
|
|
19282
|
-
|
|
19283
|
-
|
|
19284
|
-
|
|
19799
|
+
kind: "review_requested",
|
|
19800
|
+
involves: buildInvolves([{
|
|
19801
|
+
logins: reviewerLogin ? [reviewerLogin.toLowerCase()] : [],
|
|
19802
|
+
reason: "review_requested"
|
|
19803
|
+
}]),
|
|
19804
|
+
surface,
|
|
19805
|
+
relatedRefs: []
|
|
19806
|
+
};
|
|
19807
|
+
}
|
|
19808
|
+
case "ready_for_review": {
|
|
19809
|
+
const reviewerLogins = readStringArray(pr.requested_reviewers);
|
|
19810
|
+
if (reviewerLogins.length === 0) return null;
|
|
19811
|
+
return {
|
|
19812
|
+
entity,
|
|
19813
|
+
kind: "review_requested",
|
|
19814
|
+
involves: buildInvolves([{
|
|
19815
|
+
logins: reviewerLogins,
|
|
19816
|
+
reason: "review_requested"
|
|
19817
|
+
}]),
|
|
19818
|
+
surface,
|
|
19819
|
+
relatedRefs: []
|
|
19820
|
+
};
|
|
19821
|
+
}
|
|
19822
|
+
case "assigned": {
|
|
19823
|
+
const assigneeLogin = readString((isRecord(payload.assignee) ? payload.assignee : null)?.login);
|
|
19824
|
+
if (!assigneeLogin) return null;
|
|
19825
|
+
return {
|
|
19826
|
+
entity,
|
|
19827
|
+
kind: "assigned",
|
|
19828
|
+
involves: buildInvolves([{
|
|
19829
|
+
logins: [assigneeLogin.toLowerCase()],
|
|
19830
|
+
reason: "assigned"
|
|
19831
|
+
}]),
|
|
19832
|
+
surface,
|
|
19833
|
+
relatedRefs: []
|
|
19834
|
+
};
|
|
19835
|
+
}
|
|
19836
|
+
case "synchronize": return {
|
|
19837
|
+
entity,
|
|
19838
|
+
kind: "synchronized",
|
|
19839
|
+
involves: [],
|
|
19840
|
+
surface,
|
|
19841
|
+
relatedRefs: []
|
|
19842
|
+
};
|
|
19843
|
+
default: return null;
|
|
19844
|
+
}
|
|
19845
|
+
}
|
|
19846
|
+
function buildPullRequestReviewRule(action, payload) {
|
|
19847
|
+
if (action !== "submitted" && action !== "dismissed" && action !== "edited") return null;
|
|
19848
|
+
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
19849
|
+
const review = isRecord(payload.review) ? payload.review : null;
|
|
19850
|
+
if (!pr || !review) return null;
|
|
19851
|
+
const entity = extractEventEntity("pull_request_review", payload);
|
|
19852
|
+
if (!entity) return null;
|
|
19853
|
+
const number = readNumber(pr.number);
|
|
19854
|
+
const body = readString(review.body) ?? "";
|
|
19855
|
+
return {
|
|
19856
|
+
entity,
|
|
19857
|
+
kind: "reviewed",
|
|
19858
|
+
involves: buildInvolves([{
|
|
19859
|
+
logins: extractMentions$1(body),
|
|
19860
|
+
reason: "mentioned"
|
|
19861
|
+
}]),
|
|
19862
|
+
surface: {
|
|
19863
|
+
title: entitySurfaceTitle(entity, number),
|
|
19864
|
+
body,
|
|
19865
|
+
url: readString(review.html_url) ?? ""
|
|
19866
|
+
},
|
|
19867
|
+
relatedRefs: []
|
|
19868
|
+
};
|
|
19869
|
+
}
|
|
19870
|
+
function buildPullRequestReviewCommentRule(action, payload) {
|
|
19871
|
+
if (action !== "created" && action !== "edited") return null;
|
|
19872
|
+
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
19873
|
+
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
19874
|
+
if (!pr || !comment) return null;
|
|
19875
|
+
const entity = extractEventEntity("pull_request_review_comment", payload);
|
|
19876
|
+
if (!entity) return null;
|
|
19877
|
+
const number = readNumber(pr.number);
|
|
19878
|
+
const body = readString(comment.body) ?? "";
|
|
19879
|
+
return {
|
|
19880
|
+
entity,
|
|
19881
|
+
kind: "review_comment",
|
|
19882
|
+
involves: buildInvolves([{
|
|
19883
|
+
logins: extractMentions$1(body),
|
|
19884
|
+
reason: "mentioned"
|
|
19885
|
+
}]),
|
|
19886
|
+
surface: {
|
|
19887
|
+
title: entitySurfaceTitle(entity, number),
|
|
19888
|
+
body,
|
|
19889
|
+
url: readString(comment.html_url) ?? ""
|
|
19890
|
+
},
|
|
19891
|
+
relatedRefs: []
|
|
19892
|
+
};
|
|
19893
|
+
}
|
|
19894
|
+
function buildIssueCommentRule(action, payload) {
|
|
19895
|
+
if (action !== "created" && action !== "edited") return null;
|
|
19896
|
+
const issue = isRecord(payload.issue) ? payload.issue : null;
|
|
19897
|
+
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
19898
|
+
if (!issue || !comment) return null;
|
|
19899
|
+
const entity = extractEventEntity("issue_comment", payload);
|
|
19900
|
+
if (!entity) return null;
|
|
19901
|
+
const number = readNumber(issue.number);
|
|
19902
|
+
const body = readString(comment.body) ?? "";
|
|
19903
|
+
return {
|
|
19904
|
+
entity,
|
|
19905
|
+
kind: "commented",
|
|
19906
|
+
involves: buildInvolves([{
|
|
19907
|
+
logins: extractMentions$1(body),
|
|
19908
|
+
reason: "mentioned"
|
|
19909
|
+
}]),
|
|
19910
|
+
surface: {
|
|
19911
|
+
title: entitySurfaceTitle(entity, number),
|
|
19912
|
+
body,
|
|
19913
|
+
url: readString(comment.html_url) ?? ""
|
|
19914
|
+
},
|
|
19915
|
+
relatedRefs: []
|
|
19916
|
+
};
|
|
19917
|
+
}
|
|
19918
|
+
function buildIssuesRule(action, payload) {
|
|
19919
|
+
const issue = isRecord(payload.issue) ? payload.issue : null;
|
|
19920
|
+
if (!issue) return null;
|
|
19921
|
+
const entity = extractEventEntity("issues", payload);
|
|
19922
|
+
if (!entity) return null;
|
|
19923
|
+
const number = readNumber(issue.number);
|
|
19924
|
+
const body = readString(issue.body) ?? "";
|
|
19925
|
+
const url = readString(issue.html_url) ?? "";
|
|
19926
|
+
const title = entitySurfaceTitle(entity, number);
|
|
19927
|
+
switch (action) {
|
|
19928
|
+
case "opened": {
|
|
19929
|
+
const assigneeLogins = readStringArray(issue.assignees);
|
|
19930
|
+
const mentionLogins = extractMentions$1(body);
|
|
19931
|
+
return {
|
|
19932
|
+
entity,
|
|
19933
|
+
kind: "opened",
|
|
19934
|
+
involves: buildInvolves([{
|
|
19935
|
+
logins: assigneeLogins,
|
|
19936
|
+
reason: "assigned"
|
|
19937
|
+
}, {
|
|
19938
|
+
logins: mentionLogins,
|
|
19939
|
+
reason: "mentioned"
|
|
19940
|
+
}]),
|
|
19941
|
+
surface: {
|
|
19942
|
+
title,
|
|
19943
|
+
body,
|
|
19944
|
+
url
|
|
19285
19945
|
},
|
|
19286
|
-
|
|
19287
|
-
|
|
19288
|
-
|
|
19289
|
-
|
|
19290
|
-
|
|
19291
|
-
|
|
19292
|
-
|
|
19293
|
-
|
|
19294
|
-
|
|
19295
|
-
|
|
19296
|
-
|
|
19297
|
-
|
|
19298
|
-
|
|
19299
|
-
|
|
19300
|
-
|
|
19301
|
-
|
|
19302
|
-
|
|
19946
|
+
relatedRefs: []
|
|
19947
|
+
};
|
|
19948
|
+
}
|
|
19949
|
+
case "edited": return {
|
|
19950
|
+
entity,
|
|
19951
|
+
kind: "edited",
|
|
19952
|
+
involves: buildInvolves([{
|
|
19953
|
+
logins: extractMentions$1(body),
|
|
19954
|
+
reason: "mentioned"
|
|
19955
|
+
}]),
|
|
19956
|
+
surface: {
|
|
19957
|
+
title,
|
|
19958
|
+
body,
|
|
19959
|
+
url
|
|
19960
|
+
},
|
|
19961
|
+
relatedRefs: []
|
|
19962
|
+
};
|
|
19963
|
+
case "assigned": {
|
|
19964
|
+
const login = readString((isRecord(payload.assignee) ? payload.assignee : null)?.login);
|
|
19965
|
+
if (!login) return null;
|
|
19966
|
+
return {
|
|
19967
|
+
entity,
|
|
19968
|
+
kind: "assigned",
|
|
19969
|
+
involves: buildInvolves([{
|
|
19970
|
+
logins: [login.toLowerCase()],
|
|
19971
|
+
reason: "assigned"
|
|
19972
|
+
}]),
|
|
19973
|
+
surface: {
|
|
19974
|
+
title,
|
|
19975
|
+
body,
|
|
19976
|
+
url
|
|
19977
|
+
},
|
|
19978
|
+
relatedRefs: []
|
|
19979
|
+
};
|
|
19303
19980
|
}
|
|
19981
|
+
case "closed": return {
|
|
19982
|
+
entity,
|
|
19983
|
+
kind: "closed",
|
|
19984
|
+
involves: [],
|
|
19985
|
+
surface: {
|
|
19986
|
+
title,
|
|
19987
|
+
body,
|
|
19988
|
+
url
|
|
19989
|
+
},
|
|
19990
|
+
relatedRefs: []
|
|
19991
|
+
};
|
|
19992
|
+
case "reopened": return {
|
|
19993
|
+
entity,
|
|
19994
|
+
kind: "reopened",
|
|
19995
|
+
involves: [],
|
|
19996
|
+
surface: {
|
|
19997
|
+
title,
|
|
19998
|
+
body,
|
|
19999
|
+
url
|
|
20000
|
+
},
|
|
20001
|
+
relatedRefs: []
|
|
20002
|
+
};
|
|
20003
|
+
default: return null;
|
|
20004
|
+
}
|
|
20005
|
+
}
|
|
20006
|
+
function buildDiscussionRule(action, payload) {
|
|
20007
|
+
const disc = isRecord(payload.discussion) ? payload.discussion : null;
|
|
20008
|
+
if (!disc) return null;
|
|
20009
|
+
const entity = extractEventEntity("discussion", payload);
|
|
20010
|
+
if (!entity) return null;
|
|
20011
|
+
const number = readNumber(disc.number);
|
|
20012
|
+
const body = readString(disc.body) ?? "";
|
|
20013
|
+
const url = readString(disc.html_url) ?? "";
|
|
20014
|
+
const title = entitySurfaceTitle(entity, number);
|
|
20015
|
+
switch (action) {
|
|
20016
|
+
case "created": return {
|
|
20017
|
+
entity,
|
|
20018
|
+
kind: "opened",
|
|
20019
|
+
involves: buildInvolves([{
|
|
20020
|
+
logins: extractMentions$1(body),
|
|
20021
|
+
reason: "mentioned"
|
|
20022
|
+
}]),
|
|
20023
|
+
surface: {
|
|
20024
|
+
title,
|
|
20025
|
+
body,
|
|
20026
|
+
url
|
|
20027
|
+
},
|
|
20028
|
+
relatedRefs: []
|
|
20029
|
+
};
|
|
20030
|
+
case "edited": return {
|
|
20031
|
+
entity,
|
|
20032
|
+
kind: "edited",
|
|
20033
|
+
involves: buildInvolves([{
|
|
20034
|
+
logins: extractMentions$1(body),
|
|
20035
|
+
reason: "mentioned"
|
|
20036
|
+
}]),
|
|
20037
|
+
surface: {
|
|
20038
|
+
title,
|
|
20039
|
+
body,
|
|
20040
|
+
url
|
|
20041
|
+
},
|
|
20042
|
+
relatedRefs: []
|
|
20043
|
+
};
|
|
20044
|
+
case "closed": return {
|
|
20045
|
+
entity,
|
|
20046
|
+
kind: "closed",
|
|
20047
|
+
involves: [],
|
|
20048
|
+
surface: {
|
|
20049
|
+
title,
|
|
20050
|
+
body,
|
|
20051
|
+
url
|
|
20052
|
+
},
|
|
20053
|
+
relatedRefs: []
|
|
20054
|
+
};
|
|
20055
|
+
case "reopened": return {
|
|
20056
|
+
entity,
|
|
20057
|
+
kind: "reopened",
|
|
20058
|
+
involves: [],
|
|
20059
|
+
surface: {
|
|
20060
|
+
title,
|
|
20061
|
+
body,
|
|
20062
|
+
url
|
|
20063
|
+
},
|
|
20064
|
+
relatedRefs: []
|
|
20065
|
+
};
|
|
20066
|
+
case "answered":
|
|
20067
|
+
case "unanswered": return {
|
|
20068
|
+
entity,
|
|
20069
|
+
kind: "other",
|
|
20070
|
+
involves: [],
|
|
20071
|
+
surface: {
|
|
20072
|
+
title,
|
|
20073
|
+
body,
|
|
20074
|
+
url
|
|
20075
|
+
},
|
|
20076
|
+
relatedRefs: []
|
|
20077
|
+
};
|
|
20078
|
+
default: return null;
|
|
19304
20079
|
}
|
|
19305
|
-
return routed;
|
|
19306
20080
|
}
|
|
19307
|
-
|
|
20081
|
+
function buildDiscussionCommentRule(action, payload) {
|
|
20082
|
+
if (action !== "created" && action !== "edited") return null;
|
|
20083
|
+
const disc = isRecord(payload.discussion) ? payload.discussion : null;
|
|
20084
|
+
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
20085
|
+
if (!disc || !comment) return null;
|
|
20086
|
+
const entity = extractEventEntity("discussion_comment", payload);
|
|
20087
|
+
if (!entity) return null;
|
|
20088
|
+
const number = readNumber(disc.number);
|
|
20089
|
+
const body = readString(comment.body) ?? "";
|
|
20090
|
+
return {
|
|
20091
|
+
entity,
|
|
20092
|
+
kind: "commented",
|
|
20093
|
+
involves: buildInvolves([{
|
|
20094
|
+
logins: extractMentions$1(body),
|
|
20095
|
+
reason: "mentioned"
|
|
20096
|
+
}]),
|
|
20097
|
+
surface: {
|
|
20098
|
+
title: entitySurfaceTitle(entity, number),
|
|
20099
|
+
body,
|
|
20100
|
+
url: readString(comment.html_url) ?? ""
|
|
20101
|
+
},
|
|
20102
|
+
relatedRefs: []
|
|
20103
|
+
};
|
|
20104
|
+
}
|
|
20105
|
+
function buildCommitCommentRule(action, payload) {
|
|
20106
|
+
if (action !== "created") return null;
|
|
20107
|
+
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
20108
|
+
if (!comment) return null;
|
|
20109
|
+
const entity = extractEventEntity("commit_comment", payload);
|
|
20110
|
+
if (!entity) return null;
|
|
20111
|
+
const body = readString(comment.body) ?? "";
|
|
20112
|
+
return {
|
|
20113
|
+
entity,
|
|
20114
|
+
kind: "commit_commented",
|
|
20115
|
+
involves: buildInvolves([{
|
|
20116
|
+
logins: extractMentions$1(body),
|
|
20117
|
+
reason: "mentioned"
|
|
20118
|
+
}]),
|
|
20119
|
+
surface: {
|
|
20120
|
+
title: entitySurfaceTitle(entity, null),
|
|
20121
|
+
body,
|
|
20122
|
+
url: readString(comment.html_url) ?? ""
|
|
20123
|
+
},
|
|
20124
|
+
relatedRefs: []
|
|
20125
|
+
};
|
|
20126
|
+
}
|
|
20127
|
+
const log$1 = createLogger$1("GithubAppWebhook");
|
|
20128
|
+
function verifySignature(secret, rawBody, signatureHeader) {
|
|
20129
|
+
const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
|
|
20130
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
20131
|
+
const receivedBuf = Buffer.from(signatureHeader, "utf8");
|
|
20132
|
+
if (expectedBuf.length !== receivedBuf.length || !timingSafeEqual(expectedBuf, receivedBuf)) throw new UnauthorizedError("Invalid webhook signature");
|
|
20133
|
+
}
|
|
20134
|
+
function readInstallationId(payload) {
|
|
20135
|
+
if (!isRecord(payload)) return null;
|
|
20136
|
+
const id = (isRecord(payload.installation) ? payload.installation : null)?.id;
|
|
20137
|
+
return typeof id === "number" && Number.isFinite(id) ? id : null;
|
|
20138
|
+
}
|
|
20139
|
+
function parseInstallationMetadata(installation) {
|
|
20140
|
+
const id = installation.id;
|
|
20141
|
+
if (typeof id !== "number" || !Number.isFinite(id)) return null;
|
|
20142
|
+
const account = isRecord(installation.account) ? installation.account : null;
|
|
20143
|
+
if (!account) return null;
|
|
20144
|
+
const accountId = account.id;
|
|
20145
|
+
const accountLogin = account.login;
|
|
20146
|
+
const accountType = account.type;
|
|
20147
|
+
if (typeof accountId !== "number" || typeof accountLogin !== "string") return null;
|
|
20148
|
+
if (accountType !== "User" && accountType !== "Organization") return null;
|
|
20149
|
+
const permissionsParsed = githubAppInstallationPermissionsSchema$1.safeParse(installation.permissions);
|
|
20150
|
+
return {
|
|
20151
|
+
id,
|
|
20152
|
+
accountType,
|
|
20153
|
+
accountLogin,
|
|
20154
|
+
accountGithubId: accountId,
|
|
20155
|
+
permissions: permissionsParsed.success ? permissionsParsed.data : {},
|
|
20156
|
+
events: Array.isArray(installation.events) ? installation.events.filter((e) => typeof e === "string") : [],
|
|
20157
|
+
suspendedAt: typeof installation.suspended_at === "string" ? installation.suspended_at : null
|
|
20158
|
+
};
|
|
20159
|
+
}
|
|
20160
|
+
async function handleInstallationLifecycle(app, eventType, payload) {
|
|
20161
|
+
if (!isRecord(payload)) return "ignored:malformed";
|
|
20162
|
+
if (eventType === "installation_repositories") return "noop";
|
|
20163
|
+
const action = typeof payload.action === "string" ? payload.action : null;
|
|
20164
|
+
const installation = isRecord(payload.installation) ? payload.installation : null;
|
|
20165
|
+
const installationId = installation && typeof installation.id === "number" ? installation.id : null;
|
|
20166
|
+
if (!installation || installationId === null) return "ignored:malformed";
|
|
20167
|
+
switch (action) {
|
|
20168
|
+
case "created":
|
|
20169
|
+
case "new_permissions_accepted": {
|
|
20170
|
+
const metadata = parseInstallationMetadata(installation);
|
|
20171
|
+
if (!metadata) return "ignored:malformed";
|
|
20172
|
+
await upsertInstallationFromMetadata(app.db, { installation: metadata });
|
|
20173
|
+
return action;
|
|
20174
|
+
}
|
|
20175
|
+
case "deleted":
|
|
20176
|
+
await deleteInstallationByGithubId(app.db, installationId);
|
|
20177
|
+
return "deleted";
|
|
20178
|
+
case "suspend": {
|
|
20179
|
+
const suspendedAtRaw = installation.suspended_at;
|
|
20180
|
+
const suspendedAt = typeof suspendedAtRaw === "string" ? new Date(suspendedAtRaw) : /* @__PURE__ */ new Date();
|
|
20181
|
+
await markInstallationSuspended(app.db, installationId, suspendedAt);
|
|
20182
|
+
return "suspended";
|
|
20183
|
+
}
|
|
20184
|
+
case "unsuspend":
|
|
20185
|
+
await markInstallationUnsuspended(app.db, installationId, /* @__PURE__ */ new Date());
|
|
20186
|
+
return "unsuspended";
|
|
20187
|
+
default: return "ignored:unknown-action";
|
|
20188
|
+
}
|
|
20189
|
+
}
|
|
20190
|
+
/**
|
|
20191
|
+
* GitHub App webhook ingestion — single SaaS-wide endpoint. Replaces the
|
|
20192
|
+
* legacy `/webhooks/github/:orgId` per-org endpoint. Wiring:
|
|
20193
|
+
*
|
|
20194
|
+
* 1. HMAC verify (server-level App webhook secret, NOT per-org)
|
|
20195
|
+
* 2. ping → 200 fast-path
|
|
20196
|
+
* 3. installation / installation_repositories → lifecycle handler, NOT
|
|
20197
|
+
* the normalize pipeline (these events shouldn't fan out as cards)
|
|
20198
|
+
* 4. other events → installation.id → hub_organization_id reverse-lookup,
|
|
20199
|
+
* then Stage 1 normalize → claimEvent → Stage 2 audience → Stage 3
|
|
20200
|
+
* deliver. unclaimEvent on handler failure so GitHub's retry has a
|
|
20201
|
+
* chance to clear.
|
|
20202
|
+
*
|
|
20203
|
+
* Routes return 200 for "ignored" cases (no installation context, not
|
|
20204
|
+
* bound, suspended, duplicate delivery) so GitHub doesn't accumulate
|
|
20205
|
+
* retries for events the operator can't act on.
|
|
20206
|
+
*/
|
|
20207
|
+
async function githubAppWebhookRoutes(app) {
|
|
19308
20208
|
app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_request, body, done) => {
|
|
19309
20209
|
done(null, body);
|
|
19310
20210
|
});
|
|
19311
|
-
const
|
|
19312
|
-
|
|
20211
|
+
const appConfig = app.config.oauth?.githubApp;
|
|
20212
|
+
if (!appConfig?.webhookSecret) {
|
|
20213
|
+
app.post("/github-app", async (_request, reply) => reply.status(501).send({ error: "GitHub App webhook is not configured for this Hub deployment." }));
|
|
20214
|
+
return;
|
|
20215
|
+
}
|
|
20216
|
+
const webhookSecret = appConfig.webhookSecret;
|
|
20217
|
+
const appSlug = appConfig.slug ?? null;
|
|
20218
|
+
const webhookMax = app.config.rateLimit?.webhookMax ?? 600;
|
|
20219
|
+
app.post("/github-app", { config: { rateLimit: {
|
|
19313
20220
|
max: webhookMax,
|
|
19314
20221
|
timeWindow: "1 minute"
|
|
19315
20222
|
} } }, async (request, reply) => {
|
|
19316
|
-
const { orgId } = request.params;
|
|
19317
|
-
const webhookSecret = await getDecryptedGithubWebhookSecret(app.db, orgId, app.config.secrets.encryptionKey);
|
|
19318
|
-
if (!webhookSecret) return reply.status(501).send({ error: "GitHub webhook is not configured for this organization. An admin must set the webhook secret in Team settings." });
|
|
19319
20223
|
const rawBody = request.body;
|
|
19320
20224
|
if (!Buffer.isBuffer(rawBody)) throw new BadRequestError("Expected raw body buffer");
|
|
19321
20225
|
const signatureHeader = request.headers["x-hub-signature-256"];
|
|
@@ -19333,19 +20237,73 @@ async function githubWebhookRoutes(app) {
|
|
|
19333
20237
|
ok: true,
|
|
19334
20238
|
event: "ping"
|
|
19335
20239
|
});
|
|
19336
|
-
if (
|
|
20240
|
+
if (eventType === "installation" || eventType === "installation_repositories") {
|
|
20241
|
+
const lifecycle = await handleInstallationLifecycle(app, eventType, payload);
|
|
20242
|
+
return reply.status(200).send({
|
|
20243
|
+
ok: true,
|
|
20244
|
+
event: eventType,
|
|
20245
|
+
lifecycle
|
|
20246
|
+
});
|
|
20247
|
+
}
|
|
20248
|
+
const installationId = readInstallationId(payload);
|
|
20249
|
+
if (installationId === null) return reply.status(200).send({
|
|
19337
20250
|
ok: true,
|
|
19338
20251
|
event: eventType,
|
|
19339
|
-
|
|
20252
|
+
ignored: "no installation context"
|
|
19340
20253
|
});
|
|
20254
|
+
const installation = await findInstallationByGithubId(app.db, installationId);
|
|
20255
|
+
if (!installation) {
|
|
20256
|
+
log$1.warn({
|
|
20257
|
+
installationId,
|
|
20258
|
+
eventType
|
|
20259
|
+
}, "installation not seen, skipping");
|
|
20260
|
+
return reply.status(200).send({
|
|
20261
|
+
ok: true,
|
|
20262
|
+
event: eventType,
|
|
20263
|
+
ignored: "installation not seen"
|
|
20264
|
+
});
|
|
20265
|
+
}
|
|
20266
|
+
if (!installation.hubOrganizationId) {
|
|
20267
|
+
log$1.warn({
|
|
20268
|
+
installationId,
|
|
20269
|
+
eventType
|
|
20270
|
+
}, "installation not bound to any hub org, skipping");
|
|
20271
|
+
return reply.status(200).send({
|
|
20272
|
+
ok: true,
|
|
20273
|
+
event: eventType,
|
|
20274
|
+
ignored: "installation not bound"
|
|
20275
|
+
});
|
|
20276
|
+
}
|
|
20277
|
+
if (installation.suspendedAt !== null) return reply.status(200).send({
|
|
20278
|
+
ok: true,
|
|
20279
|
+
event: eventType,
|
|
20280
|
+
ignored: "suspended"
|
|
20281
|
+
});
|
|
20282
|
+
const source = {
|
|
20283
|
+
kind: "github-app-installation",
|
|
20284
|
+
installationId,
|
|
20285
|
+
organizationId: installation.hubOrganizationId
|
|
20286
|
+
};
|
|
19341
20287
|
const deliveryHeader = request.headers["x-github-delivery"];
|
|
19342
20288
|
const deliveryId = typeof deliveryHeader === "string" && deliveryHeader.length > 0 ? deliveryHeader : null;
|
|
20289
|
+
const event = normalizeGithubEvent(eventType, payload, source, deliveryId);
|
|
20290
|
+
if (!event) {
|
|
20291
|
+
log$1.debug({
|
|
20292
|
+
eventType,
|
|
20293
|
+
action: isRecord(payload) ? payload.action : null
|
|
20294
|
+
}, "Stage 1 returned null");
|
|
20295
|
+
return reply.status(200).send({
|
|
20296
|
+
ok: true,
|
|
20297
|
+
event: eventType,
|
|
20298
|
+
handled: false
|
|
20299
|
+
});
|
|
20300
|
+
}
|
|
19343
20301
|
if (deliveryId) {
|
|
19344
20302
|
if (!await claimEvent(app.db, deliveryId, "github")) {
|
|
19345
20303
|
log$1.info({
|
|
19346
20304
|
deliveryId,
|
|
19347
20305
|
eventType
|
|
19348
|
-
}, "duplicate
|
|
20306
|
+
}, "duplicate delivery, skipping");
|
|
19349
20307
|
return reply.status(200).send({
|
|
19350
20308
|
ok: true,
|
|
19351
20309
|
event: eventType,
|
|
@@ -19354,217 +20312,36 @@ async function githubWebhookRoutes(app) {
|
|
|
19354
20312
|
}
|
|
19355
20313
|
}
|
|
19356
20314
|
try {
|
|
19357
|
-
const
|
|
19358
|
-
|
|
19359
|
-
|
|
19360
|
-
|
|
19361
|
-
|
|
19362
|
-
|
|
19363
|
-
|
|
19364
|
-
|
|
20315
|
+
const audience = await resolveAudience(app.db, event, appSlug);
|
|
20316
|
+
if (audience.length === 0) {
|
|
20317
|
+
log$1.info({
|
|
20318
|
+
entityType: event.entity.type,
|
|
20319
|
+
entityKey: event.entity.key,
|
|
20320
|
+
actor: event.actor.githubLogin
|
|
20321
|
+
}, "audience empty, skipping");
|
|
20322
|
+
return reply.status(200).send({
|
|
20323
|
+
ok: true,
|
|
20324
|
+
event: eventType,
|
|
20325
|
+
audience: 0
|
|
20326
|
+
});
|
|
20327
|
+
}
|
|
20328
|
+
const stats = await deliverNormalizedEvent(app, event, audience);
|
|
19365
20329
|
return reply.status(200).send({
|
|
19366
20330
|
ok: true,
|
|
19367
20331
|
event: eventType,
|
|
19368
|
-
|
|
20332
|
+
...stats
|
|
19369
20333
|
});
|
|
19370
20334
|
} catch (err) {
|
|
19371
20335
|
if (deliveryId) await unclaimEvent(app.db, deliveryId, "github").catch((unclaimErr) => {
|
|
19372
20336
|
log$1.error({
|
|
19373
20337
|
err: unclaimErr,
|
|
19374
20338
|
deliveryId
|
|
19375
|
-
}, "failed to unclaim
|
|
20339
|
+
}, "failed to unclaim delivery after handler error");
|
|
19376
20340
|
});
|
|
19377
20341
|
throw err;
|
|
19378
20342
|
}
|
|
19379
20343
|
});
|
|
19380
20344
|
}
|
|
19381
|
-
/** Extract text body from any GitHub webhook event for @mention scanning. */
|
|
19382
|
-
function extractEventText(eventType, payload) {
|
|
19383
|
-
if (!isRecord(payload)) return null;
|
|
19384
|
-
switch (eventType) {
|
|
19385
|
-
case "issues": {
|
|
19386
|
-
const issue = isRecord(payload.issue) ? payload.issue : null;
|
|
19387
|
-
return typeof issue?.body === "string" ? issue.body : null;
|
|
19388
|
-
}
|
|
19389
|
-
case "issue_comment":
|
|
19390
|
-
case "pull_request_review_comment":
|
|
19391
|
-
case "commit_comment":
|
|
19392
|
-
case "discussion_comment": {
|
|
19393
|
-
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
19394
|
-
return typeof comment?.body === "string" ? comment.body : null;
|
|
19395
|
-
}
|
|
19396
|
-
case "pull_request": {
|
|
19397
|
-
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
19398
|
-
return typeof pr?.body === "string" ? pr.body : null;
|
|
19399
|
-
}
|
|
19400
|
-
case "pull_request_review": {
|
|
19401
|
-
const review = isRecord(payload.review) ? payload.review : null;
|
|
19402
|
-
return typeof review?.body === "string" ? review.body : null;
|
|
19403
|
-
}
|
|
19404
|
-
case "discussion": {
|
|
19405
|
-
const disc = isRecord(payload.discussion) ? payload.discussion : null;
|
|
19406
|
-
return typeof disc?.body === "string" ? disc.body : null;
|
|
19407
|
-
}
|
|
19408
|
-
default: return null;
|
|
19409
|
-
}
|
|
19410
|
-
}
|
|
19411
|
-
/** Extract context info from any GitHub webhook event for delegation messages. */
|
|
19412
|
-
function extractEventContext(eventType, payload) {
|
|
19413
|
-
if (!isRecord(payload)) return null;
|
|
19414
|
-
const repo = isRecord(payload.repository) ? payload.repository : null;
|
|
19415
|
-
const sender = isRecord(payload.sender) ? payload.sender : null;
|
|
19416
|
-
const repository = typeof repo?.full_name === "string" ? repo.full_name : "";
|
|
19417
|
-
const senderLogin = typeof sender?.login === "string" ? sender.login : "";
|
|
19418
|
-
const action = typeof payload.action === "string" ? payload.action : void 0;
|
|
19419
|
-
switch (eventType) {
|
|
19420
|
-
case "issues": {
|
|
19421
|
-
const issue = isRecord(payload.issue) ? payload.issue : null;
|
|
19422
|
-
if (!issue) return null;
|
|
19423
|
-
return {
|
|
19424
|
-
event: "issues",
|
|
19425
|
-
action,
|
|
19426
|
-
repository,
|
|
19427
|
-
sender: senderLogin,
|
|
19428
|
-
title: `Issue #${issue.number}: ${issue.title}`,
|
|
19429
|
-
body: typeof issue.body === "string" ? issue.body : "",
|
|
19430
|
-
url: typeof issue.html_url === "string" ? issue.html_url : ""
|
|
19431
|
-
};
|
|
19432
|
-
}
|
|
19433
|
-
case "issue_comment": {
|
|
19434
|
-
const issue = isRecord(payload.issue) ? payload.issue : null;
|
|
19435
|
-
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
19436
|
-
if (!issue || !comment) return null;
|
|
19437
|
-
return {
|
|
19438
|
-
event: "issue_comment",
|
|
19439
|
-
action,
|
|
19440
|
-
repository,
|
|
19441
|
-
sender: senderLogin,
|
|
19442
|
-
title: `Issue #${issue.number}: ${issue.title}`,
|
|
19443
|
-
body: typeof comment.body === "string" ? comment.body : "",
|
|
19444
|
-
url: typeof comment.html_url === "string" ? comment.html_url : ""
|
|
19445
|
-
};
|
|
19446
|
-
}
|
|
19447
|
-
case "pull_request": {
|
|
19448
|
-
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
19449
|
-
if (!pr) return null;
|
|
19450
|
-
return {
|
|
19451
|
-
event: "pull_request",
|
|
19452
|
-
action,
|
|
19453
|
-
repository,
|
|
19454
|
-
sender: senderLogin,
|
|
19455
|
-
title: `PR #${pr.number}: ${pr.title}`,
|
|
19456
|
-
body: typeof pr.body === "string" ? pr.body : "",
|
|
19457
|
-
url: typeof pr.html_url === "string" ? pr.html_url : ""
|
|
19458
|
-
};
|
|
19459
|
-
}
|
|
19460
|
-
case "pull_request_review": {
|
|
19461
|
-
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
19462
|
-
const review = isRecord(payload.review) ? payload.review : null;
|
|
19463
|
-
if (!pr || !review) return null;
|
|
19464
|
-
return {
|
|
19465
|
-
event: "pull_request_review",
|
|
19466
|
-
action,
|
|
19467
|
-
repository,
|
|
19468
|
-
sender: senderLogin,
|
|
19469
|
-
title: `PR #${pr.number}: ${pr.title}`,
|
|
19470
|
-
body: typeof review.body === "string" ? review.body : "",
|
|
19471
|
-
url: typeof review.html_url === "string" ? review.html_url : ""
|
|
19472
|
-
};
|
|
19473
|
-
}
|
|
19474
|
-
case "pull_request_review_comment": {
|
|
19475
|
-
const pr = isRecord(payload.pull_request) ? payload.pull_request : null;
|
|
19476
|
-
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
19477
|
-
if (!pr || !comment) return null;
|
|
19478
|
-
return {
|
|
19479
|
-
event: "pull_request_review_comment",
|
|
19480
|
-
action,
|
|
19481
|
-
repository,
|
|
19482
|
-
sender: senderLogin,
|
|
19483
|
-
title: `PR #${pr.number}: ${pr.title}`,
|
|
19484
|
-
body: typeof comment.body === "string" ? comment.body : "",
|
|
19485
|
-
url: typeof comment.html_url === "string" ? comment.html_url : ""
|
|
19486
|
-
};
|
|
19487
|
-
}
|
|
19488
|
-
case "discussion": {
|
|
19489
|
-
const disc = isRecord(payload.discussion) ? payload.discussion : null;
|
|
19490
|
-
if (!disc) return null;
|
|
19491
|
-
return {
|
|
19492
|
-
event: "discussion",
|
|
19493
|
-
action,
|
|
19494
|
-
repository,
|
|
19495
|
-
sender: senderLogin,
|
|
19496
|
-
title: typeof disc.title === "string" ? disc.title : "",
|
|
19497
|
-
body: typeof disc.body === "string" ? disc.body : "",
|
|
19498
|
-
url: typeof disc.html_url === "string" ? disc.html_url : ""
|
|
19499
|
-
};
|
|
19500
|
-
}
|
|
19501
|
-
case "discussion_comment": {
|
|
19502
|
-
const disc = isRecord(payload.discussion) ? payload.discussion : null;
|
|
19503
|
-
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
19504
|
-
if (!disc || !comment) return null;
|
|
19505
|
-
return {
|
|
19506
|
-
event: "discussion_comment",
|
|
19507
|
-
action,
|
|
19508
|
-
repository,
|
|
19509
|
-
sender: senderLogin,
|
|
19510
|
-
title: typeof disc.title === "string" ? disc.title : "",
|
|
19511
|
-
body: typeof comment.body === "string" ? comment.body : "",
|
|
19512
|
-
url: typeof comment.html_url === "string" ? comment.html_url : ""
|
|
19513
|
-
};
|
|
19514
|
-
}
|
|
19515
|
-
case "commit_comment": {
|
|
19516
|
-
const comment = isRecord(payload.comment) ? payload.comment : null;
|
|
19517
|
-
if (!comment) return null;
|
|
19518
|
-
return {
|
|
19519
|
-
event: "commit_comment",
|
|
19520
|
-
action,
|
|
19521
|
-
repository,
|
|
19522
|
-
sender: senderLogin,
|
|
19523
|
-
title: "Commit comment",
|
|
19524
|
-
body: typeof comment.body === "string" ? comment.body : "",
|
|
19525
|
-
url: typeof comment.html_url === "string" ? comment.html_url : ""
|
|
19526
|
-
};
|
|
19527
|
-
}
|
|
19528
|
-
default: return null;
|
|
19529
|
-
}
|
|
19530
|
-
}
|
|
19531
|
-
/**
|
|
19532
|
-
* Run mention delegation for a given event type and payload.
|
|
19533
|
-
* Only called after action gating confirms this is a "new content" event.
|
|
19534
|
-
*/
|
|
19535
|
-
async function handleMentionDelegation(app, organizationId, eventType, payload) {
|
|
19536
|
-
const textMentions = extractMentions$1(extractEventText(eventType, payload));
|
|
19537
|
-
const structuralMentions = extractStructuralMentions(eventType, payload);
|
|
19538
|
-
const mentions = [...new Set([...textMentions, ...structuralMentions])];
|
|
19539
|
-
if (mentions.length === 0) return 0;
|
|
19540
|
-
const ctx = extractEventContext(eventType, payload);
|
|
19541
|
-
if (!ctx) return 0;
|
|
19542
|
-
const entity = extractEventEntity(eventType, payload);
|
|
19543
|
-
if (!entity) {
|
|
19544
|
-
log$1.warn({ eventType }, "mention extracted but no entity resolvable; skipping fan-out");
|
|
19545
|
-
return 0;
|
|
19546
|
-
}
|
|
19547
|
-
return routeMentionDelegations(app, organizationId, mentions, ctx, entity, eventType === "pull_request" && ctx.repository.length > 0 ? parseFixesRefs(ctx.body, ctx.repository) : []);
|
|
19548
|
-
}
|
|
19549
|
-
/** Actions that represent new/changed content (worth scanning for @mentions).
|
|
19550
|
-
* Note: `pull_request.review_requested` doesn't carry an @mention in any
|
|
19551
|
-
* text body — the reviewer is in `requested_reviewer.login`. We pick it up
|
|
19552
|
-
* via `extractStructuralMentions`. The complementary `review_request_removed`
|
|
19553
|
-
* is intentionally omitted to avoid notifying the reviewer twice. */
|
|
19554
|
-
const MENTION_ACTIONS = {
|
|
19555
|
-
issues: ["opened", "edited"],
|
|
19556
|
-
issue_comment: ["created"],
|
|
19557
|
-
pull_request: [
|
|
19558
|
-
"opened",
|
|
19559
|
-
"edited",
|
|
19560
|
-
"review_requested"
|
|
19561
|
-
],
|
|
19562
|
-
pull_request_review: ["submitted"],
|
|
19563
|
-
pull_request_review_comment: ["created"],
|
|
19564
|
-
discussion: ["created", "edited"],
|
|
19565
|
-
discussion_comment: ["created"],
|
|
19566
|
-
commit_comment: ["created"]
|
|
19567
|
-
};
|
|
19568
20345
|
/**
|
|
19569
20346
|
* Boot-time configuration sanity checks. Called from `buildApp` so BOTH
|
|
19570
20347
|
* server entry points are covered:
|
|
@@ -21019,6 +21796,12 @@ async function buildApp(config) {
|
|
|
21019
21796
|
...sslOptions(config.database.url)
|
|
21020
21797
|
});
|
|
21021
21798
|
const notifier = createNotifier(listenClient);
|
|
21799
|
+
registerCrossInstanceBroadcaster((payload) => {
|
|
21800
|
+
notifier.notifyAdminBroadcast(payload).catch(() => {});
|
|
21801
|
+
});
|
|
21802
|
+
notifier.onAdminBroadcast((payload) => {
|
|
21803
|
+
broadcastToAdmins(payload);
|
|
21804
|
+
});
|
|
21022
21805
|
await app.register(websocket, { options: { maxPayload: config.ws?.maxPayload ?? 65536 } });
|
|
21023
21806
|
const corsOrigin = config.cors?.origin;
|
|
21024
21807
|
const isDev = process.env.NODE_ENV !== "production";
|
|
@@ -21098,7 +21881,7 @@ async function buildApp(config) {
|
|
|
21098
21881
|
await app.register(healthzRoutes);
|
|
21099
21882
|
await app.register(namePlugin("apiV1Scope", async (api) => {
|
|
21100
21883
|
await api.register(healthRoutes);
|
|
21101
|
-
await api.register(
|
|
21884
|
+
await api.register(githubAppWebhookRoutes, { prefix: "/webhooks" });
|
|
21102
21885
|
await api.register(authRoutes, { prefix: "/auth" });
|
|
21103
21886
|
await api.register(githubOauthRoutes, { prefix: "/auth/github" });
|
|
21104
21887
|
await api.register(publicInvitationRoutes, { prefix: "/invitations" });
|
|
@@ -21540,7 +22323,7 @@ const declineUpdate = async () => false;
|
|
|
21540
22323
|
* relaunch picks up the new binary.
|
|
21541
22324
|
*
|
|
21542
22325
|
* `managed=false` means the process is running standalone (e.g. manual
|
|
21543
|
-
* `client start`, `
|
|
22326
|
+
* `client start`, `connect <token> --no-service`, CI without a supervisor).
|
|
21544
22327
|
* Exiting in that mode would leave the client offline until an operator
|
|
21545
22328
|
* noticed — so the callback instead prints a restart hint, returns
|
|
21546
22329
|
* `{ installed: true }`, and the UpdateManager stops retrying until the
|