@agent-team-foundation/first-tree-hub 0.10.15 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.mjs +3 -3
- package/dist/{dist-D6AOiyNg.mjs → dist-BoHl9HwW.mjs} +66 -2
- package/dist/drizzle/0030_chat_first_workspace.sql +129 -0
- package/dist/drizzle/meta/_journal.json +7 -0
- package/dist/{feishu-DQ1l18Ah.mjs → feishu-Dxk6ArOK.mjs} +1 -1
- package/dist/index.mjs +3 -3
- package/dist/{invitation-CBnQyB7o-Bulf3Sl7.mjs → invitation-CBnQyB7o-TmnIj3kx.mjs} +1 -1
- package/dist/{saas-connect-CebXWFF-.mjs → saas-connect-DLSyrQcC.mjs} +1275 -294
- package/dist/web/assets/index-BxGzfDTS.js +383 -0
- package/dist/web/assets/{index-BQda2sqe.js → index-COflQOwF.js} +1 -1
- package/dist/web/assets/{index-CKoTjI0J.css → index-DDqPt6PI.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-C7yW7sWI.js +0 -388
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { m as __toESM } from "./esm-CYu4tXXn.mjs";
|
|
2
2
|
import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DPyf745N-BSc8QNcR.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-DUeYbwm-.mjs";
|
|
4
|
-
import { $ as
|
|
4
|
+
import { $ as messageSourceSchema$1, A as createChatSchema, B as githubCallbackQuerySchema, C as agentTypeSchema$1, Ct as updateMemberSchema, D as createAdapterConfigSchema, Dt as wsAuthFrameSchema, E as connectTokenExchangeSchema, Et as updateTaskStatusSchema, F as createTaskSchema, G as inboxDeliverFrameSchema$1, H as githubStartQuerySchema, I as defaultRuntimeConfigPayload, J as isReservedAgentName$1, K as inboxPollQuerySchema, L as delegateFeishuUserSchema, M as createMemberSchema, N as createOrgFromMeSchema, O as createAdapterMappingSchema, P as createOrganizationSchema, Q as loginSchema, R as dryRunAgentRuntimeConfigSchema, S as agentRuntimeConfigPayloadSchema$1, St as updateClientCapabilitiesSchema, T as clientRegisterSchema, Tt as updateSystemConfigSchema, U as imageInlineContentSchema, V as githubDevCallbackQuerySchema, W as inboxAckFrameSchema, X as linkTaskChatSchema, Y as joinByInvitationSchema, Z as listMeChatsQuerySchema, _ as addParticipantSchema, _t as taskListQuerySchema, a as AGENT_STATUSES, at as safeRedirectPath, b as agentBindRequestSchema, bt as updateAgentSchema, ct as sendMessageSchema, d as TASK_CREATOR_TYPES, dt as sessionEventMessageSchema, et as notificationQuerySchema, f as TASK_HEALTH_SIGNALS, ft as sessionEventSchema$1, g as addMeChatParticipantsSchema, gt as switchOrgSchema, h as WS_AUTH_FRAME_TIMEOUT_MS, ht as stripCode, i as AGENT_SOURCES, it as runtimeStateMessageSchema, j as createMeChatSchema, k as createAgentSchema, l as MENTION_REGEX, lt as sendToAgentSchema, m as TASK_TERMINAL_STATUSES, mt as sessionStateMessageSchema, n as AGENT_NAME_REGEX$1, nt as rebindAgentSchema, o as AGENT_TYPES, ot as scanMentionTokens, p as TASK_STATUSES, pt as sessionReconcileRequestSchema, q as isRedactedEnvValue, r as AGENT_SELECTOR_HEADER$1, rt as refreshTokenSchema, s as AGENT_VISIBILITY, st as selfServiceFeishuBotSchema, t as AGENT_BIND_REJECT_REASONS, tt as paginationQuerySchema, u as SYSTEM_CONFIG_DEFAULTS, ut as sessionCompletionMessageSchema, v as adminCreateTaskSchema, vt as updateAdapterConfigSchema, w as clientCapabilitiesSchema$1, wt as updateOrganizationSchema, x as agentPinnedMessageSchema$1, xt as updateChatSchema, y as adminUpdateTaskSchema, yt as updateAgentRuntimeConfigSchema, z as extractMentions } from "./dist-BoHl9HwW.mjs";
|
|
5
5
|
import { _ as recordRedemption, a as ConflictError, b as uuidv7, c as UnauthorizedError, d as findActiveByToken, f as getActiveInvitation, h as organizations, i as ClientUserMismatchError$1, l as buildInviteUrl, m as invitations, n as BadRequestError, o as ForbiddenError, p as invitationRedemptions, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as ensureActiveInvitation, y as users } from "./invitation-B1pjAyOz-BaCA9PII.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { ZodError, z } from "zod";
|
|
@@ -734,7 +734,11 @@ z.object({
|
|
|
734
734
|
metadata: z.record(z.string(), z.unknown()),
|
|
735
735
|
createdAt: z.string(),
|
|
736
736
|
updatedAt: z.string()
|
|
737
|
-
}).extend({
|
|
737
|
+
}).extend({
|
|
738
|
+
participants: z.array(chatParticipantSchema),
|
|
739
|
+
title: z.string(),
|
|
740
|
+
firstMessagePreview: z.string().nullable()
|
|
741
|
+
});
|
|
738
742
|
z.object({ topic: z.string().trim().max(500).nullable() });
|
|
739
743
|
z.object({
|
|
740
744
|
agentId: z.string().min(1),
|
|
@@ -1041,6 +1045,59 @@ z.object({
|
|
|
1041
1045
|
});
|
|
1042
1046
|
z.object({ token: z.string().min(1) });
|
|
1043
1047
|
z.object({}).optional();
|
|
1048
|
+
const meChatFilterSchema = z.enum([
|
|
1049
|
+
"all",
|
|
1050
|
+
"unread",
|
|
1051
|
+
"watching"
|
|
1052
|
+
]);
|
|
1053
|
+
const meChatMembershipKindSchema = z.enum(["participant", "watching"]);
|
|
1054
|
+
z.object({
|
|
1055
|
+
cursor: z.string().optional(),
|
|
1056
|
+
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
1057
|
+
filter: meChatFilterSchema.default("all")
|
|
1058
|
+
});
|
|
1059
|
+
const meChatParticipantSchema = z.object({
|
|
1060
|
+
agentId: z.string(),
|
|
1061
|
+
displayName: z.string(),
|
|
1062
|
+
type: z.string()
|
|
1063
|
+
});
|
|
1064
|
+
const meChatRowSchema = z.object({
|
|
1065
|
+
chatId: z.string(),
|
|
1066
|
+
type: z.string(),
|
|
1067
|
+
membershipKind: meChatMembershipKindSchema,
|
|
1068
|
+
title: z.string(),
|
|
1069
|
+
topic: z.string().nullable(),
|
|
1070
|
+
participants: z.array(meChatParticipantSchema),
|
|
1071
|
+
participantCount: z.number().int(),
|
|
1072
|
+
lastMessageAt: z.string().nullable(),
|
|
1073
|
+
lastMessagePreview: z.string().nullable(),
|
|
1074
|
+
unreadMentionCount: z.number().int(),
|
|
1075
|
+
canReply: z.boolean(),
|
|
1076
|
+
taskId: z.string().nullable(),
|
|
1077
|
+
taskStatus: z.string().nullable()
|
|
1078
|
+
});
|
|
1079
|
+
z.object({
|
|
1080
|
+
rows: z.array(meChatRowSchema),
|
|
1081
|
+
nextCursor: z.string().nullable()
|
|
1082
|
+
});
|
|
1083
|
+
z.object({
|
|
1084
|
+
participantIds: z.array(z.string().min(1)).min(1),
|
|
1085
|
+
topic: z.string().trim().max(500).optional().nullable()
|
|
1086
|
+
});
|
|
1087
|
+
z.object({ participantIds: z.array(z.string().min(1)).min(1) });
|
|
1088
|
+
z.object({
|
|
1089
|
+
chatId: z.string(),
|
|
1090
|
+
lastReadAt: z.string(),
|
|
1091
|
+
unreadMentionCount: z.number().int()
|
|
1092
|
+
});
|
|
1093
|
+
z.object({
|
|
1094
|
+
chatId: z.string(),
|
|
1095
|
+
membershipKind: meChatMembershipKindSchema.nullable()
|
|
1096
|
+
});
|
|
1097
|
+
z.object({
|
|
1098
|
+
type: z.literal("chat:message"),
|
|
1099
|
+
chatId: z.string()
|
|
1100
|
+
});
|
|
1044
1101
|
z.enum([
|
|
1045
1102
|
"connect",
|
|
1046
1103
|
"create_agent",
|
|
@@ -8146,7 +8203,7 @@ async function onboardCreate(args) {
|
|
|
8146
8203
|
}
|
|
8147
8204
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
8148
8205
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
8149
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
8206
|
+
const { bindFeishuBot } = await import("./feishu-Dxk6ArOK.mjs").then((n) => n.r);
|
|
8150
8207
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
8151
8208
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
8152
8209
|
else {
|
|
@@ -9126,7 +9183,7 @@ function createFeedbackHandler(config) {
|
|
|
9126
9183
|
return { handle };
|
|
9127
9184
|
}
|
|
9128
9185
|
//#endregion
|
|
9129
|
-
//#region ../server/dist/app-
|
|
9186
|
+
//#region ../server/dist/app-DNJkrky7.mjs
|
|
9130
9187
|
var __defProp = Object.defineProperty;
|
|
9131
9188
|
var __exportAll = (all, no_symbols) => {
|
|
9132
9189
|
let target = {};
|
|
@@ -9210,17 +9267,44 @@ const chats = pgTable("chats", {
|
|
|
9210
9267
|
lifecyclePolicy: text("lifecycle_policy").default("persistent"),
|
|
9211
9268
|
parentChatId: text("parent_chat_id"),
|
|
9212
9269
|
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
9270
|
+
lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
|
|
9271
|
+
lastMessagePreview: text("last_message_preview"),
|
|
9213
9272
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
9214
9273
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
9215
|
-
});
|
|
9216
|
-
/**
|
|
9274
|
+
}, (table) => [index("idx_chats_org_last_message").on(table.organizationId, desc(table.lastMessageAt))]);
|
|
9275
|
+
/** Speaking participants of a chat (M:N). Watchers live in chat_subscriptions. */
|
|
9217
9276
|
const chatParticipants = pgTable("chat_participants", {
|
|
9218
9277
|
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
9219
9278
|
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
9220
9279
|
role: text("role").notNull().default("member"),
|
|
9221
9280
|
mode: text("mode").notNull().default("full"),
|
|
9281
|
+
lastReadAt: timestamp("last_read_at", { withTimezone: true }),
|
|
9282
|
+
unreadMentionCount: integer("unread_mention_count").notNull().default(0),
|
|
9222
9283
|
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
|
|
9223
9284
|
}, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_participants_agent").on(table.agentId)]);
|
|
9285
|
+
/**
|
|
9286
|
+
* Non-speaking observers ("watchers"). Used by the chat-first workspace so a
|
|
9287
|
+
* user can supervise chats their managed agents participate in without
|
|
9288
|
+
* accidentally being part of fan-out.
|
|
9289
|
+
*
|
|
9290
|
+
* Invariants:
|
|
9291
|
+
* 1. (chat_id, agent_id) is mutually exclusive with chat_participants.
|
|
9292
|
+
* 2. Rows here NEVER produce inbox_entries (fan-out exclusivity).
|
|
9293
|
+
* 3. Mention candidate resolution NEVER includes these rows.
|
|
9294
|
+
* 4. State transitions (join/leave) carry last_read_at + counter; lifecycle
|
|
9295
|
+
* recomputes default to NULL/0 and MUST NOT run on the join/leave path.
|
|
9296
|
+
*
|
|
9297
|
+
* See docs/chat-first-workspace-product-design.md "Data Model" + "State
|
|
9298
|
+
* Transitions" for the full contract.
|
|
9299
|
+
*/
|
|
9300
|
+
const chatSubscriptions = pgTable("chat_subscriptions", {
|
|
9301
|
+
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
9302
|
+
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
9303
|
+
kind: text("kind").notNull().default("watching"),
|
|
9304
|
+
lastReadAt: timestamp("last_read_at", { withTimezone: true }),
|
|
9305
|
+
unreadMentionCount: integer("unread_mention_count").notNull().default(0),
|
|
9306
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
9307
|
+
}, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_chat_subscriptions_agent").on(table.agentId)]);
|
|
9224
9308
|
/** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
|
|
9225
9309
|
const members = pgTable("members", {
|
|
9226
9310
|
id: text("id").primaryKey(),
|
|
@@ -10015,7 +10099,7 @@ async function deleteAdapterConfig(db, id) {
|
|
|
10015
10099
|
const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
|
|
10016
10100
|
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
10017
10101
|
}
|
|
10018
|
-
const log$
|
|
10102
|
+
const log$6 = createLogger$1("AdminAdapters");
|
|
10019
10103
|
const orgQuerySchema$1 = z.object({ organizationId: z.string().min(1).optional() });
|
|
10020
10104
|
function parseId(raw) {
|
|
10021
10105
|
const id = Number(raw);
|
|
@@ -10038,7 +10122,7 @@ async function adminAdapterRoutes(app) {
|
|
|
10038
10122
|
const scope = memberScope(request);
|
|
10039
10123
|
await assertCanManage(app.db, scope, body.agentId);
|
|
10040
10124
|
const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
|
|
10041
|
-
app.adapterManager.reload().catch((err) => log$
|
|
10125
|
+
app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after create"));
|
|
10042
10126
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10043
10127
|
return reply.status(201).send({
|
|
10044
10128
|
...config,
|
|
@@ -10062,7 +10146,7 @@ async function adminAdapterRoutes(app) {
|
|
|
10062
10146
|
const existing = await getAdapterConfig(app.db, id);
|
|
10063
10147
|
await assertCanManage(app.db, scope, existing.agentId);
|
|
10064
10148
|
const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
|
|
10065
|
-
app.adapterManager.reload().catch((err) => log$
|
|
10149
|
+
app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after update"));
|
|
10066
10150
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10067
10151
|
return {
|
|
10068
10152
|
...config,
|
|
@@ -10076,7 +10160,7 @@ async function adminAdapterRoutes(app) {
|
|
|
10076
10160
|
const existing = await getAdapterConfig(app.db, id);
|
|
10077
10161
|
await assertCanManage(app.db, scope, existing.agentId);
|
|
10078
10162
|
await deleteAdapterConfig(app.db, id);
|
|
10079
|
-
app.adapterManager.reload().catch((err) => log$
|
|
10163
|
+
app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after delete"));
|
|
10080
10164
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10081
10165
|
return reply.status(204).send();
|
|
10082
10166
|
});
|
|
@@ -10157,6 +10241,227 @@ const agentConfigs = pgTable("agent_configs", {
|
|
|
10157
10241
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
10158
10242
|
});
|
|
10159
10243
|
/**
|
|
10244
|
+
* Chat-first workspace — watcher subscription helpers.
|
|
10245
|
+
*
|
|
10246
|
+
* Watchers (rows in `chat_subscriptions`) are non-speaking observers. A
|
|
10247
|
+
* member who manages an agent that participates in a chat — but whose own
|
|
10248
|
+
* human agent is not a speaker there — sees the chat in their workspace
|
|
10249
|
+
* via a watcher row.
|
|
10250
|
+
*
|
|
10251
|
+
* Two distinct kinds of operation live here:
|
|
10252
|
+
*
|
|
10253
|
+
* 1. Set rebuilds (`recompute*`). Idempotent set-based recomputations
|
|
10254
|
+
* driven by lifecycle events (chat created, participant added/removed,
|
|
10255
|
+
* member status flipped, etc.). These DEFAULT new rows to NULL/0 read
|
|
10256
|
+
* state.
|
|
10257
|
+
*
|
|
10258
|
+
* 2. State-carry transitions (`joinAsParticipant`, `leaveAsParticipant`).
|
|
10259
|
+
* Move a single (chat, agent) pair between `chat_participants` and
|
|
10260
|
+
* `chat_subscriptions` while preserving `last_read_at` and
|
|
10261
|
+
* `unread_mention_count`. NEVER call recompute on this path or you'll
|
|
10262
|
+
* lose read state.
|
|
10263
|
+
*
|
|
10264
|
+
* See docs/chat-first-workspace-product-design.md "State Transitions" and
|
|
10265
|
+
* "Risk Constraints".
|
|
10266
|
+
*/
|
|
10267
|
+
/**
|
|
10268
|
+
* Recompute watcher rows for ONE chat. For every active member who:
|
|
10269
|
+
* - manages a non-human agent that speaks in the chat, AND
|
|
10270
|
+
* - whose own human agent is NOT a speaker in the chat
|
|
10271
|
+
* an `(chat_id, member.agent_id)` watcher row is upserted (NULL read state).
|
|
10272
|
+
*
|
|
10273
|
+
* Watchers whose anchoring condition no longer holds (manager left, the
|
|
10274
|
+
* managed agent was removed from the chat, the manager joined as a speaker
|
|
10275
|
+
* themselves) are deleted.
|
|
10276
|
+
*
|
|
10277
|
+
* Idempotent: safe to call multiple times for the same chat.
|
|
10278
|
+
*/
|
|
10279
|
+
async function recomputeChatWatchers(db, chatId) {
|
|
10280
|
+
await db.execute(sql`
|
|
10281
|
+
INSERT INTO chat_subscriptions
|
|
10282
|
+
(chat_id, agent_id, kind, last_read_at, unread_mention_count, created_at)
|
|
10283
|
+
SELECT DISTINCT cp.chat_id, m.agent_id, 'watching', NULL::timestamp with time zone, 0, now()
|
|
10284
|
+
FROM chat_participants cp
|
|
10285
|
+
JOIN agents a ON a.uuid = cp.agent_id
|
|
10286
|
+
JOIN members m ON m.id = a.manager_id
|
|
10287
|
+
WHERE cp.chat_id = ${chatId}
|
|
10288
|
+
AND m.status = 'active'
|
|
10289
|
+
AND a.type <> 'human'
|
|
10290
|
+
AND NOT EXISTS (
|
|
10291
|
+
SELECT 1 FROM chat_participants cp2
|
|
10292
|
+
WHERE cp2.chat_id = cp.chat_id
|
|
10293
|
+
AND cp2.agent_id = m.agent_id
|
|
10294
|
+
)
|
|
10295
|
+
ON CONFLICT (chat_id, agent_id) DO NOTHING
|
|
10296
|
+
`);
|
|
10297
|
+
await db.execute(sql`
|
|
10298
|
+
DELETE FROM chat_subscriptions cs
|
|
10299
|
+
WHERE cs.chat_id = ${chatId}
|
|
10300
|
+
AND NOT EXISTS (
|
|
10301
|
+
SELECT 1
|
|
10302
|
+
FROM chat_participants cp
|
|
10303
|
+
JOIN agents a ON a.uuid = cp.agent_id
|
|
10304
|
+
JOIN members m ON m.id = a.manager_id
|
|
10305
|
+
WHERE cp.chat_id = cs.chat_id
|
|
10306
|
+
AND m.agent_id = cs.agent_id
|
|
10307
|
+
AND m.status = 'active'
|
|
10308
|
+
AND a.type <> 'human'
|
|
10309
|
+
AND NOT EXISTS (
|
|
10310
|
+
SELECT 1 FROM chat_participants cp2
|
|
10311
|
+
WHERE cp2.chat_id = cp.chat_id
|
|
10312
|
+
AND cp2.agent_id = m.agent_id
|
|
10313
|
+
)
|
|
10314
|
+
)
|
|
10315
|
+
`);
|
|
10316
|
+
}
|
|
10317
|
+
/**
|
|
10318
|
+
* Recompute watcher rows touching ONE agent across all chats it speaks in.
|
|
10319
|
+
* Used after `rebindAgent` (manager change) so the new manager picks up
|
|
10320
|
+
* watcher rows and the old manager's are dropped.
|
|
10321
|
+
*/
|
|
10322
|
+
async function recomputeWatchersForAgent(db, agentId) {
|
|
10323
|
+
const chatRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId));
|
|
10324
|
+
for (const { chatId } of chatRows) await recomputeChatWatchers(db, chatId);
|
|
10325
|
+
}
|
|
10326
|
+
/**
|
|
10327
|
+
* Recompute watcher rows touching ONE member across all chats. Triggered
|
|
10328
|
+
* when the member's status flips active ↔ left.
|
|
10329
|
+
*/
|
|
10330
|
+
async function recomputeWatchersForMember(db, memberId) {
|
|
10331
|
+
const rows = await db.selectDistinct({ chatId: chatParticipants.chatId }).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(and(eq(agents.managerId, memberId), ne(agents.type, "human")));
|
|
10332
|
+
for (const { chatId } of rows) await recomputeChatWatchers(db, chatId);
|
|
10333
|
+
}
|
|
10334
|
+
/**
|
|
10335
|
+
* Mirror of `services/chat.ts` `maybeUpgradeDirectToGroup`. Inlined here so
|
|
10336
|
+
* `joinAsParticipant` keeps the upgrade rule + the state carry in one
|
|
10337
|
+
* transaction without depending on chat.ts (avoids a circular import).
|
|
10338
|
+
*/
|
|
10339
|
+
async function maybeUpgradeDirectToGroup$1(tx, chatId, existingParticipantIds) {
|
|
10340
|
+
if (existingParticipantIds.length + 1 < 3) return;
|
|
10341
|
+
const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
10342
|
+
if (!chat || chat.type !== "direct") return;
|
|
10343
|
+
await tx.update(chats).set({
|
|
10344
|
+
type: "group",
|
|
10345
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
10346
|
+
}).where(eq(chats.id, chatId));
|
|
10347
|
+
if (existingParticipantIds.length === 0) return;
|
|
10348
|
+
const ids = (await tx.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((r) => r.uuid);
|
|
10349
|
+
if (ids.length === 0) return;
|
|
10350
|
+
await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
|
|
10351
|
+
}
|
|
10352
|
+
/**
|
|
10353
|
+
* Watcher → speaking participant. State-carry transaction.
|
|
10354
|
+
*
|
|
10355
|
+
* 1. DELETE the watcher row (returning read state).
|
|
10356
|
+
* 2. If a participant row already exists, no-op (idempotent).
|
|
10357
|
+
* 3. Otherwise, run the direct → group upgrade rule against the *current*
|
|
10358
|
+
* participant set, then INSERT the participant row carrying read state.
|
|
10359
|
+
*
|
|
10360
|
+
* If `requireWatcherOrVisible` is true, refuse when the user has neither a
|
|
10361
|
+
* watcher row nor admin-derived visibility — used to keep the public
|
|
10362
|
+
* `/me/chats/:chatId/join` endpoint honest. Pre-check happens in the
|
|
10363
|
+
* route layer where we have the full member scope.
|
|
10364
|
+
*/
|
|
10365
|
+
async function joinAsParticipant(db, chatId, humanAgentId) {
|
|
10366
|
+
return db.transaction(async (tx) => {
|
|
10367
|
+
const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
|
|
10368
|
+
lastReadAt: chatSubscriptions.lastReadAt,
|
|
10369
|
+
unreadMentionCount: chatSubscriptions.unreadMentionCount
|
|
10370
|
+
});
|
|
10371
|
+
const [existing] = await tx.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
|
|
10372
|
+
if (existing) return {
|
|
10373
|
+
chatId,
|
|
10374
|
+
inserted: false,
|
|
10375
|
+
carried: carriedRow ?? null
|
|
10376
|
+
};
|
|
10377
|
+
await maybeUpgradeDirectToGroup$1(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId));
|
|
10378
|
+
await tx.insert(chatParticipants).values({
|
|
10379
|
+
chatId,
|
|
10380
|
+
agentId: humanAgentId,
|
|
10381
|
+
role: "member",
|
|
10382
|
+
mode: "full",
|
|
10383
|
+
lastReadAt: carriedRow?.lastReadAt ?? null,
|
|
10384
|
+
unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
|
|
10385
|
+
});
|
|
10386
|
+
return {
|
|
10387
|
+
chatId,
|
|
10388
|
+
inserted: true,
|
|
10389
|
+
carried: carriedRow ?? null
|
|
10390
|
+
};
|
|
10391
|
+
});
|
|
10392
|
+
}
|
|
10393
|
+
/**
|
|
10394
|
+
* Speaking participant → watcher (or fully detach).
|
|
10395
|
+
*
|
|
10396
|
+
* 1. DELETE the participant row (returning read state).
|
|
10397
|
+
* 2. Test "still visible": is the user still the manager of an agent that
|
|
10398
|
+
* remains a participant in this chat? If yes, INSERT a watcher row
|
|
10399
|
+
* carrying read state. If no, drop entirely.
|
|
10400
|
+
*
|
|
10401
|
+
* Caller must validate that the user actually has a participant row to
|
|
10402
|
+
* leave (returns `NotFoundError` if not).
|
|
10403
|
+
*/
|
|
10404
|
+
async function leaveAsParticipant(db, chatId, humanAgentId) {
|
|
10405
|
+
return db.transaction(async (tx) => {
|
|
10406
|
+
const [carried] = await tx.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).returning({
|
|
10407
|
+
lastReadAt: chatParticipants.lastReadAt,
|
|
10408
|
+
unreadMentionCount: chatParticipants.unreadMentionCount
|
|
10409
|
+
});
|
|
10410
|
+
if (!carried) throw new NotFoundError("Not a participant of this chat");
|
|
10411
|
+
const [stillVisibleRow] = await tx.execute(sql`
|
|
10412
|
+
SELECT EXISTS (
|
|
10413
|
+
SELECT 1
|
|
10414
|
+
FROM chat_participants cp
|
|
10415
|
+
JOIN agents a ON a.uuid = cp.agent_id
|
|
10416
|
+
JOIN members m ON m.id = a.manager_id
|
|
10417
|
+
WHERE cp.chat_id = ${chatId}
|
|
10418
|
+
AND m.agent_id = ${humanAgentId}
|
|
10419
|
+
AND m.status = 'active'
|
|
10420
|
+
AND a.type <> 'human'
|
|
10421
|
+
) AS visible
|
|
10422
|
+
`);
|
|
10423
|
+
if (!Boolean(stillVisibleRow?.visible)) return {
|
|
10424
|
+
chatId,
|
|
10425
|
+
membershipKind: null
|
|
10426
|
+
};
|
|
10427
|
+
await tx.insert(chatSubscriptions).values({
|
|
10428
|
+
chatId,
|
|
10429
|
+
agentId: humanAgentId,
|
|
10430
|
+
kind: "watching",
|
|
10431
|
+
lastReadAt: carried.lastReadAt,
|
|
10432
|
+
unreadMentionCount: carried.unreadMentionCount
|
|
10433
|
+
}).onConflictDoNothing();
|
|
10434
|
+
return {
|
|
10435
|
+
chatId,
|
|
10436
|
+
membershipKind: "watching"
|
|
10437
|
+
};
|
|
10438
|
+
});
|
|
10439
|
+
}
|
|
10440
|
+
/**
|
|
10441
|
+
* Resolve the membership row of the human agent for the given chat. Returns
|
|
10442
|
+
* one of: 'participant', 'watching', or null.
|
|
10443
|
+
*
|
|
10444
|
+
* Used by `/me/chats/:chatId/join` to refuse a join when the user has
|
|
10445
|
+
* neither a watcher row nor a participant row, and isn't otherwise
|
|
10446
|
+
* authorised (admin in the chat's org).
|
|
10447
|
+
*/
|
|
10448
|
+
async function resolveChatMembership(db, chatId, humanAgentId) {
|
|
10449
|
+
const [participant] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
|
|
10450
|
+
if (participant) return "participant";
|
|
10451
|
+
const [sub] = await db.select({ chatId: chatSubscriptions.chatId }).from(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).limit(1);
|
|
10452
|
+
if (sub) return "watching";
|
|
10453
|
+
return null;
|
|
10454
|
+
}
|
|
10455
|
+
/**
|
|
10456
|
+
* Used by `/me/chats/:chatId/join`. Throw 409 if already a speaker (no work
|
|
10457
|
+
* to do) and 403 if no watcher row and no admin override. Admin override is
|
|
10458
|
+
* resolved at the route layer; this helper only reports the watcher state.
|
|
10459
|
+
*/
|
|
10460
|
+
function ensureCanJoin(membership) {
|
|
10461
|
+
if (membership === "participant") throw new ConflictError("Already a participant in this chat");
|
|
10462
|
+
if (membership === null) throw new ForbiddenError("Not a watcher of this chat — open the chat from your workspace before joining");
|
|
10463
|
+
}
|
|
10464
|
+
/**
|
|
10160
10465
|
* Names beginning with `__` are reserved for Hub-internal pseudo agents
|
|
10161
10466
|
* (e.g. the task notifier). User-facing creation must not be able to
|
|
10162
10467
|
* squat on them, otherwise internal traffic could be routed through a
|
|
@@ -10463,6 +10768,7 @@ async function updateAgent(db, uuid, data) {
|
|
|
10463
10768
|
}
|
|
10464
10769
|
const [updated] = await db.update(agents).set(updates).where(eq(agents.uuid, agent.uuid)).returning();
|
|
10465
10770
|
if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
10771
|
+
if (data.managerId !== void 0 && data.managerId !== agent.managerId) await recomputeWatchersForAgent(db, agent.uuid);
|
|
10466
10772
|
return updated;
|
|
10467
10773
|
}
|
|
10468
10774
|
/**
|
|
@@ -10549,6 +10855,71 @@ async function deleteAgent(db, uuid) {
|
|
|
10549
10855
|
return agent;
|
|
10550
10856
|
}
|
|
10551
10857
|
/**
|
|
10858
|
+
* Process-local cache for the per-chat realtime push audience
|
|
10859
|
+
* (`chat_participants ∪ chat_subscriptions`, keyed by human agent
|
|
10860
|
+
* uuid). Sits in front of the admin WS dispatch so a chat with N
|
|
10861
|
+
* messages/sec doesn't issue N audience-resolution queries; one query
|
|
10862
|
+
* + cache hit per chat per TTL window.
|
|
10863
|
+
*
|
|
10864
|
+
* The cache exposes both a populator (`getCachedAudience`) and an
|
|
10865
|
+
* invalidator (`invalidateChatAudience`). Participant-mutation paths
|
|
10866
|
+
* (`addMeChatParticipants`, `joinMeChat`, `leaveMeChat`,
|
|
10867
|
+
* `recomputeChatWatchers`, `joinAsParticipant`, `leaveAsParticipant`)
|
|
10868
|
+
* MUST call `invalidateChatAudience` after their tx commits so the
|
|
10869
|
+
* very next dispatch reflects the new audience without waiting for
|
|
10870
|
+
* the TTL to age out — without invalidation, a freshly-added speaker
|
|
10871
|
+
* would miss `chat:message` pushes for up to TTL_MS.
|
|
10872
|
+
*
|
|
10873
|
+
* Cross-instance correctness: not handled here. The PG NOTIFY layer
|
|
10874
|
+
* already broadcasts message events to every replica; each replica's
|
|
10875
|
+
* audience cache is independently invalidated by its own
|
|
10876
|
+
* service-layer mutations on chats it routes traffic for. For
|
|
10877
|
+
* cross-replica participant changes to invalidate this cache, route
|
|
10878
|
+
* the mutation through the same replica that hosts the WS connection
|
|
10879
|
+
* (sticky routing) or add a dedicated `chat:audience` PG NOTIFY in
|
|
10880
|
+
* a follow-up.
|
|
10881
|
+
*/
|
|
10882
|
+
const log$5 = createLogger$1("ChatAudienceCache");
|
|
10883
|
+
const TTL_MS = 5e3;
|
|
10884
|
+
const MAX_ENTRIES = 1024;
|
|
10885
|
+
const cache = /* @__PURE__ */ new Map();
|
|
10886
|
+
/** Resolve a chat's push audience, hitting the cache when fresh.
|
|
10887
|
+
* Returns null on DB error (caller should skip dispatch). */
|
|
10888
|
+
async function getCachedAudience(db, chatId) {
|
|
10889
|
+
const now = Date.now();
|
|
10890
|
+
const cached = cache.get(chatId);
|
|
10891
|
+
if (cached && cached.expiresAt > now) return cached.audience;
|
|
10892
|
+
try {
|
|
10893
|
+
const rows = await db.execute(sql`
|
|
10894
|
+
SELECT agent_id FROM chat_participants WHERE chat_id = ${chatId}
|
|
10895
|
+
UNION
|
|
10896
|
+
SELECT agent_id FROM chat_subscriptions WHERE chat_id = ${chatId}
|
|
10897
|
+
`);
|
|
10898
|
+
const audience = new Set(rows.map((r) => r.agent_id));
|
|
10899
|
+
cache.set(chatId, {
|
|
10900
|
+
audience,
|
|
10901
|
+
expiresAt: now + TTL_MS
|
|
10902
|
+
});
|
|
10903
|
+
if (cache.size > MAX_ENTRIES) {
|
|
10904
|
+
for (const [k, v] of cache) if (v.expiresAt <= now) cache.delete(k);
|
|
10905
|
+
}
|
|
10906
|
+
return audience;
|
|
10907
|
+
} catch (err) {
|
|
10908
|
+
log$5.warn({
|
|
10909
|
+
err,
|
|
10910
|
+
chatId
|
|
10911
|
+
}, "failed to resolve chat audience");
|
|
10912
|
+
return null;
|
|
10913
|
+
}
|
|
10914
|
+
}
|
|
10915
|
+
/** Drop the cached audience for a chat. Called from participant-
|
|
10916
|
+
* mutation paths after their transaction commits, so the next
|
|
10917
|
+
* `chat:message` dispatch hits the DB and reflects the new
|
|
10918
|
+
* membership instead of serving a stale TTL window. */
|
|
10919
|
+
function invalidateChatAudience(chatId) {
|
|
10920
|
+
cache.delete(chatId);
|
|
10921
|
+
}
|
|
10922
|
+
/**
|
|
10552
10923
|
* When a direct chat grows past 2 participants, upgrade it to `group` and
|
|
10553
10924
|
* flip every existing non-human agent participant to `mention_only` — see
|
|
10554
10925
|
* proposals/hub-agent-messaging-reply-and-mentions §3.3. The caller is
|
|
@@ -10603,6 +10974,7 @@ async function createChat(db, creatorId, data) {
|
|
|
10603
10974
|
...isDirectAgentOnly ? { mode: "mention_only" } : {}
|
|
10604
10975
|
}));
|
|
10605
10976
|
await tx.insert(chatParticipants).values(participantRows);
|
|
10977
|
+
await recomputeChatWatchers(tx, chatId);
|
|
10606
10978
|
const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10607
10979
|
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
10608
10980
|
return {
|
|
@@ -10666,12 +11038,15 @@ async function ensureParticipant(db, chatId, agentId) {
|
|
|
10666
11038
|
if (existing) return;
|
|
10667
11039
|
await db.transaction(async (tx) => {
|
|
10668
11040
|
await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
|
|
11041
|
+
await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, agentId)));
|
|
10669
11042
|
await tx.insert(chatParticipants).values({
|
|
10670
11043
|
chatId,
|
|
10671
11044
|
agentId,
|
|
10672
11045
|
mode: "full"
|
|
10673
11046
|
}).onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
|
|
11047
|
+
await recomputeChatWatchers(tx, chatId);
|
|
10674
11048
|
});
|
|
11049
|
+
invalidateChatAudience(chatId);
|
|
10675
11050
|
}
|
|
10676
11051
|
async function addParticipant(db, chatId, requesterId, data) {
|
|
10677
11052
|
const chat = await getChat(db, chatId);
|
|
@@ -10686,12 +11061,15 @@ async function addParticipant(db, chatId, requesterId, data) {
|
|
|
10686
11061
|
if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
|
|
10687
11062
|
await db.transaction(async (tx) => {
|
|
10688
11063
|
await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
|
|
11064
|
+
await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, data.agentId)));
|
|
10689
11065
|
await tx.insert(chatParticipants).values({
|
|
10690
11066
|
chatId,
|
|
10691
11067
|
agentId: data.agentId,
|
|
10692
11068
|
mode: data.mode ?? "full"
|
|
10693
11069
|
});
|
|
11070
|
+
await recomputeChatWatchers(tx, chatId);
|
|
10694
11071
|
});
|
|
11072
|
+
invalidateChatAudience(chatId);
|
|
10695
11073
|
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10696
11074
|
}
|
|
10697
11075
|
async function removeParticipant(db, chatId, requesterId, targetAgentId) {
|
|
@@ -10699,6 +11077,8 @@ async function removeParticipant(db, chatId, requesterId, targetAgentId) {
|
|
|
10699
11077
|
if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
|
|
10700
11078
|
const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, targetAgentId))).returning();
|
|
10701
11079
|
if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
|
|
11080
|
+
await recomputeChatWatchers(db, chatId);
|
|
11081
|
+
invalidateChatAudience(chatId);
|
|
10702
11082
|
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10703
11083
|
}
|
|
10704
11084
|
/**
|
|
@@ -10790,23 +11170,38 @@ async function joinChat(db, chatId, memberId, humanAgentId) {
|
|
|
10790
11170
|
const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
|
|
10791
11171
|
if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
|
|
10792
11172
|
await db.transaction(async (tx) => {
|
|
11173
|
+
const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
|
|
11174
|
+
lastReadAt: chatSubscriptions.lastReadAt,
|
|
11175
|
+
unreadMentionCount: chatSubscriptions.unreadMentionCount
|
|
11176
|
+
});
|
|
10793
11177
|
await maybeUpgradeDirectToGroup(tx, chatId, participantAgentIds, 1);
|
|
10794
11178
|
await tx.insert(chatParticipants).values({
|
|
10795
11179
|
chatId,
|
|
10796
11180
|
agentId: humanAgentId,
|
|
10797
11181
|
role: "member",
|
|
10798
|
-
mode: "full"
|
|
11182
|
+
mode: "full",
|
|
11183
|
+
lastReadAt: carriedRow?.lastReadAt ?? null,
|
|
11184
|
+
unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
|
|
10799
11185
|
});
|
|
11186
|
+
await recomputeChatWatchers(tx, chatId);
|
|
10800
11187
|
});
|
|
11188
|
+
invalidateChatAudience(chatId);
|
|
10801
11189
|
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10802
11190
|
}
|
|
10803
11191
|
/**
|
|
10804
11192
|
* Manager leaves a chat. Removes their human agent from participants.
|
|
10805
11193
|
* Only allowed if the human agent is a participant.
|
|
11194
|
+
*
|
|
11195
|
+
* Delegates the participant→watcher transition to `leaveAsParticipant`
|
|
11196
|
+
* so admin-side and `/me/chats/:id/leave` share one canonical path. The
|
|
11197
|
+
* earlier "recompute then UPDATE-back state" variant violated the design
|
|
11198
|
+
* rule that recompute is only for set rebuild — never on a transition
|
|
11199
|
+
* path (review #228 issue #2). The returned participant list is fetched
|
|
11200
|
+
* after the tx commits, matching the admin route's existing contract.
|
|
10806
11201
|
*/
|
|
10807
11202
|
async function leaveChat(db, chatId, humanAgentId) {
|
|
10808
|
-
|
|
10809
|
-
|
|
11203
|
+
await leaveAsParticipant(db, chatId, humanAgentId);
|
|
11204
|
+
invalidateChatAudience(chatId);
|
|
10810
11205
|
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10811
11206
|
}
|
|
10812
11207
|
async function findOrCreateDirectChat(db, agentAId, agentBId) {
|
|
@@ -10846,6 +11241,7 @@ async function findOrCreateDirectChat(db, agentAId, agentBId) {
|
|
|
10846
11241
|
role: "member",
|
|
10847
11242
|
mode
|
|
10848
11243
|
}]);
|
|
11244
|
+
await recomputeChatWatchers(tx, chatId);
|
|
10849
11245
|
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
10850
11246
|
return chat;
|
|
10851
11247
|
});
|
|
@@ -11548,6 +11944,88 @@ async function listAgentsWithRuntime(db, scope) {
|
|
|
11548
11944
|
type: agents.type
|
|
11549
11945
|
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope.organizationId, scope.memberId)));
|
|
11550
11946
|
}
|
|
11947
|
+
/**
|
|
11948
|
+
* Chat-first workspace — append-only post-fan-out projection.
|
|
11949
|
+
*
|
|
11950
|
+
* The single sanctioned extension point on the message hot path. Called
|
|
11951
|
+
* from `services/message.ts` AFTER existing fan-out completes, inside the
|
|
11952
|
+
* same transaction. Three responsibilities:
|
|
11953
|
+
*
|
|
11954
|
+
* 1. Mention propagation: increment `unread_mention_count` for mentioned
|
|
11955
|
+
* speaking participants AND for watcher rows whose managed agent was
|
|
11956
|
+
* mentioned. Sender row is excluded.
|
|
11957
|
+
*
|
|
11958
|
+
* 2. Chats projection: roll forward `chats.last_message_at`,
|
|
11959
|
+
* `chats.last_message_preview`. Powers the conversation list cursor +
|
|
11960
|
+
* sort + preview.
|
|
11961
|
+
*
|
|
11962
|
+
* 3. Realtime kick: fire-and-forget `pg_notify('chat_message_events', …)`
|
|
11963
|
+
* so admin WS sockets can translate it into a `chat:message` frame.
|
|
11964
|
+
* Failure is swallowed — durable persistence is the correctness path.
|
|
11965
|
+
*
|
|
11966
|
+
* Strict invariants (see docs/chat-first-workspace-product-design.md
|
|
11967
|
+
* "Risk Constraints"):
|
|
11968
|
+
* - This module appends ONLY. Never edits existing fan-out / inbox /
|
|
11969
|
+
* mention-extraction code.
|
|
11970
|
+
* - Watchers (chat_subscriptions) are NEVER added to inbox_entries here.
|
|
11971
|
+
* Their counters are bumped purely as a per-user red-dot signal.
|
|
11972
|
+
* - Mention candidate set is `chat_participants` only; watchers are not
|
|
11973
|
+
* direct `@`-mention targets.
|
|
11974
|
+
*/
|
|
11975
|
+
let dispatcher = null;
|
|
11976
|
+
function registerChatMessageDispatcher(fn) {
|
|
11977
|
+
dispatcher = fn;
|
|
11978
|
+
}
|
|
11979
|
+
/**
|
|
11980
|
+
* Best-effort cross-process kick for the chat-first workspace. Call AFTER
|
|
11981
|
+
* the message transaction commits — never inside the tx. Failure logs +
|
|
11982
|
+
* drops; web reconnect refetches.
|
|
11983
|
+
*
|
|
11984
|
+
* Speakers also get an inbox NOTIFY through the existing path. They will
|
|
11985
|
+
* receive both, and the web client de-dupes naturally because both end up
|
|
11986
|
+
* invalidating the same query keys.
|
|
11987
|
+
*/
|
|
11988
|
+
function fireChatMessageKick(chatId, messageId) {
|
|
11989
|
+
if (!dispatcher) return;
|
|
11990
|
+
try {
|
|
11991
|
+
dispatcher(chatId, messageId);
|
|
11992
|
+
} catch {}
|
|
11993
|
+
}
|
|
11994
|
+
/**
|
|
11995
|
+
* Apply the post-fan-out projection. MUST be called inside the same
|
|
11996
|
+
* transaction as the message INSERT. Safe to call when `mentionedAgentIds`
|
|
11997
|
+
* is empty (degenerate case skips the mention UPDATEs).
|
|
11998
|
+
*/
|
|
11999
|
+
async function applyAfterFanOut(tx, input) {
|
|
12000
|
+
const { chatId, senderId, mentionedAgentIds, contentPreview, messageCreatedAt } = input;
|
|
12001
|
+
const previewClipped = contentPreview.length > 0 ? contentPreview.slice(0, 200) : null;
|
|
12002
|
+
const ts = messageCreatedAt ?? /* @__PURE__ */ new Date();
|
|
12003
|
+
await tx.update(chats).set({
|
|
12004
|
+
lastMessageAt: ts,
|
|
12005
|
+
lastMessagePreview: previewClipped
|
|
12006
|
+
}).where(eq(chats.id, chatId));
|
|
12007
|
+
if (mentionedAgentIds.length === 0) return;
|
|
12008
|
+
await tx.update(chatParticipants).set({ unreadMentionCount: sql`${chatParticipants.unreadMentionCount} + 1` }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, mentionedAgentIds), ne(chatParticipants.agentId, senderId)));
|
|
12009
|
+
const managerHumanAgentIds = (await tx.execute(sql`
|
|
12010
|
+
SELECT DISTINCT m.agent_id AS human_agent_id
|
|
12011
|
+
FROM agents a
|
|
12012
|
+
JOIN members m ON m.id = a.manager_id
|
|
12013
|
+
WHERE a.uuid IN ${makeUuidList(mentionedAgentIds)}
|
|
12014
|
+
AND a.type <> 'human'
|
|
12015
|
+
AND m.status = 'active'
|
|
12016
|
+
`)).map((r) => r.human_agent_id);
|
|
12017
|
+
if (managerHumanAgentIds.length === 0) return;
|
|
12018
|
+
await tx.update(chatSubscriptions).set({ unreadMentionCount: sql`${chatSubscriptions.unreadMentionCount} + 1` }).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, managerHumanAgentIds)));
|
|
12019
|
+
}
|
|
12020
|
+
/**
|
|
12021
|
+
* Build a parenthesised, comma-separated list of bound parameters: `(?, ?, ?)`.
|
|
12022
|
+
* Used in raw SQL where drizzle's `inArray` can't be directly applied (e.g.
|
|
12023
|
+
* inside a hand-rolled SELECT). Always called with a non-empty list — the
|
|
12024
|
+
* caller short-circuits the empty case.
|
|
12025
|
+
*/
|
|
12026
|
+
function makeUuidList(ids) {
|
|
12027
|
+
return sql`(${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`;
|
|
12028
|
+
}
|
|
11551
12029
|
const log$4 = createLogger$1("message");
|
|
11552
12030
|
async function sendMessage(db, chatId, senderId, data, options = {}) {
|
|
11553
12031
|
return withSpan("inbox.enqueue", messageAttrs({
|
|
@@ -11649,6 +12127,15 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
|
11649
12127
|
}
|
|
11650
12128
|
await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
|
|
11651
12129
|
if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
12130
|
+
const previewText = typeof outboundContent === "string" ? outboundContent.trim() : "";
|
|
12131
|
+
await applyAfterFanOut(tx, {
|
|
12132
|
+
chatId,
|
|
12133
|
+
messageId: msg.id,
|
|
12134
|
+
senderId,
|
|
12135
|
+
mentionedAgentIds: mergedMentions,
|
|
12136
|
+
contentPreview: previewText,
|
|
12137
|
+
messageCreatedAt: msg.createdAt
|
|
12138
|
+
});
|
|
11652
12139
|
return {
|
|
11653
12140
|
message: msg,
|
|
11654
12141
|
recipients,
|
|
@@ -11665,6 +12152,7 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
|
11665
12152
|
agentId: txResult.recipientAgentIds[i]
|
|
11666
12153
|
}, "predictive session activation failed");
|
|
11667
12154
|
}
|
|
12155
|
+
fireChatMessageKick(chatId, txResult.message.id);
|
|
11668
12156
|
return {
|
|
11669
12157
|
message: txResult.message,
|
|
11670
12158
|
recipients: txResult.recipients
|
|
@@ -11726,15 +12214,24 @@ const INBOX_CHANNEL = "inbox_notifications";
|
|
|
11726
12214
|
const CONFIG_CHANNEL = "config_changes";
|
|
11727
12215
|
const SESSION_STATE_CHANNEL = "session_state_changes";
|
|
11728
12216
|
const RUNTIME_STATE_CHANNEL = "runtime_state_changes";
|
|
12217
|
+
/**
|
|
12218
|
+
* Chat-first workspace cross-process kick. Carries `<chatId>:<messageId>`.
|
|
12219
|
+
* Lets admin WS sockets translate every chat message (speaker AND watcher
|
|
12220
|
+
* audience) into a `chat:message` frame, without being coupled to the
|
|
12221
|
+
* inbox NOTIFY path that only reaches speakers.
|
|
12222
|
+
*/
|
|
12223
|
+
const CHAT_MESSAGE_CHANNEL = "chat_message_events";
|
|
11729
12224
|
function createNotifier(listenClient) {
|
|
11730
12225
|
const subscriptions = /* @__PURE__ */ new Map();
|
|
11731
12226
|
const configChangeHandlers = [];
|
|
11732
12227
|
const sessionStateChangeHandlers = [];
|
|
11733
12228
|
const runtimeStateChangeHandlers = [];
|
|
12229
|
+
const chatMessageHandlers = [];
|
|
11734
12230
|
let unlistenInboxFn = null;
|
|
11735
12231
|
let unlistenConfigFn = null;
|
|
11736
12232
|
let unlistenSessionStateFn = null;
|
|
11737
12233
|
let unlistenRuntimeStateFn = null;
|
|
12234
|
+
let unlistenChatMessageFn = null;
|
|
11738
12235
|
function handleNotification(payload) {
|
|
11739
12236
|
const sepIdx = payload.indexOf(":");
|
|
11740
12237
|
if (sepIdx === -1) return;
|
|
@@ -11789,6 +12286,11 @@ function createNotifier(listenClient) {
|
|
|
11789
12286
|
await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
|
|
11790
12287
|
} catch {}
|
|
11791
12288
|
},
|
|
12289
|
+
async notifyChatMessage(chatId, messageId) {
|
|
12290
|
+
try {
|
|
12291
|
+
await listenClient`SELECT pg_notify(${CHAT_MESSAGE_CHANNEL}, ${`${chatId}:${messageId}`})`;
|
|
12292
|
+
} catch {}
|
|
12293
|
+
},
|
|
11792
12294
|
async pushFrameToInbox(inboxId, frame) {
|
|
11793
12295
|
const map = subscriptions.get(inboxId);
|
|
11794
12296
|
if (!map) return 0;
|
|
@@ -11815,6 +12317,9 @@ function createNotifier(listenClient) {
|
|
|
11815
12317
|
onRuntimeStateChange(handler) {
|
|
11816
12318
|
runtimeStateChangeHandlers.push(handler);
|
|
11817
12319
|
},
|
|
12320
|
+
onChatMessage(handler) {
|
|
12321
|
+
chatMessageHandlers.push(handler);
|
|
12322
|
+
},
|
|
11818
12323
|
async start() {
|
|
11819
12324
|
unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
|
|
11820
12325
|
if (payload) handleNotification(payload);
|
|
@@ -11857,6 +12362,19 @@ function createNotifier(listenClient) {
|
|
|
11857
12362
|
}
|
|
11858
12363
|
}
|
|
11859
12364
|
})).unlisten;
|
|
12365
|
+
unlistenChatMessageFn = (await listenClient.listen(CHAT_MESSAGE_CHANNEL, (payload) => {
|
|
12366
|
+
if (!payload) return;
|
|
12367
|
+
const sep = payload.indexOf(":");
|
|
12368
|
+
if (sep <= 0) return;
|
|
12369
|
+
const chatId = payload.slice(0, sep);
|
|
12370
|
+
const messageId = payload.slice(sep + 1);
|
|
12371
|
+
for (const handler of chatMessageHandlers) try {
|
|
12372
|
+
handler({
|
|
12373
|
+
chatId,
|
|
12374
|
+
messageId
|
|
12375
|
+
});
|
|
12376
|
+
} catch {}
|
|
12377
|
+
})).unlisten;
|
|
11860
12378
|
},
|
|
11861
12379
|
async stop() {
|
|
11862
12380
|
if (unlistenInboxFn) {
|
|
@@ -11875,6 +12393,10 @@ function createNotifier(listenClient) {
|
|
|
11875
12393
|
await unlistenRuntimeStateFn();
|
|
11876
12394
|
unlistenRuntimeStateFn = null;
|
|
11877
12395
|
}
|
|
12396
|
+
if (unlistenChatMessageFn) {
|
|
12397
|
+
await unlistenChatMessageFn();
|
|
12398
|
+
unlistenChatMessageFn = null;
|
|
12399
|
+
}
|
|
11878
12400
|
}
|
|
11879
12401
|
};
|
|
11880
12402
|
}
|
|
@@ -12317,101 +12839,640 @@ async function collectTargetInboxes(db, chatId, inReplyTo) {
|
|
|
12317
12839
|
}
|
|
12318
12840
|
return [...set];
|
|
12319
12841
|
}
|
|
12320
|
-
|
|
12321
|
-
|
|
12322
|
-
|
|
12323
|
-
|
|
12324
|
-
|
|
12325
|
-
|
|
12326
|
-
|
|
12327
|
-
|
|
12328
|
-
|
|
12329
|
-
|
|
12330
|
-
|
|
12331
|
-
|
|
12332
|
-
|
|
12333
|
-
|
|
12334
|
-
|
|
12335
|
-
|
|
12336
|
-
|
|
12337
|
-
|
|
12338
|
-
|
|
12339
|
-
|
|
12340
|
-
|
|
12341
|
-
|
|
12342
|
-
|
|
12343
|
-
|
|
12344
|
-
|
|
12345
|
-
|
|
12346
|
-
|
|
12347
|
-
|
|
12348
|
-
|
|
12349
|
-
|
|
12350
|
-
|
|
12351
|
-
|
|
12352
|
-
|
|
12353
|
-
|
|
12354
|
-
|
|
12355
|
-
|
|
12356
|
-
|
|
12357
|
-
|
|
12358
|
-
|
|
12359
|
-
|
|
12360
|
-
|
|
12361
|
-
|
|
12362
|
-
|
|
12363
|
-
|
|
12364
|
-
|
|
12365
|
-
|
|
12366
|
-
|
|
12367
|
-
|
|
12368
|
-
|
|
12369
|
-
|
|
12370
|
-
|
|
12371
|
-
|
|
12372
|
-
|
|
12373
|
-
|
|
12374
|
-
|
|
12375
|
-
|
|
12376
|
-
|
|
12377
|
-
|
|
12378
|
-
|
|
12379
|
-
|
|
12380
|
-
|
|
12381
|
-
|
|
12382
|
-
|
|
12383
|
-
|
|
12384
|
-
|
|
12385
|
-
|
|
12386
|
-
|
|
12387
|
-
|
|
12388
|
-
|
|
12389
|
-
|
|
12390
|
-
|
|
12391
|
-
|
|
12392
|
-
|
|
12393
|
-
|
|
12394
|
-
|
|
12395
|
-
|
|
12396
|
-
|
|
12397
|
-
|
|
12398
|
-
|
|
12399
|
-
|
|
12400
|
-
|
|
12401
|
-
|
|
12402
|
-
|
|
12403
|
-
|
|
12404
|
-
|
|
12405
|
-
|
|
12406
|
-
|
|
12407
|
-
|
|
12408
|
-
|
|
12409
|
-
|
|
12410
|
-
|
|
12411
|
-
|
|
12412
|
-
|
|
12413
|
-
|
|
12414
|
-
|
|
12842
|
+
/** Extract a plain-text summary from a message's JSONB content field.
|
|
12843
|
+
* Used as the auto-title fallback in chat list rendering — see
|
|
12844
|
+
* `me-chat.ts:resolveChatTitle` and `admin/chats.ts:getChat`.
|
|
12845
|
+
*
|
|
12846
|
+
* - `@<name>` mention tokens are stripped before truncation: in the
|
|
12847
|
+
* chat-first model they're routing/audience metadata, not part of
|
|
12848
|
+
* the user's intent. Leaving them in produces noisy titles like
|
|
12849
|
+
* "@hub-agent-01 帮我重构这个文件" or "你好 @hub-agent-02 看看".
|
|
12850
|
+
* - Whitespace runs (including those left behind by mention removal)
|
|
12851
|
+
* collapse to single spaces.
|
|
12852
|
+
* - If the cleaned text is empty (e.g., a message that's only
|
|
12853
|
+
* `@hub-agent-01`), returns null so the caller falls through to
|
|
12854
|
+
* the participant-join fallback.
|
|
12855
|
+
* - Slicing is code-point-aware (`Array.from + join`) so emoji /
|
|
12856
|
+
* surrogate pairs aren't split into garbled half-characters. */
|
|
12857
|
+
function extractSummary(content, maxLen = 50) {
|
|
12858
|
+
let text = "";
|
|
12859
|
+
if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
|
|
12860
|
+
else if (typeof content === "string") text = content;
|
|
12861
|
+
if (!text) return null;
|
|
12862
|
+
const cleaned = stripCode(text).replace(MENTION_REGEX, "").replace(/\s+/g, " ").trim();
|
|
12863
|
+
if (!cleaned) return null;
|
|
12864
|
+
return Array.from(cleaned).slice(0, maxLen).join("");
|
|
12865
|
+
}
|
|
12866
|
+
/** List sessions for a specific agent, with optional state filters. */
|
|
12867
|
+
async function listAgentSessions(db, agentId, filters) {
|
|
12868
|
+
const conditions = [eq(agentChatSessions.agentId, agentId)];
|
|
12869
|
+
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
12870
|
+
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
12871
|
+
const rows = await db.select({
|
|
12872
|
+
agentId: agentChatSessions.agentId,
|
|
12873
|
+
chatId: agentChatSessions.chatId,
|
|
12874
|
+
state: agentChatSessions.state,
|
|
12875
|
+
updatedAt: agentChatSessions.updatedAt,
|
|
12876
|
+
chatCreatedAt: chats.createdAt,
|
|
12877
|
+
chatTopic: chats.topic
|
|
12878
|
+
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
|
|
12879
|
+
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
12880
|
+
const agentRuntimeState = presence?.runtimeState ?? null;
|
|
12881
|
+
if (filters?.runtimeState && agentRuntimeState !== filters.runtimeState) return [];
|
|
12882
|
+
const chatIds = rows.map((r) => r.chatId);
|
|
12883
|
+
const messageCounts = chatIds.length > 0 ? await db.select({
|
|
12884
|
+
chatId: inboxEntries.chatId,
|
|
12885
|
+
count: sql`count(*)::int`
|
|
12886
|
+
}).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
|
|
12887
|
+
const countMap = new Map(messageCounts.map((r) => [r.chatId, r.count]));
|
|
12888
|
+
const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
12889
|
+
chatId: messages.chatId,
|
|
12890
|
+
content: messages.content
|
|
12891
|
+
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
12892
|
+
const summaryMap = /* @__PURE__ */ new Map();
|
|
12893
|
+
for (const row of firstMessages) {
|
|
12894
|
+
const summary = extractSummary(row.content);
|
|
12895
|
+
if (summary) summaryMap.set(row.chatId, summary);
|
|
12896
|
+
}
|
|
12897
|
+
return rows.map((r) => ({
|
|
12898
|
+
agentId: r.agentId,
|
|
12899
|
+
chatId: r.chatId,
|
|
12900
|
+
state: r.state,
|
|
12901
|
+
runtimeState: agentRuntimeState,
|
|
12902
|
+
startedAt: r.chatCreatedAt.toISOString(),
|
|
12903
|
+
lastActivityAt: r.updatedAt.toISOString(),
|
|
12904
|
+
messageCount: countMap.get(r.chatId) ?? 0,
|
|
12905
|
+
summary: summaryMap.get(r.chatId) ?? null,
|
|
12906
|
+
topic: r.chatTopic ?? null
|
|
12907
|
+
}));
|
|
12908
|
+
}
|
|
12909
|
+
/** Get a single session's detail. */
|
|
12910
|
+
async function getSession(db, agentId, chatId) {
|
|
12911
|
+
const [row] = await db.select({
|
|
12912
|
+
agentId: agentChatSessions.agentId,
|
|
12913
|
+
chatId: agentChatSessions.chatId,
|
|
12914
|
+
state: agentChatSessions.state,
|
|
12915
|
+
updatedAt: agentChatSessions.updatedAt,
|
|
12916
|
+
chatCreatedAt: chats.createdAt,
|
|
12917
|
+
chatTopic: chats.topic
|
|
12918
|
+
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
|
|
12919
|
+
if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
12920
|
+
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
12921
|
+
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)));
|
|
12922
|
+
const firstMsg = (await db.execute(sql`SELECT content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at ASC LIMIT 1`))[0];
|
|
12923
|
+
const summary = firstMsg ? extractSummary(firstMsg.content) : null;
|
|
12924
|
+
return {
|
|
12925
|
+
agentId: row.agentId,
|
|
12926
|
+
chatId: row.chatId,
|
|
12927
|
+
state: row.state,
|
|
12928
|
+
runtimeState: presence?.runtimeState ?? null,
|
|
12929
|
+
startedAt: row.chatCreatedAt.toISOString(),
|
|
12930
|
+
lastActivityAt: row.updatedAt.toISOString(),
|
|
12931
|
+
messageCount: countRow?.count ?? 0,
|
|
12932
|
+
summary,
|
|
12933
|
+
topic: row.chatTopic ?? null
|
|
12934
|
+
};
|
|
12935
|
+
}
|
|
12936
|
+
/** List all sessions across all agents, with pagination. Scoped to organization. */
|
|
12937
|
+
async function listAllSessions(db, limit, cursor, filters) {
|
|
12938
|
+
const conditions = [];
|
|
12939
|
+
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
12940
|
+
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
12941
|
+
if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
|
|
12942
|
+
if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
|
|
12943
|
+
if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
|
|
12944
|
+
const rows = await db.select({
|
|
12945
|
+
agentId: agentChatSessions.agentId,
|
|
12946
|
+
chatId: agentChatSessions.chatId,
|
|
12947
|
+
state: agentChatSessions.state,
|
|
12948
|
+
updatedAt: agentChatSessions.updatedAt,
|
|
12949
|
+
chatCreatedAt: chats.createdAt,
|
|
12950
|
+
chatTopic: chats.topic
|
|
12951
|
+
}).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);
|
|
12952
|
+
const hasMore = rows.length > limit;
|
|
12953
|
+
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
12954
|
+
const agentIds = [...new Set(items.map((r) => r.agentId))];
|
|
12955
|
+
const presenceRows = agentIds.length > 0 ? await db.select({
|
|
12956
|
+
agentId: agentPresence.agentId,
|
|
12957
|
+
runtimeState: agentPresence.runtimeState
|
|
12958
|
+
}).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
|
|
12959
|
+
const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
|
|
12960
|
+
const chatIds = [...new Set(items.map((r) => r.chatId))];
|
|
12961
|
+
const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
12962
|
+
chatId: messages.chatId,
|
|
12963
|
+
content: messages.content
|
|
12964
|
+
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
12965
|
+
const summaryMap = /* @__PURE__ */ new Map();
|
|
12966
|
+
for (const row of firstMessages) {
|
|
12967
|
+
const summary = extractSummary(row.content);
|
|
12968
|
+
if (summary) summaryMap.set(row.chatId, summary);
|
|
12969
|
+
}
|
|
12970
|
+
const last = items[items.length - 1];
|
|
12971
|
+
const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
|
|
12972
|
+
return {
|
|
12973
|
+
items: items.map((r) => ({
|
|
12974
|
+
agentId: r.agentId,
|
|
12975
|
+
chatId: r.chatId,
|
|
12976
|
+
state: r.state,
|
|
12977
|
+
runtimeState: runtimeMap.get(r.agentId) ?? null,
|
|
12978
|
+
startedAt: r.chatCreatedAt.toISOString(),
|
|
12979
|
+
lastActivityAt: r.updatedAt.toISOString(),
|
|
12980
|
+
messageCount: 0,
|
|
12981
|
+
summary: summaryMap.get(r.chatId) ?? null,
|
|
12982
|
+
topic: r.chatTopic ?? null
|
|
12983
|
+
})),
|
|
12984
|
+
nextCursor
|
|
12985
|
+
};
|
|
12986
|
+
}
|
|
12987
|
+
/** Commit `active → suspended`. No-op on suspended/evicted. Throws if row is missing. */
|
|
12988
|
+
async function suspendSession(db, agentId, chatId, organizationId, notifier) {
|
|
12989
|
+
return transitionSessionState(db, agentId, chatId, "suspended", ["active"], organizationId, notifier);
|
|
12990
|
+
}
|
|
12991
|
+
/** Commit `suspended → evicted` (terminal — listings hide it, revival defense blocks resurrection). */
|
|
12992
|
+
async function archiveSession(db, agentId, chatId, organizationId, notifier) {
|
|
12993
|
+
return transitionSessionState(db, agentId, chatId, "evicted", ["suspended"], organizationId, notifier);
|
|
12994
|
+
}
|
|
12995
|
+
async function transitionSessionState(db, agentId, chatId, target, from, organizationId, notifier) {
|
|
12996
|
+
const now = /* @__PURE__ */ new Date();
|
|
12997
|
+
let finalState = null;
|
|
12998
|
+
let transitioned = false;
|
|
12999
|
+
await db.transaction(async (tx) => {
|
|
13000
|
+
const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
|
|
13001
|
+
if (!existing) return;
|
|
13002
|
+
const current = existing.state;
|
|
13003
|
+
finalState = current;
|
|
13004
|
+
if (!from.includes(current)) return;
|
|
13005
|
+
await tx.update(agentChatSessions).set({
|
|
13006
|
+
state: target,
|
|
13007
|
+
updatedAt: now
|
|
13008
|
+
}).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId)));
|
|
13009
|
+
const [counts] = await tx.select({
|
|
13010
|
+
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
13011
|
+
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
13012
|
+
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
13013
|
+
await tx.update(agentPresence).set({
|
|
13014
|
+
activeSessions: counts?.active ?? 0,
|
|
13015
|
+
totalSessions: counts?.total ?? 0,
|
|
13016
|
+
lastSeenAt: now
|
|
13017
|
+
}).where(eq(agentPresence.agentId, agentId));
|
|
13018
|
+
finalState = target;
|
|
13019
|
+
transitioned = true;
|
|
13020
|
+
});
|
|
13021
|
+
if (finalState === null) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
13022
|
+
if (transitioned && notifier) notifier.notifySessionStateChange(agentId, chatId, target, organizationId).catch(() => {});
|
|
13023
|
+
return {
|
|
13024
|
+
state: finalState,
|
|
13025
|
+
transitioned
|
|
13026
|
+
};
|
|
13027
|
+
}
|
|
13028
|
+
/**
|
|
13029
|
+
* Filter sessions to only those where the given agent is also a participant in the chat.
|
|
13030
|
+
* Used when a non-manager views sessions of an org-visible agent — they should only see
|
|
13031
|
+
* sessions for chats they participate in.
|
|
13032
|
+
*/
|
|
13033
|
+
async function filterSessionsByParticipant(db, sessions, participantAgentId) {
|
|
13034
|
+
if (sessions.length === 0) return [];
|
|
13035
|
+
const chatIds = sessions.map((s) => s.chatId);
|
|
13036
|
+
const participantRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(inArray(chatParticipants.chatId, chatIds), eq(chatParticipants.agentId, participantAgentId)));
|
|
13037
|
+
const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
|
|
13038
|
+
return sessions.filter((s) => allowedChatIds.has(s.chatId));
|
|
13039
|
+
}
|
|
13040
|
+
/**
|
|
13041
|
+
* Member-facing chat service backing `/me/chats*` endpoints (chat-first
|
|
13042
|
+
* workspace).
|
|
13043
|
+
*
|
|
13044
|
+
* Responsibilities:
|
|
13045
|
+
* - Cursor-paginated conversation list across participant + watcher rows
|
|
13046
|
+
* for the caller's human agent.
|
|
13047
|
+
* - Create a new chat (no dedupe, runs `recomputeChatWatchers` after).
|
|
13048
|
+
* - Add participants (idempotent, runs `recomputeChatWatchers` after).
|
|
13049
|
+
* - Mark-read (touches whichever of the two tables holds the user's row).
|
|
13050
|
+
* - Join → state-carry watcher → speaker (delegates to `watcher.ts`).
|
|
13051
|
+
* - Leave → state-carry speaker → watcher (delegates to `watcher.ts`).
|
|
13052
|
+
*
|
|
13053
|
+
* See docs/chat-first-workspace-product-design.md "API Contract" + "Data
|
|
13054
|
+
* Model".
|
|
13055
|
+
*/
|
|
13056
|
+
function encodeCursor(lastMessageAt, chatId) {
|
|
13057
|
+
const payload = `${lastMessageAt ? lastMessageAt.toISOString() : ""}|${chatId}`;
|
|
13058
|
+
return Buffer.from(payload, "utf8").toString("base64url");
|
|
13059
|
+
}
|
|
13060
|
+
function decodeCursor(cursor) {
|
|
13061
|
+
try {
|
|
13062
|
+
const decoded = Buffer.from(cursor, "base64url").toString("utf8");
|
|
13063
|
+
const sep = decoded.indexOf("|");
|
|
13064
|
+
if (sep < 0) return null;
|
|
13065
|
+
const tsPart = decoded.slice(0, sep);
|
|
13066
|
+
const chatId = decoded.slice(sep + 1);
|
|
13067
|
+
if (!chatId) return null;
|
|
13068
|
+
const lastMessageAt = tsPart.length > 0 ? new Date(tsPart) : null;
|
|
13069
|
+
if (lastMessageAt && Number.isNaN(lastMessageAt.getTime())) return null;
|
|
13070
|
+
return {
|
|
13071
|
+
lastMessageAt,
|
|
13072
|
+
chatId
|
|
13073
|
+
};
|
|
13074
|
+
} catch {
|
|
13075
|
+
return null;
|
|
13076
|
+
}
|
|
13077
|
+
}
|
|
13078
|
+
/**
|
|
13079
|
+
* GET /me/chats — cursor-paginated conversation list.
|
|
13080
|
+
*
|
|
13081
|
+
* SQL strategy:
|
|
13082
|
+
* - One query that UNIONs participant rows and subscription rows for the
|
|
13083
|
+
* caller's human agent, joined to chats. The UNION+coalesce keeps both
|
|
13084
|
+
* `unread_mention_count` and `membership_kind` per row.
|
|
13085
|
+
* - Filter `parent_chat_id IS NULL` (threads are excluded in v1).
|
|
13086
|
+
* - Sort `(last_message_at DESC NULLS LAST, chat_id DESC)`.
|
|
13087
|
+
* - Cursor narrows the result to rows STRICTLY before `(cursor.ts, cursor.id)`.
|
|
13088
|
+
* - Followed by a small participant-list lookup for the page only.
|
|
13089
|
+
*/
|
|
13090
|
+
async function listMeChats(db, humanAgentId, query) {
|
|
13091
|
+
const limit = query.limit;
|
|
13092
|
+
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
|
13093
|
+
if (query.cursor && !cursor) throw new BadRequestError("Invalid cursor");
|
|
13094
|
+
const filterUnreadOnly = query.filter === "unread";
|
|
13095
|
+
const filterWatchingOnly = query.filter === "watching";
|
|
13096
|
+
const cursorTsIso = cursor?.lastMessageAt ? cursor.lastMessageAt.toISOString() : null;
|
|
13097
|
+
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
|
|
13098
|
+
OR c.last_message_at < ${cursorTsIso}::timestamptz
|
|
13099
|
+
OR (c.last_message_at = ${cursorTsIso}::timestamptz AND c.id < ${cursor.chatId}))`;
|
|
13100
|
+
const rawRows = await db.execute(sql`
|
|
13101
|
+
WITH membership AS (
|
|
13102
|
+
SELECT chat_id, 'participant'::text AS membership_kind, unread_mention_count
|
|
13103
|
+
FROM chat_participants
|
|
13104
|
+
WHERE agent_id = ${humanAgentId}
|
|
13105
|
+
UNION ALL
|
|
13106
|
+
SELECT chat_id, 'watching'::text AS membership_kind, unread_mention_count
|
|
13107
|
+
FROM chat_subscriptions
|
|
13108
|
+
WHERE agent_id = ${humanAgentId}
|
|
13109
|
+
),
|
|
13110
|
+
/* Resolve duplicates (should not happen post-invariant-1, but cheap) by
|
|
13111
|
+
preferring the participant row. */
|
|
13112
|
+
deduped AS (
|
|
13113
|
+
SELECT DISTINCT ON (chat_id)
|
|
13114
|
+
chat_id, membership_kind, unread_mention_count
|
|
13115
|
+
FROM membership
|
|
13116
|
+
ORDER BY chat_id, CASE WHEN membership_kind = 'participant' THEN 0 ELSE 1 END
|
|
13117
|
+
)
|
|
13118
|
+
SELECT
|
|
13119
|
+
c.id AS chat_id,
|
|
13120
|
+
c.type AS type,
|
|
13121
|
+
c.topic AS topic,
|
|
13122
|
+
c.parent_chat_id AS parent_chat_id,
|
|
13123
|
+
c.last_message_at AS last_message_at,
|
|
13124
|
+
c.last_message_preview AS last_message_preview,
|
|
13125
|
+
(SELECT count(*) FROM chat_participants WHERE chat_id = c.id) AS participant_count,
|
|
13126
|
+
d.membership_kind AS membership_kind,
|
|
13127
|
+
d.unread_mention_count AS unread_mention_count
|
|
13128
|
+
FROM chats c
|
|
13129
|
+
JOIN deduped d ON d.chat_id = c.id
|
|
13130
|
+
WHERE c.parent_chat_id IS NULL
|
|
13131
|
+
/* Filter: unread / watching */
|
|
13132
|
+
AND (${!filterUnreadOnly}::bool OR d.unread_mention_count > 0)
|
|
13133
|
+
AND (${!filterWatchingOnly}::bool OR d.membership_kind = 'watching')
|
|
13134
|
+
AND ${cursorPredicate}
|
|
13135
|
+
ORDER BY c.last_message_at DESC NULLS LAST, c.id DESC
|
|
13136
|
+
LIMIT ${limit + 1}
|
|
13137
|
+
`);
|
|
13138
|
+
const toDate = (v) => {
|
|
13139
|
+
if (v === null) return null;
|
|
13140
|
+
return v instanceof Date ? v : new Date(v);
|
|
13141
|
+
};
|
|
13142
|
+
const hasMore = rawRows.length > limit;
|
|
13143
|
+
const pageRaw = hasMore ? rawRows.slice(0, limit) : rawRows;
|
|
13144
|
+
const last = pageRaw[pageRaw.length - 1];
|
|
13145
|
+
const nextCursor = hasMore && last ? encodeCursor(toDate(last.last_message_at), last.chat_id) : null;
|
|
13146
|
+
if (pageRaw.length === 0) return {
|
|
13147
|
+
rows: [],
|
|
13148
|
+
nextCursor: null
|
|
13149
|
+
};
|
|
13150
|
+
const chatIds = pageRaw.map((r) => r.chat_id);
|
|
13151
|
+
const participantRows = await db.select({
|
|
13152
|
+
chatId: chatParticipants.chatId,
|
|
13153
|
+
agentId: chatParticipants.agentId,
|
|
13154
|
+
displayName: agents.displayName,
|
|
13155
|
+
type: agents.type
|
|
13156
|
+
}).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(inArray(chatParticipants.chatId, chatIds));
|
|
13157
|
+
const participantsByChat = /* @__PURE__ */ new Map();
|
|
13158
|
+
for (const p of participantRows) {
|
|
13159
|
+
const list = participantsByChat.get(p.chatId) ?? [];
|
|
13160
|
+
list.push({
|
|
13161
|
+
agentId: p.agentId,
|
|
13162
|
+
displayName: p.displayName,
|
|
13163
|
+
type: p.type
|
|
13164
|
+
});
|
|
13165
|
+
participantsByChat.set(p.chatId, list);
|
|
13166
|
+
}
|
|
13167
|
+
const firstMessageRows = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
13168
|
+
chatId: messages.chatId,
|
|
13169
|
+
content: messages.content
|
|
13170
|
+
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
13171
|
+
const firstMessageSummary = /* @__PURE__ */ new Map();
|
|
13172
|
+
for (const row of firstMessageRows) {
|
|
13173
|
+
const s = extractSummary(row.content);
|
|
13174
|
+
if (s) firstMessageSummary.set(row.chatId, s);
|
|
13175
|
+
}
|
|
13176
|
+
return {
|
|
13177
|
+
rows: pageRaw.map((r) => {
|
|
13178
|
+
const participants = participantsByChat.get(r.chat_id) ?? [];
|
|
13179
|
+
const title = resolveChatTitle(r.topic, firstMessageSummary.get(r.chat_id) ?? null, participants, humanAgentId);
|
|
13180
|
+
return {
|
|
13181
|
+
chatId: r.chat_id,
|
|
13182
|
+
type: r.type,
|
|
13183
|
+
membershipKind: r.membership_kind,
|
|
13184
|
+
title,
|
|
13185
|
+
topic: r.topic,
|
|
13186
|
+
participants,
|
|
13187
|
+
participantCount: Number(r.participant_count),
|
|
13188
|
+
lastMessageAt: toDate(r.last_message_at)?.toISOString() ?? null,
|
|
13189
|
+
lastMessagePreview: r.last_message_preview,
|
|
13190
|
+
unreadMentionCount: r.unread_mention_count,
|
|
13191
|
+
canReply: r.membership_kind === "participant",
|
|
13192
|
+
taskId: null,
|
|
13193
|
+
taskStatus: null
|
|
13194
|
+
};
|
|
13195
|
+
}),
|
|
13196
|
+
nextCursor
|
|
13197
|
+
};
|
|
13198
|
+
}
|
|
13199
|
+
/**
|
|
13200
|
+
* Title resolution priority:
|
|
13201
|
+
*
|
|
13202
|
+
* 1. `chat.topic` (manual, set via `PATCH /admin/chats/:id`)
|
|
13203
|
+
* 2. First message summary (auto, ≤ 50 chars from `extractSummary`)
|
|
13204
|
+
* 3. Participant join (fallback when chat has no messages yet)
|
|
13205
|
+
*
|
|
13206
|
+
* The first-message fallback is the chat-first equivalent of how
|
|
13207
|
+
* ChatGPT / Claude.ai name conversations from the user's opening
|
|
13208
|
+
* prompt — gives same-agent multi-chats distinct identities and
|
|
13209
|
+
* removes the "title duplicates participants chip row" anti-pattern.
|
|
13210
|
+
*/
|
|
13211
|
+
function resolveChatTitle(topic, firstMessageSummary, participants, selfAgentId) {
|
|
13212
|
+
if (topic && topic.length > 0) return topic;
|
|
13213
|
+
if (firstMessageSummary && firstMessageSummary.length > 0) return firstMessageSummary;
|
|
13214
|
+
const others = participants.filter((p) => p.agentId !== selfAgentId);
|
|
13215
|
+
if (others.length === 0) return "Empty chat";
|
|
13216
|
+
if (others.length <= 3) return others.map((p) => p.displayName).join(", ");
|
|
13217
|
+
return `${others[0]?.displayName}, ${others[1]?.displayName} +${others.length - 2}`;
|
|
13218
|
+
}
|
|
13219
|
+
async function createMeChat(db, humanAgentId, organizationId, body) {
|
|
13220
|
+
const distinctIds = [...new Set(body.participantIds)].filter((id) => id !== humanAgentId);
|
|
13221
|
+
if (distinctIds.length === 0) throw new BadRequestError("At least one non-self participant required");
|
|
13222
|
+
const allIds = [humanAgentId, ...distinctIds];
|
|
13223
|
+
const found = await db.select({
|
|
13224
|
+
uuid: agents.uuid,
|
|
13225
|
+
organizationId: agents.organizationId,
|
|
13226
|
+
type: agents.type
|
|
13227
|
+
}).from(agents).where(inArray(agents.uuid, allIds));
|
|
13228
|
+
if (found.length !== allIds.length) {
|
|
13229
|
+
const foundSet = new Set(found.map((a) => a.uuid));
|
|
13230
|
+
throw new BadRequestError(`Agents not found: ${allIds.filter((id) => !foundSet.has(id)).join(", ")}`);
|
|
13231
|
+
}
|
|
13232
|
+
const crossOrg = found.filter((a) => a.organizationId !== organizationId);
|
|
13233
|
+
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.uuid).join(", ")}`);
|
|
13234
|
+
const chatType = distinctIds.length === 1 ? "direct" : "group";
|
|
13235
|
+
const isDirectAgentOnly = chatType === "direct" && found.every((a) => a.type !== "human");
|
|
13236
|
+
const chatId = randomUUID();
|
|
13237
|
+
const topic = body.topic ?? null;
|
|
13238
|
+
await db.transaction(async (tx) => {
|
|
13239
|
+
await tx.insert(chats).values({
|
|
13240
|
+
id: chatId,
|
|
13241
|
+
organizationId,
|
|
13242
|
+
type: chatType,
|
|
13243
|
+
topic
|
|
13244
|
+
});
|
|
13245
|
+
await tx.insert(chatParticipants).values(allIds.map((agentId) => ({
|
|
13246
|
+
chatId,
|
|
13247
|
+
agentId,
|
|
13248
|
+
role: agentId === humanAgentId ? "owner" : "member",
|
|
13249
|
+
...isDirectAgentOnly ? { mode: "mention_only" } : {}
|
|
13250
|
+
})));
|
|
13251
|
+
await recomputeChatWatchers(tx, chatId);
|
|
13252
|
+
});
|
|
13253
|
+
invalidateChatAudience(chatId);
|
|
13254
|
+
return { chatId };
|
|
13255
|
+
}
|
|
13256
|
+
async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrganizationId, body) {
|
|
13257
|
+
const distinct = [...new Set(body.participantIds)];
|
|
13258
|
+
if (distinct.length === 0) throw new BadRequestError("At least one participant required");
|
|
13259
|
+
const [chat] = await db.select({
|
|
13260
|
+
id: chats.id,
|
|
13261
|
+
organizationId: chats.organizationId,
|
|
13262
|
+
type: chats.type
|
|
13263
|
+
}).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
13264
|
+
if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
13265
|
+
if (chat.organizationId !== callerOrganizationId) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
13266
|
+
const [callerRow] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, callerHumanAgentId))).limit(1);
|
|
13267
|
+
if (!callerRow) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
13268
|
+
const found = await db.select({
|
|
13269
|
+
uuid: agents.uuid,
|
|
13270
|
+
organizationId: agents.organizationId,
|
|
13271
|
+
type: agents.type
|
|
13272
|
+
}).from(agents).where(inArray(agents.uuid, distinct));
|
|
13273
|
+
if (found.length !== distinct.length) {
|
|
13274
|
+
const foundSet = new Set(found.map((a) => a.uuid));
|
|
13275
|
+
throw new BadRequestError(`Agents not found: ${distinct.filter((id) => !foundSet.has(id)).join(", ")}`);
|
|
13276
|
+
}
|
|
13277
|
+
const crossOrg = found.filter((a) => a.organizationId !== chat.organizationId);
|
|
13278
|
+
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization participant rejected: ${crossOrg.map((a) => a.uuid).join(", ")}`);
|
|
13279
|
+
await db.transaction(async (tx) => {
|
|
13280
|
+
const existing = await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
13281
|
+
const existingSet = new Set(existing.map((e) => e.agentId));
|
|
13282
|
+
const toInsert = distinct.filter((id) => !existingSet.has(id));
|
|
13283
|
+
if (toInsert.length === 0) {
|
|
13284
|
+
await recomputeChatWatchers(tx, chatId);
|
|
13285
|
+
return;
|
|
13286
|
+
}
|
|
13287
|
+
const isUpgradingToGroup = existing.length + toInsert.length >= 3 && chat.type === "direct";
|
|
13288
|
+
const isAlreadyGroup = chat.type === "group";
|
|
13289
|
+
const isGroupAfter = isUpgradingToGroup || isAlreadyGroup;
|
|
13290
|
+
if (isUpgradingToGroup) {
|
|
13291
|
+
await tx.update(chats).set({
|
|
13292
|
+
type: "group",
|
|
13293
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
13294
|
+
}).where(eq(chats.id, chatId));
|
|
13295
|
+
const nonHumanIds = (await tx.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existing.map((e) => e.agentId)), sql`${agents.type} <> 'human'`))).map((a) => a.uuid);
|
|
13296
|
+
if (nonHumanIds.length > 0) await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, nonHumanIds)));
|
|
13297
|
+
}
|
|
13298
|
+
const carriedRows = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, toInsert))).returning({
|
|
13299
|
+
agentId: chatSubscriptions.agentId,
|
|
13300
|
+
lastReadAt: chatSubscriptions.lastReadAt,
|
|
13301
|
+
unreadMentionCount: chatSubscriptions.unreadMentionCount
|
|
13302
|
+
});
|
|
13303
|
+
const carriedByAgent = new Map(carriedRows.map((r) => [r.agentId, r]));
|
|
13304
|
+
const typeByAgent = new Map(found.map((a) => [a.uuid, a.type]));
|
|
13305
|
+
await tx.insert(chatParticipants).values(toInsert.map((agentId) => {
|
|
13306
|
+
const agentType = typeByAgent.get(agentId);
|
|
13307
|
+
const mode = isGroupAfter && agentType !== "human" ? "mention_only" : "full";
|
|
13308
|
+
const carried = carriedByAgent.get(agentId);
|
|
13309
|
+
return {
|
|
13310
|
+
chatId,
|
|
13311
|
+
agentId,
|
|
13312
|
+
role: "member",
|
|
13313
|
+
mode,
|
|
13314
|
+
lastReadAt: carried?.lastReadAt ?? null,
|
|
13315
|
+
unreadMentionCount: carried?.unreadMentionCount ?? 0
|
|
13316
|
+
};
|
|
13317
|
+
})).onConflictDoNothing();
|
|
13318
|
+
await recomputeChatWatchers(tx, chatId);
|
|
13319
|
+
});
|
|
13320
|
+
invalidateChatAudience(chatId);
|
|
13321
|
+
}
|
|
13322
|
+
async function markMeChatRead(db, chatId, humanAgentId) {
|
|
13323
|
+
const now = /* @__PURE__ */ new Date();
|
|
13324
|
+
await db.update(chatParticipants).set({
|
|
13325
|
+
lastReadAt: now,
|
|
13326
|
+
unreadMentionCount: 0
|
|
13327
|
+
}).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId)));
|
|
13328
|
+
await db.update(chatSubscriptions).set({
|
|
13329
|
+
lastReadAt: now,
|
|
13330
|
+
unreadMentionCount: 0
|
|
13331
|
+
}).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId)));
|
|
13332
|
+
return {
|
|
13333
|
+
chatId,
|
|
13334
|
+
lastReadAt: now.toISOString(),
|
|
13335
|
+
unreadMentionCount: 0
|
|
13336
|
+
};
|
|
13337
|
+
}
|
|
13338
|
+
async function joinMeChat(db, chatId, humanAgentId) {
|
|
13339
|
+
ensureCanJoin(await resolveChatMembership(db, chatId, humanAgentId));
|
|
13340
|
+
await joinAsParticipant(db, chatId, humanAgentId);
|
|
13341
|
+
invalidateChatAudience(chatId);
|
|
13342
|
+
}
|
|
13343
|
+
async function leaveMeChat(db, chatId, humanAgentId) {
|
|
13344
|
+
const result = await leaveAsParticipant(db, chatId, humanAgentId);
|
|
13345
|
+
invalidateChatAudience(chatId);
|
|
13346
|
+
return result;
|
|
13347
|
+
}
|
|
13348
|
+
async function adminChatRoutes(app) {
|
|
13349
|
+
/** List all chats in org (admin-only, for audit). Members should use GET /mine. */
|
|
13350
|
+
app.get("/", { preHandler: requireAdminRoleHook() }, async (request) => {
|
|
13351
|
+
const query = paginationQuerySchema.parse(request.query);
|
|
13352
|
+
const rawQuery = request.query;
|
|
13353
|
+
const orgParam = rawQuery.organizationId ?? rawQuery.org;
|
|
13354
|
+
let orgId;
|
|
13355
|
+
if (orgParam) orgId = (await resolveOrganization(app.db, orgParam)).id;
|
|
13356
|
+
else orgId = await resolveDefaultOrgId(app.db);
|
|
13357
|
+
const conditions = [eq(chats.organizationId, orgId)];
|
|
13358
|
+
if (query.cursor) conditions.push(lt(chats.createdAt, new Date(query.cursor)));
|
|
13359
|
+
const where = and(...conditions);
|
|
13360
|
+
const rows = await app.db.select({
|
|
13361
|
+
id: chats.id,
|
|
13362
|
+
organizationId: chats.organizationId,
|
|
13363
|
+
type: chats.type,
|
|
13364
|
+
topic: chats.topic,
|
|
13365
|
+
lifecyclePolicy: chats.lifecyclePolicy,
|
|
13366
|
+
metadata: chats.metadata,
|
|
13367
|
+
createdAt: chats.createdAt,
|
|
13368
|
+
updatedAt: chats.updatedAt,
|
|
13369
|
+
participantCount: sql`(
|
|
13370
|
+
SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id}
|
|
13371
|
+
)`
|
|
13372
|
+
}).from(chats).where(where).orderBy(desc(chats.createdAt)).limit(query.limit + 1);
|
|
13373
|
+
const hasMore = rows.length > query.limit;
|
|
13374
|
+
const items = hasMore ? rows.slice(0, query.limit) : rows;
|
|
13375
|
+
const last = items[items.length - 1];
|
|
13376
|
+
const nextCursor = hasMore && last ? last.createdAt.toISOString() : null;
|
|
13377
|
+
return {
|
|
13378
|
+
items: items.map((c) => ({
|
|
13379
|
+
id: c.id,
|
|
13380
|
+
organizationId: c.organizationId,
|
|
13381
|
+
type: c.type,
|
|
13382
|
+
topic: c.topic,
|
|
13383
|
+
lifecyclePolicy: c.lifecyclePolicy,
|
|
13384
|
+
metadata: c.metadata,
|
|
13385
|
+
participantCount: c.participantCount,
|
|
13386
|
+
createdAt: c.createdAt.toISOString(),
|
|
13387
|
+
updatedAt: c.updatedAt.toISOString()
|
|
13388
|
+
})),
|
|
13389
|
+
nextCursor
|
|
13390
|
+
};
|
|
13391
|
+
});
|
|
13392
|
+
/** Get chat detail with participants (requires participation or supervision).
|
|
13393
|
+
*
|
|
13394
|
+
* Response includes a server-resolved `title` and `firstMessagePreview`
|
|
13395
|
+
* so the chat-view header can mirror the conversation list's title
|
|
13396
|
+
* fallback chain (`topic > first message summary > participant join`)
|
|
13397
|
+
* without re-implementing it client-side. The `firstMessagePreview`
|
|
13398
|
+
* is exposed alongside the resolved title so the client can also use
|
|
13399
|
+
* it for tooltips / debugging — the resolved `title` should be the
|
|
13400
|
+
* default render target. */
|
|
13401
|
+
app.get("/:chatId", async (request) => {
|
|
13402
|
+
const { chatId } = request.params;
|
|
13403
|
+
const scope = memberScope(request);
|
|
13404
|
+
await assertChatAccess(app.db, scope, chatId);
|
|
13405
|
+
const [chat] = await app.db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
13406
|
+
if (!chat) throw new Error("Unexpected: chat missing after access check");
|
|
13407
|
+
const participants = await app.db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
13408
|
+
const firstMsgRows = await app.db.execute(sql`
|
|
13409
|
+
SELECT content FROM messages
|
|
13410
|
+
WHERE chat_id = ${chatId}
|
|
13411
|
+
ORDER BY created_at ASC
|
|
13412
|
+
LIMIT 1
|
|
13413
|
+
`);
|
|
13414
|
+
const firstMessagePreview = firstMsgRows[0] ? extractSummary(firstMsgRows[0].content) : null;
|
|
13415
|
+
const participantAgentIds = participants.map((p) => p.agentId);
|
|
13416
|
+
const agentRows = participantAgentIds.length > 0 ? await app.db.select({
|
|
13417
|
+
agentId: agents.uuid,
|
|
13418
|
+
displayName: agents.displayName,
|
|
13419
|
+
type: agents.type
|
|
13420
|
+
}).from(agents).where(inArray(agents.uuid, participantAgentIds)) : [];
|
|
13421
|
+
const agentMeta = new Map(agentRows.map((a) => [a.agentId, a]));
|
|
13422
|
+
const participantsForTitle = participants.map((p) => {
|
|
13423
|
+
const meta = agentMeta.get(p.agentId);
|
|
13424
|
+
return {
|
|
13425
|
+
agentId: p.agentId,
|
|
13426
|
+
displayName: meta?.displayName ?? p.agentId,
|
|
13427
|
+
type: meta?.type ?? "unknown"
|
|
13428
|
+
};
|
|
13429
|
+
});
|
|
13430
|
+
const title = resolveChatTitle(chat.topic, firstMessagePreview, participantsForTitle, scope.humanAgentId);
|
|
13431
|
+
return {
|
|
13432
|
+
...chat,
|
|
13433
|
+
title,
|
|
13434
|
+
firstMessagePreview,
|
|
13435
|
+
createdAt: chat.createdAt.toISOString(),
|
|
13436
|
+
updatedAt: chat.updatedAt.toISOString(),
|
|
13437
|
+
participants: participants.map((p) => ({
|
|
13438
|
+
agentId: p.agentId,
|
|
13439
|
+
role: p.role,
|
|
13440
|
+
mode: p.mode,
|
|
13441
|
+
joinedAt: p.joinedAt.toISOString()
|
|
13442
|
+
}))
|
|
13443
|
+
};
|
|
13444
|
+
});
|
|
13445
|
+
/** Rename (or clear) a chat's topic. Requires participation or supervision — same gate as reading it. */
|
|
13446
|
+
app.patch("/:chatId", async (request) => {
|
|
13447
|
+
const { chatId } = request.params;
|
|
13448
|
+
const scope = memberScope(request);
|
|
13449
|
+
await assertChatAccess(app.db, scope, chatId);
|
|
13450
|
+
const body = updateChatSchema.parse(request.body);
|
|
13451
|
+
const nextTopic = body.topic && body.topic.length > 0 ? body.topic : null;
|
|
13452
|
+
const [updated] = await app.db.update(chats).set({
|
|
13453
|
+
topic: nextTopic,
|
|
13454
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
13455
|
+
}).where(eq(chats.id, chatId)).returning();
|
|
13456
|
+
if (!updated) throw new Error("Unexpected: chat missing after update");
|
|
13457
|
+
return {
|
|
13458
|
+
...updated,
|
|
13459
|
+
createdAt: updated.createdAt.toISOString(),
|
|
13460
|
+
updatedAt: updated.updatedAt.toISOString()
|
|
13461
|
+
};
|
|
13462
|
+
});
|
|
13463
|
+
/** List messages in a chat with delivery status (requires participation or supervision) */
|
|
13464
|
+
app.get("/:chatId/messages", async (request) => {
|
|
13465
|
+
const { chatId } = request.params;
|
|
13466
|
+
const scope = memberScope(request);
|
|
13467
|
+
await assertChatAccess(app.db, scope, chatId);
|
|
13468
|
+
const query = paginationQuerySchema.parse(request.query);
|
|
13469
|
+
const where = query.cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(query.cursor))) : eq(messages.chatId, chatId);
|
|
13470
|
+
const rows = await app.db.select({
|
|
13471
|
+
id: messages.id,
|
|
13472
|
+
chatId: messages.chatId,
|
|
13473
|
+
senderId: messages.senderId,
|
|
13474
|
+
format: messages.format,
|
|
13475
|
+
content: messages.content,
|
|
12415
13476
|
metadata: messages.metadata,
|
|
12416
13477
|
replyToInbox: messages.replyToInbox,
|
|
12417
13478
|
replyToChat: messages.replyToChat,
|
|
@@ -13147,189 +14208,6 @@ async function adminOverviewRoutes(app) {
|
|
|
13147
14208
|
};
|
|
13148
14209
|
});
|
|
13149
14210
|
}
|
|
13150
|
-
const SUMMARY_MAX_LENGTH = 50;
|
|
13151
|
-
/** Extract a plain-text summary from a message's JSONB content field. */
|
|
13152
|
-
function extractSummary(content, maxLen = SUMMARY_MAX_LENGTH) {
|
|
13153
|
-
let text = "";
|
|
13154
|
-
if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
|
|
13155
|
-
else if (typeof content === "string") text = content;
|
|
13156
|
-
if (!text) return null;
|
|
13157
|
-
return Array.from(text).slice(0, maxLen).join("");
|
|
13158
|
-
}
|
|
13159
|
-
/** List sessions for a specific agent, with optional state filters. */
|
|
13160
|
-
async function listAgentSessions(db, agentId, filters) {
|
|
13161
|
-
const conditions = [eq(agentChatSessions.agentId, agentId)];
|
|
13162
|
-
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
13163
|
-
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
13164
|
-
const rows = await db.select({
|
|
13165
|
-
agentId: agentChatSessions.agentId,
|
|
13166
|
-
chatId: agentChatSessions.chatId,
|
|
13167
|
-
state: agentChatSessions.state,
|
|
13168
|
-
updatedAt: agentChatSessions.updatedAt,
|
|
13169
|
-
chatCreatedAt: chats.createdAt,
|
|
13170
|
-
chatTopic: chats.topic
|
|
13171
|
-
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
|
|
13172
|
-
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
13173
|
-
const agentRuntimeState = presence?.runtimeState ?? null;
|
|
13174
|
-
if (filters?.runtimeState && agentRuntimeState !== filters.runtimeState) return [];
|
|
13175
|
-
const chatIds = rows.map((r) => r.chatId);
|
|
13176
|
-
const messageCounts = chatIds.length > 0 ? await db.select({
|
|
13177
|
-
chatId: inboxEntries.chatId,
|
|
13178
|
-
count: sql`count(*)::int`
|
|
13179
|
-
}).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
|
|
13180
|
-
const countMap = new Map(messageCounts.map((r) => [r.chatId, r.count]));
|
|
13181
|
-
const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
13182
|
-
chatId: messages.chatId,
|
|
13183
|
-
content: messages.content
|
|
13184
|
-
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
13185
|
-
const summaryMap = /* @__PURE__ */ new Map();
|
|
13186
|
-
for (const row of firstMessages) {
|
|
13187
|
-
const summary = extractSummary(row.content);
|
|
13188
|
-
if (summary) summaryMap.set(row.chatId, summary);
|
|
13189
|
-
}
|
|
13190
|
-
return rows.map((r) => ({
|
|
13191
|
-
agentId: r.agentId,
|
|
13192
|
-
chatId: r.chatId,
|
|
13193
|
-
state: r.state,
|
|
13194
|
-
runtimeState: agentRuntimeState,
|
|
13195
|
-
startedAt: r.chatCreatedAt.toISOString(),
|
|
13196
|
-
lastActivityAt: r.updatedAt.toISOString(),
|
|
13197
|
-
messageCount: countMap.get(r.chatId) ?? 0,
|
|
13198
|
-
summary: summaryMap.get(r.chatId) ?? null,
|
|
13199
|
-
topic: r.chatTopic ?? null
|
|
13200
|
-
}));
|
|
13201
|
-
}
|
|
13202
|
-
/** Get a single session's detail. */
|
|
13203
|
-
async function getSession(db, agentId, chatId) {
|
|
13204
|
-
const [row] = await db.select({
|
|
13205
|
-
agentId: agentChatSessions.agentId,
|
|
13206
|
-
chatId: agentChatSessions.chatId,
|
|
13207
|
-
state: agentChatSessions.state,
|
|
13208
|
-
updatedAt: agentChatSessions.updatedAt,
|
|
13209
|
-
chatCreatedAt: chats.createdAt,
|
|
13210
|
-
chatTopic: chats.topic
|
|
13211
|
-
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
|
|
13212
|
-
if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
13213
|
-
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
13214
|
-
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)));
|
|
13215
|
-
const firstMsg = (await db.execute(sql`SELECT content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at ASC LIMIT 1`))[0];
|
|
13216
|
-
const summary = firstMsg ? extractSummary(firstMsg.content) : null;
|
|
13217
|
-
return {
|
|
13218
|
-
agentId: row.agentId,
|
|
13219
|
-
chatId: row.chatId,
|
|
13220
|
-
state: row.state,
|
|
13221
|
-
runtimeState: presence?.runtimeState ?? null,
|
|
13222
|
-
startedAt: row.chatCreatedAt.toISOString(),
|
|
13223
|
-
lastActivityAt: row.updatedAt.toISOString(),
|
|
13224
|
-
messageCount: countRow?.count ?? 0,
|
|
13225
|
-
summary,
|
|
13226
|
-
topic: row.chatTopic ?? null
|
|
13227
|
-
};
|
|
13228
|
-
}
|
|
13229
|
-
/** List all sessions across all agents, with pagination. Scoped to organization. */
|
|
13230
|
-
async function listAllSessions(db, limit, cursor, filters) {
|
|
13231
|
-
const conditions = [];
|
|
13232
|
-
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
13233
|
-
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
13234
|
-
if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
|
|
13235
|
-
if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
|
|
13236
|
-
if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
|
|
13237
|
-
const rows = await db.select({
|
|
13238
|
-
agentId: agentChatSessions.agentId,
|
|
13239
|
-
chatId: agentChatSessions.chatId,
|
|
13240
|
-
state: agentChatSessions.state,
|
|
13241
|
-
updatedAt: agentChatSessions.updatedAt,
|
|
13242
|
-
chatCreatedAt: chats.createdAt,
|
|
13243
|
-
chatTopic: chats.topic
|
|
13244
|
-
}).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);
|
|
13245
|
-
const hasMore = rows.length > limit;
|
|
13246
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
13247
|
-
const agentIds = [...new Set(items.map((r) => r.agentId))];
|
|
13248
|
-
const presenceRows = agentIds.length > 0 ? await db.select({
|
|
13249
|
-
agentId: agentPresence.agentId,
|
|
13250
|
-
runtimeState: agentPresence.runtimeState
|
|
13251
|
-
}).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
|
|
13252
|
-
const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
|
|
13253
|
-
const chatIds = [...new Set(items.map((r) => r.chatId))];
|
|
13254
|
-
const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
13255
|
-
chatId: messages.chatId,
|
|
13256
|
-
content: messages.content
|
|
13257
|
-
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
13258
|
-
const summaryMap = /* @__PURE__ */ new Map();
|
|
13259
|
-
for (const row of firstMessages) {
|
|
13260
|
-
const summary = extractSummary(row.content);
|
|
13261
|
-
if (summary) summaryMap.set(row.chatId, summary);
|
|
13262
|
-
}
|
|
13263
|
-
const last = items[items.length - 1];
|
|
13264
|
-
const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
|
|
13265
|
-
return {
|
|
13266
|
-
items: items.map((r) => ({
|
|
13267
|
-
agentId: r.agentId,
|
|
13268
|
-
chatId: r.chatId,
|
|
13269
|
-
state: r.state,
|
|
13270
|
-
runtimeState: runtimeMap.get(r.agentId) ?? null,
|
|
13271
|
-
startedAt: r.chatCreatedAt.toISOString(),
|
|
13272
|
-
lastActivityAt: r.updatedAt.toISOString(),
|
|
13273
|
-
messageCount: 0,
|
|
13274
|
-
summary: summaryMap.get(r.chatId) ?? null,
|
|
13275
|
-
topic: r.chatTopic ?? null
|
|
13276
|
-
})),
|
|
13277
|
-
nextCursor
|
|
13278
|
-
};
|
|
13279
|
-
}
|
|
13280
|
-
/** Commit `active → suspended`. No-op on suspended/evicted. Throws if row is missing. */
|
|
13281
|
-
async function suspendSession(db, agentId, chatId, organizationId, notifier) {
|
|
13282
|
-
return transitionSessionState(db, agentId, chatId, "suspended", ["active"], organizationId, notifier);
|
|
13283
|
-
}
|
|
13284
|
-
/** Commit `suspended → evicted` (terminal — listings hide it, revival defense blocks resurrection). */
|
|
13285
|
-
async function archiveSession(db, agentId, chatId, organizationId, notifier) {
|
|
13286
|
-
return transitionSessionState(db, agentId, chatId, "evicted", ["suspended"], organizationId, notifier);
|
|
13287
|
-
}
|
|
13288
|
-
async function transitionSessionState(db, agentId, chatId, target, from, organizationId, notifier) {
|
|
13289
|
-
const now = /* @__PURE__ */ new Date();
|
|
13290
|
-
let finalState = null;
|
|
13291
|
-
let transitioned = false;
|
|
13292
|
-
await db.transaction(async (tx) => {
|
|
13293
|
-
const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
|
|
13294
|
-
if (!existing) return;
|
|
13295
|
-
const current = existing.state;
|
|
13296
|
-
finalState = current;
|
|
13297
|
-
if (!from.includes(current)) return;
|
|
13298
|
-
await tx.update(agentChatSessions).set({
|
|
13299
|
-
state: target,
|
|
13300
|
-
updatedAt: now
|
|
13301
|
-
}).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId)));
|
|
13302
|
-
const [counts] = await tx.select({
|
|
13303
|
-
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
13304
|
-
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
13305
|
-
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
13306
|
-
await tx.update(agentPresence).set({
|
|
13307
|
-
activeSessions: counts?.active ?? 0,
|
|
13308
|
-
totalSessions: counts?.total ?? 0,
|
|
13309
|
-
lastSeenAt: now
|
|
13310
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
13311
|
-
finalState = target;
|
|
13312
|
-
transitioned = true;
|
|
13313
|
-
});
|
|
13314
|
-
if (finalState === null) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
13315
|
-
if (transitioned && notifier) notifier.notifySessionStateChange(agentId, chatId, target, organizationId).catch(() => {});
|
|
13316
|
-
return {
|
|
13317
|
-
state: finalState,
|
|
13318
|
-
transitioned
|
|
13319
|
-
};
|
|
13320
|
-
}
|
|
13321
|
-
/**
|
|
13322
|
-
* Filter sessions to only those where the given agent is also a participant in the chat.
|
|
13323
|
-
* Used when a non-manager views sessions of an org-visible agent — they should only see
|
|
13324
|
-
* sessions for chats they participate in.
|
|
13325
|
-
*/
|
|
13326
|
-
async function filterSessionsByParticipant(db, sessions, participantAgentId) {
|
|
13327
|
-
if (sessions.length === 0) return [];
|
|
13328
|
-
const chatIds = sessions.map((s) => s.chatId);
|
|
13329
|
-
const participantRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(inArray(chatParticipants.chatId, chatIds), eq(chatParticipants.agentId, participantAgentId)));
|
|
13330
|
-
const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
|
|
13331
|
-
return sessions.filter((s) => allowedChatIds.has(s.chatId));
|
|
13332
|
-
}
|
|
13333
14211
|
/**
|
|
13334
14212
|
* Session events — structured event stream per (agent, chat) session.
|
|
13335
14213
|
* `kind` is 'tool_call' | 'error'; the payload shape is enforced by the
|
|
@@ -14140,6 +15018,33 @@ function adminWsRoutes(notifier, jwtSecret) {
|
|
|
14140
15018
|
...payload
|
|
14141
15019
|
});
|
|
14142
15020
|
});
|
|
15021
|
+
notifier.onChatMessage(({ chatId }) => {
|
|
15022
|
+
dispatchChatMessage(chatId);
|
|
15023
|
+
});
|
|
15024
|
+
async function dispatchChatMessage(chatId) {
|
|
15025
|
+
if (adminSockets.size === 0) return;
|
|
15026
|
+
const audience = await getCachedAudience(getDbForChatLookup(), chatId);
|
|
15027
|
+
if (!audience || audience.size === 0) return;
|
|
15028
|
+
const frame = JSON.stringify({
|
|
15029
|
+
type: "chat:message",
|
|
15030
|
+
chatId
|
|
15031
|
+
});
|
|
15032
|
+
for (const [ws, meta] of adminSockets) {
|
|
15033
|
+
if (ws.readyState !== 1) continue;
|
|
15034
|
+
if (!audience.has(meta.humanAgentId)) continue;
|
|
15035
|
+
try {
|
|
15036
|
+
ws.send(frame);
|
|
15037
|
+
} catch {}
|
|
15038
|
+
}
|
|
15039
|
+
}
|
|
15040
|
+
let cachedDbForChatLookup = null;
|
|
15041
|
+
function getDbForChatLookup() {
|
|
15042
|
+
if (!cachedDbForChatLookup) throw new Error("admin WS: db not initialised yet");
|
|
15043
|
+
return cachedDbForChatLookup;
|
|
15044
|
+
}
|
|
15045
|
+
function rememberDb(db) {
|
|
15046
|
+
if (!cachedDbForChatLookup) cachedDbForChatLookup = db;
|
|
15047
|
+
}
|
|
14143
15048
|
return async (app) => {
|
|
14144
15049
|
app.get("/admin", {
|
|
14145
15050
|
websocket: true,
|
|
@@ -14182,7 +15087,8 @@ function adminWsRoutes(notifier, jwtSecret) {
|
|
|
14182
15087
|
}
|
|
14183
15088
|
const [memberRow] = await app.db.select({
|
|
14184
15089
|
id: members.id,
|
|
14185
|
-
role: members.role
|
|
15090
|
+
role: members.role,
|
|
15091
|
+
agentId: members.agentId
|
|
14186
15092
|
}).from(members).where(and(eq(members.userId, userId), eq(members.organizationId, organizationId), eq(members.status, "active"))).limit(1);
|
|
14187
15093
|
if (!memberRow) {
|
|
14188
15094
|
socket.send(JSON.stringify({
|
|
@@ -14198,10 +15104,14 @@ function adminWsRoutes(notifier, jwtSecret) {
|
|
|
14198
15104
|
organizationId,
|
|
14199
15105
|
memberId
|
|
14200
15106
|
});
|
|
15107
|
+
rememberDb(app.db);
|
|
14201
15108
|
const visibleAgentIds = await loadVisibleAgentIds(app.db, organizationId, memberId);
|
|
15109
|
+
const [humanAgentRow] = await app.db.select({ uuid: agents.uuid }).from(agents).where(eq(agents.uuid, memberRow.agentId)).limit(1);
|
|
15110
|
+
const humanAgentId = humanAgentRow?.uuid ?? memberRow.agentId;
|
|
14202
15111
|
adminSockets.set(socket, {
|
|
14203
15112
|
organizationId,
|
|
14204
15113
|
memberId,
|
|
15114
|
+
humanAgentId,
|
|
14205
15115
|
visibleAgentIds
|
|
14206
15116
|
});
|
|
14207
15117
|
socket.send(JSON.stringify({ type: "admin:connected" }));
|
|
@@ -15784,6 +16694,7 @@ async function ensureMembership(db, data) {
|
|
|
15784
16694
|
if (existing) {
|
|
15785
16695
|
if (existing.status === "left") {
|
|
15786
16696
|
await db.update(members).set({ status: "active" }).where(eq(members.id, existing.id));
|
|
16697
|
+
await recomputeWatchersForMember(db, existing.id);
|
|
15787
16698
|
return {
|
|
15788
16699
|
...existing,
|
|
15789
16700
|
status: "active"
|
|
@@ -15911,11 +16822,18 @@ async function pickPrimaryMembership(db, userId) {
|
|
|
15911
16822
|
* (last admin allowed to leave, leaves an orphan team) and the cleanup is
|
|
15912
16823
|
* a v2 sweep job.
|
|
15913
16824
|
*/
|
|
16825
|
+
/**
|
|
16826
|
+
* Soft-leave an organization. Flips the member's row to `status='left'`
|
|
16827
|
+
* and reconciles watcher rows: `recomputeChatWatchers`'s active-member
|
|
16828
|
+
* predicate now drops every watcher anchored to this member, removing
|
|
16829
|
+
* the chats from their `/me/chats` watching list.
|
|
16830
|
+
*/
|
|
15914
16831
|
async function leaveOrganization(db, memberId) {
|
|
15915
16832
|
const [existing] = await db.select().from(members).where(eq(members.id, memberId)).limit(1);
|
|
15916
16833
|
if (!existing) throw new NotFoundError(`Membership "${memberId}" not found`);
|
|
15917
16834
|
if (existing.status === "left") return existing;
|
|
15918
16835
|
await db.update(members).set({ status: "left" }).where(eq(members.id, memberId));
|
|
16836
|
+
await recomputeWatchersForMember(db, memberId);
|
|
15919
16837
|
return {
|
|
15920
16838
|
...existing,
|
|
15921
16839
|
status: "left"
|
|
@@ -16573,7 +17491,7 @@ async function inferWizardStep(app, m) {
|
|
|
16573
17491
|
* landing page.
|
|
16574
17492
|
*/
|
|
16575
17493
|
async function publicInvitePreviewRoute(app) {
|
|
16576
|
-
const { previewInvitation } = await import("./invitation-CBnQyB7o-
|
|
17494
|
+
const { previewInvitation } = await import("./invitation-CBnQyB7o-TmnIj3kx.mjs");
|
|
16577
17495
|
app.get("/:token/preview", async (request, reply) => {
|
|
16578
17496
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
16579
17497
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -16603,7 +17521,7 @@ async function adminInvitationRoutes(app) {
|
|
|
16603
17521
|
const m = requireMember(request);
|
|
16604
17522
|
if (m.role !== "admin") throw new ForbiddenError("Admin role required");
|
|
16605
17523
|
if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
|
|
16606
|
-
const { rotateInvitation } = await import("./invitation-CBnQyB7o-
|
|
17524
|
+
const { rotateInvitation } = await import("./invitation-CBnQyB7o-TmnIj3kx.mjs");
|
|
16607
17525
|
const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
|
|
16608
17526
|
return {
|
|
16609
17527
|
id: inv.id,
|
|
@@ -16616,6 +17534,57 @@ async function adminInvitationRoutes(app) {
|
|
|
16616
17534
|
};
|
|
16617
17535
|
});
|
|
16618
17536
|
}
|
|
17537
|
+
/**
|
|
17538
|
+
* `/me/chats*` member-facing chat APIs for the chat-first workspace. Mounted
|
|
17539
|
+
* under the existing `memberAuth` hook.
|
|
17540
|
+
*
|
|
17541
|
+
* Auth & visibility model: every read/write here resolves through the
|
|
17542
|
+
* caller's human agent uuid (member.agentId). Server-side authorisation
|
|
17543
|
+
* keeps cross-org leakage impossible — `chats.organization_id` is verified
|
|
17544
|
+
* against the participant's own membership inside each service.
|
|
17545
|
+
*/
|
|
17546
|
+
async function meChatRoutes(app) {
|
|
17547
|
+
/** GET /me/chats — paginated conversation list (chat-first workspace). */
|
|
17548
|
+
app.get("/", async (request) => {
|
|
17549
|
+
const scope = memberScope(request);
|
|
17550
|
+
const query = listMeChatsQuerySchema.parse(request.query);
|
|
17551
|
+
return listMeChats(app.db, scope.humanAgentId, query);
|
|
17552
|
+
});
|
|
17553
|
+
/** POST /me/chats — always creates a new chat (no dedupe). */
|
|
17554
|
+
app.post("/", async (request, reply) => {
|
|
17555
|
+
const scope = memberScope(request);
|
|
17556
|
+
const body = createMeChatSchema.parse(request.body);
|
|
17557
|
+
const result = await createMeChat(app.db, scope.humanAgentId, scope.organizationId, body);
|
|
17558
|
+
return reply.status(201).send(result);
|
|
17559
|
+
});
|
|
17560
|
+
/** POST /me/chats/:chatId/read — mark the user's row read. Idempotent. */
|
|
17561
|
+
app.post("/:chatId/read", async (request) => {
|
|
17562
|
+
const { chatId } = request.params;
|
|
17563
|
+
const scope = memberScope(request);
|
|
17564
|
+
return markMeChatRead(app.db, chatId, scope.humanAgentId);
|
|
17565
|
+
});
|
|
17566
|
+
/** POST /me/chats/:chatId/participants — add one or more speaking participants. Idempotent. */
|
|
17567
|
+
app.post("/:chatId/participants", async (request, reply) => {
|
|
17568
|
+
const { chatId } = request.params;
|
|
17569
|
+
const scope = memberScope(request);
|
|
17570
|
+
const body = addMeChatParticipantsSchema.parse(request.body);
|
|
17571
|
+
await addMeChatParticipants(app.db, chatId, scope.humanAgentId, scope.organizationId, body);
|
|
17572
|
+
return reply.status(204).send();
|
|
17573
|
+
});
|
|
17574
|
+
/** POST /me/chats/:chatId/join — watcher → speaking participant. State-carry. */
|
|
17575
|
+
app.post("/:chatId/join", async (request, reply) => {
|
|
17576
|
+
const { chatId } = request.params;
|
|
17577
|
+
const scope = memberScope(request);
|
|
17578
|
+
await joinMeChat(app.db, chatId, scope.humanAgentId);
|
|
17579
|
+
return reply.status(204).send();
|
|
17580
|
+
});
|
|
17581
|
+
/** POST /me/chats/:chatId/leave — speaking participant → watcher (or detach). */
|
|
17582
|
+
app.post("/:chatId/leave", async (request) => {
|
|
17583
|
+
const { chatId } = request.params;
|
|
17584
|
+
const scope = memberScope(request);
|
|
17585
|
+
return leaveMeChat(app.db, chatId, scope.humanAgentId);
|
|
17586
|
+
});
|
|
17587
|
+
}
|
|
16619
17588
|
const SALT_ROUNDS = 10;
|
|
16620
17589
|
/**
|
|
16621
17590
|
* Create a member in an organization.
|
|
@@ -17254,6 +18223,7 @@ var schema_exports = /* @__PURE__ */ __exportAll({
|
|
|
17254
18223
|
agents: () => agents,
|
|
17255
18224
|
authIdentities: () => authIdentities,
|
|
17256
18225
|
chatParticipants: () => chatParticipants,
|
|
18226
|
+
chatSubscriptions: () => chatSubscriptions,
|
|
17257
18227
|
chats: () => chats,
|
|
17258
18228
|
clients: () => clients,
|
|
17259
18229
|
inboxEntries: () => inboxEntries,
|
|
@@ -18598,6 +19568,10 @@ async function buildApp(config) {
|
|
|
18598
19568
|
adminApp.addHook("onRequest", memberAuth);
|
|
18599
19569
|
await adminApp.register(adminChatRoutes);
|
|
18600
19570
|
}, { prefix: "/admin/chats" });
|
|
19571
|
+
await api.register(async (memberApp) => {
|
|
19572
|
+
memberApp.addHook("onRequest", memberAuth);
|
|
19573
|
+
await memberApp.register(meChatRoutes);
|
|
19574
|
+
}, { prefix: "/me/chats" });
|
|
18601
19575
|
await api.register(async (adminApp) => {
|
|
18602
19576
|
adminApp.addHook("onRequest", memberAuth);
|
|
18603
19577
|
await adminApp.register(adminClientRoutes);
|
|
@@ -18704,6 +19678,13 @@ async function buildApp(config) {
|
|
|
18704
19678
|
kaelRuntime?.reload().catch((err) => hotReloadLog.error({ err }, "kael hot-reload failed (PG NOTIFY)"));
|
|
18705
19679
|
}
|
|
18706
19680
|
});
|
|
19681
|
+
registerChatMessageDispatcher((chatId, messageId) => {
|
|
19682
|
+
notifier.notifyChatMessage(chatId, messageId).catch((err) => createLogger$1("chat-message-kick").warn({
|
|
19683
|
+
err,
|
|
19684
|
+
chatId,
|
|
19685
|
+
messageId
|
|
19686
|
+
}, "chat:message kick failed"));
|
|
19687
|
+
});
|
|
18707
19688
|
app.addHook("onReady", async () => {
|
|
18708
19689
|
await ensureDefaultOrganization(db);
|
|
18709
19690
|
await notifier.start();
|