@agent-team-foundation/first-tree-hub 0.14.1 → 0.14.3
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-C15ZBOCC.mjs → bootstrap-CQQGgIx1.mjs} +0 -7
- package/dist/cli/index.mjs +14 -39
- package/dist/{client-CzXmweS9-DhUiuQvL.mjs → client-BSfCc0pJ-BP_1f21y.mjs} +3095 -1291
- package/dist/{client-CREn8bJ0-C5fHJir6.mjs → client-q1EYQD1n-ypjoumIO.mjs} +3 -3
- package/dist/{dist-1XGLJMOq.mjs → dist-CwsiHGX7.mjs} +51 -19
- package/dist/{feishu-BGx71p5s.mjs → feishu-DHSy6WDD.mjs} +1 -1
- package/dist/index.mjs +7 -7
- package/dist/invitation-C9m2gQx4-BSErdb8x.mjs +4 -0
- package/dist/{invitation-DZO4NX3P-BPxTeHf-.mjs → invitation-D_ENPHyj-5ETiae5r.mjs} +3 -23
- package/dist/{saas-connect-BBRxjmBS.mjs → saas-connect-ChxZv2YQ.mjs} +621 -2078
- package/dist/{errors-LPcARA4K-Dbrptiyz.mjs → uuid-DbS_4vFh-iFghv4zA.mjs} +37 -2
- package/dist/web/assets/{index-DMqnX4IR.js → index-B76SVAz8.js} +1 -1
- package/dist/web/assets/index-DAAemCLz.js +416 -0
- package/dist/web/assets/index-DE7Q3QWE.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/invitation-CNv7gfFF-DOFZ75wb.mjs +0 -4
- package/dist/web/assets/index-BOK7e_td.css +0 -1
- package/dist/web/assets/index-QDcpYpEa.js +0 -416
|
@@ -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-CQQGgIx1.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 listMeChatSourceCountsQuerySchema, A as createMeChatSchema, At as updateOrganizationSchema, B as githubAppInstallationClaimBodySchema, C as clientRegisterSchema, Ct as submitQuestionAnswerSchema, D as createAdapterMappingSchema, Dt as updateChatSchema, E as createAdapterConfigSchema, Et as updateAgentSchema, F as delegateFeishuUserSchema, G as imageInlineContentSchema, H as githubCallbackQuerySchema, I as dryRunAgentRuntimeConfigSchema, J as inboxPollQuerySchema, K as inboxAckFrameSchema, M as createOrgFromMeSchema, O as createAgentSchema, Ot as updateClientCapabilitiesSchema,
|
|
6
|
-
import { a as
|
|
5
|
+
import { $ as listMeChatSourceCountsQuerySchema, A as createMeChatSchema, At as updateOrganizationSchema, B as githubAppInstallationClaimBodySchema, C as clientRegisterSchema, Ct as submitQuestionAnswerSchema, D as createAdapterMappingSchema, Dt as updateChatSchema, E as createAdapterConfigSchema, Et as updateAgentSchema, F as delegateFeishuUserSchema, G as imageInlineContentSchema, H as githubCallbackQuerySchema, I as dryRunAgentRuntimeConfigSchema, J as inboxPollQuerySchema, K as inboxAckFrameSchema, M as createOrgFromMeSchema, O as createAgentSchema, Ot as updateClientCapabilitiesSchema, Q as joinByInvitationSchema, R as getMeDocResponseSchema, T as contextTreeSnapshotSchema, Tt as updateAgentRuntimeConfigSchema, U as githubDevCallbackQuerySchema, V as githubAppInstallationPermissionsSchema$1, W as githubStartQuerySchema, X as isRedactedEnvValue, Y as isOrgSettingNamespace, _ as agentBindRequestSchema, _t as sendToAgentSchema, at as paginationQuerySchema, b as agentTypeSchema$1, bt as sessionReconcileRequestSchema, dt as refreshTokenSchema, et as listMeChatsQuerySchema, f as NOTIFICATION_TYPES, ft as runtimeStateMessageSchema, g as addParticipantSchema, gt as sendMessageSchema, h as addMeChatParticipantsSchema, ht as selfServiceFeishuBotSchema, i as AGENT_STATUSES, it as onboardingEventSchema, j as createMemberSchema, jt as wsAuthFrameSchema, k as createChatSchema, kt as updateMemberSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, o as AGENT_VISIBILITY, ot as patchChatEngagementSchema, p as ORG_SETTINGS_NAMESPACES$1, pt as safeRedirectPath, q as inboxDeliverFrameSchema$1, r as AGENT_SELECTOR_HEADER$1, rt as notificationQuerySchema, s as CHAT_ENGAGEMENT_STATUSES, st as patchOnboardingSchema, t as AGENT_BIND_REJECT_REASONS, tt as loginSchema, ut as rebindAgentSchema, v as agentPinnedMessageSchema$1, vt as sessionEventMessageSchema, w as connectTokenExchangeSchema, wt as updateAdapterConfigSchema, x as chatMetadataSchema$1, xt as sessionStateMessageSchema, y as agentRuntimeConfigPayloadSchema$1, yt as sessionEventSchema$1, z as getMeDocSchema } from "./dist-CwsiHGX7.mjs";
|
|
6
|
+
import { a as ClientUserMismatchError$1, c as NotFoundError, d as users, f as uuidv7, i as ClientOrgMismatchError$1, l as UnauthorizedError, n as AppError, o as ConflictError, r as BadRequestError, s as ForbiddenError, u as organizations } from "./uuid-DbS_4vFh-iFghv4zA.mjs";
|
|
7
7
|
import { n as init_esm, r as trace, t as esm_exports } from "./esm-iadMkGbV.mjs";
|
|
8
|
-
import { $ as
|
|
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
|
|
8
|
+
import { $ as heartbeatClient, $t as unbindAgent, A as createChat, At as pruneStaleSilentEntries, B as filterSessionsByParticipant, Bt as resolveDefaultOrgId$1, C as claimBacklogForPush, Ct as markMeChatUnread, D as clearAgentAvatarImage, Dt as notifyRecipients, E as cleanupStalePresence, Et as messages, F as disconnectClient, Ft as registerClient, G as getCachedAudience, Gt as setAgentAvatarImage, H as getActivityOverview, Ht as sendMessage, I as editMessage, It as removeParticipant, J as getClient, Jt as setRuntimeState, K as getCallerEngagement, Kt as setChatEngagement, L as ensureDefaultOrganization, Lt as resetActivity, M as createNotifier, Mt as rebindAgent, N as deleteAgent, Nt as recomputeWatchersForMember, O as clients, Ot as pendingQuestions, P as deriveAuthState, Pt as registerChatMessageDispatcher, Q as getSession, Qt as touchAgent, R as ensureParticipant, Rt as resetTimedOutEntries, S as claimAndBuildForPush, St as markMeChatRead, T as cleanupStaleClients, Tt as members, U as getAgent, Ut as sendToAgent$1, V as findOrCreateDirectChat, Vt as retireClient, W as getAgentAvatarImage, Wt as serverInstances, X as getOrganization, Xt as suspendAgent, Y as getOnlineCount, Yt as submitAnswer, Z as getPresence, Zt as suspendSession, _ as bindAgent, _t as listClientsForOrgAdmin, a as adapterConfigs, at as leaveMeChat, b as chats, bt as listMessages, c as addParticipant, ct as listAgentsForAdmin, d as agentConfigs, dt as listAgentsWithRuntime, en as updateAgent, et as heartbeatInstance, f as agentPresence, ft as listAllSessions, g as assertParticipant, gt as listClients, h as assertClientOwner, ht as listChatsForMember, i as adapterAgentMappings, it as leaveChat, j as createMeChat, jt as reactivateAgent, k as createAgent, kt as pollInbox, l as agentAvatarImageUrl, lt as listAgentsForMember, m as archiveSession, mt as listChats, n as SUPPORTED_AVATAR_IMAGE_MIMES, nn as updateOrganization, nt as joinChat, o as addChatParticipants, ot as listActiveAgentsPinnedToClient, p as agents, pt as listChatParticipantsWithNames, q as getChatDetail, qt as setOffline, r as ackEntryByIdForBoundAgents, rn as upsertSessionState, rt as joinMeChat, s as addMeChatParticipants, st as listAgentSessions, t as MAX_AVATAR_IMAGE_BYTES, tn as updateClientCapabilities, tt as inboxEntries, u as agentChatSessions, ut as listAgentsManagedByUser, v as chatMembership, vt as listMeChatSourceCounts, w as claimClient, wt as markStaleAgents, x as checkAgentNameAvailability, y as chatUserState, yt as listMeChats, z as extractSummary, zt as resolveChatTitle } from "./client-BSfCc0pJ-BP_1f21y.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 } from "./invitation-D_ENPHyj-5ETiae5r.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";
|
|
@@ -25,7 +25,7 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
import * as semver from "semver";
|
|
26
26
|
import { confirm, input, password, select } from "@inquirer/prompts";
|
|
27
27
|
import bcrypt from "bcrypt";
|
|
28
|
-
import { and, asc,
|
|
28
|
+
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
|
|
29
29
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
30
30
|
import postgres from "postgres";
|
|
31
31
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
@@ -822,13 +822,17 @@ z.object({
|
|
|
822
822
|
participantIds: z.array(z.string()).min(1),
|
|
823
823
|
metadata: optionalChatMetadataSchema.optional()
|
|
824
824
|
});
|
|
825
|
-
|
|
825
|
+
/**
|
|
826
|
+
* Participant row with the agent's public-ish metadata resolved — used by the
|
|
827
|
+
* client runtime for `@<name>` mention extraction against the authoritative
|
|
828
|
+
* participant set (see proposals/hub-agent-messaging-reply-and-mentions §4).
|
|
829
|
+
*/
|
|
830
|
+
const chatParticipantDetailSchema = z.object({
|
|
826
831
|
agentId: z.string(),
|
|
827
832
|
role: z.string(),
|
|
828
833
|
mode: z.string(),
|
|
829
834
|
joinedAt: z.string()
|
|
830
|
-
})
|
|
831
|
-
chatParticipantSchema.extend({
|
|
835
|
+
}).extend({
|
|
832
836
|
name: z.string().nullable(),
|
|
833
837
|
displayName: z.string(),
|
|
834
838
|
type: z.string()
|
|
@@ -843,7 +847,7 @@ z.object({
|
|
|
843
847
|
createdAt: z.string(),
|
|
844
848
|
updatedAt: z.string()
|
|
845
849
|
}).extend({
|
|
846
|
-
participants: z.array(
|
|
850
|
+
participants: z.array(chatParticipantDetailSchema),
|
|
847
851
|
title: z.string(),
|
|
848
852
|
firstMessagePreview: z.string().nullable(),
|
|
849
853
|
engagementStatus: chatEngagementStatusSchema
|
|
@@ -883,9 +887,13 @@ z.object({
|
|
|
883
887
|
* Optional opt-in flags the client carries on `client:register` to advertise
|
|
884
888
|
* which negotiable wire-protocol features it implements. Distinct from
|
|
885
889
|
* `clientCapabilitiesSchema` (per-runtime-provider availability — different
|
|
886
|
-
* concept).
|
|
887
|
-
*
|
|
888
|
-
*
|
|
890
|
+
* concept).
|
|
891
|
+
*
|
|
892
|
+
* 0.10.4 ~ 0.14.2 clients still send this block (with `wsInboxDeliver: true`
|
|
893
|
+
* hard-coded). The 0.14.3+ runtime omits it. The schema is retained so that
|
|
894
|
+
* middle-version `client:register` frames still parse, even though the
|
|
895
|
+
* server no longer reads any of these fields — the WS inbox data plane is
|
|
896
|
+
* mandatory on this server build.
|
|
889
897
|
*/
|
|
890
898
|
const clientWireCapabilitiesSchema = z.object({ wsInboxDeliver: z.boolean().default(false) }).partial();
|
|
891
899
|
z.object({
|
|
@@ -1110,8 +1118,8 @@ z.object({
|
|
|
1110
1118
|
});
|
|
1111
1119
|
/**
|
|
1112
1120
|
* Server → client WS frame carrying the full image bytes for an image
|
|
1113
|
-
* message. Pushed before the corresponding `
|
|
1114
|
-
*
|
|
1121
|
+
* message. Pushed before the corresponding `inbox:deliver` frame so the
|
|
1122
|
+
* client has the file on disk by the time it renders the message.
|
|
1115
1123
|
*
|
|
1116
1124
|
* Best-effort: if the target client WS lives on a different server
|
|
1117
1125
|
* instance (or is offline), the frame is lost and the reference message
|
|
@@ -1142,6 +1150,23 @@ const messageFormatSchema = z.enum([
|
|
|
1142
1150
|
"question",
|
|
1143
1151
|
"question_answer"
|
|
1144
1152
|
]);
|
|
1153
|
+
/**
|
|
1154
|
+
* Optional intent tag set by the client when posting through
|
|
1155
|
+
* `POST /agent/chats/:id/messages`. Tells the server *why* this write is
|
|
1156
|
+
* happening so it can pick the right enforcement profile.
|
|
1157
|
+
*
|
|
1158
|
+
* - `"agent-final-text"`: handler-initiated forward of an agent's final
|
|
1159
|
+
* reply text (today: `runtime/result-sink.ts`) OR an `AskUserQuestion`
|
|
1160
|
+
* payload posted via the canUseTool bridge. Both should land in chat
|
|
1161
|
+
* history so human observers in the web UI can see what the agent is
|
|
1162
|
+
* doing, but neither should wake other agents and neither should be
|
|
1163
|
+
* subject to the group-chat `@mention required` guard — they are not
|
|
1164
|
+
* a user-typed group broadcast. v1 §四 改造 4 (b) bypass channel.
|
|
1165
|
+
*
|
|
1166
|
+
* Default-`undefined` means a regular agent-initiated send (CLI `chat send`,
|
|
1167
|
+
* adapter, etc.) and goes through the normal enforcement profile.
|
|
1168
|
+
*/
|
|
1169
|
+
const messagePurposeSchema = z.enum(["agent-final-text"]);
|
|
1145
1170
|
z.object({
|
|
1146
1171
|
format: messageFormatSchema.default("text"),
|
|
1147
1172
|
content: z.unknown(),
|
|
@@ -1149,7 +1174,8 @@ z.object({
|
|
|
1149
1174
|
inReplyTo: z.string().optional(),
|
|
1150
1175
|
replyToInbox: z.string().optional(),
|
|
1151
1176
|
replyToChat: z.string().optional(),
|
|
1152
|
-
source: messageSourceSchema.optional()
|
|
1177
|
+
source: messageSourceSchema.optional(),
|
|
1178
|
+
purpose: messagePurposeSchema.optional()
|
|
1153
1179
|
});
|
|
1154
1180
|
z.object({
|
|
1155
1181
|
format: messageFormatSchema.default("text"),
|
|
@@ -1157,7 +1183,8 @@ z.object({
|
|
|
1157
1183
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
1158
1184
|
replyToInbox: z.string().optional(),
|
|
1159
1185
|
replyToChat: z.string().optional(),
|
|
1160
|
-
source: messageSourceSchema.optional()
|
|
1186
|
+
source: messageSourceSchema.optional(),
|
|
1187
|
+
direct: z.boolean().optional()
|
|
1161
1188
|
});
|
|
1162
1189
|
const messageSchema = z.object({
|
|
1163
1190
|
id: z.string(),
|
|
@@ -1253,14 +1280,11 @@ z.object({
|
|
|
1253
1280
|
}).extend({ message: clientMessageSchema });
|
|
1254
1281
|
z.object({ limit: z.coerce.number().int().min(1).max(50).default(10) });
|
|
1255
1282
|
/**
|
|
1256
|
-
* server → client: a single inbox entry pushed over the active WS connection
|
|
1257
|
-
* replacing the legacy `new_message` doorbell + HTTP `/inbox` poll round-trip.
|
|
1283
|
+
* server → client: a single inbox entry pushed over the active WS connection.
|
|
1258
1284
|
*
|
|
1259
1285
|
* `entryId` is the server-side `inbox_entries.id` the client must echo back
|
|
1260
|
-
* in `inbox:ack`. `
|
|
1261
|
-
*
|
|
1262
|
-
* side dispatch logic is reused verbatim (see proposal
|
|
1263
|
-
* hub-inbox-ws-data-plane §3.1).
|
|
1286
|
+
* in `inbox:ack`. `clientMessageSchema` carries `precedingMessages`, so the
|
|
1287
|
+
* client-side dispatch logic handles the silent-context bundle uniformly.
|
|
1264
1288
|
*
|
|
1265
1289
|
* `.passthrough()` so a forward-rolling server may extend the frame without
|
|
1266
1290
|
* breaking older clients that validate strictly. Older clients drop unknown
|
|
@@ -1370,6 +1394,10 @@ z.object({
|
|
|
1370
1394
|
lastReadAt: z.string(),
|
|
1371
1395
|
unreadMentionCount: z.number().int()
|
|
1372
1396
|
});
|
|
1397
|
+
z.object({
|
|
1398
|
+
chatId: z.string(),
|
|
1399
|
+
unreadMentionCount: z.number().int()
|
|
1400
|
+
});
|
|
1373
1401
|
z.object({
|
|
1374
1402
|
chatId: z.string(),
|
|
1375
1403
|
membershipKind: meChatMembershipKindSchema.nullable()
|
|
@@ -1988,8 +2016,13 @@ z.object({
|
|
|
1988
2016
|
/**
|
|
1989
2017
|
* Negotiable wire-protocol features the server advertises in its `welcome`
|
|
1990
2018
|
* frame. Older clients drop the `capabilities` field silently because the
|
|
1991
|
-
* frame is `.passthrough()`.
|
|
1992
|
-
*
|
|
2019
|
+
* frame is `.passthrough()`.
|
|
2020
|
+
*
|
|
2021
|
+
* Required by clients in the 0.10.4 ~ 0.14.2 range: those builds read
|
|
2022
|
+
* `wsInboxDeliver` here to decide whether to skip the local HTTP poll loop
|
|
2023
|
+
* and rely on `inbox:deliver` push frames. The 0.14.3+ runtime ignores the
|
|
2024
|
+
* field (push is the only path) but the server still emits it so middle-
|
|
2025
|
+
* version clients keep working.
|
|
1993
2026
|
*/
|
|
1994
2027
|
const serverCapabilitiesSchema = z.object({ wsInboxDeliver: z.boolean().default(false) }).partial();
|
|
1995
2028
|
/**
|
|
@@ -2118,13 +2151,6 @@ defineConfig({
|
|
|
2118
2151
|
refreshTokenExpiry: field(z.string().default("30d"), { env: "FIRST_TREE_HUB_AUTH_REFRESH_TOKEN_EXPIRY" }),
|
|
2119
2152
|
connectTokenExpiry: field(z.string().default("10m"), { env: "FIRST_TREE_HUB_AUTH_CONNECT_TOKEN_EXPIRY" })
|
|
2120
2153
|
},
|
|
2121
|
-
contextTreeSync: optional({
|
|
2122
|
-
githubToken: field(z.string(), {
|
|
2123
|
-
env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN",
|
|
2124
|
-
secret: true
|
|
2125
|
-
}),
|
|
2126
|
-
githubTokenRepos: field(z.string().optional(), { env: "FIRST_TREE_HUB_CONTEXT_TREE_GITHUB_TOKEN_REPOS" })
|
|
2127
|
-
}),
|
|
2128
2154
|
oauth: optional({ githubApp: optional({
|
|
2129
2155
|
appId: field(z.string().min(1), { env: "FIRST_TREE_HUB_GITHUB_APP_ID" }),
|
|
2130
2156
|
clientId: field(z.string().min(1), { env: "FIRST_TREE_HUB_GITHUB_APP_CLIENT_ID" }),
|
|
@@ -2380,15 +2406,6 @@ var FirstTreeHubSDK = class {
|
|
|
2380
2406
|
return false;
|
|
2381
2407
|
}
|
|
2382
2408
|
}
|
|
2383
|
-
async pull(limit = 10) {
|
|
2384
|
-
return { entries: await this.requestJson(`/api/v1/agent/inbox?limit=${limit}`) };
|
|
2385
|
-
}
|
|
2386
|
-
async ack(entryId) {
|
|
2387
|
-
await this.requestVoid(`/api/v1/agent/inbox/${entryId}/ack`, { method: "POST" });
|
|
2388
|
-
}
|
|
2389
|
-
async renew(entryId) {
|
|
2390
|
-
await this.requestVoid(`/api/v1/agent/inbox/${entryId}/renew`, { method: "POST" });
|
|
2391
|
-
}
|
|
2392
2409
|
async sendMessage(chatId, data) {
|
|
2393
2410
|
return this.requestJson(`/api/v1/agent/chats/${chatId}/messages`, {
|
|
2394
2411
|
method: "POST",
|
|
@@ -2404,6 +2421,16 @@ var FirstTreeHubSDK = class {
|
|
|
2404
2421
|
async listChats(options) {
|
|
2405
2422
|
return this.requestJson(`/api/v1/agent/chats${this.queryString(options)}`);
|
|
2406
2423
|
}
|
|
2424
|
+
/**
|
|
2425
|
+
* Fetch full chat detail (topic + participant membership rows). Used by the
|
|
2426
|
+
* runtime bootstrap path to assemble a chat-level identity block injected
|
|
2427
|
+
* into CLAUDE.md / AGENTS.md so the agent knows the chat's topic and who
|
|
2428
|
+
* else is in the room. Participant rows here lack name/displayName/type —
|
|
2429
|
+
* call `listChatParticipants` for that.
|
|
2430
|
+
*/
|
|
2431
|
+
async getChatDetail(chatId) {
|
|
2432
|
+
return this.requestJson(`/api/v1/agent/chats/${chatId}`);
|
|
2433
|
+
}
|
|
2407
2434
|
async listMessages(chatId, options) {
|
|
2408
2435
|
return this.requestJson(`/api/v1/agent/chats/${chatId}/messages${this.queryString(options)}`);
|
|
2409
2436
|
}
|
|
@@ -2546,17 +2573,6 @@ const RECONNECT_MAX_MS = 3e4;
|
|
|
2546
2573
|
const WS_CONNECT_TIMEOUT_MS = 1e4;
|
|
2547
2574
|
const HEARTBEAT_INTERVAL_MS = 3e4;
|
|
2548
2575
|
/**
|
|
2549
|
-
* Client-side opt-in for the WS inbox data plane. Gates BOTH the
|
|
2550
|
-
* `wireCapabilities.wsInboxDeliver` flag we declare on `client:register`
|
|
2551
|
-
* AND how we interpret the server's welcome capability — without this AND,
|
|
2552
|
-
* a future client kill-switch could land in a half-state where we tell the
|
|
2553
|
-
* server "no thanks" but still treat welcome's `wsInboxDeliver:true` as
|
|
2554
|
-
* authoritative and stop the 5s HTTP poll, leaving messages stuck if a
|
|
2555
|
-
* NOTIFY ever drops. Hard-coded `true` for now; flip to a config knob if
|
|
2556
|
-
* you need a runtime kill-switch.
|
|
2557
|
-
*/
|
|
2558
|
-
const WS_INBOX_DELIVER_OPT_IN = true;
|
|
2559
|
-
/**
|
|
2560
2576
|
* Unified-user-token C5: reconnect PROACTIVELY this many ms before the JWT's
|
|
2561
2577
|
* `exp` claim so the client rotates to a fresh JWT without ever hitting the
|
|
2562
2578
|
* server-side `auth:expired` push. The provider's next `getAccessToken()` call
|
|
@@ -2616,15 +2632,6 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2616
2632
|
/** Count of `server:welcome` frames received; drives `isReconnect` flag. */
|
|
2617
2633
|
welcomeFramesReceived = 0;
|
|
2618
2634
|
/**
|
|
2619
|
-
* Whether the most recent `server:welcome` frame advertised
|
|
2620
|
-
* `capabilities.wsInboxDeliver`. The runtime (AgentSlot) reads this
|
|
2621
|
-
* (via {@link supportsWsInboxDeliver}) to decide whether to keep the
|
|
2622
|
-
* legacy 5s HTTP poll or rely entirely on `inbox:deliver` push frames.
|
|
2623
|
-
* Re-evaluated on every reconnect — the welcome frame is the source of
|
|
2624
|
-
* truth, never assumed sticky across connections.
|
|
2625
|
-
*/
|
|
2626
|
-
wsInboxDeliverActive = false;
|
|
2627
|
-
/**
|
|
2628
2635
|
* Last handshake error, stashed for the `close` handler to surface a typed
|
|
2629
2636
|
* reason (e.g. {@link ClientOrgMismatchError}) instead of a generic
|
|
2630
2637
|
* "closed before ready" when `connect()` is pending.
|
|
@@ -2637,11 +2644,11 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2637
2644
|
desiredBindings = /* @__PURE__ */ new Map();
|
|
2638
2645
|
pendingBinds = /* @__PURE__ */ new Map();
|
|
2639
2646
|
/**
|
|
2640
|
-
* In-flight image writes from recent `image_payload` frames.
|
|
2641
|
-
*
|
|
2642
|
-
*
|
|
2643
|
-
*
|
|
2644
|
-
*
|
|
2647
|
+
* In-flight image writes from recent `image_payload` frames. `image_payload`
|
|
2648
|
+
* arrives on the WS just before `inbox:deliver` for the same message, but
|
|
2649
|
+
* the EventEmitter dispatch is sync — so without gating, the deliver
|
|
2650
|
+
* handler can fire before the image bytes hit disk. Block `inbox:deliver`
|
|
2651
|
+
* emission until these settle.
|
|
2645
2652
|
*/
|
|
2646
2653
|
pendingImageWrites = /* @__PURE__ */ new Set();
|
|
2647
2654
|
constructor(config) {
|
|
@@ -2662,24 +2669,21 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2662
2669
|
return this.boundAgents;
|
|
2663
2670
|
}
|
|
2664
2671
|
/**
|
|
2665
|
-
*
|
|
2666
|
-
*
|
|
2667
|
-
*
|
|
2668
|
-
*
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
return this.wsInboxDeliverActive;
|
|
2672
|
-
}
|
|
2673
|
-
/**
|
|
2674
|
-
* Ack a delivered inbox entry over the WS data plane. Replaces the legacy
|
|
2675
|
-
* `sdk.ack()` HTTP call when the connection has negotiated
|
|
2676
|
-
* `wsInboxDeliver`. Safe to call when the WS is closed — the frame is
|
|
2677
|
-
* dropped silently and the entry will time out and re-deliver on
|
|
2678
|
-
* reconnect, mirroring how the legacy timeout reaper handles HTTP
|
|
2679
|
-
* ack-loss.
|
|
2672
|
+
* Ack a delivered inbox entry over the WS data plane. Safe to call when the
|
|
2673
|
+
* WS is closed — the frame is dropped (logged) and the entry will time out
|
|
2674
|
+
* server-side and re-deliver on reconnect. The handler has by then already
|
|
2675
|
+
* started processing, so reaper-driven redelivery surfaces as a duplicate
|
|
2676
|
+
* dispatch on the next connect; SessionManager's dedupe key
|
|
2677
|
+
* `(chatId, messageId)` collapses it.
|
|
2680
2678
|
*/
|
|
2681
2679
|
sendInboxAck(entryId) {
|
|
2682
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
2680
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
2681
|
+
this.wsLogger.warn({
|
|
2682
|
+
entryId,
|
|
2683
|
+
readyState: this.ws?.readyState
|
|
2684
|
+
}, "inbox:ack dropped — socket not OPEN");
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2683
2687
|
this.ws.send(JSON.stringify({
|
|
2684
2688
|
type: "inbox:ack",
|
|
2685
2689
|
entryId
|
|
@@ -2836,7 +2840,6 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2836
2840
|
this.clearAuthRefreshTimer();
|
|
2837
2841
|
const wasRegistered = this.registered;
|
|
2838
2842
|
this.registered = false;
|
|
2839
|
-
this.wsInboxDeliverActive = false;
|
|
2840
2843
|
this.rejectAllPendingBinds("WebSocket closed");
|
|
2841
2844
|
if (!settled) {
|
|
2842
2845
|
this.wsLogger.warn({ code }, "closed before ready");
|
|
@@ -2872,8 +2875,7 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2872
2875
|
clientId: this.clientId,
|
|
2873
2876
|
hostname: hostname(),
|
|
2874
2877
|
os: platform(),
|
|
2875
|
-
sdkVersion: this.sdkVersion
|
|
2876
|
-
wireCapabilities: { wsInboxDeliver: WS_INBOX_DELIVER_OPT_IN }
|
|
2878
|
+
sdkVersion: this.sdkVersion
|
|
2877
2879
|
}));
|
|
2878
2880
|
return;
|
|
2879
2881
|
}
|
|
@@ -2883,7 +2885,6 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2883
2885
|
this.wsLogger.warn({ issues: parsed.error.issues.map((i) => i.message) }, "ignoring malformed server:welcome frame");
|
|
2884
2886
|
return;
|
|
2885
2887
|
}
|
|
2886
|
-
this.wsInboxDeliverActive = parsed.data.capabilities?.wsInboxDeliver === true && WS_INBOX_DELIVER_OPT_IN;
|
|
2887
2888
|
const isReconnect = this.welcomeFramesReceived > 0;
|
|
2888
2889
|
this.welcomeFramesReceived++;
|
|
2889
2890
|
this.emit("server:welcome", {
|
|
@@ -3021,15 +3022,6 @@ var ClientConnection = class extends EventEmitter {
|
|
|
3021
3022
|
write.finally(() => this.pendingImageWrites.delete(write));
|
|
3022
3023
|
return;
|
|
3023
3024
|
}
|
|
3024
|
-
if (type === "new_message") {
|
|
3025
|
-
const inboxId = msg.inboxId;
|
|
3026
|
-
if (!inboxId) return;
|
|
3027
|
-
if (this.pendingImageWrites.size > 0) Promise.all([...this.pendingImageWrites]).finally(() => {
|
|
3028
|
-
this.emit("agent:message", inboxId, msg);
|
|
3029
|
-
});
|
|
3030
|
-
else this.emit("agent:message", inboxId, msg);
|
|
3031
|
-
return;
|
|
3032
|
-
}
|
|
3033
3025
|
if (type === "inbox:deliver") {
|
|
3034
3026
|
const parsed = inboxDeliverFrameSchema.safeParse(msg);
|
|
3035
3027
|
if (!parsed.success) {
|
|
@@ -3332,7 +3324,7 @@ const FIRST_TREE_WORKSPACE_MARKER = ".first-tree-workspace";
|
|
|
3332
3324
|
* and on resume().
|
|
3333
3325
|
*/
|
|
3334
3326
|
function bootstrapWorkspace(options) {
|
|
3335
|
-
const { workspacePath, identity, contextTreePath, serverUrl, chatId, briefing } = options;
|
|
3327
|
+
const { workspacePath, identity, contextTreePath, serverUrl, chatId, chatContext, briefing } = options;
|
|
3336
3328
|
const agentDir = join(workspacePath, ".agent");
|
|
3337
3329
|
const contextDir = join(agentDir, "context");
|
|
3338
3330
|
if (existsSync(contextDir)) rmSync(contextDir, {
|
|
@@ -3348,7 +3340,8 @@ function bootstrapWorkspace(options) {
|
|
|
3348
3340
|
metadata: identity.metadata,
|
|
3349
3341
|
chatId,
|
|
3350
3342
|
serverUrl,
|
|
3351
|
-
contextTreePath
|
|
3343
|
+
contextTreePath,
|
|
3344
|
+
...chatContext ? { chatContext } : {}
|
|
3352
3345
|
};
|
|
3353
3346
|
writeFileSync(join(agentDir, "identity.json"), JSON.stringify(identityData, null, 2), "utf-8");
|
|
3354
3347
|
if (contextTreePath) {
|
|
@@ -3440,22 +3433,51 @@ You are running inside **Agent Hub**, a messaging platform for agent teams.
|
|
|
3440
3433
|
|
|
3441
3434
|
- Messages from other team members arrive as your prompt input. Each message has a
|
|
3442
3435
|
\`[From: <agent-name>]\` header — that name is what you pass back to \`chat send\`.
|
|
3443
|
-
- **Your final text
|
|
3436
|
+
- **Your final response text is delivered to the chat for human observers to read.
|
|
3437
|
+
It does NOT wake other agents.** To make another agent take action, use
|
|
3438
|
+
\`first-tree-hub chat send <name>\` explicitly (see "Communication Rules" below).
|
|
3444
3439
|
- **Stay silent when you have nothing to add.** Not every message needs a reply.
|
|
3445
3440
|
If you have nothing new for the recipient, output nothing and the runtime ends the turn.
|
|
3446
3441
|
- For **proactive communication** (other agents, other chats, or different format),
|
|
3447
3442
|
use the \`first-tree-hub\` CLI below.
|
|
3448
3443
|
|
|
3444
|
+
## Communication Rules
|
|
3445
|
+
|
|
3446
|
+
Your final response text is delivered to the chat for **human observers**
|
|
3447
|
+
to read. It does NOT wake other agents.
|
|
3448
|
+
|
|
3449
|
+
To make another agent take action, you MUST explicitly call:
|
|
3450
|
+
|
|
3451
|
+
first-tree-hub chat send <name> "..."
|
|
3452
|
+
|
|
3453
|
+
Decision guide (based on participant \`type\` in the Current Chat Context block):
|
|
3454
|
+
|
|
3455
|
+
- Target is a **human** in this chat → your final text is enough; do not
|
|
3456
|
+
redundantly chat send (it just adds noise).
|
|
3457
|
+
- Target is an **agent** in this chat → they will NOT see your final text
|
|
3458
|
+
as a wake signal. You MUST chat send <name> if you need them to act.
|
|
3459
|
+
- No specific target (just narrating progress / thinking aloud) → final
|
|
3460
|
+
text only; no send needed.
|
|
3461
|
+
|
|
3462
|
+
**Fallback** (if Current Chat Context block is missing — context injection
|
|
3463
|
+
may have failed): use conservative mode — all cross-agent collaboration
|
|
3464
|
+
goes through explicit \`chat send\`; do not rely on final text to wake
|
|
3465
|
+
anyone.
|
|
3466
|
+
|
|
3449
3467
|
## Sending Messages
|
|
3450
3468
|
|
|
3451
3469
|
The CLI auto-reads its config from env — no setup needed.
|
|
3452
3470
|
|
|
3453
3471
|
\`\`\`bash
|
|
3454
|
-
# Send to an agent by NAME (uuids are NOT accepted — run \`first-tree-hub agent list\` for names)
|
|
3472
|
+
# Send to an agent by NAME (uuids are NOT accepted — run \`first-tree-hub agent list\` for names).
|
|
3473
|
+
# Routing: the recipient MUST be a participant of your current chat — the message
|
|
3474
|
+
# lands in that chat. If they are NOT a member the call ERRORS with a hint. To open
|
|
3475
|
+
# a side-conversation with a non-member, use the --direct flag explicitly.
|
|
3455
3476
|
first-tree-hub chat send <agentName> "your message"
|
|
3456
3477
|
|
|
3457
|
-
#
|
|
3458
|
-
|
|
3478
|
+
# Open or reuse a direct chat with the recipient (bypass the member check).
|
|
3479
|
+
# Use only when intentionally starting a side conversation with a non-member.
|
|
3480
|
+
first-tree-hub chat send --direct <agentName> "your message"
|
|
3459
3481
|
|
|
3460
3482
|
# Markdown format (default is text)
|
|
3461
3483
|
first-tree-hub chat send <agentName> -f markdown "**bold**"
|
|
@@ -3467,6 +3489,17 @@ first-tree-hub chat send <agentName> --reply-to <messageId> "reply"
|
|
|
3467
3489
|
echo "long body" | first-tree-hub chat send <agentName>
|
|
3468
3490
|
\`\`\`
|
|
3469
3491
|
|
|
3492
|
+
**Reaching another agent — pick the right flag**:
|
|
3493
|
+
|
|
3494
|
+
- **Same chat member** → \`chat send <agentName> "..."\` (no flag).
|
|
3495
|
+
- **Non-member** → \`chat send --direct <agentName> "..."\`. The CLI opens (or
|
|
3496
|
+
reuses) the direct chat AND auto-mentions the recipient so they actually wake.
|
|
3497
|
+
|
|
3498
|
+
The CLI **only addresses agents by name**. You cannot route by chat-id from
|
|
3499
|
+
this command, and \`@<name>\` in your content only resolves against the chat
|
|
3500
|
+
you are currently in — naming someone who is not a member is rejected so a
|
|
3501
|
+
silent-drop misroute is impossible.
|
|
3502
|
+
|
|
3470
3503
|
**Content rules (important):**
|
|
3471
3504
|
|
|
3472
3505
|
- Pass content as a **raw string** — never \`JSON.stringify\` it first. Wrapping in
|
|
@@ -3476,6 +3509,74 @@ echo "long body" | first-tree-hub chat send <agentName>
|
|
|
3476
3509
|
use **stdin** with real newlines, plus \`-f markdown\`.
|
|
3477
3510
|
`;
|
|
3478
3511
|
}
|
|
3512
|
+
/**
|
|
3513
|
+
* Build a narrow `ChatContext` snapshot for the current session.
|
|
3514
|
+
*
|
|
3515
|
+
* Calls the two existing agent-scoped endpoints in parallel:
|
|
3516
|
+
* - `GET /agent/chats/:chatId` — chat detail (topic)
|
|
3517
|
+
* - `GET /agent/chats/:chatId/participants` — participant rows with names
|
|
3518
|
+
*
|
|
3519
|
+
* Throws on either HTTP failure so the caller (handler) can log + degrade
|
|
3520
|
+
* to the no-context path. The bootstrap branch then writes neither the
|
|
3521
|
+
* identity.json `chatContext` field nor the CLAUDE.md / AGENTS.md section.
|
|
3522
|
+
*/
|
|
3523
|
+
async function fetchChatContext(sdk, chatId, identity) {
|
|
3524
|
+
const [detail, participants] = await Promise.all([sdk.getChatDetail(chatId), sdk.listChatParticipants(chatId)]);
|
|
3525
|
+
const filteredParticipants = participants.filter((p) => p.name !== null && p.name.length > 0).map((p) => ({
|
|
3526
|
+
name: p.name,
|
|
3527
|
+
displayName: p.displayName,
|
|
3528
|
+
type: p.type === "human" ? "human" : "agent"
|
|
3529
|
+
}));
|
|
3530
|
+
const selfOwner = resolveSelfOwner(identity, participants);
|
|
3531
|
+
return {
|
|
3532
|
+
chatId,
|
|
3533
|
+
title: detail.title,
|
|
3534
|
+
topic: detail.topic,
|
|
3535
|
+
...selfOwner ? { selfOwner } : {},
|
|
3536
|
+
participants: filteredParticipants
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
/**
|
|
3540
|
+
* For delegate agents (personal_assistant whose `delegateMention` points at
|
|
3541
|
+
* a chat participant) return `{name, displayName}` of the human owner; for
|
|
3542
|
+
* autonomous agents return `undefined`.
|
|
3543
|
+
*
|
|
3544
|
+
* `delegateMention` holds the OWNER'S `name` slug — see
|
|
3545
|
+
* web/.../identity-section.tsx ("delegate <AgentChip ...>").
|
|
3546
|
+
*/
|
|
3547
|
+
function resolveSelfOwner(identity, participants) {
|
|
3548
|
+
if (identity.type !== "personal_assistant") return void 0;
|
|
3549
|
+
if (!identity.delegateMention) return void 0;
|
|
3550
|
+
const owner = participants.find((p) => p.name === identity.delegateMention && p.type === "human");
|
|
3551
|
+
if (!owner || !owner.name) return void 0;
|
|
3552
|
+
return {
|
|
3553
|
+
name: owner.name,
|
|
3554
|
+
displayName: owner.displayName
|
|
3555
|
+
};
|
|
3556
|
+
}
|
|
3557
|
+
/**
|
|
3558
|
+
* Render the "Current Chat Context" markdown section that both Claude Code
|
|
3559
|
+
* (CLAUDE.md) and Codex (AGENTS.md) inject into the agent's prompt context.
|
|
3560
|
+
*
|
|
3561
|
+
* Shared so the two handlers never drift on field shape or wording. Returns
|
|
3562
|
+
* `null` when there's no context to render — caller skips the section.
|
|
3563
|
+
*
|
|
3564
|
+
* See proposals/hub-chat-message-v1-design §四 改造 3.
|
|
3565
|
+
*/
|
|
3566
|
+
function renderChatContextSection(chatContext) {
|
|
3567
|
+
if (!chatContext) return null;
|
|
3568
|
+
const lines = [];
|
|
3569
|
+
lines.push("## Current Chat Context");
|
|
3570
|
+
lines.push("");
|
|
3571
|
+
lines.push(`- Chat ID: ${chatContext.chatId}`);
|
|
3572
|
+
if (chatContext.title && chatContext.title.trim().length > 0) lines.push(`- Title: ${chatContext.title}`);
|
|
3573
|
+
if (chatContext.topic && chatContext.topic.trim().length > 0 && chatContext.topic !== chatContext.title) lines.push(`- Topic: ${chatContext.topic}`);
|
|
3574
|
+
if (chatContext.selfOwner) lines.push(`- Your owner: ${chatContext.selfOwner.displayName} (@${chatContext.selfOwner.name})`);
|
|
3575
|
+
lines.push("- Participants:");
|
|
3576
|
+
if (chatContext.participants.length === 0) lines.push(" - (none)");
|
|
3577
|
+
else for (const p of chatContext.participants) lines.push(` - @${p.name} (${p.displayName}, type=${p.type})`);
|
|
3578
|
+
return `${lines.join("\n")}\n`;
|
|
3579
|
+
}
|
|
3479
3580
|
function resolveGitRepoTargetPath(workspace, localPath) {
|
|
3480
3581
|
const safetyError = getRepoLocalPathSafetyError(localPath);
|
|
3481
3582
|
if (safetyError) throw new Error(`Unsafe git repo localPath "${localPath}": ${safetyError}`);
|
|
@@ -3997,6 +4098,7 @@ function createGitMirrorManager(opts) {
|
|
|
3997
4098
|
}, "worktree create conflict");
|
|
3998
4099
|
throw new GitMirrorWorktreeConflictError(`Worktree target "${absTarget}" is already occupied by ${classifyOccupant(absTarget)} — aborting (D13)`);
|
|
3999
4100
|
}
|
|
4101
|
+
await gitOk(["worktree", "prune"], mirror, 1e4);
|
|
4000
4102
|
const pathExists = existsSync(absTarget);
|
|
4001
4103
|
const hasBranch = await branchExists(mirror, branchName);
|
|
4002
4104
|
mkdirSync(dirname(absTarget), { recursive: true });
|
|
@@ -4897,7 +4999,8 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4897
4999
|
try {
|
|
4898
5000
|
await sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
|
|
4899
5001
|
format: "question",
|
|
4900
|
-
content: questionContent
|
|
5002
|
+
content: questionContent,
|
|
5003
|
+
purpose: "agent-final-text"
|
|
4901
5004
|
});
|
|
4902
5005
|
} catch (err) {
|
|
4903
5006
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -5167,16 +5270,45 @@ const createClaudeCodeHandler = (config) => {
|
|
|
5167
5270
|
}
|
|
5168
5271
|
}
|
|
5169
5272
|
}
|
|
5170
|
-
/**
|
|
5171
|
-
|
|
5273
|
+
/**
|
|
5274
|
+
* Best-effort chat-context fetch for the identity-injection path. Failures
|
|
5275
|
+
* are logged but never bubble — bootstrap continues with `undefined` and
|
|
5276
|
+
* the agent simply loses the "Current Chat Context" block (graceful
|
|
5277
|
+
* degradation; the Communication Rules in tools.md still tell it to fall
|
|
5278
|
+
* back to conservative mode).
|
|
5279
|
+
*/
|
|
5280
|
+
async function fetchChatContextOrLog(sessionCtx) {
|
|
5281
|
+
try {
|
|
5282
|
+
return await fetchChatContext(sessionCtx.sdk, sessionCtx.chatId, sessionCtx.agent);
|
|
5283
|
+
} catch (err) {
|
|
5284
|
+
sessionCtx.log(`fetchChatContext failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
5285
|
+
return;
|
|
5286
|
+
}
|
|
5287
|
+
}
|
|
5288
|
+
/**
|
|
5289
|
+
* Refresh the workspace's identity.json + CLAUDE.md from the latest chat
|
|
5290
|
+
* context. v1.7 fix: chat-context must NOT be frozen at first bootstrap.
|
|
5291
|
+
* When new participants join later, every resume re-fetches and rewrites
|
|
5292
|
+
* the "Current Chat Context" section so the agent sees the live roster.
|
|
5293
|
+
* Skips the expensive `first-tree tree integrate` shell-out — that part
|
|
5294
|
+
* stays sentinel-protected and runs only on a fresh bootstrap.
|
|
5295
|
+
*/
|
|
5296
|
+
function refreshIdentityAndPrompt(workspace, sessionCtx, chatContext) {
|
|
5172
5297
|
bootstrapWorkspace({
|
|
5173
5298
|
workspacePath: workspace,
|
|
5174
5299
|
identity: sessionCtx.agent,
|
|
5175
5300
|
contextTreePath,
|
|
5176
5301
|
serverUrl: sessionCtx.sdk.serverUrl,
|
|
5177
|
-
chatId: sessionCtx.chatId
|
|
5302
|
+
chatId: sessionCtx.chatId,
|
|
5303
|
+
chatContext
|
|
5178
5304
|
});
|
|
5179
|
-
generateClaudeMd(workspace, sessionCtx.agent, contextTreePath);
|
|
5305
|
+
generateClaudeMd(workspace, sessionCtx.agent, contextTreePath, chatContext);
|
|
5306
|
+
}
|
|
5307
|
+
/** Full bootstrap: refresh identity/prompt + run the expensive
|
|
5308
|
+
* `first-tree tree integrate` shell-out. Used on `start()` and on
|
|
5309
|
+
* `resume()` when the stage-2 sentinel is missing. */
|
|
5310
|
+
function runBootstrap(workspace, sessionCtx, chatContext) {
|
|
5311
|
+
refreshIdentityAndPrompt(workspace, sessionCtx, chatContext);
|
|
5180
5312
|
if (contextTreePath) installFirstTreeIntegration({
|
|
5181
5313
|
workspacePath: workspace,
|
|
5182
5314
|
contextTreePath,
|
|
@@ -5190,7 +5322,8 @@ const createClaudeCodeHandler = (config) => {
|
|
|
5190
5322
|
ctx = sessionCtx;
|
|
5191
5323
|
claudeSessionId = randomUUID();
|
|
5192
5324
|
cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
|
|
5193
|
-
|
|
5325
|
+
const chatContext = await fetchChatContextOrLog(sessionCtx);
|
|
5326
|
+
runBootstrap(cwd, sessionCtx, chatContext);
|
|
5194
5327
|
const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
|
|
5195
5328
|
await prepareGitWorktrees(cwd, payload, sessionCtx);
|
|
5196
5329
|
markWorkspaceInitComplete(cwd);
|
|
@@ -5206,7 +5339,9 @@ const createClaudeCodeHandler = (config) => {
|
|
|
5206
5339
|
claudeSessionId = sessionId;
|
|
5207
5340
|
retryCount = 0;
|
|
5208
5341
|
cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
|
|
5209
|
-
|
|
5342
|
+
const chatContext = await fetchChatContextOrLog(sessionCtx);
|
|
5343
|
+
if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) runBootstrap(cwd, sessionCtx, chatContext);
|
|
5344
|
+
else refreshIdentityAndPrompt(cwd, sessionCtx, chatContext);
|
|
5210
5345
|
const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
|
|
5211
5346
|
await prepareGitWorktrees(cwd, payload, sessionCtx);
|
|
5212
5347
|
markWorkspaceInitComplete(cwd);
|
|
@@ -5299,12 +5434,14 @@ function isHubWorktreeMarker(path) {
|
|
|
5299
5434
|
* `agent_configs.payload.prompt.append` and are passed to the Claude SDK via
|
|
5300
5435
|
* `systemPrompt.append` — not through this file.
|
|
5301
5436
|
*/
|
|
5302
|
-
function generateClaudeMd(workspacePath, identity, contextTreePath) {
|
|
5437
|
+
function generateClaudeMd(workspacePath, identity, contextTreePath, chatContext) {
|
|
5303
5438
|
const sections = [];
|
|
5304
5439
|
const contextDir = join(workspacePath, ".agent", "context");
|
|
5305
5440
|
const name = identity.displayName ?? identity.agentId;
|
|
5306
5441
|
if (identity.type === "personal_assistant") sections.push(`# Agent Identity\n\nYou are ${name}, a personal assistant agent.\n`);
|
|
5307
5442
|
else sections.push(`# Agent Identity\n\nYou are ${name}, an autonomous agent.\n`);
|
|
5443
|
+
const chatContextSection = renderChatContextSection(chatContext);
|
|
5444
|
+
if (chatContextSection) sections.push(chatContextSection);
|
|
5308
5445
|
const agentInstructionsPath = join(contextDir, "agent-instructions.md");
|
|
5309
5446
|
if (existsSync(agentInstructionsPath)) {
|
|
5310
5447
|
const instructions = readFileSync(agentInstructionsPath, "utf-8");
|
|
@@ -5410,7 +5547,7 @@ const createCodexHandler = (config) => {
|
|
|
5410
5547
|
cfg.mcp_servers = mcpServers;
|
|
5411
5548
|
return cfg;
|
|
5412
5549
|
}
|
|
5413
|
-
function buildAgentBriefing(payload) {
|
|
5550
|
+
function buildAgentBriefing(payload, chatContext) {
|
|
5414
5551
|
const lines = [];
|
|
5415
5552
|
lines.push("# Agent Briefing");
|
|
5416
5553
|
lines.push("");
|
|
@@ -5418,11 +5555,28 @@ const createCodexHandler = (config) => {
|
|
|
5418
5555
|
lines.push(payload.prompt.append.trim());
|
|
5419
5556
|
lines.push("");
|
|
5420
5557
|
}
|
|
5558
|
+
const chatContextSection = renderChatContextSection(chatContext);
|
|
5559
|
+
if (chatContextSection) {
|
|
5560
|
+
lines.push(chatContextSection.trimEnd());
|
|
5561
|
+
lines.push("");
|
|
5562
|
+
}
|
|
5421
5563
|
lines.push("Refer to `.agent/identity.json` for your agent identity, `.agent/tools.md` for the");
|
|
5422
5564
|
lines.push("first-tree-hub SDK reference, and `.agent/context/` for organisational context");
|
|
5423
5565
|
lines.push("(when configured).");
|
|
5424
5566
|
return lines.join("\n").concat("\n");
|
|
5425
5567
|
}
|
|
5568
|
+
/**
|
|
5569
|
+
* Best-effort chat-context fetch for the identity-injection path. Failures
|
|
5570
|
+
* are logged but never bubble — bootstrap continues with `undefined`.
|
|
5571
|
+
*/
|
|
5572
|
+
async function fetchChatContextOrLog(sessionCtx) {
|
|
5573
|
+
try {
|
|
5574
|
+
return await fetchChatContext(sessionCtx.sdk, sessionCtx.chatId, sessionCtx.agent);
|
|
5575
|
+
} catch (err) {
|
|
5576
|
+
sessionCtx.log(`fetchChatContext failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
5577
|
+
return;
|
|
5578
|
+
}
|
|
5579
|
+
}
|
|
5426
5580
|
function toCodexInput(message, sessionCtx) {
|
|
5427
5581
|
return sessionCtx.formatInboundContent(message).then((text) => text);
|
|
5428
5582
|
}
|
|
@@ -5682,15 +5836,17 @@ const createCodexHandler = (config) => {
|
|
|
5682
5836
|
env: [],
|
|
5683
5837
|
gitRepos: []
|
|
5684
5838
|
};
|
|
5839
|
+
const chatContext = await fetchChatContextOrLog(sessionCtx);
|
|
5685
5840
|
bootstrapWorkspace({
|
|
5686
5841
|
workspacePath: cwd,
|
|
5687
5842
|
identity: sessionCtx.agent,
|
|
5688
5843
|
contextTreePath,
|
|
5689
5844
|
serverUrl: sessionCtx.sdk.serverUrl,
|
|
5690
5845
|
chatId: sessionCtx.chatId,
|
|
5846
|
+
chatContext,
|
|
5691
5847
|
briefing: {
|
|
5692
5848
|
format: "agents-md",
|
|
5693
|
-
content: buildAgentBriefing(payload)
|
|
5849
|
+
content: buildAgentBriefing(payload, chatContext)
|
|
5694
5850
|
}
|
|
5695
5851
|
});
|
|
5696
5852
|
ensureFirstTreeBinding(cwd, sessionCtx);
|
|
@@ -5719,20 +5875,20 @@ const createCodexHandler = (config) => {
|
|
|
5719
5875
|
env: [],
|
|
5720
5876
|
gitRepos: []
|
|
5721
5877
|
};
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
}
|
|
5734
|
-
|
|
5735
|
-
|
|
5878
|
+
const chatContext = await fetchChatContextOrLog(sessionCtx);
|
|
5879
|
+
bootstrapWorkspace({
|
|
5880
|
+
workspacePath: cwd,
|
|
5881
|
+
identity: sessionCtx.agent,
|
|
5882
|
+
contextTreePath,
|
|
5883
|
+
serverUrl: sessionCtx.sdk.serverUrl,
|
|
5884
|
+
chatId: sessionCtx.chatId,
|
|
5885
|
+
chatContext,
|
|
5886
|
+
briefing: {
|
|
5887
|
+
format: "agents-md",
|
|
5888
|
+
content: buildAgentBriefing(payload, chatContext)
|
|
5889
|
+
}
|
|
5890
|
+
});
|
|
5891
|
+
if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) ensureFirstTreeBinding(cwd, sessionCtx);
|
|
5736
5892
|
await prepareGitWorktrees(payload, cwd, sessionCtx);
|
|
5737
5893
|
markWorkspaceInitComplete(cwd);
|
|
5738
5894
|
codex = new Codex({
|
|
@@ -6048,17 +6204,10 @@ var Deduplicator = class {
|
|
|
6048
6204
|
}
|
|
6049
6205
|
};
|
|
6050
6206
|
function createResultSink(deps) {
|
|
6051
|
-
async function buildMetadata(
|
|
6207
|
+
async function buildMetadata() {
|
|
6052
6208
|
const metadata = {};
|
|
6053
6209
|
const documentBasePath = await deps.getDocumentBasePath?.();
|
|
6054
6210
|
if (documentBasePath) metadata.documentContext = documentContextSchema.parse({ basePath: documentBasePath });
|
|
6055
|
-
if (trigger && trigger.senderId !== deps.agent.agentId) {
|
|
6056
|
-
const participants = await deps.participants.get();
|
|
6057
|
-
if (participants.length <= 2) {
|
|
6058
|
-
const peer = participants.find((p) => p.agentId === trigger.senderId);
|
|
6059
|
-
if (!peer || peer.mode === "mention_only") metadata.mentions = [trigger.senderId];
|
|
6060
|
-
} else metadata.mentions = [trigger.senderId];
|
|
6061
|
-
}
|
|
6062
6211
|
return Object.keys(metadata).length > 0 ? metadata : void 0;
|
|
6063
6212
|
}
|
|
6064
6213
|
return async function forwardResult(text) {
|
|
@@ -6069,10 +6218,11 @@ function createResultSink(deps) {
|
|
|
6069
6218
|
}
|
|
6070
6219
|
const trigger = deps.getTrigger();
|
|
6071
6220
|
deps.clearTrigger();
|
|
6072
|
-
const metadata = await buildMetadata(
|
|
6221
|
+
const metadata = await buildMetadata();
|
|
6073
6222
|
await deps.sdk.sendMessage(deps.chatId, {
|
|
6074
6223
|
format: "text",
|
|
6075
6224
|
content: text,
|
|
6225
|
+
purpose: "agent-final-text",
|
|
6076
6226
|
...trigger ? { inReplyTo: trigger.messageId } : {},
|
|
6077
6227
|
...metadata ? { metadata } : {}
|
|
6078
6228
|
});
|
|
@@ -6617,18 +6767,13 @@ var SessionManager = class {
|
|
|
6617
6767
|
this.config.onStateChange(chatId, state);
|
|
6618
6768
|
}
|
|
6619
6769
|
/**
|
|
6620
|
-
* ACK an inbox entry — delayed until handler starts processing.
|
|
6621
|
-
*
|
|
6622
|
-
* Routes through `config.ackEntry` when set (WS push path) or falls back to
|
|
6623
|
-
* `sdk.ack` (HTTP poll path). One ack per entry, one channel per slot —
|
|
6624
|
-
* mixing channels in one slot would leak the server's per-agent in-flight
|
|
6625
|
-
* counter (proposal hub-inbox-ws-data-plane §3.5).
|
|
6770
|
+
* ACK an inbox entry — delayed until handler starts processing. Routes
|
|
6771
|
+
* through `config.ackEntry`, which is wired to the WS data plane.
|
|
6626
6772
|
*/
|
|
6627
6773
|
async ackEntry(entryId, chatId) {
|
|
6628
6774
|
if (entryId === void 0) return;
|
|
6629
6775
|
try {
|
|
6630
|
-
|
|
6631
|
-
else await this.config.sdk.ack(entryId);
|
|
6776
|
+
await this.config.ackEntry(entryId);
|
|
6632
6777
|
} catch {
|
|
6633
6778
|
this.config.log.warn({
|
|
6634
6779
|
chatId,
|
|
@@ -6649,7 +6794,6 @@ var SessionManager = class {
|
|
|
6649
6794
|
this.currentTrigger.delete(chatId);
|
|
6650
6795
|
},
|
|
6651
6796
|
log,
|
|
6652
|
-
participants,
|
|
6653
6797
|
getDocumentBasePath: () => this.resolveDocumentBasePath(log)
|
|
6654
6798
|
});
|
|
6655
6799
|
const envCtx = {
|
|
@@ -6782,9 +6926,7 @@ var AgentSlot = class {
|
|
|
6782
6926
|
sessionManager = null;
|
|
6783
6927
|
config;
|
|
6784
6928
|
logger;
|
|
6785
|
-
sdk = null;
|
|
6786
6929
|
agentConfigCache = null;
|
|
6787
|
-
pollingTimer = null;
|
|
6788
6930
|
reconcileTimer = null;
|
|
6789
6931
|
listeners = [];
|
|
6790
6932
|
/**
|
|
@@ -6822,7 +6964,6 @@ var AgentSlot = class {
|
|
|
6822
6964
|
}
|
|
6823
6965
|
async start(contextTreeBinding) {
|
|
6824
6966
|
const sdk = (await this.clientConnection.bindAgent(this.config.agentId, this.config.runtimeType ?? this.config.type, this.config.runtimeVersion)).sdk;
|
|
6825
|
-
this.sdk = sdk;
|
|
6826
6967
|
const agent = await sdk.register();
|
|
6827
6968
|
this.logger.info({ displayName: agent.displayName }, "agent bound");
|
|
6828
6969
|
if (agent.type === "human") {
|
|
@@ -6842,9 +6983,6 @@ var AgentSlot = class {
|
|
|
6842
6983
|
throw new Error(`Hub unreachable while loading agent config: ${msg}`);
|
|
6843
6984
|
}
|
|
6844
6985
|
this.inboxId = agent.inboxId;
|
|
6845
|
-
const onMessage = (agentId) => {
|
|
6846
|
-
if (agentId === this.config.agentId) this.pullAndDispatch();
|
|
6847
|
-
};
|
|
6848
6986
|
const onInboxDeliver = (inboxId, frame) => {
|
|
6849
6987
|
if (inboxId !== this.inboxId) return;
|
|
6850
6988
|
this.dispatchPushedFrame(frame).catch((err) => {
|
|
@@ -6863,14 +7001,10 @@ var AgentSlot = class {
|
|
|
6863
7001
|
const onReconcileResult = (result) => {
|
|
6864
7002
|
if (result.agentId === this.config.agentId && this.sessionManager) this.sessionManager.applyStaleChatIds(result.staleChatIds);
|
|
6865
7003
|
};
|
|
6866
|
-
this.clientConnection.on("agent:message", onMessage);
|
|
6867
7004
|
this.clientConnection.on("inbox:deliver", onInboxDeliver);
|
|
6868
7005
|
this.clientConnection.on("agent:bound", onBound);
|
|
6869
7006
|
this.clientConnection.on("session:reconcile:result", onReconcileResult);
|
|
6870
7007
|
this.listeners.push({
|
|
6871
|
-
event: "agent:message",
|
|
6872
|
-
fn: onMessage
|
|
6873
|
-
}, {
|
|
6874
7008
|
event: "inbox:deliver",
|
|
6875
7009
|
fn: onInboxDeliver
|
|
6876
7010
|
}, {
|
|
@@ -6888,10 +7022,10 @@ var AgentSlot = class {
|
|
|
6888
7022
|
agentId: this.config.agentId
|
|
6889
7023
|
})
|
|
6890
7024
|
});
|
|
6891
|
-
const ackEntry =
|
|
7025
|
+
const ackEntry = (entryId) => {
|
|
6892
7026
|
this.clientConnection.sendInboxAck(entryId);
|
|
6893
7027
|
return Promise.resolve();
|
|
6894
|
-
}
|
|
7028
|
+
};
|
|
6895
7029
|
this.sessionManager = new SessionManager({
|
|
6896
7030
|
session: this.config.session,
|
|
6897
7031
|
concurrency: this.config.concurrency,
|
|
@@ -6934,24 +7068,15 @@ var AgentSlot = class {
|
|
|
6934
7068
|
event: "session:command",
|
|
6935
7069
|
fn: onCommand
|
|
6936
7070
|
});
|
|
6937
|
-
this.startPolling();
|
|
6938
7071
|
this.startReconcileLoop();
|
|
6939
7072
|
return agent;
|
|
6940
7073
|
}
|
|
6941
7074
|
async stop() {
|
|
6942
|
-
if (this.pollingTimer) {
|
|
6943
|
-
clearInterval(this.pollingTimer);
|
|
6944
|
-
this.pollingTimer = null;
|
|
6945
|
-
}
|
|
6946
7075
|
if (this.reconcileTimer) {
|
|
6947
7076
|
clearInterval(this.reconcileTimer);
|
|
6948
7077
|
this.reconcileTimer = null;
|
|
6949
7078
|
}
|
|
6950
|
-
for (const entry of this.listeners)
|
|
6951
|
-
else if (entry.event === "inbox:deliver") this.clientConnection.off(entry.event, entry.fn);
|
|
6952
|
-
else if (entry.event === "agent:bound") this.clientConnection.off(entry.event, entry.fn);
|
|
6953
|
-
else if (entry.event === "session:reconcile:result") this.clientConnection.off(entry.event, entry.fn);
|
|
6954
|
-
else this.clientConnection.off(entry.event, entry.fn);
|
|
7079
|
+
for (const entry of this.listeners) this.clientConnection.off(entry.event, entry.fn);
|
|
6955
7080
|
this.listeners = [];
|
|
6956
7081
|
await this.clientConnection.unbindAgent(this.config.agentId);
|
|
6957
7082
|
await this.sessionManager?.shutdown();
|
|
@@ -6972,31 +7097,18 @@ var AgentSlot = class {
|
|
|
6972
7097
|
const runtimeState = this.sessionManager.getAggregateRuntimeState();
|
|
6973
7098
|
if (runtimeState) this.clientConnection.reportRuntimeState(this.config.agentId, runtimeState);
|
|
6974
7099
|
}
|
|
6975
|
-
startPolling() {
|
|
6976
|
-
if (this.clientConnection.supportsWsInboxDeliver) {
|
|
6977
|
-
this.logger.info("WS inbox data plane active — skipping 5s HTTP poll");
|
|
6978
|
-
return;
|
|
6979
|
-
}
|
|
6980
|
-
this.pollingTimer = setInterval(() => {
|
|
6981
|
-
this.pullAndDispatch();
|
|
6982
|
-
}, 5e3);
|
|
6983
|
-
this.pullAndDispatch();
|
|
6984
|
-
}
|
|
6985
7100
|
/**
|
|
6986
7101
|
* Translate an `inbox:deliver` push frame into the {@link InboxEntryWithMessage}
|
|
6987
7102
|
* shape `SessionManager.dispatch` expects, then dispatch.
|
|
6988
7103
|
*
|
|
6989
7104
|
* Ack happens INSIDE `dispatch` via the `ackEntry` callback we pinned at
|
|
6990
|
-
* construction time —
|
|
6991
|
-
*
|
|
6992
|
-
*
|
|
6993
|
-
*
|
|
6994
|
-
* which leaks the server's per-agent in-flight counter and stalls push
|
|
6995
|
-
* after `inboxMaxInFlightPerAgent` messages.
|
|
7105
|
+
* construction time — `clientConnection.sendInboxAck`. Sending an additional
|
|
7106
|
+
* ack here would double-ack: a WS frame the server cannot match against any
|
|
7107
|
+
* `delivered` row, which leaks the server's per-agent in-flight counter and
|
|
7108
|
+
* stalls push after `inboxMaxInFlightPerAgent` messages.
|
|
6996
7109
|
*
|
|
6997
7110
|
* Dispatch errors propagate up; the entry stays `delivered` server-side
|
|
6998
|
-
* and the 300s timeout reaper rolls it back to `pending` for replay
|
|
6999
|
-
* (proposal §3.7).
|
|
7111
|
+
* and the 300s timeout reaper rolls it back to `pending` for replay.
|
|
7000
7112
|
*/
|
|
7001
7113
|
async dispatchPushedFrame(frame) {
|
|
7002
7114
|
if (!this.sessionManager) return;
|
|
@@ -7024,15 +7136,6 @@ var AgentSlot = class {
|
|
|
7024
7136
|
if (chatIds.length === 0) return;
|
|
7025
7137
|
this.clientConnection.sendSessionReconcile(this.config.agentId, chatIds);
|
|
7026
7138
|
}
|
|
7027
|
-
async pullAndDispatch() {
|
|
7028
|
-
if (!this.sdk || !this.sessionManager) return;
|
|
7029
|
-
try {
|
|
7030
|
-
const { entries } = await this.sdk.pull(10);
|
|
7031
|
-
for (const entry of entries) await this.sessionManager.dispatch(entry);
|
|
7032
|
-
} catch (err) {
|
|
7033
|
-
this.logger.warn({ err }, "poll error");
|
|
7034
|
-
}
|
|
7035
|
-
}
|
|
7036
7139
|
};
|
|
7037
7140
|
/**
|
|
7038
7141
|
* Top-level marker file Claude Code writes after a successful OAuth login.
|
|
@@ -9587,7 +9690,7 @@ function formatCheckReport(items) {
|
|
|
9587
9690
|
}
|
|
9588
9691
|
return lines.join("\n");
|
|
9589
9692
|
}
|
|
9590
|
-
async function resolveDefaultOrgId
|
|
9693
|
+
async function resolveDefaultOrgId(serverUrl, accessToken) {
|
|
9591
9694
|
const res = await cliFetch(`${serverUrl}/api/v1/me`, {
|
|
9592
9695
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
9593
9696
|
signal: AbortSignal.timeout(1e4)
|
|
@@ -9622,7 +9725,7 @@ async function onboardCreate(args) {
|
|
|
9622
9725
|
if (args.role) metadata.role = args.role;
|
|
9623
9726
|
if (args.domains) metadata.domains = args.domains.split(",").map((d) => d.trim());
|
|
9624
9727
|
print.line(`Creating agent "${args.id}"...\n`);
|
|
9625
|
-
const orgId = await resolveDefaultOrgId
|
|
9728
|
+
const orgId = await resolveDefaultOrgId(serverUrl, accessToken);
|
|
9626
9729
|
const primary = await createAgentViaAdmin(serverUrl, accessToken, orgId, {
|
|
9627
9730
|
name: args.id,
|
|
9628
9731
|
type: args.type,
|
|
@@ -9659,7 +9762,7 @@ async function onboardCreate(args) {
|
|
|
9659
9762
|
}
|
|
9660
9763
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
9661
9764
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
9662
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
9765
|
+
const { bindFeishuBot } = await import("./feishu-DHSy6WDD.mjs").then((n) => n.r);
|
|
9663
9766
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
9664
9767
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
9665
9768
|
else {
|
|
@@ -10872,7 +10975,7 @@ function createFeedbackHandler(config) {
|
|
|
10872
10975
|
return { handle };
|
|
10873
10976
|
}
|
|
10874
10977
|
//#endregion
|
|
10875
|
-
//#region ../server/dist/app-
|
|
10978
|
+
//#region ../server/dist/app-z2-jRXbZ.mjs
|
|
10876
10979
|
var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
|
|
10877
10980
|
init_esm();
|
|
10878
10981
|
var __defProp = Object.defineProperty;
|
|
@@ -10885,17 +10988,6 @@ var __exportAll = (all, no_symbols) => {
|
|
|
10885
10988
|
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
10886
10989
|
return target;
|
|
10887
10990
|
};
|
|
10888
|
-
/** Maps external user identities to internal Agents. */
|
|
10889
|
-
const adapterAgentMappings = pgTable("adapter_agent_mappings", {
|
|
10890
|
-
id: serial("id").primaryKey(),
|
|
10891
|
-
platform: text("platform").notNull(),
|
|
10892
|
-
externalUserId: text("external_user_id").notNull(),
|
|
10893
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
10894
|
-
boundVia: text("bound_via"),
|
|
10895
|
-
displayName: text("display_name"),
|
|
10896
|
-
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
10897
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
10898
|
-
}, (table) => [unique("uq_adapter_agent_mapping").on(table.platform, table.externalUserId)]);
|
|
10899
10991
|
/**
|
|
10900
10992
|
* Pull the JWT-verified `UserScope` off the request. The `userAuthHook`
|
|
10901
10993
|
* middleware populates `request.user` synchronously before any handler
|
|
@@ -10956,10 +11048,23 @@ async function requireAgentAccess(request, db, kind) {
|
|
|
10956
11048
|
};
|
|
10957
11049
|
}
|
|
10958
11050
|
/**
|
|
10959
|
-
* Gate access to a chat. Allowed if the caller's HUMAN agent
|
|
10960
|
-
*
|
|
10961
|
-
*
|
|
10962
|
-
* remains private to
|
|
11051
|
+
* Gate access to a chat. Allowed if the caller's HUMAN agent has any
|
|
11052
|
+
* `chat_membership` row (speaker OR watcher), OR any agent the caller
|
|
11053
|
+
* manages (via members.id) is a speaker. Admin role does NOT auto-grant
|
|
11054
|
+
* chat access — chat content remains private to members and supervisors
|
|
11055
|
+
* (their managers).
|
|
11056
|
+
*
|
|
11057
|
+
* Watchers are allowed on the direct-membership branch because they
|
|
11058
|
+
* surface in `listMeChats` with their own unread badge and engagement
|
|
11059
|
+
* state; chat-scoped per-user operations like read-cursor and
|
|
11060
|
+
* watcher→speaker upgrade must be reachable from that surface. Write
|
|
11061
|
+
* endpoints that need to refuse watchers rely on `ensureParticipant`
|
|
11062
|
+
* or service-layer checks, not on this guard.
|
|
11063
|
+
*
|
|
11064
|
+
* The supervisor branch is a fallback for callers whose human agent
|
|
11065
|
+
* has no direct row but who manage a speaker — e.g. before
|
|
11066
|
+
* `recomputeChatWatchers` has materialised the watcher row, or when a
|
|
11067
|
+
* member's human agent and managed agent diverge in cross-org chats.
|
|
10963
11068
|
*
|
|
10964
11069
|
* The Params type is generic so routes that mount on a path with extra
|
|
10965
11070
|
* params (e.g. `/agents/:uuid/sessions/:chatId/...` for compound checks)
|
|
@@ -10979,7 +11084,7 @@ async function requireChatAccess(request, db) {
|
|
|
10979
11084
|
role: caller.role,
|
|
10980
11085
|
humanAgentId: caller.humanAgentId
|
|
10981
11086
|
};
|
|
10982
|
-
const [direct] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, caller.humanAgentId)
|
|
11087
|
+
const [direct] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, caller.humanAgentId))).limit(1);
|
|
10983
11088
|
if (direct) {
|
|
10984
11089
|
stampOrgScope(request, scope);
|
|
10985
11090
|
stampChatResource(request, chat);
|
|
@@ -11068,16 +11173,6 @@ async function adapterMappingRoutes(app) {
|
|
|
11068
11173
|
return reply.status(204).send();
|
|
11069
11174
|
});
|
|
11070
11175
|
}
|
|
11071
|
-
/** Bot credentials for external platform adapters. Credentials are encrypted at application layer (AES-256-GCM). */
|
|
11072
|
-
const adapterConfigs = pgTable("adapter_configs", {
|
|
11073
|
-
id: serial("id").primaryKey(),
|
|
11074
|
-
platform: text("platform").notNull(),
|
|
11075
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
11076
|
-
credentials: jsonb("credentials").$type().notNull(),
|
|
11077
|
-
status: text("status").notNull().default("active"),
|
|
11078
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
11079
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
11080
|
-
}, (t) => [unique("uq_adapter_configs_agent_platform").on(t.agentId, t.platform)]);
|
|
11081
11176
|
const ALGORITHM = "aes-256-gcm";
|
|
11082
11177
|
const IV_LENGTH = 12;
|
|
11083
11178
|
const AUTH_TAG_LENGTH = 16;
|
|
@@ -11346,7 +11441,7 @@ async function agentChatRoutes(app) {
|
|
|
11346
11441
|
app.get("/:chatId", async (request) => {
|
|
11347
11442
|
const identity = requireAgent(request);
|
|
11348
11443
|
await assertParticipant(app.db, request.params.chatId, identity.uuid);
|
|
11349
|
-
const detail = await getChatDetail(app.db, request.params.chatId);
|
|
11444
|
+
const detail = await getChatDetail(app.db, request.params.chatId, identity.uuid);
|
|
11350
11445
|
return {
|
|
11351
11446
|
...serializeChat(detail),
|
|
11352
11447
|
participants: detail.participants.map((p) => ({
|
|
@@ -11411,710 +11506,124 @@ async function agentConfigRoutes$1(app) {
|
|
|
11411
11506
|
return await app.configService.getDecrypted(identity.uuid);
|
|
11412
11507
|
});
|
|
11413
11508
|
}
|
|
11414
|
-
|
|
11415
|
-
|
|
11416
|
-
|
|
11417
|
-
*
|
|
11418
|
-
*
|
|
11419
|
-
|
|
11420
|
-
|
|
11421
|
-
|
|
11422
|
-
|
|
11423
|
-
|
|
11424
|
-
|
|
11425
|
-
|
|
11426
|
-
|
|
11427
|
-
|
|
11428
|
-
|
|
11429
|
-
|
|
11430
|
-
|
|
11431
|
-
|
|
11432
|
-
|
|
11433
|
-
|
|
11434
|
-
|
|
11435
|
-
|
|
11436
|
-
|
|
11437
|
-
|
|
11438
|
-
|
|
11439
|
-
|
|
11440
|
-
|
|
11441
|
-
}
|
|
11442
|
-
|
|
11443
|
-
|
|
11444
|
-
|
|
11445
|
-
|
|
11446
|
-
|
|
11447
|
-
|
|
11448
|
-
|
|
11449
|
-
|
|
11450
|
-
|
|
11451
|
-
|
|
11452
|
-
|
|
11453
|
-
|
|
11454
|
-
|
|
11455
|
-
const
|
|
11456
|
-
|
|
11457
|
-
return
|
|
11458
|
-
|
|
11459
|
-
|
|
11460
|
-
|
|
11461
|
-
|
|
11462
|
-
}
|
|
11463
|
-
/**
|
|
11464
|
-
* Ensure the default organization exists. Called on server startup.
|
|
11465
|
-
* Uses a fixed UUID for the default org to ensure idempotency.
|
|
11466
|
-
*/
|
|
11467
|
-
async function ensureDefaultOrganization(db) {
|
|
11468
|
-
const [existing] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, "default")).limit(1);
|
|
11469
|
-
if (existing) return existing;
|
|
11470
|
-
const id = uuidv7();
|
|
11471
|
-
const [org] = await db.insert(organizations).values({
|
|
11472
|
-
id,
|
|
11473
|
-
name: "default",
|
|
11474
|
-
displayName: "Default Organization"
|
|
11475
|
-
}).onConflictDoNothing().returning();
|
|
11476
|
-
return org ?? existing;
|
|
11509
|
+
const log$5 = createLogger$1("AgentFeishuBot");
|
|
11510
|
+
async function agentFeishuBotRoutes(app) {
|
|
11511
|
+
/**
|
|
11512
|
+
* PUT /agent/me/feishu-bot
|
|
11513
|
+
* Self-service: agent binds its own Feishu bot (upsert).
|
|
11514
|
+
*/
|
|
11515
|
+
app.put("/me/feishu-bot", async (request, reply) => {
|
|
11516
|
+
const identity = requireAgent(request);
|
|
11517
|
+
const body = selfServiceFeishuBotSchema.parse(request.body);
|
|
11518
|
+
if ((await getAgent(app.db, identity.uuid)).type === "human") throw new BadRequestError("Human agents cannot bind Feishu bots. Use bind-user instead.");
|
|
11519
|
+
const current = (await listAdapterConfigs(app.db)).find((c) => c.agentId === identity.uuid && c.platform === "feishu");
|
|
11520
|
+
let config;
|
|
11521
|
+
if (current) config = await updateAdapterConfig(app.db, current.id, {
|
|
11522
|
+
credentials: {
|
|
11523
|
+
app_id: body.appId,
|
|
11524
|
+
app_secret: body.appSecret
|
|
11525
|
+
},
|
|
11526
|
+
status: "active"
|
|
11527
|
+
}, app.config.secrets.encryptionKey);
|
|
11528
|
+
else config = await createAdapterConfig(app.db, {
|
|
11529
|
+
platform: "feishu",
|
|
11530
|
+
agentId: identity.uuid,
|
|
11531
|
+
credentials: {
|
|
11532
|
+
app_id: body.appId,
|
|
11533
|
+
app_secret: body.appSecret
|
|
11534
|
+
},
|
|
11535
|
+
status: "active"
|
|
11536
|
+
}, app.config.secrets.encryptionKey);
|
|
11537
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after self-service bind"));
|
|
11538
|
+
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11539
|
+
return reply.status(current ? 200 : 201).send({
|
|
11540
|
+
...config,
|
|
11541
|
+
createdAt: config.createdAt.toISOString(),
|
|
11542
|
+
updatedAt: config.updatedAt.toISOString()
|
|
11543
|
+
});
|
|
11544
|
+
});
|
|
11545
|
+
/**
|
|
11546
|
+
* DELETE /agent/me/feishu-bot
|
|
11547
|
+
* Self-service: agent unbinds its own Feishu bot.
|
|
11548
|
+
*/
|
|
11549
|
+
app.delete("/me/feishu-bot", async (request, reply) => {
|
|
11550
|
+
const identity = requireAgent(request);
|
|
11551
|
+
const current = (await listAdapterConfigs(app.db)).find((c) => c.agentId === identity.uuid && c.platform === "feishu");
|
|
11552
|
+
if (!current) return reply.status(204).send();
|
|
11553
|
+
await deleteAdapterConfig(app.db, current.id);
|
|
11554
|
+
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after self-service unbind"));
|
|
11555
|
+
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
11556
|
+
return reply.status(204).send();
|
|
11557
|
+
});
|
|
11477
11558
|
}
|
|
11478
11559
|
/**
|
|
11479
|
-
*
|
|
11480
|
-
*
|
|
11481
|
-
*
|
|
11560
|
+
* Maps internal Chats to external IM platform channels.
|
|
11561
|
+
* NOTE: The unique constraint uses COALESCE(thread_id, '') which cannot be
|
|
11562
|
+
* expressed in Drizzle ORM. It is defined in the migration SQL directly as:
|
|
11563
|
+
* CREATE UNIQUE INDEX uq_adapter_chat_mapping ON adapter_chat_mappings
|
|
11564
|
+
* (platform, external_channel_id, COALESCE(thread_id, ''));
|
|
11482
11565
|
*/
|
|
11483
|
-
const
|
|
11566
|
+
const adapterChatMappings = pgTable("adapter_chat_mappings", {
|
|
11567
|
+
id: serial("id").primaryKey(),
|
|
11568
|
+
platform: text("platform").notNull(),
|
|
11569
|
+
externalChannelId: text("external_channel_id").notNull(),
|
|
11570
|
+
chatId: text("chat_id").notNull().references(() => chats.id),
|
|
11571
|
+
threadId: text("thread_id"),
|
|
11572
|
+
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
11573
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
11574
|
+
});
|
|
11575
|
+
/** Cross-reference between internal messages and external platform message IDs. */
|
|
11576
|
+
const adapterMessageReferences = pgTable("adapter_message_references", {
|
|
11577
|
+
id: serial("id").primaryKey(),
|
|
11578
|
+
messageId: text("message_id").notNull().references(() => messages.id),
|
|
11579
|
+
platform: text("platform").notNull(),
|
|
11580
|
+
externalMessageId: text("external_message_id").notNull(),
|
|
11581
|
+
externalChannelId: text("external_channel_id").notNull(),
|
|
11582
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
11583
|
+
}, (table) => [unique("uq_adapter_message_ref").on(table.messageId, table.platform)]);
|
|
11484
11584
|
/**
|
|
11485
|
-
*
|
|
11486
|
-
*
|
|
11487
|
-
* timestamp as `?v=<epoch>` so a fresh upload busts any browser cache
|
|
11488
|
-
* that may have memoised the previous version.
|
|
11489
|
-
*
|
|
11490
|
-
* Auth: the image route is intentionally public read — the URL leaks no
|
|
11491
|
-
* more than the agent's UUID, which is already required to address it.
|
|
11492
|
-
* Keeping it unauthenticated lets `<img src>` render without bespoke
|
|
11493
|
-
* fetch-and-blob plumbing.
|
|
11585
|
+
* Attempt to claim an event for processing.
|
|
11586
|
+
* Returns true if this is the first time the event is seen, false if duplicate.
|
|
11494
11587
|
*/
|
|
11495
|
-
function
|
|
11496
|
-
|
|
11497
|
-
return `/api/v1/agents/${uuid}/avatar?v=${updatedAt.getTime()}`;
|
|
11588
|
+
async function claimEvent(db, eventId, platform) {
|
|
11589
|
+
return (await db.execute(sql`INSERT INTO processed_events (event_id, platform) VALUES (${eventId}, ${platform}) ON CONFLICT DO NOTHING RETURNING event_id`)).length > 0;
|
|
11498
11590
|
}
|
|
11499
11591
|
/**
|
|
11500
|
-
*
|
|
11501
|
-
*
|
|
11502
|
-
* "we don't know what's installed yet" (empty / never reported) from
|
|
11503
|
-
* "client explicitly reports this provider is missing".
|
|
11592
|
+
* Remove a claimed event so it can be retried on next delivery.
|
|
11593
|
+
* Called when processing fails after claimEvent() succeeded.
|
|
11504
11594
|
*/
|
|
11505
|
-
function
|
|
11506
|
-
|
|
11507
|
-
const caps = metadata.capabilities;
|
|
11508
|
-
if (!caps || typeof caps !== "object") return false;
|
|
11509
|
-
return Object.keys(caps).length > 0;
|
|
11595
|
+
async function unclaimEvent(db, eventId, platform) {
|
|
11596
|
+
await db.execute(sql`DELETE FROM processed_events WHERE event_id = ${eventId} AND platform = ${platform}`);
|
|
11510
11597
|
}
|
|
11511
|
-
/**
|
|
11512
|
-
|
|
11513
|
-
|
|
11514
|
-
|
|
11515
|
-
|
|
11516
|
-
|
|
11517
|
-
|
|
11518
|
-
|
|
11519
|
-
|
|
11520
|
-
|
|
11521
|
-
|
|
11522
|
-
|
|
11523
|
-
|
|
11524
|
-
|
|
11525
|
-
|
|
11526
|
-
|
|
11527
|
-
|
|
11528
|
-
|
|
11529
|
-
|
|
11530
|
-
|
|
11531
|
-
|
|
11532
|
-
|
|
11533
|
-
|
|
11534
|
-
|
|
11535
|
-
|
|
11536
|
-
|
|
11537
|
-
|
|
11598
|
+
/** Look up the internal agent ID for an external user. */
|
|
11599
|
+
async function findAgentByExternalUser(db, platform, externalUserId) {
|
|
11600
|
+
const [row] = await db.select({
|
|
11601
|
+
agentId: adapterAgentMappings.agentId,
|
|
11602
|
+
displayName: adapterAgentMappings.displayName
|
|
11603
|
+
}).from(adapterAgentMappings).where(and(eq(adapterAgentMappings.platform, platform), eq(adapterAgentMappings.externalUserId, externalUserId))).limit(1);
|
|
11604
|
+
return row ?? null;
|
|
11605
|
+
}
|
|
11606
|
+
/** Look up the external user ID for an internal agent. */
|
|
11607
|
+
async function findExternalUserByAgent(db, platform, agentId) {
|
|
11608
|
+
const [row] = await db.select({ externalUserId: adapterAgentMappings.externalUserId }).from(adapterAgentMappings).where(and(eq(adapterAgentMappings.platform, platform), eq(adapterAgentMappings.agentId, agentId))).limit(1);
|
|
11609
|
+
return row ?? null;
|
|
11610
|
+
}
|
|
11611
|
+
/** Create an agent mapping. */
|
|
11612
|
+
async function createAgentMapping(db, data) {
|
|
11613
|
+
const [row] = await db.insert(adapterAgentMappings).values({
|
|
11614
|
+
platform: data.platform,
|
|
11615
|
+
externalUserId: data.externalUserId,
|
|
11616
|
+
agentId: data.agentId,
|
|
11617
|
+
boundVia: data.boundVia ?? null,
|
|
11618
|
+
displayName: data.displayName ?? null,
|
|
11619
|
+
metadata: data.metadata ?? {}
|
|
11620
|
+
}).onConflictDoNothing().returning();
|
|
11621
|
+
if (!row) {
|
|
11622
|
+
const [existing] = await db.select().from(adapterAgentMappings).where(and(eq(adapterAgentMappings.platform, data.platform), eq(adapterAgentMappings.externalUserId, data.externalUserId))).limit(1);
|
|
11623
|
+
if (!existing) throw new Error("Unexpected: concurrent insert failed and row not found");
|
|
11624
|
+
return existing;
|
|
11538
11625
|
}
|
|
11539
|
-
|
|
11540
|
-
/**
|
|
11541
|
-
* Resolve + validate the client that will own the new agent.
|
|
11542
|
-
*
|
|
11543
|
-
* Rule (unified-user-token, post-first-bind relaxation):
|
|
11544
|
-
* - Human agents represent the member themselves and have no runtime; a
|
|
11545
|
-
* missing `clientId` is required and the column stays NULL.
|
|
11546
|
-
* - Non-human agents MAY omit `clientId` at creation; the row stays NULL
|
|
11547
|
-
* and is claimed on the first WS bind (see `api/agent/ws-client.ts`).
|
|
11548
|
-
* - When a non-human agent IS created with a `clientId`, the pinned client
|
|
11549
|
-
* must already be owned by the manager's user (Rule R-RUN).
|
|
11550
|
-
*/
|
|
11551
|
-
/**
|
|
11552
|
-
* Check that a client's reported capabilities show the given runtime provider
|
|
11553
|
-
* as **available** (SDK installed, regardless of auth state).
|
|
11554
|
-
*
|
|
11555
|
-
* Tri-state semantics by `clients.metadata.capabilities` shape:
|
|
11556
|
-
* - empty / absent — client hasn't probed yet (newly registered or pre-P2
|
|
11557
|
-
* install). Treat as "unknown" and allow; the in-band repair path
|
|
11558
|
-
* (RUNTIME_PROVIDER_MISMATCH on bind) catches actual incompatibility.
|
|
11559
|
-
* - reported, entry shows `state: ok | unauthenticated` (i.e. `available:
|
|
11560
|
-
* true`) — allow.
|
|
11561
|
-
* - reported, entry missing OR `state: missing | error` — block unless
|
|
11562
|
-
* `force` is set. We deliberately do NOT treat mere key presence as
|
|
11563
|
-
* support: probeCapabilities() always emits an entry per built-in
|
|
11564
|
-
* provider, including `{ state: "missing" }` for absent SDKs.
|
|
11565
|
-
*
|
|
11566
|
-
* Skipped entirely for human agents (no clientId) and when `force` is set
|
|
11567
|
-
* (e.g. operator overrides for an offline client).
|
|
11568
|
-
*/
|
|
11569
|
-
async function ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, options = {}) {
|
|
11570
|
-
if (clientId === null) return;
|
|
11571
|
-
if (options.force) return;
|
|
11572
|
-
const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
11573
|
-
if (!client) return;
|
|
11574
|
-
if (!clientCapabilitiesReported(client.metadata)) return;
|
|
11575
|
-
if (!clientSupportsRuntimeProvider(client.metadata, runtimeProvider)) throw new BadRequestError(`Client "${clientId}" does not have runtime provider "${runtimeProvider}" available. Install the matching SDK on that machine and re-run capability detection, or retry with \`force: true\` if the client is offline / capabilities are stale.`);
|
|
11576
|
-
}
|
|
11577
|
-
async function resolveAgentClient(db, data) {
|
|
11578
|
-
if (data.type === "human") {
|
|
11579
|
-
if (data.clientId) throw new BadRequestError("Human agents cannot be pinned to a client");
|
|
11580
|
-
return null;
|
|
11581
|
-
}
|
|
11582
|
-
if (!data.clientId) return null;
|
|
11583
|
-
const [manager] = await db.select({ userId: members.userId }).from(members).where(eq(members.id, data.managerId)).limit(1);
|
|
11584
|
-
if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
|
|
11585
|
-
const [client] = await db.select({
|
|
11586
|
-
id: clients.id,
|
|
11587
|
-
userId: clients.userId
|
|
11588
|
-
}).from(clients).where(eq(clients.id, data.clientId)).limit(1);
|
|
11589
|
-
if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
|
|
11590
|
-
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.`);
|
|
11591
|
-
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.`);
|
|
11592
|
-
return client.id;
|
|
11593
|
-
}
|
|
11594
|
-
/**
|
|
11595
|
-
* Validate a `delegateMention` write at the service layer. Two checks:
|
|
11596
|
-
* 1. Target uuid must resolve to an existing agent — dangling references
|
|
11597
|
-
* would silently break webhook delegation at runtime.
|
|
11598
|
-
* 2. Target must belong to the same organization as the source agent —
|
|
11599
|
-
* cross-org delegate links are rejected here at the source so the
|
|
11600
|
-
* database never accumulates dirty rows. The webhook router has a
|
|
11601
|
-
* defense-in-depth check that filters them at fan-out time, but this
|
|
11602
|
-
* keeps the data clean and gives the admin UI an immediate 422 instead
|
|
11603
|
-
* of a silent runtime drop.
|
|
11604
|
-
*
|
|
11605
|
-
* `null` clears the field — handled by the caller; we are only invoked when
|
|
11606
|
-
* the caller wrote a non-null uuid.
|
|
11607
|
-
*/
|
|
11608
|
-
async function validateDelegateMentionTarget(db, targetUuid, sourceOrgId) {
|
|
11609
|
-
const [target] = await db.select({
|
|
11610
|
-
uuid: agents.uuid,
|
|
11611
|
-
organizationId: agents.organizationId
|
|
11612
|
-
}).from(agents).where(eq(agents.uuid, targetUuid)).limit(1);
|
|
11613
|
-
if (!target) throw new BadRequestError(`delegateMention target "${targetUuid}" not found`);
|
|
11614
|
-
if (target.organizationId !== sourceOrgId) throw new BadRequestError("delegateMention target must belong to the same organization as the agent");
|
|
11615
|
-
}
|
|
11616
|
-
/**
|
|
11617
|
-
* Service-layer guard: `delegateMention` is only available for `human` agents.
|
|
11618
|
-
* Mirrors the Web UI in `identity-section.tsx`, which only renders the
|
|
11619
|
-
* delegate-mention selector when `agent.type === "human"`. Without this
|
|
11620
|
-
* server-side check, CLI / Admin API / internal scripts could write
|
|
11621
|
-
* delegateMention onto non-human rows, silently re-enabling the
|
|
11622
|
-
* autonomous-agent-self-mention path that resolveAudience would then fan
|
|
11623
|
-
* out. Called from `createAgent` / `updateAgent` before
|
|
11624
|
-
* `validateDelegateMentionTarget` so a wrong source type fails fast without
|
|
11625
|
-
* the target lookup round-trip.
|
|
11626
|
-
*/
|
|
11627
|
-
function assertDelegateMentionAllowed(sourceType) {
|
|
11628
|
-
if (sourceType !== AGENT_TYPES.HUMAN) throw new BadRequestError("delegateMention can only be set on human agents");
|
|
11629
|
-
}
|
|
11630
|
-
/**
|
|
11631
|
-
* Pick the first admin member in the org for internal system agents. Throws
|
|
11632
|
-
* if the org has no admin — the caller should surface the error so an admin
|
|
11633
|
-
* is created before the system tries to register more agents.
|
|
11634
|
-
*/
|
|
11635
|
-
async function resolveFallbackManagerId(db, orgId) {
|
|
11636
|
-
const [row] = await db.select({ id: members.id }).from(members).where(and(eq(members.organizationId, orgId), eq(members.role, "admin"))).orderBy(members.createdAt).limit(1);
|
|
11637
|
-
if (!row) throw new BadRequestError(`Cannot create agent in organization "${orgId}" — no admin member exists. Create an admin member first (see \`first-tree-hub onboard\`).`);
|
|
11638
|
-
return row.id;
|
|
11639
|
-
}
|
|
11640
|
-
async function createAgent(db, data, options = {}) {
|
|
11641
|
-
const uuid = uuidv7();
|
|
11642
|
-
const name = data.name ?? null;
|
|
11643
|
-
const runtimeProvider = data.runtimeProvider ?? "claude-code";
|
|
11644
|
-
if (name?.startsWith(RESERVED_AGENT_NAME_PREFIX)) throw new BadRequestError(`Agent name "${name}" is reserved — names starting with "${RESERVED_AGENT_NAME_PREFIX}" are Hub-internal`);
|
|
11645
|
-
if (name && isReservedAgentName$1(name)) throw new BadRequestError(`Agent name "${name}" is reserved — pick a different one.`);
|
|
11646
|
-
const inboxId = `inbox_${uuid}`;
|
|
11647
|
-
let orgId;
|
|
11648
|
-
let managerId;
|
|
11649
|
-
if (data.managerId && data.organizationId) {
|
|
11650
|
-
orgId = data.organizationId;
|
|
11651
|
-
managerId = data.managerId;
|
|
11652
|
-
} else if (data.managerId) {
|
|
11653
|
-
const [manager] = await db.select({
|
|
11654
|
-
id: members.id,
|
|
11655
|
-
organizationId: members.organizationId
|
|
11656
|
-
}).from(members).where(eq(members.id, data.managerId)).limit(1);
|
|
11657
|
-
if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
|
|
11658
|
-
orgId = manager.organizationId;
|
|
11659
|
-
managerId = manager.id;
|
|
11660
|
-
} else {
|
|
11661
|
-
orgId = data.organizationId ?? await resolveDefaultOrgId(db);
|
|
11662
|
-
managerId = await resolveFallbackManagerId(db, orgId);
|
|
11663
|
-
}
|
|
11664
|
-
const clientId = await resolveAgentClient(db, {
|
|
11665
|
-
clientId: data.clientId,
|
|
11666
|
-
managerId,
|
|
11667
|
-
type: data.type
|
|
11668
|
-
});
|
|
11669
|
-
await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
|
|
11670
|
-
if (data.delegateMention) {
|
|
11671
|
-
assertDelegateMentionAllowed(data.type);
|
|
11672
|
-
await validateDelegateMentionTarget(db, data.delegateMention, orgId);
|
|
11673
|
-
}
|
|
11674
|
-
const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
|
|
11675
|
-
if (org && org.maxAgents > 0) {
|
|
11676
|
-
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.`);
|
|
11677
|
-
}
|
|
11678
|
-
const resolvedDisplayName = data.displayName?.trim() || name || "Unnamed Agent";
|
|
11679
|
-
try {
|
|
11680
|
-
return await db.transaction(async (tx) => {
|
|
11681
|
-
const [row] = await tx.insert(agents).values({
|
|
11682
|
-
uuid,
|
|
11683
|
-
name,
|
|
11684
|
-
organizationId: orgId,
|
|
11685
|
-
type: data.type,
|
|
11686
|
-
displayName: resolvedDisplayName,
|
|
11687
|
-
delegateMention: data.delegateMention ?? null,
|
|
11688
|
-
inboxId,
|
|
11689
|
-
source: data.source ?? null,
|
|
11690
|
-
visibility: data.visibility ?? defaultVisibility(data.type),
|
|
11691
|
-
metadata: data.metadata ?? {},
|
|
11692
|
-
managerId,
|
|
11693
|
-
clientId,
|
|
11694
|
-
runtimeProvider
|
|
11695
|
-
}).returning();
|
|
11696
|
-
if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
11697
|
-
const initialPayload = defaultRuntimeConfigPayload(runtimeProvider);
|
|
11698
|
-
if (data.gitRepos && data.gitRepos.length > 0) initialPayload.gitRepos = data.gitRepos;
|
|
11699
|
-
await tx.insert(agentConfigs).values({
|
|
11700
|
-
agentId: row.uuid,
|
|
11701
|
-
version: 1,
|
|
11702
|
-
payload: initialPayload,
|
|
11703
|
-
updatedBy: "system"
|
|
11704
|
-
}).onConflictDoNothing();
|
|
11705
|
-
return row;
|
|
11706
|
-
});
|
|
11707
|
-
} catch (err) {
|
|
11708
|
-
if ((err?.code ?? err?.cause?.code ?? "") === "23505" && name) throw new ConflictError(`Agent name "${name}" already exists in organization "${orgId}"`);
|
|
11709
|
-
throw err;
|
|
11710
|
-
}
|
|
11711
|
-
}
|
|
11712
|
-
async function checkAgentNameAvailability(db, orgId, name) {
|
|
11713
|
-
if (!AGENT_NAME_REGEX$1.test(name)) return {
|
|
11714
|
-
available: false,
|
|
11715
|
-
reason: "invalid"
|
|
11716
|
-
};
|
|
11717
|
-
if (isReservedAgentName$1(name) || name.startsWith(RESERVED_AGENT_NAME_PREFIX)) return {
|
|
11718
|
-
available: false,
|
|
11719
|
-
reason: "reserved"
|
|
11720
|
-
};
|
|
11721
|
-
const [existing] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, orgId), eq(agents.name, name), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
|
|
11722
|
-
return existing ? {
|
|
11723
|
-
available: false,
|
|
11724
|
-
reason: "taken"
|
|
11725
|
-
} : { available: true };
|
|
11726
|
-
}
|
|
11727
|
-
async function getAgent(db, uuid) {
|
|
11728
|
-
const [agent] = await db.select().from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
|
|
11729
|
-
if (!agent) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
11730
|
-
return agent;
|
|
11731
|
-
}
|
|
11732
|
-
/**
|
|
11733
|
-
* Admin-only variant: return every non-deleted agent in the org, ignoring
|
|
11734
|
-
* the visibility filter. Used by the `/admin` "All Agents" view so a team
|
|
11735
|
-
* admin can see and act on private agents owned by other members. The
|
|
11736
|
-
* route layer is responsible for gating this to admin callers — the
|
|
11737
|
-
* service does not enforce role by itself, but it does enforce org scope
|
|
11738
|
-
* and the not-deleted predicate.
|
|
11739
|
-
*/
|
|
11740
|
-
async function listAgentsForAdmin(db, scope, limit, cursor) {
|
|
11741
|
-
const conditions = [eq(agents.organizationId, scope.organizationId), ne(agents.status, AGENT_STATUSES.DELETED)];
|
|
11742
|
-
if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
|
|
11743
|
-
const where = and(...conditions);
|
|
11744
|
-
const rows = await db.select({
|
|
11745
|
-
uuid: agents.uuid,
|
|
11746
|
-
name: agents.name,
|
|
11747
|
-
organizationId: agents.organizationId,
|
|
11748
|
-
type: agents.type,
|
|
11749
|
-
displayName: agents.displayName,
|
|
11750
|
-
delegateMention: agents.delegateMention,
|
|
11751
|
-
inboxId: agents.inboxId,
|
|
11752
|
-
status: agents.status,
|
|
11753
|
-
visibility: agents.visibility,
|
|
11754
|
-
metadata: agents.metadata,
|
|
11755
|
-
managerId: agents.managerId,
|
|
11756
|
-
clientId: agents.clientId,
|
|
11757
|
-
runtimeProvider: agents.runtimeProvider,
|
|
11758
|
-
avatarColorToken: agents.avatarColorToken,
|
|
11759
|
-
avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
|
|
11760
|
-
createdAt: agents.createdAt,
|
|
11761
|
-
updatedAt: agents.updatedAt,
|
|
11762
|
-
presenceStatus: agentPresence.status,
|
|
11763
|
-
runtimeType: agentPresence.runtimeType,
|
|
11764
|
-
runtimeState: agentPresence.runtimeState,
|
|
11765
|
-
activeSessions: agentPresence.activeSessions
|
|
11766
|
-
}).from(agents).leftJoin(agentPresence, eq(agents.uuid, agentPresence.agentId)).where(where).orderBy(desc(agents.createdAt)).limit(limit + 1);
|
|
11767
|
-
const hasMore = rows.length > limit;
|
|
11768
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
11769
|
-
const last = items[items.length - 1];
|
|
11770
|
-
return {
|
|
11771
|
-
items,
|
|
11772
|
-
nextCursor: hasMore && last ? last.createdAt.toISOString() : null
|
|
11773
|
-
};
|
|
11774
|
-
}
|
|
11775
|
-
/**
|
|
11776
|
-
* List agents visible to a specific member.
|
|
11777
|
-
* Uses agentVisibilityCondition from access-control (same rules for all roles).
|
|
11778
|
-
*/
|
|
11779
|
-
async function listAgentsForMember(db, scope, limit, cursor, type) {
|
|
11780
|
-
const conditions = [agentVisibilityCondition(scope.organizationId, scope.memberId)];
|
|
11781
|
-
if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
|
|
11782
|
-
if (type) conditions.push(eq(agents.type, type));
|
|
11783
|
-
const where = and(...conditions);
|
|
11784
|
-
const rows = await db.select({
|
|
11785
|
-
uuid: agents.uuid,
|
|
11786
|
-
name: agents.name,
|
|
11787
|
-
organizationId: agents.organizationId,
|
|
11788
|
-
type: agents.type,
|
|
11789
|
-
displayName: agents.displayName,
|
|
11790
|
-
delegateMention: agents.delegateMention,
|
|
11791
|
-
inboxId: agents.inboxId,
|
|
11792
|
-
status: agents.status,
|
|
11793
|
-
visibility: agents.visibility,
|
|
11794
|
-
metadata: agents.metadata,
|
|
11795
|
-
managerId: agents.managerId,
|
|
11796
|
-
clientId: agents.clientId,
|
|
11797
|
-
runtimeProvider: agents.runtimeProvider,
|
|
11798
|
-
avatarColorToken: agents.avatarColorToken,
|
|
11799
|
-
avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
|
|
11800
|
-
createdAt: agents.createdAt,
|
|
11801
|
-
updatedAt: agents.updatedAt,
|
|
11802
|
-
presenceStatus: agentPresence.status,
|
|
11803
|
-
runtimeType: agentPresence.runtimeType,
|
|
11804
|
-
runtimeState: agentPresence.runtimeState,
|
|
11805
|
-
activeSessions: agentPresence.activeSessions
|
|
11806
|
-
}).from(agents).leftJoin(agentPresence, eq(agents.uuid, agentPresence.agentId)).where(where).orderBy(desc(agents.createdAt)).limit(limit + 1);
|
|
11807
|
-
const hasMore = rows.length > limit;
|
|
11808
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
11809
|
-
const last = items[items.length - 1];
|
|
11810
|
-
return {
|
|
11811
|
-
items,
|
|
11812
|
-
nextCursor: hasMore && last ? last.createdAt.toISOString() : null
|
|
11813
|
-
};
|
|
11814
|
-
}
|
|
11815
|
-
async function updateAgent(db, uuid, data) {
|
|
11816
|
-
const agent = await getAgent(db, uuid);
|
|
11817
|
-
if (data.clientId !== void 0) {
|
|
11818
|
-
if (data.clientId === null) throw new BadRequestError("clientId cannot be cleared — once bound, an agent stays bound to its client");
|
|
11819
|
-
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.");
|
|
11820
|
-
}
|
|
11821
|
-
const updates = { updatedAt: /* @__PURE__ */ new Date() };
|
|
11822
|
-
if (data.type !== void 0) {
|
|
11823
|
-
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.");
|
|
11824
|
-
updates.type = data.type;
|
|
11825
|
-
}
|
|
11826
|
-
if (data.displayName !== void 0) updates.displayName = data.displayName;
|
|
11827
|
-
if (data.delegateMention !== void 0) {
|
|
11828
|
-
if (data.delegateMention !== null) {
|
|
11829
|
-
assertDelegateMentionAllowed(data.type ?? agent.type);
|
|
11830
|
-
await validateDelegateMentionTarget(db, data.delegateMention, agent.organizationId);
|
|
11831
|
-
}
|
|
11832
|
-
updates.delegateMention = data.delegateMention;
|
|
11833
|
-
}
|
|
11834
|
-
if (data.visibility !== void 0) updates.visibility = data.visibility;
|
|
11835
|
-
if (data.metadata !== void 0) updates.metadata = data.metadata;
|
|
11836
|
-
if (data.avatarColorToken !== void 0) updates.avatarColorToken = data.avatarColorToken;
|
|
11837
|
-
if (data.managerId !== void 0) {
|
|
11838
|
-
if (data.managerId === null) throw new BadRequestError("managerId cannot be cleared — every agent must have a manager");
|
|
11839
|
-
const [manager] = await db.select({
|
|
11840
|
-
id: members.id,
|
|
11841
|
-
organizationId: members.organizationId
|
|
11842
|
-
}).from(members).where(eq(members.id, data.managerId)).limit(1);
|
|
11843
|
-
if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
|
|
11844
|
-
if (manager.organizationId !== agent.organizationId) throw new BadRequestError("Manager must belong to the same organization as the agent");
|
|
11845
|
-
updates.managerId = data.managerId;
|
|
11846
|
-
}
|
|
11847
|
-
if (data.clientId !== void 0 && data.clientId !== null && agent.clientId === null) {
|
|
11848
|
-
const resolvedClientId = await resolveAgentClient(db, {
|
|
11849
|
-
clientId: data.clientId,
|
|
11850
|
-
managerId: updates.managerId ?? agent.managerId,
|
|
11851
|
-
type: agent.type
|
|
11852
|
-
});
|
|
11853
|
-
if (resolvedClientId !== null) updates.clientId = resolvedClientId;
|
|
11854
|
-
}
|
|
11855
|
-
const [updated] = await db.update(agents).set(updates).where(eq(agents.uuid, agent.uuid)).returning();
|
|
11856
|
-
if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
11857
|
-
if (data.managerId !== void 0 && data.managerId !== agent.managerId) await recomputeWatchersForAgent(db, agent.uuid);
|
|
11858
|
-
return updated;
|
|
11859
|
-
}
|
|
11860
|
-
/**
|
|
11861
|
-
* Atomically re-bind an agent to a new client and/or runtime provider.
|
|
11862
|
-
*
|
|
11863
|
-
* Validations: agent must exist and not be human; new client must belong to
|
|
11864
|
-
* the same owner (manager.userId) and same organization; client must report
|
|
11865
|
-
* the requested runtime provider in its capabilities (skipped under `force`).
|
|
11866
|
-
*
|
|
11867
|
-
* Intended caller: PATCH /agents/:uuid/rebind. The Web "Re-bind"
|
|
11868
|
-
* dialog routes both same-client runtime-only switches and cross-client
|
|
11869
|
-
* moves through this single entry.
|
|
11870
|
-
*
|
|
11871
|
-
* NOTE: active sessions on the previous client are not auto-suspended in P1.
|
|
11872
|
-
* P3 will wire in cross-service coordination (inbox + presence + session)
|
|
11873
|
-
* so the destination client can resume cleanly.
|
|
11874
|
-
*/
|
|
11875
|
-
async function rebindAgent(db, uuid, data) {
|
|
11876
|
-
const agent = await getAgent(db, uuid);
|
|
11877
|
-
if (agent.type === "human") throw new BadRequestError("Human agents have no runtime — they cannot be re-bound to a client.");
|
|
11878
|
-
const newClientId = await resolveAgentClient(db, {
|
|
11879
|
-
clientId: data.clientId,
|
|
11880
|
-
managerId: agent.managerId,
|
|
11881
|
-
type: agent.type
|
|
11882
|
-
});
|
|
11883
|
-
if (newClientId === null) throw new BadRequestError("Rebind requires a non-null clientId.");
|
|
11884
|
-
await ensureClientSupportsRuntimeProvider(db, newClientId, data.runtimeProvider, { force: data.force });
|
|
11885
|
-
const [updated] = await db.update(agents).set({
|
|
11886
|
-
clientId: newClientId,
|
|
11887
|
-
runtimeProvider: data.runtimeProvider,
|
|
11888
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
11889
|
-
}).where(eq(agents.uuid, uuid)).returning();
|
|
11890
|
-
if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
11891
|
-
return updated;
|
|
11892
|
-
}
|
|
11893
|
-
/**
|
|
11894
|
-
* Reactivate a suspended agent.
|
|
11895
|
-
*/
|
|
11896
|
-
async function reactivateAgent(db, uuid) {
|
|
11897
|
-
const [existing] = await db.select({
|
|
11898
|
-
uuid: agents.uuid,
|
|
11899
|
-
status: agents.status
|
|
11900
|
-
}).from(agents).where(eq(agents.uuid, uuid)).limit(1);
|
|
11901
|
-
if (!existing || existing.status === AGENT_STATUSES.DELETED) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
11902
|
-
if (existing.status !== AGENT_STATUSES.SUSPENDED) throw new BadRequestError("Only suspended agents can be reactivated.");
|
|
11903
|
-
const [agent] = await db.update(agents).set({
|
|
11904
|
-
status: AGENT_STATUSES.ACTIVE,
|
|
11905
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
11906
|
-
}).where(eq(agents.uuid, uuid)).returning();
|
|
11907
|
-
if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
11908
|
-
return agent;
|
|
11909
|
-
}
|
|
11910
|
-
/**
|
|
11911
|
-
* Suspend an agent. Once suspended, Rule R-RUN refuses every runtime bind
|
|
11912
|
-
* and every agent-selector-authorised HTTP call.
|
|
11913
|
-
*/
|
|
11914
|
-
async function suspendAgent(db, uuid) {
|
|
11915
|
-
const [agent] = await db.update(agents).set({
|
|
11916
|
-
status: AGENT_STATUSES.SUSPENDED,
|
|
11917
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
11918
|
-
}).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning();
|
|
11919
|
-
if (!agent) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
11920
|
-
return agent;
|
|
11921
|
-
}
|
|
11922
|
-
/**
|
|
11923
|
-
* Delete an agent. Only allowed when status is "suspended". Sets name to NULL
|
|
11924
|
-
* so the name becomes reusable.
|
|
11925
|
-
*/
|
|
11926
|
-
async function deleteAgent(db, uuid) {
|
|
11927
|
-
const [existing] = await db.select({
|
|
11928
|
-
uuid: agents.uuid,
|
|
11929
|
-
status: agents.status
|
|
11930
|
-
}).from(agents).where(eq(agents.uuid, uuid)).limit(1);
|
|
11931
|
-
if (!existing || existing.status === AGENT_STATUSES.DELETED) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
11932
|
-
if (existing.status !== AGENT_STATUSES.SUSPENDED) throw new BadRequestError("Only suspended agents can be deleted. Suspend the agent first.");
|
|
11933
|
-
const [agent] = await db.update(agents).set({
|
|
11934
|
-
status: AGENT_STATUSES.DELETED,
|
|
11935
|
-
name: null,
|
|
11936
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
11937
|
-
}).where(eq(agents.uuid, uuid)).returning();
|
|
11938
|
-
await db.delete(adapterConfigs).where(eq(adapterConfigs.agentId, uuid));
|
|
11939
|
-
await db.delete(adapterAgentMappings).where(eq(adapterAgentMappings.agentId, uuid));
|
|
11940
|
-
if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
11941
|
-
return agent;
|
|
11942
|
-
}
|
|
11943
|
-
/**
|
|
11944
|
-
* Supported avatar-image MIME types. The web client always uploads WEBP after
|
|
11945
|
-
* its own resize step; we accept PNG/JPEG too so a caller using the raw HTTP
|
|
11946
|
-
* API (curl, scripts) doesn't have to re-encode. Anything else is rejected at
|
|
11947
|
-
* the boundary — we never store an unknown content type.
|
|
11948
|
-
*/
|
|
11949
|
-
const SUPPORTED_AVATAR_IMAGE_MIMES = [
|
|
11950
|
-
"image/webp",
|
|
11951
|
-
"image/png",
|
|
11952
|
-
"image/jpeg"
|
|
11953
|
-
];
|
|
11954
|
-
/** Hard server-side ceiling for the stored bytea blob. Client pre-resizes to ~50KB. */
|
|
11955
|
-
const MAX_AVATAR_IMAGE_BYTES = 512 * 1024;
|
|
11956
|
-
function isSupportedAvatarMime(mime) {
|
|
11957
|
-
return SUPPORTED_AVATAR_IMAGE_MIMES.find((m) => m === mime) !== void 0;
|
|
11958
|
-
}
|
|
11959
|
-
/**
|
|
11960
|
-
* Fetch the avatar image blob for an agent. Returns `null` when no image
|
|
11961
|
-
* is set (the column is NULL). The data + mime pair is always coherent
|
|
11962
|
-
* (set/cleared together by the service writes below).
|
|
11963
|
-
*/
|
|
11964
|
-
async function getAgentAvatarImage(db, uuid) {
|
|
11965
|
-
const [row] = await db.select({
|
|
11966
|
-
data: agents.avatarImageData,
|
|
11967
|
-
mime: agents.avatarImageMime,
|
|
11968
|
-
updatedAt: agents.avatarImageUpdatedAt
|
|
11969
|
-
}).from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
|
|
11970
|
-
if (!row || !row.data || !row.mime || !row.updatedAt) return null;
|
|
11971
|
-
return {
|
|
11972
|
-
data: row.data,
|
|
11973
|
-
mime: row.mime,
|
|
11974
|
-
updatedAt: row.updatedAt
|
|
11975
|
-
};
|
|
11976
|
-
}
|
|
11977
|
-
/** Replace (or set) an agent's avatar image. Validates mime + size. */
|
|
11978
|
-
async function setAgentAvatarImage(db, uuid, data, mime) {
|
|
11979
|
-
if (!isSupportedAvatarMime(mime)) throw new BadRequestError(`Unsupported avatar image type "${mime}". Use PNG, JPEG, or WEBP.`);
|
|
11980
|
-
if (data.length === 0) throw new BadRequestError("Avatar image payload is empty.");
|
|
11981
|
-
if (data.length > 524288) throw new BadRequestError(`Avatar image is too large (${data.length} bytes; max ${MAX_AVATAR_IMAGE_BYTES}).`);
|
|
11982
|
-
const now = /* @__PURE__ */ new Date();
|
|
11983
|
-
if ((await db.update(agents).set({
|
|
11984
|
-
avatarImageData: data,
|
|
11985
|
-
avatarImageMime: mime,
|
|
11986
|
-
avatarImageUpdatedAt: now,
|
|
11987
|
-
updatedAt: now
|
|
11988
|
-
}).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning({ uuid: agents.uuid })).length === 0) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
11989
|
-
return now;
|
|
11990
|
-
}
|
|
11991
|
-
/** Clear an agent's avatar image (falls back to color + initial). */
|
|
11992
|
-
async function clearAgentAvatarImage(db, uuid) {
|
|
11993
|
-
if ((await db.update(agents).set({
|
|
11994
|
-
avatarImageData: null,
|
|
11995
|
-
avatarImageMime: null,
|
|
11996
|
-
avatarImageUpdatedAt: null,
|
|
11997
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
11998
|
-
}).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning({ uuid: agents.uuid })).length === 0) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
11999
|
-
}
|
|
12000
|
-
const log$5 = createLogger$1("AgentFeishuBot");
|
|
12001
|
-
async function agentFeishuBotRoutes(app) {
|
|
12002
|
-
/**
|
|
12003
|
-
* PUT /agent/me/feishu-bot
|
|
12004
|
-
* Self-service: agent binds its own Feishu bot (upsert).
|
|
12005
|
-
*/
|
|
12006
|
-
app.put("/me/feishu-bot", async (request, reply) => {
|
|
12007
|
-
const identity = requireAgent(request);
|
|
12008
|
-
const body = selfServiceFeishuBotSchema.parse(request.body);
|
|
12009
|
-
if ((await getAgent(app.db, identity.uuid)).type === "human") throw new BadRequestError("Human agents cannot bind Feishu bots. Use bind-user instead.");
|
|
12010
|
-
const current = (await listAdapterConfigs(app.db)).find((c) => c.agentId === identity.uuid && c.platform === "feishu");
|
|
12011
|
-
let config;
|
|
12012
|
-
if (current) config = await updateAdapterConfig(app.db, current.id, {
|
|
12013
|
-
credentials: {
|
|
12014
|
-
app_id: body.appId,
|
|
12015
|
-
app_secret: body.appSecret
|
|
12016
|
-
},
|
|
12017
|
-
status: "active"
|
|
12018
|
-
}, app.config.secrets.encryptionKey);
|
|
12019
|
-
else config = await createAdapterConfig(app.db, {
|
|
12020
|
-
platform: "feishu",
|
|
12021
|
-
agentId: identity.uuid,
|
|
12022
|
-
credentials: {
|
|
12023
|
-
app_id: body.appId,
|
|
12024
|
-
app_secret: body.appSecret
|
|
12025
|
-
},
|
|
12026
|
-
status: "active"
|
|
12027
|
-
}, app.config.secrets.encryptionKey);
|
|
12028
|
-
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after self-service bind"));
|
|
12029
|
-
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
12030
|
-
return reply.status(current ? 200 : 201).send({
|
|
12031
|
-
...config,
|
|
12032
|
-
createdAt: config.createdAt.toISOString(),
|
|
12033
|
-
updatedAt: config.updatedAt.toISOString()
|
|
12034
|
-
});
|
|
12035
|
-
});
|
|
12036
|
-
/**
|
|
12037
|
-
* DELETE /agent/me/feishu-bot
|
|
12038
|
-
* Self-service: agent unbinds its own Feishu bot.
|
|
12039
|
-
*/
|
|
12040
|
-
app.delete("/me/feishu-bot", async (request, reply) => {
|
|
12041
|
-
const identity = requireAgent(request);
|
|
12042
|
-
const current = (await listAdapterConfigs(app.db)).find((c) => c.agentId === identity.uuid && c.platform === "feishu");
|
|
12043
|
-
if (!current) return reply.status(204).send();
|
|
12044
|
-
await deleteAdapterConfig(app.db, current.id);
|
|
12045
|
-
app.adapterManager.reload().catch((err) => log$5.error({ err }, "adapter reload failed after self-service unbind"));
|
|
12046
|
-
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
12047
|
-
return reply.status(204).send();
|
|
12048
|
-
});
|
|
12049
|
-
}
|
|
12050
|
-
/**
|
|
12051
|
-
* Maps internal Chats to external IM platform channels.
|
|
12052
|
-
* NOTE: The unique constraint uses COALESCE(thread_id, '') which cannot be
|
|
12053
|
-
* expressed in Drizzle ORM. It is defined in the migration SQL directly as:
|
|
12054
|
-
* CREATE UNIQUE INDEX uq_adapter_chat_mapping ON adapter_chat_mappings
|
|
12055
|
-
* (platform, external_channel_id, COALESCE(thread_id, ''));
|
|
12056
|
-
*/
|
|
12057
|
-
const adapterChatMappings = pgTable("adapter_chat_mappings", {
|
|
12058
|
-
id: serial("id").primaryKey(),
|
|
12059
|
-
platform: text("platform").notNull(),
|
|
12060
|
-
externalChannelId: text("external_channel_id").notNull(),
|
|
12061
|
-
chatId: text("chat_id").notNull().references(() => chats.id),
|
|
12062
|
-
threadId: text("thread_id"),
|
|
12063
|
-
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
12064
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
12065
|
-
});
|
|
12066
|
-
/** Cross-reference between internal messages and external platform message IDs. */
|
|
12067
|
-
const adapterMessageReferences = pgTable("adapter_message_references", {
|
|
12068
|
-
id: serial("id").primaryKey(),
|
|
12069
|
-
messageId: text("message_id").notNull().references(() => messages.id),
|
|
12070
|
-
platform: text("platform").notNull(),
|
|
12071
|
-
externalMessageId: text("external_message_id").notNull(),
|
|
12072
|
-
externalChannelId: text("external_channel_id").notNull(),
|
|
12073
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
12074
|
-
}, (table) => [unique("uq_adapter_message_ref").on(table.messageId, table.platform)]);
|
|
12075
|
-
/**
|
|
12076
|
-
* Attempt to claim an event for processing.
|
|
12077
|
-
* Returns true if this is the first time the event is seen, false if duplicate.
|
|
12078
|
-
*/
|
|
12079
|
-
async function claimEvent(db, eventId, platform) {
|
|
12080
|
-
return (await db.execute(sql`INSERT INTO processed_events (event_id, platform) VALUES (${eventId}, ${platform}) ON CONFLICT DO NOTHING RETURNING event_id`)).length > 0;
|
|
12081
|
-
}
|
|
12082
|
-
/**
|
|
12083
|
-
* Remove a claimed event so it can be retried on next delivery.
|
|
12084
|
-
* Called when processing fails after claimEvent() succeeded.
|
|
12085
|
-
*/
|
|
12086
|
-
async function unclaimEvent(db, eventId, platform) {
|
|
12087
|
-
await db.execute(sql`DELETE FROM processed_events WHERE event_id = ${eventId} AND platform = ${platform}`);
|
|
12088
|
-
}
|
|
12089
|
-
/** Look up the internal agent ID for an external user. */
|
|
12090
|
-
async function findAgentByExternalUser(db, platform, externalUserId) {
|
|
12091
|
-
const [row] = await db.select({
|
|
12092
|
-
agentId: adapterAgentMappings.agentId,
|
|
12093
|
-
displayName: adapterAgentMappings.displayName
|
|
12094
|
-
}).from(adapterAgentMappings).where(and(eq(adapterAgentMappings.platform, platform), eq(adapterAgentMappings.externalUserId, externalUserId))).limit(1);
|
|
12095
|
-
return row ?? null;
|
|
12096
|
-
}
|
|
12097
|
-
/** Look up the external user ID for an internal agent. */
|
|
12098
|
-
async function findExternalUserByAgent(db, platform, agentId) {
|
|
12099
|
-
const [row] = await db.select({ externalUserId: adapterAgentMappings.externalUserId }).from(adapterAgentMappings).where(and(eq(adapterAgentMappings.platform, platform), eq(adapterAgentMappings.agentId, agentId))).limit(1);
|
|
12100
|
-
return row ?? null;
|
|
12101
|
-
}
|
|
12102
|
-
/** Create an agent mapping. */
|
|
12103
|
-
async function createAgentMapping(db, data) {
|
|
12104
|
-
const [row] = await db.insert(adapterAgentMappings).values({
|
|
12105
|
-
platform: data.platform,
|
|
12106
|
-
externalUserId: data.externalUserId,
|
|
12107
|
-
agentId: data.agentId,
|
|
12108
|
-
boundVia: data.boundVia ?? null,
|
|
12109
|
-
displayName: data.displayName ?? null,
|
|
12110
|
-
metadata: data.metadata ?? {}
|
|
12111
|
-
}).onConflictDoNothing().returning();
|
|
12112
|
-
if (!row) {
|
|
12113
|
-
const [existing] = await db.select().from(adapterAgentMappings).where(and(eq(adapterAgentMappings.platform, data.platform), eq(adapterAgentMappings.externalUserId, data.externalUserId))).limit(1);
|
|
12114
|
-
if (!existing) throw new Error("Unexpected: concurrent insert failed and row not found");
|
|
12115
|
-
return existing;
|
|
12116
|
-
}
|
|
12117
|
-
return row;
|
|
11626
|
+
return row;
|
|
12118
11627
|
}
|
|
12119
11628
|
/** Look up the internal chat ID for an external channel. */
|
|
12120
11629
|
async function findChatByExternalChannel(db, platform, externalChannelId, threadId) {
|
|
@@ -12147,7 +11656,7 @@ async function findOrCreateChatForChannel(db, data) {
|
|
|
12147
11656
|
const internalType = data.chatType === "p2p" ? "direct" : "group";
|
|
12148
11657
|
return db.transaction(async (tx) => {
|
|
12149
11658
|
const [botAgent] = await tx.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, data.botAgentId)).limit(1);
|
|
12150
|
-
const orgId = botAgent?.organizationId ?? await resolveDefaultOrgId(db);
|
|
11659
|
+
const orgId = botAgent?.organizationId ?? await resolveDefaultOrgId$1(db);
|
|
12151
11660
|
const metadata = chatMetadataSchema$1.parse({
|
|
12152
11661
|
source: data.platform,
|
|
12153
11662
|
externalChannelId: data.externalChannelId
|
|
@@ -12238,355 +11747,11 @@ async function agentFeishuUserRoutes(app) {
|
|
|
12238
11747
|
return reply.status(204).send();
|
|
12239
11748
|
});
|
|
12240
11749
|
}
|
|
12241
|
-
function normaliseSource(source) {
|
|
12242
|
-
if (source === null) return null;
|
|
12243
|
-
const parsed = messageSourceSchema$1.safeParse(source);
|
|
12244
|
-
return parsed.success ? parsed.data : null;
|
|
12245
|
-
}
|
|
12246
|
-
function normaliseMode(mode) {
|
|
12247
|
-
return mode === "mention_only" ? "mention_only" : "full";
|
|
12248
|
-
}
|
|
12249
|
-
/**
|
|
12250
|
-
* Batch variant — builds all payloads with a single DB lookup per agent plus
|
|
12251
|
-
* batched lookups for participant modes and inReplyTo snapshots.
|
|
12252
|
-
*/
|
|
12253
|
-
async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
|
|
12254
|
-
if (items.length === 0) return [];
|
|
12255
|
-
const agentId = await resolveAgentId(db, {
|
|
12256
|
-
kind: "inboxId",
|
|
12257
|
-
inboxId
|
|
12258
|
-
});
|
|
12259
|
-
const [cfg] = await db.select({ version: agentConfigs.version }).from(agentConfigs).where(eq(agentConfigs.agentId, agentId)).limit(1);
|
|
12260
|
-
const version = cfg?.version ?? 1;
|
|
12261
|
-
const chatIds = [...new Set(items.map((it) => it.entryChatId ?? it.message.chatId).filter((id) => id !== null))];
|
|
12262
|
-
const modeByChat = /* @__PURE__ */ new Map();
|
|
12263
|
-
if (chatIds.length > 0) {
|
|
12264
|
-
const rows = await db.select({
|
|
12265
|
-
chatId: chatMembership.chatId,
|
|
12266
|
-
mode: chatMembership.mode
|
|
12267
|
-
}).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
|
|
12268
|
-
for (const r of rows) modeByChat.set(r.chatId, normaliseMode(r.mode));
|
|
12269
|
-
}
|
|
12270
|
-
const inReplyToIds = [...new Set(items.map((it) => it.message.inReplyTo).filter((id) => id !== null))];
|
|
12271
|
-
const snapshotById = /* @__PURE__ */ new Map();
|
|
12272
|
-
if (inReplyToIds.length > 0) {
|
|
12273
|
-
const origs = await db.select({
|
|
12274
|
-
id: messages.id,
|
|
12275
|
-
senderId: messages.senderId,
|
|
12276
|
-
chatId: messages.chatId,
|
|
12277
|
-
replyToChat: messages.replyToChat
|
|
12278
|
-
}).from(messages).where(inArray(messages.id, inReplyToIds));
|
|
12279
|
-
for (const o of origs) snapshotById.set(o.id, {
|
|
12280
|
-
senderId: o.senderId,
|
|
12281
|
-
chatId: o.chatId,
|
|
12282
|
-
replyToChat: o.replyToChat
|
|
12283
|
-
});
|
|
12284
|
-
}
|
|
12285
|
-
return items.map(({ entryChatId, message: m, precedingMessages = [] }) => ({
|
|
12286
|
-
id: m.id,
|
|
12287
|
-
chatId: m.chatId,
|
|
12288
|
-
senderId: m.senderId,
|
|
12289
|
-
format: m.format,
|
|
12290
|
-
content: m.content,
|
|
12291
|
-
metadata: m.metadata,
|
|
12292
|
-
replyToInbox: m.replyToInbox,
|
|
12293
|
-
replyToChat: m.replyToChat,
|
|
12294
|
-
inReplyTo: m.inReplyTo,
|
|
12295
|
-
source: normaliseSource(m.source),
|
|
12296
|
-
createdAt: m.createdAt,
|
|
12297
|
-
configVersion: version,
|
|
12298
|
-
recipientMode: modeByChat.get(entryChatId ?? m.chatId) ?? "full",
|
|
12299
|
-
inReplyToSnapshot: m.inReplyTo ? snapshotById.get(m.inReplyTo) ?? null : null,
|
|
12300
|
-
precedingMessages
|
|
12301
|
-
}));
|
|
12302
|
-
}
|
|
12303
|
-
async function resolveAgentId(db, source) {
|
|
12304
|
-
if (source.kind === "agentId") return source.agentId;
|
|
12305
|
-
const [agent] = await db.select({ uuid: agents.uuid }).from(agents).where(eq(agents.inboxId, source.inboxId)).limit(1);
|
|
12306
|
-
if (!agent) throw new Error(`No agent owns inbox "${source.inboxId}"`);
|
|
12307
|
-
return agent.uuid;
|
|
12308
|
-
}
|
|
12309
|
-
const DEFAULT_INBOX_TIMEOUT_SECONDS = 300;
|
|
12310
|
-
const DEFAULT_MAX_RETRY_COUNT = 3;
|
|
12311
|
-
const PRECEDING_CONTEXT_WINDOW_SECONDS = 1440 * 60;
|
|
12312
|
-
async function pollInbox(db, inboxId, limit) {
|
|
12313
|
-
return withSpan("inbox.deliver", {
|
|
12314
|
-
"inbox.id": inboxId,
|
|
12315
|
-
"inbox.poll.limit": limit
|
|
12316
|
-
}, () => pollInboxInner(db, inboxId, limit));
|
|
12317
|
-
}
|
|
12318
|
-
async function pollInboxInner(db, inboxId, limit) {
|
|
12319
|
-
return db.transaction(async (tx) => {
|
|
12320
|
-
const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(limit).for("update", { skipLocked: true });
|
|
12321
|
-
return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
|
|
12322
|
-
status: "delivered",
|
|
12323
|
-
deliveredAt: /* @__PURE__ */ new Date()
|
|
12324
|
-
}).where(inArray(inboxEntries.id, targetIds)).returning());
|
|
12325
|
-
});
|
|
12326
|
-
}
|
|
12327
|
-
/**
|
|
12328
|
-
* Shared payload assembler for already-claimed `inbox_entries` rows.
|
|
12329
|
-
*
|
|
12330
|
-
* Both the HTTP poll path (`pollInbox`) and the WS push path
|
|
12331
|
-
* (`claimAndBuildForPush`) call this with rows they have just `UPDATE`d to
|
|
12332
|
-
* `status='delivered'`. Keeping the silent-context bundling in one place is
|
|
12333
|
-
* the only way to keep the two paths from drifting (proposal
|
|
12334
|
-
* hub-inbox-ws-data-plane §3.2 risk #1).
|
|
12335
|
-
*
|
|
12336
|
-
* Steps:
|
|
12337
|
-
* 1. Sort by `createdAt` ASC (PG `RETURNING` does not guarantee order).
|
|
12338
|
-
* 2. For each trigger, collect silent context & bulk-ack stale silent rows.
|
|
12339
|
-
* 3. Fetch the trigger messages.
|
|
12340
|
-
* 4. Build wire payloads via the single dispatcher.
|
|
12341
|
-
*
|
|
12342
|
-
* Returns `[]` if `claimed` is empty.
|
|
12343
|
-
*/
|
|
12344
|
-
async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
|
|
12345
|
-
if (claimed.length === 0) return [];
|
|
12346
|
-
claimed.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
12347
|
-
const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
|
|
12348
|
-
const messageIds = claimed.map((e) => e.messageId);
|
|
12349
|
-
const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
|
|
12350
|
-
const msgMap = new Map(msgs.map((m) => [m.id, m]));
|
|
12351
|
-
const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
|
|
12352
|
-
const msg = msgMap.get(entry.messageId);
|
|
12353
|
-
if (!msg) throw new Error(`Unexpected: message ${entry.messageId} not found`);
|
|
12354
|
-
return {
|
|
12355
|
-
entryChatId: entry.chatId,
|
|
12356
|
-
precedingMessages: precedingByEntryId.get(entry.id) ?? [],
|
|
12357
|
-
message: {
|
|
12358
|
-
id: msg.id,
|
|
12359
|
-
chatId: msg.chatId,
|
|
12360
|
-
senderId: msg.senderId,
|
|
12361
|
-
format: msg.format,
|
|
12362
|
-
content: msg.content,
|
|
12363
|
-
metadata: msg.metadata,
|
|
12364
|
-
replyToInbox: msg.replyToInbox,
|
|
12365
|
-
replyToChat: msg.replyToChat,
|
|
12366
|
-
inReplyTo: msg.inReplyTo,
|
|
12367
|
-
source: msg.source,
|
|
12368
|
-
createdAt: msg.createdAt.toISOString()
|
|
12369
|
-
}
|
|
12370
|
-
};
|
|
12371
|
-
}));
|
|
12372
|
-
return claimed.map((entry, idx) => {
|
|
12373
|
-
const payload = payloads[idx];
|
|
12374
|
-
if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
|
|
12375
|
-
return {
|
|
12376
|
-
id: entry.id,
|
|
12377
|
-
inboxId: entry.inboxId,
|
|
12378
|
-
messageId: entry.messageId,
|
|
12379
|
-
chatId: entry.chatId,
|
|
12380
|
-
status: entry.status,
|
|
12381
|
-
retryCount: entry.retryCount,
|
|
12382
|
-
createdAt: entry.createdAt.toISOString(),
|
|
12383
|
-
deliveredAt: entry.deliveredAt?.toISOString() ?? null,
|
|
12384
|
-
ackedAt: entry.ackedAt?.toISOString() ?? null,
|
|
12385
|
-
message: payload
|
|
12386
|
-
};
|
|
12387
|
-
});
|
|
12388
|
-
}
|
|
12389
|
-
/**
|
|
12390
|
-
* Realistic upper bound on rows a single NOTIFY references. The unique
|
|
12391
|
-
* constraint `(inbox_id, message_id, chat_id)` caps a `(inbox, message)`
|
|
12392
|
-
* pair at one row per chatId; the only way to exceed 1 today is the replyTo
|
|
12393
|
-
* cross-chat path (`message.ts` writes a second row keyed by the original's
|
|
12394
|
-
* `replyToChat`). 8 leaves headroom for any future fan-out variant without
|
|
12395
|
-
* requiring a schema change here.
|
|
12396
|
-
*/
|
|
12397
|
-
const PUSH_CLAIM_BATCH_LIMIT = 8;
|
|
12398
|
-
/**
|
|
12399
|
-
* WS-push path: atomically claim every pending entry the just-fired
|
|
12400
|
-
* `NOTIFY (inboxId:messageId)` references and assemble their wire payloads.
|
|
12401
|
-
*
|
|
12402
|
-
* Returns `[]` if no row matches — benign race with HTTP poll or another
|
|
12403
|
-
* server instance that already claimed the entry. NOTIFY is fire-and-forget
|
|
12404
|
-
* (proposal §3.2).
|
|
12405
|
-
*
|
|
12406
|
-
* Why an array, not a single row: `sendMessage` can write **two** rows for
|
|
12407
|
-
* the same `(inbox, messageId)` pair when the recipient is both a chat
|
|
12408
|
-
* participant and the `replyToInbox` of an earlier message — the unique key
|
|
12409
|
-
* is `(inbox_id, message_id, chat_id)`, so the rows differ by chatId. The
|
|
12410
|
-
* old `LIMIT 1` shape would only push the first; the second sat `pending`
|
|
12411
|
-
* until reconnect. Aligning with `pollInboxInner`'s `LIMIT N` shape closes
|
|
12412
|
-
* that gap and keeps push/poll behaviour interchangeable.
|
|
12413
|
-
*/
|
|
12414
|
-
async function claimAndBuildForPush(db, inboxId, messageId) {
|
|
12415
|
-
return withSpan("inbox.deliver.push", {
|
|
12416
|
-
"inbox.id": inboxId,
|
|
12417
|
-
"message.id": messageId
|
|
12418
|
-
}, () => db.transaction(async (tx) => {
|
|
12419
|
-
const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.messageId, messageId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(PUSH_CLAIM_BATCH_LIMIT).for("update", { skipLocked: true });
|
|
12420
|
-
return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
|
|
12421
|
-
status: "delivered",
|
|
12422
|
-
deliveredAt: /* @__PURE__ */ new Date()
|
|
12423
|
-
}).where(inArray(inboxEntries.id, targetIds)).returning());
|
|
12424
|
-
}));
|
|
12425
|
-
}
|
|
12426
|
-
/**
|
|
12427
|
-
* WS-push backlog path: on agent rebind (or once an in-flight slot frees up
|
|
12428
|
-
* after an ack), drain up to `limit` pending `notify=true` entries oldest-
|
|
12429
|
-
* first and assemble wire payloads. Identical claim shape to the HTTP poll
|
|
12430
|
-
* path — they are intentionally interchangeable so a hot-path bug fixed in
|
|
12431
|
-
* one shows up in the other (proposal §3.3 / §3.5).
|
|
12432
|
-
*/
|
|
12433
|
-
async function claimBacklogForPush(db, inboxId, limit) {
|
|
12434
|
-
return withSpan("inbox.deliver.backlog", {
|
|
12435
|
-
"inbox.id": inboxId,
|
|
12436
|
-
"inbox.backlog.limit": limit
|
|
12437
|
-
}, () => pollInboxInner(db, inboxId, limit));
|
|
12438
|
-
}
|
|
12439
|
-
/**
|
|
12440
|
-
* Per claimed trigger: SELECT silent (notify=false) pending rows in the same
|
|
12441
|
-
* chat that occurred between the previous trigger in this batch (or beginning
|
|
12442
|
-
* of time) and this trigger, capped by `PRECEDING_CONTEXT_MAX_ENTRIES` and
|
|
12443
|
-
* `PRECEDING_CONTEXT_WINDOW_SECONDS`. Returned messages are oldest-first.
|
|
12444
|
-
*
|
|
12445
|
-
* Side effect: bulk-ack ALL silent pending rows in each chat with
|
|
12446
|
-
* createdAt < latest_trigger.createdAt — including ones that fell outside
|
|
12447
|
-
* the window/cap. Otherwise stale silent rows would accumulate and re-load
|
|
12448
|
-
* on every poll.
|
|
12449
|
-
*/
|
|
12450
|
-
async function collectPrecedingContext(tx, inboxId, triggers) {
|
|
12451
|
-
const result = /* @__PURE__ */ new Map();
|
|
12452
|
-
const byChat = /* @__PURE__ */ new Map();
|
|
12453
|
-
for (const t of triggers) {
|
|
12454
|
-
if (t.chatId === null) continue;
|
|
12455
|
-
const list = byChat.get(t.chatId) ?? [];
|
|
12456
|
-
list.push(t);
|
|
12457
|
-
byChat.set(t.chatId, list);
|
|
12458
|
-
}
|
|
12459
|
-
for (const [chatId, chatTriggers] of byChat) {
|
|
12460
|
-
chatTriggers.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
12461
|
-
let prevCreatedAt = null;
|
|
12462
|
-
for (const trigger of chatTriggers) {
|
|
12463
|
-
const preceding = (await tx.select({
|
|
12464
|
-
messageId: messages.id,
|
|
12465
|
-
senderId: messages.senderId,
|
|
12466
|
-
format: messages.format,
|
|
12467
|
-
content: messages.content,
|
|
12468
|
-
metadata: messages.metadata,
|
|
12469
|
-
createdAt: messages.createdAt
|
|
12470
|
-
}).from(inboxEntries).innerJoin(messages, eq(messages.id, inboxEntries.messageId)).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, trigger.createdAt), prevCreatedAt === null ? void 0 : gt(inboxEntries.createdAt, prevCreatedAt), sql`${inboxEntries.createdAt} > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})`)).orderBy(desc(inboxEntries.createdAt)).limit(50).for("update", {
|
|
12471
|
-
of: inboxEntries,
|
|
12472
|
-
skipLocked: true
|
|
12473
|
-
})).map((r) => ({
|
|
12474
|
-
id: r.messageId,
|
|
12475
|
-
senderId: r.senderId,
|
|
12476
|
-
format: r.format,
|
|
12477
|
-
content: r.content,
|
|
12478
|
-
metadata: r.metadata ?? {},
|
|
12479
|
-
createdAt: r.createdAt.toISOString()
|
|
12480
|
-
})).reverse();
|
|
12481
|
-
result.set(trigger.id, preceding);
|
|
12482
|
-
prevCreatedAt = trigger.createdAt;
|
|
12483
|
-
}
|
|
12484
|
-
const latestTrigger = chatTriggers[chatTriggers.length - 1];
|
|
12485
|
-
if (latestTrigger) await tx.update(inboxEntries).set({
|
|
12486
|
-
status: "acked",
|
|
12487
|
-
ackedAt: /* @__PURE__ */ new Date()
|
|
12488
|
-
}).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, latestTrigger.createdAt)));
|
|
12489
|
-
}
|
|
12490
|
-
return result;
|
|
12491
|
-
}
|
|
12492
|
-
async function ackEntry$2(db, entryId, inboxId) {
|
|
12493
|
-
return withSpan("inbox.ack", {
|
|
12494
|
-
[FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId),
|
|
12495
|
-
"inbox.id": inboxId
|
|
12496
|
-
}, async () => {
|
|
12497
|
-
const [entry] = await db.update(inboxEntries).set({
|
|
12498
|
-
status: "acked",
|
|
12499
|
-
ackedAt: /* @__PURE__ */ new Date()
|
|
12500
|
-
}).where(and(eq(inboxEntries.id, entryId), eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "delivered"))).returning();
|
|
12501
|
-
if (!entry) throw new NotFoundError("Inbox entry not found or not in delivered status");
|
|
12502
|
-
return entry;
|
|
12503
|
-
});
|
|
12504
|
-
}
|
|
12505
|
-
/**
|
|
12506
|
-
* Ack a delivered entry from the WS data plane, scoped to the inboxes the
|
|
12507
|
-
* connected socket has bound. Returns the acked row on success, `null` if no
|
|
12508
|
-
* row matches — a benign outcome the caller should ignore (the entry may
|
|
12509
|
-
* have already been acked, timed out, or never belonged to this socket).
|
|
12510
|
-
*
|
|
12511
|
-
* Distinct from {@link ackEntry} so the WS path can ack without trusting an
|
|
12512
|
-
* `inboxId` from the wire — only entries whose `inboxId` is in `inboxIds`
|
|
12513
|
-
* are eligible. Empty `inboxIds` short-circuits to `null`.
|
|
12514
|
-
*/
|
|
12515
|
-
async function ackEntryByIdForBoundAgents(db, entryId, inboxIds) {
|
|
12516
|
-
if (inboxIds.length === 0) return null;
|
|
12517
|
-
return withSpan("inbox.ack.ws", { [FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId) }, async () => {
|
|
12518
|
-
const [entry] = await db.update(inboxEntries).set({
|
|
12519
|
-
status: "acked",
|
|
12520
|
-
ackedAt: /* @__PURE__ */ new Date()
|
|
12521
|
-
}).where(and(eq(inboxEntries.id, entryId), inArray(inboxEntries.inboxId, inboxIds), eq(inboxEntries.status, "delivered"))).returning();
|
|
12522
|
-
return entry ?? null;
|
|
12523
|
-
});
|
|
12524
|
-
}
|
|
12525
|
-
async function renewEntry(db, entryId, inboxId) {
|
|
12526
|
-
const [entry] = await db.update(inboxEntries).set({ deliveredAt: /* @__PURE__ */ new Date() }).where(and(eq(inboxEntries.id, entryId), eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "delivered"))).returning();
|
|
12527
|
-
if (!entry) throw new NotFoundError("Inbox entry not found or not in delivered status");
|
|
12528
|
-
return entry;
|
|
12529
|
-
}
|
|
12530
|
-
async function resetTimedOutEntries(db, timeoutSeconds = DEFAULT_INBOX_TIMEOUT_SECONDS, maxRetries = DEFAULT_MAX_RETRY_COUNT) {
|
|
12531
|
-
const reset = await db.update(inboxEntries).set({
|
|
12532
|
-
status: "pending",
|
|
12533
|
-
retryCount: sql`${inboxEntries.retryCount} + 1`
|
|
12534
|
-
}).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, lt(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
|
|
12535
|
-
const failed = await db.update(inboxEntries).set({ status: "failed" }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, gte(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
|
|
12536
|
-
return {
|
|
12537
|
-
reset: reset.length,
|
|
12538
|
-
failed: failed.length
|
|
12539
|
-
};
|
|
12540
|
-
}
|
|
12541
|
-
/** Default age (30 days) past which silent rows that no notify-true delivery
|
|
12542
|
-
* ever picked up are physically deleted. */
|
|
12543
|
-
const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
|
|
12544
|
-
/**
|
|
12545
|
-
* Garbage-collect silent inbox rows so the table doesn't grow forever in
|
|
12546
|
-
* chats where a `mention_only` agent is never @mentioned.
|
|
12547
|
-
*
|
|
12548
|
-
* Two cleanup paths:
|
|
12549
|
-
*
|
|
12550
|
-
* 1. `notify=false AND status='acked'` of any age — these are fully
|
|
12551
|
-
* consumed (either bundled into a previous trigger or aged out via the
|
|
12552
|
-
* bulk-ack in `collectPrecedingContext`); keep them only as long as
|
|
12553
|
-
* the corresponding message rows we link to. The unique constraint
|
|
12554
|
-
* `(inbox_id, message_id, chat_id)` means leaving them around blocks
|
|
12555
|
-
* legitimate retries with the same key.
|
|
12556
|
-
*
|
|
12557
|
-
* 2. `notify=false AND status='pending' AND createdAt < NOW() - maxAge` —
|
|
12558
|
-
* stale silent rows that no trigger ever caught up with. After 30
|
|
12559
|
-
* days they're useless as preceding context (the @mention almost
|
|
12560
|
-
* certainly already happened or the chat went dormant).
|
|
12561
|
-
*
|
|
12562
|
-
* Returns the number of rows deleted in each bucket so the background task
|
|
12563
|
-
* can log meaningful counts.
|
|
12564
|
-
*/
|
|
12565
|
-
async function pruneStaleSilentEntries(db, maxAgeSeconds = SILENT_ROW_GC_MAX_AGE_SECONDS) {
|
|
12566
|
-
const ackedDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "acked"))).returning({ id: inboxEntries.id });
|
|
12567
|
-
const stalePendingDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "pending"), sql`${inboxEntries.createdAt} < NOW() - make_interval(secs => ${maxAgeSeconds})`)).returning({ id: inboxEntries.id });
|
|
12568
|
-
return {
|
|
12569
|
-
ackedDeleted: ackedDeleted.length,
|
|
12570
|
-
stalePendingDeleted: stalePendingDeleted.length
|
|
12571
|
-
};
|
|
12572
|
-
}
|
|
12573
11750
|
async function agentInboxRoutes(app) {
|
|
12574
11751
|
app.get("/", async (request) => {
|
|
12575
11752
|
const identity = requireAgent(request);
|
|
12576
|
-
const query = inboxPollQuerySchema.parse(request.query);
|
|
12577
|
-
return await pollInbox(app.db, identity.inboxId, query.limit);
|
|
12578
|
-
});
|
|
12579
|
-
app.post("/:entryId/ack", async (request, reply) => {
|
|
12580
|
-
const identity = requireAgent(request);
|
|
12581
|
-
const entryId = Number(request.params.entryId);
|
|
12582
|
-
await ackEntry$2(app.db, entryId, identity.inboxId);
|
|
12583
|
-
return reply.status(204).send();
|
|
12584
|
-
});
|
|
12585
|
-
app.post("/:entryId/renew", async (request, reply) => {
|
|
12586
|
-
const identity = requireAgent(request);
|
|
12587
|
-
const entryId = Number(request.params.entryId);
|
|
12588
|
-
await renewEntry(app.db, entryId, identity.inboxId);
|
|
12589
|
-
return reply.status(204).send();
|
|
11753
|
+
const query = inboxPollQuerySchema.parse(request.query);
|
|
11754
|
+
return await pollInbox(app.db, identity.inboxId, query.limit);
|
|
12590
11755
|
});
|
|
12591
11756
|
}
|
|
12592
11757
|
async function agentMeRoutes(app) {
|
|
@@ -12619,11 +11784,11 @@ async function agentMeRoutes(app) {
|
|
|
12619
11784
|
* {imageId, mimeType, filename, size}
|
|
12620
11785
|
*
|
|
12621
11786
|
* The push is fire-and-forget: `ws.send()` queues the frame into the socket's
|
|
12622
|
-
* send buffer synchronously, which is the only ordering guarantee we need
|
|
12623
|
-
*
|
|
12624
|
-
*
|
|
12625
|
-
*
|
|
12626
|
-
*
|
|
11787
|
+
* send buffer synchronously, which is the only ordering guarantee we need —
|
|
11788
|
+
* the subsequent `inbox:deliver` frame is driven by a PG NOTIFY round trip,
|
|
11789
|
+
* so the image lands first on the wire. Awaiting the TCP flush here would
|
|
11790
|
+
* put a slow subscriber's backpressure on the sender's HTTP response for a
|
|
11791
|
+
* feature that is already best-effort.
|
|
12627
11792
|
*
|
|
12628
11793
|
* Non-image messages are returned unchanged. Missing-subscriber / wrong-
|
|
12629
11794
|
* instance cases are acceptable loss per the image-out-of-messages design
|
|
@@ -13320,8 +12485,7 @@ async function summarizeContextTreeUsage(db, organizationId, windowDays) {
|
|
|
13320
12485
|
/**
|
|
13321
12486
|
* Default per-agent in-flight cap when `server.inbox.maxInFlightPerAgent` is
|
|
13322
12487
|
* unset. Mirrors the schema default so a hub running without an explicit
|
|
13323
|
-
* `inbox` block still gets reasonable backpressure
|
|
13324
|
-
* flipped on. See proposal hub-inbox-ws-data-plane §3.5.
|
|
12488
|
+
* `inbox` block still gets reasonable backpressure.
|
|
13325
12489
|
*/
|
|
13326
12490
|
const DEFAULT_INBOX_MAX_IN_FLIGHT_PER_AGENT = 32;
|
|
13327
12491
|
/**
|
|
@@ -13366,23 +12530,12 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13366
12530
|
let authExpiryTimer = null;
|
|
13367
12531
|
const boundAgents = /* @__PURE__ */ new Map();
|
|
13368
12532
|
/**
|
|
13369
|
-
* Whether the connected client opted into the WS inbox data plane via
|
|
13370
|
-
* `client:register.wireCapabilities.wsInboxDeliver`. Set per-socket
|
|
13371
|
-
* because client SDKs are upgraded independently — an old client
|
|
13372
|
-
* connecting to a new server must keep receiving the legacy
|
|
13373
|
-
* `new_message` doorbell + HTTP poll path (proposal §3.6).
|
|
13374
|
-
*/
|
|
13375
|
-
let clientWantsWsInboxDeliver = false;
|
|
13376
|
-
/**
|
|
13377
12533
|
* Per-agent in-flight `inbox:deliver` counter for backpressure. Lives on
|
|
13378
12534
|
* the socket — when the WS closes it goes with it; that's intentional,
|
|
13379
12535
|
* because re-counting on a fresh connection would bias the cap against
|
|
13380
|
-
* a healthy reconnect
|
|
12536
|
+
* a healthy reconnect.
|
|
13381
12537
|
*/
|
|
13382
12538
|
const inboxInFlight = /* @__PURE__ */ new Map();
|
|
13383
|
-
function pushUseWsDataPlane() {
|
|
13384
|
-
return clientWantsWsInboxDeliver;
|
|
13385
|
-
}
|
|
13386
12539
|
/**
|
|
13387
12540
|
* Returns `false` when the socket has already moved out of `OPEN` —
|
|
13388
12541
|
* the only failure mode the caller can observe synchronously.
|
|
@@ -13625,7 +12778,6 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13625
12778
|
try {
|
|
13626
12779
|
if (type === "client:register") {
|
|
13627
12780
|
const data = clientRegisterSchema.parse(msg);
|
|
13628
|
-
clientWantsWsInboxDeliver = data.wireCapabilities?.wsInboxDeliver === true;
|
|
13629
12781
|
let placeholderOrgId = jwtDefaultOrgId;
|
|
13630
12782
|
if (!placeholderOrgId) {
|
|
13631
12783
|
const [m] = await app.db.select({ organizationId: members.organizationId }).from(members).where(and(eq(members.userId, session.userId), eq(members.status, "active"))).orderBy(desc(members.createdAt), desc(members.id)).limit(1);
|
|
@@ -13763,9 +12915,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13763
12915
|
inboxId: agent.inboxId,
|
|
13764
12916
|
organizationId: agent.organizationId
|
|
13765
12917
|
});
|
|
13766
|
-
|
|
13767
|
-
if (wsPushActive) notifier.subscribe(agent.inboxId, socket, makeInboxPushHandler(agent.id, agent.inboxId));
|
|
13768
|
-
else notifier.subscribe(agent.inboxId, socket);
|
|
12918
|
+
notifier.subscribe(agent.inboxId, socket, makeInboxPushHandler(agent.id, agent.inboxId));
|
|
13769
12919
|
socket.send(JSON.stringify({
|
|
13770
12920
|
type: "agent:bound",
|
|
13771
12921
|
ref,
|
|
@@ -13773,7 +12923,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13773
12923
|
displayName: agent.displayName,
|
|
13774
12924
|
agentType: agent.type
|
|
13775
12925
|
}));
|
|
13776
|
-
|
|
12926
|
+
drainBacklogForAgent(agent.id, agent.inboxId).catch((err) => {
|
|
13777
12927
|
app.log.error({
|
|
13778
12928
|
err,
|
|
13779
12929
|
agentId: agent.id
|
|
@@ -13894,6 +13044,14 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13894
13044
|
} else if (type === "inbox:ack") {
|
|
13895
13045
|
const payloadResult = inboxAckFrameSchema.safeParse(msg);
|
|
13896
13046
|
if (!payloadResult.success) {
|
|
13047
|
+
app.log.warn({
|
|
13048
|
+
clientId,
|
|
13049
|
+
issues: payloadResult.error.issues.map((i) => ({
|
|
13050
|
+
path: i.path.join("."),
|
|
13051
|
+
code: i.code,
|
|
13052
|
+
message: i.message
|
|
13053
|
+
}))
|
|
13054
|
+
}, "malformed inbox:ack frame — replying error");
|
|
13897
13055
|
socket.send(JSON.stringify({
|
|
13898
13056
|
type: "error",
|
|
13899
13057
|
message: "Malformed inbox:ack frame"
|
|
@@ -13903,7 +13061,14 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13903
13061
|
const { entryId } = payloadResult.data;
|
|
13904
13062
|
try {
|
|
13905
13063
|
const ackedEntry = await ackEntryByIdForBoundAgents(app.db, entryId, [...boundAgents.values()].map((a) => a.inboxId));
|
|
13906
|
-
if (!ackedEntry)
|
|
13064
|
+
if (!ackedEntry) {
|
|
13065
|
+
app.log.debug({
|
|
13066
|
+
clientId,
|
|
13067
|
+
entryId,
|
|
13068
|
+
boundInboxes: boundAgents.size
|
|
13069
|
+
}, "inbox:ack matched no row — stale ack or reaper race");
|
|
13070
|
+
return;
|
|
13071
|
+
}
|
|
13907
13072
|
const owner = [...boundAgents.values()].find((a) => a.inboxId === ackedEntry.inboxId);
|
|
13908
13073
|
if (owner) {
|
|
13909
13074
|
inboxInFlight.set(owner.agentId, Math.max(0, (inboxInFlight.get(owner.agentId) ?? 1) - 1));
|
|
@@ -14591,6 +13756,7 @@ const APP_JWT_EXPIRY = "9m";
|
|
|
14591
13756
|
* caller's side; the docs recommend 60 seconds. We mirror that.
|
|
14592
13757
|
*/
|
|
14593
13758
|
const APP_JWT_IAT_SKEW_SECONDS = 60;
|
|
13759
|
+
const APP_INSTALLATION_TOKEN_URL = (id) => `https://api.github.com/app/installations/${id}/access_tokens`;
|
|
14594
13760
|
const APP_INSTALLATION_URL = (id) => `https://api.github.com/app/installations/${id}`;
|
|
14595
13761
|
const OAUTH_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
14596
13762
|
const OAUTH_AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
|
|
@@ -14695,6 +13861,39 @@ async function listUserAccessibleInstallationIds(userAccessToken, opts = {}) {
|
|
|
14695
13861
|
return out;
|
|
14696
13862
|
}
|
|
14697
13863
|
/**
|
|
13864
|
+
* Mint a per-installation token (server-to-server). The token is cheap
|
|
13865
|
+
* (one signature + one HTTP round-trip) and the upstream TTL is ~1h, so
|
|
13866
|
+
* the recommended caller pattern is "mint per request" rather than caching
|
|
13867
|
+
* — caching forces the caller to also track expiry, suspended state, and
|
|
13868
|
+
* GitHub-side permission churn, which the design explicitly punts to Phase
|
|
13869
|
+
* 4. We give callers a typed result and let them cache if profiling shows
|
|
13870
|
+
* the round-trip is hot.
|
|
13871
|
+
*
|
|
13872
|
+
* Throws `GithubAppApiError` on non-2xx. 401 means the App JWT is bad or
|
|
13873
|
+
* the App's key has been rotated upstream; 404 means the installation
|
|
13874
|
+
* was uninstalled. Callers SHOULD persist the suspension state when 403
|
|
13875
|
+
* comes back with `suspended` (the design tracks this as `suspended_at`
|
|
13876
|
+
* on `github_app_installations`).
|
|
13877
|
+
*/
|
|
13878
|
+
async function mintInstallationToken(appJwt, installationId, opts = {}) {
|
|
13879
|
+
const res = await (opts.fetcher ?? fetch)(APP_INSTALLATION_TOKEN_URL(installationId), {
|
|
13880
|
+
method: "POST",
|
|
13881
|
+
headers: {
|
|
13882
|
+
Authorization: `Bearer ${appJwt}`,
|
|
13883
|
+
Accept: "application/vnd.github+json",
|
|
13884
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
13885
|
+
}
|
|
13886
|
+
});
|
|
13887
|
+
if (!res.ok) throw new GithubAppApiError(res.status, `GitHub App installation-token request failed (${res.status})`);
|
|
13888
|
+
const body = await res.json();
|
|
13889
|
+
return {
|
|
13890
|
+
token: body.token,
|
|
13891
|
+
expiresAt: body.expires_at,
|
|
13892
|
+
permissions: body.permissions ?? {},
|
|
13893
|
+
repositorySelection: body.repository_selection ?? "all"
|
|
13894
|
+
};
|
|
13895
|
+
}
|
|
13896
|
+
/**
|
|
14698
13897
|
* Trade an expiring user-to-server access token for a fresh pair using
|
|
14699
13898
|
* its refresh token. Thrown on:
|
|
14700
13899
|
* - Network / 5xx — `GithubAppApiError(status, …)`
|
|
@@ -15765,743 +14964,6 @@ async function bootstrapConfigRoutes(_app) {
|
|
|
15765
14964
|
});
|
|
15766
14965
|
}
|
|
15767
14966
|
/**
|
|
15768
|
-
* Per-(chat, agent) user state — independent from membership structure.
|
|
15769
|
-
*
|
|
15770
|
-
* This is the third layer of the chat data model: while `chats` owns
|
|
15771
|
-
* the entity and `chat_membership` owns the structural relation
|
|
15772
|
-
* (who can speak, who watches), this table owns the user's private
|
|
15773
|
-
* state about a chat. The reason it lives apart: structural changes
|
|
15774
|
-
* (speaker ↔ watcher, manager rebind, recompute) must never overwrite
|
|
15775
|
-
* user-private state — physical separation makes that an invariant
|
|
15776
|
-
* rather than a service-layer discipline.
|
|
15777
|
-
*
|
|
15778
|
-
* Columns evolve incrementally as new per-user state is needed.
|
|
15779
|
-
* Currently:
|
|
15780
|
-
* - `last_read_at`, `unread_mention_count` — seeded by PR-A from
|
|
15781
|
-
* the legacy `chat_participants` / `chat_subscriptions` columns.
|
|
15782
|
-
* - `engagement_status` — added in 0040; per-(chat, user) view
|
|
15783
|
-
* state (active / archived / deleted). Auto-revives archived →
|
|
15784
|
-
* active on new message; deleted is sticky (only the user can
|
|
15785
|
-
* restore from the chat detail page).
|
|
15786
|
-
*
|
|
15787
|
-
* Future fields slated for this table: pinned, mute_until, draft,
|
|
15788
|
-
* custom_title, last_seen_at — each as a separate change.
|
|
15789
|
-
*
|
|
15790
|
-
* Rows are lazy-upserted on first user write (markRead / mention
|
|
15791
|
-
* counter bump / engagement transition). Reads use COALESCE for
|
|
15792
|
-
* defaults so callers see `'active'` etc. even when no row exists.
|
|
15793
|
-
* Service-layer integrity (no FK / CHECK / trigger).
|
|
15794
|
-
*
|
|
15795
|
-
* See proposals/chat-data-model-restructure.20260512.md §8.6.
|
|
15796
|
-
*/
|
|
15797
|
-
const chatUserState = pgTable("chat_user_state", {
|
|
15798
|
-
chatId: text("chat_id").notNull(),
|
|
15799
|
-
agentId: text("agent_id").notNull(),
|
|
15800
|
-
lastReadAt: timestamp("last_read_at", { withTimezone: true }),
|
|
15801
|
-
unreadMentionCount: integer("unread_mention_count").notNull().default(0),
|
|
15802
|
-
engagementStatus: text("engagement_status").notNull().default("active")
|
|
15803
|
-
}, (table) => [
|
|
15804
|
-
primaryKey({ columns: [table.chatId, table.agentId] }),
|
|
15805
|
-
index("idx_user_state_agent").on(table.agentId),
|
|
15806
|
-
index("idx_user_state_unread").on(table.agentId).where(sql`unread_mention_count > 0`)
|
|
15807
|
-
]);
|
|
15808
|
-
/** Extract a plain-text summary from a message's JSONB content field.
|
|
15809
|
-
* Used as the auto-title fallback in chat list rendering — see
|
|
15810
|
-
* `me-chat.ts:resolveChatTitle` and `admin/chats.ts:getChat`.
|
|
15811
|
-
*
|
|
15812
|
-
* - `@<name>` mention tokens are stripped before truncation: in the
|
|
15813
|
-
* chat-first model they're routing/audience metadata, not part of
|
|
15814
|
-
* the user's intent. Leaving them in produces noisy titles like
|
|
15815
|
-
* "@hub-agent-01 帮我重构这个文件" or "你好 @hub-agent-02 看看".
|
|
15816
|
-
* - Whitespace runs (including those left behind by mention removal)
|
|
15817
|
-
* collapse to single spaces.
|
|
15818
|
-
* - If the cleaned text is empty (e.g., a message that's only
|
|
15819
|
-
* `@hub-agent-01`), returns null so the caller falls through to
|
|
15820
|
-
* the participant-join fallback.
|
|
15821
|
-
* - Slicing is code-point-aware (`Array.from + join`) so emoji /
|
|
15822
|
-
* surrogate pairs aren't split into garbled half-characters. */
|
|
15823
|
-
function extractSummary(content, maxLen = 50) {
|
|
15824
|
-
let text = "";
|
|
15825
|
-
if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
|
|
15826
|
-
else if (typeof content === "string") text = content;
|
|
15827
|
-
if (!text) return null;
|
|
15828
|
-
const cleaned = stripCode(text).replace(MENTION_REGEX, "").replace(/\s+/g, " ").trim();
|
|
15829
|
-
if (!cleaned) return null;
|
|
15830
|
-
return Array.from(cleaned).slice(0, maxLen).join("");
|
|
15831
|
-
}
|
|
15832
|
-
/** List sessions for a specific agent, with optional state filters. */
|
|
15833
|
-
async function listAgentSessions(db, agentId, filters) {
|
|
15834
|
-
const conditions = [eq(agentChatSessions.agentId, agentId)];
|
|
15835
|
-
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
15836
|
-
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
15837
|
-
const rows = await db.select({
|
|
15838
|
-
agentId: agentChatSessions.agentId,
|
|
15839
|
-
chatId: agentChatSessions.chatId,
|
|
15840
|
-
state: agentChatSessions.state,
|
|
15841
|
-
updatedAt: agentChatSessions.updatedAt,
|
|
15842
|
-
chatCreatedAt: chats.createdAt,
|
|
15843
|
-
chatTopic: chats.topic
|
|
15844
|
-
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
|
|
15845
|
-
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
15846
|
-
const agentRuntimeState = presence?.runtimeState ?? null;
|
|
15847
|
-
if (filters?.runtimeState && agentRuntimeState !== filters.runtimeState) return [];
|
|
15848
|
-
const chatIds = rows.map((r) => r.chatId);
|
|
15849
|
-
const messageCounts = chatIds.length > 0 ? await db.select({
|
|
15850
|
-
chatId: inboxEntries.chatId,
|
|
15851
|
-
count: sql`count(*)::int`
|
|
15852
|
-
}).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
|
|
15853
|
-
const countMap = new Map(messageCounts.map((r) => [r.chatId, r.count]));
|
|
15854
|
-
const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
15855
|
-
chatId: messages.chatId,
|
|
15856
|
-
content: messages.content
|
|
15857
|
-
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
15858
|
-
const summaryMap = /* @__PURE__ */ new Map();
|
|
15859
|
-
for (const row of firstMessages) {
|
|
15860
|
-
const summary = extractSummary(row.content);
|
|
15861
|
-
if (summary) summaryMap.set(row.chatId, summary);
|
|
15862
|
-
}
|
|
15863
|
-
return rows.map((r) => ({
|
|
15864
|
-
agentId: r.agentId,
|
|
15865
|
-
chatId: r.chatId,
|
|
15866
|
-
state: r.state,
|
|
15867
|
-
runtimeState: agentRuntimeState,
|
|
15868
|
-
startedAt: r.chatCreatedAt.toISOString(),
|
|
15869
|
-
lastActivityAt: r.updatedAt.toISOString(),
|
|
15870
|
-
messageCount: countMap.get(r.chatId) ?? 0,
|
|
15871
|
-
summary: summaryMap.get(r.chatId) ?? null,
|
|
15872
|
-
topic: r.chatTopic ?? null
|
|
15873
|
-
}));
|
|
15874
|
-
}
|
|
15875
|
-
/** Get a single session's detail. */
|
|
15876
|
-
async function getSession(db, agentId, chatId) {
|
|
15877
|
-
const [row] = await db.select({
|
|
15878
|
-
agentId: agentChatSessions.agentId,
|
|
15879
|
-
chatId: agentChatSessions.chatId,
|
|
15880
|
-
state: agentChatSessions.state,
|
|
15881
|
-
updatedAt: agentChatSessions.updatedAt,
|
|
15882
|
-
chatCreatedAt: chats.createdAt,
|
|
15883
|
-
chatTopic: chats.topic
|
|
15884
|
-
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
|
|
15885
|
-
if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
15886
|
-
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
15887
|
-
const [countRow] = await db.select({ count: sql`count(*)::int` }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), eq(inboxEntries.chatId, chatId)));
|
|
15888
|
-
const firstMsg = (await db.execute(sql`SELECT content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at ASC LIMIT 1`))[0];
|
|
15889
|
-
const summary = firstMsg ? extractSummary(firstMsg.content) : null;
|
|
15890
|
-
return {
|
|
15891
|
-
agentId: row.agentId,
|
|
15892
|
-
chatId: row.chatId,
|
|
15893
|
-
state: row.state,
|
|
15894
|
-
runtimeState: presence?.runtimeState ?? null,
|
|
15895
|
-
startedAt: row.chatCreatedAt.toISOString(),
|
|
15896
|
-
lastActivityAt: row.updatedAt.toISOString(),
|
|
15897
|
-
messageCount: countRow?.count ?? 0,
|
|
15898
|
-
summary,
|
|
15899
|
-
topic: row.chatTopic ?? null
|
|
15900
|
-
};
|
|
15901
|
-
}
|
|
15902
|
-
/** List all sessions across all agents, with pagination. Scoped to organization. */
|
|
15903
|
-
async function listAllSessions(db, limit, cursor, filters) {
|
|
15904
|
-
const conditions = [];
|
|
15905
|
-
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
15906
|
-
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
15907
|
-
if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
|
|
15908
|
-
if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
|
|
15909
|
-
if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
|
|
15910
|
-
const rows = await db.select({
|
|
15911
|
-
agentId: agentChatSessions.agentId,
|
|
15912
|
-
chatId: agentChatSessions.chatId,
|
|
15913
|
-
state: agentChatSessions.state,
|
|
15914
|
-
updatedAt: agentChatSessions.updatedAt,
|
|
15915
|
-
chatCreatedAt: chats.createdAt,
|
|
15916
|
-
chatTopic: chats.topic
|
|
15917
|
-
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).innerJoin(agents, eq(agentChatSessions.agentId, agents.uuid)).where(conditions.length > 0 ? and(...conditions) : void 0).orderBy(desc(agentChatSessions.updatedAt)).limit(limit + 1);
|
|
15918
|
-
const hasMore = rows.length > limit;
|
|
15919
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
15920
|
-
const agentIds = [...new Set(items.map((r) => r.agentId))];
|
|
15921
|
-
const presenceRows = agentIds.length > 0 ? await db.select({
|
|
15922
|
-
agentId: agentPresence.agentId,
|
|
15923
|
-
runtimeState: agentPresence.runtimeState
|
|
15924
|
-
}).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
|
|
15925
|
-
const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
|
|
15926
|
-
const chatIds = [...new Set(items.map((r) => r.chatId))];
|
|
15927
|
-
const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
15928
|
-
chatId: messages.chatId,
|
|
15929
|
-
content: messages.content
|
|
15930
|
-
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
15931
|
-
const summaryMap = /* @__PURE__ */ new Map();
|
|
15932
|
-
for (const row of firstMessages) {
|
|
15933
|
-
const summary = extractSummary(row.content);
|
|
15934
|
-
if (summary) summaryMap.set(row.chatId, summary);
|
|
15935
|
-
}
|
|
15936
|
-
const last = items[items.length - 1];
|
|
15937
|
-
const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
|
|
15938
|
-
return {
|
|
15939
|
-
items: items.map((r) => ({
|
|
15940
|
-
agentId: r.agentId,
|
|
15941
|
-
chatId: r.chatId,
|
|
15942
|
-
state: r.state,
|
|
15943
|
-
runtimeState: runtimeMap.get(r.agentId) ?? null,
|
|
15944
|
-
startedAt: r.chatCreatedAt.toISOString(),
|
|
15945
|
-
lastActivityAt: r.updatedAt.toISOString(),
|
|
15946
|
-
messageCount: 0,
|
|
15947
|
-
summary: summaryMap.get(r.chatId) ?? null,
|
|
15948
|
-
topic: r.chatTopic ?? null
|
|
15949
|
-
})),
|
|
15950
|
-
nextCursor
|
|
15951
|
-
};
|
|
15952
|
-
}
|
|
15953
|
-
/** Commit `active → suspended`. No-op on suspended/evicted. Throws if row is missing. */
|
|
15954
|
-
async function suspendSession(db, agentId, chatId, organizationId, notifier) {
|
|
15955
|
-
return transitionSessionState(db, agentId, chatId, "suspended", ["active"], organizationId, notifier);
|
|
15956
|
-
}
|
|
15957
|
-
/** Commit `suspended → evicted` (terminal — listings hide it, revival defense blocks resurrection). */
|
|
15958
|
-
async function archiveSession(db, agentId, chatId, organizationId, notifier) {
|
|
15959
|
-
return transitionSessionState(db, agentId, chatId, "evicted", ["suspended"], organizationId, notifier);
|
|
15960
|
-
}
|
|
15961
|
-
async function transitionSessionState(db, agentId, chatId, target, from, organizationId, notifier) {
|
|
15962
|
-
const now = /* @__PURE__ */ new Date();
|
|
15963
|
-
let finalState = null;
|
|
15964
|
-
let transitioned = false;
|
|
15965
|
-
await db.transaction(async (tx) => {
|
|
15966
|
-
const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
|
|
15967
|
-
if (!existing) return;
|
|
15968
|
-
const current = existing.state;
|
|
15969
|
-
finalState = current;
|
|
15970
|
-
if (!from.includes(current)) return;
|
|
15971
|
-
await tx.update(agentChatSessions).set({
|
|
15972
|
-
state: target,
|
|
15973
|
-
updatedAt: now
|
|
15974
|
-
}).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId)));
|
|
15975
|
-
const [counts] = await tx.select({
|
|
15976
|
-
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
15977
|
-
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
15978
|
-
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
15979
|
-
await tx.update(agentPresence).set({
|
|
15980
|
-
activeSessions: counts?.active ?? 0,
|
|
15981
|
-
totalSessions: counts?.total ?? 0,
|
|
15982
|
-
lastSeenAt: now
|
|
15983
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
15984
|
-
if (target === "evicted") await markSupersededByChat(tx, chatId, "chat_archived");
|
|
15985
|
-
finalState = target;
|
|
15986
|
-
transitioned = true;
|
|
15987
|
-
});
|
|
15988
|
-
if (finalState === null) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
15989
|
-
if (transitioned && notifier) notifier.notifySessionStateChange(agentId, chatId, target, organizationId).catch(() => {});
|
|
15990
|
-
return {
|
|
15991
|
-
state: finalState,
|
|
15992
|
-
transitioned
|
|
15993
|
-
};
|
|
15994
|
-
}
|
|
15995
|
-
/**
|
|
15996
|
-
* Filter sessions to only those where the given agent is also a participant in the chat.
|
|
15997
|
-
* Used when a non-manager views sessions of an org-visible agent — they should only see
|
|
15998
|
-
* sessions for chats they participate in.
|
|
15999
|
-
*/
|
|
16000
|
-
async function filterSessionsByParticipant(db, sessions, participantAgentId) {
|
|
16001
|
-
if (sessions.length === 0) return [];
|
|
16002
|
-
const chatIds = sessions.map((s) => s.chatId);
|
|
16003
|
-
const participantRows = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.agentId, participantAgentId), eq(chatMembership.accessMode, "speaker")));
|
|
16004
|
-
const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
|
|
16005
|
-
return sessions.filter((s) => allowedChatIds.has(s.chatId));
|
|
16006
|
-
}
|
|
16007
|
-
/**
|
|
16008
|
-
* Member-facing chat service backing `/me/chats*` endpoints (chat-first
|
|
16009
|
-
* workspace).
|
|
16010
|
-
*
|
|
16011
|
-
* Responsibilities:
|
|
16012
|
-
* - Cursor-paginated conversation list (single-stream JOIN over the
|
|
16013
|
-
* unified `chat_membership` + `chat_user_state` tables).
|
|
16014
|
-
* - Create a new chat (no dedupe, runs `recomputeChatWatchers` after).
|
|
16015
|
-
* - Add participants (idempotent, UPSERT into `chat_membership`,
|
|
16016
|
-
* runs `recomputeChatWatchers` after).
|
|
16017
|
-
* - Mark-read (UPSERT into `chat_user_state`).
|
|
16018
|
-
* - Join → watcher to speaker (delegates to `watcher.ts`).
|
|
16019
|
-
* - Leave → speaker to watcher or detach (delegates to `watcher.ts`).
|
|
16020
|
-
*
|
|
16021
|
-
* See proposals/chat-data-model-restructure.20260512.md §8 (schema)
|
|
16022
|
-
* and §11.1 (per-route mapping).
|
|
16023
|
-
*/
|
|
16024
|
-
function encodeCursor(lastMessageAt, chatId) {
|
|
16025
|
-
const payload = `${lastMessageAt ? lastMessageAt.toISOString() : ""}|${chatId}`;
|
|
16026
|
-
return Buffer.from(payload, "utf8").toString("base64url");
|
|
16027
|
-
}
|
|
16028
|
-
function decodeCursor(cursor) {
|
|
16029
|
-
try {
|
|
16030
|
-
const decoded = Buffer.from(cursor, "base64url").toString("utf8");
|
|
16031
|
-
const sep = decoded.indexOf("|");
|
|
16032
|
-
if (sep < 0) return null;
|
|
16033
|
-
const tsPart = decoded.slice(0, sep);
|
|
16034
|
-
const chatId = decoded.slice(sep + 1);
|
|
16035
|
-
if (!chatId) return null;
|
|
16036
|
-
const lastMessageAt = tsPart.length > 0 ? new Date(tsPart) : null;
|
|
16037
|
-
if (lastMessageAt && Number.isNaN(lastMessageAt.getTime())) return null;
|
|
16038
|
-
return {
|
|
16039
|
-
lastMessageAt,
|
|
16040
|
-
chatId
|
|
16041
|
-
};
|
|
16042
|
-
} catch {
|
|
16043
|
-
return null;
|
|
16044
|
-
}
|
|
16045
|
-
}
|
|
16046
|
-
const { ACTIVE: ACTIVE$1, ARCHIVED: ARCHIVED$1, DELETED } = CHAT_ENGAGEMENT_STATUSES;
|
|
16047
|
-
/**
|
|
16048
|
-
* SQL predicate for each engagement view tab. `deleted` is never a valid view
|
|
16049
|
-
* value — deleted rows are reachable only through `GET /chats/:chatId` + the
|
|
16050
|
-
* Restore banner on the chat detail page.
|
|
16051
|
-
*/
|
|
16052
|
-
const ENGAGEMENT_VIEW_PREDICATE = {
|
|
16053
|
-
active: sql`COALESCE(cus.engagement_status, ${ACTIVE$1}) = ${ACTIVE$1}`,
|
|
16054
|
-
archived: sql`COALESCE(cus.engagement_status, ${ACTIVE$1}) = ${ARCHIVED$1}`,
|
|
16055
|
-
all: sql`COALESCE(cus.engagement_status, ${ACTIVE$1}) IN (${ACTIVE$1}, ${ARCHIVED$1})`
|
|
16056
|
-
};
|
|
16057
|
-
/**
|
|
16058
|
-
* Write the caller's engagement state for this chat. UPSERT into
|
|
16059
|
-
* `chat_user_state` — the row may not yet exist (the user might not have
|
|
16060
|
-
* marked-read or been @-mentioned), so an INSERT with the engagement value
|
|
16061
|
-
* is the first write; subsequent transitions are UPDATEs.
|
|
16062
|
-
*
|
|
16063
|
-
* Idempotent. Mirrors the UPSERT shape used by `markMeChatRead`.
|
|
16064
|
-
*/
|
|
16065
|
-
async function setChatEngagement(db, chatId, agentId, status) {
|
|
16066
|
-
await db.insert(chatUserState).values({
|
|
16067
|
-
chatId,
|
|
16068
|
-
agentId,
|
|
16069
|
-
unreadMentionCount: 0,
|
|
16070
|
-
engagementStatus: status
|
|
16071
|
-
}).onConflictDoUpdate({
|
|
16072
|
-
target: [chatUserState.chatId, chatUserState.agentId],
|
|
16073
|
-
set: { engagementStatus: status }
|
|
16074
|
-
});
|
|
16075
|
-
}
|
|
16076
|
-
/**
|
|
16077
|
-
* Read the caller's engagement state. Returns `'active'` when no
|
|
16078
|
-
* `chat_user_state` row exists yet (lazy-materialised; matches the SQL
|
|
16079
|
-
* `COALESCE(..., 'active')` used elsewhere).
|
|
16080
|
-
*/
|
|
16081
|
-
async function getCallerEngagement(db, chatId, agentId) {
|
|
16082
|
-
const [row] = await db.select({ engagementStatus: chatUserState.engagementStatus }).from(chatUserState).where(and(eq(chatUserState.chatId, chatId), eq(chatUserState.agentId, agentId))).limit(1);
|
|
16083
|
-
return row?.engagementStatus ?? ACTIVE$1;
|
|
16084
|
-
}
|
|
16085
|
-
const KNOWN_NON_MANUAL_PREDICATE = sql`(
|
|
16086
|
-
(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' IN (${sql.join(GITHUB_ENTITY_TYPES.map((t) => sql`${t}`), sql.raw(", "))}))
|
|
16087
|
-
OR c.metadata->>'source' = 'feishu'
|
|
16088
|
-
)`;
|
|
16089
|
-
const chatSourceSqlExpression = sql`CASE
|
|
16090
|
-
${sql.join(GITHUB_ENTITY_TYPES.map((t) => sql`WHEN c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = ${t} THEN ${`github_${t}`}`), sql.raw("\n "))}
|
|
16091
|
-
WHEN c.metadata->>'source' = 'feishu' THEN 'feishu'
|
|
16092
|
-
ELSE 'manual'
|
|
16093
|
-
END`;
|
|
16094
|
-
function sourceFilterSql(source) {
|
|
16095
|
-
switch (source) {
|
|
16096
|
-
case "manual": return sql`(${KNOWN_NON_MANUAL_PREDICATE}) IS NOT TRUE`;
|
|
16097
|
-
case "github_issue": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'issue')`;
|
|
16098
|
-
case "github_pull_request": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'pull_request')`;
|
|
16099
|
-
case "github_discussion": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'discussion')`;
|
|
16100
|
-
case "github_commit": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'commit')`;
|
|
16101
|
-
case "feishu": return sql`(c.metadata->>'source' = 'feishu')`;
|
|
16102
|
-
}
|
|
16103
|
-
}
|
|
16104
|
-
/**
|
|
16105
|
-
* GET /me/chats — cursor-paginated conversation list.
|
|
16106
|
-
*
|
|
16107
|
-
* SQL strategy:
|
|
16108
|
-
* - Single-stream query: `chats JOIN chat_membership LEFT JOIN
|
|
16109
|
-
* chat_user_state`. The membership row carries access_mode
|
|
16110
|
-
* (speaker → "participant" / watcher → "watching"); the user
|
|
16111
|
-
* state row supplies the unread counter (COALESCE → 0 when
|
|
16112
|
-
* row is missing).
|
|
16113
|
-
* - Filter `parent_chat_id IS NULL` (nested chats not surfaced in v1).
|
|
16114
|
-
* - Filter `c.organization_id = ?` to defend against historical
|
|
16115
|
-
* cross-org pollution rows that may still reference the caller
|
|
16116
|
-
* (see fix/cross-org-direct-chat-pollution).
|
|
16117
|
-
* - Sort `(last_message_at DESC NULLS LAST, chat_id DESC)`.
|
|
16118
|
-
* - Cursor narrows the result to rows STRICTLY before the cursor.
|
|
16119
|
-
* - Followed by a participants-list lookup for the page only.
|
|
16120
|
-
*/
|
|
16121
|
-
async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
16122
|
-
const limit = query.limit;
|
|
16123
|
-
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
|
16124
|
-
if (query.cursor && !cursor) throw new BadRequestError("Invalid cursor");
|
|
16125
|
-
const filterUnreadOnly = query.filter === "unread";
|
|
16126
|
-
const filterWatchingOnly = query.filter === "watching";
|
|
16127
|
-
const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
|
|
16128
|
-
const sourcePredicate = query.source ? sourceFilterSql(query.source) : sql`TRUE`;
|
|
16129
|
-
const cursorTsIso = cursor?.lastMessageAt ? cursor.lastMessageAt.toISOString() : null;
|
|
16130
|
-
const cursorPredicate = !cursor ? sql`TRUE` : cursor.lastMessageAt === null ? sql`(c.last_message_at IS NULL AND c.id < ${cursor.chatId})` : sql`(c.last_message_at IS NULL
|
|
16131
|
-
OR c.last_message_at < ${cursorTsIso}::timestamptz
|
|
16132
|
-
OR (c.last_message_at = ${cursorTsIso}::timestamptz AND c.id < ${cursor.chatId}))`;
|
|
16133
|
-
const rawRows = await db.execute(sql`
|
|
16134
|
-
SELECT
|
|
16135
|
-
c.id AS chat_id,
|
|
16136
|
-
c.type AS type,
|
|
16137
|
-
c.topic AS topic,
|
|
16138
|
-
c.parent_chat_id AS parent_chat_id,
|
|
16139
|
-
c.last_message_at AS last_message_at,
|
|
16140
|
-
c.last_message_preview AS last_message_preview,
|
|
16141
|
-
(SELECT count(*) FROM chat_membership
|
|
16142
|
-
WHERE chat_id = c.id AND access_mode = 'speaker') AS participant_count,
|
|
16143
|
-
cm.access_mode AS access_mode,
|
|
16144
|
-
COALESCE(cus.unread_mention_count, 0) AS unread_mention_count,
|
|
16145
|
-
COALESCE(cus.engagement_status, ${ACTIVE$1}) AS engagement_status
|
|
16146
|
-
FROM chats c
|
|
16147
|
-
JOIN chat_membership cm
|
|
16148
|
-
ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
|
|
16149
|
-
LEFT JOIN chat_user_state cus
|
|
16150
|
-
ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
|
|
16151
|
-
WHERE c.parent_chat_id IS NULL
|
|
16152
|
-
/* Scope to the caller's org. Without this, cross-org dirty
|
|
16153
|
-
chats whose chat_membership still references the caller's
|
|
16154
|
-
human agent (historical pollution — see
|
|
16155
|
-
fix/cross-org-direct-chat-pollution) would leak into the
|
|
16156
|
-
list and 404 on click via requireChatAccess. */
|
|
16157
|
-
AND c.organization_id = ${organizationId}
|
|
16158
|
-
AND (${!filterUnreadOnly}::bool OR COALESCE(cus.unread_mention_count, 0) > 0)
|
|
16159
|
-
AND (${!filterWatchingOnly}::bool OR cm.access_mode = 'watcher')
|
|
16160
|
-
AND ${engagementPredicate}
|
|
16161
|
-
AND ${sourcePredicate}
|
|
16162
|
-
AND ${cursorPredicate}
|
|
16163
|
-
ORDER BY c.last_message_at DESC NULLS LAST, c.id DESC
|
|
16164
|
-
LIMIT ${limit + 1}
|
|
16165
|
-
`);
|
|
16166
|
-
const toDate = (v) => {
|
|
16167
|
-
if (v === null) return null;
|
|
16168
|
-
return v instanceof Date ? v : new Date(v);
|
|
16169
|
-
};
|
|
16170
|
-
const hasMore = rawRows.length > limit;
|
|
16171
|
-
const pageRaw = hasMore ? rawRows.slice(0, limit) : rawRows;
|
|
16172
|
-
const last = pageRaw[pageRaw.length - 1];
|
|
16173
|
-
const nextCursor = hasMore && last ? encodeCursor(toDate(last.last_message_at), last.chat_id) : null;
|
|
16174
|
-
if (pageRaw.length === 0) return {
|
|
16175
|
-
rows: [],
|
|
16176
|
-
nextCursor: null
|
|
16177
|
-
};
|
|
16178
|
-
const chatIds = pageRaw.map((r) => r.chat_id);
|
|
16179
|
-
const participantRows = await db.select({
|
|
16180
|
-
chatId: chatMembership.chatId,
|
|
16181
|
-
agentId: chatMembership.agentId,
|
|
16182
|
-
displayName: agents.displayName,
|
|
16183
|
-
type: agents.type,
|
|
16184
|
-
avatarColorToken: agents.avatarColorToken,
|
|
16185
|
-
avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
|
|
16186
|
-
sessionState: agentChatSessions.state
|
|
16187
|
-
}).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")));
|
|
16188
|
-
const participantsByChat = /* @__PURE__ */ new Map();
|
|
16189
|
-
const engagedByChat = /* @__PURE__ */ new Map();
|
|
16190
|
-
for (const p of participantRows) {
|
|
16191
|
-
const list = participantsByChat.get(p.chatId) ?? [];
|
|
16192
|
-
list.push({
|
|
16193
|
-
agentId: p.agentId,
|
|
16194
|
-
displayName: p.displayName,
|
|
16195
|
-
type: p.type,
|
|
16196
|
-
avatarColorToken: p.avatarColorToken,
|
|
16197
|
-
avatarImageUrl: agentAvatarImageUrl(p.agentId, p.avatarImageUpdatedAt)
|
|
16198
|
-
});
|
|
16199
|
-
participantsByChat.set(p.chatId, list);
|
|
16200
|
-
if (p.sessionState === "active") {
|
|
16201
|
-
const engaged = engagedByChat.get(p.chatId) ?? [];
|
|
16202
|
-
engaged.push(p.agentId);
|
|
16203
|
-
engagedByChat.set(p.chatId, engaged);
|
|
16204
|
-
}
|
|
16205
|
-
}
|
|
16206
|
-
const liveActivityByChat = await deriveLiveActivity(db, chatIds);
|
|
16207
|
-
const firstMessageRows = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
16208
|
-
chatId: messages.chatId,
|
|
16209
|
-
content: messages.content
|
|
16210
|
-
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
16211
|
-
const firstMessageSummary = /* @__PURE__ */ new Map();
|
|
16212
|
-
for (const row of firstMessageRows) {
|
|
16213
|
-
const s = extractSummary(row.content);
|
|
16214
|
-
if (s) firstMessageSummary.set(row.chatId, s);
|
|
16215
|
-
}
|
|
16216
|
-
return {
|
|
16217
|
-
rows: pageRaw.map((r) => {
|
|
16218
|
-
const participants = participantsByChat.get(r.chat_id) ?? [];
|
|
16219
|
-
const title = resolveChatTitle(r.topic, firstMessageSummary.get(r.chat_id) ?? null, participants, humanAgentId);
|
|
16220
|
-
const isSpeaker = r.access_mode === "speaker";
|
|
16221
|
-
return {
|
|
16222
|
-
chatId: r.chat_id,
|
|
16223
|
-
type: r.type,
|
|
16224
|
-
membershipKind: isSpeaker ? "participant" : "watching",
|
|
16225
|
-
title,
|
|
16226
|
-
topic: r.topic,
|
|
16227
|
-
participants,
|
|
16228
|
-
participantCount: Number(r.participant_count),
|
|
16229
|
-
lastMessageAt: toDate(r.last_message_at)?.toISOString() ?? null,
|
|
16230
|
-
lastMessagePreview: r.last_message_preview,
|
|
16231
|
-
unreadMentionCount: r.unread_mention_count,
|
|
16232
|
-
canReply: isSpeaker,
|
|
16233
|
-
engagementStatus: r.engagement_status,
|
|
16234
|
-
engagedAgentIds: engagedByChat.get(r.chat_id) ?? [],
|
|
16235
|
-
liveActivity: liveActivityByChat.get(r.chat_id) ?? null
|
|
16236
|
-
};
|
|
16237
|
-
}),
|
|
16238
|
-
nextCursor
|
|
16239
|
-
};
|
|
16240
|
-
}
|
|
16241
|
-
/**
|
|
16242
|
-
* Per-chat live activity, derived from the most recent `session_events` row.
|
|
16243
|
-
*
|
|
16244
|
-
* Returns a chatId → LiveActivity map; chats with no activity (or where the
|
|
16245
|
-
* latest event is terminal / stale) are absent from the map (caller treats
|
|
16246
|
-
* absence as null).
|
|
16247
|
-
*/
|
|
16248
|
-
async function deriveLiveActivity(db, chatIds) {
|
|
16249
|
-
if (chatIds.length === 0) return /* @__PURE__ */ new Map();
|
|
16250
|
-
const chatIdInClause = sql.join(chatIds.map((id) => sql`${id}`), sql`, `);
|
|
16251
|
-
const rows = (await db.execute(sql`
|
|
16252
|
-
SELECT acs.agent_id AS agent_id,
|
|
16253
|
-
acs.chat_id AS chat_id,
|
|
16254
|
-
e.kind AS kind,
|
|
16255
|
-
e.payload AS payload,
|
|
16256
|
-
e.created_at AS created_at
|
|
16257
|
-
FROM agent_chat_sessions acs
|
|
16258
|
-
CROSS JOIN LATERAL (
|
|
16259
|
-
SELECT kind, payload, created_at, seq
|
|
16260
|
-
FROM session_events se
|
|
16261
|
-
WHERE se.agent_id = acs.agent_id
|
|
16262
|
-
AND se.chat_id = acs.chat_id
|
|
16263
|
-
ORDER BY se.seq DESC
|
|
16264
|
-
LIMIT 1
|
|
16265
|
-
) e
|
|
16266
|
-
WHERE acs.chat_id IN (${chatIdInClause})
|
|
16267
|
-
AND acs.state <> 'evicted'
|
|
16268
|
-
`)).map((r) => ({
|
|
16269
|
-
agent_id: r.agent_id,
|
|
16270
|
-
chat_id: r.chat_id,
|
|
16271
|
-
kind: r.kind,
|
|
16272
|
-
payload: r.payload,
|
|
16273
|
-
created_at: r.created_at
|
|
16274
|
-
}));
|
|
16275
|
-
const now = Date.now();
|
|
16276
|
-
const byChat = /* @__PURE__ */ new Map();
|
|
16277
|
-
for (const row of rows) {
|
|
16278
|
-
const activity = toLiveActivity(row);
|
|
16279
|
-
if (!activity) continue;
|
|
16280
|
-
const createdAtMs = new Date(row.created_at).getTime();
|
|
16281
|
-
if (now - createdAtMs > 6e4) continue;
|
|
16282
|
-
const existing = byChat.get(row.chat_id);
|
|
16283
|
-
if (!existing || createdAtMs > existing.createdAtMs) byChat.set(row.chat_id, {
|
|
16284
|
-
activity,
|
|
16285
|
-
createdAtMs
|
|
16286
|
-
});
|
|
16287
|
-
}
|
|
16288
|
-
const out = /* @__PURE__ */ new Map();
|
|
16289
|
-
for (const [chatId, { activity }] of byChat) out.set(chatId, activity);
|
|
16290
|
-
return out;
|
|
16291
|
-
}
|
|
16292
|
-
/**
|
|
16293
|
-
* Translate a `session_events` row into a `LiveActivity`, or null when the
|
|
16294
|
-
* kind is terminal (`turn_end` / `error`) or unrecognised. Pure & exported
|
|
16295
|
-
* for unit testing.
|
|
16296
|
-
*/
|
|
16297
|
-
function toLiveActivity(row) {
|
|
16298
|
-
const startedAt = new Date(row.created_at).toISOString();
|
|
16299
|
-
switch (row.kind) {
|
|
16300
|
-
case "tool_call": {
|
|
16301
|
-
const payload = row.payload ?? {};
|
|
16302
|
-
const label = typeof payload.name === "string" && payload.name.length > 0 ? payload.name : "Tool";
|
|
16303
|
-
return {
|
|
16304
|
-
agentId: row.agent_id,
|
|
16305
|
-
kind: "tool_call",
|
|
16306
|
-
label,
|
|
16307
|
-
startedAt
|
|
16308
|
-
};
|
|
16309
|
-
}
|
|
16310
|
-
case "thinking": return {
|
|
16311
|
-
agentId: row.agent_id,
|
|
16312
|
-
kind: "thinking",
|
|
16313
|
-
label: "Thinking",
|
|
16314
|
-
startedAt
|
|
16315
|
-
};
|
|
16316
|
-
case "assistant_text": return {
|
|
16317
|
-
agentId: row.agent_id,
|
|
16318
|
-
kind: "assistant_text",
|
|
16319
|
-
label: "Writing",
|
|
16320
|
-
startedAt
|
|
16321
|
-
};
|
|
16322
|
-
default: return null;
|
|
16323
|
-
}
|
|
16324
|
-
}
|
|
16325
|
-
/**
|
|
16326
|
-
* Title resolution priority:
|
|
16327
|
-
*
|
|
16328
|
-
* 1. `chat.topic` (manual, set via `PATCH /chats/:chatId`)
|
|
16329
|
-
* 2. First message summary (auto, ≤ 50 chars from `extractSummary`)
|
|
16330
|
-
* 3. Participant join (fallback when chat has no messages yet)
|
|
16331
|
-
*/
|
|
16332
|
-
function resolveChatTitle(topic, firstMessageSummary, participants, selfAgentId) {
|
|
16333
|
-
if (topic && topic.length > 0) return topic;
|
|
16334
|
-
if (firstMessageSummary && firstMessageSummary.length > 0) return firstMessageSummary;
|
|
16335
|
-
const others = participants.filter((p) => p.agentId !== selfAgentId);
|
|
16336
|
-
if (others.length === 0) return "Empty chat";
|
|
16337
|
-
if (others.length <= 3) return others.map((p) => p.displayName).join(", ");
|
|
16338
|
-
return `${others[0]?.displayName}, ${others[1]?.displayName} +${others.length - 2}`;
|
|
16339
|
-
}
|
|
16340
|
-
async function createMeChat(db, humanAgentId, organizationId, body) {
|
|
16341
|
-
const distinctIds = [...new Set(body.participantIds)].filter((id) => id !== humanAgentId);
|
|
16342
|
-
if (distinctIds.length === 0) throw new BadRequestError("At least one non-self participant required");
|
|
16343
|
-
const allIds = [humanAgentId, ...distinctIds];
|
|
16344
|
-
const found = await db.select({
|
|
16345
|
-
uuid: agents.uuid,
|
|
16346
|
-
organizationId: agents.organizationId,
|
|
16347
|
-
type: agents.type
|
|
16348
|
-
}).from(agents).where(inArray(agents.uuid, allIds));
|
|
16349
|
-
if (found.length !== allIds.length) {
|
|
16350
|
-
const foundSet = new Set(found.map((a) => a.uuid));
|
|
16351
|
-
throw new BadRequestError(`Agents not found: ${allIds.filter((id) => !foundSet.has(id)).join(", ")}`);
|
|
16352
|
-
}
|
|
16353
|
-
const crossOrg = found.filter((a) => a.organizationId !== organizationId);
|
|
16354
|
-
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.uuid).join(", ")}`);
|
|
16355
|
-
const chatType = distinctIds.length === 1 ? "direct" : "group";
|
|
16356
|
-
const chatId = randomUUID();
|
|
16357
|
-
const topic = body.topic ?? null;
|
|
16358
|
-
await db.transaction(async (tx) => {
|
|
16359
|
-
await tx.insert(chats).values({
|
|
16360
|
-
id: chatId,
|
|
16361
|
-
organizationId,
|
|
16362
|
-
type: chatType,
|
|
16363
|
-
topic
|
|
16364
|
-
});
|
|
16365
|
-
await addChatParticipants(tx, chatId, allIds.map((agentId) => ({
|
|
16366
|
-
agentId,
|
|
16367
|
-
role: agentId === humanAgentId ? "owner" : "member"
|
|
16368
|
-
})));
|
|
16369
|
-
await recomputeChatWatchers(tx, chatId);
|
|
16370
|
-
});
|
|
16371
|
-
invalidateChatAudience(chatId);
|
|
16372
|
-
return { chatId };
|
|
16373
|
-
}
|
|
16374
|
-
async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrganizationId, body) {
|
|
16375
|
-
const distinct = [...new Set(body.participantIds)];
|
|
16376
|
-
if (distinct.length === 0) throw new BadRequestError("At least one participant required");
|
|
16377
|
-
const [chat] = await db.select({
|
|
16378
|
-
id: chats.id,
|
|
16379
|
-
organizationId: chats.organizationId,
|
|
16380
|
-
type: chats.type
|
|
16381
|
-
}).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
16382
|
-
if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
16383
|
-
if (chat.organizationId !== callerOrganizationId) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
16384
|
-
const [callerRow] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, callerHumanAgentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
|
|
16385
|
-
if (!callerRow) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
16386
|
-
const found = await db.select({
|
|
16387
|
-
uuid: agents.uuid,
|
|
16388
|
-
organizationId: agents.organizationId,
|
|
16389
|
-
type: agents.type
|
|
16390
|
-
}).from(agents).where(inArray(agents.uuid, distinct));
|
|
16391
|
-
if (found.length !== distinct.length) {
|
|
16392
|
-
const foundSet = new Set(found.map((a) => a.uuid));
|
|
16393
|
-
throw new BadRequestError(`Agents not found: ${distinct.filter((id) => !foundSet.has(id)).join(", ")}`);
|
|
16394
|
-
}
|
|
16395
|
-
const crossOrg = found.filter((a) => a.organizationId !== chat.organizationId);
|
|
16396
|
-
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization participant rejected: ${crossOrg.map((a) => a.uuid).join(", ")}`);
|
|
16397
|
-
await db.transaction(async (tx) => {
|
|
16398
|
-
const existingSpeakers = await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
16399
|
-
const existingSpeakerSet = new Set(existingSpeakers.map((e) => e.agentId));
|
|
16400
|
-
const toUpsert = distinct.filter((id) => !existingSpeakerSet.has(id));
|
|
16401
|
-
if (toUpsert.length === 0) {
|
|
16402
|
-
await recomputeChatWatchers(tx, chatId);
|
|
16403
|
-
return;
|
|
16404
|
-
}
|
|
16405
|
-
if (existingSpeakers.length + toUpsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
|
|
16406
|
-
await addChatParticipants(tx, chatId, toUpsert.map((agentId) => ({
|
|
16407
|
-
agentId,
|
|
16408
|
-
role: "member"
|
|
16409
|
-
})), { upgradeWatcherToSpeaker: true });
|
|
16410
|
-
await recomputeChatWatchers(tx, chatId);
|
|
16411
|
-
});
|
|
16412
|
-
invalidateChatAudience(chatId);
|
|
16413
|
-
}
|
|
16414
|
-
async function markMeChatRead(db, chatId, humanAgentId) {
|
|
16415
|
-
const now = /* @__PURE__ */ new Date();
|
|
16416
|
-
await db.insert(chatUserState).values({
|
|
16417
|
-
chatId,
|
|
16418
|
-
agentId: humanAgentId,
|
|
16419
|
-
lastReadAt: now,
|
|
16420
|
-
unreadMentionCount: 0
|
|
16421
|
-
}).onConflictDoUpdate({
|
|
16422
|
-
target: [chatUserState.chatId, chatUserState.agentId],
|
|
16423
|
-
set: {
|
|
16424
|
-
lastReadAt: now,
|
|
16425
|
-
unreadMentionCount: 0
|
|
16426
|
-
}
|
|
16427
|
-
});
|
|
16428
|
-
return {
|
|
16429
|
-
chatId,
|
|
16430
|
-
lastReadAt: now.toISOString(),
|
|
16431
|
-
unreadMentionCount: 0
|
|
16432
|
-
};
|
|
16433
|
-
}
|
|
16434
|
-
async function joinMeChat(db, chatId, humanAgentId) {
|
|
16435
|
-
ensureCanJoin(await resolveChatMembership(db, chatId, humanAgentId));
|
|
16436
|
-
await joinAsParticipant(db, chatId, humanAgentId);
|
|
16437
|
-
invalidateChatAudience(chatId);
|
|
16438
|
-
}
|
|
16439
|
-
async function leaveMeChat(db, chatId, humanAgentId) {
|
|
16440
|
-
const result = await leaveAsParticipant(db, chatId, humanAgentId);
|
|
16441
|
-
invalidateChatAudience(chatId);
|
|
16442
|
-
return result;
|
|
16443
|
-
}
|
|
16444
|
-
/**
|
|
16445
|
-
* Used by future bell-badge / list-pill counts. The partial index
|
|
16446
|
-
* `idx_user_state_unread WHERE unread_mention_count > 0` bounds the
|
|
16447
|
-
* driving scan; we then join `chat_membership` + `chats` so the badge
|
|
16448
|
-
* stays consistent with `listMeChats`.
|
|
16449
|
-
*
|
|
16450
|
-
* Why the joins (not just a single-table count): per §11.4 a user's
|
|
16451
|
-
* `chat_user_state` row is **preserved on detach** so read state
|
|
16452
|
-
* survives a leave/rejoin cycle. Without the membership join, any
|
|
16453
|
-
* preserved row with `unread_mention_count > 0` would keep
|
|
16454
|
-
* contributing to the badge even though the chat no longer appears in
|
|
16455
|
-
* the list. The `chats` join applies the same org-scoping +
|
|
16456
|
-
* `parent_chat_id IS NULL` filter as `listMeChats` so the two counts
|
|
16457
|
-
* cannot drift in the cross-org pollution or nested-chat cases either.
|
|
16458
|
-
*
|
|
16459
|
-
* Engagement parity: deleted chats are excluded from `listMeChats`
|
|
16460
|
-
* (any `engagement` view), so the badge must exclude them too — otherwise
|
|
16461
|
-
* the user sees an unread red dot for a chat they've removed from view.
|
|
16462
|
-
*/
|
|
16463
|
-
/**
|
|
16464
|
-
* Per-source aggregate for the conversation-list tag bar.
|
|
16465
|
-
*
|
|
16466
|
-
* Returns one row per source the caller has at least one chat for, plus an
|
|
16467
|
-
* always-present `manual` entry (zero counts when there are no manual chats —
|
|
16468
|
-
* the workspace UI uses `manual` as its default tab and must render it even
|
|
16469
|
-
* when empty).
|
|
16470
|
-
*
|
|
16471
|
-
* Filtering matches `listMeChats` for the corresponding tab so the badges
|
|
16472
|
-
* cannot drift from the list: same membership join, same `parent_chat_id IS
|
|
16473
|
-
* NULL` and `organization_id` scopes, same engagement view, same
|
|
16474
|
-
* `chat_user_state.unread_mention_count` source.
|
|
16475
|
-
*/
|
|
16476
|
-
async function listMeChatSourceCounts(db, humanAgentId, organizationId, query) {
|
|
16477
|
-
const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
|
|
16478
|
-
const rows = await db.execute(sql`
|
|
16479
|
-
SELECT
|
|
16480
|
-
${chatSourceSqlExpression} AS source,
|
|
16481
|
-
count(*)::int AS chat_count,
|
|
16482
|
-
count(*) FILTER (WHERE COALESCE(cus.unread_mention_count, 0) > 0)::int AS unread_chat_count
|
|
16483
|
-
FROM chats c
|
|
16484
|
-
JOIN chat_membership cm
|
|
16485
|
-
ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
|
|
16486
|
-
LEFT JOIN chat_user_state cus
|
|
16487
|
-
ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
|
|
16488
|
-
WHERE c.parent_chat_id IS NULL
|
|
16489
|
-
AND c.organization_id = ${organizationId}
|
|
16490
|
-
AND ${engagementPredicate}
|
|
16491
|
-
GROUP BY 1
|
|
16492
|
-
`);
|
|
16493
|
-
const counts = {};
|
|
16494
|
-
for (const row of rows) counts[row.source] = {
|
|
16495
|
-
chatCount: Number(row.chat_count),
|
|
16496
|
-
unreadChatCount: Number(row.unread_chat_count)
|
|
16497
|
-
};
|
|
16498
|
-
if (!counts.manual) counts.manual = {
|
|
16499
|
-
chatCount: 0,
|
|
16500
|
-
unreadChatCount: 0
|
|
16501
|
-
};
|
|
16502
|
-
return { counts };
|
|
16503
|
-
}
|
|
16504
|
-
/**
|
|
16505
14967
|
* Class C — resource-scoped chat routes. Mounted at
|
|
16506
14968
|
* `/api/v1/chats/:chatId/...`. The chat's `organizationId` locates its
|
|
16507
14969
|
* org; `requireChatAccess` resolves the caller's membership in that org
|
|
@@ -16510,7 +14972,17 @@ async function listMeChatSourceCounts(db, humanAgentId, organizationId, query) {
|
|
|
16510
14972
|
async function chatRoutes(app) {
|
|
16511
14973
|
app.get("/:chatId", async (request) => {
|
|
16512
14974
|
const { chat, scope } = await requireChatAccess(request, app.db);
|
|
16513
|
-
const participants = await app.db.select(
|
|
14975
|
+
const participants = await app.db.select({
|
|
14976
|
+
agentId: chatMembership.agentId,
|
|
14977
|
+
role: chatMembership.role,
|
|
14978
|
+
mode: chatMembership.mode,
|
|
14979
|
+
joinedAt: chatMembership.joinedAt,
|
|
14980
|
+
name: agents.name,
|
|
14981
|
+
displayName: agents.displayName,
|
|
14982
|
+
type: agents.type,
|
|
14983
|
+
avatarColorToken: agents.avatarColorToken,
|
|
14984
|
+
avatarImageUpdatedAt: agents.avatarImageUpdatedAt
|
|
14985
|
+
}).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chat.id), eq(chatMembership.accessMode, "speaker")));
|
|
16514
14986
|
const firstMsgRows = await app.db.execute(sql`
|
|
16515
14987
|
SELECT content FROM messages
|
|
16516
14988
|
WHERE chat_id = ${chat.id}
|
|
@@ -16518,25 +14990,13 @@ async function chatRoutes(app) {
|
|
|
16518
14990
|
LIMIT 1
|
|
16519
14991
|
`);
|
|
16520
14992
|
const firstMessagePreview = firstMsgRows[0] ? extractSummary(firstMsgRows[0].content) : null;
|
|
16521
|
-
const
|
|
16522
|
-
|
|
16523
|
-
|
|
16524
|
-
|
|
16525
|
-
|
|
16526
|
-
|
|
16527
|
-
|
|
16528
|
-
}).from(agents).where(inArray(agents.uuid, participantAgentIds)) : [];
|
|
16529
|
-
const agentMeta = new Map(agentRows.map((a) => [a.agentId, a]));
|
|
16530
|
-
const participantsForTitle = participants.map((p) => {
|
|
16531
|
-
const meta = agentMeta.get(p.agentId);
|
|
16532
|
-
return {
|
|
16533
|
-
agentId: p.agentId,
|
|
16534
|
-
displayName: meta?.displayName ?? p.agentId,
|
|
16535
|
-
type: meta?.type ?? "unknown",
|
|
16536
|
-
avatarColorToken: meta?.avatarColorToken ?? null,
|
|
16537
|
-
avatarImageUrl: agentAvatarImageUrl(p.agentId, meta?.avatarImageUpdatedAt ?? null)
|
|
16538
|
-
};
|
|
16539
|
-
});
|
|
14993
|
+
const participantsForTitle = participants.map((p) => ({
|
|
14994
|
+
agentId: p.agentId,
|
|
14995
|
+
displayName: p.displayName,
|
|
14996
|
+
type: p.type,
|
|
14997
|
+
avatarColorToken: p.avatarColorToken ?? null,
|
|
14998
|
+
avatarImageUrl: agentAvatarImageUrl(p.agentId, p.avatarImageUpdatedAt ?? null)
|
|
14999
|
+
}));
|
|
16540
15000
|
const title = resolveChatTitle(chat.topic, firstMessagePreview, participantsForTitle, scope.humanAgentId);
|
|
16541
15001
|
const engagementStatus = await getCallerEngagement(app.db, chat.id, scope.humanAgentId);
|
|
16542
15002
|
return {
|
|
@@ -16550,6 +15010,9 @@ async function chatRoutes(app) {
|
|
|
16550
15010
|
agentId: p.agentId,
|
|
16551
15011
|
role: p.role,
|
|
16552
15012
|
mode: p.mode,
|
|
15013
|
+
name: p.name,
|
|
15014
|
+
displayName: p.displayName,
|
|
15015
|
+
type: p.type,
|
|
16553
15016
|
joinedAt: p.joinedAt.toISOString()
|
|
16554
15017
|
}))
|
|
16555
15018
|
};
|
|
@@ -16699,6 +15162,11 @@ async function chatRoutes(app) {
|
|
|
16699
15162
|
const { scope } = await requireChatAccess(request, app.db);
|
|
16700
15163
|
return markMeChatRead(app.db, request.params.chatId, scope.humanAgentId);
|
|
16701
15164
|
});
|
|
15165
|
+
/** POST /chats/:chatId/unread — manual "mark as unread" affordance. Idempotent. */
|
|
15166
|
+
app.post("/:chatId/unread", async (request) => {
|
|
15167
|
+
const { scope } = await requireChatAccess(request, app.db);
|
|
15168
|
+
return markMeChatUnread(app.db, request.params.chatId, scope.humanAgentId);
|
|
15169
|
+
});
|
|
16702
15170
|
/** POST /chats/:chatId/participants — add speaking participants. Idempotent. */
|
|
16703
15171
|
app.post("/:chatId/participants", async (request, reply) => {
|
|
16704
15172
|
const { scope } = await requireChatAccess(request, app.db);
|
|
@@ -17158,6 +15626,26 @@ function normalizeRemoteRepoUrl(value) {
|
|
|
17158
15626
|
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(value)) return `https://github.com/${value}`;
|
|
17159
15627
|
return value;
|
|
17160
15628
|
}
|
|
15629
|
+
/**
|
|
15630
|
+
* Whether this binding actually drives a GitHub-hosted remote fetch — the
|
|
15631
|
+
* only case where minting a GitHub App installation token is meaningful.
|
|
15632
|
+
*
|
|
15633
|
+
* Returns false when:
|
|
15634
|
+
* - `localPath` is set (sync code short-circuits to the local checkout
|
|
15635
|
+
* before ever looking at `repo`)
|
|
15636
|
+
* - `repo` is missing
|
|
15637
|
+
* - `repo` is a file:// URL, a non-GitHub HTTPS URL, or otherwise
|
|
15638
|
+
* unparseable
|
|
15639
|
+
*
|
|
15640
|
+
* Used by the snapshot routes to gate the "install the GitHub App"
|
|
15641
|
+
* guidance — without this gate, every unavailable snapshot (missing repo,
|
|
15642
|
+
* bad branch, …) gets a misleading App-install hint appended.
|
|
15643
|
+
*/
|
|
15644
|
+
function isGithubRemoteBinding(binding) {
|
|
15645
|
+
if (binding.localPath && binding.localPath.trim().length > 0) return false;
|
|
15646
|
+
if (!binding.repo) return false;
|
|
15647
|
+
return isGithubHttpsRepo(normalizeRemoteRepoUrl(binding.repo));
|
|
15648
|
+
}
|
|
17161
15649
|
function managedContextTreeCacheRoot() {
|
|
17162
15650
|
return join(DEFAULT_DATA_DIR$1, "context-tree-repos");
|
|
17163
15651
|
}
|
|
@@ -17331,7 +15819,7 @@ function errorMessage(error) {
|
|
|
17331
15819
|
return redactSecret(error.message.trim().split("\n")[0] ?? "");
|
|
17332
15820
|
}
|
|
17333
15821
|
function redactSecret(message) {
|
|
17334
|
-
return message.replace(/(https?:\/\/)[^/@\s]+@/g, "$1[redacted]@").replace(/\
|
|
15822
|
+
return message.replace(/(https?:\/\/)[^/@\s]+@/g, "$1[redacted]@").replace(/\b(?:ghp|ghs|ghu|gho|ghr)_[A-Za-z0-9_]+/g, "[redacted]").replace(/\bgithub_pat_[A-Za-z0-9_]+/g, "[redacted]");
|
|
17335
15823
|
}
|
|
17336
15824
|
function unavailableSnapshot(repo, branch, detail) {
|
|
17337
15825
|
return {
|
|
@@ -17911,6 +16399,80 @@ function ghostNodeId(path) {
|
|
|
17911
16399
|
function toPosix(path) {
|
|
17912
16400
|
return sep === "/" ? path : path.split(sep).join("/");
|
|
17913
16401
|
}
|
|
16402
|
+
/**
|
|
16403
|
+
* Mint a short-lived GitHub App installation token for the given installation.
|
|
16404
|
+
* Returns `ok: false` (with a precise reason) when the org has no App
|
|
16405
|
+
* configured, no installation row, the installation is suspended, or GitHub
|
|
16406
|
+
* rejects the mint — callers fall back to unauthenticated git fetch (public
|
|
16407
|
+
* repos still resolve; private repos surface as an unavailable snapshot
|
|
16408
|
+
* with a remediation message).
|
|
16409
|
+
*
|
|
16410
|
+
* Takes the `installation` row directly so the helper has no DB dependency
|
|
16411
|
+
* — route handlers do the `findInstallationByOrg` lookup themselves. Keeps
|
|
16412
|
+
* this module a pure transform that's trivial to unit-test.
|
|
16413
|
+
*
|
|
16414
|
+
* Credentials use the narrow `GithubAppCredentials` shape so the helper
|
|
16415
|
+
* isn't coupled to the broader OAuth config surface; callers pass
|
|
16416
|
+
* `config.oauth?.githubApp`, which structurally satisfies it.
|
|
16417
|
+
*/
|
|
16418
|
+
async function mintContextTreeInstallationToken(installation, appCredentials, options = {}) {
|
|
16419
|
+
if (!appCredentials) return {
|
|
16420
|
+
ok: false,
|
|
16421
|
+
reason: "no-app-config"
|
|
16422
|
+
};
|
|
16423
|
+
if (!installation) return {
|
|
16424
|
+
ok: false,
|
|
16425
|
+
reason: "no-installation"
|
|
16426
|
+
};
|
|
16427
|
+
if (installation.suspendedAt) return {
|
|
16428
|
+
ok: false,
|
|
16429
|
+
reason: "suspended"
|
|
16430
|
+
};
|
|
16431
|
+
try {
|
|
16432
|
+
return {
|
|
16433
|
+
ok: true,
|
|
16434
|
+
token: (await mintInstallationToken(await createAppJwt({
|
|
16435
|
+
appId: appCredentials.appId,
|
|
16436
|
+
privateKeyPem: appCredentials.privateKeyPem
|
|
16437
|
+
}), installation.installationId, { fetcher: options.fetcher })).token
|
|
16438
|
+
};
|
|
16439
|
+
} catch (error) {
|
|
16440
|
+
return {
|
|
16441
|
+
ok: false,
|
|
16442
|
+
reason: "mint-failed",
|
|
16443
|
+
detail: error instanceof GithubAppApiError ? `GitHub returned ${error.status} when minting an installation token.` : "Hub could not mint a GitHub App installation token."
|
|
16444
|
+
};
|
|
16445
|
+
}
|
|
16446
|
+
}
|
|
16447
|
+
/**
|
|
16448
|
+
* Append a remediation hint to an unavailable snapshot's `contextStatus.detail`
|
|
16449
|
+
* when the underlying cause is a missing / suspended / failed GitHub App token
|
|
16450
|
+
* mint. Public-repo snapshots (mint reason `no-app-config`) are left untouched
|
|
16451
|
+
* — the deployment may legitimately have no App configured.
|
|
16452
|
+
*
|
|
16453
|
+
* Gated on `isGithubRemoteBinding(binding)` so unrelated unavailable
|
|
16454
|
+
* reasons (no repo configured, localPath missing, illegal branch name,
|
|
16455
|
+
* public-repo fetch error) don't get a misleading "install the GitHub
|
|
16456
|
+
* App" hint appended.
|
|
16457
|
+
*
|
|
16458
|
+
* Lives next to `mintContextTreeInstallationToken` so the two routes that
|
|
16459
|
+
* call mint share one shaping function; the snapshot service itself stays
|
|
16460
|
+
* token-agnostic.
|
|
16461
|
+
*/
|
|
16462
|
+
function decorateSnapshotWithMintGuidance(snapshot, binding, mintResult) {
|
|
16463
|
+
if (mintResult.ok) return snapshot;
|
|
16464
|
+
if (snapshot.snapshotStatus !== "unavailable") return snapshot;
|
|
16465
|
+
if (mintResult.reason === "no-app-config") return snapshot;
|
|
16466
|
+
if (!isGithubRemoteBinding(binding)) return snapshot;
|
|
16467
|
+
const guidance = mintResult.reason === "no-installation" ? "Install the First Tree GitHub App from Team Settings and grant it access to this repo." : mintResult.reason === "suspended" ? "The GitHub App installation is suspended — unsuspend it from your GitHub account settings." : `Hub could not mint a GitHub App installation token.${mintResult.detail ? ` ${mintResult.detail}` : ""}`;
|
|
16468
|
+
return {
|
|
16469
|
+
...snapshot,
|
|
16470
|
+
contextStatus: {
|
|
16471
|
+
...snapshot.contextStatus,
|
|
16472
|
+
detail: `${snapshot.contextStatus.detail} ${guidance}`
|
|
16473
|
+
}
|
|
16474
|
+
};
|
|
16475
|
+
}
|
|
17914
16476
|
const querySchema$1 = z.object({ window: z.enum([
|
|
17915
16477
|
"1d",
|
|
17916
16478
|
"7d",
|
|
@@ -17926,12 +16488,15 @@ async function contextTreeSnapshotRoutes(app) {
|
|
|
17926
16488
|
const { userId } = requireUser(request);
|
|
17927
16489
|
const orgId = await resolveUserPrimaryOrgId(app.db, userId);
|
|
17928
16490
|
const binding = orgId ? await getOrgContextTree(app.db, orgId) : {};
|
|
17929
|
-
|
|
16491
|
+
let mintResult = null;
|
|
16492
|
+
if (orgId && isGithubRemoteBinding(binding)) mintResult = await mintContextTreeInstallationToken(await findInstallationByOrg(app.db, orgId), app.config.oauth?.githubApp);
|
|
16493
|
+
const githubToken = mintResult?.ok ? mintResult.token : void 0;
|
|
17930
16494
|
const window = query.window ?? "7d";
|
|
17931
|
-
const
|
|
16495
|
+
const rawSnapshot = await getContextTreeSnapshot({
|
|
17932
16496
|
...binding,
|
|
17933
16497
|
githubToken
|
|
17934
16498
|
}, window);
|
|
16499
|
+
const snapshot = mintResult ? decorateSnapshotWithMintGuidance(rawSnapshot, binding, mintResult) : rawSnapshot;
|
|
17935
16500
|
const usage = orgId ? await summarizeContextTreeUsage(app.db, orgId, contextTreeSnapshotWindowDays(window)) : snapshot.usage;
|
|
17936
16501
|
return contextTreeSnapshotSchema.parse({
|
|
17937
16502
|
...snapshot,
|
|
@@ -17939,31 +16504,6 @@ async function contextTreeSnapshotRoutes(app) {
|
|
|
17939
16504
|
});
|
|
17940
16505
|
});
|
|
17941
16506
|
}
|
|
17942
|
-
function contextTreeGithubTokenForRepo(repo, syncConfig) {
|
|
17943
|
-
if (!repo || !syncConfig?.githubToken) return void 0;
|
|
17944
|
-
const repoKey = githubRepoKey(repo);
|
|
17945
|
-
if (!repoKey) return void 0;
|
|
17946
|
-
return new Set((syncConfig.githubTokenRepos ?? "").split(",").map((entry) => normalizeGithubRepoKey(entry)).filter((entry) => entry !== null)).has(repoKey) ? syncConfig.githubToken : void 0;
|
|
17947
|
-
}
|
|
17948
|
-
function githubRepoKey(value) {
|
|
17949
|
-
const shorthand = normalizeGithubRepoKey(value);
|
|
17950
|
-
if (shorthand) return shorthand;
|
|
17951
|
-
let url;
|
|
17952
|
-
try {
|
|
17953
|
-
url = new URL(value);
|
|
17954
|
-
} catch {
|
|
17955
|
-
return null;
|
|
17956
|
-
}
|
|
17957
|
-
if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "github.com") return null;
|
|
17958
|
-
if (url.username || url.password) return null;
|
|
17959
|
-
return normalizeGithubRepoKey(url.pathname.replace(/^\/+/, ""));
|
|
17960
|
-
}
|
|
17961
|
-
function normalizeGithubRepoKey(value) {
|
|
17962
|
-
const trimmed = value.trim().replace(/^\/+/, "").replace(/\.git$/i, "");
|
|
17963
|
-
const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed);
|
|
17964
|
-
if (!match) return null;
|
|
17965
|
-
return `${match[1]?.toLowerCase()}/${match[2]?.toLowerCase()}`;
|
|
17966
|
-
}
|
|
17967
16507
|
/**
|
|
17968
16508
|
* Resolve the client IP for rate-limit attribution.
|
|
17969
16509
|
*
|
|
@@ -18065,7 +16605,7 @@ async function healthzRoutes(app) {
|
|
|
18065
16605
|
* `api/orgs/invitations.ts` (Class B, admin-gated).
|
|
18066
16606
|
*/
|
|
18067
16607
|
async function publicInvitationRoutes(app) {
|
|
18068
|
-
const { previewInvitation } = await import("./invitation-
|
|
16608
|
+
const { previewInvitation } = await import("./invitation-C9m2gQx4-BSErdb8x.mjs");
|
|
18069
16609
|
app.get("/:token/preview", async (request, reply) => {
|
|
18070
16610
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
18071
16611
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -18354,7 +16894,7 @@ async function meRoutes(app) {
|
|
|
18354
16894
|
*/
|
|
18355
16895
|
app.get("/me/pinned-agents", async (request) => {
|
|
18356
16896
|
const { userId } = requireUser(request);
|
|
18357
|
-
const { listMyPinnedAgents } = await import("./client-
|
|
16897
|
+
const { listMyPinnedAgents } = await import("./client-q1EYQD1n-ypjoumIO.mjs");
|
|
18358
16898
|
return listMyPinnedAgents(app.db, { userId });
|
|
18359
16899
|
});
|
|
18360
16900
|
/**
|
|
@@ -18930,12 +17470,15 @@ async function orgContextTreeSnapshotRoutes(app) {
|
|
|
18930
17470
|
const query = querySchema.parse(request.query);
|
|
18931
17471
|
const scope = await requireOrgMembership(request, app.db);
|
|
18932
17472
|
const binding = await getOrgContextTree(app.db, scope.organizationId);
|
|
18933
|
-
|
|
17473
|
+
let mintResult = null;
|
|
17474
|
+
if (isGithubRemoteBinding(binding)) mintResult = await mintContextTreeInstallationToken(await findInstallationByOrg(app.db, scope.organizationId), app.config.oauth?.githubApp);
|
|
17475
|
+
const githubToken = mintResult?.ok ? mintResult.token : void 0;
|
|
18934
17476
|
const window = query.window ?? "7d";
|
|
18935
|
-
const
|
|
17477
|
+
const rawSnapshot = await getContextTreeSnapshot({
|
|
18936
17478
|
...binding,
|
|
18937
17479
|
githubToken
|
|
18938
17480
|
}, window);
|
|
17481
|
+
const snapshot = mintResult ? decorateSnapshotWithMintGuidance(rawSnapshot, binding, mintResult) : rawSnapshot;
|
|
18939
17482
|
const usage = await summarizeContextTreeUsage(app.db, scope.organizationId, contextTreeSnapshotWindowDays(window));
|
|
18940
17483
|
return contextTreeSnapshotSchema.parse({
|
|
18941
17484
|
...snapshot,
|