@agent-team-foundation/first-tree-hub 0.11.5 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.mjs +6 -6
- package/dist/client-BPUdUaZT-CyCrpCTP.mjs +2033 -0
- package/dist/client-BhCtO2df-BGOu-rRN.mjs +7 -0
- package/dist/{dist-BQtAQNRD.mjs → dist-LgF7LHpE.mjs} +1 -1
- package/dist/{dist-CfvCT4E0.mjs → dist-UOZ6vMUW.mjs} +112 -9
- package/dist/drizzle/0034_pending_questions.sql +34 -0
- package/dist/drizzle/meta/_journal.json +7 -0
- package/dist/{feishu-DbSvp9UH.mjs → feishu-C6qlhju2.mjs} +1 -1
- package/dist/{getMachineId-bsd-c2VImogj.mjs → getMachineId-bsd-BmasEOJr.mjs} +1 -1
- package/dist/{getMachineId-bsd-DyySs8xz.mjs → getMachineId-bsd-Dh3h0DDE.mjs} +1 -1
- package/dist/{getMachineId-darwin-Cl7TSzgO.mjs → getMachineId-darwin-CuhM3hfZ.mjs} +1 -1
- package/dist/{getMachineId-darwin-DKgI8b1d.mjs → getMachineId-darwin-D9wR0SLj.mjs} +1 -1
- package/dist/{getMachineId-linux-1OIMWfdh.mjs → getMachineId-linux-CYfb0oxZ.mjs} +1 -1
- package/dist/{getMachineId-linux-cT7EbP10.mjs → getMachineId-linux-D8ZaSjAC.mjs} +1 -1
- package/dist/{getMachineId-unsupported-CkX-YOG1.mjs → getMachineId-unsupported-Cu3iisaD.mjs} +1 -1
- package/dist/{getMachineId-unsupported-CmVlhzIo.mjs → getMachineId-unsupported-DZqI4ZT5.mjs} +1 -1
- package/dist/{getMachineId-win-C2cM60YT.mjs → getMachineId-win-8ZJbtrdf.mjs} +1 -1
- package/dist/{getMachineId-win-Chl03TYe.mjs → getMachineId-win-DT-hqwVp.mjs} +1 -1
- package/dist/index.mjs +6 -6
- package/dist/{invitation-C299fxkP-BR-niZyp.mjs → invitation-C299fxkP-KyCNax4T.mjs} +1 -1
- package/dist/{observability-BAScT_5S-gw1ODB_o.mjs → observability-BAScT_5S-BcW9HgkG.mjs} +13 -13
- package/dist/{observability-CYsdAcoF.mjs → observability-eLA9iNK_.mjs} +3 -3
- package/dist/{saas-connect-CO554S-V.mjs → saas-connect-Drn9g6cR.mjs} +367 -1340
- package/dist/web/assets/index-B_Tf2I6v.css +1 -0
- package/dist/web/assets/{index-B7noAoV-.js → index-Bnyz7inW.js} +1 -1
- package/dist/web/assets/index-Dy3jIUX5.js +391 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/client-D_TRJFZY-LbgJF47t.mjs +0 -4
- package/dist/client-DqdGiggm-NQoGZ2vM.mjs +0 -524
- package/dist/web/assets/index-DPLa60vJ.css +0 -1
- package/dist/web/assets/index-DvGkka4N.js +0 -390
- /package/dist/{esm-Ci8E1Gtj.mjs → esm-iadMkGbV.mjs} +0 -0
- /package/dist/{src-aJMV60mR.mjs → src-DNBS5Yjj.mjs} +0 -0
|
@@ -0,0 +1,2033 @@
|
|
|
1
|
+
import { O as withSpan, f as messageAttrs, s as createLogger } from "./observability-BAScT_5S-BcW9HgkG.mjs";
|
|
2
|
+
import { a as AGENT_STATUSES, at as questionAnswerMessageContentSchema, dt as scanMentionTokens, ot as questionMessageContentSchema, s as AGENT_VISIBILITY, w as clientCapabilitiesSchema, z as extractMentions } from "./dist-UOZ6vMUW.mjs";
|
|
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
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { and, desc, eq, inArray, isNotNull, lt, ne, or, sql } from "drizzle-orm";
|
|
6
|
+
import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, unique } from "drizzle-orm/pg-core";
|
|
7
|
+
//#region ../server/dist/client-BPUdUaZT.mjs
|
|
8
|
+
/**
|
|
9
|
+
* Client connections. A client is a single SDK process (AgentRuntime) that may
|
|
10
|
+
* host multiple agents. From the unified-user-token milestone on, a client is
|
|
11
|
+
* owned by a user — Rule R-RUN requires `clients.user_id == jwt.userId` for
|
|
12
|
+
* every `agent:bind` request. `user_id` is nullable only to accommodate legacy
|
|
13
|
+
* rows created before JWT-on-handshake; the WS handshake claims the row on
|
|
14
|
+
* first re-register under an authenticated JWT (see `client:register` M13).
|
|
15
|
+
*
|
|
16
|
+
* A client is also bound to exactly one organization for its lifetime. The
|
|
17
|
+
* `organization_id` column is populated on first registration from the
|
|
18
|
+
* authenticated JWT's org claim and never changes thereafter. Re-registering
|
|
19
|
+
* the same clientId under a JWT for a different org is rejected with
|
|
20
|
+
* `CLIENT_ORG_MISMATCH` — the CLI responds by abandoning the local clientId
|
|
21
|
+
* and registering a new one instead (see docs/multi-tenancy-hardening-design.md).
|
|
22
|
+
*/
|
|
23
|
+
const clients = pgTable("clients", {
|
|
24
|
+
id: text("id").primaryKey(),
|
|
25
|
+
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
|
|
26
|
+
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
27
|
+
status: text("status").notNull().default("disconnected"),
|
|
28
|
+
sdkVersion: text("sdk_version"),
|
|
29
|
+
hostname: text("hostname"),
|
|
30
|
+
os: text("os"),
|
|
31
|
+
instanceId: text("instance_id"),
|
|
32
|
+
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
|
33
|
+
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
|
|
34
|
+
metadata: jsonb("metadata").$type()
|
|
35
|
+
}, (table) => [index("idx_clients_user").on(table.userId), index("idx_clients_org").on(table.organizationId)]);
|
|
36
|
+
/** Agent registration. Each agent owns a unique inboxId for message delivery. */
|
|
37
|
+
const agents = pgTable("agents", {
|
|
38
|
+
uuid: text("uuid").primaryKey(),
|
|
39
|
+
name: text("name"),
|
|
40
|
+
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
41
|
+
type: text("type").notNull(),
|
|
42
|
+
displayName: text("display_name").notNull(),
|
|
43
|
+
delegateMention: text("delegate_mention"),
|
|
44
|
+
inboxId: text("inbox_id").unique().notNull(),
|
|
45
|
+
status: text("status").notNull().default("active"),
|
|
46
|
+
source: text("source"),
|
|
47
|
+
visibility: text("visibility").notNull().default("private"),
|
|
48
|
+
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
49
|
+
managerId: text("manager_id").notNull(),
|
|
50
|
+
clientId: text("client_id").references(() => clients.id, { onDelete: "restrict" }),
|
|
51
|
+
runtimeProvider: text("runtime_provider").notNull().default("claude-code"),
|
|
52
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
53
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
54
|
+
}, (table) => [
|
|
55
|
+
index("idx_agents_org").on(table.organizationId),
|
|
56
|
+
index("idx_agents_manager").on(table.managerId),
|
|
57
|
+
index("idx_agents_visibility_org").on(table.organizationId, table.visibility),
|
|
58
|
+
index("idx_agents_client").on(table.clientId),
|
|
59
|
+
unique("uq_agents_org_name").on(table.organizationId, table.name)
|
|
60
|
+
]);
|
|
61
|
+
/** Communication container. All messages between agents flow within a Chat. */
|
|
62
|
+
const chats = pgTable("chats", {
|
|
63
|
+
id: text("id").primaryKey(),
|
|
64
|
+
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
65
|
+
type: text("type").notNull().default("direct"),
|
|
66
|
+
topic: text("topic"),
|
|
67
|
+
lifecyclePolicy: text("lifecycle_policy").default("persistent"),
|
|
68
|
+
parentChatId: text("parent_chat_id"),
|
|
69
|
+
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
70
|
+
lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
|
|
71
|
+
lastMessagePreview: text("last_message_preview"),
|
|
72
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
73
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
74
|
+
}, (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
|
+
/** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
|
|
109
|
+
const members = pgTable("members", {
|
|
110
|
+
id: text("id").primaryKey(),
|
|
111
|
+
userId: text("user_id").notNull().references(() => users.id),
|
|
112
|
+
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
113
|
+
agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
|
|
114
|
+
role: text("role").notNull(),
|
|
115
|
+
status: text("status").notNull().default("active"),
|
|
116
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
117
|
+
}, (table) => [
|
|
118
|
+
unique("uq_members_user_org").on(table.userId, table.organizationId),
|
|
119
|
+
index("idx_members_user").on(table.userId),
|
|
120
|
+
index("idx_members_org").on(table.organizationId)
|
|
121
|
+
]);
|
|
122
|
+
/**
|
|
123
|
+
* Process-local cache for the per-chat realtime push audience
|
|
124
|
+
* (`chat_participants ∪ chat_subscriptions`, keyed by human agent
|
|
125
|
+
* uuid). Sits in front of the admin WS dispatch so a chat with N
|
|
126
|
+
* messages/sec doesn't issue N audience-resolution queries; one query
|
|
127
|
+
* + cache hit per chat per TTL window.
|
|
128
|
+
*
|
|
129
|
+
* The cache exposes both a populator (`getCachedAudience`) and an
|
|
130
|
+
* invalidator (`invalidateChatAudience`). Participant-mutation paths
|
|
131
|
+
* (`addMeChatParticipants`, `joinMeChat`, `leaveMeChat`,
|
|
132
|
+
* `recomputeChatWatchers`, `joinAsParticipant`, `leaveAsParticipant`)
|
|
133
|
+
* MUST call `invalidateChatAudience` after their tx commits so the
|
|
134
|
+
* very next dispatch reflects the new audience without waiting for
|
|
135
|
+
* the TTL to age out — without invalidation, a freshly-added speaker
|
|
136
|
+
* would miss `chat:message` pushes for up to TTL_MS.
|
|
137
|
+
*
|
|
138
|
+
* Cross-instance correctness: not handled here. The PG NOTIFY layer
|
|
139
|
+
* already broadcasts message events to every replica; each replica's
|
|
140
|
+
* audience cache is independently invalidated by its own
|
|
141
|
+
* service-layer mutations on chats it routes traffic for. For
|
|
142
|
+
* cross-replica participant changes to invalidate this cache, route
|
|
143
|
+
* the mutation through the same replica that hosts the WS connection
|
|
144
|
+
* (sticky routing) or add a dedicated `chat:audience` PG NOTIFY in
|
|
145
|
+
* a follow-up.
|
|
146
|
+
*/
|
|
147
|
+
const log$2 = createLogger("ChatAudienceCache");
|
|
148
|
+
const TTL_MS = 5e3;
|
|
149
|
+
const MAX_ENTRIES = 1024;
|
|
150
|
+
const cache = /* @__PURE__ */ new Map();
|
|
151
|
+
/** Resolve a chat's push audience, hitting the cache when fresh.
|
|
152
|
+
* Returns null on DB error (caller should skip dispatch). */
|
|
153
|
+
async function getCachedAudience(db, chatId) {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const cached = cache.get(chatId);
|
|
156
|
+
if (cached && cached.expiresAt > now) return cached.audience;
|
|
157
|
+
try {
|
|
158
|
+
const rows = await db.execute(sql`
|
|
159
|
+
SELECT agent_id FROM chat_participants WHERE chat_id = ${chatId}
|
|
160
|
+
UNION
|
|
161
|
+
SELECT agent_id FROM chat_subscriptions WHERE chat_id = ${chatId}
|
|
162
|
+
`);
|
|
163
|
+
const audience = new Set(rows.map((r) => r.agent_id));
|
|
164
|
+
cache.set(chatId, {
|
|
165
|
+
audience,
|
|
166
|
+
expiresAt: now + TTL_MS
|
|
167
|
+
});
|
|
168
|
+
if (cache.size > MAX_ENTRIES) {
|
|
169
|
+
for (const [k, v] of cache) if (v.expiresAt <= now) cache.delete(k);
|
|
170
|
+
}
|
|
171
|
+
return audience;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
log$2.warn({
|
|
174
|
+
err,
|
|
175
|
+
chatId
|
|
176
|
+
}, "failed to resolve chat audience");
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Drop the cached audience for a chat. Called from participant-
|
|
181
|
+
* mutation paths after their transaction commits, so the next
|
|
182
|
+
* `chat:message` dispatch hits the DB and reflects the new
|
|
183
|
+
* membership instead of serving a stale TTL window. */
|
|
184
|
+
function invalidateChatAudience(chatId) {
|
|
185
|
+
cache.delete(chatId);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Chat-first workspace — watcher subscription helpers.
|
|
189
|
+
*
|
|
190
|
+
* Watchers (rows in `chat_subscriptions`) are non-speaking observers. A
|
|
191
|
+
* member who manages an agent that participates in a chat — but whose own
|
|
192
|
+
* human agent is not a speaker there — sees the chat in their workspace
|
|
193
|
+
* via a watcher row.
|
|
194
|
+
*
|
|
195
|
+
* Two distinct kinds of operation live here:
|
|
196
|
+
*
|
|
197
|
+
* 1. Set rebuilds (`recompute*`). Idempotent set-based recomputations
|
|
198
|
+
* driven by lifecycle events (chat created, participant added/removed,
|
|
199
|
+
* member status flipped, etc.). These DEFAULT new rows to NULL/0 read
|
|
200
|
+
* state.
|
|
201
|
+
*
|
|
202
|
+
* 2. State-carry transitions (`joinAsParticipant`, `leaveAsParticipant`).
|
|
203
|
+
* Move a single (chat, agent) pair between `chat_participants` and
|
|
204
|
+
* `chat_subscriptions` while preserving `last_read_at` and
|
|
205
|
+
* `unread_mention_count`. NEVER call recompute on this path or you'll
|
|
206
|
+
* lose read state.
|
|
207
|
+
*
|
|
208
|
+
* See docs/chat-first-workspace-product-design.md "State Transitions" and
|
|
209
|
+
* "Risk Constraints".
|
|
210
|
+
*/
|
|
211
|
+
/**
|
|
212
|
+
* Recompute watcher rows for ONE chat. For every active member who:
|
|
213
|
+
* - manages a non-human agent that speaks in the chat, AND
|
|
214
|
+
* - whose own human agent is NOT a speaker in the chat
|
|
215
|
+
* an `(chat_id, member.agent_id)` watcher row is upserted (NULL read state).
|
|
216
|
+
*
|
|
217
|
+
* Watchers whose anchoring condition no longer holds (manager left, the
|
|
218
|
+
* managed agent was removed from the chat, the manager joined as a speaker
|
|
219
|
+
* themselves) are deleted.
|
|
220
|
+
*
|
|
221
|
+
* Idempotent: safe to call multiple times for the same chat.
|
|
222
|
+
*/
|
|
223
|
+
async function recomputeChatWatchers(db, chatId) {
|
|
224
|
+
await db.execute(sql`
|
|
225
|
+
INSERT INTO chat_subscriptions
|
|
226
|
+
(chat_id, agent_id, kind, last_read_at, unread_mention_count, created_at)
|
|
227
|
+
SELECT DISTINCT cp.chat_id, m.agent_id, 'watching', NULL::timestamp with time zone, 0, now()
|
|
228
|
+
FROM chat_participants cp
|
|
229
|
+
JOIN agents a ON a.uuid = cp.agent_id
|
|
230
|
+
JOIN members m ON m.id = a.manager_id
|
|
231
|
+
WHERE cp.chat_id = ${chatId}
|
|
232
|
+
AND m.status = 'active'
|
|
233
|
+
AND a.type <> 'human'
|
|
234
|
+
AND NOT EXISTS (
|
|
235
|
+
SELECT 1 FROM chat_participants cp2
|
|
236
|
+
WHERE cp2.chat_id = cp.chat_id
|
|
237
|
+
AND cp2.agent_id = m.agent_id
|
|
238
|
+
)
|
|
239
|
+
ON CONFLICT (chat_id, agent_id) DO NOTHING
|
|
240
|
+
`);
|
|
241
|
+
await db.execute(sql`
|
|
242
|
+
DELETE FROM chat_subscriptions cs
|
|
243
|
+
WHERE cs.chat_id = ${chatId}
|
|
244
|
+
AND NOT EXISTS (
|
|
245
|
+
SELECT 1
|
|
246
|
+
FROM chat_participants cp
|
|
247
|
+
JOIN agents a ON a.uuid = cp.agent_id
|
|
248
|
+
JOIN members m ON m.id = a.manager_id
|
|
249
|
+
WHERE cp.chat_id = cs.chat_id
|
|
250
|
+
AND m.agent_id = cs.agent_id
|
|
251
|
+
AND m.status = 'active'
|
|
252
|
+
AND a.type <> 'human'
|
|
253
|
+
AND NOT EXISTS (
|
|
254
|
+
SELECT 1 FROM chat_participants cp2
|
|
255
|
+
WHERE cp2.chat_id = cp.chat_id
|
|
256
|
+
AND cp2.agent_id = m.agent_id
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
`);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Recompute watcher rows touching ONE agent across all chats it speaks in.
|
|
263
|
+
* Used after `rebindAgent` (manager change) so the new manager picks up
|
|
264
|
+
* watcher rows and the old manager's are dropped.
|
|
265
|
+
*/
|
|
266
|
+
async function recomputeWatchersForAgent(db, agentId) {
|
|
267
|
+
const chatRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId));
|
|
268
|
+
for (const { chatId } of chatRows) await recomputeChatWatchers(db, chatId);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Recompute watcher rows touching ONE member across all chats. Triggered
|
|
272
|
+
* when the member's status flips active ↔ left.
|
|
273
|
+
*/
|
|
274
|
+
async function recomputeWatchersForMember(db, memberId) {
|
|
275
|
+
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")));
|
|
276
|
+
for (const { chatId } of rows) await recomputeChatWatchers(db, chatId);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Mirror of `services/chat.ts` `maybeUpgradeDirectToGroup`. Inlined here so
|
|
280
|
+
* `joinAsParticipant` keeps the upgrade rule + the state carry in one
|
|
281
|
+
* transaction without depending on chat.ts (avoids a circular import).
|
|
282
|
+
*/
|
|
283
|
+
async function maybeUpgradeDirectToGroup$1(tx, chatId, existingParticipantIds) {
|
|
284
|
+
if (existingParticipantIds.length + 1 < 3) return;
|
|
285
|
+
const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
286
|
+
if (!chat || chat.type !== "direct") return;
|
|
287
|
+
await tx.update(chats).set({
|
|
288
|
+
type: "group",
|
|
289
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
290
|
+
}).where(eq(chats.id, chatId));
|
|
291
|
+
if (existingParticipantIds.length === 0) return;
|
|
292
|
+
const ids = (await tx.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((r) => r.uuid);
|
|
293
|
+
if (ids.length === 0) return;
|
|
294
|
+
await tx.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Watcher → speaking participant. State-carry transaction.
|
|
298
|
+
*
|
|
299
|
+
* 1. DELETE the watcher row (returning read state).
|
|
300
|
+
* 2. If a participant row already exists, no-op (idempotent).
|
|
301
|
+
* 3. Otherwise, run the direct → group upgrade rule against the *current*
|
|
302
|
+
* participant set, then INSERT the participant row carrying read state.
|
|
303
|
+
*
|
|
304
|
+
* If `requireWatcherOrVisible` is true, refuse when the user has neither a
|
|
305
|
+
* watcher row nor admin-derived visibility — used to keep the public
|
|
306
|
+
* `/me/chats/:chatId/join` endpoint honest. Pre-check happens in the
|
|
307
|
+
* route layer where we have the full member scope.
|
|
308
|
+
*/
|
|
309
|
+
async function joinAsParticipant(db, chatId, humanAgentId) {
|
|
310
|
+
return db.transaction(async (tx) => {
|
|
311
|
+
const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
|
|
312
|
+
lastReadAt: chatSubscriptions.lastReadAt,
|
|
313
|
+
unreadMentionCount: chatSubscriptions.unreadMentionCount
|
|
314
|
+
});
|
|
315
|
+
const [existing] = await tx.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
|
|
316
|
+
if (existing) return {
|
|
317
|
+
chatId,
|
|
318
|
+
inserted: false,
|
|
319
|
+
carried: carriedRow ?? null
|
|
320
|
+
};
|
|
321
|
+
await maybeUpgradeDirectToGroup$1(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId));
|
|
322
|
+
await tx.insert(chatParticipants).values({
|
|
323
|
+
chatId,
|
|
324
|
+
agentId: humanAgentId,
|
|
325
|
+
role: "member",
|
|
326
|
+
mode: "full",
|
|
327
|
+
lastReadAt: carriedRow?.lastReadAt ?? null,
|
|
328
|
+
unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
|
|
329
|
+
});
|
|
330
|
+
return {
|
|
331
|
+
chatId,
|
|
332
|
+
inserted: true,
|
|
333
|
+
carried: carriedRow ?? null
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Speaking participant → watcher (or fully detach).
|
|
339
|
+
*
|
|
340
|
+
* 1. DELETE the participant row (returning read state).
|
|
341
|
+
* 2. Test "still visible": is the user still the manager of an agent that
|
|
342
|
+
* remains a participant in this chat? If yes, INSERT a watcher row
|
|
343
|
+
* carrying read state. If no, drop entirely.
|
|
344
|
+
*
|
|
345
|
+
* Caller must validate that the user actually has a participant row to
|
|
346
|
+
* leave (returns `NotFoundError` if not).
|
|
347
|
+
*/
|
|
348
|
+
async function leaveAsParticipant(db, chatId, humanAgentId) {
|
|
349
|
+
return db.transaction(async (tx) => {
|
|
350
|
+
const [carried] = await tx.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).returning({
|
|
351
|
+
lastReadAt: chatParticipants.lastReadAt,
|
|
352
|
+
unreadMentionCount: chatParticipants.unreadMentionCount
|
|
353
|
+
});
|
|
354
|
+
if (!carried) throw new NotFoundError("Not a participant of this chat");
|
|
355
|
+
const [stillVisibleRow] = await tx.execute(sql`
|
|
356
|
+
SELECT EXISTS (
|
|
357
|
+
SELECT 1
|
|
358
|
+
FROM chat_participants cp
|
|
359
|
+
JOIN agents a ON a.uuid = cp.agent_id
|
|
360
|
+
JOIN members m ON m.id = a.manager_id
|
|
361
|
+
WHERE cp.chat_id = ${chatId}
|
|
362
|
+
AND m.agent_id = ${humanAgentId}
|
|
363
|
+
AND m.status = 'active'
|
|
364
|
+
AND a.type <> 'human'
|
|
365
|
+
) AS visible
|
|
366
|
+
`);
|
|
367
|
+
if (!Boolean(stillVisibleRow?.visible)) return {
|
|
368
|
+
chatId,
|
|
369
|
+
membershipKind: null
|
|
370
|
+
};
|
|
371
|
+
await tx.insert(chatSubscriptions).values({
|
|
372
|
+
chatId,
|
|
373
|
+
agentId: humanAgentId,
|
|
374
|
+
kind: "watching",
|
|
375
|
+
lastReadAt: carried.lastReadAt,
|
|
376
|
+
unreadMentionCount: carried.unreadMentionCount
|
|
377
|
+
}).onConflictDoNothing();
|
|
378
|
+
return {
|
|
379
|
+
chatId,
|
|
380
|
+
membershipKind: "watching"
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Resolve the membership row of the human agent for the given chat. Returns
|
|
386
|
+
* one of: 'participant', 'watching', or null.
|
|
387
|
+
*
|
|
388
|
+
* Used by `/me/chats/:chatId/join` to refuse a join when the user has
|
|
389
|
+
* neither a watcher row nor a participant row, and isn't otherwise
|
|
390
|
+
* authorised (admin in the chat's org).
|
|
391
|
+
*/
|
|
392
|
+
async function resolveChatMembership(db, chatId, humanAgentId) {
|
|
393
|
+
const [participant] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).limit(1);
|
|
394
|
+
if (participant) return "participant";
|
|
395
|
+
const [sub] = await db.select({ chatId: chatSubscriptions.chatId }).from(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).limit(1);
|
|
396
|
+
if (sub) return "watching";
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Used by `/me/chats/:chatId/join`. Throw 409 if already a speaker (no work
|
|
401
|
+
* to do) and 403 if no watcher row and no admin override. Admin override is
|
|
402
|
+
* resolved at the route layer; this helper only reports the watcher state.
|
|
403
|
+
*/
|
|
404
|
+
function ensureCanJoin(membership) {
|
|
405
|
+
if (membership === "participant") throw new ConflictError("Already a participant in this chat");
|
|
406
|
+
if (membership === null) throw new ForbiddenError("Not a watcher of this chat — open the chat from your workspace before joining");
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* When a direct chat grows past 2 participants, upgrade it to `group` and
|
|
410
|
+
* flip every existing non-human agent participant to `mention_only` — see
|
|
411
|
+
* proposals/hub-agent-messaging-reply-and-mentions §3.3. The caller is
|
|
412
|
+
* expected to insert the new participant AFTER this runs, so the "existing"
|
|
413
|
+
* set excludes them.
|
|
414
|
+
*
|
|
415
|
+
* Idempotent: if the chat is already a group, no-op.
|
|
416
|
+
*/
|
|
417
|
+
async function maybeUpgradeDirectToGroup(db, chatId, existingParticipantIds, newParticipantCount) {
|
|
418
|
+
if (existingParticipantIds.length + newParticipantCount < 3) return;
|
|
419
|
+
const [chat] = await db.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
420
|
+
if (!chat || chat.type !== "direct") return;
|
|
421
|
+
await db.update(chats).set({
|
|
422
|
+
type: "group",
|
|
423
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
424
|
+
}).where(eq(chats.id, chatId));
|
|
425
|
+
if (existingParticipantIds.length === 0) return;
|
|
426
|
+
const ids = (await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, existingParticipantIds), ne(agents.type, "human")))).map((a) => a.uuid);
|
|
427
|
+
if (ids.length === 0) return;
|
|
428
|
+
await db.update(chatParticipants).set({ mode: "mention_only" }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, ids)));
|
|
429
|
+
}
|
|
430
|
+
async function createChat(db, creatorId, data) {
|
|
431
|
+
const chatId = randomUUID();
|
|
432
|
+
const allParticipantIds = new Set([creatorId, ...data.participantIds]);
|
|
433
|
+
const existingAgents = await db.select({
|
|
434
|
+
id: agents.uuid,
|
|
435
|
+
organizationId: agents.organizationId,
|
|
436
|
+
type: agents.type
|
|
437
|
+
}).from(agents).where(inArray(agents.uuid, [...allParticipantIds]));
|
|
438
|
+
if (existingAgents.length !== allParticipantIds.size) {
|
|
439
|
+
const found = new Set(existingAgents.map((a) => a.id));
|
|
440
|
+
throw new BadRequestError(`Agents not found: ${[...allParticipantIds].filter((id) => !found.has(id)).join(", ")}`);
|
|
441
|
+
}
|
|
442
|
+
const creator = existingAgents.find((a) => a.id === creatorId);
|
|
443
|
+
if (!creator) throw new Error("Unexpected: creator not in existingAgents");
|
|
444
|
+
const orgId = creator.organizationId;
|
|
445
|
+
const crossOrg = existingAgents.filter((a) => a.organizationId !== orgId);
|
|
446
|
+
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.id).join(", ")}`);
|
|
447
|
+
const isDirectAgentOnly = data.type === "direct" && existingAgents.every((a) => a.type !== "human");
|
|
448
|
+
return db.transaction(async (tx) => {
|
|
449
|
+
const [chat] = await tx.insert(chats).values({
|
|
450
|
+
id: chatId,
|
|
451
|
+
organizationId: orgId,
|
|
452
|
+
type: data.type,
|
|
453
|
+
topic: data.topic ?? null,
|
|
454
|
+
metadata: data.metadata ?? {}
|
|
455
|
+
}).returning();
|
|
456
|
+
const participantRows = [...allParticipantIds].map((agentId) => ({
|
|
457
|
+
chatId,
|
|
458
|
+
agentId,
|
|
459
|
+
role: agentId === creatorId ? "owner" : "member",
|
|
460
|
+
...isDirectAgentOnly ? { mode: "mention_only" } : {}
|
|
461
|
+
}));
|
|
462
|
+
await tx.insert(chatParticipants).values(participantRows);
|
|
463
|
+
await recomputeChatWatchers(tx, chatId);
|
|
464
|
+
const participants = await tx.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
465
|
+
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
466
|
+
return {
|
|
467
|
+
...chat,
|
|
468
|
+
participants
|
|
469
|
+
};
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
async function getChat(db, chatId) {
|
|
473
|
+
const [chat] = await db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
474
|
+
if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
475
|
+
return chat;
|
|
476
|
+
}
|
|
477
|
+
async function getChatDetail(db, chatId) {
|
|
478
|
+
const chat = await getChat(db, chatId);
|
|
479
|
+
const participants = await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
480
|
+
return {
|
|
481
|
+
...chat,
|
|
482
|
+
participants
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
async function listChats(db, agentId, limit, cursor) {
|
|
486
|
+
const chatIds = (await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentId))).map((r) => r.chatId);
|
|
487
|
+
if (chatIds.length === 0) return {
|
|
488
|
+
items: [],
|
|
489
|
+
nextCursor: null
|
|
490
|
+
};
|
|
491
|
+
const where = cursor ? and(inArray(chats.id, chatIds), lt(chats.updatedAt, new Date(cursor))) : inArray(chats.id, chatIds);
|
|
492
|
+
const rows = await db.select().from(chats).where(where).orderBy(desc(chats.updatedAt)).limit(limit + 1);
|
|
493
|
+
const hasMore = rows.length > limit;
|
|
494
|
+
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
495
|
+
const last = items[items.length - 1];
|
|
496
|
+
return {
|
|
497
|
+
items,
|
|
498
|
+
nextCursor: hasMore && last ? last.updatedAt.toISOString() : null
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* List participants of a chat with their agent names — used by the client
|
|
503
|
+
* runtime to resolve `@<name>` mentions against the authoritative participant
|
|
504
|
+
* set (see proposals/hub-agent-messaging-reply-and-mentions §4).
|
|
505
|
+
*/
|
|
506
|
+
async function listChatParticipantsWithNames(db, chatId) {
|
|
507
|
+
return await db.select({
|
|
508
|
+
agentId: chatParticipants.agentId,
|
|
509
|
+
role: chatParticipants.role,
|
|
510
|
+
mode: chatParticipants.mode,
|
|
511
|
+
joinedAt: chatParticipants.joinedAt,
|
|
512
|
+
name: agents.name,
|
|
513
|
+
displayName: agents.displayName,
|
|
514
|
+
type: agents.type
|
|
515
|
+
}).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId));
|
|
516
|
+
}
|
|
517
|
+
async function assertParticipant(db, chatId, agentId) {
|
|
518
|
+
const [row] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
|
|
519
|
+
if (!row) throw new ForbiddenError("Not a participant of this chat");
|
|
520
|
+
}
|
|
521
|
+
/** Ensure an agent is a participant of a chat. Silently adds them if not already. */
|
|
522
|
+
async function ensureParticipant(db, chatId, agentId) {
|
|
523
|
+
const [existing] = await db.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, agentId))).limit(1);
|
|
524
|
+
if (existing) return;
|
|
525
|
+
await db.transaction(async (tx) => {
|
|
526
|
+
await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
|
|
527
|
+
await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, agentId)));
|
|
528
|
+
await tx.insert(chatParticipants).values({
|
|
529
|
+
chatId,
|
|
530
|
+
agentId,
|
|
531
|
+
mode: "full"
|
|
532
|
+
}).onConflictDoNothing({ target: [chatParticipants.chatId, chatParticipants.agentId] });
|
|
533
|
+
await recomputeChatWatchers(tx, chatId);
|
|
534
|
+
});
|
|
535
|
+
invalidateChatAudience(chatId);
|
|
536
|
+
}
|
|
537
|
+
async function addParticipant(db, chatId, requesterId, data) {
|
|
538
|
+
const chat = await getChat(db, chatId);
|
|
539
|
+
await assertParticipant(db, chatId, requesterId);
|
|
540
|
+
const [targetAgent] = await db.select({
|
|
541
|
+
id: agents.uuid,
|
|
542
|
+
organizationId: agents.organizationId
|
|
543
|
+
}).from(agents).where(eq(agents.uuid, data.agentId)).limit(1);
|
|
544
|
+
if (!targetAgent) throw new NotFoundError(`Agent "${data.agentId}" not found`);
|
|
545
|
+
if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
|
|
546
|
+
const [existing] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, data.agentId))).limit(1);
|
|
547
|
+
if (existing) throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
|
|
548
|
+
await db.transaction(async (tx) => {
|
|
549
|
+
await maybeUpgradeDirectToGroup(tx, chatId, (await tx.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId), 1);
|
|
550
|
+
await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, data.agentId)));
|
|
551
|
+
await tx.insert(chatParticipants).values({
|
|
552
|
+
chatId,
|
|
553
|
+
agentId: data.agentId,
|
|
554
|
+
mode: data.mode ?? "full"
|
|
555
|
+
});
|
|
556
|
+
await recomputeChatWatchers(tx, chatId);
|
|
557
|
+
});
|
|
558
|
+
invalidateChatAudience(chatId);
|
|
559
|
+
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
560
|
+
}
|
|
561
|
+
async function removeParticipant(db, chatId, requesterId, targetAgentId) {
|
|
562
|
+
await assertParticipant(db, chatId, requesterId);
|
|
563
|
+
if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
|
|
564
|
+
const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, targetAgentId))).returning();
|
|
565
|
+
if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
|
|
566
|
+
await recomputeChatWatchers(db, chatId);
|
|
567
|
+
invalidateChatAudience(chatId);
|
|
568
|
+
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* List chats visible to a member, grouped by agent.
|
|
572
|
+
* A member sees chats where:
|
|
573
|
+
* 1. Their human agent is a participant, OR
|
|
574
|
+
* 2. Any agent they manage (managerId = memberId) is a participant (supervision)
|
|
575
|
+
*/
|
|
576
|
+
async function listChatsForMember(db, memberId, humanAgentId) {
|
|
577
|
+
const managedAgents = await db.select({
|
|
578
|
+
uuid: agents.uuid,
|
|
579
|
+
name: agents.name,
|
|
580
|
+
type: agents.type,
|
|
581
|
+
displayName: agents.displayName
|
|
582
|
+
}).from(agents).where(eq(agents.managerId, memberId));
|
|
583
|
+
const agentMap = /* @__PURE__ */ new Map();
|
|
584
|
+
for (const a of managedAgents) agentMap.set(a.uuid, a);
|
|
585
|
+
if (!agentMap.has(humanAgentId)) {
|
|
586
|
+
const [ha] = await db.select({
|
|
587
|
+
uuid: agents.uuid,
|
|
588
|
+
name: agents.name,
|
|
589
|
+
type: agents.type,
|
|
590
|
+
displayName: agents.displayName
|
|
591
|
+
}).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
|
|
592
|
+
if (ha) agentMap.set(ha.uuid, ha);
|
|
593
|
+
}
|
|
594
|
+
const agentIds = [...agentMap.keys()];
|
|
595
|
+
if (agentIds.length === 0) return [];
|
|
596
|
+
const participations = await db.select({
|
|
597
|
+
chatId: chatParticipants.chatId,
|
|
598
|
+
agentId: chatParticipants.agentId,
|
|
599
|
+
role: chatParticipants.role,
|
|
600
|
+
mode: chatParticipants.mode
|
|
601
|
+
}).from(chatParticipants).where(inArray(chatParticipants.agentId, agentIds));
|
|
602
|
+
if (participations.length === 0) return [];
|
|
603
|
+
const chatIds = [...new Set(participations.map((p) => p.chatId))];
|
|
604
|
+
const agentChatMap = /* @__PURE__ */ new Map();
|
|
605
|
+
for (const p of participations) {
|
|
606
|
+
const list = agentChatMap.get(p.agentId) ?? [];
|
|
607
|
+
list.push(p.chatId);
|
|
608
|
+
agentChatMap.set(p.agentId, list);
|
|
609
|
+
}
|
|
610
|
+
const chatRows = await db.select({
|
|
611
|
+
id: chats.id,
|
|
612
|
+
type: chats.type,
|
|
613
|
+
topic: chats.topic,
|
|
614
|
+
metadata: chats.metadata,
|
|
615
|
+
createdAt: chats.createdAt,
|
|
616
|
+
updatedAt: chats.updatedAt,
|
|
617
|
+
participantCount: sql`(SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id})`
|
|
618
|
+
}).from(chats).where(inArray(chats.id, chatIds)).orderBy(desc(chats.updatedAt));
|
|
619
|
+
const chatMap = new Map(chatRows.map((c) => [c.id, c]));
|
|
620
|
+
const humanParticipantChatIds = new Set(participations.filter((p) => p.agentId === humanAgentId).map((p) => p.chatId));
|
|
621
|
+
const result = [];
|
|
622
|
+
for (const [agentId, agentChatIds] of agentChatMap) {
|
|
623
|
+
const agentInfo = agentMap.get(agentId);
|
|
624
|
+
if (!agentInfo) continue;
|
|
625
|
+
const agentChats = agentChatIds.map((chatId) => {
|
|
626
|
+
const chat = chatMap.get(chatId);
|
|
627
|
+
if (!chat) return null;
|
|
628
|
+
const isSupervisionOnly = agentId !== humanAgentId && !humanParticipantChatIds.has(chatId);
|
|
629
|
+
return {
|
|
630
|
+
id: chat.id,
|
|
631
|
+
type: chat.type,
|
|
632
|
+
topic: chat.topic,
|
|
633
|
+
participantCount: chat.participantCount,
|
|
634
|
+
isSupervisionOnly,
|
|
635
|
+
createdAt: chat.createdAt.toISOString(),
|
|
636
|
+
updatedAt: chat.updatedAt.toISOString()
|
|
637
|
+
};
|
|
638
|
+
}).filter((c) => c !== null);
|
|
639
|
+
if (agentChats.length > 0) result.push({
|
|
640
|
+
agent: agentInfo,
|
|
641
|
+
chats: agentChats
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
return result;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Manager joins a chat. Adds their human agent as a participant.
|
|
648
|
+
* Requires the member to have supervision rights (manages at least one existing participant).
|
|
649
|
+
*/
|
|
650
|
+
async function joinChat(db, chatId, memberId, humanAgentId) {
|
|
651
|
+
const chat = await getChat(db, chatId);
|
|
652
|
+
const participantAgentIds = (await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId);
|
|
653
|
+
if (participantAgentIds.length === 0) throw new NotFoundError("Chat has no participants");
|
|
654
|
+
if (participantAgentIds.includes(humanAgentId)) throw new ConflictError("Already a participant in this chat");
|
|
655
|
+
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");
|
|
656
|
+
const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
|
|
657
|
+
if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
|
|
658
|
+
await db.transaction(async (tx) => {
|
|
659
|
+
const [carriedRow] = await tx.delete(chatSubscriptions).where(and(eq(chatSubscriptions.chatId, chatId), eq(chatSubscriptions.agentId, humanAgentId))).returning({
|
|
660
|
+
lastReadAt: chatSubscriptions.lastReadAt,
|
|
661
|
+
unreadMentionCount: chatSubscriptions.unreadMentionCount
|
|
662
|
+
});
|
|
663
|
+
await maybeUpgradeDirectToGroup(tx, chatId, participantAgentIds, 1);
|
|
664
|
+
await tx.insert(chatParticipants).values({
|
|
665
|
+
chatId,
|
|
666
|
+
agentId: humanAgentId,
|
|
667
|
+
role: "member",
|
|
668
|
+
mode: "full",
|
|
669
|
+
lastReadAt: carriedRow?.lastReadAt ?? null,
|
|
670
|
+
unreadMentionCount: carriedRow?.unreadMentionCount ?? 0
|
|
671
|
+
});
|
|
672
|
+
await recomputeChatWatchers(tx, chatId);
|
|
673
|
+
});
|
|
674
|
+
invalidateChatAudience(chatId);
|
|
675
|
+
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Manager leaves a chat. Removes their human agent from participants.
|
|
679
|
+
* Only allowed if the human agent is a participant.
|
|
680
|
+
*
|
|
681
|
+
* Delegates the participant→watcher transition to `leaveAsParticipant`
|
|
682
|
+
* so admin-side and `/me/chats/:id/leave` share one canonical path. The
|
|
683
|
+
* earlier "recompute then UPDATE-back state" variant violated the design
|
|
684
|
+
* rule that recompute is only for set rebuild — never on a transition
|
|
685
|
+
* path (review #228 issue #2). The returned participant list is fetched
|
|
686
|
+
* after the tx commits, matching the admin route's existing contract.
|
|
687
|
+
*/
|
|
688
|
+
async function leaveChat(db, chatId, humanAgentId) {
|
|
689
|
+
await leaveAsParticipant(db, chatId, humanAgentId);
|
|
690
|
+
invalidateChatAudience(chatId);
|
|
691
|
+
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
692
|
+
}
|
|
693
|
+
async function findOrCreateDirectChat(db, agentAId, agentBId) {
|
|
694
|
+
const aChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentAId));
|
|
695
|
+
const bChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentBId));
|
|
696
|
+
const bChatIds = new Set(bChats.map((r) => r.chatId));
|
|
697
|
+
const commonChatIds = aChats.map((r) => r.chatId).filter((id) => bChatIds.has(id));
|
|
698
|
+
if (commonChatIds.length > 0) {
|
|
699
|
+
const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct")));
|
|
700
|
+
if (directChats.length > 0 && directChats[0]) return directChats[0];
|
|
701
|
+
}
|
|
702
|
+
const ends = await db.select({
|
|
703
|
+
uuid: agents.uuid,
|
|
704
|
+
organizationId: agents.organizationId,
|
|
705
|
+
type: agents.type
|
|
706
|
+
}).from(agents).where(inArray(agents.uuid, [agentAId, agentBId]));
|
|
707
|
+
const agentA = ends.find((a) => a.uuid === agentAId);
|
|
708
|
+
if (!agentA) throw new NotFoundError(`Agent "${agentAId}" not found`);
|
|
709
|
+
const agentB = ends.find((a) => a.uuid === agentBId);
|
|
710
|
+
if (!agentB) throw new NotFoundError(`Agent "${agentBId}" not found`);
|
|
711
|
+
const mode = agentA.type !== "human" && agentB.type !== "human" ? "mention_only" : "full";
|
|
712
|
+
const chatId = randomUUID();
|
|
713
|
+
return db.transaction(async (tx) => {
|
|
714
|
+
const [chat] = await tx.insert(chats).values({
|
|
715
|
+
id: chatId,
|
|
716
|
+
organizationId: agentA.organizationId,
|
|
717
|
+
type: "direct"
|
|
718
|
+
}).returning();
|
|
719
|
+
await tx.insert(chatParticipants).values([{
|
|
720
|
+
chatId,
|
|
721
|
+
agentId: agentAId,
|
|
722
|
+
role: "member",
|
|
723
|
+
mode
|
|
724
|
+
}, {
|
|
725
|
+
chatId,
|
|
726
|
+
agentId: agentBId,
|
|
727
|
+
role: "member",
|
|
728
|
+
mode
|
|
729
|
+
}]);
|
|
730
|
+
await recomputeChatWatchers(tx, chatId);
|
|
731
|
+
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
732
|
+
return chat;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
/** Agent presence and runtime state. Tracked via WebSocket connections; stale entries are cleaned up using server_instances heartbeat. */
|
|
736
|
+
const agentPresence = pgTable("agent_presence", {
|
|
737
|
+
agentId: text("agent_id").primaryKey().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
738
|
+
status: text("status").notNull().default("offline"),
|
|
739
|
+
instanceId: text("instance_id"),
|
|
740
|
+
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
|
741
|
+
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
|
|
742
|
+
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
|
743
|
+
runtimeType: text("runtime_type"),
|
|
744
|
+
runtimeVersion: text("runtime_version"),
|
|
745
|
+
runtimeState: text("runtime_state"),
|
|
746
|
+
activeSessions: integer("active_sessions"),
|
|
747
|
+
totalSessions: integer("total_sessions"),
|
|
748
|
+
runtimeUpdatedAt: timestamp("runtime_updated_at", { withTimezone: true })
|
|
749
|
+
});
|
|
750
|
+
/**
|
|
751
|
+
* Shared access-control primitives. Most route-level gating now lives in
|
|
752
|
+
* `scope/require-*.ts` — this module is reduced to two helpers that need
|
|
753
|
+
* SQL building blocks reused across routes and tests:
|
|
754
|
+
*
|
|
755
|
+
* - `agentVisibilityCondition` — WHERE clause for "agents visible to a
|
|
756
|
+
* member" (org-visible OR managerId = the caller's member). Composed
|
|
757
|
+
* into list queries that already select from `agents`.
|
|
758
|
+
* - `listAgentsManagedByUser` — cross-org list of agents personally
|
|
759
|
+
* managed by a user; powers the CLI `agent list --remote` view.
|
|
760
|
+
*
|
|
761
|
+
* Visibility is the same for all roles — admin sees the same set as a
|
|
762
|
+
* regular member. Admin privilege is expressed through manageability
|
|
763
|
+
* (`requireAgentAccess(..., "manage")`), not visibility.
|
|
764
|
+
*/
|
|
765
|
+
/**
|
|
766
|
+
* SQL WHERE conditions for agents visible to a member.
|
|
767
|
+
* target org + not deleted + (organization-visible OR managerId = caller's member)
|
|
768
|
+
*/
|
|
769
|
+
function agentVisibilityCondition(orgId, memberId) {
|
|
770
|
+
return and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId)));
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Cross-org listing helper for "agents I personally manage". Used by the
|
|
774
|
+
* CLI `agent list --remote` view — JOINs `agents → members.id` and filters
|
|
775
|
+
* by `members.user_id`.
|
|
776
|
+
*/
|
|
777
|
+
async function listAgentsManagedByUser(db, userId) {
|
|
778
|
+
return db.select({
|
|
779
|
+
uuid: agents.uuid,
|
|
780
|
+
name: agents.name,
|
|
781
|
+
displayName: agents.displayName,
|
|
782
|
+
type: agents.type,
|
|
783
|
+
organizationId: agents.organizationId,
|
|
784
|
+
inboxId: agents.inboxId,
|
|
785
|
+
visibility: agents.visibility,
|
|
786
|
+
runtimeProvider: agents.runtimeProvider,
|
|
787
|
+
clientId: agents.clientId
|
|
788
|
+
}).from(agents).innerJoin(members, eq(agents.managerId, members.id)).where(and(eq(members.userId, userId), eq(members.status, "active"), ne(agents.status, AGENT_STATUSES.DELETED)));
|
|
789
|
+
}
|
|
790
|
+
/** Messages. Immutable after creation. Each message belongs to exactly one Chat. */
|
|
791
|
+
const messages = pgTable("messages", {
|
|
792
|
+
id: text("id").primaryKey(),
|
|
793
|
+
chatId: text("chat_id").notNull().references(() => chats.id),
|
|
794
|
+
senderId: text("sender_id").notNull(),
|
|
795
|
+
format: text("format").notNull(),
|
|
796
|
+
content: jsonb("content").$type().notNull(),
|
|
797
|
+
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
798
|
+
replyToInbox: text("reply_to_inbox"),
|
|
799
|
+
replyToChat: text("reply_to_chat"),
|
|
800
|
+
inReplyTo: text("in_reply_to"),
|
|
801
|
+
source: text("source"),
|
|
802
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
803
|
+
}, (table) => [index("idx_messages_chat_time").on(table.chatId, table.createdAt), index("idx_messages_in_reply_to").on(table.inReplyTo)]);
|
|
804
|
+
/** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
|
|
805
|
+
const inboxEntries = pgTable("inbox_entries", {
|
|
806
|
+
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
807
|
+
inboxId: text("inbox_id").notNull(),
|
|
808
|
+
messageId: text("message_id").notNull().references(() => messages.id),
|
|
809
|
+
chatId: text("chat_id"),
|
|
810
|
+
status: text("status").notNull().default("pending"),
|
|
811
|
+
notify: boolean("notify").notNull().default(true),
|
|
812
|
+
retryCount: integer("retry_count").notNull().default(0),
|
|
813
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
814
|
+
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
|
|
815
|
+
ackedAt: timestamp("acked_at", { withTimezone: true })
|
|
816
|
+
}, (table) => [
|
|
817
|
+
unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
|
|
818
|
+
index("idx_inbox_pending").on(table.inboxId, table.createdAt),
|
|
819
|
+
index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
|
|
820
|
+
index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
|
|
821
|
+
]);
|
|
822
|
+
/** Server instance heartbeat. Used to detect crashed instances and clean up associated agent_presence records. */
|
|
823
|
+
const serverInstances = pgTable("server_instances", {
|
|
824
|
+
instanceId: text("instance_id").primaryKey(),
|
|
825
|
+
lastHeartbeat: timestamp("last_heartbeat", { withTimezone: true }).notNull().defaultNow()
|
|
826
|
+
});
|
|
827
|
+
/** Common field reset when agent goes offline or is unbound. */
|
|
828
|
+
function runtimeFieldsReset(now) {
|
|
829
|
+
return {
|
|
830
|
+
runtimeState: null,
|
|
831
|
+
activeSessions: null,
|
|
832
|
+
totalSessions: null,
|
|
833
|
+
runtimeUpdatedAt: now,
|
|
834
|
+
lastSeenAt: now
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
async function setOffline(db, agentId) {
|
|
838
|
+
const now = /* @__PURE__ */ new Date();
|
|
839
|
+
await db.update(agentPresence).set({
|
|
840
|
+
status: "offline",
|
|
841
|
+
instanceId: null,
|
|
842
|
+
...runtimeFieldsReset(now)
|
|
843
|
+
}).where(eq(agentPresence.agentId, agentId));
|
|
844
|
+
}
|
|
845
|
+
async function getPresence(db, agentId) {
|
|
846
|
+
const [row] = await db.select().from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
847
|
+
return row ?? null;
|
|
848
|
+
}
|
|
849
|
+
async function getOnlineCount(db) {
|
|
850
|
+
const [result] = await db.select({ count: sql`count(*)::int` }).from(agentPresence).where(eq(agentPresence.status, "online"));
|
|
851
|
+
return result?.count ?? 0;
|
|
852
|
+
}
|
|
853
|
+
async function bindAgent(db, agentId, data) {
|
|
854
|
+
const now = /* @__PURE__ */ new Date();
|
|
855
|
+
await db.insert(agentPresence).values({
|
|
856
|
+
agentId,
|
|
857
|
+
status: "online",
|
|
858
|
+
instanceId: data.instanceId,
|
|
859
|
+
clientId: data.clientId,
|
|
860
|
+
runtimeType: data.runtimeType,
|
|
861
|
+
runtimeVersion: data.runtimeVersion ?? null,
|
|
862
|
+
runtimeState: "idle",
|
|
863
|
+
connectedAt: now,
|
|
864
|
+
lastSeenAt: now,
|
|
865
|
+
runtimeUpdatedAt: now
|
|
866
|
+
}).onConflictDoUpdate({
|
|
867
|
+
target: agentPresence.agentId,
|
|
868
|
+
set: {
|
|
869
|
+
status: "online",
|
|
870
|
+
instanceId: data.instanceId,
|
|
871
|
+
clientId: data.clientId,
|
|
872
|
+
runtimeType: data.runtimeType,
|
|
873
|
+
runtimeVersion: data.runtimeVersion ?? null,
|
|
874
|
+
runtimeState: "idle",
|
|
875
|
+
activeSessions: null,
|
|
876
|
+
totalSessions: null,
|
|
877
|
+
connectedAt: now,
|
|
878
|
+
lastSeenAt: now,
|
|
879
|
+
runtimeUpdatedAt: now
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
async function unbindAgent(db, agentId) {
|
|
884
|
+
const now = /* @__PURE__ */ new Date();
|
|
885
|
+
await db.update(agentPresence).set({
|
|
886
|
+
status: "offline",
|
|
887
|
+
clientId: null,
|
|
888
|
+
...runtimeFieldsReset(now)
|
|
889
|
+
}).where(eq(agentPresence.agentId, agentId));
|
|
890
|
+
}
|
|
891
|
+
/** Set runtime state directly from client-reported value.
|
|
892
|
+
*
|
|
893
|
+
* When an org-scoped notifier is provided, emit a PG NOTIFY on the
|
|
894
|
+
* `runtime_state_changes` channel so the pulse aggregator (and any future
|
|
895
|
+
* admin-side consumers) can observe the transition. Fire-and-forget to match
|
|
896
|
+
* notifier semantics elsewhere in this module. */
|
|
897
|
+
async function setRuntimeState(db, agentId, runtimeState, options) {
|
|
898
|
+
const now = /* @__PURE__ */ new Date();
|
|
899
|
+
await db.update(agentPresence).set({
|
|
900
|
+
runtimeState,
|
|
901
|
+
runtimeUpdatedAt: now,
|
|
902
|
+
lastSeenAt: now
|
|
903
|
+
}).where(eq(agentPresence.agentId, agentId));
|
|
904
|
+
if (options?.notifier && options.organizationId) options.notifier.notifyRuntimeStateChange(agentId, runtimeState, options.organizationId).catch(() => {});
|
|
905
|
+
}
|
|
906
|
+
/** Touch agent last_seen_at on heartbeat (per-agent liveness). */
|
|
907
|
+
async function touchAgent(db, agentId) {
|
|
908
|
+
await db.update(agentPresence).set({ lastSeenAt: /* @__PURE__ */ new Date() }).where(eq(agentPresence.agentId, agentId));
|
|
909
|
+
}
|
|
910
|
+
async function heartbeatInstance(db, instanceId) {
|
|
911
|
+
await db.insert(serverInstances).values({
|
|
912
|
+
instanceId,
|
|
913
|
+
lastHeartbeat: /* @__PURE__ */ new Date()
|
|
914
|
+
}).onConflictDoUpdate({
|
|
915
|
+
target: serverInstances.instanceId,
|
|
916
|
+
set: { lastHeartbeat: /* @__PURE__ */ new Date() }
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* M1: Mark agents as offline whose last_seen_at is older than staleSeconds.
|
|
921
|
+
* Unlike cleanupStalePresence (which checks instance liveness), this checks
|
|
922
|
+
* per-agent heartbeat liveness — detecting agents that stopped heartbeating
|
|
923
|
+
* while the client process may still be alive.
|
|
924
|
+
*
|
|
925
|
+
* Returns the list of agent IDs that were marked stale (for notification in Step 6).
|
|
926
|
+
*/
|
|
927
|
+
async function markStaleAgents(db, staleSeconds = 60) {
|
|
928
|
+
return (await db.execute(sql`
|
|
929
|
+
UPDATE agent_presence SET
|
|
930
|
+
status = 'offline',
|
|
931
|
+
client_id = NULL,
|
|
932
|
+
runtime_state = NULL,
|
|
933
|
+
active_sessions = NULL,
|
|
934
|
+
total_sessions = NULL,
|
|
935
|
+
runtime_updated_at = NOW()
|
|
936
|
+
WHERE status = 'online'
|
|
937
|
+
AND last_seen_at < NOW() - make_interval(secs => ${staleSeconds})
|
|
938
|
+
RETURNING agent_id
|
|
939
|
+
`)).map((r) => r.agent_id);
|
|
940
|
+
}
|
|
941
|
+
async function cleanupStalePresence(db, staleSeconds = 60) {
|
|
942
|
+
return (await db.execute(sql`
|
|
943
|
+
UPDATE agent_presence SET status = 'offline', instance_id = NULL,
|
|
944
|
+
runtime_state = NULL,
|
|
945
|
+
active_sessions = NULL, total_sessions = NULL,
|
|
946
|
+
runtime_updated_at = NOW()
|
|
947
|
+
WHERE instance_id IN (
|
|
948
|
+
SELECT instance_id FROM server_instances
|
|
949
|
+
WHERE last_heartbeat < NOW() - make_interval(secs => ${staleSeconds})
|
|
950
|
+
)
|
|
951
|
+
AND status = 'online'
|
|
952
|
+
RETURNING agent_id
|
|
953
|
+
`)).length;
|
|
954
|
+
}
|
|
955
|
+
/** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
|
|
956
|
+
const agentChatSessions = pgTable("agent_chat_sessions", {
|
|
957
|
+
agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
958
|
+
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
959
|
+
state: text("state").notNull(),
|
|
960
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
961
|
+
}, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
|
|
962
|
+
/**
|
|
963
|
+
* Upsert session state + refresh presence aggregates + NOTIFY.
|
|
964
|
+
*
|
|
965
|
+
* `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
|
|
966
|
+
* state" cache, not a session history log. A new runtime session starting on
|
|
967
|
+
* the same (agent, chat) pair MUST overwrite whatever ended before — including
|
|
968
|
+
* an `evicted` row left by a previous terminate. The previous "revival
|
|
969
|
+
* defense" conflated two concerns: "this runtime session ended" (which is
|
|
970
|
+
* what `evicted` actually means) and "this chat is permanently archived for
|
|
971
|
+
* this agent" (a chat-level decision that should live on `chats`, not here).
|
|
972
|
+
* See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
|
|
973
|
+
*
|
|
974
|
+
* Presence row contract: this function tolerates a missing `agent_presence`
|
|
975
|
+
* row by using `INSERT ... ON CONFLICT DO UPDATE`. The predictive-write path
|
|
976
|
+
* (sendMessage on first message) may target an agent whose client has never
|
|
977
|
+
* bound, so a prior `update agent_presence ... where agentId` would silently
|
|
978
|
+
* drop the activeSessions/totalSessions refresh. See PR #198 review §2.
|
|
979
|
+
*/
|
|
980
|
+
async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier, options) {
|
|
981
|
+
const now = /* @__PURE__ */ new Date();
|
|
982
|
+
let wrote = false;
|
|
983
|
+
await db.transaction(async (tx) => {
|
|
984
|
+
await tx.insert(agentChatSessions).values({
|
|
985
|
+
agentId,
|
|
986
|
+
chatId,
|
|
987
|
+
state,
|
|
988
|
+
updatedAt: now
|
|
989
|
+
}).onConflictDoUpdate({
|
|
990
|
+
target: [agentChatSessions.agentId, agentChatSessions.chatId],
|
|
991
|
+
set: {
|
|
992
|
+
state,
|
|
993
|
+
updatedAt: now
|
|
994
|
+
},
|
|
995
|
+
setWhere: ne(agentChatSessions.state, state)
|
|
996
|
+
});
|
|
997
|
+
const [counts] = await tx.select({
|
|
998
|
+
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
999
|
+
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
1000
|
+
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
1001
|
+
const activeSessions = counts?.active ?? 0;
|
|
1002
|
+
const totalSessions = counts?.total ?? 0;
|
|
1003
|
+
const presenceSet = options?.touchPresenceLastSeen ?? true ? {
|
|
1004
|
+
activeSessions,
|
|
1005
|
+
totalSessions,
|
|
1006
|
+
lastSeenAt: now
|
|
1007
|
+
} : {
|
|
1008
|
+
activeSessions,
|
|
1009
|
+
totalSessions
|
|
1010
|
+
};
|
|
1011
|
+
await tx.insert(agentPresence).values({
|
|
1012
|
+
agentId,
|
|
1013
|
+
activeSessions,
|
|
1014
|
+
totalSessions
|
|
1015
|
+
}).onConflictDoUpdate({
|
|
1016
|
+
target: [agentPresence.agentId],
|
|
1017
|
+
set: presenceSet
|
|
1018
|
+
});
|
|
1019
|
+
wrote = true;
|
|
1020
|
+
});
|
|
1021
|
+
if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
|
|
1022
|
+
}
|
|
1023
|
+
async function resetActivity(db, agentId) {
|
|
1024
|
+
const now = /* @__PURE__ */ new Date();
|
|
1025
|
+
await db.update(agentPresence).set({
|
|
1026
|
+
runtimeState: "idle",
|
|
1027
|
+
runtimeUpdatedAt: now
|
|
1028
|
+
}).where(eq(agentPresence.agentId, agentId));
|
|
1029
|
+
}
|
|
1030
|
+
async function getActivityOverview(db) {
|
|
1031
|
+
const [agentCounts] = await db.select({
|
|
1032
|
+
total: sql`count(*)::int`,
|
|
1033
|
+
running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
|
|
1034
|
+
idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
|
|
1035
|
+
working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
|
|
1036
|
+
blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
|
|
1037
|
+
error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
|
|
1038
|
+
}).from(agentPresence);
|
|
1039
|
+
const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
|
|
1040
|
+
return {
|
|
1041
|
+
total: agentCounts?.total ?? 0,
|
|
1042
|
+
running: agentCounts?.running ?? 0,
|
|
1043
|
+
byState: {
|
|
1044
|
+
idle: agentCounts?.idle ?? 0,
|
|
1045
|
+
working: agentCounts?.working ?? 0,
|
|
1046
|
+
blocked: agentCounts?.blocked ?? 0,
|
|
1047
|
+
error: agentCounts?.error ?? 0
|
|
1048
|
+
},
|
|
1049
|
+
clients: clientCounts?.count ?? 0
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* List agents with active runtime state.
|
|
1054
|
+
* When scope is provided, filters to agents visible to the member.
|
|
1055
|
+
*/
|
|
1056
|
+
async function listAgentsWithRuntime(db, scope) {
|
|
1057
|
+
if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
|
|
1058
|
+
return db.select({
|
|
1059
|
+
agentId: agentPresence.agentId,
|
|
1060
|
+
status: agentPresence.status,
|
|
1061
|
+
instanceId: agentPresence.instanceId,
|
|
1062
|
+
connectedAt: agentPresence.connectedAt,
|
|
1063
|
+
lastSeenAt: agentPresence.lastSeenAt,
|
|
1064
|
+
clientId: agentPresence.clientId,
|
|
1065
|
+
runtimeType: agentPresence.runtimeType,
|
|
1066
|
+
runtimeVersion: agentPresence.runtimeVersion,
|
|
1067
|
+
runtimeState: agentPresence.runtimeState,
|
|
1068
|
+
activeSessions: agentPresence.activeSessions,
|
|
1069
|
+
totalSessions: agentPresence.totalSessions,
|
|
1070
|
+
runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
|
|
1071
|
+
type: agents.type
|
|
1072
|
+
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope.organizationId, scope.memberId)));
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Chat-first workspace — append-only post-fan-out projection.
|
|
1076
|
+
*
|
|
1077
|
+
* The single sanctioned extension point on the message hot path. Called
|
|
1078
|
+
* from `services/message.ts` AFTER existing fan-out completes, inside the
|
|
1079
|
+
* same transaction. Three responsibilities:
|
|
1080
|
+
*
|
|
1081
|
+
* 1. Mention propagation: increment `unread_mention_count` for mentioned
|
|
1082
|
+
* speaking participants AND for watcher rows whose managed agent was
|
|
1083
|
+
* mentioned. Sender row is excluded.
|
|
1084
|
+
*
|
|
1085
|
+
* 2. Chats projection: roll forward `chats.last_message_at`,
|
|
1086
|
+
* `chats.last_message_preview`. Powers the conversation list cursor +
|
|
1087
|
+
* sort + preview.
|
|
1088
|
+
*
|
|
1089
|
+
* 3. Realtime kick: fire-and-forget `pg_notify('chat_message_events', …)`
|
|
1090
|
+
* so admin WS sockets can translate it into a `chat:message` frame.
|
|
1091
|
+
* Failure is swallowed — durable persistence is the correctness path.
|
|
1092
|
+
*
|
|
1093
|
+
* Strict invariants (see docs/chat-first-workspace-product-design.md
|
|
1094
|
+
* "Risk Constraints"):
|
|
1095
|
+
* - This module appends ONLY. Never edits existing fan-out / inbox /
|
|
1096
|
+
* mention-extraction code.
|
|
1097
|
+
* - Watchers (chat_subscriptions) are NEVER added to inbox_entries here.
|
|
1098
|
+
* Their counters are bumped purely as a per-user red-dot signal.
|
|
1099
|
+
* - Mention candidate set is `chat_participants` only; watchers are not
|
|
1100
|
+
* direct `@`-mention targets.
|
|
1101
|
+
*/
|
|
1102
|
+
let dispatcher = null;
|
|
1103
|
+
function registerChatMessageDispatcher(fn) {
|
|
1104
|
+
dispatcher = fn;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Best-effort cross-process kick for the chat-first workspace. Call AFTER
|
|
1108
|
+
* the message transaction commits — never inside the tx. Failure logs +
|
|
1109
|
+
* drops; web reconnect refetches.
|
|
1110
|
+
*
|
|
1111
|
+
* Speakers also get an inbox NOTIFY through the existing path. They will
|
|
1112
|
+
* receive both, and the web client de-dupes naturally because both end up
|
|
1113
|
+
* invalidating the same query keys.
|
|
1114
|
+
*/
|
|
1115
|
+
function fireChatMessageKick(chatId, messageId) {
|
|
1116
|
+
if (!dispatcher) return;
|
|
1117
|
+
try {
|
|
1118
|
+
dispatcher(chatId, messageId);
|
|
1119
|
+
} catch {}
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Apply the post-fan-out projection. MUST be called inside the same
|
|
1123
|
+
* transaction as the message INSERT. Safe to call when `mentionedAgentIds`
|
|
1124
|
+
* is empty (degenerate case skips the mention UPDATEs).
|
|
1125
|
+
*/
|
|
1126
|
+
async function applyAfterFanOut(tx, input) {
|
|
1127
|
+
const { chatId, senderId, mentionedAgentIds, contentPreview, messageCreatedAt } = input;
|
|
1128
|
+
const previewClipped = contentPreview.length > 0 ? contentPreview.slice(0, 200) : null;
|
|
1129
|
+
const ts = messageCreatedAt ?? /* @__PURE__ */ new Date();
|
|
1130
|
+
await tx.update(chats).set({
|
|
1131
|
+
lastMessageAt: ts,
|
|
1132
|
+
lastMessagePreview: previewClipped
|
|
1133
|
+
}).where(eq(chats.id, chatId));
|
|
1134
|
+
if (mentionedAgentIds.length === 0) return;
|
|
1135
|
+
await tx.update(chatParticipants).set({ unreadMentionCount: sql`${chatParticipants.unreadMentionCount} + 1` }).where(and(eq(chatParticipants.chatId, chatId), inArray(chatParticipants.agentId, mentionedAgentIds), ne(chatParticipants.agentId, senderId)));
|
|
1136
|
+
const managerHumanAgentIds = (await tx.execute(sql`
|
|
1137
|
+
SELECT DISTINCT m.agent_id AS human_agent_id
|
|
1138
|
+
FROM agents a
|
|
1139
|
+
JOIN members m ON m.id = a.manager_id
|
|
1140
|
+
WHERE a.uuid IN ${makeUuidList(mentionedAgentIds)}
|
|
1141
|
+
AND a.type <> 'human'
|
|
1142
|
+
AND m.status = 'active'
|
|
1143
|
+
`)).map((r) => r.human_agent_id);
|
|
1144
|
+
if (managerHumanAgentIds.length === 0) return;
|
|
1145
|
+
await tx.update(chatSubscriptions).set({ unreadMentionCount: sql`${chatSubscriptions.unreadMentionCount} + 1` }).where(and(eq(chatSubscriptions.chatId, chatId), inArray(chatSubscriptions.agentId, managerHumanAgentIds)));
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Build a parenthesised, comma-separated list of bound parameters: `(?, ?, ?)`.
|
|
1149
|
+
* Used in raw SQL where drizzle's `inArray` can't be directly applied (e.g.
|
|
1150
|
+
* inside a hand-rolled SELECT). Always called with a non-empty list — the
|
|
1151
|
+
* caller short-circuits the empty case.
|
|
1152
|
+
*/
|
|
1153
|
+
function makeUuidList(ids) {
|
|
1154
|
+
return sql`(${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`;
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Server-side lifecycle tracker for `format=question` messages.
|
|
1158
|
+
*
|
|
1159
|
+
* Written when an agent emits a question through `sendMessage`; status
|
|
1160
|
+
* flips to `answered` when the user posts an answer, or to `superseded`
|
|
1161
|
+
* when the chat session is archived or its client is claimed away.
|
|
1162
|
+
*
|
|
1163
|
+
* Per the team's "integrity in service layer" convention, NO foreign-key
|
|
1164
|
+
* constraints — referential integrity is enforced by the question
|
|
1165
|
+
* service itself (chat-id / agent-id / message-id are validated at
|
|
1166
|
+
* write time and the lifecycle hooks supersede orphaned rows).
|
|
1167
|
+
*/
|
|
1168
|
+
const pendingQuestions = pgTable("pending_questions", {
|
|
1169
|
+
id: text("id").primaryKey(),
|
|
1170
|
+
agentId: text("agent_id").notNull(),
|
|
1171
|
+
chatId: text("chat_id").notNull(),
|
|
1172
|
+
messageId: text("message_id").notNull(),
|
|
1173
|
+
status: text("status").notNull().default("pending"),
|
|
1174
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
1175
|
+
answeredAt: timestamp("answered_at", { withTimezone: true }),
|
|
1176
|
+
supersededAt: timestamp("superseded_at", { withTimezone: true }),
|
|
1177
|
+
supersededReason: text("superseded_reason")
|
|
1178
|
+
}, (table) => [index("idx_pending_questions_agent_status").on(table.agentId, table.status), index("idx_pending_questions_chat_status").on(table.chatId, table.status)]);
|
|
1179
|
+
const INBOX_CHANNEL = "inbox_notifications";
|
|
1180
|
+
const CONFIG_CHANNEL = "config_changes";
|
|
1181
|
+
const SESSION_STATE_CHANNEL = "session_state_changes";
|
|
1182
|
+
const RUNTIME_STATE_CHANNEL = "runtime_state_changes";
|
|
1183
|
+
/**
|
|
1184
|
+
* Chat-first workspace cross-process kick. Carries `<chatId>:<messageId>`.
|
|
1185
|
+
* Lets admin WS sockets translate every chat message (speaker AND watcher
|
|
1186
|
+
* audience) into a `chat:message` frame, without being coupled to the
|
|
1187
|
+
* inbox NOTIFY path that only reaches speakers.
|
|
1188
|
+
*/
|
|
1189
|
+
const CHAT_MESSAGE_CHANNEL = "chat_message_events";
|
|
1190
|
+
function createNotifier(listenClient) {
|
|
1191
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
1192
|
+
const configChangeHandlers = [];
|
|
1193
|
+
const sessionStateChangeHandlers = [];
|
|
1194
|
+
const runtimeStateChangeHandlers = [];
|
|
1195
|
+
const chatMessageHandlers = [];
|
|
1196
|
+
let unlistenInboxFn = null;
|
|
1197
|
+
let unlistenConfigFn = null;
|
|
1198
|
+
let unlistenSessionStateFn = null;
|
|
1199
|
+
let unlistenRuntimeStateFn = null;
|
|
1200
|
+
let unlistenChatMessageFn = null;
|
|
1201
|
+
function handleNotification(payload) {
|
|
1202
|
+
const sepIdx = payload.indexOf(":");
|
|
1203
|
+
if (sepIdx === -1) return;
|
|
1204
|
+
const inboxId = payload.slice(0, sepIdx);
|
|
1205
|
+
const messageId = payload.slice(sepIdx + 1);
|
|
1206
|
+
const sockets = subscriptions.get(inboxId);
|
|
1207
|
+
if (!sockets) return;
|
|
1208
|
+
const doorbellFrame = JSON.stringify({
|
|
1209
|
+
type: "new_message",
|
|
1210
|
+
inboxId,
|
|
1211
|
+
messageId
|
|
1212
|
+
});
|
|
1213
|
+
for (const [ws, pushHandler] of sockets) {
|
|
1214
|
+
if (ws.readyState !== ws.OPEN) continue;
|
|
1215
|
+
if (pushHandler) Promise.resolve(pushHandler(messageId)).catch(() => {});
|
|
1216
|
+
else ws.send(doorbellFrame);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return {
|
|
1220
|
+
subscribe(inboxId, ws, pushHandler) {
|
|
1221
|
+
let map = subscriptions.get(inboxId);
|
|
1222
|
+
if (!map) {
|
|
1223
|
+
map = /* @__PURE__ */ new Map();
|
|
1224
|
+
subscriptions.set(inboxId, map);
|
|
1225
|
+
}
|
|
1226
|
+
map.set(ws, pushHandler ?? null);
|
|
1227
|
+
},
|
|
1228
|
+
unsubscribe(inboxId, ws) {
|
|
1229
|
+
const map = subscriptions.get(inboxId);
|
|
1230
|
+
if (map) {
|
|
1231
|
+
map.delete(ws);
|
|
1232
|
+
if (map.size === 0) subscriptions.delete(inboxId);
|
|
1233
|
+
}
|
|
1234
|
+
},
|
|
1235
|
+
async notify(inboxId, messageId) {
|
|
1236
|
+
try {
|
|
1237
|
+
await listenClient`SELECT pg_notify(${INBOX_CHANNEL}, ${`${inboxId}:${messageId}`})`;
|
|
1238
|
+
} catch {}
|
|
1239
|
+
},
|
|
1240
|
+
async notifyConfigChange(configType) {
|
|
1241
|
+
try {
|
|
1242
|
+
await listenClient`SELECT pg_notify(${CONFIG_CHANNEL}, ${configType})`;
|
|
1243
|
+
} catch {}
|
|
1244
|
+
},
|
|
1245
|
+
async notifySessionStateChange(agentId, chatId, state, organizationId) {
|
|
1246
|
+
try {
|
|
1247
|
+
await listenClient`SELECT pg_notify(${SESSION_STATE_CHANNEL}, ${`${agentId}:${chatId}:${state}:${organizationId}`})`;
|
|
1248
|
+
} catch {}
|
|
1249
|
+
},
|
|
1250
|
+
async notifyRuntimeStateChange(agentId, state, organizationId) {
|
|
1251
|
+
try {
|
|
1252
|
+
await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
|
|
1253
|
+
} catch {}
|
|
1254
|
+
},
|
|
1255
|
+
async notifyChatMessage(chatId, messageId) {
|
|
1256
|
+
try {
|
|
1257
|
+
await listenClient`SELECT pg_notify(${CHAT_MESSAGE_CHANNEL}, ${`${chatId}:${messageId}`})`;
|
|
1258
|
+
} catch {}
|
|
1259
|
+
},
|
|
1260
|
+
async pushFrameToInbox(inboxId, frame) {
|
|
1261
|
+
const map = subscriptions.get(inboxId);
|
|
1262
|
+
if (!map) return 0;
|
|
1263
|
+
let queued = 0;
|
|
1264
|
+
const pending = [];
|
|
1265
|
+
for (const ws of map.keys()) {
|
|
1266
|
+
if (ws.readyState !== ws.OPEN) continue;
|
|
1267
|
+
pending.push(new Promise((resolve) => {
|
|
1268
|
+
ws.send(frame, (err) => {
|
|
1269
|
+
if (!err) queued += 1;
|
|
1270
|
+
resolve();
|
|
1271
|
+
});
|
|
1272
|
+
}));
|
|
1273
|
+
}
|
|
1274
|
+
await Promise.all(pending);
|
|
1275
|
+
return queued;
|
|
1276
|
+
},
|
|
1277
|
+
onConfigChange(handler) {
|
|
1278
|
+
configChangeHandlers.push(handler);
|
|
1279
|
+
},
|
|
1280
|
+
onSessionStateChange(handler) {
|
|
1281
|
+
sessionStateChangeHandlers.push(handler);
|
|
1282
|
+
},
|
|
1283
|
+
onRuntimeStateChange(handler) {
|
|
1284
|
+
runtimeStateChangeHandlers.push(handler);
|
|
1285
|
+
},
|
|
1286
|
+
onChatMessage(handler) {
|
|
1287
|
+
chatMessageHandlers.push(handler);
|
|
1288
|
+
},
|
|
1289
|
+
async start() {
|
|
1290
|
+
unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
|
|
1291
|
+
if (payload) handleNotification(payload);
|
|
1292
|
+
})).unlisten;
|
|
1293
|
+
unlistenConfigFn = (await listenClient.listen(CONFIG_CHANNEL, (payload) => {
|
|
1294
|
+
if (payload) for (const handler of configChangeHandlers) handler(payload);
|
|
1295
|
+
})).unlisten;
|
|
1296
|
+
unlistenSessionStateFn = (await listenClient.listen(SESSION_STATE_CHANNEL, (payload) => {
|
|
1297
|
+
if (payload) {
|
|
1298
|
+
const firstSep = payload.indexOf(":");
|
|
1299
|
+
const secondSep = payload.indexOf(":", firstSep + 1);
|
|
1300
|
+
const thirdSep = payload.indexOf(":", secondSep + 1);
|
|
1301
|
+
if (firstSep > 0 && secondSep > firstSep && thirdSep > secondSep) {
|
|
1302
|
+
const agentId = payload.slice(0, firstSep);
|
|
1303
|
+
const chatId = payload.slice(firstSep + 1, secondSep);
|
|
1304
|
+
const state = payload.slice(secondSep + 1, thirdSep);
|
|
1305
|
+
const organizationId = payload.slice(thirdSep + 1);
|
|
1306
|
+
for (const handler of sessionStateChangeHandlers) handler({
|
|
1307
|
+
agentId,
|
|
1308
|
+
chatId,
|
|
1309
|
+
state,
|
|
1310
|
+
organizationId
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
})).unlisten;
|
|
1315
|
+
unlistenRuntimeStateFn = (await listenClient.listen(RUNTIME_STATE_CHANNEL, (payload) => {
|
|
1316
|
+
if (payload) {
|
|
1317
|
+
const firstSep = payload.indexOf(":");
|
|
1318
|
+
const secondSep = payload.indexOf(":", firstSep + 1);
|
|
1319
|
+
if (firstSep > 0 && secondSep > firstSep) {
|
|
1320
|
+
const agentId = payload.slice(0, firstSep);
|
|
1321
|
+
const state = payload.slice(firstSep + 1, secondSep);
|
|
1322
|
+
const organizationId = payload.slice(secondSep + 1);
|
|
1323
|
+
for (const handler of runtimeStateChangeHandlers) handler({
|
|
1324
|
+
agentId,
|
|
1325
|
+
state,
|
|
1326
|
+
organizationId
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
})).unlisten;
|
|
1331
|
+
unlistenChatMessageFn = (await listenClient.listen(CHAT_MESSAGE_CHANNEL, (payload) => {
|
|
1332
|
+
if (!payload) return;
|
|
1333
|
+
const sep = payload.indexOf(":");
|
|
1334
|
+
if (sep <= 0) return;
|
|
1335
|
+
const chatId = payload.slice(0, sep);
|
|
1336
|
+
const messageId = payload.slice(sep + 1);
|
|
1337
|
+
for (const handler of chatMessageHandlers) try {
|
|
1338
|
+
handler({
|
|
1339
|
+
chatId,
|
|
1340
|
+
messageId
|
|
1341
|
+
});
|
|
1342
|
+
} catch {}
|
|
1343
|
+
})).unlisten;
|
|
1344
|
+
},
|
|
1345
|
+
async stop() {
|
|
1346
|
+
if (unlistenInboxFn) {
|
|
1347
|
+
await unlistenInboxFn();
|
|
1348
|
+
unlistenInboxFn = null;
|
|
1349
|
+
}
|
|
1350
|
+
if (unlistenConfigFn) {
|
|
1351
|
+
await unlistenConfigFn();
|
|
1352
|
+
unlistenConfigFn = null;
|
|
1353
|
+
}
|
|
1354
|
+
if (unlistenSessionStateFn) {
|
|
1355
|
+
await unlistenSessionStateFn();
|
|
1356
|
+
unlistenSessionStateFn = null;
|
|
1357
|
+
}
|
|
1358
|
+
if (unlistenRuntimeStateFn) {
|
|
1359
|
+
await unlistenRuntimeStateFn();
|
|
1360
|
+
unlistenRuntimeStateFn = null;
|
|
1361
|
+
}
|
|
1362
|
+
if (unlistenChatMessageFn) {
|
|
1363
|
+
await unlistenChatMessageFn();
|
|
1364
|
+
unlistenChatMessageFn = null;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
/** Fire-and-forget: notify all recipients that a new message is available. */
|
|
1370
|
+
function notifyRecipients(notifier, recipients, messageId) {
|
|
1371
|
+
for (const inboxId of recipients) notifier.notify(inboxId, messageId).catch(() => {});
|
|
1372
|
+
}
|
|
1373
|
+
const log$1 = createLogger("questions");
|
|
1374
|
+
/**
|
|
1375
|
+
* Insert a `pending_questions` row inside the same transaction that wrote a
|
|
1376
|
+
* `format=question` message. Caller is `sendMessage` after the message INSERT
|
|
1377
|
+
* returns, so a rollback drops both rows together. No-op (returns silently)
|
|
1378
|
+
* if the message content is not a valid `QuestionMessageContent` — the caller
|
|
1379
|
+
* will already have rejected such input upstream, but we defend in depth so a
|
|
1380
|
+
* malformed write never leaves a dangling pending row.
|
|
1381
|
+
*/
|
|
1382
|
+
async function recordPendingQuestionFromMessage(tx, args) {
|
|
1383
|
+
const parsed = questionMessageContentSchema.safeParse(args.content);
|
|
1384
|
+
if (!parsed.success) throw new BadRequestError("Invalid question message content", { "question.parse_error": parsed.error.message.slice(0, 200) });
|
|
1385
|
+
const { correlationId } = parsed.data;
|
|
1386
|
+
await tx.insert(pendingQuestions).values({
|
|
1387
|
+
id: correlationId,
|
|
1388
|
+
agentId: args.agentId,
|
|
1389
|
+
chatId: args.chatId,
|
|
1390
|
+
messageId: args.messageId,
|
|
1391
|
+
status: "pending"
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Defensive write-side check: codex-runtime agents must never emit
|
|
1396
|
+
* `format=question` (Codex SDK 0.125 has no ask-user surface, so any such
|
|
1397
|
+
* message would be a runtime regression). Looks up the sender's
|
|
1398
|
+
* `runtime_provider` and rejects if it is `codex`. Throws `ForbiddenError`
|
|
1399
|
+
* (HTTP 403) so the bug surfaces loudly to the offending writer.
|
|
1400
|
+
*
|
|
1401
|
+
* Returns the runtime provider for telemetry / further checks (e.g. the
|
|
1402
|
+
* caller can attach it to the active span).
|
|
1403
|
+
*/
|
|
1404
|
+
async function assertSenderMayEmitQuestion(tx, senderAgentId) {
|
|
1405
|
+
const [row] = await tx.select({ runtimeProvider: agents.runtimeProvider }).from(agents).where(eq(agents.uuid, senderAgentId)).limit(1);
|
|
1406
|
+
if (!row) throw new NotFoundError(`Sender agent "${senderAgentId}" not found`);
|
|
1407
|
+
if (row.runtimeProvider === "codex") {
|
|
1408
|
+
log$1.error({
|
|
1409
|
+
agentId: senderAgentId,
|
|
1410
|
+
runtimeProvider: row.runtimeProvider
|
|
1411
|
+
}, "rejected format=question emit from codex-runtime agent");
|
|
1412
|
+
throw new ForbiddenError("Codex runtime cannot emit ask-user questions", {
|
|
1413
|
+
"question.codex_emit_attempt": true,
|
|
1414
|
+
"agent.id": senderAgentId
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
return row.runtimeProvider;
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* User-side answer submission. Atomically:
|
|
1421
|
+
* 1. Lock the `pending_questions` row by correlationId.
|
|
1422
|
+
* 2. Refuse if status !== "pending" (409 if already-answered, 410-shaped
|
|
1423
|
+
* 400 if superseded — both surface as ConflictError so the caller knows
|
|
1424
|
+
* the question is no longer answerable).
|
|
1425
|
+
* 3. Validate that the answer keys match the original `questions[]`.
|
|
1426
|
+
* 4. Flip status to `answered` INSIDE the lock-tx, before releasing the
|
|
1427
|
+
* row lock. This is the linearisation point: the second concurrent
|
|
1428
|
+
* submitter (waiting on the same row lock) will, on its turn, see
|
|
1429
|
+
* status=answered and exit with ConflictError BEFORE it can write a
|
|
1430
|
+
* second `format=question_answer` message.
|
|
1431
|
+
* 5. Send the `format=question_answer` message OUTSIDE the lock-tx
|
|
1432
|
+
* (sendMessage opens its own transaction; nesting wasn't supported by
|
|
1433
|
+
* the existing call site). At this point we hold an exclusive logical
|
|
1434
|
+
* claim — only one submitter ever reaches this step per correlationId.
|
|
1435
|
+
*
|
|
1436
|
+
* `submitterAgentId` is the human agent on whose behalf the answer is
|
|
1437
|
+
* written (it must be a participant of the question's chat). Returns the
|
|
1438
|
+
* created `question_answer` message id so the route can include it in the
|
|
1439
|
+
* 201 response.
|
|
1440
|
+
*
|
|
1441
|
+
* Failure semantics: if step 5 (sendMessage) fails after status was flipped,
|
|
1442
|
+
* we revert the row to `pending` so the user can retry. This is best-effort —
|
|
1443
|
+
* the revert UPDATE is guarded by `status='answered'` to avoid clobbering a
|
|
1444
|
+
* supersede that might race in. If the revert itself fails, the row is
|
|
1445
|
+
* stranded as `answered` with no answer message; an operator would need to
|
|
1446
|
+
* intervene, but a sendMessage failure (local DB tx) is already
|
|
1447
|
+
* extraordinarily rare.
|
|
1448
|
+
*/
|
|
1449
|
+
async function submitAnswer(db, notifier, args) {
|
|
1450
|
+
const questionRow = await db.transaction(async (tx) => {
|
|
1451
|
+
const [row] = await tx.select({
|
|
1452
|
+
id: pendingQuestions.id,
|
|
1453
|
+
status: pendingQuestions.status,
|
|
1454
|
+
chatId: pendingQuestions.chatId,
|
|
1455
|
+
agentId: pendingQuestions.agentId,
|
|
1456
|
+
messageId: pendingQuestions.messageId
|
|
1457
|
+
}).from(pendingQuestions).where(eq(pendingQuestions.id, args.correlationId)).for("update").limit(1);
|
|
1458
|
+
if (!row) throw new NotFoundError(`Question "${args.correlationId}" not found`);
|
|
1459
|
+
if (row.chatId !== args.chatId) throw new NotFoundError(`Question "${args.correlationId}" not found in this chat`);
|
|
1460
|
+
if (row.status !== "pending") throw new ConflictError(`Question "${args.correlationId}" is no longer pending`, { "question.status": row.status });
|
|
1461
|
+
const [msg] = await tx.select({ content: messages.content }).from(messages).where(eq(messages.id, row.messageId)).limit(1);
|
|
1462
|
+
if (!msg) throw new NotFoundError(`Question message "${row.messageId}" not found`);
|
|
1463
|
+
const parsedQuestion = questionMessageContentSchema.safeParse(msg.content);
|
|
1464
|
+
if (!parsedQuestion.success) throw new BadRequestError("Stored question content is malformed", { "question.parse_error": parsedQuestion.error.message.slice(0, 200) });
|
|
1465
|
+
const expectedKeys = new Set(parsedQuestion.data.questions.map((q) => q.question));
|
|
1466
|
+
for (const key of Object.keys(args.answers)) if (!expectedKeys.has(key)) throw new BadRequestError(`Answer key "${key}" does not match any question`, { "question.id": args.correlationId });
|
|
1467
|
+
for (const key of expectedKeys) if (!(key in args.answers)) throw new BadRequestError(`Answer missing for question "${key}"`, { "question.id": args.correlationId });
|
|
1468
|
+
await tx.update(pendingQuestions).set({
|
|
1469
|
+
status: "answered",
|
|
1470
|
+
answeredAt: /* @__PURE__ */ new Date()
|
|
1471
|
+
}).where(eq(pendingQuestions.id, args.correlationId));
|
|
1472
|
+
return row;
|
|
1473
|
+
});
|
|
1474
|
+
const answerContent = {
|
|
1475
|
+
correlationId: args.correlationId,
|
|
1476
|
+
answers: args.answers
|
|
1477
|
+
};
|
|
1478
|
+
questionAnswerMessageContentSchema.parse(answerContent);
|
|
1479
|
+
let result;
|
|
1480
|
+
try {
|
|
1481
|
+
result = await sendMessage(db, args.chatId, args.submitterAgentId, {
|
|
1482
|
+
format: "question_answer",
|
|
1483
|
+
content: answerContent,
|
|
1484
|
+
inReplyTo: questionRow.messageId,
|
|
1485
|
+
source: "hub_ui"
|
|
1486
|
+
});
|
|
1487
|
+
} catch (err) {
|
|
1488
|
+
log$1.error({
|
|
1489
|
+
correlationId: args.correlationId,
|
|
1490
|
+
chatId: args.chatId,
|
|
1491
|
+
err: err instanceof Error ? err.message : String(err)
|
|
1492
|
+
}, "sendMessage failed after status flip; reverting pending_questions row to 'pending'");
|
|
1493
|
+
try {
|
|
1494
|
+
await db.update(pendingQuestions).set({
|
|
1495
|
+
status: "pending",
|
|
1496
|
+
answeredAt: null
|
|
1497
|
+
}).where(and(eq(pendingQuestions.id, args.correlationId), eq(pendingQuestions.status, "answered")));
|
|
1498
|
+
} catch (revertErr) {
|
|
1499
|
+
log$1.error({
|
|
1500
|
+
correlationId: args.correlationId,
|
|
1501
|
+
chatId: args.chatId,
|
|
1502
|
+
revertErr: revertErr instanceof Error ? revertErr.message : String(revertErr)
|
|
1503
|
+
}, "revert UPDATE also failed; row may be stranded as 'answered' without an answer message");
|
|
1504
|
+
}
|
|
1505
|
+
throw err;
|
|
1506
|
+
}
|
|
1507
|
+
if (notifier) notifyRecipients(notifier, result.recipients, result.message.id);
|
|
1508
|
+
return {
|
|
1509
|
+
messageId: result.message.id,
|
|
1510
|
+
recipients: result.recipients
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Mark every pending row whose chat is `chatId` as superseded. Used when a
|
|
1515
|
+
* chat session is archived — the agent runtime that emitted the question
|
|
1516
|
+
* may already be gone, so leaving the row pending would block forever.
|
|
1517
|
+
*/
|
|
1518
|
+
async function markSupersededByChat(tx, chatId, reason = "chat_archived") {
|
|
1519
|
+
return (await tx.update(pendingQuestions).set({
|
|
1520
|
+
status: "superseded",
|
|
1521
|
+
supersededAt: /* @__PURE__ */ new Date(),
|
|
1522
|
+
supersededReason: reason
|
|
1523
|
+
}).where(and(eq(pendingQuestions.chatId, chatId), eq(pendingQuestions.status, "pending"))).returning({ id: pendingQuestions.id })).length;
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Mark every pending row owned by any of `agentIds` as superseded. Used when
|
|
1527
|
+
* the client carrying these agents is claimed by a new user — the previous
|
|
1528
|
+
* owner's runtime is detached and cannot deliver an answer back.
|
|
1529
|
+
*/
|
|
1530
|
+
async function markSupersededByAgents(tx, agentIds, reason = "client_claimed") {
|
|
1531
|
+
if (agentIds.length === 0) return 0;
|
|
1532
|
+
return (await tx.update(pendingQuestions).set({
|
|
1533
|
+
status: "superseded",
|
|
1534
|
+
supersededAt: /* @__PURE__ */ new Date(),
|
|
1535
|
+
supersededReason: reason
|
|
1536
|
+
}).where(and(inArray(pendingQuestions.agentId, agentIds), eq(pendingQuestions.status, "pending"))).returning({ id: pendingQuestions.id })).length;
|
|
1537
|
+
}
|
|
1538
|
+
const log = createLogger("message");
|
|
1539
|
+
async function sendMessage(db, chatId, senderId, data, options = {}) {
|
|
1540
|
+
return withSpan("inbox.enqueue", messageAttrs({
|
|
1541
|
+
chatId,
|
|
1542
|
+
senderAgentId: senderId,
|
|
1543
|
+
source: data.source ?? void 0
|
|
1544
|
+
}), () => sendMessageInner(db, chatId, senderId, data, options));
|
|
1545
|
+
}
|
|
1546
|
+
async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
1547
|
+
const txResult = await db.transaction(async (tx) => {
|
|
1548
|
+
const [participants, [chatRow], [senderRow]] = await Promise.all([
|
|
1549
|
+
tx.select({
|
|
1550
|
+
agentId: chatParticipants.agentId,
|
|
1551
|
+
inboxId: agents.inboxId,
|
|
1552
|
+
mode: chatParticipants.mode,
|
|
1553
|
+
name: agents.name
|
|
1554
|
+
}).from(chatParticipants).innerJoin(agents, eq(chatParticipants.agentId, agents.uuid)).where(eq(chatParticipants.chatId, chatId)),
|
|
1555
|
+
tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
|
|
1556
|
+
tx.select({
|
|
1557
|
+
inboxId: agents.inboxId,
|
|
1558
|
+
organizationId: agents.organizationId
|
|
1559
|
+
}).from(agents).where(eq(agents.uuid, senderId)).limit(1)
|
|
1560
|
+
]);
|
|
1561
|
+
const chatType = chatRow?.type ?? null;
|
|
1562
|
+
if (!senderRow) throw new NotFoundError(`Sender agent "${senderId}" not found`);
|
|
1563
|
+
if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
|
|
1564
|
+
if (senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
|
|
1565
|
+
}
|
|
1566
|
+
const incomingMeta = data.metadata ?? {};
|
|
1567
|
+
const explicitMentionsRaw = incomingMeta.mentions;
|
|
1568
|
+
const explicitMentions = Array.isArray(explicitMentionsRaw) ? explicitMentionsRaw.filter((m) => typeof m === "string") : [];
|
|
1569
|
+
const contentText = typeof data.content === "string" ? data.content : "";
|
|
1570
|
+
const resolved = contentText ? extractMentions(contentText, participants) : [];
|
|
1571
|
+
const mergedMentions = [...new Set([...explicitMentions, ...resolved])];
|
|
1572
|
+
const metadataToStore = mergedMentions.length > 0 ? {
|
|
1573
|
+
...incomingMeta,
|
|
1574
|
+
mentions: mergedMentions
|
|
1575
|
+
} : incomingMeta;
|
|
1576
|
+
if (options.enforceGroupMention && chatType === "group") {
|
|
1577
|
+
if (mergedMentions.filter((id) => id !== senderId).length === 0) throw new BadRequestError("Sending to a group chat requires an explicit @mention. Use `agent send <name>` to message a single agent, or @<name> in the content to address one or more group members.");
|
|
1578
|
+
}
|
|
1579
|
+
let outboundContent = data.content;
|
|
1580
|
+
if (options.normalizeMentionsInContent && typeof outboundContent === "string") {
|
|
1581
|
+
const present = new Set(scanMentionTokens(outboundContent));
|
|
1582
|
+
const missingNames = [];
|
|
1583
|
+
for (const id of mergedMentions) {
|
|
1584
|
+
if (id === senderId) continue;
|
|
1585
|
+
const p = participants.find((q) => q.agentId === id);
|
|
1586
|
+
if (!p?.name) continue;
|
|
1587
|
+
if (present.has(p.name.toLowerCase())) continue;
|
|
1588
|
+
missingNames.push(p.name);
|
|
1589
|
+
}
|
|
1590
|
+
if (missingNames.length > 0) {
|
|
1591
|
+
const prefix = missingNames.map((n) => `@${n}`).join(" ");
|
|
1592
|
+
outboundContent = outboundContent.length > 0 ? `${prefix} ${outboundContent}` : prefix;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (data.format === "question") await assertSenderMayEmitQuestion(tx, senderId);
|
|
1596
|
+
const messageId = randomUUID();
|
|
1597
|
+
const [msg] = await tx.insert(messages).values({
|
|
1598
|
+
id: messageId,
|
|
1599
|
+
chatId,
|
|
1600
|
+
senderId,
|
|
1601
|
+
format: data.format,
|
|
1602
|
+
content: outboundContent,
|
|
1603
|
+
metadata: metadataToStore,
|
|
1604
|
+
replyToInbox: data.replyToInbox ?? null,
|
|
1605
|
+
replyToChat: data.replyToChat ?? null,
|
|
1606
|
+
inReplyTo: data.inReplyTo ?? null,
|
|
1607
|
+
source: data.source ?? null
|
|
1608
|
+
}).returning();
|
|
1609
|
+
if (data.format === "question" && msg) await recordPendingQuestionFromMessage(tx, {
|
|
1610
|
+
agentId: senderId,
|
|
1611
|
+
chatId,
|
|
1612
|
+
messageId: msg.id,
|
|
1613
|
+
content: outboundContent
|
|
1614
|
+
});
|
|
1615
|
+
const mentionSet = new Set(mergedMentions);
|
|
1616
|
+
const fanout = participants.filter((p) => p.agentId !== senderId).map((p) => ({
|
|
1617
|
+
agentId: p.agentId,
|
|
1618
|
+
inboxId: p.inboxId,
|
|
1619
|
+
notify: p.mode !== "mention_only" || mentionSet.has(p.agentId)
|
|
1620
|
+
}));
|
|
1621
|
+
if (fanout.length > 0) await tx.insert(inboxEntries).values(fanout.map((f) => ({
|
|
1622
|
+
inboxId: f.inboxId,
|
|
1623
|
+
messageId,
|
|
1624
|
+
chatId,
|
|
1625
|
+
notify: f.notify
|
|
1626
|
+
})));
|
|
1627
|
+
const notified = fanout.filter((f) => f.notify);
|
|
1628
|
+
const recipients = notified.map((f) => f.inboxId);
|
|
1629
|
+
const recipientAgentIds = notified.map((f) => f.agentId);
|
|
1630
|
+
if (data.inReplyTo) {
|
|
1631
|
+
const [original] = await tx.select({
|
|
1632
|
+
replyToInbox: messages.replyToInbox,
|
|
1633
|
+
replyToChat: messages.replyToChat
|
|
1634
|
+
}).from(messages).where(eq(messages.id, data.inReplyTo)).limit(1);
|
|
1635
|
+
if (original?.replyToInbox && original?.replyToChat) {
|
|
1636
|
+
await tx.insert(inboxEntries).values({
|
|
1637
|
+
inboxId: original.replyToInbox,
|
|
1638
|
+
messageId,
|
|
1639
|
+
chatId: original.replyToChat
|
|
1640
|
+
}).onConflictDoNothing();
|
|
1641
|
+
if (!recipients.includes(original.replyToInbox)) recipients.push(original.replyToInbox);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
|
|
1645
|
+
if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
1646
|
+
const previewText = typeof outboundContent === "string" ? outboundContent.trim() : "";
|
|
1647
|
+
await applyAfterFanOut(tx, {
|
|
1648
|
+
chatId,
|
|
1649
|
+
messageId: msg.id,
|
|
1650
|
+
senderId,
|
|
1651
|
+
mentionedAgentIds: mergedMentions,
|
|
1652
|
+
contentPreview: previewText,
|
|
1653
|
+
messageCreatedAt: msg.createdAt
|
|
1654
|
+
});
|
|
1655
|
+
return {
|
|
1656
|
+
message: msg,
|
|
1657
|
+
recipients,
|
|
1658
|
+
recipientAgentIds,
|
|
1659
|
+
organizationId: senderRow.organizationId
|
|
1660
|
+
};
|
|
1661
|
+
});
|
|
1662
|
+
const settled = await Promise.allSettled(txResult.recipientAgentIds.map((agentId) => upsertSessionState(db, agentId, chatId, "active", txResult.organizationId, void 0, { touchPresenceLastSeen: false })));
|
|
1663
|
+
for (let i = 0; i < settled.length; i++) {
|
|
1664
|
+
const r = settled[i];
|
|
1665
|
+
if (r?.status === "rejected") log.error({
|
|
1666
|
+
err: r.reason,
|
|
1667
|
+
chatId,
|
|
1668
|
+
agentId: txResult.recipientAgentIds[i]
|
|
1669
|
+
}, "predictive session activation failed");
|
|
1670
|
+
}
|
|
1671
|
+
fireChatMessageKick(chatId, txResult.message.id);
|
|
1672
|
+
return {
|
|
1673
|
+
message: txResult.message,
|
|
1674
|
+
recipients: txResult.recipients
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
async function sendToAgent(db, senderUuid, targetName, data) {
|
|
1678
|
+
const [sender] = await db.select({
|
|
1679
|
+
uuid: agents.uuid,
|
|
1680
|
+
organizationId: agents.organizationId
|
|
1681
|
+
}).from(agents).where(eq(agents.uuid, senderUuid)).limit(1);
|
|
1682
|
+
if (!sender) throw new NotFoundError(`Agent "${senderUuid}" not found`);
|
|
1683
|
+
const [target] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, sender.organizationId), eq(agents.name, targetName), ne(agents.status, "deleted"))).limit(1);
|
|
1684
|
+
if (!target) throw new NotFoundError(`Agent "${targetName}" not found${/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(targetName) ? " — `agent send` expects an agent NAME, not a uuid. Run `first-tree-hub agent list` to see available names." : ""}`);
|
|
1685
|
+
const chat = await findOrCreateDirectChat(db, senderUuid, target.uuid);
|
|
1686
|
+
const incomingMeta = data.metadata ?? {};
|
|
1687
|
+
const existingMentionsRaw = incomingMeta.mentions;
|
|
1688
|
+
const existingMentions = Array.isArray(existingMentionsRaw) ? existingMentionsRaw.filter((m) => typeof m === "string") : [];
|
|
1689
|
+
const mergedMentions = existingMentions.includes(target.uuid) ? existingMentions : [...existingMentions, target.uuid];
|
|
1690
|
+
const metadata = {
|
|
1691
|
+
...incomingMeta,
|
|
1692
|
+
mentions: mergedMentions
|
|
1693
|
+
};
|
|
1694
|
+
return sendMessage(db, chat.id, senderUuid, {
|
|
1695
|
+
format: data.format,
|
|
1696
|
+
content: data.content,
|
|
1697
|
+
metadata,
|
|
1698
|
+
replyToInbox: data.replyToInbox,
|
|
1699
|
+
replyToChat: data.replyToChat,
|
|
1700
|
+
source: data.source
|
|
1701
|
+
}, { normalizeMentionsInContent: true });
|
|
1702
|
+
}
|
|
1703
|
+
async function editMessage(db, chatId, messageId, senderId, data) {
|
|
1704
|
+
const [msg] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1);
|
|
1705
|
+
if (!msg) throw new NotFoundError(`Message "${messageId}" not found`);
|
|
1706
|
+
if (msg.chatId !== chatId) throw new NotFoundError(`Message "${messageId}" not found in this chat`);
|
|
1707
|
+
if (msg.senderId !== senderId) throw new ForbiddenError("Only the sender can edit a message");
|
|
1708
|
+
const setClause = {};
|
|
1709
|
+
if (data.format !== void 0) setClause.format = data.format;
|
|
1710
|
+
if (data.content !== void 0) setClause.content = data.content;
|
|
1711
|
+
const meta = msg.metadata ?? {};
|
|
1712
|
+
meta.editedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1713
|
+
setClause.metadata = meta;
|
|
1714
|
+
const [updated] = await db.update(messages).set(setClause).where(eq(messages.id, messageId)).returning();
|
|
1715
|
+
if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
1716
|
+
return updated;
|
|
1717
|
+
}
|
|
1718
|
+
async function listMessages(db, chatId, limit, cursor) {
|
|
1719
|
+
const where = cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(cursor))) : eq(messages.chatId, chatId);
|
|
1720
|
+
const rows = await db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(limit + 1);
|
|
1721
|
+
const hasMore = rows.length > limit;
|
|
1722
|
+
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
1723
|
+
const last = items[items.length - 1];
|
|
1724
|
+
return {
|
|
1725
|
+
items,
|
|
1726
|
+
nextCursor: hasMore && last ? last.createdAt.toISOString() : null
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Assert the caller can act on this client. Throws 404 for both "not found"
|
|
1731
|
+
* and "not yours" to prevent UUID enumeration. The client is owned by exactly
|
|
1732
|
+
* one user; cross-user admin access is no longer supported by this code path
|
|
1733
|
+
* (see decouple-client-from-identity-design §4.10.5 option A). Cross-user
|
|
1734
|
+
* ownership transfer goes through `claimClient` in PR-B.
|
|
1735
|
+
*/
|
|
1736
|
+
async function assertClientOwner(db, clientId, scope) {
|
|
1737
|
+
const [row] = await db.select({
|
|
1738
|
+
id: clients.id,
|
|
1739
|
+
userId: clients.userId
|
|
1740
|
+
}).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
1741
|
+
if (!row || row.userId !== scope.userId) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Upsert the clients row for a given `client_id` under an authenticated user.
|
|
1745
|
+
*
|
|
1746
|
+
* Claim semantics (decouple-client-from-identity §4.1.1):
|
|
1747
|
+
* - New client_id → INSERT with the authenticated user_id. `organization_id`
|
|
1748
|
+
* is written as a placeholder (NOT NULL legacy column; no longer consumed
|
|
1749
|
+
* by any read path) sourced from the caller-supplied JWT default org.
|
|
1750
|
+
* - Existing row with the same user_id → refresh runtime columns.
|
|
1751
|
+
* `organization_id` is **not** updated on conflict, so the placeholder set
|
|
1752
|
+
* at first insert sticks for the row's lifetime.
|
|
1753
|
+
* - Existing row with a different user_id → raises
|
|
1754
|
+
* {@link ClientUserMismatchError} (WS close 4403). The CLI guides the
|
|
1755
|
+
* operator through `first-tree-hub client claim --confirm` to take
|
|
1756
|
+
* ownership, which unpins the previous owner's agents from the machine.
|
|
1757
|
+
*/
|
|
1758
|
+
async function registerClient(db, data) {
|
|
1759
|
+
const now = /* @__PURE__ */ new Date();
|
|
1760
|
+
const [existing] = await db.select({
|
|
1761
|
+
id: clients.id,
|
|
1762
|
+
userId: clients.userId
|
|
1763
|
+
}).from(clients).where(eq(clients.id, data.clientId)).limit(1);
|
|
1764
|
+
if (existing?.userId && existing.userId !== data.userId) throw new ClientUserMismatchError(`Client "${data.clientId}" is owned by a different user. Run \`first-tree-hub client claim --confirm\` to transfer ownership.`);
|
|
1765
|
+
await db.insert(clients).values({
|
|
1766
|
+
id: data.clientId,
|
|
1767
|
+
userId: data.userId,
|
|
1768
|
+
organizationId: data.organizationId,
|
|
1769
|
+
status: "connected",
|
|
1770
|
+
instanceId: data.instanceId,
|
|
1771
|
+
hostname: data.hostname ?? null,
|
|
1772
|
+
os: data.os ?? null,
|
|
1773
|
+
sdkVersion: data.sdkVersion ?? null,
|
|
1774
|
+
connectedAt: now,
|
|
1775
|
+
lastSeenAt: now
|
|
1776
|
+
}).onConflictDoUpdate({
|
|
1777
|
+
target: clients.id,
|
|
1778
|
+
set: {
|
|
1779
|
+
userId: data.userId,
|
|
1780
|
+
status: "connected",
|
|
1781
|
+
instanceId: data.instanceId,
|
|
1782
|
+
hostname: data.hostname ?? null,
|
|
1783
|
+
os: data.os ?? null,
|
|
1784
|
+
sdkVersion: data.sdkVersion ?? null,
|
|
1785
|
+
connectedAt: now,
|
|
1786
|
+
lastSeenAt: now
|
|
1787
|
+
}
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* Transfer ownership of a client row to a new user, unpinning any agents
|
|
1792
|
+
* whose manager belonged to the previous owner. Atomic: caller is guaranteed
|
|
1793
|
+
* either a fully-applied ownership flip + bulk unpin, or no change. Idempotent
|
|
1794
|
+
* when `newUserId` already owns the row.
|
|
1795
|
+
*
|
|
1796
|
+
* Manager → user resolution goes through the members JOIN (the agents table
|
|
1797
|
+
* carries only `manager_id`); cross-org agents under the same previous owner
|
|
1798
|
+
* are unpinned together (decouple-client-from-identity §4.4).
|
|
1799
|
+
*
|
|
1800
|
+
* Caller is responsible for the caller-side authorization (the new owner must
|
|
1801
|
+
* be the authenticated request's user). The structured log
|
|
1802
|
+
* `event: client.owner_transfer` is emitted by the caller after the
|
|
1803
|
+
* transaction commits, using the returned `previousUserId` /
|
|
1804
|
+
* `unpinnedAgentIds`.
|
|
1805
|
+
*/
|
|
1806
|
+
async function claimClient(db, clientId, newUserId) {
|
|
1807
|
+
return db.transaction(async (tx) => {
|
|
1808
|
+
const [locked] = await tx.execute(sql`SELECT id, user_id FROM clients WHERE id = ${clientId} FOR UPDATE`);
|
|
1809
|
+
if (!locked) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
1810
|
+
const previousUserId = locked.user_id;
|
|
1811
|
+
if (previousUserId === newUserId) return {
|
|
1812
|
+
previousUserId,
|
|
1813
|
+
unpinnedAgentIds: []
|
|
1814
|
+
};
|
|
1815
|
+
let unpinnedAgentIds = [];
|
|
1816
|
+
if (previousUserId !== null) {
|
|
1817
|
+
unpinnedAgentIds = (await tx.select({ uuid: agents.uuid }).from(agents).innerJoin(members, eq(agents.managerId, members.id)).where(and(eq(agents.clientId, clientId), eq(members.userId, previousUserId)))).map((r) => r.uuid);
|
|
1818
|
+
if (unpinnedAgentIds.length > 0) {
|
|
1819
|
+
const now = /* @__PURE__ */ new Date();
|
|
1820
|
+
await tx.update(agents).set({
|
|
1821
|
+
clientId: null,
|
|
1822
|
+
updatedAt: now
|
|
1823
|
+
}).where(inArray(agents.uuid, unpinnedAgentIds));
|
|
1824
|
+
await tx.update(agentPresence).set({
|
|
1825
|
+
status: "offline",
|
|
1826
|
+
clientId: null,
|
|
1827
|
+
...runtimeFieldsReset(now)
|
|
1828
|
+
}).where(inArray(agentPresence.agentId, unpinnedAgentIds));
|
|
1829
|
+
await markSupersededByAgents(tx, unpinnedAgentIds, "client_claimed");
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
await tx.update(clients).set({ userId: newUserId }).where(eq(clients.id, clientId));
|
|
1833
|
+
return {
|
|
1834
|
+
previousUserId,
|
|
1835
|
+
unpinnedAgentIds
|
|
1836
|
+
};
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
async function disconnectClient(db, clientId) {
|
|
1840
|
+
const now = /* @__PURE__ */ new Date();
|
|
1841
|
+
await db.update(agentPresence).set({
|
|
1842
|
+
status: "offline",
|
|
1843
|
+
clientId: null,
|
|
1844
|
+
...runtimeFieldsReset(now)
|
|
1845
|
+
}).where(eq(agentPresence.clientId, clientId));
|
|
1846
|
+
await db.update(clients).set({
|
|
1847
|
+
status: "disconnected",
|
|
1848
|
+
lastSeenAt: now
|
|
1849
|
+
}).where(eq(clients.id, clientId));
|
|
1850
|
+
}
|
|
1851
|
+
async function heartbeatClient(db, clientId) {
|
|
1852
|
+
await db.update(clients).set({ lastSeenAt: /* @__PURE__ */ new Date() }).where(eq(clients.id, clientId));
|
|
1853
|
+
}
|
|
1854
|
+
async function getClient(db, clientId) {
|
|
1855
|
+
const [row] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
1856
|
+
return row ?? null;
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* List the active agents currently pinned to a client. Used by the WS
|
|
1860
|
+
* registration handshake to backfill `agent:pinned` notifications missed while
|
|
1861
|
+
* the client was offline — without it, an admin who pinned an agent during a
|
|
1862
|
+
* client outage would still need a manual `first-tree-hub agent add`.
|
|
1863
|
+
*
|
|
1864
|
+
* Excludes soft-deleted agents (status = "deleted"). Human agents are
|
|
1865
|
+
* naturally excluded by the `clientId` filter — they never carry a clientId.
|
|
1866
|
+
*/
|
|
1867
|
+
async function listActiveAgentsPinnedToClient(db, clientId) {
|
|
1868
|
+
return db.select({
|
|
1869
|
+
uuid: agents.uuid,
|
|
1870
|
+
name: agents.name,
|
|
1871
|
+
displayName: agents.displayName,
|
|
1872
|
+
type: agents.type,
|
|
1873
|
+
runtimeProvider: agents.runtimeProvider
|
|
1874
|
+
}).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Member-scoped: every active agent pinned to a client owned by this user.
|
|
1878
|
+
* Used by client startup to reconcile its local YAML against the authoritative
|
|
1879
|
+
* `agents.runtime_provider`. Cross-org by design — a client is owned by a
|
|
1880
|
+
* user, not an org (decouple-client-from-identity §4.1).
|
|
1881
|
+
*/
|
|
1882
|
+
async function listMyPinnedAgents(db, scope) {
|
|
1883
|
+
return (await db.select({
|
|
1884
|
+
agentId: agents.uuid,
|
|
1885
|
+
clientId: agents.clientId,
|
|
1886
|
+
runtimeProvider: agents.runtimeProvider
|
|
1887
|
+
}).from(agents).innerJoin(clients, eq(agents.clientId, clients.id)).where(and(eq(clients.userId, scope.userId), ne(agents.status, "deleted")))).filter((r) => r.clientId !== null).map((r) => ({
|
|
1888
|
+
agentId: r.agentId,
|
|
1889
|
+
clientId: r.clientId,
|
|
1890
|
+
runtimeProvider: r.runtimeProvider
|
|
1891
|
+
}));
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Replace this client's capabilities snapshot. Capabilities live under
|
|
1895
|
+
* `clients.metadata.capabilities` (Option C — no dedicated column); other
|
|
1896
|
+
* `metadata` subkeys are preserved on merge.
|
|
1897
|
+
*
|
|
1898
|
+
* Caller is expected to have already passed `assertClientOwner`.
|
|
1899
|
+
*/
|
|
1900
|
+
async function updateClientCapabilities(db, clientId, capabilities) {
|
|
1901
|
+
const parsed = clientCapabilitiesSchema.safeParse(capabilities);
|
|
1902
|
+
if (!parsed.success) throw new BadRequestError(`Invalid capabilities payload: ${parsed.error.message}`);
|
|
1903
|
+
const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
1904
|
+
if (!client) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
1905
|
+
const merged = {
|
|
1906
|
+
...client.metadata ?? {},
|
|
1907
|
+
capabilities: parsed.data
|
|
1908
|
+
};
|
|
1909
|
+
await db.update(clients).set({ metadata: merged }).where(eq(clients.id, clientId));
|
|
1910
|
+
}
|
|
1911
|
+
/**
|
|
1912
|
+
* Scope-aware client listing. Returns the caller's own clients (cross-org —
|
|
1913
|
+
* a client is owned by a user, not an org). The admin route adds a separate
|
|
1914
|
+
* `?organizationId=` cross-user view via {@link listClientsForOrgAdmin}.
|
|
1915
|
+
*/
|
|
1916
|
+
async function listClients(db, scope) {
|
|
1917
|
+
return attachAgentCounts(db, await db.select().from(clients).where(eq(clients.userId, scope.userId)));
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* Admin-only cross-user listing: every client owned by an active member of
|
|
1921
|
+
* `orgId`. Joining `clients → members.user_id` instead of `clients.organization_id`
|
|
1922
|
+
* keeps the read path consistent with the rule that connection has no
|
|
1923
|
+
* runtime relationship to organization (decouple-client-from-identity §A).
|
|
1924
|
+
*
|
|
1925
|
+
* The caller must verify admin role realtime via `requireMemberInOrg` before
|
|
1926
|
+
* invoking this function — the service does not re-check, so it is
|
|
1927
|
+
* unsafe to expose without that gate.
|
|
1928
|
+
*/
|
|
1929
|
+
async function listClientsForOrgAdmin(db, orgId) {
|
|
1930
|
+
return attachAgentCounts(db, await db.select({
|
|
1931
|
+
id: clients.id,
|
|
1932
|
+
userId: clients.userId,
|
|
1933
|
+
organizationId: clients.organizationId,
|
|
1934
|
+
status: clients.status,
|
|
1935
|
+
sdkVersion: clients.sdkVersion,
|
|
1936
|
+
hostname: clients.hostname,
|
|
1937
|
+
os: clients.os,
|
|
1938
|
+
instanceId: clients.instanceId,
|
|
1939
|
+
connectedAt: clients.connectedAt,
|
|
1940
|
+
lastSeenAt: clients.lastSeenAt,
|
|
1941
|
+
metadata: clients.metadata
|
|
1942
|
+
}).from(clients).innerJoin(members, eq(members.userId, clients.userId)).where(and(eq(members.organizationId, orgId), eq(members.status, "active"))));
|
|
1943
|
+
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Infer whether the client's locally-cached refresh token can plausibly
|
|
1946
|
+
* still mint access tokens. Used by the Web admin dashboard to render an
|
|
1947
|
+
* "AUTH EXPIRED" pill on rows whose offline duration has exceeded the
|
|
1948
|
+
* server's configured refresh-token TTL.
|
|
1949
|
+
*
|
|
1950
|
+
* Uses `lastSeenAt` (not `connectedAt`) because a healthy long-lived
|
|
1951
|
+
* client slides the refresh token continuously, so the absolute connect
|
|
1952
|
+
* time is no proxy for liveness. `lastSeenAt` is updated on register,
|
|
1953
|
+
* heartbeat, and the final disconnect — it lower-bounds the issue time
|
|
1954
|
+
* of the refresh token the client most likely still holds.
|
|
1955
|
+
*
|
|
1956
|
+
* Pure function, no DB access; the column-less design means there's no
|
|
1957
|
+
* server-side revocation path yet — every "expired" decision is purely
|
|
1958
|
+
* time-based. If we ever want admin-driven revocation, add a column
|
|
1959
|
+
* back and OR its value into this function.
|
|
1960
|
+
*/
|
|
1961
|
+
function deriveAuthState(row, refreshTokenExpirySeconds) {
|
|
1962
|
+
if (row.status === "disconnected") {
|
|
1963
|
+
if (Date.now() - row.lastSeenAt.getTime() > refreshTokenExpirySeconds * 1e3) return "expired";
|
|
1964
|
+
}
|
|
1965
|
+
return "ok";
|
|
1966
|
+
}
|
|
1967
|
+
async function attachAgentCounts(db, rows) {
|
|
1968
|
+
const counts = await db.select({
|
|
1969
|
+
clientId: agents.clientId,
|
|
1970
|
+
count: sql`count(*)::int`
|
|
1971
|
+
}).from(agents).where(and(sql`${agents.clientId} IS NOT NULL`, ne(agents.status, "deleted"))).groupBy(agents.clientId);
|
|
1972
|
+
const countMap = new Map(counts.map((c) => [c.clientId, c.count]));
|
|
1973
|
+
return rows.map((row) => ({
|
|
1974
|
+
...row,
|
|
1975
|
+
agentCount: countMap.get(row.id) ?? 0
|
|
1976
|
+
}));
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Retire a client row. Refuses while any non-deleted agent is still pinned to
|
|
1980
|
+
* it — per proposal M12, the operator must delete the agents first
|
|
1981
|
+
* (no reassign in this milestone). Throws {@link ConflictError} with the
|
|
1982
|
+
* pinned agent list so the UI can show the exact names.
|
|
1983
|
+
*
|
|
1984
|
+
* Runs in a single transaction with `SELECT … FOR UPDATE` on the client row
|
|
1985
|
+
* so a concurrent `createAgent(clientId=X)` cannot land between the pinned
|
|
1986
|
+
* check and the DELETE — otherwise the agents.client_id RESTRICT FK would
|
|
1987
|
+
* surface as a raw PG 23503 instead of the ConflictError the caller expects.
|
|
1988
|
+
*/
|
|
1989
|
+
async function retireClient(db, clientId) {
|
|
1990
|
+
await db.transaction(async (tx) => {
|
|
1991
|
+
const [locked] = await tx.execute(sql`SELECT id FROM clients WHERE id = ${clientId} FOR UPDATE`);
|
|
1992
|
+
if (!locked) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
1993
|
+
const pinned = await tx.select({
|
|
1994
|
+
uuid: agents.uuid,
|
|
1995
|
+
name: agents.name
|
|
1996
|
+
}).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
|
|
1997
|
+
if (pinned.length > 0) {
|
|
1998
|
+
const names = pinned.map((a) => a.name ?? a.uuid).join(", ");
|
|
1999
|
+
throw new ConflictError(`Cannot retire client "${clientId}" — ${pinned.length} agent(s) still pinned (${names}). Delete the pinned agents first (no reassign is available in this milestone).`);
|
|
2000
|
+
}
|
|
2001
|
+
await tx.update(agents).set({ clientId: null }).where(and(eq(agents.clientId, clientId), eq(agents.status, "deleted")));
|
|
2002
|
+
await tx.delete(clients).where(eq(clients.id, clientId));
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* System-scope sweep: mark clients as disconnected when their last-seen
|
|
2007
|
+
* server instance stopped sending heartbeats. Runs globally across all orgs
|
|
2008
|
+
* by design — it is invoked only by internal timers, never from a
|
|
2009
|
+
* user-scoped request, so the per-org filter the read paths enforce does not
|
|
2010
|
+
* apply. Org isolation on the data these clients belong to is still
|
|
2011
|
+
* enforced at the read paths (see `assertClientOwner` / `listClients`).
|
|
2012
|
+
*/
|
|
2013
|
+
async function cleanupStaleClients(db, staleSeconds = 60) {
|
|
2014
|
+
const result = await db.execute(sql`
|
|
2015
|
+
UPDATE clients SET status = 'disconnected'
|
|
2016
|
+
WHERE instance_id IN (
|
|
2017
|
+
SELECT instance_id FROM server_instances
|
|
2018
|
+
WHERE last_heartbeat < NOW() - make_interval(secs => ${staleSeconds})
|
|
2019
|
+
)
|
|
2020
|
+
AND status = 'connected'
|
|
2021
|
+
RETURNING id
|
|
2022
|
+
`);
|
|
2023
|
+
if (result.length > 0) {
|
|
2024
|
+
const staleIds = result.map((r) => r.id);
|
|
2025
|
+
await db.update(agentPresence).set({
|
|
2026
|
+
status: "offline",
|
|
2027
|
+
...runtimeFieldsReset(/* @__PURE__ */ new Date())
|
|
2028
|
+
}).where(inArray(agentPresence.clientId, staleIds));
|
|
2029
|
+
}
|
|
2030
|
+
return result.length;
|
|
2031
|
+
}
|
|
2032
|
+
//#endregion
|
|
2033
|
+
export { pendingQuestions as $, heartbeatClient as A, listAgentsWithRuntime as B, findOrCreateDirectChat as C, getClient as D, getChatDetail as E, joinChat as F, listClientsForOrgAdmin as G, listChats as H, leaveAsParticipant as I, markStaleAgents as J, listMessages as K, leaveChat as L, inboxEntries as M, invalidateChatAudience as N, getOnlineCount as O, joinAsParticipant as P, notifyRecipients as Q, listActiveAgentsPinnedToClient as R, ensureParticipant as S, getCachedAudience as T, listChatsForMember as U, listChatParticipantsWithNames as V, listClients as W, members as X, markSupersededByChat as Y, messages as Z, createNotifier as _, updateClientCapabilities as _t, agents as a, removeParticipant as at, editMessage as b, bindAgent as c, retireClient as ct, chats as d, serverInstances as dt, recomputeChatWatchers as et, claimClient as f, setOffline as ft, createChat as g, unbindAgent as gt, clients as h, touchAgent as ht, agentVisibilityCondition as i, registerClient as it, heartbeatInstance as j, getPresence as k, chatParticipants as l, sendMessage as lt, cleanupStalePresence as m, submitAnswer as mt, agentChatSessions as n, recomputeWatchersForMember as nt, assertClientOwner as o, resetActivity as ot, cleanupStaleClients as p, setRuntimeState as pt, listMyPinnedAgents as q, agentPresence as r, registerChatMessageDispatcher as rt, assertParticipant as s, resolveChatMembership as st, addParticipant as t, recomputeWatchersForAgent as tt, chatSubscriptions as u, sendToAgent as ut, deriveAuthState as v, upsertSessionState as vt, getActivityOverview as w, ensureCanJoin as x, disconnectClient as y, listAgentsManagedByUser as z };
|