@agent-team-foundation/first-tree-hub 0.10.14 → 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/{bootstrap-B2x4TTyJ.mjs → bootstrap-DUeYbwm-.mjs} +60 -4
- package/dist/cli/index.mjs +6 -6
- 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 +5 -5
- package/dist/{invitation-CBnQyB7o-Bulf3Sl7.mjs → invitation-CBnQyB7o-TmnIj3kx.mjs} +1 -1
- package/dist/{saas-connect-DWcxHtjX.mjs → saas-connect-DLSyrQcC.mjs} +1394 -395
- 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
|
-
import { C as
|
|
4
|
-
import { $ as
|
|
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 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";
|
|
@@ -21,7 +21,7 @@ import { fileURLToPath } from "node:url";
|
|
|
21
21
|
import * as semver from "semver";
|
|
22
22
|
import { confirm, input, password, select } from "@inquirer/prompts";
|
|
23
23
|
import bcrypt from "bcrypt";
|
|
24
|
-
import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
|
|
24
|
+
import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
|
|
25
25
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
26
26
|
import postgres from "postgres";
|
|
27
27
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
@@ -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",
|
|
@@ -1883,6 +1940,13 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1883
1940
|
/** Fires ~60s before JWT exp so we reconnect with a fresh token first. */
|
|
1884
1941
|
authRefreshTimer = null;
|
|
1885
1942
|
reconnectAttempt = 0;
|
|
1943
|
+
/**
|
|
1944
|
+
* If the most recent refresh attempt was rate-limited (HTTP 429), the
|
|
1945
|
+
* server-suggested wait in ms — consumed by the next `scheduleReconnect`
|
|
1946
|
+
* to floor its delay so we don't keep retrying inside the same 60s
|
|
1947
|
+
* limiter window. Cleared after one use.
|
|
1948
|
+
*/
|
|
1949
|
+
nextReconnectMinDelayMs = 0;
|
|
1886
1950
|
closing = false;
|
|
1887
1951
|
registered = false;
|
|
1888
1952
|
/** Count of `server:welcome` frames received; drives `isReconnect` flag. */
|
|
@@ -2095,6 +2159,10 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2095
2159
|
if (e.name === "AuthRefreshFailedError") {
|
|
2096
2160
|
this.closing = true;
|
|
2097
2161
|
this.emit("auth:fatal", e);
|
|
2162
|
+
} else if (e.name === "AuthRefreshRateLimitedError") {
|
|
2163
|
+
const retryAfterMs = e.retryAfterMs ?? 3e4;
|
|
2164
|
+
this.nextReconnectMinDelayMs = Math.max(this.nextReconnectMinDelayMs, retryAfterMs);
|
|
2165
|
+
this.authLogger.warn({ retryAfterMs }, "refresh rate-limited; deferring reconnect");
|
|
2098
2166
|
}
|
|
2099
2167
|
settle(reject, e);
|
|
2100
2168
|
ws.close();
|
|
@@ -2348,10 +2416,18 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2348
2416
|
if (this.closing) return;
|
|
2349
2417
|
this.reconnectAttempt++;
|
|
2350
2418
|
this.emit("reconnecting", this.reconnectAttempt);
|
|
2351
|
-
const
|
|
2419
|
+
const exponential = Math.min(RECONNECT_BASE_MS * 2 ** (this.reconnectAttempt - 1), RECONNECT_MAX_MS);
|
|
2420
|
+
const floor = this.nextReconnectMinDelayMs;
|
|
2421
|
+
this.nextReconnectMinDelayMs = 0;
|
|
2422
|
+
let delay = Math.max(exponential, floor);
|
|
2423
|
+
if (floor > 0) {
|
|
2424
|
+
const jitter = delay * .2 * (Math.random() * 2 - 1);
|
|
2425
|
+
delay = Math.max(0, Math.round(delay + jitter));
|
|
2426
|
+
}
|
|
2352
2427
|
this.wsLogger.debug({
|
|
2353
2428
|
attempt: this.reconnectAttempt,
|
|
2354
|
-
delayMs: delay
|
|
2429
|
+
delayMs: delay,
|
|
2430
|
+
floorMs: floor || void 0
|
|
2355
2431
|
}, "scheduling reconnect");
|
|
2356
2432
|
this.reconnectTimer = setTimeout(() => {
|
|
2357
2433
|
this.reconnectTimer = null;
|
|
@@ -2401,6 +2477,18 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2401
2477
|
* before the server's scheduleAuthExpiry timer fires. Short-lived tokens
|
|
2402
2478
|
* (exp <= lead window) skip the proactive reconnect entirely — we let the
|
|
2403
2479
|
* server push `auth:expired` and handle that path.
|
|
2480
|
+
*
|
|
2481
|
+
* Order is "refresh-then-close", not "close-then-let-reconnect-refresh".
|
|
2482
|
+
* The earlier shape relied on the new connection's open handler to do the
|
|
2483
|
+
* `/auth/refresh` HTTP, which forced ≥1s of WS downtime per cycle even on
|
|
2484
|
+
* the happy path (one base reconnect delay + the refresh round-trip) and
|
|
2485
|
+
* compounded badly under 429: every retry attempt also closed/reopened the
|
|
2486
|
+
* WS, holding the agent offline for 15-20s while the limiter cooled down.
|
|
2487
|
+
* Refreshing first lets us swap the new token onto a still-open WS with no
|
|
2488
|
+
* observable disconnect when the refresh succeeds; the original close-and-
|
|
2489
|
+
* reconnect flow only runs on failure as a last-ditch fallback (it'll hit
|
|
2490
|
+
* the same 429 on its next retry, but at least the Retry-After floor is
|
|
2491
|
+
* now wired up so we don't pile attempts inside the same window).
|
|
2404
2492
|
*/
|
|
2405
2493
|
scheduleProactiveAuthRefresh(token) {
|
|
2406
2494
|
this.clearAuthRefreshTimer();
|
|
@@ -2410,12 +2498,29 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2410
2498
|
if (delay <= 0) return;
|
|
2411
2499
|
this.authLogger.debug({ delayMs: delay }, "scheduled proactive auth refresh");
|
|
2412
2500
|
this.authRefreshTimer = setTimeout(() => {
|
|
2413
|
-
this.
|
|
2414
|
-
if (this.closing) return;
|
|
2415
|
-
this.authLogger.info("triggering proactive auth refresh");
|
|
2416
|
-
this.ws?.close(1e3, "proactive auth refresh");
|
|
2501
|
+
this.runProactiveAuthRefresh();
|
|
2417
2502
|
}, delay);
|
|
2418
2503
|
}
|
|
2504
|
+
async runProactiveAuthRefresh() {
|
|
2505
|
+
this.authRefreshTimer = null;
|
|
2506
|
+
if (this.closing) return;
|
|
2507
|
+
this.authLogger.info("triggering proactive auth refresh");
|
|
2508
|
+
try {
|
|
2509
|
+
await this.getAccessToken({ minValidityMs: AUTH_REFRESH_LEAD_MS + 5e3 });
|
|
2510
|
+
} catch (err) {
|
|
2511
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
2512
|
+
if (e.name === "AuthRefreshRateLimitedError") {
|
|
2513
|
+
const retryAfterMs = e.retryAfterMs ?? 3e4;
|
|
2514
|
+
this.nextReconnectMinDelayMs = Math.max(this.nextReconnectMinDelayMs, retryAfterMs);
|
|
2515
|
+
this.authLogger.warn({ retryAfterMs }, "proactive refresh rate-limited; deferring reconnect");
|
|
2516
|
+
} else if (e.name === "AuthRefreshFailedError") {
|
|
2517
|
+
this.closing = true;
|
|
2518
|
+
this.emit("auth:fatal", e);
|
|
2519
|
+
return;
|
|
2520
|
+
} else this.authLogger.warn({ err: e }, "proactive refresh failed; falling back to reconnect path");
|
|
2521
|
+
}
|
|
2522
|
+
this.ws?.close(1e3, "proactive auth refresh");
|
|
2523
|
+
}
|
|
2419
2524
|
};
|
|
2420
2525
|
/** Built-in handler registry. Populated by handler modules. */
|
|
2421
2526
|
const HANDLER_REGISTRY = /* @__PURE__ */ new Map();
|
|
@@ -8098,7 +8203,7 @@ async function onboardCreate(args) {
|
|
|
8098
8203
|
}
|
|
8099
8204
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
8100
8205
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
8101
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
8206
|
+
const { bindFeishuBot } = await import("./feishu-Dxk6ArOK.mjs").then((n) => n.r);
|
|
8102
8207
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
8103
8208
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
8104
8209
|
else {
|
|
@@ -9078,7 +9183,7 @@ function createFeedbackHandler(config) {
|
|
|
9078
9183
|
return { handle };
|
|
9079
9184
|
}
|
|
9080
9185
|
//#endregion
|
|
9081
|
-
//#region ../server/dist/app-
|
|
9186
|
+
//#region ../server/dist/app-DNJkrky7.mjs
|
|
9082
9187
|
var __defProp = Object.defineProperty;
|
|
9083
9188
|
var __exportAll = (all, no_symbols) => {
|
|
9084
9189
|
let target = {};
|
|
@@ -9162,17 +9267,44 @@ const chats = pgTable("chats", {
|
|
|
9162
9267
|
lifecyclePolicy: text("lifecycle_policy").default("persistent"),
|
|
9163
9268
|
parentChatId: text("parent_chat_id"),
|
|
9164
9269
|
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
9270
|
+
lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
|
|
9271
|
+
lastMessagePreview: text("last_message_preview"),
|
|
9165
9272
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
9166
9273
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
9167
|
-
});
|
|
9168
|
-
/**
|
|
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. */
|
|
9169
9276
|
const chatParticipants = pgTable("chat_participants", {
|
|
9170
9277
|
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
9171
9278
|
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
9172
9279
|
role: text("role").notNull().default("member"),
|
|
9173
9280
|
mode: text("mode").notNull().default("full"),
|
|
9281
|
+
lastReadAt: timestamp("last_read_at", { withTimezone: true }),
|
|
9282
|
+
unreadMentionCount: integer("unread_mention_count").notNull().default(0),
|
|
9174
9283
|
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
|
|
9175
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)]);
|
|
9176
9308
|
/** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
|
|
9177
9309
|
const members = pgTable("members", {
|
|
9178
9310
|
id: text("id").primaryKey(),
|
|
@@ -9967,7 +10099,7 @@ async function deleteAdapterConfig(db, id) {
|
|
|
9967
10099
|
const [row] = await db.delete(adapterConfigs).where(eq(adapterConfigs.id, id)).returning();
|
|
9968
10100
|
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
9969
10101
|
}
|
|
9970
|
-
const log$
|
|
10102
|
+
const log$6 = createLogger$1("AdminAdapters");
|
|
9971
10103
|
const orgQuerySchema$1 = z.object({ organizationId: z.string().min(1).optional() });
|
|
9972
10104
|
function parseId(raw) {
|
|
9973
10105
|
const id = Number(raw);
|
|
@@ -9990,7 +10122,7 @@ async function adminAdapterRoutes(app) {
|
|
|
9990
10122
|
const scope = memberScope(request);
|
|
9991
10123
|
await assertCanManage(app.db, scope, body.agentId);
|
|
9992
10124
|
const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
|
|
9993
|
-
app.adapterManager.reload().catch((err) => log$
|
|
10125
|
+
app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after create"));
|
|
9994
10126
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
9995
10127
|
return reply.status(201).send({
|
|
9996
10128
|
...config,
|
|
@@ -10014,7 +10146,7 @@ async function adminAdapterRoutes(app) {
|
|
|
10014
10146
|
const existing = await getAdapterConfig(app.db, id);
|
|
10015
10147
|
await assertCanManage(app.db, scope, existing.agentId);
|
|
10016
10148
|
const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
|
|
10017
|
-
app.adapterManager.reload().catch((err) => log$
|
|
10149
|
+
app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after update"));
|
|
10018
10150
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10019
10151
|
return {
|
|
10020
10152
|
...config,
|
|
@@ -10028,7 +10160,7 @@ async function adminAdapterRoutes(app) {
|
|
|
10028
10160
|
const existing = await getAdapterConfig(app.db, id);
|
|
10029
10161
|
await assertCanManage(app.db, scope, existing.agentId);
|
|
10030
10162
|
await deleteAdapterConfig(app.db, id);
|
|
10031
|
-
app.adapterManager.reload().catch((err) => log$
|
|
10163
|
+
app.adapterManager.reload().catch((err) => log$6.error({ err }, "adapter reload failed after delete"));
|
|
10032
10164
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
10033
10165
|
return reply.status(204).send();
|
|
10034
10166
|
});
|
|
@@ -10109,6 +10241,227 @@ const agentConfigs = pgTable("agent_configs", {
|
|
|
10109
10241
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
10110
10242
|
});
|
|
10111
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
|
+
/**
|
|
10112
10465
|
* Names beginning with `__` are reserved for Hub-internal pseudo agents
|
|
10113
10466
|
* (e.g. the task notifier). User-facing creation must not be able to
|
|
10114
10467
|
* squat on them, otherwise internal traffic could be routed through a
|
|
@@ -10415,6 +10768,7 @@ async function updateAgent(db, uuid, data) {
|
|
|
10415
10768
|
}
|
|
10416
10769
|
const [updated] = await db.update(agents).set(updates).where(eq(agents.uuid, agent.uuid)).returning();
|
|
10417
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);
|
|
10418
10772
|
return updated;
|
|
10419
10773
|
}
|
|
10420
10774
|
/**
|
|
@@ -10501,6 +10855,71 @@ async function deleteAgent(db, uuid) {
|
|
|
10501
10855
|
return agent;
|
|
10502
10856
|
}
|
|
10503
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
|
+
/**
|
|
10504
10923
|
* When a direct chat grows past 2 participants, upgrade it to `group` and
|
|
10505
10924
|
* flip every existing non-human agent participant to `mention_only` — see
|
|
10506
10925
|
* proposals/hub-agent-messaging-reply-and-mentions §3.3. The caller is
|
|
@@ -10555,6 +10974,7 @@ async function createChat(db, creatorId, data) {
|
|
|
10555
10974
|
...isDirectAgentOnly ? { mode: "mention_only" } : {}
|
|
10556
10975
|
}));
|
|
10557
10976
|
await tx.insert(chatParticipants).values(participantRows);
|
|
10977
|
+
await recomputeChatWatchers(tx, chatId);
|
|
10558
10978
|
const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10559
10979
|
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
10560
10980
|
return {
|
|
@@ -10618,12 +11038,15 @@ async function ensureParticipant(db, chatId, agentId) {
|
|
|
10618
11038
|
if (existing) return;
|
|
10619
11039
|
await db.transaction(async (tx) => {
|
|
10620
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)));
|
|
10621
11042
|
await tx.insert(chatParticipants).values({
|
|
10622
11043
|
chatId,
|
|
10623
11044
|
agentId,
|
|
10624
11045
|
mode: "full"
|
|
10625
11046
|
}).onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
|
|
11047
|
+
await recomputeChatWatchers(tx, chatId);
|
|
10626
11048
|
});
|
|
11049
|
+
invalidateChatAudience(chatId);
|
|
10627
11050
|
}
|
|
10628
11051
|
async function addParticipant(db, chatId, requesterId, data) {
|
|
10629
11052
|
const chat = await getChat(db, chatId);
|
|
@@ -10638,12 +11061,15 @@ async function addParticipant(db, chatId, requesterId, data) {
|
|
|
10638
11061
|
if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
|
|
10639
11062
|
await db.transaction(async (tx) => {
|
|
10640
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)));
|
|
10641
11065
|
await tx.insert(chatParticipants).values({
|
|
10642
11066
|
chatId,
|
|
10643
11067
|
agentId: data.agentId,
|
|
10644
11068
|
mode: data.mode ?? "full"
|
|
10645
11069
|
});
|
|
11070
|
+
await recomputeChatWatchers(tx, chatId);
|
|
10646
11071
|
});
|
|
11072
|
+
invalidateChatAudience(chatId);
|
|
10647
11073
|
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10648
11074
|
}
|
|
10649
11075
|
async function removeParticipant(db, chatId, requesterId, targetAgentId) {
|
|
@@ -10651,6 +11077,8 @@ async function removeParticipant(db, chatId, requesterId, targetAgentId) {
|
|
|
10651
11077
|
if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
|
|
10652
11078
|
const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, targetAgentId))).returning();
|
|
10653
11079
|
if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
|
|
11080
|
+
await recomputeChatWatchers(db, chatId);
|
|
11081
|
+
invalidateChatAudience(chatId);
|
|
10654
11082
|
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10655
11083
|
}
|
|
10656
11084
|
/**
|
|
@@ -10742,23 +11170,38 @@ async function joinChat(db, chatId, memberId, humanAgentId) {
|
|
|
10742
11170
|
const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
|
|
10743
11171
|
if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
|
|
10744
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
|
+
});
|
|
10745
11177
|
await maybeUpgradeDirectToGroup(tx, chatId, participantAgentIds, 1);
|
|
10746
11178
|
await tx.insert(chatParticipants).values({
|
|
10747
11179
|
chatId,
|
|
10748
11180
|
agentId: humanAgentId,
|
|
10749
11181
|
role: "member",
|
|
10750
|
-
mode: "full"
|
|
11182
|
+
mode: "full",
|
|
11183
|
+
lastReadAt: carriedRow?.lastReadAt ?? null,
|
|
11184
|
+
unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
|
|
10751
11185
|
});
|
|
11186
|
+
await recomputeChatWatchers(tx, chatId);
|
|
10752
11187
|
});
|
|
11188
|
+
invalidateChatAudience(chatId);
|
|
10753
11189
|
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10754
11190
|
}
|
|
10755
11191
|
/**
|
|
10756
11192
|
* Manager leaves a chat. Removes their human agent from participants.
|
|
10757
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.
|
|
10758
11201
|
*/
|
|
10759
11202
|
async function leaveChat(db, chatId, humanAgentId) {
|
|
10760
|
-
|
|
10761
|
-
|
|
11203
|
+
await leaveAsParticipant(db, chatId, humanAgentId);
|
|
11204
|
+
invalidateChatAudience(chatId);
|
|
10762
11205
|
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
10763
11206
|
}
|
|
10764
11207
|
async function findOrCreateDirectChat(db, agentAId, agentBId) {
|
|
@@ -10798,6 +11241,7 @@ async function findOrCreateDirectChat(db, agentAId, agentBId) {
|
|
|
10798
11241
|
role: "member",
|
|
10799
11242
|
mode
|
|
10800
11243
|
}]);
|
|
11244
|
+
await recomputeChatWatchers(tx, chatId);
|
|
10801
11245
|
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
10802
11246
|
return chat;
|
|
10803
11247
|
});
|
|
@@ -11316,6 +11760,22 @@ function removeClientConnection(clientId, ws) {
|
|
|
11316
11760
|
clientConnections.delete(clientId);
|
|
11317
11761
|
return agentIds;
|
|
11318
11762
|
}
|
|
11763
|
+
/**
|
|
11764
|
+
* Was `ws` the socket currently registered as `clientId`'s active connection
|
|
11765
|
+
* at the time of the call? Used by ws-client.ts's `socket.on("close")` to
|
|
11766
|
+
* decide whether to write `clients.status='disconnected'` to the DB — when a
|
|
11767
|
+
* fast reconnect happens, the new socket has already swapped itself in via
|
|
11768
|
+
* `setClientConnection`, so the old socket's late-arriving onClose must NOT
|
|
11769
|
+
* stamp the row back to disconnected.
|
|
11770
|
+
*
|
|
11771
|
+
* The check is "this socket equals the registered ws", not "this socket is
|
|
11772
|
+
* still OPEN" — the close handler runs precisely when the socket is no
|
|
11773
|
+
* longer OPEN, but the in-memory entry might still legitimately point at
|
|
11774
|
+
* us if no new connection has taken over yet.
|
|
11775
|
+
*/
|
|
11776
|
+
function isActiveClientConnection(clientId, ws) {
|
|
11777
|
+
return clientConnections.get(clientId)?.ws === ws;
|
|
11778
|
+
}
|
|
11319
11779
|
/** Send a message to a client's WebSocket. Returns true if delivered. */
|
|
11320
11780
|
function sendToClient(clientId, message) {
|
|
11321
11781
|
const entry = clientConnections.get(clientId);
|
|
@@ -11484,6 +11944,88 @@ async function listAgentsWithRuntime(db, scope) {
|
|
|
11484
11944
|
type: agents.type
|
|
11485
11945
|
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope.organizationId, scope.memberId)));
|
|
11486
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
|
+
}
|
|
11487
12029
|
const log$4 = createLogger$1("message");
|
|
11488
12030
|
async function sendMessage(db, chatId, senderId, data, options = {}) {
|
|
11489
12031
|
return withSpan("inbox.enqueue", messageAttrs({
|
|
@@ -11585,6 +12127,15 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
|
11585
12127
|
}
|
|
11586
12128
|
await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
|
|
11587
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
|
+
});
|
|
11588
12139
|
return {
|
|
11589
12140
|
message: msg,
|
|
11590
12141
|
recipients,
|
|
@@ -11601,6 +12152,7 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
|
11601
12152
|
agentId: txResult.recipientAgentIds[i]
|
|
11602
12153
|
}, "predictive session activation failed");
|
|
11603
12154
|
}
|
|
12155
|
+
fireChatMessageKick(chatId, txResult.message.id);
|
|
11604
12156
|
return {
|
|
11605
12157
|
message: txResult.message,
|
|
11606
12158
|
recipients: txResult.recipients
|
|
@@ -11662,15 +12214,24 @@ const INBOX_CHANNEL = "inbox_notifications";
|
|
|
11662
12214
|
const CONFIG_CHANNEL = "config_changes";
|
|
11663
12215
|
const SESSION_STATE_CHANNEL = "session_state_changes";
|
|
11664
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";
|
|
11665
12224
|
function createNotifier(listenClient) {
|
|
11666
12225
|
const subscriptions = /* @__PURE__ */ new Map();
|
|
11667
12226
|
const configChangeHandlers = [];
|
|
11668
12227
|
const sessionStateChangeHandlers = [];
|
|
11669
12228
|
const runtimeStateChangeHandlers = [];
|
|
12229
|
+
const chatMessageHandlers = [];
|
|
11670
12230
|
let unlistenInboxFn = null;
|
|
11671
12231
|
let unlistenConfigFn = null;
|
|
11672
12232
|
let unlistenSessionStateFn = null;
|
|
11673
12233
|
let unlistenRuntimeStateFn = null;
|
|
12234
|
+
let unlistenChatMessageFn = null;
|
|
11674
12235
|
function handleNotification(payload) {
|
|
11675
12236
|
const sepIdx = payload.indexOf(":");
|
|
11676
12237
|
if (sepIdx === -1) return;
|
|
@@ -11725,6 +12286,11 @@ function createNotifier(listenClient) {
|
|
|
11725
12286
|
await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
|
|
11726
12287
|
} catch {}
|
|
11727
12288
|
},
|
|
12289
|
+
async notifyChatMessage(chatId, messageId) {
|
|
12290
|
+
try {
|
|
12291
|
+
await listenClient`SELECT pg_notify(${CHAT_MESSAGE_CHANNEL}, ${`${chatId}:${messageId}`})`;
|
|
12292
|
+
} catch {}
|
|
12293
|
+
},
|
|
11728
12294
|
async pushFrameToInbox(inboxId, frame) {
|
|
11729
12295
|
const map = subscriptions.get(inboxId);
|
|
11730
12296
|
if (!map) return 0;
|
|
@@ -11751,6 +12317,9 @@ function createNotifier(listenClient) {
|
|
|
11751
12317
|
onRuntimeStateChange(handler) {
|
|
11752
12318
|
runtimeStateChangeHandlers.push(handler);
|
|
11753
12319
|
},
|
|
12320
|
+
onChatMessage(handler) {
|
|
12321
|
+
chatMessageHandlers.push(handler);
|
|
12322
|
+
},
|
|
11754
12323
|
async start() {
|
|
11755
12324
|
unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
|
|
11756
12325
|
if (payload) handleNotification(payload);
|
|
@@ -11793,6 +12362,19 @@ function createNotifier(listenClient) {
|
|
|
11793
12362
|
}
|
|
11794
12363
|
}
|
|
11795
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;
|
|
11796
12378
|
},
|
|
11797
12379
|
async stop() {
|
|
11798
12380
|
if (unlistenInboxFn) {
|
|
@@ -11811,6 +12393,10 @@ function createNotifier(listenClient) {
|
|
|
11811
12393
|
await unlistenRuntimeStateFn();
|
|
11812
12394
|
unlistenRuntimeStateFn = null;
|
|
11813
12395
|
}
|
|
12396
|
+
if (unlistenChatMessageFn) {
|
|
12397
|
+
await unlistenChatMessageFn();
|
|
12398
|
+
unlistenChatMessageFn = null;
|
|
12399
|
+
}
|
|
11814
12400
|
}
|
|
11815
12401
|
};
|
|
11816
12402
|
}
|
|
@@ -12253,86 +12839,625 @@ async function collectTargetInboxes(db, chatId, inReplyTo) {
|
|
|
12253
12839
|
}
|
|
12254
12840
|
return [...set];
|
|
12255
12841
|
}
|
|
12256
|
-
|
|
12257
|
-
|
|
12258
|
-
|
|
12259
|
-
|
|
12260
|
-
|
|
12261
|
-
|
|
12262
|
-
|
|
12263
|
-
|
|
12264
|
-
|
|
12265
|
-
|
|
12266
|
-
|
|
12267
|
-
|
|
12268
|
-
|
|
12269
|
-
|
|
12270
|
-
|
|
12271
|
-
|
|
12272
|
-
|
|
12273
|
-
|
|
12274
|
-
|
|
12275
|
-
|
|
12276
|
-
|
|
12277
|
-
|
|
12278
|
-
|
|
12279
|
-
|
|
12280
|
-
|
|
12281
|
-
|
|
12282
|
-
|
|
12283
|
-
|
|
12284
|
-
|
|
12285
|
-
|
|
12286
|
-
|
|
12287
|
-
|
|
12288
|
-
|
|
12289
|
-
|
|
12290
|
-
|
|
12291
|
-
|
|
12292
|
-
|
|
12293
|
-
|
|
12294
|
-
|
|
12295
|
-
|
|
12296
|
-
|
|
12297
|
-
|
|
12298
|
-
|
|
12299
|
-
|
|
12300
|
-
|
|
12301
|
-
|
|
12302
|
-
|
|
12303
|
-
|
|
12304
|
-
|
|
12305
|
-
|
|
12306
|
-
|
|
12307
|
-
|
|
12308
|
-
|
|
12309
|
-
|
|
12310
|
-
|
|
12311
|
-
|
|
12312
|
-
|
|
12313
|
-
|
|
12314
|
-
|
|
12315
|
-
|
|
12316
|
-
|
|
12317
|
-
|
|
12318
|
-
|
|
12319
|
-
|
|
12320
|
-
|
|
12321
|
-
|
|
12322
|
-
|
|
12323
|
-
|
|
12324
|
-
|
|
12325
|
-
|
|
12326
|
-
|
|
12327
|
-
|
|
12328
|
-
|
|
12329
|
-
|
|
12330
|
-
|
|
12331
|
-
|
|
12332
|
-
|
|
12333
|
-
|
|
12334
|
-
|
|
12335
|
-
|
|
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()
|
|
12336
13461
|
};
|
|
12337
13462
|
});
|
|
12338
13463
|
/** List messages in a chat with delivery status (requires participation or supervision) */
|
|
@@ -13083,189 +14208,6 @@ async function adminOverviewRoutes(app) {
|
|
|
13083
14208
|
};
|
|
13084
14209
|
});
|
|
13085
14210
|
}
|
|
13086
|
-
const SUMMARY_MAX_LENGTH = 50;
|
|
13087
|
-
/** Extract a plain-text summary from a message's JSONB content field. */
|
|
13088
|
-
function extractSummary(content, maxLen = SUMMARY_MAX_LENGTH) {
|
|
13089
|
-
let text = "";
|
|
13090
|
-
if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
|
|
13091
|
-
else if (typeof content === "string") text = content;
|
|
13092
|
-
if (!text) return null;
|
|
13093
|
-
return Array.from(text).slice(0, maxLen).join("");
|
|
13094
|
-
}
|
|
13095
|
-
/** List sessions for a specific agent, with optional state filters. */
|
|
13096
|
-
async function listAgentSessions(db, agentId, filters) {
|
|
13097
|
-
const conditions = [eq(agentChatSessions.agentId, agentId)];
|
|
13098
|
-
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
13099
|
-
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
13100
|
-
const rows = await db.select({
|
|
13101
|
-
agentId: agentChatSessions.agentId,
|
|
13102
|
-
chatId: agentChatSessions.chatId,
|
|
13103
|
-
state: agentChatSessions.state,
|
|
13104
|
-
updatedAt: agentChatSessions.updatedAt,
|
|
13105
|
-
chatCreatedAt: chats.createdAt,
|
|
13106
|
-
chatTopic: chats.topic
|
|
13107
|
-
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
|
|
13108
|
-
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
13109
|
-
const agentRuntimeState = presence?.runtimeState ?? null;
|
|
13110
|
-
if (filters?.runtimeState && agentRuntimeState !== filters.runtimeState) return [];
|
|
13111
|
-
const chatIds = rows.map((r) => r.chatId);
|
|
13112
|
-
const messageCounts = chatIds.length > 0 ? await db.select({
|
|
13113
|
-
chatId: inboxEntries.chatId,
|
|
13114
|
-
count: sql`count(*)::int`
|
|
13115
|
-
}).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
|
|
13116
|
-
const countMap = new Map(messageCounts.map((r) => [r.chatId, r.count]));
|
|
13117
|
-
const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
13118
|
-
chatId: messages.chatId,
|
|
13119
|
-
content: messages.content
|
|
13120
|
-
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
13121
|
-
const summaryMap = /* @__PURE__ */ new Map();
|
|
13122
|
-
for (const row of firstMessages) {
|
|
13123
|
-
const summary = extractSummary(row.content);
|
|
13124
|
-
if (summary) summaryMap.set(row.chatId, summary);
|
|
13125
|
-
}
|
|
13126
|
-
return rows.map((r) => ({
|
|
13127
|
-
agentId: r.agentId,
|
|
13128
|
-
chatId: r.chatId,
|
|
13129
|
-
state: r.state,
|
|
13130
|
-
runtimeState: agentRuntimeState,
|
|
13131
|
-
startedAt: r.chatCreatedAt.toISOString(),
|
|
13132
|
-
lastActivityAt: r.updatedAt.toISOString(),
|
|
13133
|
-
messageCount: countMap.get(r.chatId) ?? 0,
|
|
13134
|
-
summary: summaryMap.get(r.chatId) ?? null,
|
|
13135
|
-
topic: r.chatTopic ?? null
|
|
13136
|
-
}));
|
|
13137
|
-
}
|
|
13138
|
-
/** Get a single session's detail. */
|
|
13139
|
-
async function getSession(db, agentId, chatId) {
|
|
13140
|
-
const [row] = await db.select({
|
|
13141
|
-
agentId: agentChatSessions.agentId,
|
|
13142
|
-
chatId: agentChatSessions.chatId,
|
|
13143
|
-
state: agentChatSessions.state,
|
|
13144
|
-
updatedAt: agentChatSessions.updatedAt,
|
|
13145
|
-
chatCreatedAt: chats.createdAt,
|
|
13146
|
-
chatTopic: chats.topic
|
|
13147
|
-
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
|
|
13148
|
-
if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
13149
|
-
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
13150
|
-
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)));
|
|
13151
|
-
const firstMsg = (await db.execute(sql`SELECT content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at ASC LIMIT 1`))[0];
|
|
13152
|
-
const summary = firstMsg ? extractSummary(firstMsg.content) : null;
|
|
13153
|
-
return {
|
|
13154
|
-
agentId: row.agentId,
|
|
13155
|
-
chatId: row.chatId,
|
|
13156
|
-
state: row.state,
|
|
13157
|
-
runtimeState: presence?.runtimeState ?? null,
|
|
13158
|
-
startedAt: row.chatCreatedAt.toISOString(),
|
|
13159
|
-
lastActivityAt: row.updatedAt.toISOString(),
|
|
13160
|
-
messageCount: countRow?.count ?? 0,
|
|
13161
|
-
summary,
|
|
13162
|
-
topic: row.chatTopic ?? null
|
|
13163
|
-
};
|
|
13164
|
-
}
|
|
13165
|
-
/** List all sessions across all agents, with pagination. Scoped to organization. */
|
|
13166
|
-
async function listAllSessions(db, limit, cursor, filters) {
|
|
13167
|
-
const conditions = [];
|
|
13168
|
-
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
13169
|
-
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
13170
|
-
if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
|
|
13171
|
-
if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
|
|
13172
|
-
if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
|
|
13173
|
-
const rows = await db.select({
|
|
13174
|
-
agentId: agentChatSessions.agentId,
|
|
13175
|
-
chatId: agentChatSessions.chatId,
|
|
13176
|
-
state: agentChatSessions.state,
|
|
13177
|
-
updatedAt: agentChatSessions.updatedAt,
|
|
13178
|
-
chatCreatedAt: chats.createdAt,
|
|
13179
|
-
chatTopic: chats.topic
|
|
13180
|
-
}).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);
|
|
13181
|
-
const hasMore = rows.length > limit;
|
|
13182
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
13183
|
-
const agentIds = [...new Set(items.map((r) => r.agentId))];
|
|
13184
|
-
const presenceRows = agentIds.length > 0 ? await db.select({
|
|
13185
|
-
agentId: agentPresence.agentId,
|
|
13186
|
-
runtimeState: agentPresence.runtimeState
|
|
13187
|
-
}).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
|
|
13188
|
-
const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
|
|
13189
|
-
const chatIds = [...new Set(items.map((r) => r.chatId))];
|
|
13190
|
-
const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
13191
|
-
chatId: messages.chatId,
|
|
13192
|
-
content: messages.content
|
|
13193
|
-
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
13194
|
-
const summaryMap = /* @__PURE__ */ new Map();
|
|
13195
|
-
for (const row of firstMessages) {
|
|
13196
|
-
const summary = extractSummary(row.content);
|
|
13197
|
-
if (summary) summaryMap.set(row.chatId, summary);
|
|
13198
|
-
}
|
|
13199
|
-
const last = items[items.length - 1];
|
|
13200
|
-
const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
|
|
13201
|
-
return {
|
|
13202
|
-
items: items.map((r) => ({
|
|
13203
|
-
agentId: r.agentId,
|
|
13204
|
-
chatId: r.chatId,
|
|
13205
|
-
state: r.state,
|
|
13206
|
-
runtimeState: runtimeMap.get(r.agentId) ?? null,
|
|
13207
|
-
startedAt: r.chatCreatedAt.toISOString(),
|
|
13208
|
-
lastActivityAt: r.updatedAt.toISOString(),
|
|
13209
|
-
messageCount: 0,
|
|
13210
|
-
summary: summaryMap.get(r.chatId) ?? null,
|
|
13211
|
-
topic: r.chatTopic ?? null
|
|
13212
|
-
})),
|
|
13213
|
-
nextCursor
|
|
13214
|
-
};
|
|
13215
|
-
}
|
|
13216
|
-
/** Commit `active → suspended`. No-op on suspended/evicted. Throws if row is missing. */
|
|
13217
|
-
async function suspendSession(db, agentId, chatId, organizationId, notifier) {
|
|
13218
|
-
return transitionSessionState(db, agentId, chatId, "suspended", ["active"], organizationId, notifier);
|
|
13219
|
-
}
|
|
13220
|
-
/** Commit `suspended → evicted` (terminal — listings hide it, revival defense blocks resurrection). */
|
|
13221
|
-
async function archiveSession(db, agentId, chatId, organizationId, notifier) {
|
|
13222
|
-
return transitionSessionState(db, agentId, chatId, "evicted", ["suspended"], organizationId, notifier);
|
|
13223
|
-
}
|
|
13224
|
-
async function transitionSessionState(db, agentId, chatId, target, from, organizationId, notifier) {
|
|
13225
|
-
const now = /* @__PURE__ */ new Date();
|
|
13226
|
-
let finalState = null;
|
|
13227
|
-
let transitioned = false;
|
|
13228
|
-
await db.transaction(async (tx) => {
|
|
13229
|
-
const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
|
|
13230
|
-
if (!existing) return;
|
|
13231
|
-
const current = existing.state;
|
|
13232
|
-
finalState = current;
|
|
13233
|
-
if (!from.includes(current)) return;
|
|
13234
|
-
await tx.update(agentChatSessions).set({
|
|
13235
|
-
state: target,
|
|
13236
|
-
updatedAt: now
|
|
13237
|
-
}).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId)));
|
|
13238
|
-
const [counts] = await tx.select({
|
|
13239
|
-
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
13240
|
-
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
13241
|
-
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
13242
|
-
await tx.update(agentPresence).set({
|
|
13243
|
-
activeSessions: counts?.active ?? 0,
|
|
13244
|
-
totalSessions: counts?.total ?? 0,
|
|
13245
|
-
lastSeenAt: now
|
|
13246
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
13247
|
-
finalState = target;
|
|
13248
|
-
transitioned = true;
|
|
13249
|
-
});
|
|
13250
|
-
if (finalState === null) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
13251
|
-
if (transitioned && notifier) notifier.notifySessionStateChange(agentId, chatId, target, organizationId).catch(() => {});
|
|
13252
|
-
return {
|
|
13253
|
-
state: finalState,
|
|
13254
|
-
transitioned
|
|
13255
|
-
};
|
|
13256
|
-
}
|
|
13257
|
-
/**
|
|
13258
|
-
* Filter sessions to only those where the given agent is also a participant in the chat.
|
|
13259
|
-
* Used when a non-manager views sessions of an org-visible agent — they should only see
|
|
13260
|
-
* sessions for chats they participate in.
|
|
13261
|
-
*/
|
|
13262
|
-
async function filterSessionsByParticipant(db, sessions, participantAgentId) {
|
|
13263
|
-
if (sessions.length === 0) return [];
|
|
13264
|
-
const chatIds = sessions.map((s) => s.chatId);
|
|
13265
|
-
const participantRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(inArray(chatParticipants.chatId, chatIds), eq(chatParticipants.agentId, participantAgentId)));
|
|
13266
|
-
const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
|
|
13267
|
-
return sessions.filter((s) => allowedChatIds.has(s.chatId));
|
|
13268
|
-
}
|
|
13269
14211
|
/**
|
|
13270
14212
|
* Session events — structured event stream per (agent, chat) session.
|
|
13271
14213
|
* `kind` is 'tool_call' | 'error'; the payload shape is enforced by the
|
|
@@ -14076,6 +15018,33 @@ function adminWsRoutes(notifier, jwtSecret) {
|
|
|
14076
15018
|
...payload
|
|
14077
15019
|
});
|
|
14078
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
|
+
}
|
|
14079
15048
|
return async (app) => {
|
|
14080
15049
|
app.get("/admin", {
|
|
14081
15050
|
websocket: true,
|
|
@@ -14118,7 +15087,8 @@ function adminWsRoutes(notifier, jwtSecret) {
|
|
|
14118
15087
|
}
|
|
14119
15088
|
const [memberRow] = await app.db.select({
|
|
14120
15089
|
id: members.id,
|
|
14121
|
-
role: members.role
|
|
15090
|
+
role: members.role,
|
|
15091
|
+
agentId: members.agentId
|
|
14122
15092
|
}).from(members).where(and(eq(members.userId, userId), eq(members.organizationId, organizationId), eq(members.status, "active"))).limit(1);
|
|
14123
15093
|
if (!memberRow) {
|
|
14124
15094
|
socket.send(JSON.stringify({
|
|
@@ -14134,10 +15104,14 @@ function adminWsRoutes(notifier, jwtSecret) {
|
|
|
14134
15104
|
organizationId,
|
|
14135
15105
|
memberId
|
|
14136
15106
|
});
|
|
15107
|
+
rememberDb(app.db);
|
|
14137
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;
|
|
14138
15111
|
adminSockets.set(socket, {
|
|
14139
15112
|
organizationId,
|
|
14140
15113
|
memberId,
|
|
15114
|
+
humanAgentId,
|
|
14141
15115
|
visibleAgentIds
|
|
14142
15116
|
});
|
|
14143
15117
|
socket.send(JSON.stringify({ type: "admin:connected" }));
|
|
@@ -14406,18 +15380,11 @@ async function pollInbox(db, inboxId, limit) {
|
|
|
14406
15380
|
}
|
|
14407
15381
|
async function pollInboxInner(db, inboxId, limit) {
|
|
14408
15382
|
return db.transaction(async (tx) => {
|
|
14409
|
-
|
|
14410
|
-
|
|
14411
|
-
|
|
14412
|
-
|
|
14413
|
-
|
|
14414
|
-
WHERE inbox_id = ${inboxId} AND status = 'pending' AND notify = true
|
|
14415
|
-
ORDER BY created_at
|
|
14416
|
-
LIMIT ${limit}
|
|
14417
|
-
FOR UPDATE SKIP LOCKED
|
|
14418
|
-
)
|
|
14419
|
-
RETURNING *
|
|
14420
|
-
`));
|
|
15383
|
+
const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(limit).for("update", { skipLocked: true });
|
|
15384
|
+
return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
|
|
15385
|
+
status: "delivered",
|
|
15386
|
+
deliveredAt: /* @__PURE__ */ new Date()
|
|
15387
|
+
}).where(inArray(inboxEntries.id, targetIds)).returning());
|
|
14421
15388
|
});
|
|
14422
15389
|
}
|
|
14423
15390
|
/**
|
|
@@ -14430,7 +15397,7 @@ async function pollInboxInner(db, inboxId, limit) {
|
|
|
14430
15397
|
* hub-inbox-ws-data-plane §3.2 risk #1).
|
|
14431
15398
|
*
|
|
14432
15399
|
* Steps:
|
|
14433
|
-
* 1. Sort by `
|
|
15400
|
+
* 1. Sort by `createdAt` ASC (PG `RETURNING` does not guarantee order).
|
|
14434
15401
|
* 2. For each trigger, collect silent context & bulk-ack stale silent rows.
|
|
14435
15402
|
* 3. Fetch the trigger messages.
|
|
14436
15403
|
* 4. Build wire payloads via the single dispatcher.
|
|
@@ -14439,16 +15406,16 @@ async function pollInboxInner(db, inboxId, limit) {
|
|
|
14439
15406
|
*/
|
|
14440
15407
|
async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
|
|
14441
15408
|
if (claimed.length === 0) return [];
|
|
14442
|
-
claimed.sort((a, b) => a.
|
|
15409
|
+
claimed.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
14443
15410
|
const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
|
|
14444
|
-
const messageIds = claimed.map((e) => e.
|
|
15411
|
+
const messageIds = claimed.map((e) => e.messageId);
|
|
14445
15412
|
const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
|
|
14446
15413
|
const msgMap = new Map(msgs.map((m) => [m.id, m]));
|
|
14447
15414
|
const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
|
|
14448
|
-
const msg = msgMap.get(entry.
|
|
14449
|
-
if (!msg) throw new Error(`Unexpected: message ${entry.
|
|
15415
|
+
const msg = msgMap.get(entry.messageId);
|
|
15416
|
+
if (!msg) throw new Error(`Unexpected: message ${entry.messageId} not found`);
|
|
14450
15417
|
return {
|
|
14451
|
-
entryChatId: entry.
|
|
15418
|
+
entryChatId: entry.chatId,
|
|
14452
15419
|
precedingMessages: precedingByEntryId.get(entry.id) ?? [],
|
|
14453
15420
|
message: {
|
|
14454
15421
|
id: msg.id,
|
|
@@ -14469,15 +15436,15 @@ async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
|
|
|
14469
15436
|
const payload = payloads[idx];
|
|
14470
15437
|
if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
|
|
14471
15438
|
return {
|
|
14472
|
-
id:
|
|
14473
|
-
inboxId: entry.
|
|
14474
|
-
messageId: entry.
|
|
14475
|
-
chatId: entry.
|
|
15439
|
+
id: entry.id,
|
|
15440
|
+
inboxId: entry.inboxId,
|
|
15441
|
+
messageId: entry.messageId,
|
|
15442
|
+
chatId: entry.chatId,
|
|
14476
15443
|
status: entry.status,
|
|
14477
|
-
retryCount: entry.
|
|
14478
|
-
createdAt: entry.
|
|
14479
|
-
deliveredAt: entry.
|
|
14480
|
-
ackedAt: entry.
|
|
15444
|
+
retryCount: entry.retryCount,
|
|
15445
|
+
createdAt: entry.createdAt.toISOString(),
|
|
15446
|
+
deliveredAt: entry.deliveredAt?.toISOString() ?? null,
|
|
15447
|
+
ackedAt: entry.ackedAt?.toISOString() ?? null,
|
|
14481
15448
|
message: payload
|
|
14482
15449
|
};
|
|
14483
15450
|
});
|
|
@@ -14512,21 +15479,11 @@ async function claimAndBuildForPush(db, inboxId, messageId) {
|
|
|
14512
15479
|
"inbox.id": inboxId,
|
|
14513
15480
|
"message.id": messageId
|
|
14514
15481
|
}, () => db.transaction(async (tx) => {
|
|
14515
|
-
|
|
14516
|
-
|
|
14517
|
-
|
|
14518
|
-
|
|
14519
|
-
|
|
14520
|
-
WHERE inbox_id = ${inboxId}
|
|
14521
|
-
AND message_id = ${messageId}
|
|
14522
|
-
AND status = 'pending'
|
|
14523
|
-
AND notify = true
|
|
14524
|
-
ORDER BY created_at
|
|
14525
|
-
LIMIT ${PUSH_CLAIM_BATCH_LIMIT}
|
|
14526
|
-
FOR UPDATE SKIP LOCKED
|
|
14527
|
-
)
|
|
14528
|
-
RETURNING *
|
|
14529
|
-
`));
|
|
15482
|
+
const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.messageId, messageId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(PUSH_CLAIM_BATCH_LIMIT).for("update", { skipLocked: true });
|
|
15483
|
+
return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
|
|
15484
|
+
status: "delivered",
|
|
15485
|
+
deliveredAt: /* @__PURE__ */ new Date()
|
|
15486
|
+
}).where(inArray(inboxEntries.id, targetIds)).returning());
|
|
14530
15487
|
}));
|
|
14531
15488
|
}
|
|
14532
15489
|
/**
|
|
@@ -14549,7 +15506,7 @@ async function claimBacklogForPush(db, inboxId, limit) {
|
|
|
14549
15506
|
* `PRECEDING_CONTEXT_WINDOW_SECONDS`. Returned messages are oldest-first.
|
|
14550
15507
|
*
|
|
14551
15508
|
* Side effect: bulk-ack ALL silent pending rows in each chat with
|
|
14552
|
-
*
|
|
15509
|
+
* createdAt < latest_trigger.createdAt — including ones that fell outside
|
|
14553
15510
|
* the window/cap. Otherwise stale silent rows would accumulate and re-load
|
|
14554
15511
|
* on every poll.
|
|
14555
15512
|
*/
|
|
@@ -14557,51 +15514,41 @@ async function collectPrecedingContext(tx, inboxId, triggers) {
|
|
|
14557
15514
|
const result = /* @__PURE__ */ new Map();
|
|
14558
15515
|
const byChat = /* @__PURE__ */ new Map();
|
|
14559
15516
|
for (const t of triggers) {
|
|
14560
|
-
if (t.
|
|
14561
|
-
const list = byChat.get(t.
|
|
15517
|
+
if (t.chatId === null) continue;
|
|
15518
|
+
const list = byChat.get(t.chatId) ?? [];
|
|
14562
15519
|
list.push(t);
|
|
14563
|
-
byChat.set(t.
|
|
15520
|
+
byChat.set(t.chatId, list);
|
|
14564
15521
|
}
|
|
14565
15522
|
for (const [chatId, chatTriggers] of byChat) {
|
|
14566
|
-
chatTriggers.sort((a, b) => a.
|
|
15523
|
+
chatTriggers.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
14567
15524
|
let prevCreatedAt = null;
|
|
14568
15525
|
for (const trigger of chatTriggers) {
|
|
14569
|
-
const preceding = (await tx.
|
|
14570
|
-
|
|
14571
|
-
|
|
14572
|
-
|
|
14573
|
-
|
|
14574
|
-
|
|
14575
|
-
|
|
14576
|
-
|
|
14577
|
-
|
|
14578
|
-
|
|
14579
|
-
|
|
14580
|
-
|
|
14581
|
-
|
|
14582
|
-
LIMIT ${50}
|
|
14583
|
-
FOR UPDATE OF ie SKIP LOCKED
|
|
14584
|
-
`)).map((r) => ({
|
|
14585
|
-
id: r.message_id,
|
|
14586
|
-
senderId: r.sender_id,
|
|
15526
|
+
const preceding = (await tx.select({
|
|
15527
|
+
messageId: messages.id,
|
|
15528
|
+
senderId: messages.senderId,
|
|
15529
|
+
format: messages.format,
|
|
15530
|
+
content: messages.content,
|
|
15531
|
+
metadata: messages.metadata,
|
|
15532
|
+
createdAt: messages.createdAt
|
|
15533
|
+
}).from(inboxEntries).innerJoin(messages, eq(messages.id, inboxEntries.messageId)).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, trigger.createdAt), prevCreatedAt === null ? void 0 : gt(inboxEntries.createdAt, prevCreatedAt), sql`${inboxEntries.createdAt} > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})`)).orderBy(desc(inboxEntries.createdAt)).limit(50).for("update", {
|
|
15534
|
+
of: inboxEntries,
|
|
15535
|
+
skipLocked: true
|
|
15536
|
+
})).map((r) => ({
|
|
15537
|
+
id: r.messageId,
|
|
15538
|
+
senderId: r.senderId,
|
|
14587
15539
|
format: r.format,
|
|
14588
15540
|
content: r.content,
|
|
14589
15541
|
metadata: r.metadata ?? {},
|
|
14590
|
-
createdAt: r.
|
|
15542
|
+
createdAt: r.createdAt.toISOString()
|
|
14591
15543
|
})).reverse();
|
|
14592
15544
|
result.set(trigger.id, preceding);
|
|
14593
|
-
prevCreatedAt = trigger.
|
|
15545
|
+
prevCreatedAt = trigger.createdAt;
|
|
14594
15546
|
}
|
|
14595
15547
|
const latestTrigger = chatTriggers[chatTriggers.length - 1];
|
|
14596
|
-
if (latestTrigger) await tx.
|
|
14597
|
-
|
|
14598
|
-
|
|
14599
|
-
|
|
14600
|
-
AND chat_id = ${chatId}
|
|
14601
|
-
AND status = 'pending'
|
|
14602
|
-
AND notify = false
|
|
14603
|
-
AND created_at < ${latestTrigger.created_at}
|
|
14604
|
-
`);
|
|
15548
|
+
if (latestTrigger) await tx.update(inboxEntries).set({
|
|
15549
|
+
status: "acked",
|
|
15550
|
+
ackedAt: /* @__PURE__ */ new Date()
|
|
15551
|
+
}).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, latestTrigger.createdAt)));
|
|
14605
15552
|
}
|
|
14606
15553
|
return result;
|
|
14607
15554
|
}
|
|
@@ -14644,23 +15591,14 @@ async function renewEntry(db, entryId, inboxId) {
|
|
|
14644
15591
|
return entry;
|
|
14645
15592
|
}
|
|
14646
15593
|
async function resetTimedOutEntries(db, timeoutSeconds = DEFAULT_INBOX_TIMEOUT_SECONDS, maxRetries = DEFAULT_MAX_RETRY_COUNT) {
|
|
14647
|
-
const
|
|
14648
|
-
|
|
14649
|
-
|
|
14650
|
-
|
|
14651
|
-
|
|
14652
|
-
RETURNING id
|
|
14653
|
-
`);
|
|
14654
|
-
const failedResult = await db.execute(sql`
|
|
14655
|
-
UPDATE inbox_entries SET status = 'failed'
|
|
14656
|
-
WHERE status = 'delivered'
|
|
14657
|
-
AND delivered_at < NOW() - make_interval(secs => ${timeoutSeconds})
|
|
14658
|
-
AND retry_count >= ${maxRetries}
|
|
14659
|
-
RETURNING id
|
|
14660
|
-
`);
|
|
15594
|
+
const reset = await db.update(inboxEntries).set({
|
|
15595
|
+
status: "pending",
|
|
15596
|
+
retryCount: sql`${inboxEntries.retryCount} + 1`
|
|
15597
|
+
}).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, lt(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
|
|
15598
|
+
const failed = await db.update(inboxEntries).set({ status: "failed" }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, gte(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
|
|
14661
15599
|
return {
|
|
14662
|
-
reset:
|
|
14663
|
-
failed:
|
|
15600
|
+
reset: reset.length,
|
|
15601
|
+
failed: failed.length
|
|
14664
15602
|
};
|
|
14665
15603
|
}
|
|
14666
15604
|
/** Default age (30 days) past which silent rows that no notify-true delivery
|
|
@@ -14679,7 +15617,7 @@ const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
|
|
|
14679
15617
|
* `(inbox_id, message_id, chat_id)` means leaving them around blocks
|
|
14680
15618
|
* legitimate retries with the same key.
|
|
14681
15619
|
*
|
|
14682
|
-
* 2. `notify=false AND status='pending' AND
|
|
15620
|
+
* 2. `notify=false AND status='pending' AND createdAt < NOW() - maxAge` —
|
|
14683
15621
|
* stale silent rows that no trigger ever caught up with. After 30
|
|
14684
15622
|
* days they're useless as preceding context (the @mention almost
|
|
14685
15623
|
* certainly already happened or the chat went dormant).
|
|
@@ -14688,22 +15626,11 @@ const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
|
|
|
14688
15626
|
* can log meaningful counts.
|
|
14689
15627
|
*/
|
|
14690
15628
|
async function pruneStaleSilentEntries(db, maxAgeSeconds = SILENT_ROW_GC_MAX_AGE_SECONDS) {
|
|
14691
|
-
const
|
|
14692
|
-
|
|
14693
|
-
WHERE notify = false
|
|
14694
|
-
AND status = 'acked'
|
|
14695
|
-
RETURNING id
|
|
14696
|
-
`);
|
|
14697
|
-
const staleResult = await db.execute(sql`
|
|
14698
|
-
DELETE FROM inbox_entries
|
|
14699
|
-
WHERE notify = false
|
|
14700
|
-
AND status = 'pending'
|
|
14701
|
-
AND created_at < NOW() - make_interval(secs => ${maxAgeSeconds})
|
|
14702
|
-
RETURNING id
|
|
14703
|
-
`);
|
|
15629
|
+
const ackedDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "acked"))).returning({ id: inboxEntries.id });
|
|
15630
|
+
const stalePendingDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "pending"), sql`${inboxEntries.createdAt} < NOW() - make_interval(secs => ${maxAgeSeconds})`)).returning({ id: inboxEntries.id });
|
|
14704
15631
|
return {
|
|
14705
|
-
ackedDeleted:
|
|
14706
|
-
stalePendingDeleted:
|
|
15632
|
+
ackedDeleted: ackedDeleted.length,
|
|
15633
|
+
stalePendingDeleted: stalePendingDeleted.length
|
|
14707
15634
|
};
|
|
14708
15635
|
}
|
|
14709
15636
|
async function agentInboxRoutes(app) {
|
|
@@ -15581,8 +16508,9 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
15581
16508
|
}
|
|
15582
16509
|
boundAgents.clear();
|
|
15583
16510
|
if (clientId) {
|
|
16511
|
+
const stillActive = isActiveClientConnection(clientId, socket);
|
|
15584
16512
|
removeClientConnection(clientId, socket);
|
|
15585
|
-
try {
|
|
16513
|
+
if (stillActive) try {
|
|
15586
16514
|
await disconnectClient(app.db, clientId);
|
|
15587
16515
|
} catch {}
|
|
15588
16516
|
}
|
|
@@ -15766,6 +16694,7 @@ async function ensureMembership(db, data) {
|
|
|
15766
16694
|
if (existing) {
|
|
15767
16695
|
if (existing.status === "left") {
|
|
15768
16696
|
await db.update(members).set({ status: "active" }).where(eq(members.id, existing.id));
|
|
16697
|
+
await recomputeWatchersForMember(db, existing.id);
|
|
15769
16698
|
return {
|
|
15770
16699
|
...existing,
|
|
15771
16700
|
status: "active"
|
|
@@ -15893,11 +16822,18 @@ async function pickPrimaryMembership(db, userId) {
|
|
|
15893
16822
|
* (last admin allowed to leave, leaves an orphan team) and the cleanup is
|
|
15894
16823
|
* a v2 sweep job.
|
|
15895
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
|
+
*/
|
|
15896
16831
|
async function leaveOrganization(db, memberId) {
|
|
15897
16832
|
const [existing] = await db.select().from(members).where(eq(members.id, memberId)).limit(1);
|
|
15898
16833
|
if (!existing) throw new NotFoundError(`Membership "${memberId}" not found`);
|
|
15899
16834
|
if (existing.status === "left") return existing;
|
|
15900
16835
|
await db.update(members).set({ status: "left" }).where(eq(members.id, memberId));
|
|
16836
|
+
await recomputeWatchersForMember(db, memberId);
|
|
15901
16837
|
return {
|
|
15902
16838
|
...existing,
|
|
15903
16839
|
status: "left"
|
|
@@ -16555,7 +17491,7 @@ async function inferWizardStep(app, m) {
|
|
|
16555
17491
|
* landing page.
|
|
16556
17492
|
*/
|
|
16557
17493
|
async function publicInvitePreviewRoute(app) {
|
|
16558
|
-
const { previewInvitation } = await import("./invitation-CBnQyB7o-
|
|
17494
|
+
const { previewInvitation } = await import("./invitation-CBnQyB7o-TmnIj3kx.mjs");
|
|
16559
17495
|
app.get("/:token/preview", async (request, reply) => {
|
|
16560
17496
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
16561
17497
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -16585,7 +17521,7 @@ async function adminInvitationRoutes(app) {
|
|
|
16585
17521
|
const m = requireMember(request);
|
|
16586
17522
|
if (m.role !== "admin") throw new ForbiddenError("Admin role required");
|
|
16587
17523
|
if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
|
|
16588
|
-
const { rotateInvitation } = await import("./invitation-CBnQyB7o-
|
|
17524
|
+
const { rotateInvitation } = await import("./invitation-CBnQyB7o-TmnIj3kx.mjs");
|
|
16589
17525
|
const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
|
|
16590
17526
|
return {
|
|
16591
17527
|
id: inv.id,
|
|
@@ -16598,6 +17534,57 @@ async function adminInvitationRoutes(app) {
|
|
|
16598
17534
|
};
|
|
16599
17535
|
});
|
|
16600
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
|
+
}
|
|
16601
17588
|
const SALT_ROUNDS = 10;
|
|
16602
17589
|
/**
|
|
16603
17590
|
* Create a member in an organization.
|
|
@@ -17236,6 +18223,7 @@ var schema_exports = /* @__PURE__ */ __exportAll({
|
|
|
17236
18223
|
agents: () => agents,
|
|
17237
18224
|
authIdentities: () => authIdentities,
|
|
17238
18225
|
chatParticipants: () => chatParticipants,
|
|
18226
|
+
chatSubscriptions: () => chatSubscriptions,
|
|
17239
18227
|
chats: () => chats,
|
|
17240
18228
|
clients: () => clients,
|
|
17241
18229
|
inboxEntries: () => inboxEntries,
|
|
@@ -18580,6 +19568,10 @@ async function buildApp(config) {
|
|
|
18580
19568
|
adminApp.addHook("onRequest", memberAuth);
|
|
18581
19569
|
await adminApp.register(adminChatRoutes);
|
|
18582
19570
|
}, { prefix: "/admin/chats" });
|
|
19571
|
+
await api.register(async (memberApp) => {
|
|
19572
|
+
memberApp.addHook("onRequest", memberAuth);
|
|
19573
|
+
await memberApp.register(meChatRoutes);
|
|
19574
|
+
}, { prefix: "/me/chats" });
|
|
18583
19575
|
await api.register(async (adminApp) => {
|
|
18584
19576
|
adminApp.addHook("onRequest", memberAuth);
|
|
18585
19577
|
await adminApp.register(adminClientRoutes);
|
|
@@ -18686,6 +19678,13 @@ async function buildApp(config) {
|
|
|
18686
19678
|
kaelRuntime?.reload().catch((err) => hotReloadLog.error({ err }, "kael hot-reload failed (PG NOTIFY)"));
|
|
18687
19679
|
}
|
|
18688
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
|
+
});
|
|
18689
19688
|
app.addHook("onReady", async () => {
|
|
18690
19689
|
await ensureDefaultOrganization(db);
|
|
18691
19690
|
await notifier.start();
|