@agent-team-foundation/first-tree-hub 0.14.1 → 0.14.2
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/cli/index.mjs +10 -18
- package/dist/{client-CREn8bJ0-C5fHJir6.mjs → client-CDw0f-kN-BPzOVd8L.mjs} +3 -3
- package/dist/{client-CzXmweS9-DhUiuQvL.mjs → client-CZ_VnbEc-CBF46cJd.mjs} +3119 -1290
- package/dist/{dist-1XGLJMOq.mjs → dist-DmYxT5Kb.mjs} +33 -6
- package/dist/{feishu-BGx71p5s.mjs → feishu-CCWd-JE4.mjs} +1 -1
- package/dist/index.mjs +6 -6
- package/dist/invitation-C9m2gQx4-CkwWteA3.mjs +4 -0
- package/dist/{invitation-DZO4NX3P-BPxTeHf-.mjs → invitation-D_ENPHyj-5ETiae5r.mjs} +3 -23
- package/dist/{saas-connect-BBRxjmBS.mjs → saas-connect-DgCSZ8Yk.mjs} +297 -1760
- 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
|
@@ -2,11 +2,11 @@ import { a as __toCommonJS, o as __toESM, t as __commonJSMin } from "./chunk-BSw
|
|
|
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
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-C15ZBOCC.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-DmYxT5Kb.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 getSession, $t as suspendSession, A as createAgent, At as pollInbox, B as extractSummary, Bt as resetTimedOutEntries, C as claimAndBuildForPush, Ct as markMeChatRead, D as cleanupStalePresence, Dt as messages, E as cleanupStaleClients, Et as members, F as deriveAuthState, Ft as registerChatMessageDispatcher, G as getAgentAvatarImage, Gt as sendToAgent$1, H as findOrCreateDirectChat, Ht as resolveDefaultOrgId$1, I as disconnectClient, It as registerClient, J as getChatDetail, Jt as setChatEngagement, K as getCachedAudience, Kt as serverInstances, L as editMessage, Lt as removeParticipant, M as createMeChat, Mt as reactivateAgent, N as createNotifier, Nt as rebindAgent, O as clearAgentAvatarImage, Ot as notifyRecipients, P as deleteAgent, Pt as recomputeWatchersForMember, Q as getPresence, Qt as suspendAgent, R as ensureDefaultOrganization, Rt as renewEntry, S as checkAgentNameAvailability, T as claimClient, Tt as markStaleAgents, U as getActivityOverview, Ut as retireClient, V as filterSessionsByParticipant, Vt as resolveChatTitle, W as getAgent, Wt as sendMessage, X as getOnlineCount, Xt as setRuntimeState, Y as getClient, Yt as setOffline, Z as getOrganization, Zt as submitAnswer, _ as assertParticipant, _t as listClients, a as adapterAgentMappings, an as upsertSessionState, at as leaveChat, b as chatUserState, bt as listMeChats, c as addMeChatParticipants, ct as listAgentSessions, d as agentChatSessions, dt as listAgentsManagedByUser, en as touchAgent, et as heartbeatClient, f as agentConfigs, ft as listAgentsWithRuntime, g as assertClientOwner, gt as listChatsForMember, h as archiveSession, ht as listChats, i as ackEntryByIdForBoundAgents, in as updateOrganization, it as joinMeChat, j as createChat, jt as pruneStaleSilentEntries, k as clients, kt as pendingQuestions, l as addParticipant, lt as listAgentsForAdmin, m as agents, mt as listChatParticipantsWithNames, n as SUPPORTED_AVATAR_IMAGE_MIMES, nn as updateAgent, nt as inboxEntries, o as adapterConfigs, ot as leaveMeChat, p as agentPresence, pt as listAllSessions, q as getCallerEngagement, qt as setAgentAvatarImage, r as ackEntry$2, rn as updateClientCapabilities, rt as joinChat, s as addChatParticipants, st as listActiveAgentsPinnedToClient, t as MAX_AVATAR_IMAGE_BYTES, tn as unbindAgent, tt as heartbeatInstance, u as agentAvatarImageUrl, ut as listAgentsForMember, v as bindAgent, vt as listClientsForOrgAdmin, w as claimBacklogForPush, wt as markMeChatUnread, x as chats, xt as listMessages, y as chatMembership, yt as listMeChatSourceCounts, z as ensureParticipant, zt as resetActivity } from "./client-CZ_VnbEc-CBF46cJd.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
|
|
@@ -1142,6 +1146,23 @@ const messageFormatSchema = z.enum([
|
|
|
1142
1146
|
"question",
|
|
1143
1147
|
"question_answer"
|
|
1144
1148
|
]);
|
|
1149
|
+
/**
|
|
1150
|
+
* Optional intent tag set by the client when posting through
|
|
1151
|
+
* `POST /agent/chats/:id/messages`. Tells the server *why* this write is
|
|
1152
|
+
* happening so it can pick the right enforcement profile.
|
|
1153
|
+
*
|
|
1154
|
+
* - `"agent-final-text"`: handler-initiated forward of an agent's final
|
|
1155
|
+
* reply text (today: `runtime/result-sink.ts`) OR an `AskUserQuestion`
|
|
1156
|
+
* payload posted via the canUseTool bridge. Both should land in chat
|
|
1157
|
+
* history so human observers in the web UI can see what the agent is
|
|
1158
|
+
* doing, but neither should wake other agents and neither should be
|
|
1159
|
+
* subject to the group-chat `@mention required` guard — they are not
|
|
1160
|
+
* a user-typed group broadcast. v1 §四 改造 4 (b) bypass channel.
|
|
1161
|
+
*
|
|
1162
|
+
* Default-`undefined` means a regular agent-initiated send (CLI `chat send`,
|
|
1163
|
+
* adapter, etc.) and goes through the normal enforcement profile.
|
|
1164
|
+
*/
|
|
1165
|
+
const messagePurposeSchema = z.enum(["agent-final-text"]);
|
|
1145
1166
|
z.object({
|
|
1146
1167
|
format: messageFormatSchema.default("text"),
|
|
1147
1168
|
content: z.unknown(),
|
|
@@ -1149,7 +1170,8 @@ z.object({
|
|
|
1149
1170
|
inReplyTo: z.string().optional(),
|
|
1150
1171
|
replyToInbox: z.string().optional(),
|
|
1151
1172
|
replyToChat: z.string().optional(),
|
|
1152
|
-
source: messageSourceSchema.optional()
|
|
1173
|
+
source: messageSourceSchema.optional(),
|
|
1174
|
+
purpose: messagePurposeSchema.optional()
|
|
1153
1175
|
});
|
|
1154
1176
|
z.object({
|
|
1155
1177
|
format: messageFormatSchema.default("text"),
|
|
@@ -1157,7 +1179,8 @@ z.object({
|
|
|
1157
1179
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
1158
1180
|
replyToInbox: z.string().optional(),
|
|
1159
1181
|
replyToChat: z.string().optional(),
|
|
1160
|
-
source: messageSourceSchema.optional()
|
|
1182
|
+
source: messageSourceSchema.optional(),
|
|
1183
|
+
direct: z.boolean().optional()
|
|
1161
1184
|
});
|
|
1162
1185
|
const messageSchema = z.object({
|
|
1163
1186
|
id: z.string(),
|
|
@@ -1370,6 +1393,10 @@ z.object({
|
|
|
1370
1393
|
lastReadAt: z.string(),
|
|
1371
1394
|
unreadMentionCount: z.number().int()
|
|
1372
1395
|
});
|
|
1396
|
+
z.object({
|
|
1397
|
+
chatId: z.string(),
|
|
1398
|
+
unreadMentionCount: z.number().int()
|
|
1399
|
+
});
|
|
1373
1400
|
z.object({
|
|
1374
1401
|
chatId: z.string(),
|
|
1375
1402
|
membershipKind: meChatMembershipKindSchema.nullable()
|
|
@@ -2404,6 +2431,16 @@ var FirstTreeHubSDK = class {
|
|
|
2404
2431
|
async listChats(options) {
|
|
2405
2432
|
return this.requestJson(`/api/v1/agent/chats${this.queryString(options)}`);
|
|
2406
2433
|
}
|
|
2434
|
+
/**
|
|
2435
|
+
* Fetch full chat detail (topic + participant membership rows). Used by the
|
|
2436
|
+
* runtime bootstrap path to assemble a chat-level identity block injected
|
|
2437
|
+
* into CLAUDE.md / AGENTS.md so the agent knows the chat's topic and who
|
|
2438
|
+
* else is in the room. Participant rows here lack name/displayName/type —
|
|
2439
|
+
* call `listChatParticipants` for that.
|
|
2440
|
+
*/
|
|
2441
|
+
async getChatDetail(chatId) {
|
|
2442
|
+
return this.requestJson(`/api/v1/agent/chats/${chatId}`);
|
|
2443
|
+
}
|
|
2407
2444
|
async listMessages(chatId, options) {
|
|
2408
2445
|
return this.requestJson(`/api/v1/agent/chats/${chatId}/messages${this.queryString(options)}`);
|
|
2409
2446
|
}
|
|
@@ -3332,7 +3369,7 @@ const FIRST_TREE_WORKSPACE_MARKER = ".first-tree-workspace";
|
|
|
3332
3369
|
* and on resume().
|
|
3333
3370
|
*/
|
|
3334
3371
|
function bootstrapWorkspace(options) {
|
|
3335
|
-
const { workspacePath, identity, contextTreePath, serverUrl, chatId, briefing } = options;
|
|
3372
|
+
const { workspacePath, identity, contextTreePath, serverUrl, chatId, chatContext, briefing } = options;
|
|
3336
3373
|
const agentDir = join(workspacePath, ".agent");
|
|
3337
3374
|
const contextDir = join(agentDir, "context");
|
|
3338
3375
|
if (existsSync(contextDir)) rmSync(contextDir, {
|
|
@@ -3348,7 +3385,8 @@ function bootstrapWorkspace(options) {
|
|
|
3348
3385
|
metadata: identity.metadata,
|
|
3349
3386
|
chatId,
|
|
3350
3387
|
serverUrl,
|
|
3351
|
-
contextTreePath
|
|
3388
|
+
contextTreePath,
|
|
3389
|
+
...chatContext ? { chatContext } : {}
|
|
3352
3390
|
};
|
|
3353
3391
|
writeFileSync(join(agentDir, "identity.json"), JSON.stringify(identityData, null, 2), "utf-8");
|
|
3354
3392
|
if (contextTreePath) {
|
|
@@ -3440,22 +3478,51 @@ You are running inside **Agent Hub**, a messaging platform for agent teams.
|
|
|
3440
3478
|
|
|
3441
3479
|
- Messages from other team members arrive as your prompt input. Each message has a
|
|
3442
3480
|
\`[From: <agent-name>]\` header — that name is what you pass back to \`chat send\`.
|
|
3443
|
-
- **Your final text
|
|
3481
|
+
- **Your final response text is delivered to the chat for human observers to read.
|
|
3482
|
+
It does NOT wake other agents.** To make another agent take action, use
|
|
3483
|
+
\`first-tree-hub chat send <name>\` explicitly (see "Communication Rules" below).
|
|
3444
3484
|
- **Stay silent when you have nothing to add.** Not every message needs a reply.
|
|
3445
3485
|
If you have nothing new for the recipient, output nothing and the runtime ends the turn.
|
|
3446
3486
|
- For **proactive communication** (other agents, other chats, or different format),
|
|
3447
3487
|
use the \`first-tree-hub\` CLI below.
|
|
3448
3488
|
|
|
3489
|
+
## Communication Rules
|
|
3490
|
+
|
|
3491
|
+
Your final response text is delivered to the chat for **human observers**
|
|
3492
|
+
to read. It does NOT wake other agents.
|
|
3493
|
+
|
|
3494
|
+
To make another agent take action, you MUST explicitly call:
|
|
3495
|
+
|
|
3496
|
+
first-tree-hub chat send <name> "..."
|
|
3497
|
+
|
|
3498
|
+
Decision guide (based on participant \`type\` in the Current Chat Context block):
|
|
3499
|
+
|
|
3500
|
+
- Target is a **human** in this chat → your final text is enough; do not
|
|
3501
|
+
redundantly chat send (it just adds noise).
|
|
3502
|
+
- Target is an **agent** in this chat → they will NOT see your final text
|
|
3503
|
+
as a wake signal. You MUST chat send <name> if you need them to act.
|
|
3504
|
+
- No specific target (just narrating progress / thinking aloud) → final
|
|
3505
|
+
text only; no send needed.
|
|
3506
|
+
|
|
3507
|
+
**Fallback** (if Current Chat Context block is missing — context injection
|
|
3508
|
+
may have failed): use conservative mode — all cross-agent collaboration
|
|
3509
|
+
goes through explicit \`chat send\`; do not rely on final text to wake
|
|
3510
|
+
anyone.
|
|
3511
|
+
|
|
3449
3512
|
## Sending Messages
|
|
3450
3513
|
|
|
3451
3514
|
The CLI auto-reads its config from env — no setup needed.
|
|
3452
3515
|
|
|
3453
3516
|
\`\`\`bash
|
|
3454
|
-
# Send to an agent by NAME (uuids are NOT accepted — run \`first-tree-hub agent list\` for names)
|
|
3517
|
+
# Send to an agent by NAME (uuids are NOT accepted — run \`first-tree-hub agent list\` for names).
|
|
3518
|
+
# Routing: the recipient MUST be a participant of your current chat — the message
|
|
3519
|
+
# lands in that chat. If they are NOT a member the call ERRORS with a hint. To open
|
|
3520
|
+
# a side-conversation with a non-member, use the --direct flag explicitly.
|
|
3455
3521
|
first-tree-hub chat send <agentName> "your message"
|
|
3456
3522
|
|
|
3457
|
-
#
|
|
3458
|
-
|
|
3523
|
+
# Open or reuse a direct chat with the recipient (bypass the member check).
|
|
3524
|
+
# Use only when intentionally starting a side conversation with a non-member.
|
|
3525
|
+
first-tree-hub chat send --direct <agentName> "your message"
|
|
3459
3526
|
|
|
3460
3527
|
# Markdown format (default is text)
|
|
3461
3528
|
first-tree-hub chat send <agentName> -f markdown "**bold**"
|
|
@@ -3467,6 +3534,17 @@ first-tree-hub chat send <agentName> --reply-to <messageId> "reply"
|
|
|
3467
3534
|
echo "long body" | first-tree-hub chat send <agentName>
|
|
3468
3535
|
\`\`\`
|
|
3469
3536
|
|
|
3537
|
+
**Reaching another agent — pick the right flag**:
|
|
3538
|
+
|
|
3539
|
+
- **Same chat member** → \`chat send <agentName> "..."\` (no flag).
|
|
3540
|
+
- **Non-member** → \`chat send --direct <agentName> "..."\`. The CLI opens (or
|
|
3541
|
+
reuses) the direct chat AND auto-mentions the recipient so they actually wake.
|
|
3542
|
+
|
|
3543
|
+
The CLI **only addresses agents by name**. You cannot route by chat-id from
|
|
3544
|
+
this command, and \`@<name>\` in your content only resolves against the chat
|
|
3545
|
+
you are currently in — naming someone who is not a member is rejected so a
|
|
3546
|
+
silent-drop misroute is impossible.
|
|
3547
|
+
|
|
3470
3548
|
**Content rules (important):**
|
|
3471
3549
|
|
|
3472
3550
|
- Pass content as a **raw string** — never \`JSON.stringify\` it first. Wrapping in
|
|
@@ -3476,6 +3554,74 @@ echo "long body" | first-tree-hub chat send <agentName>
|
|
|
3476
3554
|
use **stdin** with real newlines, plus \`-f markdown\`.
|
|
3477
3555
|
`;
|
|
3478
3556
|
}
|
|
3557
|
+
/**
|
|
3558
|
+
* Build a narrow `ChatContext` snapshot for the current session.
|
|
3559
|
+
*
|
|
3560
|
+
* Calls the two existing agent-scoped endpoints in parallel:
|
|
3561
|
+
* - `GET /agent/chats/:chatId` — chat detail (topic)
|
|
3562
|
+
* - `GET /agent/chats/:chatId/participants` — participant rows with names
|
|
3563
|
+
*
|
|
3564
|
+
* Throws on either HTTP failure so the caller (handler) can log + degrade
|
|
3565
|
+
* to the no-context path. The bootstrap branch then writes neither the
|
|
3566
|
+
* identity.json `chatContext` field nor the CLAUDE.md / AGENTS.md section.
|
|
3567
|
+
*/
|
|
3568
|
+
async function fetchChatContext(sdk, chatId, identity) {
|
|
3569
|
+
const [detail, participants] = await Promise.all([sdk.getChatDetail(chatId), sdk.listChatParticipants(chatId)]);
|
|
3570
|
+
const filteredParticipants = participants.filter((p) => p.name !== null && p.name.length > 0).map((p) => ({
|
|
3571
|
+
name: p.name,
|
|
3572
|
+
displayName: p.displayName,
|
|
3573
|
+
type: p.type === "human" ? "human" : "agent"
|
|
3574
|
+
}));
|
|
3575
|
+
const selfOwner = resolveSelfOwner(identity, participants);
|
|
3576
|
+
return {
|
|
3577
|
+
chatId,
|
|
3578
|
+
title: detail.title,
|
|
3579
|
+
topic: detail.topic,
|
|
3580
|
+
...selfOwner ? { selfOwner } : {},
|
|
3581
|
+
participants: filteredParticipants
|
|
3582
|
+
};
|
|
3583
|
+
}
|
|
3584
|
+
/**
|
|
3585
|
+
* For delegate agents (personal_assistant whose `delegateMention` points at
|
|
3586
|
+
* a chat participant) return `{name, displayName}` of the human owner; for
|
|
3587
|
+
* autonomous agents return `undefined`.
|
|
3588
|
+
*
|
|
3589
|
+
* `delegateMention` holds the OWNER'S `name` slug — see
|
|
3590
|
+
* web/.../identity-section.tsx ("delegate <AgentChip ...>").
|
|
3591
|
+
*/
|
|
3592
|
+
function resolveSelfOwner(identity, participants) {
|
|
3593
|
+
if (identity.type !== "personal_assistant") return void 0;
|
|
3594
|
+
if (!identity.delegateMention) return void 0;
|
|
3595
|
+
const owner = participants.find((p) => p.name === identity.delegateMention && p.type === "human");
|
|
3596
|
+
if (!owner || !owner.name) return void 0;
|
|
3597
|
+
return {
|
|
3598
|
+
name: owner.name,
|
|
3599
|
+
displayName: owner.displayName
|
|
3600
|
+
};
|
|
3601
|
+
}
|
|
3602
|
+
/**
|
|
3603
|
+
* Render the "Current Chat Context" markdown section that both Claude Code
|
|
3604
|
+
* (CLAUDE.md) and Codex (AGENTS.md) inject into the agent's prompt context.
|
|
3605
|
+
*
|
|
3606
|
+
* Shared so the two handlers never drift on field shape or wording. Returns
|
|
3607
|
+
* `null` when there's no context to render — caller skips the section.
|
|
3608
|
+
*
|
|
3609
|
+
* See proposals/hub-chat-message-v1-design §四 改造 3.
|
|
3610
|
+
*/
|
|
3611
|
+
function renderChatContextSection(chatContext) {
|
|
3612
|
+
if (!chatContext) return null;
|
|
3613
|
+
const lines = [];
|
|
3614
|
+
lines.push("## Current Chat Context");
|
|
3615
|
+
lines.push("");
|
|
3616
|
+
lines.push(`- Chat ID: ${chatContext.chatId}`);
|
|
3617
|
+
if (chatContext.title && chatContext.title.trim().length > 0) lines.push(`- Title: ${chatContext.title}`);
|
|
3618
|
+
if (chatContext.topic && chatContext.topic.trim().length > 0 && chatContext.topic !== chatContext.title) lines.push(`- Topic: ${chatContext.topic}`);
|
|
3619
|
+
if (chatContext.selfOwner) lines.push(`- Your owner: ${chatContext.selfOwner.displayName} (@${chatContext.selfOwner.name})`);
|
|
3620
|
+
lines.push("- Participants:");
|
|
3621
|
+
if (chatContext.participants.length === 0) lines.push(" - (none)");
|
|
3622
|
+
else for (const p of chatContext.participants) lines.push(` - @${p.name} (${p.displayName}, type=${p.type})`);
|
|
3623
|
+
return `${lines.join("\n")}\n`;
|
|
3624
|
+
}
|
|
3479
3625
|
function resolveGitRepoTargetPath(workspace, localPath) {
|
|
3480
3626
|
const safetyError = getRepoLocalPathSafetyError(localPath);
|
|
3481
3627
|
if (safetyError) throw new Error(`Unsafe git repo localPath "${localPath}": ${safetyError}`);
|
|
@@ -3997,6 +4143,7 @@ function createGitMirrorManager(opts) {
|
|
|
3997
4143
|
}, "worktree create conflict");
|
|
3998
4144
|
throw new GitMirrorWorktreeConflictError(`Worktree target "${absTarget}" is already occupied by ${classifyOccupant(absTarget)} — aborting (D13)`);
|
|
3999
4145
|
}
|
|
4146
|
+
await gitOk(["worktree", "prune"], mirror, 1e4);
|
|
4000
4147
|
const pathExists = existsSync(absTarget);
|
|
4001
4148
|
const hasBranch = await branchExists(mirror, branchName);
|
|
4002
4149
|
mkdirSync(dirname(absTarget), { recursive: true });
|
|
@@ -4897,7 +5044,8 @@ const createClaudeCodeHandler = (config) => {
|
|
|
4897
5044
|
try {
|
|
4898
5045
|
await sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
|
|
4899
5046
|
format: "question",
|
|
4900
|
-
content: questionContent
|
|
5047
|
+
content: questionContent,
|
|
5048
|
+
purpose: "agent-final-text"
|
|
4901
5049
|
});
|
|
4902
5050
|
} catch (err) {
|
|
4903
5051
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -5167,16 +5315,45 @@ const createClaudeCodeHandler = (config) => {
|
|
|
5167
5315
|
}
|
|
5168
5316
|
}
|
|
5169
5317
|
}
|
|
5170
|
-
/**
|
|
5171
|
-
|
|
5318
|
+
/**
|
|
5319
|
+
* Best-effort chat-context fetch for the identity-injection path. Failures
|
|
5320
|
+
* are logged but never bubble — bootstrap continues with `undefined` and
|
|
5321
|
+
* the agent simply loses the "Current Chat Context" block (graceful
|
|
5322
|
+
* degradation; the Communication Rules in tools.md still tell it to fall
|
|
5323
|
+
* back to conservative mode).
|
|
5324
|
+
*/
|
|
5325
|
+
async function fetchChatContextOrLog(sessionCtx) {
|
|
5326
|
+
try {
|
|
5327
|
+
return await fetchChatContext(sessionCtx.sdk, sessionCtx.chatId, sessionCtx.agent);
|
|
5328
|
+
} catch (err) {
|
|
5329
|
+
sessionCtx.log(`fetchChatContext failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
5330
|
+
return;
|
|
5331
|
+
}
|
|
5332
|
+
}
|
|
5333
|
+
/**
|
|
5334
|
+
* Refresh the workspace's identity.json + CLAUDE.md from the latest chat
|
|
5335
|
+
* context. v1.7 fix: chat-context must NOT be frozen at first bootstrap.
|
|
5336
|
+
* When new participants join later, every resume re-fetches and rewrites
|
|
5337
|
+
* the "Current Chat Context" section so the agent sees the live roster.
|
|
5338
|
+
* Skips the expensive `first-tree tree integrate` shell-out — that part
|
|
5339
|
+
* stays sentinel-protected and runs only on a fresh bootstrap.
|
|
5340
|
+
*/
|
|
5341
|
+
function refreshIdentityAndPrompt(workspace, sessionCtx, chatContext) {
|
|
5172
5342
|
bootstrapWorkspace({
|
|
5173
5343
|
workspacePath: workspace,
|
|
5174
5344
|
identity: sessionCtx.agent,
|
|
5175
5345
|
contextTreePath,
|
|
5176
5346
|
serverUrl: sessionCtx.sdk.serverUrl,
|
|
5177
|
-
chatId: sessionCtx.chatId
|
|
5347
|
+
chatId: sessionCtx.chatId,
|
|
5348
|
+
chatContext
|
|
5178
5349
|
});
|
|
5179
|
-
generateClaudeMd(workspace, sessionCtx.agent, contextTreePath);
|
|
5350
|
+
generateClaudeMd(workspace, sessionCtx.agent, contextTreePath, chatContext);
|
|
5351
|
+
}
|
|
5352
|
+
/** Full bootstrap: refresh identity/prompt + run the expensive
|
|
5353
|
+
* `first-tree tree integrate` shell-out. Used on `start()` and on
|
|
5354
|
+
* `resume()` when the stage-2 sentinel is missing. */
|
|
5355
|
+
function runBootstrap(workspace, sessionCtx, chatContext) {
|
|
5356
|
+
refreshIdentityAndPrompt(workspace, sessionCtx, chatContext);
|
|
5180
5357
|
if (contextTreePath) installFirstTreeIntegration({
|
|
5181
5358
|
workspacePath: workspace,
|
|
5182
5359
|
contextTreePath,
|
|
@@ -5190,7 +5367,8 @@ const createClaudeCodeHandler = (config) => {
|
|
|
5190
5367
|
ctx = sessionCtx;
|
|
5191
5368
|
claudeSessionId = randomUUID();
|
|
5192
5369
|
cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
|
|
5193
|
-
|
|
5370
|
+
const chatContext = await fetchChatContextOrLog(sessionCtx);
|
|
5371
|
+
runBootstrap(cwd, sessionCtx, chatContext);
|
|
5194
5372
|
const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
|
|
5195
5373
|
await prepareGitWorktrees(cwd, payload, sessionCtx);
|
|
5196
5374
|
markWorkspaceInitComplete(cwd);
|
|
@@ -5206,7 +5384,9 @@ const createClaudeCodeHandler = (config) => {
|
|
|
5206
5384
|
claudeSessionId = sessionId;
|
|
5207
5385
|
retryCount = 0;
|
|
5208
5386
|
cwd = acquireWorkspace(workspaceRoot, sessionCtx.chatId);
|
|
5209
|
-
|
|
5387
|
+
const chatContext = await fetchChatContextOrLog(sessionCtx);
|
|
5388
|
+
if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) runBootstrap(cwd, sessionCtx, chatContext);
|
|
5389
|
+
else refreshIdentityAndPrompt(cwd, sessionCtx, chatContext);
|
|
5210
5390
|
const payload = agentConfigCache?.get(sessionCtx.agent.agentId)?.payload;
|
|
5211
5391
|
await prepareGitWorktrees(cwd, payload, sessionCtx);
|
|
5212
5392
|
markWorkspaceInitComplete(cwd);
|
|
@@ -5299,12 +5479,14 @@ function isHubWorktreeMarker(path) {
|
|
|
5299
5479
|
* `agent_configs.payload.prompt.append` and are passed to the Claude SDK via
|
|
5300
5480
|
* `systemPrompt.append` — not through this file.
|
|
5301
5481
|
*/
|
|
5302
|
-
function generateClaudeMd(workspacePath, identity, contextTreePath) {
|
|
5482
|
+
function generateClaudeMd(workspacePath, identity, contextTreePath, chatContext) {
|
|
5303
5483
|
const sections = [];
|
|
5304
5484
|
const contextDir = join(workspacePath, ".agent", "context");
|
|
5305
5485
|
const name = identity.displayName ?? identity.agentId;
|
|
5306
5486
|
if (identity.type === "personal_assistant") sections.push(`# Agent Identity\n\nYou are ${name}, a personal assistant agent.\n`);
|
|
5307
5487
|
else sections.push(`# Agent Identity\n\nYou are ${name}, an autonomous agent.\n`);
|
|
5488
|
+
const chatContextSection = renderChatContextSection(chatContext);
|
|
5489
|
+
if (chatContextSection) sections.push(chatContextSection);
|
|
5308
5490
|
const agentInstructionsPath = join(contextDir, "agent-instructions.md");
|
|
5309
5491
|
if (existsSync(agentInstructionsPath)) {
|
|
5310
5492
|
const instructions = readFileSync(agentInstructionsPath, "utf-8");
|
|
@@ -5410,7 +5592,7 @@ const createCodexHandler = (config) => {
|
|
|
5410
5592
|
cfg.mcp_servers = mcpServers;
|
|
5411
5593
|
return cfg;
|
|
5412
5594
|
}
|
|
5413
|
-
function buildAgentBriefing(payload) {
|
|
5595
|
+
function buildAgentBriefing(payload, chatContext) {
|
|
5414
5596
|
const lines = [];
|
|
5415
5597
|
lines.push("# Agent Briefing");
|
|
5416
5598
|
lines.push("");
|
|
@@ -5418,11 +5600,28 @@ const createCodexHandler = (config) => {
|
|
|
5418
5600
|
lines.push(payload.prompt.append.trim());
|
|
5419
5601
|
lines.push("");
|
|
5420
5602
|
}
|
|
5603
|
+
const chatContextSection = renderChatContextSection(chatContext);
|
|
5604
|
+
if (chatContextSection) {
|
|
5605
|
+
lines.push(chatContextSection.trimEnd());
|
|
5606
|
+
lines.push("");
|
|
5607
|
+
}
|
|
5421
5608
|
lines.push("Refer to `.agent/identity.json` for your agent identity, `.agent/tools.md` for the");
|
|
5422
5609
|
lines.push("first-tree-hub SDK reference, and `.agent/context/` for organisational context");
|
|
5423
5610
|
lines.push("(when configured).");
|
|
5424
5611
|
return lines.join("\n").concat("\n");
|
|
5425
5612
|
}
|
|
5613
|
+
/**
|
|
5614
|
+
* Best-effort chat-context fetch for the identity-injection path. Failures
|
|
5615
|
+
* are logged but never bubble — bootstrap continues with `undefined`.
|
|
5616
|
+
*/
|
|
5617
|
+
async function fetchChatContextOrLog(sessionCtx) {
|
|
5618
|
+
try {
|
|
5619
|
+
return await fetchChatContext(sessionCtx.sdk, sessionCtx.chatId, sessionCtx.agent);
|
|
5620
|
+
} catch (err) {
|
|
5621
|
+
sessionCtx.log(`fetchChatContext failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
5622
|
+
return;
|
|
5623
|
+
}
|
|
5624
|
+
}
|
|
5426
5625
|
function toCodexInput(message, sessionCtx) {
|
|
5427
5626
|
return sessionCtx.formatInboundContent(message).then((text) => text);
|
|
5428
5627
|
}
|
|
@@ -5682,15 +5881,17 @@ const createCodexHandler = (config) => {
|
|
|
5682
5881
|
env: [],
|
|
5683
5882
|
gitRepos: []
|
|
5684
5883
|
};
|
|
5884
|
+
const chatContext = await fetchChatContextOrLog(sessionCtx);
|
|
5685
5885
|
bootstrapWorkspace({
|
|
5686
5886
|
workspacePath: cwd,
|
|
5687
5887
|
identity: sessionCtx.agent,
|
|
5688
5888
|
contextTreePath,
|
|
5689
5889
|
serverUrl: sessionCtx.sdk.serverUrl,
|
|
5690
5890
|
chatId: sessionCtx.chatId,
|
|
5891
|
+
chatContext,
|
|
5691
5892
|
briefing: {
|
|
5692
5893
|
format: "agents-md",
|
|
5693
|
-
content: buildAgentBriefing(payload)
|
|
5894
|
+
content: buildAgentBriefing(payload, chatContext)
|
|
5694
5895
|
}
|
|
5695
5896
|
});
|
|
5696
5897
|
ensureFirstTreeBinding(cwd, sessionCtx);
|
|
@@ -5719,20 +5920,20 @@ const createCodexHandler = (config) => {
|
|
|
5719
5920
|
env: [],
|
|
5720
5921
|
gitRepos: []
|
|
5721
5922
|
};
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
}
|
|
5734
|
-
|
|
5735
|
-
|
|
5923
|
+
const chatContext = await fetchChatContextOrLog(sessionCtx);
|
|
5924
|
+
bootstrapWorkspace({
|
|
5925
|
+
workspacePath: cwd,
|
|
5926
|
+
identity: sessionCtx.agent,
|
|
5927
|
+
contextTreePath,
|
|
5928
|
+
serverUrl: sessionCtx.sdk.serverUrl,
|
|
5929
|
+
chatId: sessionCtx.chatId,
|
|
5930
|
+
chatContext,
|
|
5931
|
+
briefing: {
|
|
5932
|
+
format: "agents-md",
|
|
5933
|
+
content: buildAgentBriefing(payload, chatContext)
|
|
5934
|
+
}
|
|
5935
|
+
});
|
|
5936
|
+
if (!existsSync(join(cwd, INIT_COMPLETE_SENTINEL_REL))) ensureFirstTreeBinding(cwd, sessionCtx);
|
|
5736
5937
|
await prepareGitWorktrees(payload, cwd, sessionCtx);
|
|
5737
5938
|
markWorkspaceInitComplete(cwd);
|
|
5738
5939
|
codex = new Codex({
|
|
@@ -6048,17 +6249,10 @@ var Deduplicator = class {
|
|
|
6048
6249
|
}
|
|
6049
6250
|
};
|
|
6050
6251
|
function createResultSink(deps) {
|
|
6051
|
-
async function buildMetadata(
|
|
6252
|
+
async function buildMetadata() {
|
|
6052
6253
|
const metadata = {};
|
|
6053
6254
|
const documentBasePath = await deps.getDocumentBasePath?.();
|
|
6054
6255
|
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
6256
|
return Object.keys(metadata).length > 0 ? metadata : void 0;
|
|
6063
6257
|
}
|
|
6064
6258
|
return async function forwardResult(text) {
|
|
@@ -6069,10 +6263,11 @@ function createResultSink(deps) {
|
|
|
6069
6263
|
}
|
|
6070
6264
|
const trigger = deps.getTrigger();
|
|
6071
6265
|
deps.clearTrigger();
|
|
6072
|
-
const metadata = await buildMetadata(
|
|
6266
|
+
const metadata = await buildMetadata();
|
|
6073
6267
|
await deps.sdk.sendMessage(deps.chatId, {
|
|
6074
6268
|
format: "text",
|
|
6075
6269
|
content: text,
|
|
6270
|
+
purpose: "agent-final-text",
|
|
6076
6271
|
...trigger ? { inReplyTo: trigger.messageId } : {},
|
|
6077
6272
|
...metadata ? { metadata } : {}
|
|
6078
6273
|
});
|
|
@@ -6649,7 +6844,6 @@ var SessionManager = class {
|
|
|
6649
6844
|
this.currentTrigger.delete(chatId);
|
|
6650
6845
|
},
|
|
6651
6846
|
log,
|
|
6652
|
-
participants,
|
|
6653
6847
|
getDocumentBasePath: () => this.resolveDocumentBasePath(log)
|
|
6654
6848
|
});
|
|
6655
6849
|
const envCtx = {
|
|
@@ -9587,7 +9781,7 @@ function formatCheckReport(items) {
|
|
|
9587
9781
|
}
|
|
9588
9782
|
return lines.join("\n");
|
|
9589
9783
|
}
|
|
9590
|
-
async function resolveDefaultOrgId
|
|
9784
|
+
async function resolveDefaultOrgId(serverUrl, accessToken) {
|
|
9591
9785
|
const res = await cliFetch(`${serverUrl}/api/v1/me`, {
|
|
9592
9786
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
9593
9787
|
signal: AbortSignal.timeout(1e4)
|
|
@@ -9622,7 +9816,7 @@ async function onboardCreate(args) {
|
|
|
9622
9816
|
if (args.role) metadata.role = args.role;
|
|
9623
9817
|
if (args.domains) metadata.domains = args.domains.split(",").map((d) => d.trim());
|
|
9624
9818
|
print.line(`Creating agent "${args.id}"...\n`);
|
|
9625
|
-
const orgId = await resolveDefaultOrgId
|
|
9819
|
+
const orgId = await resolveDefaultOrgId(serverUrl, accessToken);
|
|
9626
9820
|
const primary = await createAgentViaAdmin(serverUrl, accessToken, orgId, {
|
|
9627
9821
|
name: args.id,
|
|
9628
9822
|
type: args.type,
|
|
@@ -9659,7 +9853,7 @@ async function onboardCreate(args) {
|
|
|
9659
9853
|
}
|
|
9660
9854
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
9661
9855
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
9662
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
9856
|
+
const { bindFeishuBot } = await import("./feishu-CCWd-JE4.mjs").then((n) => n.r);
|
|
9663
9857
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
9664
9858
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
9665
9859
|
else {
|
|
@@ -10872,7 +11066,7 @@ function createFeedbackHandler(config) {
|
|
|
10872
11066
|
return { handle };
|
|
10873
11067
|
}
|
|
10874
11068
|
//#endregion
|
|
10875
|
-
//#region ../server/dist/app-
|
|
11069
|
+
//#region ../server/dist/app-Cv337jed.mjs
|
|
10876
11070
|
var import_fastify_opentelemetry = /* @__PURE__ */ __toESM(require_fastify_opentelemetry(), 1);
|
|
10877
11071
|
init_esm();
|
|
10878
11072
|
var __defProp = Object.defineProperty;
|
|
@@ -10885,17 +11079,6 @@ var __exportAll = (all, no_symbols) => {
|
|
|
10885
11079
|
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
10886
11080
|
return target;
|
|
10887
11081
|
};
|
|
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
11082
|
/**
|
|
10900
11083
|
* Pull the JWT-verified `UserScope` off the request. The `userAuthHook`
|
|
10901
11084
|
* middleware populates `request.user` synchronously before any handler
|
|
@@ -10956,10 +11139,23 @@ async function requireAgentAccess(request, db, kind) {
|
|
|
10956
11139
|
};
|
|
10957
11140
|
}
|
|
10958
11141
|
/**
|
|
10959
|
-
* Gate access to a chat. Allowed if the caller's HUMAN agent
|
|
10960
|
-
*
|
|
10961
|
-
*
|
|
10962
|
-
* remains private to
|
|
11142
|
+
* Gate access to a chat. Allowed if the caller's HUMAN agent has any
|
|
11143
|
+
* `chat_membership` row (speaker OR watcher), OR any agent the caller
|
|
11144
|
+
* manages (via members.id) is a speaker. Admin role does NOT auto-grant
|
|
11145
|
+
* chat access — chat content remains private to members and supervisors
|
|
11146
|
+
* (their managers).
|
|
11147
|
+
*
|
|
11148
|
+
* Watchers are allowed on the direct-membership branch because they
|
|
11149
|
+
* surface in `listMeChats` with their own unread badge and engagement
|
|
11150
|
+
* state; chat-scoped per-user operations like read-cursor and
|
|
11151
|
+
* watcher→speaker upgrade must be reachable from that surface. Write
|
|
11152
|
+
* endpoints that need to refuse watchers rely on `ensureParticipant`
|
|
11153
|
+
* or service-layer checks, not on this guard.
|
|
11154
|
+
*
|
|
11155
|
+
* The supervisor branch is a fallback for callers whose human agent
|
|
11156
|
+
* has no direct row but who manage a speaker — e.g. before
|
|
11157
|
+
* `recomputeChatWatchers` has materialised the watcher row, or when a
|
|
11158
|
+
* member's human agent and managed agent diverge in cross-org chats.
|
|
10963
11159
|
*
|
|
10964
11160
|
* The Params type is generic so routes that mount on a path with extra
|
|
10965
11161
|
* params (e.g. `/agents/:uuid/sessions/:chatId/...` for compound checks)
|
|
@@ -10979,7 +11175,7 @@ async function requireChatAccess(request, db) {
|
|
|
10979
11175
|
role: caller.role,
|
|
10980
11176
|
humanAgentId: caller.humanAgentId
|
|
10981
11177
|
};
|
|
10982
|
-
const [direct] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, caller.humanAgentId)
|
|
11178
|
+
const [direct] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, caller.humanAgentId))).limit(1);
|
|
10983
11179
|
if (direct) {
|
|
10984
11180
|
stampOrgScope(request, scope);
|
|
10985
11181
|
stampChatResource(request, chat);
|
|
@@ -11068,16 +11264,6 @@ async function adapterMappingRoutes(app) {
|
|
|
11068
11264
|
return reply.status(204).send();
|
|
11069
11265
|
});
|
|
11070
11266
|
}
|
|
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
11267
|
const ALGORITHM = "aes-256-gcm";
|
|
11082
11268
|
const IV_LENGTH = 12;
|
|
11083
11269
|
const AUTH_TAG_LENGTH = 16;
|
|
@@ -11346,7 +11532,7 @@ async function agentChatRoutes(app) {
|
|
|
11346
11532
|
app.get("/:chatId", async (request) => {
|
|
11347
11533
|
const identity = requireAgent(request);
|
|
11348
11534
|
await assertParticipant(app.db, request.params.chatId, identity.uuid);
|
|
11349
|
-
const detail = await getChatDetail(app.db, request.params.chatId);
|
|
11535
|
+
const detail = await getChatDetail(app.db, request.params.chatId, identity.uuid);
|
|
11350
11536
|
return {
|
|
11351
11537
|
...serializeChat(detail),
|
|
11352
11538
|
participants: detail.participants.map((p) => ({
|
|
@@ -11411,592 +11597,6 @@ async function agentConfigRoutes$1(app) {
|
|
|
11411
11597
|
return await app.configService.getDecrypted(identity.uuid);
|
|
11412
11598
|
});
|
|
11413
11599
|
}
|
|
11414
|
-
/**
|
|
11415
|
-
* Per-agent runtime configuration (Hub-managed; not the local YAML config).
|
|
11416
|
-
*
|
|
11417
|
-
* One row per agent. `version` increments on every successful UPDATE
|
|
11418
|
-
* (optimistic locking via WHERE version = :expected). Sensitive env values
|
|
11419
|
-
* inside `payload.env[*]` are AES-256-GCM encrypted at write time and
|
|
11420
|
-
* masked when echoed via the Admin API (see Step 2).
|
|
11421
|
-
*
|
|
11422
|
-
* Integrity is enforced by the service layer per project convention:
|
|
11423
|
-
* no FK / CHECK / triggers on this table.
|
|
11424
|
-
*/
|
|
11425
|
-
const agentConfigs = pgTable("agent_configs", {
|
|
11426
|
-
agentId: text("agent_id").primaryKey(),
|
|
11427
|
-
version: integer("version").notNull().default(1),
|
|
11428
|
-
payload: jsonb("payload").$type().notNull(),
|
|
11429
|
-
updatedBy: text("updated_by").notNull(),
|
|
11430
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
11431
|
-
});
|
|
11432
|
-
/**
|
|
11433
|
-
* Resolve the UUID of the "default" organization. Internal use only —
|
|
11434
|
-
* webhooks, fallbacks, etc. The HTTP API layer no longer falls back to
|
|
11435
|
-
* the JWT default org.
|
|
11436
|
-
*/
|
|
11437
|
-
async function resolveDefaultOrgId(db) {
|
|
11438
|
-
const [org] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, "default")).limit(1);
|
|
11439
|
-
if (!org) throw new Error("Default organization not found. Ensure the server has started and ensureDefaultOrganization() ran.");
|
|
11440
|
-
return org.id;
|
|
11441
|
-
}
|
|
11442
|
-
async function getOrganization(db, id) {
|
|
11443
|
-
const [org] = await db.select().from(organizations).where(eq(organizations.id, id)).limit(1);
|
|
11444
|
-
if (!org) throw new NotFoundError(`Organization "${id}" not found`);
|
|
11445
|
-
return org;
|
|
11446
|
-
}
|
|
11447
|
-
async function updateOrganization(db, id, data) {
|
|
11448
|
-
const updates = { updatedAt: /* @__PURE__ */ new Date() };
|
|
11449
|
-
if (data.name !== void 0) updates.name = data.name;
|
|
11450
|
-
if (data.displayName !== void 0) updates.displayName = data.displayName;
|
|
11451
|
-
if (data.maxAgents !== void 0) updates.maxAgents = data.maxAgents;
|
|
11452
|
-
if (data.maxMessagesPerMinute !== void 0) updates.maxMessagesPerMinute = data.maxMessagesPerMinute;
|
|
11453
|
-
if (data.features !== void 0) updates.features = data.features;
|
|
11454
|
-
try {
|
|
11455
|
-
const [org] = await db.update(organizations).set(updates).where(eq(organizations.id, id)).returning();
|
|
11456
|
-
if (!org) throw new NotFoundError(`Organization "${id}" not found`);
|
|
11457
|
-
return org;
|
|
11458
|
-
} catch (err) {
|
|
11459
|
-
if ((err?.code ?? err?.cause?.code ?? "") === "23505") throw new ConflictError(`Organization name "${data.name}" is already taken`);
|
|
11460
|
-
throw err;
|
|
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;
|
|
11477
|
-
}
|
|
11478
|
-
/**
|
|
11479
|
-
* Names beginning with `__` are reserved for Hub-internal pseudo agents.
|
|
11480
|
-
* User-facing creation must not be able to squat on them, otherwise
|
|
11481
|
-
* internal traffic could be routed through a real account.
|
|
11482
|
-
*/
|
|
11483
|
-
const RESERVED_AGENT_NAME_PREFIX = "__";
|
|
11484
|
-
/**
|
|
11485
|
-
* Derive the relative URL clients should use to fetch a manager-uploaded
|
|
11486
|
-
* avatar image. Returns `null` when no image is set. Embeds the upload
|
|
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.
|
|
11494
|
-
*/
|
|
11495
|
-
function agentAvatarImageUrl(uuid, updatedAt) {
|
|
11496
|
-
if (!updatedAt) return null;
|
|
11497
|
-
return `/api/v1/agents/${uuid}/avatar?v=${updatedAt.getTime()}`;
|
|
11498
|
-
}
|
|
11499
|
-
/**
|
|
11500
|
-
* True iff `clients.metadata.capabilities` is a non-empty object — i.e. the
|
|
11501
|
-
* client has reported at least one runtime probe result. Used to distinguish
|
|
11502
|
-
* "we don't know what's installed yet" (empty / never reported) from
|
|
11503
|
-
* "client explicitly reports this provider is missing".
|
|
11504
|
-
*/
|
|
11505
|
-
function clientCapabilitiesReported(metadata) {
|
|
11506
|
-
if (!metadata || typeof metadata !== "object") return false;
|
|
11507
|
-
const caps = metadata.capabilities;
|
|
11508
|
-
if (!caps || typeof caps !== "object") return false;
|
|
11509
|
-
return Object.keys(caps).length > 0;
|
|
11510
|
-
}
|
|
11511
|
-
/**
|
|
11512
|
-
* Inspect a `clients.metadata.capabilities` blob (jsonb) for a specific
|
|
11513
|
-
* runtime provider entry. Capabilities live under the `metadata.capabilities`
|
|
11514
|
-
* subkey (Option C); the column is unstructured at the DB layer, so we
|
|
11515
|
-
* defensively narrow before key access.
|
|
11516
|
-
*
|
|
11517
|
-
* "Supports" requires the entry's SDK to be **available** — `state: "ok"` or
|
|
11518
|
-
* `state: "unauthenticated"`. A `missing` or `error` entry is *reported* but
|
|
11519
|
-
* not usable, so we explicitly reject those rather than treating mere key
|
|
11520
|
-
* presence as support. Auth state is left to the user to fix at runtime
|
|
11521
|
-
* (the re-bind dialog surfaces an `unauthenticated` hint).
|
|
11522
|
-
*/
|
|
11523
|
-
function clientSupportsRuntimeProvider(metadata, provider) {
|
|
11524
|
-
if (!metadata || typeof metadata !== "object") return false;
|
|
11525
|
-
const caps = metadata.capabilities;
|
|
11526
|
-
if (!caps || typeof caps !== "object") return false;
|
|
11527
|
-
const entry = caps[provider];
|
|
11528
|
-
if (!entry || typeof entry !== "object") return false;
|
|
11529
|
-
return entry.available === true;
|
|
11530
|
-
}
|
|
11531
|
-
/** Default visibility per agent type. */
|
|
11532
|
-
function defaultVisibility(type) {
|
|
11533
|
-
switch (type) {
|
|
11534
|
-
case "human":
|
|
11535
|
-
case "autonomous_agent": return AGENT_VISIBILITY.ORGANIZATION;
|
|
11536
|
-
case "personal_assistant": return AGENT_VISIBILITY.PRIVATE;
|
|
11537
|
-
default: return AGENT_VISIBILITY.PRIVATE;
|
|
11538
|
-
}
|
|
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
11600
|
const log$5 = createLogger$1("AgentFeishuBot");
|
|
12001
11601
|
async function agentFeishuBotRoutes(app) {
|
|
12002
11602
|
/**
|
|
@@ -12147,7 +11747,7 @@ async function findOrCreateChatForChannel(db, data) {
|
|
|
12147
11747
|
const internalType = data.chatType === "p2p" ? "direct" : "group";
|
|
12148
11748
|
return db.transaction(async (tx) => {
|
|
12149
11749
|
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);
|
|
11750
|
+
const orgId = botAgent?.organizationId ?? await resolveDefaultOrgId$1(db);
|
|
12151
11751
|
const metadata = chatMetadataSchema$1.parse({
|
|
12152
11752
|
source: data.platform,
|
|
12153
11753
|
externalChannelId: data.externalChannelId
|
|
@@ -12238,338 +11838,6 @@ async function agentFeishuUserRoutes(app) {
|
|
|
12238
11838
|
return reply.status(204).send();
|
|
12239
11839
|
});
|
|
12240
11840
|
}
|
|
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
11841
|
async function agentInboxRoutes(app) {
|
|
12574
11842
|
app.get("/", async (request) => {
|
|
12575
11843
|
const identity = requireAgent(request);
|
|
@@ -15765,743 +15033,6 @@ async function bootstrapConfigRoutes(_app) {
|
|
|
15765
15033
|
});
|
|
15766
15034
|
}
|
|
15767
15035
|
/**
|
|
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
15036
|
* Class C — resource-scoped chat routes. Mounted at
|
|
16506
15037
|
* `/api/v1/chats/:chatId/...`. The chat's `organizationId` locates its
|
|
16507
15038
|
* org; `requireChatAccess` resolves the caller's membership in that org
|
|
@@ -16510,7 +15041,17 @@ async function listMeChatSourceCounts(db, humanAgentId, organizationId, query) {
|
|
|
16510
15041
|
async function chatRoutes(app) {
|
|
16511
15042
|
app.get("/:chatId", async (request) => {
|
|
16512
15043
|
const { chat, scope } = await requireChatAccess(request, app.db);
|
|
16513
|
-
const participants = await app.db.select(
|
|
15044
|
+
const participants = await app.db.select({
|
|
15045
|
+
agentId: chatMembership.agentId,
|
|
15046
|
+
role: chatMembership.role,
|
|
15047
|
+
mode: chatMembership.mode,
|
|
15048
|
+
joinedAt: chatMembership.joinedAt,
|
|
15049
|
+
name: agents.name,
|
|
15050
|
+
displayName: agents.displayName,
|
|
15051
|
+
type: agents.type,
|
|
15052
|
+
avatarColorToken: agents.avatarColorToken,
|
|
15053
|
+
avatarImageUpdatedAt: agents.avatarImageUpdatedAt
|
|
15054
|
+
}).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chat.id), eq(chatMembership.accessMode, "speaker")));
|
|
16514
15055
|
const firstMsgRows = await app.db.execute(sql`
|
|
16515
15056
|
SELECT content FROM messages
|
|
16516
15057
|
WHERE chat_id = ${chat.id}
|
|
@@ -16518,25 +15059,13 @@ async function chatRoutes(app) {
|
|
|
16518
15059
|
LIMIT 1
|
|
16519
15060
|
`);
|
|
16520
15061
|
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
|
-
});
|
|
15062
|
+
const participantsForTitle = participants.map((p) => ({
|
|
15063
|
+
agentId: p.agentId,
|
|
15064
|
+
displayName: p.displayName,
|
|
15065
|
+
type: p.type,
|
|
15066
|
+
avatarColorToken: p.avatarColorToken ?? null,
|
|
15067
|
+
avatarImageUrl: agentAvatarImageUrl(p.agentId, p.avatarImageUpdatedAt ?? null)
|
|
15068
|
+
}));
|
|
16540
15069
|
const title = resolveChatTitle(chat.topic, firstMessagePreview, participantsForTitle, scope.humanAgentId);
|
|
16541
15070
|
const engagementStatus = await getCallerEngagement(app.db, chat.id, scope.humanAgentId);
|
|
16542
15071
|
return {
|
|
@@ -16550,6 +15079,9 @@ async function chatRoutes(app) {
|
|
|
16550
15079
|
agentId: p.agentId,
|
|
16551
15080
|
role: p.role,
|
|
16552
15081
|
mode: p.mode,
|
|
15082
|
+
name: p.name,
|
|
15083
|
+
displayName: p.displayName,
|
|
15084
|
+
type: p.type,
|
|
16553
15085
|
joinedAt: p.joinedAt.toISOString()
|
|
16554
15086
|
}))
|
|
16555
15087
|
};
|
|
@@ -16699,6 +15231,11 @@ async function chatRoutes(app) {
|
|
|
16699
15231
|
const { scope } = await requireChatAccess(request, app.db);
|
|
16700
15232
|
return markMeChatRead(app.db, request.params.chatId, scope.humanAgentId);
|
|
16701
15233
|
});
|
|
15234
|
+
/** POST /chats/:chatId/unread — manual "mark as unread" affordance. Idempotent. */
|
|
15235
|
+
app.post("/:chatId/unread", async (request) => {
|
|
15236
|
+
const { scope } = await requireChatAccess(request, app.db);
|
|
15237
|
+
return markMeChatUnread(app.db, request.params.chatId, scope.humanAgentId);
|
|
15238
|
+
});
|
|
16702
15239
|
/** POST /chats/:chatId/participants — add speaking participants. Idempotent. */
|
|
16703
15240
|
app.post("/:chatId/participants", async (request, reply) => {
|
|
16704
15241
|
const { scope } = await requireChatAccess(request, app.db);
|
|
@@ -18065,7 +16602,7 @@ async function healthzRoutes(app) {
|
|
|
18065
16602
|
* `api/orgs/invitations.ts` (Class B, admin-gated).
|
|
18066
16603
|
*/
|
|
18067
16604
|
async function publicInvitationRoutes(app) {
|
|
18068
|
-
const { previewInvitation } = await import("./invitation-
|
|
16605
|
+
const { previewInvitation } = await import("./invitation-C9m2gQx4-CkwWteA3.mjs");
|
|
18069
16606
|
app.get("/:token/preview", async (request, reply) => {
|
|
18070
16607
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
18071
16608
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -18354,7 +16891,7 @@ async function meRoutes(app) {
|
|
|
18354
16891
|
*/
|
|
18355
16892
|
app.get("/me/pinned-agents", async (request) => {
|
|
18356
16893
|
const { userId } = requireUser(request);
|
|
18357
|
-
const { listMyPinnedAgents } = await import("./client-
|
|
16894
|
+
const { listMyPinnedAgents } = await import("./client-CDw0f-kN-BPzOVd8L.mjs");
|
|
18358
16895
|
return listMyPinnedAgents(app.db, { userId });
|
|
18359
16896
|
});
|
|
18360
16897
|
/**
|