@agent-team-foundation/first-tree-hub 0.12.7 → 0.12.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.mjs +39 -4
- package/dist/{client-93HZWg84-MIPzQD9A.mjs → client-DNEtPEBu-BtHkUya2.mjs} +2 -2
- package/dist/{client-h5l7mi0m-OEX7MOBg.mjs → client-bR8nwHaV-OxnjyKOk.mjs} +314 -276
- package/dist/{dist-CTkhS6p5.mjs → dist-CnjqakXS.mjs} +39 -10
- package/dist/drizzle/0038_chat_membership_user_state.sql +223 -0
- package/dist/drizzle/0039_drop_chat_participants_subscriptions.sql +26 -0
- package/dist/drizzle/0040_chat_user_state_engagement.sql +24 -0
- package/dist/drizzle/meta/_journal.json +21 -0
- package/dist/{feishu-DJm0EaZP.mjs → feishu-DrnBbl8T.mjs} +1 -1
- package/dist/index.mjs +4 -4
- package/dist/{invitation-C299fxkP-jQiGR5fl.mjs → invitation-C299fxkP-KKslbta2.mjs} +1 -1
- package/dist/{saas-connect-CY2NxeKx.mjs → saas-connect-CLcon-De.mjs} +192 -116
- package/dist/web/assets/{index-JGwkYWtM.js → index-BPMrSv_A.js} +1 -1
- package/dist/web/assets/index-DxAYxUpz.css +1 -0
- package/dist/web/assets/index-ntmzuk5X.js +421 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BKbK8BhK.css +0 -1
- package/dist/web/assets/index-BNM-YSSu.js +0 -421
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { O as withSpan, f as messageAttrs, s as createLogger } from "./observability-BAScT_5S-BcW9HgkG.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { N as extractMentions, a as AGENT_VISIBILITY, et as questionAnswerMessageContentSchema, g as agentTypeSchema, i as AGENT_STATUSES, k as defaultParticipantMode, o as CHAT_ENGAGEMENT_STATUSES, ot as scanMentionTokens, tt as questionMessageContentSchema, v as clientCapabilitiesSchema } from "./dist-CnjqakXS.mjs";
|
|
3
3
|
import { a as ConflictError, i as ClientUserMismatchError, l as organizations, n as BadRequestError, o as ForbiddenError, s as NotFoundError, u as users } from "./errors-CF5evtJt-B0NTIVPt.mjs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { and, desc, eq, inArray, isNotNull, lt, ne, or, sql } from "drizzle-orm";
|
|
6
6
|
import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, unique } from "drizzle-orm/pg-core";
|
|
7
|
-
//#region ../server/dist/client-
|
|
7
|
+
//#region ../server/dist/client-bR8nwHaV.mjs
|
|
8
8
|
/**
|
|
9
9
|
* Client connections. A client is a single SDK process (AgentRuntime) that may
|
|
10
10
|
* host multiple agents. From the unified-user-token milestone on, a client is
|
|
@@ -58,6 +58,39 @@ const agents = pgTable("agents", {
|
|
|
58
58
|
index("idx_agents_client").on(table.clientId),
|
|
59
59
|
unique("uq_agents_org_name").on(table.organizationId, table.name)
|
|
60
60
|
]);
|
|
61
|
+
/**
|
|
62
|
+
* Unified membership table. Replaces the two-table split
|
|
63
|
+
* (chat_participants speakers ∪ chat_subscriptions watchers) — both
|
|
64
|
+
* collapse into a single row keyed by (chat_id, agent_id) with two
|
|
65
|
+
* orthogonal columns:
|
|
66
|
+
*
|
|
67
|
+
* - `role` ∈ owner / member (creator-vs-member, governs admin actions)
|
|
68
|
+
* - `access_mode` ∈ speaker / watcher (fan-out + mention candidacy)
|
|
69
|
+
*
|
|
70
|
+
* `(owner, speaker)`, `(member, speaker)`, `(member, watcher)` are the
|
|
71
|
+
* legal combinations. `(owner, watcher)` is structurally possible but
|
|
72
|
+
* never produced by v1 paths — the creator is always a speaker.
|
|
73
|
+
*
|
|
74
|
+
* Service-layer integrity (no FK / CHECK / trigger), matching the
|
|
75
|
+
* messages / inbox_entries / notifications convention. Chat hard-delete
|
|
76
|
+
* paths must explicitly DELETE rows here (service-level cascade) — the
|
|
77
|
+
* old DB-level `ON DELETE CASCADE` is intentionally not preserved.
|
|
78
|
+
*
|
|
79
|
+
* See proposals/chat-data-model-restructure.20260512.md §8.
|
|
80
|
+
*/
|
|
81
|
+
const chatMembership = pgTable("chat_membership", {
|
|
82
|
+
chatId: text("chat_id").notNull(),
|
|
83
|
+
agentId: text("agent_id").notNull(),
|
|
84
|
+
role: text("role").notNull().default("member"),
|
|
85
|
+
accessMode: text("access_mode").notNull(),
|
|
86
|
+
mode: text("mode").notNull().default("full"),
|
|
87
|
+
source: text("source").notNull().default("manual"),
|
|
88
|
+
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
|
|
89
|
+
}, (table) => [
|
|
90
|
+
primaryKey({ columns: [table.chatId, table.agentId] }),
|
|
91
|
+
index("idx_membership_agent").on(table.agentId),
|
|
92
|
+
index("idx_membership_chat_role").on(table.chatId, table.accessMode)
|
|
93
|
+
]);
|
|
61
94
|
/** Communication container. All messages between agents flow within a Chat. */
|
|
62
95
|
const chats = pgTable("chats", {
|
|
63
96
|
id: text("id").primaryKey(),
|
|
@@ -72,39 +105,6 @@ const chats = pgTable("chats", {
|
|
|
72
105
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
73
106
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
74
107
|
}, (table) => [index("idx_chats_org_last_message").on(table.organizationId, desc(table.lastMessageAt))]);
|
|
75
|
-
/** Speaking participants of a chat (M:N). Watchers live in chat_subscriptions. */
|
|
76
|
-
const chatParticipants = pgTable("chat_participants", {
|
|
77
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
78
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
79
|
-
role: text("role").notNull().default("member"),
|
|
80
|
-
mode: text("mode").notNull().default("full"),
|
|
81
|
-
lastReadAt: timestamp("last_read_at", { withTimezone: true }),
|
|
82
|
-
unreadMentionCount: integer("unread_mention_count").notNull().default(0),
|
|
83
|
-
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
|
|
84
|
-
}, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_participants_agent").on(table.agentId)]);
|
|
85
|
-
/**
|
|
86
|
-
* Non-speaking observers ("watchers"). Used by the chat-first workspace so a
|
|
87
|
-
* user can supervise chats their managed agents participate in without
|
|
88
|
-
* accidentally being part of fan-out.
|
|
89
|
-
*
|
|
90
|
-
* Invariants:
|
|
91
|
-
* 1. (chat_id, agent_id) is mutually exclusive with chat_participants.
|
|
92
|
-
* 2. Rows here NEVER produce inbox_entries (fan-out exclusivity).
|
|
93
|
-
* 3. Mention candidate resolution NEVER includes these rows.
|
|
94
|
-
* 4. State transitions (join/leave) carry last_read_at + counter; lifecycle
|
|
95
|
-
* recomputes default to NULL/0 and MUST NOT run on the join/leave path.
|
|
96
|
-
*
|
|
97
|
-
* See docs/chat-first-workspace-product-design.md "Data Model" + "State
|
|
98
|
-
* Transitions" for the full contract.
|
|
99
|
-
*/
|
|
100
|
-
const chatSubscriptions = pgTable("chat_subscriptions", {
|
|
101
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
102
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
103
|
-
kind: text("kind").notNull().default("watching"),
|
|
104
|
-
lastReadAt: timestamp("last_read_at", { withTimezone: true }),
|
|
105
|
-
unreadMentionCount: integer("unread_mention_count").notNull().default(0),
|
|
106
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
107
|
-
}, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_chat_subscriptions_agent").on(table.agentId)]);
|
|
108
108
|
/** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
|
|
109
109
|
const members = pgTable("members", {
|
|
110
110
|
id: text("id").primaryKey(),
|
|
@@ -121,10 +121,10 @@ const members = pgTable("members", {
|
|
|
121
121
|
]);
|
|
122
122
|
/**
|
|
123
123
|
* Process-local cache for the per-chat realtime push audience
|
|
124
|
-
* (`
|
|
125
|
-
* uuid). Sits in front of the admin WS dispatch
|
|
126
|
-
* messages/sec doesn't issue N audience-resolution
|
|
127
|
-
* + cache hit per chat per TTL window.
|
|
124
|
+
* (every row in `chat_membership` for the chat — speakers + watchers,
|
|
125
|
+
* keyed by human agent uuid). Sits in front of the admin WS dispatch
|
|
126
|
+
* so a chat with N messages/sec doesn't issue N audience-resolution
|
|
127
|
+
* queries; one query + cache hit per chat per TTL window.
|
|
128
128
|
*
|
|
129
129
|
* The cache exposes both a populator (`getCachedAudience`) and an
|
|
130
130
|
* invalidator (`invalidateChatAudience`). Participant-mutation paths
|
|
@@ -156,9 +156,7 @@ async function getCachedAudience(db, chatId) {
|
|
|
156
156
|
if (cached && cached.expiresAt > now) return cached.audience;
|
|
157
157
|
try {
|
|
158
158
|
const rows = await db.execute(sql`
|
|
159
|
-
SELECT agent_id FROM
|
|
160
|
-
UNION
|
|
161
|
-
SELECT agent_id FROM chat_subscriptions WHERE chat_id = ${chatId}
|
|
159
|
+
SELECT agent_id FROM chat_membership WHERE chat_id = ${chatId}
|
|
162
160
|
`);
|
|
163
161
|
const audience = new Set(rows.map((r) => r.agent_id));
|
|
164
162
|
cache.set(chatId, {
|
|
@@ -185,20 +183,25 @@ function invalidateChatAudience(chatId) {
|
|
|
185
183
|
cache.delete(chatId);
|
|
186
184
|
}
|
|
187
185
|
/**
|
|
188
|
-
* Single source of truth for writing `
|
|
186
|
+
* Single source of truth for writing speaker rows into `chat_membership`.
|
|
187
|
+
*
|
|
188
|
+
* **This is the ONLY place in the codebase that may INSERT speaker rows
|
|
189
|
+
* (access_mode = 'speaker') into `chat_membership`.** Do not call
|
|
190
|
+
* `tx.insert(chatMembership)` with `accessMode: 'speaker'` from anywhere
|
|
191
|
+
* else. The original bug (docs/chat-participant-mode-fix-design.md §1.1)
|
|
192
|
+
* was caused by mode-derivation logic scattered across ten insert sites,
|
|
193
|
+
* several of which violated `group + non-human ⇒ mention_only`.
|
|
194
|
+
* Re-introducing a second writer reopens that hole — please don't.
|
|
189
195
|
*
|
|
190
|
-
*
|
|
191
|
-
* `
|
|
192
|
-
*
|
|
193
|
-
* (
|
|
194
|
-
* scattered insert sites each re-deriving the `mode` rule, several of
|
|
195
|
-
* which violated `group + non-human ⇒ mention_only`. Re-introducing a
|
|
196
|
-
* second writer reopens that hole — please don't.
|
|
196
|
+
* Watcher rows (access_mode = 'watcher') are written from
|
|
197
|
+
* `services/watcher.ts::recomputeChatWatchers` via raw SQL; they don't
|
|
198
|
+
* go through this service because the mode rule is `full` by construction
|
|
199
|
+
* for watchers (they receive but don't fan out).
|
|
197
200
|
*
|
|
198
|
-
* Test fixtures under `src/__tests__/` that deliberately seed
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
201
|
+
* Test fixtures under `src/__tests__/` that deliberately seed pathological
|
|
202
|
+
* rows (e.g. cross-org pollution tests) may bypass this rule; they are
|
|
203
|
+
* setting up "what bad data looks like" rather than exercising the
|
|
204
|
+
* production write path.
|
|
202
205
|
*
|
|
203
206
|
* All callers that need to add a participant — `createChat`, `addParticipant`,
|
|
204
207
|
* `ensureParticipant`, `joinChat`, `createMeChat`, `addMeChatParticipants`,
|
|
@@ -211,13 +214,20 @@ function invalidateChatAudience(chatId) {
|
|
|
211
214
|
*
|
|
212
215
|
* `changeChatType` complements it on the type-flip path: when a `direct`
|
|
213
216
|
* chat is being upgraded to `group` by the very next participant insert, the
|
|
214
|
-
* existing non-human
|
|
215
|
-
* trigger an upgrade are expected to invoke `changeChatType` BEFORE
|
|
217
|
+
* existing non-human speakers must be re-graded to `mention_only`. Callers
|
|
218
|
+
* that trigger an upgrade are expected to invoke `changeChatType` BEFORE
|
|
216
219
|
* `addChatParticipants`, inside the same transaction, so the new row picks
|
|
217
220
|
* up the post-upgrade `chats.type` and existing rows get re-graded together.
|
|
221
|
+
*
|
|
222
|
+
* Read state (`last_read_at` / `unread_mention_count`) is no longer carried
|
|
223
|
+
* here: per the chat-data-model-restructure (proposal §8), it lives in a
|
|
224
|
+
* structurally separate `chat_user_state` table whose rows survive
|
|
225
|
+
* access_mode transitions untouched. A watcher → speaker promotion just
|
|
226
|
+
* UPDATEs `chat_membership.access_mode`; the `chat_user_state` row (if any)
|
|
227
|
+
* is unaffected — no state-carry transaction needed.
|
|
218
228
|
*/
|
|
219
229
|
/**
|
|
220
|
-
* Insert
|
|
230
|
+
* Insert speaker rows whose `mode` is derived from `(chats.type, agents.type)`.
|
|
221
231
|
*
|
|
222
232
|
* Reads:
|
|
223
233
|
* - `chats.type` for the target chat (NotFoundError on missing)
|
|
@@ -225,16 +235,16 @@ function invalidateChatAudience(chatId) {
|
|
|
225
235
|
*
|
|
226
236
|
* Mode derivation:
|
|
227
237
|
* - for each row, `peerAgentTypes` is the type of every OTHER participant
|
|
228
|
-
* being inserted in the same call PLUS every EXISTING
|
|
238
|
+
* being inserted in the same call PLUS every EXISTING speaker of
|
|
229
239
|
* the chat. This matters only for `direct` chats; the helper ignores
|
|
230
240
|
* it for `group` / `thread`.
|
|
231
241
|
*
|
|
232
242
|
* Writes one INSERT (multi-row) per call.
|
|
233
243
|
*
|
|
234
244
|
* No watcher / audience-cache side effects — the caller owns those, since
|
|
235
|
-
* different entrypoints have different surrounding work (
|
|
236
|
-
*
|
|
237
|
-
*
|
|
245
|
+
* different entrypoints have different surrounding work (watcher recompute,
|
|
246
|
+
* audience invalidation). Keeping this module side-effect-free makes it
|
|
247
|
+
* testable from any tx context.
|
|
238
248
|
*/
|
|
239
249
|
async function addChatParticipants(tx, chatId, participants, options = {}) {
|
|
240
250
|
if (participants.length === 0) return;
|
|
@@ -266,35 +276,51 @@ async function addChatParticipants(tx, chatId, participants, options = {}) {
|
|
|
266
276
|
chatId,
|
|
267
277
|
agentId: spec.agentId,
|
|
268
278
|
role: spec.role ?? "member",
|
|
279
|
+
accessMode: "speaker",
|
|
269
280
|
mode: defaultParticipantMode(chatType, agentType, peerTypesForRow),
|
|
270
|
-
|
|
271
|
-
unreadMentionCount: spec.carriedReadState?.unreadMentionCount ?? 0
|
|
281
|
+
source: "manual"
|
|
272
282
|
};
|
|
273
283
|
});
|
|
274
|
-
const insert = tx.insert(
|
|
275
|
-
if (options.
|
|
284
|
+
const insert = tx.insert(chatMembership).values(rows);
|
|
285
|
+
if (options.upgradeWatcherToSpeaker) await insert.onConflictDoUpdate({
|
|
286
|
+
target: [chatMembership.chatId, chatMembership.agentId],
|
|
287
|
+
set: {
|
|
288
|
+
accessMode: "speaker",
|
|
289
|
+
mode: sqlExcluded("mode"),
|
|
290
|
+
source: "manual"
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
else if (options.onConflictDoNothing) await insert.onConflictDoNothing({ target: [chatMembership.chatId, chatMembership.agentId] });
|
|
276
294
|
else await insert;
|
|
277
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* Drizzle helper: reference `excluded.<col>` in an UPSERT's UPDATE clause.
|
|
298
|
+
* Returned as untyped SQL because Drizzle's type system doesn't model the
|
|
299
|
+
* `excluded` pseudo-row, and we only use it for two simple text columns
|
|
300
|
+
* here. Centralised so callers don't have to import `sql` just for this.
|
|
301
|
+
*/
|
|
302
|
+
function sqlExcluded(column) {
|
|
303
|
+
return sql.raw(`excluded.${column}`);
|
|
304
|
+
}
|
|
278
305
|
async function loadExistingAgentTypes(tx, chatId, excludeAgentIds) {
|
|
279
306
|
return (await tx.select({
|
|
280
307
|
type: agents.type,
|
|
281
|
-
agentId:
|
|
282
|
-
}).from(
|
|
308
|
+
agentId: chatMembership.agentId
|
|
309
|
+
}).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).filter((r) => !excludeAgentIds.has(r.agentId)).map((r) => r.type);
|
|
283
310
|
}
|
|
284
311
|
/**
|
|
285
312
|
* Upgrade `chats.type` from `direct` → `group` AND re-grade every existing
|
|
286
|
-
* non-human
|
|
313
|
+
* non-human speaker to `mention_only`. Idempotent: if `chat.type` is
|
|
287
314
|
* already `group` (or any non-`direct` value), no-op.
|
|
288
315
|
*
|
|
289
|
-
* Callers that are about to insert a 3rd
|
|
316
|
+
* Callers that are about to insert a 3rd speaker on a `direct` chat
|
|
290
317
|
* invoke this BEFORE `addChatParticipants` so the new row picks up the
|
|
291
318
|
* post-upgrade `chats.type` and the existing rows are re-graded in the
|
|
292
319
|
* same transaction.
|
|
293
320
|
*
|
|
294
|
-
*
|
|
295
|
-
* `
|
|
296
|
-
*
|
|
297
|
-
* primary mutation; `maybe…ToGroup` overstated the conditional gate.
|
|
321
|
+
* Re-grade is gated on `access_mode = 'speaker'` — watcher rows already
|
|
322
|
+
* have `mode = 'full'` by construction (recompute writes that literal)
|
|
323
|
+
* and don't participate in fan-out, so they need no touching.
|
|
298
324
|
*/
|
|
299
325
|
async function changeChatType(tx, chatId, newType) {
|
|
300
326
|
const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
@@ -305,9 +331,9 @@ async function changeChatType(tx, chatId, newType) {
|
|
|
305
331
|
type: newType,
|
|
306
332
|
updatedAt: /* @__PURE__ */ new Date()
|
|
307
333
|
}).where(eq(chats.id, chatId));
|
|
308
|
-
const ids = (await tx.select({ agentId:
|
|
334
|
+
const ids = (await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker"), ne(agents.type, "human")))).map((r) => r.agentId);
|
|
309
335
|
if (ids.length === 0) return;
|
|
310
|
-
await tx.update(
|
|
336
|
+
await tx.update(chatMembership).set({ mode: "mention_only" }).where(and(eq(chatMembership.chatId, chatId), inArray(chatMembership.agentId, ids), eq(chatMembership.accessMode, "speaker")));
|
|
311
337
|
}
|
|
312
338
|
/**
|
|
313
339
|
* Heuristic for whether an insert about to happen would push the chat past
|
|
@@ -315,182 +341,187 @@ async function changeChatType(tx, chatId, newType) {
|
|
|
315
341
|
* to call `changeChatType` before `addChatParticipants` without re-deriving
|
|
316
342
|
* the rule locally.
|
|
317
343
|
*/
|
|
318
|
-
function wouldUpgradeToGroup(
|
|
319
|
-
return
|
|
344
|
+
function wouldUpgradeToGroup(currentSpeakerCount, newSpeakerCount) {
|
|
345
|
+
return currentSpeakerCount + newSpeakerCount >= 3;
|
|
320
346
|
}
|
|
321
347
|
/**
|
|
322
|
-
* Chat-first workspace —
|
|
348
|
+
* Chat-first workspace — membership lifecycle helpers.
|
|
323
349
|
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
350
|
+
* After the chat data model restructure (see
|
|
351
|
+
* proposals/chat-data-model-restructure.20260512.md §8), "watcher" is
|
|
352
|
+
* just an `access_mode` value on `chat_membership`, not a separate
|
|
353
|
+
* table. Speaker ↔ watcher transitions are a single-table UPDATE;
|
|
354
|
+
* read state lives in `chat_user_state` and is structurally isolated
|
|
355
|
+
* from access_mode changes — there is no state-carry path anymore.
|
|
328
356
|
*
|
|
329
357
|
* Two distinct kinds of operation live here:
|
|
330
358
|
*
|
|
331
|
-
* 1. Set rebuilds (`recompute*`). Idempotent set-based
|
|
332
|
-
* driven by lifecycle events (chat created,
|
|
333
|
-
* member status flipped,
|
|
334
|
-
*
|
|
359
|
+
* 1. Set rebuilds (`recompute*`). Idempotent set-based
|
|
360
|
+
* recomputations driven by lifecycle events (chat created,
|
|
361
|
+
* participant added/removed, member status flipped, agent
|
|
362
|
+
* rebind, etc.). Strict invariant: ONLY INSERT or DELETE rows
|
|
363
|
+
* where access_mode = 'watcher'. NEVER UPDATE any row with
|
|
364
|
+
* access_mode = 'speaker' — the user's own join/leave decision
|
|
365
|
+
* must not be overwritten by ops paths.
|
|
335
366
|
*
|
|
336
|
-
* 2.
|
|
337
|
-
*
|
|
338
|
-
* `
|
|
339
|
-
*
|
|
340
|
-
*
|
|
367
|
+
* 2. Speaker ↔ watcher transitions (`joinAsParticipant`,
|
|
368
|
+
* `leaveAsParticipant`). Single-table UPDATE on
|
|
369
|
+
* `chat_membership.access_mode`; `chat_user_state` rows for
|
|
370
|
+
* the (chat, agent) pair are not touched. Per §11.4 default,
|
|
371
|
+
* a fully-detached user keeps their `chat_user_state` row
|
|
372
|
+
* (read state remembered for re-add).
|
|
341
373
|
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
374
|
+
* File name preserved across the refactor for diff readability; may
|
|
375
|
+
* be renamed in a follow-up. Public function names preserved too —
|
|
376
|
+
* `recomputeChatWatchers` still describes what it does (recomputes
|
|
377
|
+
* the watcher rows), so the rename to `recomputeChatMembership`
|
|
378
|
+
* would obscure rather than clarify.
|
|
344
379
|
*/
|
|
345
380
|
/**
|
|
346
381
|
* Recompute watcher rows for ONE chat. For every active member who:
|
|
347
382
|
* - manages a non-human agent that speaks in the chat, AND
|
|
348
383
|
* - whose own human agent is NOT a speaker in the chat
|
|
349
|
-
*
|
|
384
|
+
* a `(chat_id, member.agent_id)` watcher row is upserted.
|
|
350
385
|
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
386
|
+
* Strict invariant: only writes rows with access_mode = 'watcher';
|
|
387
|
+
* never updates or deletes any access_mode = 'speaker' row. The
|
|
388
|
+
* ON CONFLICT DO NOTHING clause guarantees that if a (chat, agent)
|
|
389
|
+
* row already exists as a speaker (the manager joined as a real
|
|
390
|
+
* participant themselves), we leave it alone.
|
|
391
|
+
*
|
|
392
|
+
* Watchers whose anchoring condition no longer holds (manager left,
|
|
393
|
+
* the managed agent was removed from the chat, the manager joined as
|
|
394
|
+
* a speaker themselves) are deleted — also gated on access_mode =
|
|
395
|
+
* 'watcher'.
|
|
354
396
|
*
|
|
355
397
|
* Idempotent: safe to call multiple times for the same chat.
|
|
356
398
|
*/
|
|
357
399
|
async function recomputeChatWatchers(db, chatId) {
|
|
358
400
|
await db.execute(sql`
|
|
359
|
-
INSERT INTO
|
|
360
|
-
(chat_id, agent_id,
|
|
361
|
-
SELECT DISTINCT
|
|
362
|
-
FROM
|
|
363
|
-
JOIN agents a ON a.uuid =
|
|
401
|
+
INSERT INTO chat_membership
|
|
402
|
+
(chat_id, agent_id, role, access_mode, mode, source, joined_at)
|
|
403
|
+
SELECT DISTINCT cm.chat_id, m.agent_id, 'member', 'watcher', 'full', 'auto_manager', now()
|
|
404
|
+
FROM chat_membership cm
|
|
405
|
+
JOIN agents a ON a.uuid = cm.agent_id
|
|
364
406
|
JOIN members m ON m.id = a.manager_id
|
|
365
|
-
WHERE
|
|
366
|
-
AND
|
|
367
|
-
AND
|
|
407
|
+
WHERE cm.chat_id = ${chatId}
|
|
408
|
+
AND cm.access_mode = 'speaker'
|
|
409
|
+
AND m.status = 'active'
|
|
410
|
+
AND a.type <> 'human'
|
|
368
411
|
AND NOT EXISTS (
|
|
369
|
-
SELECT 1 FROM
|
|
370
|
-
WHERE
|
|
371
|
-
AND
|
|
412
|
+
SELECT 1 FROM chat_membership cm2
|
|
413
|
+
WHERE cm2.chat_id = cm.chat_id
|
|
414
|
+
AND cm2.agent_id = m.agent_id
|
|
372
415
|
)
|
|
373
416
|
ON CONFLICT (chat_id, agent_id) DO NOTHING
|
|
374
417
|
`);
|
|
375
418
|
await db.execute(sql`
|
|
376
|
-
DELETE FROM
|
|
377
|
-
WHERE
|
|
419
|
+
DELETE FROM chat_membership cm
|
|
420
|
+
WHERE cm.chat_id = ${chatId}
|
|
421
|
+
AND cm.access_mode = 'watcher'
|
|
378
422
|
AND NOT EXISTS (
|
|
379
423
|
SELECT 1
|
|
380
|
-
FROM
|
|
381
|
-
JOIN agents a ON a.uuid =
|
|
424
|
+
FROM chat_membership speakers
|
|
425
|
+
JOIN agents a ON a.uuid = speakers.agent_id
|
|
382
426
|
JOIN members m ON m.id = a.manager_id
|
|
383
|
-
WHERE
|
|
384
|
-
AND
|
|
385
|
-
AND m.
|
|
386
|
-
AND
|
|
387
|
-
AND
|
|
388
|
-
SELECT 1 FROM chat_participants cp2
|
|
389
|
-
WHERE cp2.chat_id = cp.chat_id
|
|
390
|
-
AND cp2.agent_id = m.agent_id
|
|
391
|
-
)
|
|
427
|
+
WHERE speakers.chat_id = cm.chat_id
|
|
428
|
+
AND speakers.access_mode = 'speaker'
|
|
429
|
+
AND m.agent_id = cm.agent_id
|
|
430
|
+
AND m.status = 'active'
|
|
431
|
+
AND a.type <> 'human'
|
|
392
432
|
)
|
|
393
433
|
`);
|
|
394
434
|
}
|
|
395
435
|
/**
|
|
396
|
-
* Recompute watcher rows touching ONE agent across all chats it
|
|
397
|
-
* Used after `rebindAgent` (manager change) so the new
|
|
398
|
-
* watcher rows and the old manager's are dropped.
|
|
436
|
+
* Recompute watcher rows touching ONE agent across all chats it
|
|
437
|
+
* speaks in. Used after `rebindAgent` (manager change) so the new
|
|
438
|
+
* manager picks up watcher rows and the old manager's are dropped.
|
|
399
439
|
*/
|
|
400
440
|
async function recomputeWatchersForAgent(db, agentId) {
|
|
401
|
-
const chatRows = await db.select({ chatId:
|
|
441
|
+
const chatRows = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker")));
|
|
402
442
|
for (const { chatId } of chatRows) await recomputeChatWatchers(db, chatId);
|
|
403
443
|
}
|
|
404
444
|
/**
|
|
405
|
-
* Recompute watcher rows touching ONE member across all chats.
|
|
406
|
-
* when the member's status flips active ↔ left.
|
|
445
|
+
* Recompute watcher rows touching ONE member across all chats.
|
|
446
|
+
* Triggered when the member's status flips active ↔ left.
|
|
407
447
|
*/
|
|
408
448
|
async function recomputeWatchersForMember(db, memberId) {
|
|
409
|
-
const rows = await db.selectDistinct({ chatId:
|
|
449
|
+
const rows = await db.selectDistinct({ chatId: chatMembership.chatId }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.accessMode, "speaker"), eq(agents.managerId, memberId), ne(agents.type, "human")));
|
|
410
450
|
for (const { chatId } of rows) await recomputeChatWatchers(db, chatId);
|
|
411
451
|
}
|
|
412
452
|
/**
|
|
413
|
-
* Watcher →
|
|
453
|
+
* Watcher → speaker (or fresh speaker insert).
|
|
414
454
|
*
|
|
415
|
-
* 1.
|
|
416
|
-
* 2. If a
|
|
417
|
-
* 3.
|
|
418
|
-
*
|
|
455
|
+
* 1. SELECT the existing chat_membership row for the (chat, agent) pair.
|
|
456
|
+
* 2. If already a speaker → no-op (idempotent).
|
|
457
|
+
* 3. If a watcher row → run the direct→group upgrade rule, then
|
|
458
|
+
* UPDATE access_mode to 'speaker'.
|
|
459
|
+
* 4. If no row → run the direct→group upgrade rule, then INSERT a
|
|
460
|
+
* fresh speaker row.
|
|
419
461
|
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
* route layer where we have the full member scope.
|
|
462
|
+
* Caller is expected to have verified the user is authorised to join
|
|
463
|
+
* (admin override OR an existing watcher row); this helper does not
|
|
464
|
+
* gate on visibility.
|
|
424
465
|
*/
|
|
425
466
|
async function joinAsParticipant(db, chatId, humanAgentId) {
|
|
426
467
|
return db.transaction(async (tx) => {
|
|
427
|
-
const [
|
|
428
|
-
|
|
429
|
-
unreadMentionCount: chatSubscriptions.unreadMentionCount
|
|
430
|
-
});
|
|
431
|
-
const [existing] = await tx.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
|
|
432
|
-
if (existing) return {
|
|
468
|
+
const [existing] = await tx.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId))).limit(1);
|
|
469
|
+
if (existing?.accessMode === "speaker") return {
|
|
433
470
|
chatId,
|
|
434
471
|
inserted: false,
|
|
435
|
-
carried:
|
|
472
|
+
carried: null
|
|
436
473
|
};
|
|
437
|
-
if (wouldUpgradeToGroup((await tx.select({ agentId:
|
|
474
|
+
if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
|
|
438
475
|
await addChatParticipants(tx, chatId, [{
|
|
439
476
|
agentId: humanAgentId,
|
|
440
|
-
role: "member"
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}], { assertHuman: true });
|
|
477
|
+
role: "member"
|
|
478
|
+
}], {
|
|
479
|
+
assertHuman: true,
|
|
480
|
+
upgradeWatcherToSpeaker: true
|
|
481
|
+
});
|
|
446
482
|
return {
|
|
447
483
|
chatId,
|
|
448
|
-
inserted:
|
|
449
|
-
carried:
|
|
484
|
+
inserted: !existing,
|
|
485
|
+
carried: null
|
|
450
486
|
};
|
|
451
487
|
});
|
|
452
488
|
}
|
|
453
489
|
/**
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* 1. DELETE the participant row (returning read state).
|
|
457
|
-
* 2. Test "still visible": is the user still the manager of an agent that
|
|
458
|
-
* remains a participant in this chat? If yes, INSERT a watcher row
|
|
459
|
-
* carrying read state. If no, drop entirely.
|
|
490
|
+
* Speaker → watcher (or fully detach).
|
|
460
491
|
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
492
|
+
* 1. SELECT the existing speaker row; 404 if not present.
|
|
493
|
+
* 2. Test "still visible": does the user still manage a non-human
|
|
494
|
+
* agent that remains a speaker in this chat?
|
|
495
|
+
* - If yes → UPDATE access_mode to 'watcher'.
|
|
496
|
+
* - If no → DELETE the chat_membership row entirely.
|
|
497
|
+
* 3. `chat_user_state` row (if any) is preserved either way per
|
|
498
|
+
* §11.4 default — read state is remembered for re-add.
|
|
463
499
|
*/
|
|
464
500
|
async function leaveAsParticipant(db, chatId, humanAgentId) {
|
|
465
501
|
return db.transaction(async (tx) => {
|
|
466
|
-
const [
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
});
|
|
470
|
-
if (!carried) throw new NotFoundError("Not a participant of this chat");
|
|
471
|
-
const [stillVisibleRow] = await tx.execute(sql`
|
|
502
|
+
const [existing] = await tx.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId))).limit(1);
|
|
503
|
+
if (!existing || existing.accessMode !== "speaker") throw new NotFoundError("Not a participant of this chat");
|
|
504
|
+
const result = await tx.execute(sql`
|
|
472
505
|
SELECT EXISTS (
|
|
473
506
|
SELECT 1
|
|
474
|
-
FROM
|
|
475
|
-
JOIN agents a ON a.uuid =
|
|
507
|
+
FROM chat_membership cm
|
|
508
|
+
JOIN agents a ON a.uuid = cm.agent_id
|
|
476
509
|
JOIN members m ON m.id = a.manager_id
|
|
477
|
-
WHERE
|
|
510
|
+
WHERE cm.chat_id = ${chatId}
|
|
511
|
+
AND cm.access_mode = 'speaker'
|
|
478
512
|
AND m.agent_id = ${humanAgentId}
|
|
479
513
|
AND m.status = 'active'
|
|
480
514
|
AND a.type <> 'human'
|
|
481
515
|
) AS visible
|
|
482
516
|
`);
|
|
483
|
-
if (!Boolean(
|
|
484
|
-
chatId,
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
lastReadAt: carried.lastReadAt,
|
|
492
|
-
unreadMentionCount: carried.unreadMentionCount
|
|
493
|
-
}).onConflictDoNothing();
|
|
517
|
+
if (!Boolean(result[0]?.visible)) {
|
|
518
|
+
await tx.delete(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId)));
|
|
519
|
+
return {
|
|
520
|
+
chatId,
|
|
521
|
+
membershipKind: null
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
await tx.update(chatMembership).set({ accessMode: "watcher" }).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId)));
|
|
494
525
|
return {
|
|
495
526
|
chatId,
|
|
496
527
|
membershipKind: "watching"
|
|
@@ -498,24 +529,24 @@ async function leaveAsParticipant(db, chatId, humanAgentId) {
|
|
|
498
529
|
});
|
|
499
530
|
}
|
|
500
531
|
/**
|
|
501
|
-
* Resolve the membership row of the human agent for the given chat.
|
|
502
|
-
* one of: 'participant', 'watching',
|
|
532
|
+
* Resolve the membership row of the human agent for the given chat.
|
|
533
|
+
* Returns one of: 'participant' (speaker), 'watching' (watcher),
|
|
534
|
+
* or null (no row).
|
|
503
535
|
*
|
|
504
|
-
* Used by `/me/chats/:chatId/join` to refuse a join when the user
|
|
505
|
-
* neither a watcher row nor a participant row, and isn't
|
|
506
|
-
* authorised (admin in the chat's org).
|
|
536
|
+
* Used by `/me/chats/:chatId/join` to refuse a join when the user
|
|
537
|
+
* has neither a watcher row nor a participant row, and isn't
|
|
538
|
+
* otherwise authorised (admin in the chat's org).
|
|
507
539
|
*/
|
|
508
540
|
async function resolveChatMembership(db, chatId, humanAgentId) {
|
|
509
|
-
const [
|
|
510
|
-
if (
|
|
511
|
-
|
|
512
|
-
if (sub) return "watching";
|
|
513
|
-
return null;
|
|
541
|
+
const [row] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId))).limit(1);
|
|
542
|
+
if (!row) return null;
|
|
543
|
+
return row.accessMode === "speaker" ? "participant" : "watching";
|
|
514
544
|
}
|
|
515
545
|
/**
|
|
516
|
-
* Used by `/me/chats/:chatId/join`. Throw 409 if already a speaker
|
|
517
|
-
* to do) and 403 if no
|
|
518
|
-
* resolved at the route layer; this helper only reports the
|
|
546
|
+
* Used by `/me/chats/:chatId/join`. Throw 409 if already a speaker
|
|
547
|
+
* (no work to do) and 403 if no row at all (admin override is
|
|
548
|
+
* resolved at the route layer; this helper only reports the membership
|
|
549
|
+
* state).
|
|
519
550
|
*/
|
|
520
551
|
function ensureCanJoin(membership) {
|
|
521
552
|
if (membership === "participant") throw new ConflictError("Already a participant in this chat");
|
|
@@ -551,7 +582,7 @@ async function createChat(db, creatorId, data) {
|
|
|
551
582
|
role: agentId === creatorId ? "owner" : "member"
|
|
552
583
|
})));
|
|
553
584
|
await recomputeChatWatchers(tx, chatId);
|
|
554
|
-
const participants = await tx.select().from(
|
|
585
|
+
const participants = await tx.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
555
586
|
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
556
587
|
return {
|
|
557
588
|
...chat,
|
|
@@ -566,14 +597,14 @@ async function getChat(db, chatId) {
|
|
|
566
597
|
}
|
|
567
598
|
async function getChatDetail(db, chatId) {
|
|
568
599
|
const chat = await getChat(db, chatId);
|
|
569
|
-
const participants = await db.select().from(
|
|
600
|
+
const participants = await db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
570
601
|
return {
|
|
571
602
|
...chat,
|
|
572
603
|
participants
|
|
573
604
|
};
|
|
574
605
|
}
|
|
575
606
|
async function listChats(db, agentId, limit, cursor) {
|
|
576
|
-
const chatIds = (await db.select({ chatId:
|
|
607
|
+
const chatIds = (await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker")))).map((r) => r.chatId);
|
|
577
608
|
if (chatIds.length === 0) return {
|
|
578
609
|
items: [],
|
|
579
610
|
nextCursor: null
|
|
@@ -595,17 +626,17 @@ async function listChats(db, agentId, limit, cursor) {
|
|
|
595
626
|
*/
|
|
596
627
|
async function listChatParticipantsWithNames(db, chatId) {
|
|
597
628
|
return await db.select({
|
|
598
|
-
agentId:
|
|
599
|
-
role:
|
|
600
|
-
mode:
|
|
601
|
-
joinedAt:
|
|
629
|
+
agentId: chatMembership.agentId,
|
|
630
|
+
role: chatMembership.role,
|
|
631
|
+
mode: chatMembership.mode,
|
|
632
|
+
joinedAt: chatMembership.joinedAt,
|
|
602
633
|
name: agents.name,
|
|
603
634
|
displayName: agents.displayName,
|
|
604
635
|
type: agents.type
|
|
605
|
-
}).from(
|
|
636
|
+
}).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
606
637
|
}
|
|
607
638
|
async function assertParticipant(db, chatId, agentId) {
|
|
608
|
-
const [row] = await db.select({ chatId:
|
|
639
|
+
const [row] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
|
|
609
640
|
if (!row) throw new ForbiddenError("Not a participant of this chat");
|
|
610
641
|
}
|
|
611
642
|
/**
|
|
@@ -614,17 +645,16 @@ async function assertParticipant(db, chatId, agentId) {
|
|
|
614
645
|
* caller's current chat (see `sendToAgent`'s current-chat routing branch).
|
|
615
646
|
*/
|
|
616
647
|
async function isParticipant(db, chatId, agentId) {
|
|
617
|
-
const [row] = await db.select({ chatId:
|
|
648
|
+
const [row] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
|
|
618
649
|
return Boolean(row);
|
|
619
650
|
}
|
|
620
|
-
/** Ensure an agent is a
|
|
651
|
+
/** Ensure an agent is a speaker of a chat. Silently adds them if not already. */
|
|
621
652
|
async function ensureParticipant(db, chatId, agentId) {
|
|
622
|
-
const [existing] = await db.select({
|
|
623
|
-
if (existing) return;
|
|
653
|
+
const [existing] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId))).limit(1);
|
|
654
|
+
if (existing?.accessMode === "speaker") return;
|
|
624
655
|
await db.transaction(async (tx) => {
|
|
625
|
-
if (wouldUpgradeToGroup((await tx.select({ agentId:
|
|
626
|
-
await tx
|
|
627
|
-
await addChatParticipants(tx, chatId, [{ agentId }], { onConflictDoNothing: true });
|
|
656
|
+
if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
|
|
657
|
+
await addChatParticipants(tx, chatId, [{ agentId }], { upgradeWatcherToSpeaker: true });
|
|
628
658
|
await recomputeChatWatchers(tx, chatId);
|
|
629
659
|
});
|
|
630
660
|
invalidateChatAudience(chatId);
|
|
@@ -638,25 +668,24 @@ async function addParticipant(db, chatId, requesterId, data) {
|
|
|
638
668
|
}).from(agents).where(eq(agents.uuid, data.agentId)).limit(1);
|
|
639
669
|
if (!targetAgent) throw new NotFoundError(`Agent "${data.agentId}" not found`);
|
|
640
670
|
if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
|
|
641
|
-
const [existing] = await db.select({
|
|
642
|
-
if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
|
|
671
|
+
const [existing] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, data.agentId))).limit(1);
|
|
672
|
+
if (existing?.accessMode === "speaker") throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
|
|
643
673
|
await db.transaction(async (tx) => {
|
|
644
|
-
if (wouldUpgradeToGroup((await tx.select({ agentId:
|
|
645
|
-
await tx
|
|
646
|
-
await addChatParticipants(tx, chatId, [{ agentId: data.agentId }]);
|
|
674
|
+
if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
|
|
675
|
+
await addChatParticipants(tx, chatId, [{ agentId: data.agentId }], { upgradeWatcherToSpeaker: true });
|
|
647
676
|
await recomputeChatWatchers(tx, chatId);
|
|
648
677
|
});
|
|
649
678
|
invalidateChatAudience(chatId);
|
|
650
|
-
return db.select().from(
|
|
679
|
+
return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
651
680
|
}
|
|
652
681
|
async function removeParticipant(db, chatId, requesterId, targetAgentId) {
|
|
653
682
|
await assertParticipant(db, chatId, requesterId);
|
|
654
683
|
if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
|
|
655
|
-
const [removed] = await db.delete(
|
|
684
|
+
const [removed] = await db.delete(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, targetAgentId), eq(chatMembership.accessMode, "speaker"))).returning();
|
|
656
685
|
if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
|
|
657
686
|
await recomputeChatWatchers(db, chatId);
|
|
658
687
|
invalidateChatAudience(chatId);
|
|
659
|
-
return db.select().from(
|
|
688
|
+
return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
660
689
|
}
|
|
661
690
|
/**
|
|
662
691
|
* List chats visible to a member, grouped by agent.
|
|
@@ -685,11 +714,11 @@ async function listChatsForMember(db, memberId, humanAgentId) {
|
|
|
685
714
|
const agentIds = [...agentMap.keys()];
|
|
686
715
|
if (agentIds.length === 0) return [];
|
|
687
716
|
const participations = await db.select({
|
|
688
|
-
chatId:
|
|
689
|
-
agentId:
|
|
690
|
-
role:
|
|
691
|
-
mode:
|
|
692
|
-
}).from(
|
|
717
|
+
chatId: chatMembership.chatId,
|
|
718
|
+
agentId: chatMembership.agentId,
|
|
719
|
+
role: chatMembership.role,
|
|
720
|
+
mode: chatMembership.mode
|
|
721
|
+
}).from(chatMembership).where(and(inArray(chatMembership.agentId, agentIds), eq(chatMembership.accessMode, "speaker")));
|
|
693
722
|
if (participations.length === 0) return [];
|
|
694
723
|
const chatIds = [...new Set(participations.map((p) => p.chatId))];
|
|
695
724
|
const agentChatMap = /* @__PURE__ */ new Map();
|
|
@@ -705,7 +734,7 @@ async function listChatsForMember(db, memberId, humanAgentId) {
|
|
|
705
734
|
metadata: chats.metadata,
|
|
706
735
|
createdAt: chats.createdAt,
|
|
707
736
|
updatedAt: chats.updatedAt,
|
|
708
|
-
participantCount: sql`(SELECT count(*)::int FROM
|
|
737
|
+
participantCount: sql`(SELECT count(*)::int FROM chat_membership WHERE chat_id = ${chats.id} AND access_mode = 'speaker')`
|
|
709
738
|
}).from(chats).where(inArray(chats.id, chatIds)).orderBy(desc(chats.updatedAt));
|
|
710
739
|
const chatMap = new Map(chatRows.map((c) => [c.id, c]));
|
|
711
740
|
const humanParticipantChatIds = new Set(participations.filter((p) => p.agentId === humanAgentId).map((p) => p.chatId));
|
|
@@ -740,30 +769,25 @@ async function listChatsForMember(db, memberId, humanAgentId) {
|
|
|
740
769
|
*/
|
|
741
770
|
async function joinChat(db, chatId, memberId, humanAgentId) {
|
|
742
771
|
const chat = await getChat(db, chatId);
|
|
743
|
-
const participantAgentIds = (await db.select().from(
|
|
772
|
+
const participantAgentIds = (await db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).map((p) => p.agentId);
|
|
744
773
|
if (participantAgentIds.length === 0) throw new NotFoundError("Chat has no participants");
|
|
745
774
|
if (participantAgentIds.includes(humanAgentId)) throw new ConflictError("Already a participant in this chat");
|
|
746
775
|
if ((await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantAgentIds), eq(agents.managerId, memberId)))).length === 0) throw new ForbiddenError("You can only join chats where you manage at least one participant");
|
|
747
776
|
const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
|
|
748
777
|
if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
|
|
749
778
|
await db.transaction(async (tx) => {
|
|
750
|
-
const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
|
|
751
|
-
lastReadAt: chatSubscriptions.lastReadAt,
|
|
752
|
-
unreadMentionCount: chatSubscriptions.unreadMentionCount
|
|
753
|
-
});
|
|
754
779
|
if (wouldUpgradeToGroup(participantAgentIds.length, 1)) await changeChatType(tx, chatId, "group");
|
|
755
780
|
await addChatParticipants(tx, chatId, [{
|
|
756
781
|
agentId: humanAgentId,
|
|
757
|
-
role: "member"
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
}], { assertHuman: true });
|
|
782
|
+
role: "member"
|
|
783
|
+
}], {
|
|
784
|
+
assertHuman: true,
|
|
785
|
+
upgradeWatcherToSpeaker: true
|
|
786
|
+
});
|
|
763
787
|
await recomputeChatWatchers(tx, chatId);
|
|
764
788
|
});
|
|
765
789
|
invalidateChatAudience(chatId);
|
|
766
|
-
return db.select().from(
|
|
790
|
+
return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
767
791
|
}
|
|
768
792
|
/**
|
|
769
793
|
* Manager leaves a chat. Removes their human agent from participants.
|
|
@@ -779,7 +803,7 @@ async function joinChat(db, chatId, memberId, humanAgentId) {
|
|
|
779
803
|
async function leaveChat(db, chatId, humanAgentId) {
|
|
780
804
|
await leaveAsParticipant(db, chatId, humanAgentId);
|
|
781
805
|
invalidateChatAudience(chatId);
|
|
782
|
-
return db.select().from(
|
|
806
|
+
return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
783
807
|
}
|
|
784
808
|
async function findOrCreateDirectChat(db, agentAId, agentBId) {
|
|
785
809
|
const ends = await db.select({
|
|
@@ -793,10 +817,7 @@ async function findOrCreateDirectChat(db, agentAId, agentBId) {
|
|
|
793
817
|
if (!agentB) throw new NotFoundError(`Agent "${agentBId}" not found`);
|
|
794
818
|
if (agentA.organizationId !== agentB.organizationId) throw new BadRequestError(`Cannot create direct chat across organizations: agent "${agentAId}" (org "${agentA.organizationId}") vs agent "${agentBId}" (org "${agentB.organizationId}")`);
|
|
795
819
|
const orgId = agentA.organizationId;
|
|
796
|
-
const
|
|
797
|
-
const bChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentBId));
|
|
798
|
-
const bChatIds = new Set(bChats.map((r) => r.chatId));
|
|
799
|
-
const commonChatIds = aChats.map((r) => r.chatId).filter((id) => bChatIds.has(id));
|
|
820
|
+
const commonChatIds = (await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.agentId, [agentAId, agentBId]), eq(chatMembership.accessMode, "speaker"))).groupBy(chatMembership.chatId).having(sql`COUNT(DISTINCT ${chatMembership.agentId}) = 2`)).map((r) => r.chatId);
|
|
800
821
|
if (commonChatIds.length > 0) {
|
|
801
822
|
const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct"), eq(chats.organizationId, orgId))).orderBy(chats.createdAt, chats.id).limit(1);
|
|
802
823
|
if (directChats.length > 0 && directChats[0]) return directChats[0];
|
|
@@ -1165,17 +1186,21 @@ async function listAgentsWithRuntime(db, scope) {
|
|
|
1165
1186
|
*
|
|
1166
1187
|
* The single sanctioned extension point on the message hot path. Called
|
|
1167
1188
|
* from `services/message.ts` AFTER existing fan-out completes, inside the
|
|
1168
|
-
* same transaction.
|
|
1169
|
-
*
|
|
1170
|
-
* 1. Mention propagation: increment `unread_mention_count` for mentioned
|
|
1171
|
-
* speaking participants AND for watcher rows whose managed agent was
|
|
1172
|
-
* mentioned. Sender row is excluded.
|
|
1189
|
+
* same transaction. Four responsibilities:
|
|
1173
1190
|
*
|
|
1174
|
-
*
|
|
1191
|
+
* 1. Chats projection: roll forward `chats.last_message_at`,
|
|
1175
1192
|
* `chats.last_message_preview`. Powers the conversation list cursor +
|
|
1176
1193
|
* sort + preview.
|
|
1177
1194
|
*
|
|
1178
|
-
*
|
|
1195
|
+
* 2. Engagement auto-revive: flip `chat_user_state.engagement_status`
|
|
1196
|
+
* from `archived` → `active` for everyone watching this chat. `deleted`
|
|
1197
|
+
* rows are sticky and intentionally untouched.
|
|
1198
|
+
*
|
|
1199
|
+
* 3. Mention propagation: increment `unread_mention_count` for mentioned
|
|
1200
|
+
* speaking participants AND for watcher rows whose managed agent was
|
|
1201
|
+
* mentioned. Sender row is excluded.
|
|
1202
|
+
*
|
|
1203
|
+
* 4. Realtime kick: fire-and-forget `pg_notify('chat_message_events', …)`
|
|
1179
1204
|
* so admin WS sockets can translate it into a `chat:message` frame.
|
|
1180
1205
|
* Failure is swallowed — durable persistence is the correctness path.
|
|
1181
1206
|
*
|
|
@@ -1183,11 +1208,13 @@ async function listAgentsWithRuntime(db, scope) {
|
|
|
1183
1208
|
* "Risk Constraints"):
|
|
1184
1209
|
* - This module appends ONLY. Never edits existing fan-out / inbox /
|
|
1185
1210
|
* mention-extraction code.
|
|
1186
|
-
* -
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
1211
|
+
* - Watcher rows (chat_membership with access_mode='watcher') are
|
|
1212
|
+
* NEVER added to inbox_entries here. Their counters in
|
|
1213
|
+
* chat_user_state are bumped purely as a per-user red-dot signal.
|
|
1214
|
+
* - Mention candidate set is `chat_membership` speakers only;
|
|
1215
|
+
* watchers are not direct `@`-mention targets.
|
|
1190
1216
|
*/
|
|
1217
|
+
const { ACTIVE, ARCHIVED } = CHAT_ENGAGEMENT_STATUSES;
|
|
1191
1218
|
let dispatcher = null;
|
|
1192
1219
|
function registerChatMessageDispatcher(fn) {
|
|
1193
1220
|
dispatcher = fn;
|
|
@@ -1220,27 +1247,38 @@ async function applyAfterFanOut(tx, input) {
|
|
|
1220
1247
|
lastMessageAt: ts,
|
|
1221
1248
|
lastMessagePreview: previewClipped
|
|
1222
1249
|
}).where(eq(chats.id, chatId));
|
|
1250
|
+
await tx.execute(sql`
|
|
1251
|
+
UPDATE chat_user_state
|
|
1252
|
+
SET engagement_status = ${ACTIVE}
|
|
1253
|
+
WHERE chat_id = ${chatId}
|
|
1254
|
+
AND engagement_status = ${ARCHIVED}
|
|
1255
|
+
`);
|
|
1223
1256
|
if (mentionedAgentIds.length === 0) return;
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1257
|
+
const mentionedList = sql.join(mentionedAgentIds.map((id) => sql`${id}`), sql`, `);
|
|
1258
|
+
await tx.execute(sql`
|
|
1259
|
+
INSERT INTO chat_user_state (chat_id, agent_id, unread_mention_count)
|
|
1260
|
+
SELECT chat_id, agent_id, 1
|
|
1261
|
+
FROM (
|
|
1262
|
+
SELECT cm.chat_id, cm.agent_id
|
|
1263
|
+
FROM chat_membership cm
|
|
1264
|
+
WHERE cm.chat_id = ${chatId}
|
|
1265
|
+
AND cm.access_mode = 'speaker'
|
|
1266
|
+
AND cm.agent_id IN (${mentionedList})
|
|
1267
|
+
AND cm.agent_id <> ${senderId}
|
|
1268
|
+
UNION
|
|
1269
|
+
SELECT cm.chat_id, cm.agent_id
|
|
1270
|
+
FROM chat_membership cm
|
|
1271
|
+
JOIN members m ON m.agent_id = cm.agent_id
|
|
1272
|
+
JOIN agents a ON a.manager_id = m.id
|
|
1273
|
+
WHERE cm.chat_id = ${chatId}
|
|
1274
|
+
AND cm.access_mode = 'watcher'
|
|
1275
|
+
AND a.uuid IN (${mentionedList})
|
|
1276
|
+
AND a.type <> 'human'
|
|
1277
|
+
AND m.status = 'active'
|
|
1278
|
+
) targets
|
|
1279
|
+
ON CONFLICT (chat_id, agent_id)
|
|
1280
|
+
DO UPDATE SET unread_mention_count = chat_user_state.unread_mention_count + 1
|
|
1281
|
+
`);
|
|
1244
1282
|
}
|
|
1245
1283
|
/**
|
|
1246
1284
|
* Server-side lifecycle tracker for `format=question` messages.
|
|
@@ -1636,11 +1674,11 @@ async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
|
1636
1674
|
const txResult = await db.transaction(async (tx) => {
|
|
1637
1675
|
const [participants, [chatRow], [senderRow]] = await Promise.all([
|
|
1638
1676
|
tx.select({
|
|
1639
|
-
agentId:
|
|
1677
|
+
agentId: chatMembership.agentId,
|
|
1640
1678
|
inboxId: agents.inboxId,
|
|
1641
|
-
mode:
|
|
1679
|
+
mode: chatMembership.mode,
|
|
1642
1680
|
name: agents.name
|
|
1643
|
-
}).from(
|
|
1681
|
+
}).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker"))),
|
|
1644
1682
|
tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
|
|
1645
1683
|
tx.select({
|
|
1646
1684
|
inboxId: agents.inboxId,
|
|
@@ -2129,4 +2167,4 @@ async function cleanupStaleClients(db, staleSeconds = 60) {
|
|
|
2129
2167
|
return result.length;
|
|
2130
2168
|
}
|
|
2131
2169
|
//#endregion
|
|
2132
|
-
export {
|
|
2170
|
+
export { notifyRecipients as $, getPresence as A, listAgentsManagedByUser as B, ensureParticipant as C, getChatDetail as D, getCachedAudience as E, joinAsParticipant as F, listClients as G, listChatParticipantsWithNames as H, joinChat as I, listMyPinnedAgents as J, listClientsForOrgAdmin as K, leaveAsParticipant as L, heartbeatInstance as M, inboxEntries as N, getClient as O, invalidateChatAudience as P, messages as Q, leaveChat as R, ensureCanJoin as S, getActivityOverview as T, listChats as U, listAgentsWithRuntime as V, listChatsForMember as W, markSupersededByChat as X, markStaleAgents as Y, members as Z, createChat as _, unbindAgent as _t, agentVisibilityCondition as a, registerClient as at, disconnectClient as b, assertParticipant as c, resolveChatMembership as ct, chatMembership as d, sendToAgent as dt, pendingQuestions as et, chats as f, serverInstances as ft, clients as g, touchAgent as gt, cleanupStalePresence as h, submitAnswer as ht, agentPresence as i, registerChatMessageDispatcher as it, heartbeatClient as j, getOnlineCount as k, bindAgent as l, retireClient as lt, cleanupStaleClients as m, setRuntimeState as mt, addParticipant as n, recomputeWatchersForAgent as nt, agents as o, removeParticipant as ot, claimClient as p, setOffline as pt, listMessages as q, agentChatSessions as r, recomputeWatchersForMember as rt, assertClientOwner as s, resetActivity as st, addChatParticipants as t, recomputeChatWatchers as tt, changeChatType as u, sendMessage as ut, createNotifier as v, updateClientCapabilities as vt, findOrCreateDirectChat as w, editMessage as x, deriveAuthState as y, upsertSessionState as yt, listActiveAgentsPinnedToClient as z };
|