@agent-team-foundation/first-tree-hub 0.14.4 → 0.14.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap-BmeaRhRp.mjs +3 -0
- package/dist/{bootstrap-CQQGgIx1.mjs → bootstrap-CmkHQsnS.mjs} +24 -16
- package/dist/cli/index.mjs +6 -94
- package/dist/{dist-CrdnqZjv.mjs → feishu-BE7QRxnE.mjs} +170 -379
- package/dist/feishu-De9_bA91.mjs +3 -0
- package/dist/index.mjs +5 -12
- package/dist/saas-connect-CNY9Ve5V.mjs +13748 -0
- package/package.json +4 -12
- package/dist/chunk-BSw8zbkd.mjs +0 -37
- package/dist/client-BPRIfrOT-CoV_2o7e.mjs +0 -4230
- package/dist/client-CEdYVnoj-BGiGcJbH.mjs +0 -7
- package/dist/dist-LgF7LHpE.mjs +0 -430
- package/dist/drizzle/0000_shocking_darkhawk.sql +0 -92
- package/dist/drizzle/0001_v2_schema_updates.sql +0 -26
- package/dist/drizzle/0002_adapter_tables.sql +0 -64
- package/dist/drizzle/0003_feishu_adapter.sql +0 -21
- package/dist/drizzle/0004_adapter_refactor.sql +0 -13
- package/dist/drizzle/0005_delegate_mention.sql +0 -1
- package/dist/drizzle/0006_agent_tree_path.sql +0 -1
- package/dist/drizzle/0007_decouple_context_tree.sql +0 -2
- package/dist/drizzle/0008_uuid_identity.sql +0 -12
- package/dist/drizzle/0009_agent_runtime_m1.sql +0 -31
- package/dist/drizzle/0010_cloud_multi_tenancy.sql +0 -34
- package/dist/drizzle/0011_org_uuid_pk.sql +0 -22
- package/dist/drizzle/0012_session_level_state.sql +0 -19
- package/dist/drizzle/0013_hub_tasks.sql +0 -38
- package/dist/drizzle/0014_drop_task_fks.sql +0 -9
- package/dist/drizzle/0015_member_system.sql +0 -34
- package/dist/drizzle/0016_strange_havok.sql +0 -25
- package/dist/drizzle/0017_session_outputs_unique.sql +0 -1
- package/dist/drizzle/0018_agent_visibility.sql +0 -13
- package/dist/drizzle/0019_agent_configs.sql +0 -30
- package/dist/drizzle/0020_unified_user_token.sql +0 -154
- package/dist/drizzle/0021_drop_agents_profile.sql +0 -10
- package/dist/drizzle/0022_session_events.sql +0 -32
- package/dist/drizzle/0023_clients_org_scoping.sql +0 -40
- package/dist/drizzle/0024_display_name_not_null.sql +0 -31
- package/dist/drizzle/0025_inbox_silent_entries.sql +0 -53
- package/dist/drizzle/0026_saas_onboarding.sql +0 -153
- package/dist/drizzle/0027_runtime_provider.sql +0 -10
- package/dist/drizzle/0028_auth_identity_user_github_unique.sql +0 -12
- package/dist/drizzle/0029_direct_agent_only_mention_only.sql +0 -28
- package/dist/drizzle/0030_chat_first_workspace.sql +0 -129
- package/dist/drizzle/0031_drop_system_configs.sql +0 -11
- package/dist/drizzle/0032_organization_settings.sql +0 -36
- package/dist/drizzle/0033_onboarding_dismissed_at.sql +0 -13
- package/dist/drizzle/0034_pending_questions.sql +0 -34
- package/dist/drizzle/0035_drop_hub_tasks.sql +0 -7
- package/dist/drizzle/0036_github_entity_chat_mappings.sql +0 -47
- package/dist/drizzle/0037_github_app_installations.sql +0 -52
- package/dist/drizzle/0038_chat_membership_user_state.sql +0 -223
- package/dist/drizzle/0039_drop_chat_participants_subscriptions.sql +0 -26
- package/dist/drizzle/0040_chat_user_state_engagement.sql +0 -24
- package/dist/drizzle/0041_notifications_dedup_key.sql +0 -29
- package/dist/drizzle/0042_notifications_drop_legacy_types.sql +0 -36
- package/dist/drizzle/0043_onboarding_completed_at.sql +0 -32
- package/dist/drizzle/0044_agent_avatar_color.sql +0 -11
- package/dist/drizzle/0045_agent_avatar_image.sql +0 -17
- package/dist/drizzle/meta/0000_snapshot.json +0 -687
- package/dist/drizzle/meta/0001_snapshot.json +0 -687
- package/dist/drizzle/meta/0012_snapshot.json +0 -1451
- package/dist/drizzle/meta/0013_snapshot.json +0 -1771
- package/dist/drizzle/meta/0014_snapshot.json +0 -1717
- package/dist/drizzle/meta/0016_snapshot.json +0 -1917
- package/dist/drizzle/meta/0018_snapshot.json +0 -1938
- package/dist/drizzle/meta/_journal.json +0 -328
- package/dist/esm-iadMkGbV.mjs +0 -1516
- package/dist/execAsync-DUfRkc4a.mjs +0 -10
- package/dist/execAsync-YbEZSOYd.mjs +0 -10
- package/dist/feishu-DNoBroKK.mjs +0 -53
- package/dist/from-DQ7eNRwu.mjs +0 -3840
- package/dist/getMachineId-bsd-BmasEOJr.mjs +0 -27
- package/dist/getMachineId-bsd-Dh3h0DDE.mjs +0 -27
- package/dist/getMachineId-darwin-CuhM3hfZ.mjs +0 -24
- package/dist/getMachineId-darwin-D9wR0SLj.mjs +0 -24
- package/dist/getMachineId-linux-CYfb0oxZ.mjs +0 -20
- package/dist/getMachineId-linux-D8ZaSjAC.mjs +0 -20
- package/dist/getMachineId-unsupported-Cu3iisaD.mjs +0 -15
- package/dist/getMachineId-unsupported-DZqI4ZT5.mjs +0 -15
- package/dist/getMachineId-win-8ZJbtrdf.mjs +0 -26
- package/dist/getMachineId-win-DT-hqwVp.mjs +0 -26
- package/dist/invitation-C9m2gQx4-C_4f5VTs.mjs +0 -4
- package/dist/invitation-D_ENPHyj-5ETiae5r.mjs +0 -167
- package/dist/logger-core-BTmvdflj-DjW8FM4T.mjs +0 -146
- package/dist/multipart-parser-QRu3OKK4.mjs +0 -294
- package/dist/observability-BAScT_5S-BcW9HgkG.mjs +0 -96129
- package/dist/observability-eLA9iNK_.mjs +0 -5
- package/dist/saas-connect-Da55XxRX.mjs +0 -21635
- package/dist/src-DFlbpJfU.mjs +0 -1176
- package/dist/src-DNBS5Yjj.mjs +0 -735
- package/dist/uuid-DbS_4vFh-iFghv4zA.mjs +0 -129
- package/dist/web/assets/index-9wK0udbH.js +0 -416
- package/dist/web/assets/index-C7x7O7dG.js +0 -11
- package/dist/web/assets/index-DE7Q3QWE.css +0 -1
- package/dist/web/favicon.svg +0 -9
- package/dist/web/fonts/inter-latin-ext.woff2 +0 -0
- package/dist/web/fonts/inter-latin.woff2 +0 -0
- package/dist/web/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- package/dist/web/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/dist/web/index.html +0 -39
- /package/dist/{cli-fetch--tiwKm5S.mjs → cli-fetch-BGVItZxo.mjs} +0 -0
|
@@ -1,4230 +0,0 @@
|
|
|
1
|
-
import { A as FIRST_TREE_HUB_ATTR, O as withSpan, f as messageAttrs, s as createLogger } from "./observability-BAScT_5S-BcW9HgkG.mjs";
|
|
2
|
-
import { L as extractMentions, N as defaultParticipantMode, P as defaultRuntimeConfigPayload, S as clientCapabilitiesSchema, St as stripCode, Z as isReservedAgentName, a as AGENT_TYPES, b as agentTypeSchema, ct as questionAnswerMessageContentSchema, d as MENTION_REGEX, i as AGENT_STATUSES, l as GITHUB_ENTITY_TYPES, lt as questionMessageContentSchema, mt as scanMentionTokens, n as AGENT_NAME_REGEX, nt as messageSourceSchema, o as AGENT_VISIBILITY, s as CHAT_ENGAGEMENT_STATUSES } from "./dist-CrdnqZjv.mjs";
|
|
3
|
-
import { a as ClientUserMismatchError, c as NotFoundError, d as users, f as uuidv7, o as ConflictError, r as BadRequestError, s as ForbiddenError, t as AgentSendNonMemberError, u as organizations } from "./uuid-DbS_4vFh-iFghv4zA.mjs";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, lt, ne, or, sql } from "drizzle-orm";
|
|
6
|
-
import { bigserial, boolean, customType, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique } from "drizzle-orm/pg-core";
|
|
7
|
-
//#region ../server/dist/client-BPRIfrOT.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
|
-
/**
|
|
37
|
-
* `bytea` column type — Drizzle ships pg primitives but not bytea out of the
|
|
38
|
-
* box. Reads come back as Node `Buffer` (postgres-js); writes accept any
|
|
39
|
-
* `Uint8Array`. Used for the small inline avatar image blob; no streaming
|
|
40
|
-
* needed at this size (≤ ~50 KB after client-side resize).
|
|
41
|
-
*/
|
|
42
|
-
const bytea = customType({ dataType: () => "bytea" });
|
|
43
|
-
/** Agent registration. Each agent owns a unique inboxId for message delivery. */
|
|
44
|
-
const agents = pgTable("agents", {
|
|
45
|
-
uuid: text("uuid").primaryKey(),
|
|
46
|
-
name: text("name"),
|
|
47
|
-
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
48
|
-
type: text("type").notNull(),
|
|
49
|
-
displayName: text("display_name").notNull(),
|
|
50
|
-
delegateMention: text("delegate_mention"),
|
|
51
|
-
inboxId: text("inbox_id").unique().notNull(),
|
|
52
|
-
status: text("status").notNull().default("active"),
|
|
53
|
-
source: text("source"),
|
|
54
|
-
visibility: text("visibility").notNull().default("private"),
|
|
55
|
-
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
56
|
-
managerId: text("manager_id").notNull(),
|
|
57
|
-
clientId: text("client_id").references(() => clients.id, { onDelete: "restrict" }),
|
|
58
|
-
runtimeProvider: text("runtime_provider").notNull().default("claude-code"),
|
|
59
|
-
avatarColorToken: text("avatar_color_token"),
|
|
60
|
-
avatarImageData: bytea("avatar_image_data"),
|
|
61
|
-
avatarImageMime: text("avatar_image_mime"),
|
|
62
|
-
avatarImageUpdatedAt: timestamp("avatar_image_updated_at", { withTimezone: true }),
|
|
63
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
64
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
65
|
-
}, (table) => [
|
|
66
|
-
index("idx_agents_org").on(table.organizationId),
|
|
67
|
-
index("idx_agents_manager").on(table.managerId),
|
|
68
|
-
index("idx_agents_visibility_org").on(table.organizationId, table.visibility),
|
|
69
|
-
index("idx_agents_client").on(table.clientId),
|
|
70
|
-
unique("uq_agents_org_name").on(table.organizationId, table.name)
|
|
71
|
-
]);
|
|
72
|
-
/** Maps external user identities to internal Agents. */
|
|
73
|
-
const adapterAgentMappings = pgTable("adapter_agent_mappings", {
|
|
74
|
-
id: serial("id").primaryKey(),
|
|
75
|
-
platform: text("platform").notNull(),
|
|
76
|
-
externalUserId: text("external_user_id").notNull(),
|
|
77
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
78
|
-
boundVia: text("bound_via"),
|
|
79
|
-
displayName: text("display_name"),
|
|
80
|
-
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
81
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
82
|
-
}, (table) => [unique("uq_adapter_agent_mapping").on(table.platform, table.externalUserId)]);
|
|
83
|
-
/**
|
|
84
|
-
* Unified membership table. Replaces the two-table split
|
|
85
|
-
* (chat_participants speakers ∪ chat_subscriptions watchers) — both
|
|
86
|
-
* collapse into a single row keyed by (chat_id, agent_id) with two
|
|
87
|
-
* orthogonal columns:
|
|
88
|
-
*
|
|
89
|
-
* - `role` ∈ owner / member (creator-vs-member, governs admin actions)
|
|
90
|
-
* - `access_mode` ∈ speaker / watcher (fan-out + mention candidacy)
|
|
91
|
-
*
|
|
92
|
-
* `(owner, speaker)`, `(member, speaker)`, `(member, watcher)` are the
|
|
93
|
-
* legal combinations. `(owner, watcher)` is structurally possible but
|
|
94
|
-
* never produced by v1 paths — the creator is always a speaker.
|
|
95
|
-
*
|
|
96
|
-
* Service-layer integrity (no FK / CHECK / trigger), matching the
|
|
97
|
-
* messages / inbox_entries / notifications convention. Chat hard-delete
|
|
98
|
-
* paths must explicitly DELETE rows here (service-level cascade) — the
|
|
99
|
-
* old DB-level `ON DELETE CASCADE` is intentionally not preserved.
|
|
100
|
-
*
|
|
101
|
-
* See proposals/chat-data-model-restructure.20260512.md §8.
|
|
102
|
-
*/
|
|
103
|
-
const chatMembership = pgTable("chat_membership", {
|
|
104
|
-
chatId: text("chat_id").notNull(),
|
|
105
|
-
agentId: text("agent_id").notNull(),
|
|
106
|
-
role: text("role").notNull().default("member"),
|
|
107
|
-
accessMode: text("access_mode").notNull(),
|
|
108
|
-
mode: text("mode").notNull().default("full"),
|
|
109
|
-
source: text("source").notNull().default("manual"),
|
|
110
|
-
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
|
|
111
|
-
}, (table) => [
|
|
112
|
-
primaryKey({ columns: [table.chatId, table.agentId] }),
|
|
113
|
-
index("idx_membership_agent").on(table.agentId),
|
|
114
|
-
index("idx_membership_chat_role").on(table.chatId, table.accessMode)
|
|
115
|
-
]);
|
|
116
|
-
/** Communication container. All messages between agents flow within a Chat. */
|
|
117
|
-
const chats = pgTable("chats", {
|
|
118
|
-
id: text("id").primaryKey(),
|
|
119
|
-
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
120
|
-
type: text("type").notNull().default("direct"),
|
|
121
|
-
topic: text("topic"),
|
|
122
|
-
lifecyclePolicy: text("lifecycle_policy").default("persistent"),
|
|
123
|
-
parentChatId: text("parent_chat_id"),
|
|
124
|
-
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
125
|
-
lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
|
|
126
|
-
lastMessagePreview: text("last_message_preview"),
|
|
127
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
128
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
129
|
-
}, (table) => [index("idx_chats_org_last_message").on(table.organizationId, desc(table.lastMessageAt))]);
|
|
130
|
-
/** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
|
|
131
|
-
const members = pgTable("members", {
|
|
132
|
-
id: text("id").primaryKey(),
|
|
133
|
-
userId: text("user_id").notNull().references(() => users.id),
|
|
134
|
-
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
135
|
-
agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
|
|
136
|
-
role: text("role").notNull(),
|
|
137
|
-
status: text("status").notNull().default("active"),
|
|
138
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
139
|
-
}, (table) => [
|
|
140
|
-
unique("uq_members_user_org").on(table.userId, table.organizationId),
|
|
141
|
-
index("idx_members_user").on(table.userId),
|
|
142
|
-
index("idx_members_org").on(table.organizationId)
|
|
143
|
-
]);
|
|
144
|
-
/** Bot credentials for external platform adapters. Credentials are encrypted at application layer (AES-256-GCM). */
|
|
145
|
-
const adapterConfigs = pgTable("adapter_configs", {
|
|
146
|
-
id: serial("id").primaryKey(),
|
|
147
|
-
platform: text("platform").notNull(),
|
|
148
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid),
|
|
149
|
-
credentials: jsonb("credentials").$type().notNull(),
|
|
150
|
-
status: text("status").notNull().default("active"),
|
|
151
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
152
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
153
|
-
}, (t) => [unique("uq_adapter_configs_agent_platform").on(t.agentId, t.platform)]);
|
|
154
|
-
/** Messages. Immutable after creation. Each message belongs to exactly one Chat. */
|
|
155
|
-
const messages = pgTable("messages", {
|
|
156
|
-
id: text("id").primaryKey(),
|
|
157
|
-
chatId: text("chat_id").notNull().references(() => chats.id),
|
|
158
|
-
senderId: text("sender_id").notNull(),
|
|
159
|
-
format: text("format").notNull(),
|
|
160
|
-
content: jsonb("content").$type().notNull(),
|
|
161
|
-
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
162
|
-
replyToInbox: text("reply_to_inbox"),
|
|
163
|
-
replyToChat: text("reply_to_chat"),
|
|
164
|
-
inReplyTo: text("in_reply_to"),
|
|
165
|
-
source: text("source"),
|
|
166
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
167
|
-
}, (table) => [index("idx_messages_chat_time").on(table.chatId, table.createdAt), index("idx_messages_in_reply_to").on(table.inReplyTo)]);
|
|
168
|
-
/**
|
|
169
|
-
* Process-local cache for the per-chat realtime push audience
|
|
170
|
-
* (every row in `chat_membership` for the chat — speakers + watchers,
|
|
171
|
-
* keyed by human agent uuid). Sits in front of the admin WS dispatch
|
|
172
|
-
* so a chat with N messages/sec doesn't issue N audience-resolution
|
|
173
|
-
* queries; one query + cache hit per chat per TTL window.
|
|
174
|
-
*
|
|
175
|
-
* The cache exposes both a populator (`getCachedAudience`) and an
|
|
176
|
-
* invalidator (`invalidateChatAudience`). Participant-mutation paths
|
|
177
|
-
* (`addMeChatParticipants`, `joinMeChat`, `leaveMeChat`,
|
|
178
|
-
* `recomputeChatWatchers`, `joinAsParticipant`, `leaveAsParticipant`)
|
|
179
|
-
* MUST call `invalidateChatAudience` after their tx commits so the
|
|
180
|
-
* very next dispatch reflects the new audience without waiting for
|
|
181
|
-
* the TTL to age out — without invalidation, a freshly-added speaker
|
|
182
|
-
* would miss `chat:message` pushes for up to TTL_MS.
|
|
183
|
-
*
|
|
184
|
-
* Cross-instance correctness: not handled here. The PG NOTIFY layer
|
|
185
|
-
* already broadcasts message events to every replica; each replica's
|
|
186
|
-
* audience cache is independently invalidated by its own
|
|
187
|
-
* service-layer mutations on chats it routes traffic for. For
|
|
188
|
-
* cross-replica participant changes to invalidate this cache, route
|
|
189
|
-
* the mutation through the same replica that hosts the WS connection
|
|
190
|
-
* (sticky routing) or add a dedicated `chat:audience` PG NOTIFY in
|
|
191
|
-
* a follow-up.
|
|
192
|
-
*/
|
|
193
|
-
const log$2 = createLogger("ChatAudienceCache");
|
|
194
|
-
const TTL_MS = 5e3;
|
|
195
|
-
const MAX_ENTRIES = 1024;
|
|
196
|
-
const cache = /* @__PURE__ */ new Map();
|
|
197
|
-
/** Resolve a chat's push audience, hitting the cache when fresh.
|
|
198
|
-
* Returns null on DB error (caller should skip dispatch). */
|
|
199
|
-
async function getCachedAudience(db, chatId) {
|
|
200
|
-
const now = Date.now();
|
|
201
|
-
const cached = cache.get(chatId);
|
|
202
|
-
if (cached && cached.expiresAt > now) return cached.audience;
|
|
203
|
-
try {
|
|
204
|
-
const rows = await db.execute(sql`
|
|
205
|
-
SELECT agent_id FROM chat_membership WHERE chat_id = ${chatId}
|
|
206
|
-
`);
|
|
207
|
-
const audience = new Set(rows.map((r) => r.agent_id));
|
|
208
|
-
cache.set(chatId, {
|
|
209
|
-
audience,
|
|
210
|
-
expiresAt: now + TTL_MS
|
|
211
|
-
});
|
|
212
|
-
if (cache.size > MAX_ENTRIES) {
|
|
213
|
-
for (const [k, v] of cache) if (v.expiresAt <= now) cache.delete(k);
|
|
214
|
-
}
|
|
215
|
-
return audience;
|
|
216
|
-
} catch (err) {
|
|
217
|
-
log$2.warn({
|
|
218
|
-
err,
|
|
219
|
-
chatId
|
|
220
|
-
}, "failed to resolve chat audience");
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
/** Drop the cached audience for a chat. Called from participant-
|
|
225
|
-
* mutation paths after their transaction commits, so the next
|
|
226
|
-
* `chat:message` dispatch hits the DB and reflects the new
|
|
227
|
-
* membership instead of serving a stale TTL window. */
|
|
228
|
-
function invalidateChatAudience(chatId) {
|
|
229
|
-
cache.delete(chatId);
|
|
230
|
-
}
|
|
231
|
-
/** Delivery queue (envelope). One entry per recipient created during message fan-out. Uses SKIP LOCKED for concurrent-safe consumption. */
|
|
232
|
-
const inboxEntries = pgTable("inbox_entries", {
|
|
233
|
-
id: bigserial("id", { mode: "number" }).primaryKey(),
|
|
234
|
-
inboxId: text("inbox_id").notNull(),
|
|
235
|
-
messageId: text("message_id").notNull().references(() => messages.id),
|
|
236
|
-
chatId: text("chat_id"),
|
|
237
|
-
status: text("status").notNull().default("pending"),
|
|
238
|
-
notify: boolean("notify").notNull().default(true),
|
|
239
|
-
retryCount: integer("retry_count").notNull().default(0),
|
|
240
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
241
|
-
deliveredAt: timestamp("delivered_at", { withTimezone: true }),
|
|
242
|
-
ackedAt: timestamp("acked_at", { withTimezone: true })
|
|
243
|
-
}, (table) => [
|
|
244
|
-
unique("uq_inbox_delivery").on(table.inboxId, table.messageId, table.chatId),
|
|
245
|
-
index("idx_inbox_pending").on(table.inboxId, table.createdAt),
|
|
246
|
-
index("idx_inbox_pending_notify").on(table.inboxId, table.createdAt).where(sql`status = 'pending' AND notify = true`),
|
|
247
|
-
index("idx_inbox_chat_silent").on(table.inboxId, table.chatId, table.notify, table.status)
|
|
248
|
-
]);
|
|
249
|
-
/**
|
|
250
|
-
* Per-agent runtime configuration (Hub-managed; not the local YAML config).
|
|
251
|
-
*
|
|
252
|
-
* One row per agent. `version` increments on every successful UPDATE
|
|
253
|
-
* (optimistic locking via WHERE version = :expected). Sensitive env values
|
|
254
|
-
* inside `payload.env[*]` are AES-256-GCM encrypted at write time and
|
|
255
|
-
* masked when echoed via the Admin API (see Step 2).
|
|
256
|
-
*
|
|
257
|
-
* Integrity is enforced by the service layer per project convention:
|
|
258
|
-
* no FK / CHECK / triggers on this table.
|
|
259
|
-
*/
|
|
260
|
-
const agentConfigs = pgTable("agent_configs", {
|
|
261
|
-
agentId: text("agent_id").primaryKey(),
|
|
262
|
-
version: integer("version").notNull().default(1),
|
|
263
|
-
payload: jsonb("payload").$type().notNull(),
|
|
264
|
-
updatedBy: text("updated_by").notNull(),
|
|
265
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
266
|
-
});
|
|
267
|
-
function normaliseSource(source) {
|
|
268
|
-
if (source === null) return null;
|
|
269
|
-
const parsed = messageSourceSchema.safeParse(source);
|
|
270
|
-
return parsed.success ? parsed.data : null;
|
|
271
|
-
}
|
|
272
|
-
function normaliseMode(mode) {
|
|
273
|
-
return mode === "mention_only" ? "mention_only" : "full";
|
|
274
|
-
}
|
|
275
|
-
/**
|
|
276
|
-
* Batch variant — builds all payloads with a single DB lookup per agent plus
|
|
277
|
-
* batched lookups for participant modes and inReplyTo snapshots.
|
|
278
|
-
*/
|
|
279
|
-
async function buildClientMessagePayloadsForInbox(db, inboxId, items) {
|
|
280
|
-
if (items.length === 0) return [];
|
|
281
|
-
const agentId = await resolveAgentId(db, {
|
|
282
|
-
kind: "inboxId",
|
|
283
|
-
inboxId
|
|
284
|
-
});
|
|
285
|
-
const [cfg] = await db.select({ version: agentConfigs.version }).from(agentConfigs).where(eq(agentConfigs.agentId, agentId)).limit(1);
|
|
286
|
-
const version = cfg?.version ?? 1;
|
|
287
|
-
const chatIds = [...new Set(items.map((it) => it.entryChatId ?? it.message.chatId).filter((id) => id !== null))];
|
|
288
|
-
const modeByChat = /* @__PURE__ */ new Map();
|
|
289
|
-
if (chatIds.length > 0) {
|
|
290
|
-
const rows = await db.select({
|
|
291
|
-
chatId: chatMembership.chatId,
|
|
292
|
-
mode: chatMembership.mode
|
|
293
|
-
}).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
|
|
294
|
-
for (const r of rows) modeByChat.set(r.chatId, normaliseMode(r.mode));
|
|
295
|
-
}
|
|
296
|
-
const inReplyToIds = [...new Set(items.map((it) => it.message.inReplyTo).filter((id) => id !== null))];
|
|
297
|
-
const snapshotById = /* @__PURE__ */ new Map();
|
|
298
|
-
if (inReplyToIds.length > 0) {
|
|
299
|
-
const origs = await db.select({
|
|
300
|
-
id: messages.id,
|
|
301
|
-
senderId: messages.senderId,
|
|
302
|
-
chatId: messages.chatId,
|
|
303
|
-
replyToChat: messages.replyToChat
|
|
304
|
-
}).from(messages).where(inArray(messages.id, inReplyToIds));
|
|
305
|
-
for (const o of origs) snapshotById.set(o.id, {
|
|
306
|
-
senderId: o.senderId,
|
|
307
|
-
chatId: o.chatId,
|
|
308
|
-
replyToChat: o.replyToChat
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
return items.map(({ entryChatId, message: m, precedingMessages = [] }) => ({
|
|
312
|
-
id: m.id,
|
|
313
|
-
chatId: m.chatId,
|
|
314
|
-
senderId: m.senderId,
|
|
315
|
-
format: m.format,
|
|
316
|
-
content: m.content,
|
|
317
|
-
metadata: m.metadata,
|
|
318
|
-
replyToInbox: m.replyToInbox,
|
|
319
|
-
replyToChat: m.replyToChat,
|
|
320
|
-
inReplyTo: m.inReplyTo,
|
|
321
|
-
source: normaliseSource(m.source),
|
|
322
|
-
createdAt: m.createdAt,
|
|
323
|
-
configVersion: version,
|
|
324
|
-
recipientMode: modeByChat.get(entryChatId ?? m.chatId) ?? "full",
|
|
325
|
-
inReplyToSnapshot: m.inReplyTo ? snapshotById.get(m.inReplyTo) ?? null : null,
|
|
326
|
-
precedingMessages
|
|
327
|
-
}));
|
|
328
|
-
}
|
|
329
|
-
async function resolveAgentId(db, source) {
|
|
330
|
-
if (source.kind === "agentId") return source.agentId;
|
|
331
|
-
const [agent] = await db.select({ uuid: agents.uuid }).from(agents).where(eq(agents.inboxId, source.inboxId)).limit(1);
|
|
332
|
-
if (!agent) throw new Error(`No agent owns inbox "${source.inboxId}"`);
|
|
333
|
-
return agent.uuid;
|
|
334
|
-
}
|
|
335
|
-
const DEFAULT_INBOX_TIMEOUT_SECONDS = 300;
|
|
336
|
-
const DEFAULT_MAX_RETRY_COUNT = 3;
|
|
337
|
-
const PRECEDING_CONTEXT_WINDOW_SECONDS = 1440 * 60;
|
|
338
|
-
/**
|
|
339
|
-
* Backfill the most recent `PRECEDING_CONTEXT_MAX_ENTRIES` messages of `chatId`
|
|
340
|
-
* as silent (notify=false) inbox rows for every new participant. Called from
|
|
341
|
-
* `addParticipant()` inside the participant-insert transaction so a freshly
|
|
342
|
-
* added member already has prior chat history available the first time they
|
|
343
|
-
* are woken (mentioned / `chat send`-ed).
|
|
344
|
-
*
|
|
345
|
-
* Invariants the implementation upholds:
|
|
346
|
-
*
|
|
347
|
-
* - **`notify=false` everywhere**: adding a participant is not itself a
|
|
348
|
-
* wake event. The new participant only runs the LLM when an actual
|
|
349
|
-
* trigger lands later; the backfill rows then piggy-back as preceding
|
|
350
|
-
* context (see `collectPrecedingContext`).
|
|
351
|
-
* - **Old members are not woken**: only inbox rows for the brand-new
|
|
352
|
-
* participants are written.
|
|
353
|
-
* - **Transaction-scoped**: writes go through the caller's `tx`, so a
|
|
354
|
-
* rollback of `addParticipant` rolls the backfill back too.
|
|
355
|
-
* - **Quiet on chats with no prior history**: a chat with zero messages
|
|
356
|
-
* produces zero backfill rows; no error, no INSERT.
|
|
357
|
-
* - **Idempotent**: collides cleanly on the
|
|
358
|
-
* `(inbox_id, message_id, chat_id)` unique key via
|
|
359
|
-
* `ON CONFLICT DO NOTHING`. This matters when a watcher → speaker
|
|
360
|
-
* promotion already had inbox rows for some of these messages.
|
|
361
|
-
*
|
|
362
|
-
* Pure data write — no PG NOTIFY, no participant-mode logic, no watcher
|
|
363
|
-
* recompute. Callers stay responsible for those.
|
|
364
|
-
*
|
|
365
|
-
* **Caller responsibility — bulk batching**: writes a single
|
|
366
|
-
* `INSERT VALUES (...)` of `newParticipants.length * PRECEDING_CONTEXT_MAX_ENTRIES`
|
|
367
|
-
* tuples. v1's only caller (`addParticipant`) always passes 1 participant
|
|
368
|
-
* (≤ 50 rows). Future bulk-add paths (sub-chat / spawn / etc.) should split
|
|
369
|
-
* input into chunks (suggested: ≤ 512 rows per call) before passing here.
|
|
370
|
-
*
|
|
371
|
-
* See proposals/hub-chat-message-v1-design §四 改造 2.
|
|
372
|
-
*/
|
|
373
|
-
async function backfillSilentContextForNewParticipants(tx, chatId, newParticipants) {
|
|
374
|
-
if (newParticipants.length === 0) return;
|
|
375
|
-
const recent = await tx.select({ id: messages.id }).from(messages).where(eq(messages.chatId, chatId)).orderBy(desc(messages.createdAt)).limit(50);
|
|
376
|
-
if (recent.length === 0) return;
|
|
377
|
-
const rows = [];
|
|
378
|
-
for (const p of newParticipants) for (const m of recent) rows.push({
|
|
379
|
-
inboxId: p.inboxId,
|
|
380
|
-
messageId: m.id,
|
|
381
|
-
chatId,
|
|
382
|
-
notify: false
|
|
383
|
-
});
|
|
384
|
-
await tx.insert(inboxEntries).values(rows).onConflictDoNothing();
|
|
385
|
-
}
|
|
386
|
-
async function pollInbox(db, inboxId, limit) {
|
|
387
|
-
return withSpan("inbox.deliver", {
|
|
388
|
-
"inbox.id": inboxId,
|
|
389
|
-
"inbox.poll.limit": limit
|
|
390
|
-
}, () => pollInboxInner(db, inboxId, limit));
|
|
391
|
-
}
|
|
392
|
-
async function pollInboxInner(db, inboxId, limit) {
|
|
393
|
-
return db.transaction(async (tx) => {
|
|
394
|
-
const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(limit).for("update", { skipLocked: true });
|
|
395
|
-
return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
|
|
396
|
-
status: "delivered",
|
|
397
|
-
deliveredAt: /* @__PURE__ */ new Date()
|
|
398
|
-
}).where(inArray(inboxEntries.id, targetIds)).returning());
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
/**
|
|
402
|
-
* Shared payload assembler for already-claimed `inbox_entries` rows.
|
|
403
|
-
*
|
|
404
|
-
* Both the debug `GET /inbox` path (`pollInbox`) and the WS push path
|
|
405
|
-
* (`claimAndBuildForPush`) call this with rows they have just `UPDATE`d to
|
|
406
|
-
* `status='delivered'`. Keeping the silent-context bundling in one place is
|
|
407
|
-
* the only way to keep the two paths from drifting (proposal
|
|
408
|
-
* hub-inbox-ws-data-plane §3.2 risk #1).
|
|
409
|
-
*
|
|
410
|
-
* Steps:
|
|
411
|
-
* 1. Sort by `createdAt` ASC (PG `RETURNING` does not guarantee order).
|
|
412
|
-
* 2. For each trigger, collect silent context & bulk-ack stale silent rows.
|
|
413
|
-
* 3. Fetch the trigger messages.
|
|
414
|
-
* 4. Build wire payloads via the single dispatcher.
|
|
415
|
-
*
|
|
416
|
-
* Returns `[]` if `claimed` is empty.
|
|
417
|
-
*/
|
|
418
|
-
async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
|
|
419
|
-
if (claimed.length === 0) return [];
|
|
420
|
-
claimed.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
421
|
-
const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
|
|
422
|
-
const messageIds = claimed.map((e) => e.messageId);
|
|
423
|
-
const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
|
|
424
|
-
const msgMap = new Map(msgs.map((m) => [m.id, m]));
|
|
425
|
-
const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
|
|
426
|
-
const msg = msgMap.get(entry.messageId);
|
|
427
|
-
if (!msg) throw new Error(`Unexpected: message ${entry.messageId} not found`);
|
|
428
|
-
return {
|
|
429
|
-
entryChatId: entry.chatId,
|
|
430
|
-
precedingMessages: precedingByEntryId.get(entry.id) ?? [],
|
|
431
|
-
message: {
|
|
432
|
-
id: msg.id,
|
|
433
|
-
chatId: msg.chatId,
|
|
434
|
-
senderId: msg.senderId,
|
|
435
|
-
format: msg.format,
|
|
436
|
-
content: msg.content,
|
|
437
|
-
metadata: msg.metadata,
|
|
438
|
-
replyToInbox: msg.replyToInbox,
|
|
439
|
-
replyToChat: msg.replyToChat,
|
|
440
|
-
inReplyTo: msg.inReplyTo,
|
|
441
|
-
source: msg.source,
|
|
442
|
-
createdAt: msg.createdAt.toISOString()
|
|
443
|
-
}
|
|
444
|
-
};
|
|
445
|
-
}));
|
|
446
|
-
return claimed.map((entry, idx) => {
|
|
447
|
-
const payload = payloads[idx];
|
|
448
|
-
if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
|
|
449
|
-
return {
|
|
450
|
-
id: entry.id,
|
|
451
|
-
inboxId: entry.inboxId,
|
|
452
|
-
messageId: entry.messageId,
|
|
453
|
-
chatId: entry.chatId,
|
|
454
|
-
status: entry.status,
|
|
455
|
-
retryCount: entry.retryCount,
|
|
456
|
-
createdAt: entry.createdAt.toISOString(),
|
|
457
|
-
deliveredAt: entry.deliveredAt?.toISOString() ?? null,
|
|
458
|
-
ackedAt: entry.ackedAt?.toISOString() ?? null,
|
|
459
|
-
message: payload
|
|
460
|
-
};
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
/**
|
|
464
|
-
* Realistic upper bound on rows a single NOTIFY references. The unique
|
|
465
|
-
* constraint `(inbox_id, message_id, chat_id)` caps a `(inbox, message)`
|
|
466
|
-
* pair at one row per chatId; the only way to exceed 1 today is the replyTo
|
|
467
|
-
* cross-chat path (`message.ts` writes a second row keyed by the original's
|
|
468
|
-
* `replyToChat`). 8 leaves headroom for any future fan-out variant without
|
|
469
|
-
* requiring a schema change here.
|
|
470
|
-
*/
|
|
471
|
-
const PUSH_CLAIM_BATCH_LIMIT = 8;
|
|
472
|
-
/**
|
|
473
|
-
* WS-push path: atomically claim every pending entry the just-fired
|
|
474
|
-
* `NOTIFY (inboxId:messageId)` references and assemble their wire payloads.
|
|
475
|
-
*
|
|
476
|
-
* Returns `[]` if no row matches — benign race with another server instance
|
|
477
|
-
* (or the debug `GET /inbox` endpoint) that already claimed the entry.
|
|
478
|
-
* NOTIFY is fire-and-forget (proposal §3.2).
|
|
479
|
-
*
|
|
480
|
-
* Why an array, not a single row: `sendMessage` can write **two** rows for
|
|
481
|
-
* the same `(inbox, messageId)` pair when the recipient is both a chat
|
|
482
|
-
* participant and the `replyToInbox` of an earlier message — the unique key
|
|
483
|
-
* is `(inbox_id, message_id, chat_id)`, so the rows differ by chatId. The
|
|
484
|
-
* old `LIMIT 1` shape would only push the first; the second sat `pending`
|
|
485
|
-
* until reconnect. Aligning with `pollInboxInner`'s `LIMIT N` shape closes
|
|
486
|
-
* that gap and keeps push/poll behaviour interchangeable.
|
|
487
|
-
*/
|
|
488
|
-
async function claimAndBuildForPush(db, inboxId, messageId) {
|
|
489
|
-
return withSpan("inbox.deliver.push", {
|
|
490
|
-
"inbox.id": inboxId,
|
|
491
|
-
"message.id": messageId
|
|
492
|
-
}, () => db.transaction(async (tx) => {
|
|
493
|
-
const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.messageId, messageId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(PUSH_CLAIM_BATCH_LIMIT).for("update", { skipLocked: true });
|
|
494
|
-
return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
|
|
495
|
-
status: "delivered",
|
|
496
|
-
deliveredAt: /* @__PURE__ */ new Date()
|
|
497
|
-
}).where(inArray(inboxEntries.id, targetIds)).returning());
|
|
498
|
-
}));
|
|
499
|
-
}
|
|
500
|
-
/**
|
|
501
|
-
* WS-push backlog path: on agent rebind (or once an in-flight slot frees up
|
|
502
|
-
* after an ack), drain up to `limit` pending `notify=true` entries oldest-
|
|
503
|
-
* first and assemble wire payloads. Identical claim shape to `pollInbox` —
|
|
504
|
-
* they are intentionally interchangeable so a hot-path bug fixed in one
|
|
505
|
-
* shows up in the other (proposal §3.3 / §3.5).
|
|
506
|
-
*/
|
|
507
|
-
async function claimBacklogForPush(db, inboxId, limit) {
|
|
508
|
-
return withSpan("inbox.deliver.backlog", {
|
|
509
|
-
"inbox.id": inboxId,
|
|
510
|
-
"inbox.backlog.limit": limit
|
|
511
|
-
}, () => pollInboxInner(db, inboxId, limit));
|
|
512
|
-
}
|
|
513
|
-
/**
|
|
514
|
-
* Per claimed trigger: SELECT silent (notify=false) pending rows in the same
|
|
515
|
-
* chat that occurred between the previous trigger in this batch (or beginning
|
|
516
|
-
* of time) and this trigger, capped by `PRECEDING_CONTEXT_MAX_ENTRIES` and
|
|
517
|
-
* `PRECEDING_CONTEXT_WINDOW_SECONDS`. Returned messages are oldest-first.
|
|
518
|
-
*
|
|
519
|
-
* Side effect: bulk-ack ALL silent pending rows in each chat with
|
|
520
|
-
* createdAt < latest_trigger.createdAt — including ones that fell outside
|
|
521
|
-
* the window/cap. Otherwise stale silent rows would accumulate and re-load
|
|
522
|
-
* on every poll.
|
|
523
|
-
*/
|
|
524
|
-
async function collectPrecedingContext(tx, inboxId, triggers) {
|
|
525
|
-
const result = /* @__PURE__ */ new Map();
|
|
526
|
-
const byChat = /* @__PURE__ */ new Map();
|
|
527
|
-
for (const t of triggers) {
|
|
528
|
-
if (t.chatId === null) continue;
|
|
529
|
-
const list = byChat.get(t.chatId) ?? [];
|
|
530
|
-
list.push(t);
|
|
531
|
-
byChat.set(t.chatId, list);
|
|
532
|
-
}
|
|
533
|
-
for (const [chatId, chatTriggers] of byChat) {
|
|
534
|
-
chatTriggers.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
535
|
-
let prevCreatedAt = null;
|
|
536
|
-
for (const trigger of chatTriggers) {
|
|
537
|
-
const preceding = (await tx.select({
|
|
538
|
-
messageId: messages.id,
|
|
539
|
-
senderId: messages.senderId,
|
|
540
|
-
format: messages.format,
|
|
541
|
-
content: messages.content,
|
|
542
|
-
metadata: messages.metadata,
|
|
543
|
-
createdAt: messages.createdAt
|
|
544
|
-
}).from(inboxEntries).innerJoin(messages, eq(messages.id, inboxEntries.messageId)).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, trigger.createdAt), prevCreatedAt === null ? void 0 : gt(inboxEntries.createdAt, prevCreatedAt), sql`${inboxEntries.createdAt} > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})`)).orderBy(desc(messages.createdAt)).limit(50).for("update", {
|
|
545
|
-
of: inboxEntries,
|
|
546
|
-
skipLocked: true
|
|
547
|
-
})).map((r) => ({
|
|
548
|
-
id: r.messageId,
|
|
549
|
-
senderId: r.senderId,
|
|
550
|
-
format: r.format,
|
|
551
|
-
content: r.content,
|
|
552
|
-
metadata: r.metadata ?? {},
|
|
553
|
-
createdAt: r.createdAt.toISOString()
|
|
554
|
-
})).reverse();
|
|
555
|
-
result.set(trigger.id, preceding);
|
|
556
|
-
prevCreatedAt = trigger.createdAt;
|
|
557
|
-
}
|
|
558
|
-
const latestTrigger = chatTriggers[chatTriggers.length - 1];
|
|
559
|
-
if (latestTrigger) await tx.update(inboxEntries).set({
|
|
560
|
-
status: "acked",
|
|
561
|
-
ackedAt: /* @__PURE__ */ new Date()
|
|
562
|
-
}).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, latestTrigger.createdAt)));
|
|
563
|
-
}
|
|
564
|
-
return result;
|
|
565
|
-
}
|
|
566
|
-
/**
|
|
567
|
-
* Ack a delivered entry from the WS data plane, scoped to the inboxes the
|
|
568
|
-
* connected socket has bound. Returns the acked row on success, `null` if no
|
|
569
|
-
* row matches — a benign outcome the caller should ignore (the entry may
|
|
570
|
-
* have already been acked, timed out, or never belonged to this socket).
|
|
571
|
-
*
|
|
572
|
-
* Trusts only the `inboxId` set the connected socket has bound (no `inboxId`
|
|
573
|
-
* on the wire), and short-circuits on an empty `inboxIds`.
|
|
574
|
-
*/
|
|
575
|
-
async function ackEntryByIdForBoundAgents(db, entryId, inboxIds) {
|
|
576
|
-
if (inboxIds.length === 0) return null;
|
|
577
|
-
return withSpan("inbox.ack.ws", { [FIRST_TREE_HUB_ATTR.INBOX_ENTRY_ID]: String(entryId) }, async () => {
|
|
578
|
-
const [entry] = await db.update(inboxEntries).set({
|
|
579
|
-
status: "acked",
|
|
580
|
-
ackedAt: /* @__PURE__ */ new Date()
|
|
581
|
-
}).where(and(eq(inboxEntries.id, entryId), inArray(inboxEntries.inboxId, inboxIds), eq(inboxEntries.status, "delivered"))).returning();
|
|
582
|
-
return entry ?? null;
|
|
583
|
-
});
|
|
584
|
-
}
|
|
585
|
-
async function resetTimedOutEntries(db, timeoutSeconds = DEFAULT_INBOX_TIMEOUT_SECONDS, maxRetries = DEFAULT_MAX_RETRY_COUNT) {
|
|
586
|
-
const reset = await db.update(inboxEntries).set({
|
|
587
|
-
status: "pending",
|
|
588
|
-
retryCount: sql`${inboxEntries.retryCount} + 1`
|
|
589
|
-
}).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, lt(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
|
|
590
|
-
const failed = await db.update(inboxEntries).set({ status: "failed" }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, gte(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
|
|
591
|
-
return {
|
|
592
|
-
reset: reset.length,
|
|
593
|
-
failed: failed.length
|
|
594
|
-
};
|
|
595
|
-
}
|
|
596
|
-
/** Default age (30 days) past which silent rows that no notify-true delivery
|
|
597
|
-
* ever picked up are physically deleted. */
|
|
598
|
-
const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
|
|
599
|
-
/**
|
|
600
|
-
* Garbage-collect silent inbox rows so the table doesn't grow forever in
|
|
601
|
-
* chats where a `mention_only` agent is never @mentioned.
|
|
602
|
-
*
|
|
603
|
-
* Two cleanup paths:
|
|
604
|
-
*
|
|
605
|
-
* 1. `notify=false AND status='acked'` of any age — these are fully
|
|
606
|
-
* consumed (either bundled into a previous trigger or aged out via the
|
|
607
|
-
* bulk-ack in `collectPrecedingContext`); keep them only as long as
|
|
608
|
-
* the corresponding message rows we link to. The unique constraint
|
|
609
|
-
* `(inbox_id, message_id, chat_id)` means leaving them around blocks
|
|
610
|
-
* legitimate retries with the same key.
|
|
611
|
-
*
|
|
612
|
-
* 2. `notify=false AND status='pending' AND createdAt < NOW() - maxAge` —
|
|
613
|
-
* stale silent rows that no trigger ever caught up with. After 30
|
|
614
|
-
* days they're useless as preceding context (the @mention almost
|
|
615
|
-
* certainly already happened or the chat went dormant).
|
|
616
|
-
*
|
|
617
|
-
* Returns the number of rows deleted in each bucket so the background task
|
|
618
|
-
* can log meaningful counts.
|
|
619
|
-
*/
|
|
620
|
-
async function pruneStaleSilentEntries(db, maxAgeSeconds = SILENT_ROW_GC_MAX_AGE_SECONDS) {
|
|
621
|
-
const ackedDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "acked"))).returning({ id: inboxEntries.id });
|
|
622
|
-
const stalePendingDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "pending"), sql`${inboxEntries.createdAt} < NOW() - make_interval(secs => ${maxAgeSeconds})`)).returning({ id: inboxEntries.id });
|
|
623
|
-
return {
|
|
624
|
-
ackedDeleted: ackedDeleted.length,
|
|
625
|
-
stalePendingDeleted: stalePendingDeleted.length
|
|
626
|
-
};
|
|
627
|
-
}
|
|
628
|
-
/** Per-session state snapshot. One row per (agent, chat) pair, upserted on each session:state message. */
|
|
629
|
-
const agentChatSessions = pgTable("agent_chat_sessions", {
|
|
630
|
-
agentId: text("agent_id").notNull().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
631
|
-
chatId: text("chat_id").notNull().references(() => chats.id, { onDelete: "cascade" }),
|
|
632
|
-
state: text("state").notNull(),
|
|
633
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
634
|
-
}, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
|
|
635
|
-
/**
|
|
636
|
-
* Per-(chat, agent) user state — independent from membership structure.
|
|
637
|
-
*
|
|
638
|
-
* This is the third layer of the chat data model: while `chats` owns
|
|
639
|
-
* the entity and `chat_membership` owns the structural relation
|
|
640
|
-
* (who can speak, who watches), this table owns the user's private
|
|
641
|
-
* state about a chat. The reason it lives apart: structural changes
|
|
642
|
-
* (speaker ↔ watcher, manager rebind, recompute) must never overwrite
|
|
643
|
-
* user-private state — physical separation makes that an invariant
|
|
644
|
-
* rather than a service-layer discipline.
|
|
645
|
-
*
|
|
646
|
-
* Columns evolve incrementally as new per-user state is needed.
|
|
647
|
-
* Currently:
|
|
648
|
-
* - `last_read_at`, `unread_mention_count` — seeded by PR-A from
|
|
649
|
-
* the legacy `chat_participants` / `chat_subscriptions` columns.
|
|
650
|
-
* - `engagement_status` — added in 0040; per-(chat, user) view
|
|
651
|
-
* state (active / archived / deleted). Auto-revives archived →
|
|
652
|
-
* active on new message; deleted is sticky (only the user can
|
|
653
|
-
* restore from the chat detail page).
|
|
654
|
-
*
|
|
655
|
-
* Future fields slated for this table: pinned, mute_until, draft,
|
|
656
|
-
* custom_title, last_seen_at — each as a separate change.
|
|
657
|
-
*
|
|
658
|
-
* Rows are lazy-upserted on first user write (markRead / mention
|
|
659
|
-
* counter bump / engagement transition). Reads use COALESCE for
|
|
660
|
-
* defaults so callers see `'active'` etc. even when no row exists.
|
|
661
|
-
* Service-layer integrity (no FK / CHECK / trigger).
|
|
662
|
-
*
|
|
663
|
-
* See proposals/chat-data-model-restructure.20260512.md §8.6.
|
|
664
|
-
*/
|
|
665
|
-
const chatUserState = pgTable("chat_user_state", {
|
|
666
|
-
chatId: text("chat_id").notNull(),
|
|
667
|
-
agentId: text("agent_id").notNull(),
|
|
668
|
-
lastReadAt: timestamp("last_read_at", { withTimezone: true }),
|
|
669
|
-
unreadMentionCount: integer("unread_mention_count").notNull().default(0),
|
|
670
|
-
engagementStatus: text("engagement_status").notNull().default("active")
|
|
671
|
-
}, (table) => [
|
|
672
|
-
primaryKey({ columns: [table.chatId, table.agentId] }),
|
|
673
|
-
index("idx_user_state_agent").on(table.agentId),
|
|
674
|
-
index("idx_user_state_unread").on(table.agentId).where(sql`unread_mention_count > 0`)
|
|
675
|
-
]);
|
|
676
|
-
/** Agent presence and runtime state. Tracked via WebSocket connections; stale entries are cleaned up using server_instances heartbeat. */
|
|
677
|
-
const agentPresence = pgTable("agent_presence", {
|
|
678
|
-
agentId: text("agent_id").primaryKey().references(() => agents.uuid, { onDelete: "cascade" }),
|
|
679
|
-
status: text("status").notNull().default("offline"),
|
|
680
|
-
instanceId: text("instance_id"),
|
|
681
|
-
connectedAt: timestamp("connected_at", { withTimezone: true }),
|
|
682
|
-
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
|
|
683
|
-
clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
|
|
684
|
-
runtimeType: text("runtime_type"),
|
|
685
|
-
runtimeVersion: text("runtime_version"),
|
|
686
|
-
runtimeState: text("runtime_state"),
|
|
687
|
-
activeSessions: integer("active_sessions"),
|
|
688
|
-
totalSessions: integer("total_sessions"),
|
|
689
|
-
runtimeUpdatedAt: timestamp("runtime_updated_at", { withTimezone: true })
|
|
690
|
-
});
|
|
691
|
-
/**
|
|
692
|
-
* Shared access-control primitives. Most route-level gating now lives in
|
|
693
|
-
* `scope/require-*.ts` — this module is reduced to two helpers that need
|
|
694
|
-
* SQL building blocks reused across routes and tests:
|
|
695
|
-
*
|
|
696
|
-
* - `agentVisibilityCondition` — WHERE clause for "agents visible to a
|
|
697
|
-
* member" (org-visible OR managerId = the caller's member). Composed
|
|
698
|
-
* into list queries that already select from `agents`.
|
|
699
|
-
* - `listAgentsManagedByUser` — cross-org list of agents personally
|
|
700
|
-
* managed by a user; powers the CLI `agent list --remote` view.
|
|
701
|
-
*
|
|
702
|
-
* Visibility is the same for all roles — admin sees the same set as a
|
|
703
|
-
* regular member. Admin privilege is expressed through manageability
|
|
704
|
-
* (`requireAgentAccess(..., "manage")`), not visibility.
|
|
705
|
-
*/
|
|
706
|
-
/**
|
|
707
|
-
* SQL WHERE conditions for agents visible to a member.
|
|
708
|
-
* target org + not deleted + (organization-visible OR managerId = caller's member)
|
|
709
|
-
*/
|
|
710
|
-
function agentVisibilityCondition(orgId, memberId) {
|
|
711
|
-
return and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId)));
|
|
712
|
-
}
|
|
713
|
-
/**
|
|
714
|
-
* Cross-org listing helper for "agents I personally manage". Used by the
|
|
715
|
-
* CLI `agent list --remote` view — JOINs `agents → members.id` and filters
|
|
716
|
-
* by `members.user_id`.
|
|
717
|
-
*/
|
|
718
|
-
async function listAgentsManagedByUser(db, userId) {
|
|
719
|
-
return db.select({
|
|
720
|
-
uuid: agents.uuid,
|
|
721
|
-
name: agents.name,
|
|
722
|
-
displayName: agents.displayName,
|
|
723
|
-
type: agents.type,
|
|
724
|
-
organizationId: agents.organizationId,
|
|
725
|
-
inboxId: agents.inboxId,
|
|
726
|
-
visibility: agents.visibility,
|
|
727
|
-
runtimeProvider: agents.runtimeProvider,
|
|
728
|
-
clientId: agents.clientId,
|
|
729
|
-
avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
|
|
730
|
-
userAvatarUrl: users.avatarUrl
|
|
731
|
-
}).from(agents).innerJoin(members, eq(agents.managerId, members.id)).leftJoin(users, eq(users.id, members.userId)).where(and(eq(members.userId, userId), eq(members.status, "active"), ne(agents.status, AGENT_STATUSES.DELETED)));
|
|
732
|
-
}
|
|
733
|
-
/**
|
|
734
|
-
* Resolve the UUID of the "default" organization. Internal use only —
|
|
735
|
-
* webhooks, fallbacks, etc. The HTTP API layer no longer falls back to
|
|
736
|
-
* the JWT default org.
|
|
737
|
-
*/
|
|
738
|
-
async function resolveDefaultOrgId(db) {
|
|
739
|
-
const [org] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, "default")).limit(1);
|
|
740
|
-
if (!org) throw new Error("Default organization not found. Ensure the server has started and ensureDefaultOrganization() ran.");
|
|
741
|
-
return org.id;
|
|
742
|
-
}
|
|
743
|
-
async function getOrganization(db, id) {
|
|
744
|
-
const [org] = await db.select().from(organizations).where(eq(organizations.id, id)).limit(1);
|
|
745
|
-
if (!org) throw new NotFoundError(`Organization "${id}" not found`);
|
|
746
|
-
return org;
|
|
747
|
-
}
|
|
748
|
-
async function updateOrganization(db, id, data) {
|
|
749
|
-
const updates = { updatedAt: /* @__PURE__ */ new Date() };
|
|
750
|
-
if (data.name !== void 0) updates.name = data.name;
|
|
751
|
-
if (data.displayName !== void 0) updates.displayName = data.displayName;
|
|
752
|
-
if (data.maxAgents !== void 0) updates.maxAgents = data.maxAgents;
|
|
753
|
-
if (data.maxMessagesPerMinute !== void 0) updates.maxMessagesPerMinute = data.maxMessagesPerMinute;
|
|
754
|
-
if (data.features !== void 0) updates.features = data.features;
|
|
755
|
-
try {
|
|
756
|
-
const [org] = await db.update(organizations).set(updates).where(eq(organizations.id, id)).returning();
|
|
757
|
-
if (!org) throw new NotFoundError(`Organization "${id}" not found`);
|
|
758
|
-
return org;
|
|
759
|
-
} catch (err) {
|
|
760
|
-
if ((err?.code ?? err?.cause?.code ?? "") === "23505") throw new ConflictError(`Organization name "${data.name}" is already taken`);
|
|
761
|
-
throw err;
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
/**
|
|
765
|
-
* Ensure the default organization exists. Called on server startup.
|
|
766
|
-
* Uses a fixed UUID for the default org to ensure idempotency.
|
|
767
|
-
*/
|
|
768
|
-
async function ensureDefaultOrganization(db) {
|
|
769
|
-
const [existing] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, "default")).limit(1);
|
|
770
|
-
if (existing) return existing;
|
|
771
|
-
const id = uuidv7();
|
|
772
|
-
const [org] = await db.insert(organizations).values({
|
|
773
|
-
id,
|
|
774
|
-
name: "default",
|
|
775
|
-
displayName: "Default Organization"
|
|
776
|
-
}).onConflictDoNothing().returning();
|
|
777
|
-
return org ?? existing;
|
|
778
|
-
}
|
|
779
|
-
/**
|
|
780
|
-
* Single source of truth for writing speaker rows into `chat_membership`.
|
|
781
|
-
*
|
|
782
|
-
* **This is the ONLY place in the codebase that may INSERT speaker rows
|
|
783
|
-
* (access_mode = 'speaker') into `chat_membership`.** Do not call
|
|
784
|
-
* `tx.insert(chatMembership)` with `accessMode: 'speaker'` from anywhere
|
|
785
|
-
* else. The original bug (docs/chat-participant-mode-fix-design.md §1.1)
|
|
786
|
-
* was caused by mode-derivation logic scattered across ten insert sites,
|
|
787
|
-
* several of which violated `group + non-human ⇒ mention_only`.
|
|
788
|
-
* Re-introducing a second writer reopens that hole — please don't.
|
|
789
|
-
*
|
|
790
|
-
* Watcher rows (access_mode = 'watcher') are written from
|
|
791
|
-
* `services/watcher.ts::recomputeChatWatchers` via raw SQL; they don't
|
|
792
|
-
* go through this service because the mode rule is `full` by construction
|
|
793
|
-
* for watchers (they receive but don't fan out).
|
|
794
|
-
*
|
|
795
|
-
* Test fixtures under `src/__tests__/` that deliberately seed pathological
|
|
796
|
-
* rows (e.g. cross-org pollution tests) may bypass this rule; they are
|
|
797
|
-
* setting up "what bad data looks like" rather than exercising the
|
|
798
|
-
* production write path.
|
|
799
|
-
*
|
|
800
|
-
* All callers that need to add a participant — `createChat`, `addParticipant`,
|
|
801
|
-
* `ensureParticipant`, `joinChat`, `createMeChat`, `addMeChatParticipants`,
|
|
802
|
-
* `findOrCreateDirectChat`, `findOrCreateChatForChannel`, `joinAsParticipant`,
|
|
803
|
-
* … — go through `addChatParticipants`. The function performs ONE round-trip
|
|
804
|
-
* to read `chats.type` + every involved `agents.type`, runs each row through
|
|
805
|
-
* `defaultParticipantMode`, and inserts the result. `agents.type` is parsed
|
|
806
|
-
* through the shared `agentTypeSchema` so schema drift surfaces loudly
|
|
807
|
-
* instead of silently coercing to a default.
|
|
808
|
-
*
|
|
809
|
-
* `changeChatType` complements it on the type-flip path: when a `direct`
|
|
810
|
-
* chat is being upgraded to `group` by the very next participant insert, the
|
|
811
|
-
* existing non-human speakers must be re-graded to `mention_only`. Callers
|
|
812
|
-
* that trigger an upgrade are expected to invoke `changeChatType` BEFORE
|
|
813
|
-
* `addChatParticipants`, inside the same transaction, so the new row picks
|
|
814
|
-
* up the post-upgrade `chats.type` and existing rows get re-graded together.
|
|
815
|
-
*
|
|
816
|
-
* Read state (`last_read_at` / `unread_mention_count`) is no longer carried
|
|
817
|
-
* here: per the chat-data-model-restructure (proposal §8), it lives in a
|
|
818
|
-
* structurally separate `chat_user_state` table whose rows survive
|
|
819
|
-
* access_mode transitions untouched. A watcher → speaker promotion just
|
|
820
|
-
* UPDATEs `chat_membership.access_mode`; the `chat_user_state` row (if any)
|
|
821
|
-
* is unaffected — no state-carry transaction needed.
|
|
822
|
-
*/
|
|
823
|
-
/**
|
|
824
|
-
* Insert speaker rows whose `mode` is derived from `(chats.type, agents.type)`.
|
|
825
|
-
*
|
|
826
|
-
* Reads:
|
|
827
|
-
* - `chats.type` for the target chat (NotFoundError on missing)
|
|
828
|
-
* - `agents.type` for every requested participant (BadRequestError on missing)
|
|
829
|
-
*
|
|
830
|
-
* Mode derivation:
|
|
831
|
-
* - for each row, `peerAgentTypes` is the type of every OTHER participant
|
|
832
|
-
* being inserted in the same call PLUS every EXISTING speaker of
|
|
833
|
-
* the chat. This matters only for `direct` chats; the helper ignores
|
|
834
|
-
* it for `group`.
|
|
835
|
-
*
|
|
836
|
-
* Writes one INSERT (multi-row) per call.
|
|
837
|
-
*
|
|
838
|
-
* No watcher / audience-cache side effects — the caller owns those, since
|
|
839
|
-
* different entrypoints have different surrounding work (watcher recompute,
|
|
840
|
-
* audience invalidation). Keeping this module side-effect-free makes it
|
|
841
|
-
* testable from any tx context.
|
|
842
|
-
*/
|
|
843
|
-
async function addChatParticipants(tx, chatId, participants, options = {}) {
|
|
844
|
-
if (participants.length === 0) return;
|
|
845
|
-
const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
846
|
-
if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
847
|
-
if (chat.type !== "direct" && chat.type !== "group") throw new Error(`Unexpected chat type "${chat.type}" for chat "${chatId}"`);
|
|
848
|
-
const chatType = chat.type;
|
|
849
|
-
const agentIds = participants.map((p) => p.agentId);
|
|
850
|
-
const agentRows = await tx.select({
|
|
851
|
-
uuid: agents.uuid,
|
|
852
|
-
type: agents.type
|
|
853
|
-
}).from(agents).where(inArray(agents.uuid, agentIds));
|
|
854
|
-
const agentTypeById = /* @__PURE__ */ new Map();
|
|
855
|
-
for (const row of agentRows) agentTypeById.set(row.uuid, row.type);
|
|
856
|
-
const missing = agentIds.filter((id) => !agentTypeById.has(id));
|
|
857
|
-
if (missing.length > 0) throw new BadRequestError(`Agents not found: ${missing.join(", ")}`);
|
|
858
|
-
if (options.assertHuman) {
|
|
859
|
-
const nonHuman = agentRows.filter((a) => a.type !== "human");
|
|
860
|
-
if (nonHuman.length > 0) throw new BadRequestError(`assertHuman violated: agents must be of type 'human' but got ${nonHuman.map((a) => `${a.uuid}=${a.type}`).join(", ")}`);
|
|
861
|
-
}
|
|
862
|
-
let existingAgentTypes = [];
|
|
863
|
-
if (chatType === "direct") existingAgentTypes = await loadExistingAgentTypes(tx, chatId, new Set(agentIds));
|
|
864
|
-
const rows = participants.map((spec) => {
|
|
865
|
-
const rawAgentType = agentTypeById.get(spec.agentId);
|
|
866
|
-
if (rawAgentType === void 0) throw new Error("Unexpected: agent type lookup unset after presence check");
|
|
867
|
-
const agentType = agentTypeSchema.parse(rawAgentType);
|
|
868
|
-
const peerTypesForRow = chatType === "direct" ? [...existingAgentTypes, ...participants.filter((p) => p.agentId !== spec.agentId).map((p) => agentTypeById.get(p.agentId)).filter((t) => t !== void 0)].map((t) => agentTypeSchema.parse(t)) : [];
|
|
869
|
-
return {
|
|
870
|
-
chatId,
|
|
871
|
-
agentId: spec.agentId,
|
|
872
|
-
role: spec.role ?? "member",
|
|
873
|
-
accessMode: "speaker",
|
|
874
|
-
mode: defaultParticipantMode(chatType, agentType, peerTypesForRow),
|
|
875
|
-
source: "manual"
|
|
876
|
-
};
|
|
877
|
-
});
|
|
878
|
-
const insert = tx.insert(chatMembership).values(rows);
|
|
879
|
-
if (options.upgradeWatcherToSpeaker) await insert.onConflictDoUpdate({
|
|
880
|
-
target: [chatMembership.chatId, chatMembership.agentId],
|
|
881
|
-
set: {
|
|
882
|
-
accessMode: "speaker",
|
|
883
|
-
mode: sqlExcluded("mode"),
|
|
884
|
-
source: "manual"
|
|
885
|
-
}
|
|
886
|
-
});
|
|
887
|
-
else if (options.onConflictDoNothing) await insert.onConflictDoNothing({ target: [chatMembership.chatId, chatMembership.agentId] });
|
|
888
|
-
else await insert;
|
|
889
|
-
}
|
|
890
|
-
/**
|
|
891
|
-
* Drizzle helper: reference `excluded.<col>` in an UPSERT's UPDATE clause.
|
|
892
|
-
* Returned as untyped SQL because Drizzle's type system doesn't model the
|
|
893
|
-
* `excluded` pseudo-row, and we only use it for two simple text columns
|
|
894
|
-
* here. Centralised so callers don't have to import `sql` just for this.
|
|
895
|
-
*/
|
|
896
|
-
function sqlExcluded(column) {
|
|
897
|
-
return sql.raw(`excluded.${column}`);
|
|
898
|
-
}
|
|
899
|
-
async function loadExistingAgentTypes(tx, chatId, excludeAgentIds) {
|
|
900
|
-
return (await tx.select({
|
|
901
|
-
type: agents.type,
|
|
902
|
-
agentId: chatMembership.agentId
|
|
903
|
-
}).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).filter((r) => !excludeAgentIds.has(r.agentId)).map((r) => r.type);
|
|
904
|
-
}
|
|
905
|
-
/**
|
|
906
|
-
* Upgrade `chats.type` from `direct` → `group` AND re-grade every existing
|
|
907
|
-
* non-human speaker to `mention_only`. Idempotent: if `chat.type` is
|
|
908
|
-
* already `group` (or any non-`direct` value), no-op.
|
|
909
|
-
*
|
|
910
|
-
* Callers that are about to insert a 3rd speaker on a `direct` chat
|
|
911
|
-
* invoke this BEFORE `addChatParticipants` so the new row picks up the
|
|
912
|
-
* post-upgrade `chats.type` and the existing rows are re-graded in the
|
|
913
|
-
* same transaction.
|
|
914
|
-
*
|
|
915
|
-
* Re-grade is gated on `access_mode = 'speaker'` — watcher rows already
|
|
916
|
-
* have `mode = 'full'` by construction (recompute writes that literal)
|
|
917
|
-
* and don't participate in fan-out, so they need no touching.
|
|
918
|
-
*/
|
|
919
|
-
async function changeChatType(tx, chatId, newType) {
|
|
920
|
-
const [chat] = await tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
921
|
-
if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
922
|
-
if (chat.type === newType) return;
|
|
923
|
-
if (newType === "group" && chat.type !== "direct") throw new BadRequestError(`Cannot change chat type from "${chat.type}" to "${newType}"`);
|
|
924
|
-
await tx.update(chats).set({
|
|
925
|
-
type: newType,
|
|
926
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
927
|
-
}).where(eq(chats.id, chatId));
|
|
928
|
-
const ids = (await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker"), ne(agents.type, "human")))).map((r) => r.agentId);
|
|
929
|
-
if (ids.length === 0) return;
|
|
930
|
-
await tx.update(chatMembership).set({ mode: "mention_only" }).where(and(eq(chatMembership.chatId, chatId), inArray(chatMembership.agentId, ids), eq(chatMembership.accessMode, "speaker")));
|
|
931
|
-
}
|
|
932
|
-
/**
|
|
933
|
-
* Heuristic for whether an insert about to happen would push the chat past
|
|
934
|
-
* the direct → group threshold. Pure helper so callers can decide whether
|
|
935
|
-
* to call `changeChatType` before `addChatParticipants` without re-deriving
|
|
936
|
-
* the rule locally.
|
|
937
|
-
*/
|
|
938
|
-
function wouldUpgradeToGroup(currentSpeakerCount, newSpeakerCount) {
|
|
939
|
-
return currentSpeakerCount + newSpeakerCount >= 3;
|
|
940
|
-
}
|
|
941
|
-
/**
|
|
942
|
-
* Chat-first workspace — membership lifecycle helpers.
|
|
943
|
-
*
|
|
944
|
-
* After the chat data model restructure (see
|
|
945
|
-
* proposals/chat-data-model-restructure.20260512.md §8), "watcher" is
|
|
946
|
-
* just an `access_mode` value on `chat_membership`, not a separate
|
|
947
|
-
* table. Speaker ↔ watcher transitions are a single-table UPDATE;
|
|
948
|
-
* read state lives in `chat_user_state` and is structurally isolated
|
|
949
|
-
* from access_mode changes — there is no state-carry path anymore.
|
|
950
|
-
*
|
|
951
|
-
* Two distinct kinds of operation live here:
|
|
952
|
-
*
|
|
953
|
-
* 1. Set rebuilds (`recompute*`). Idempotent set-based
|
|
954
|
-
* recomputations driven by lifecycle events (chat created,
|
|
955
|
-
* participant added/removed, member status flipped, agent
|
|
956
|
-
* rebind, etc.). Strict invariant: ONLY INSERT or DELETE rows
|
|
957
|
-
* where access_mode = 'watcher'. NEVER UPDATE any row with
|
|
958
|
-
* access_mode = 'speaker' — the user's own join/leave decision
|
|
959
|
-
* must not be overwritten by ops paths.
|
|
960
|
-
*
|
|
961
|
-
* 2. Speaker ↔ watcher transitions (`joinAsParticipant`,
|
|
962
|
-
* `leaveAsParticipant`). Single-table UPDATE on
|
|
963
|
-
* `chat_membership.access_mode`; `chat_user_state` rows for
|
|
964
|
-
* the (chat, agent) pair are not touched. Per §11.4 default,
|
|
965
|
-
* a fully-detached user keeps their `chat_user_state` row
|
|
966
|
-
* (read state remembered for re-add).
|
|
967
|
-
*
|
|
968
|
-
* File name preserved across the refactor for diff readability; may
|
|
969
|
-
* be renamed in a follow-up. Public function names preserved too —
|
|
970
|
-
* `recomputeChatWatchers` still describes what it does (recomputes
|
|
971
|
-
* the watcher rows), so the rename to `recomputeChatMembership`
|
|
972
|
-
* would obscure rather than clarify.
|
|
973
|
-
*/
|
|
974
|
-
/**
|
|
975
|
-
* Recompute watcher rows for ONE chat. For every active member who:
|
|
976
|
-
* - manages a non-human agent that speaks in the chat, AND
|
|
977
|
-
* - whose own human agent is NOT a speaker in the chat
|
|
978
|
-
* a `(chat_id, member.agent_id)` watcher row is upserted.
|
|
979
|
-
*
|
|
980
|
-
* Strict invariant: only writes rows with access_mode = 'watcher';
|
|
981
|
-
* never updates or deletes any access_mode = 'speaker' row. The
|
|
982
|
-
* ON CONFLICT DO NOTHING clause guarantees that if a (chat, agent)
|
|
983
|
-
* row already exists as a speaker (the manager joined as a real
|
|
984
|
-
* participant themselves), we leave it alone.
|
|
985
|
-
*
|
|
986
|
-
* Watchers whose anchoring condition no longer holds (manager left,
|
|
987
|
-
* the managed agent was removed from the chat, the manager joined as
|
|
988
|
-
* a speaker themselves) are deleted — also gated on access_mode =
|
|
989
|
-
* 'watcher'.
|
|
990
|
-
*
|
|
991
|
-
* Idempotent: safe to call multiple times for the same chat.
|
|
992
|
-
*/
|
|
993
|
-
async function recomputeChatWatchers(db, chatId) {
|
|
994
|
-
await db.execute(sql`
|
|
995
|
-
INSERT INTO chat_membership
|
|
996
|
-
(chat_id, agent_id, role, access_mode, mode, source, joined_at)
|
|
997
|
-
SELECT DISTINCT cm.chat_id, m.agent_id, 'member', 'watcher', 'full', 'auto_manager', now()
|
|
998
|
-
FROM chat_membership cm
|
|
999
|
-
JOIN agents a ON a.uuid = cm.agent_id
|
|
1000
|
-
JOIN members m ON m.id = a.manager_id
|
|
1001
|
-
WHERE cm.chat_id = ${chatId}
|
|
1002
|
-
AND cm.access_mode = 'speaker'
|
|
1003
|
-
AND m.status = 'active'
|
|
1004
|
-
AND a.type <> 'human'
|
|
1005
|
-
AND NOT EXISTS (
|
|
1006
|
-
SELECT 1 FROM chat_membership cm2
|
|
1007
|
-
WHERE cm2.chat_id = cm.chat_id
|
|
1008
|
-
AND cm2.agent_id = m.agent_id
|
|
1009
|
-
)
|
|
1010
|
-
ON CONFLICT (chat_id, agent_id) DO NOTHING
|
|
1011
|
-
`);
|
|
1012
|
-
await db.execute(sql`
|
|
1013
|
-
DELETE FROM chat_membership cm
|
|
1014
|
-
WHERE cm.chat_id = ${chatId}
|
|
1015
|
-
AND cm.access_mode = 'watcher'
|
|
1016
|
-
AND NOT EXISTS (
|
|
1017
|
-
SELECT 1
|
|
1018
|
-
FROM chat_membership speakers
|
|
1019
|
-
JOIN agents a ON a.uuid = speakers.agent_id
|
|
1020
|
-
JOIN members m ON m.id = a.manager_id
|
|
1021
|
-
WHERE speakers.chat_id = cm.chat_id
|
|
1022
|
-
AND speakers.access_mode = 'speaker'
|
|
1023
|
-
AND m.agent_id = cm.agent_id
|
|
1024
|
-
AND m.status = 'active'
|
|
1025
|
-
AND a.type <> 'human'
|
|
1026
|
-
)
|
|
1027
|
-
`);
|
|
1028
|
-
}
|
|
1029
|
-
/**
|
|
1030
|
-
* Recompute watcher rows touching ONE agent across all chats it
|
|
1031
|
-
* speaks in. Used after `rebindAgent` (manager change) so the new
|
|
1032
|
-
* manager picks up watcher rows and the old manager's are dropped.
|
|
1033
|
-
*/
|
|
1034
|
-
async function recomputeWatchersForAgent(db, agentId) {
|
|
1035
|
-
const chatRows = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker")));
|
|
1036
|
-
for (const { chatId } of chatRows) await recomputeChatWatchers(db, chatId);
|
|
1037
|
-
}
|
|
1038
|
-
/**
|
|
1039
|
-
* Recompute watcher rows touching ONE member across all chats.
|
|
1040
|
-
* Triggered when the member's status flips active ↔ left.
|
|
1041
|
-
*/
|
|
1042
|
-
async function recomputeWatchersForMember(db, memberId) {
|
|
1043
|
-
const rows = await db.selectDistinct({ chatId: chatMembership.chatId }).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.accessMode, "speaker"), eq(agents.managerId, memberId), ne(agents.type, "human")));
|
|
1044
|
-
for (const { chatId } of rows) await recomputeChatWatchers(db, chatId);
|
|
1045
|
-
}
|
|
1046
|
-
/**
|
|
1047
|
-
* Watcher → speaker (or fresh speaker insert).
|
|
1048
|
-
*
|
|
1049
|
-
* 1. SELECT the existing chat_membership row for the (chat, agent) pair.
|
|
1050
|
-
* 2. If already a speaker → no-op (idempotent).
|
|
1051
|
-
* 3. If a watcher row → run the direct→group upgrade rule, then
|
|
1052
|
-
* UPDATE access_mode to 'speaker'.
|
|
1053
|
-
* 4. If no row → run the direct→group upgrade rule, then INSERT a
|
|
1054
|
-
* fresh speaker row.
|
|
1055
|
-
*
|
|
1056
|
-
* Caller is expected to have verified the user is authorised to join
|
|
1057
|
-
* (admin override OR an existing watcher row); this helper does not
|
|
1058
|
-
* gate on visibility.
|
|
1059
|
-
*/
|
|
1060
|
-
async function joinAsParticipant(db, chatId, humanAgentId) {
|
|
1061
|
-
return db.transaction(async (tx) => {
|
|
1062
|
-
const [existing] = await tx.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId))).limit(1);
|
|
1063
|
-
if (existing?.accessMode === "speaker") return {
|
|
1064
|
-
chatId,
|
|
1065
|
-
inserted: false,
|
|
1066
|
-
carried: null
|
|
1067
|
-
};
|
|
1068
|
-
if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
|
|
1069
|
-
await addChatParticipants(tx, chatId, [{
|
|
1070
|
-
agentId: humanAgentId,
|
|
1071
|
-
role: "member"
|
|
1072
|
-
}], {
|
|
1073
|
-
assertHuman: true,
|
|
1074
|
-
upgradeWatcherToSpeaker: true
|
|
1075
|
-
});
|
|
1076
|
-
return {
|
|
1077
|
-
chatId,
|
|
1078
|
-
inserted: !existing,
|
|
1079
|
-
carried: null
|
|
1080
|
-
};
|
|
1081
|
-
});
|
|
1082
|
-
}
|
|
1083
|
-
/**
|
|
1084
|
-
* Speaker → watcher (or fully detach).
|
|
1085
|
-
*
|
|
1086
|
-
* 1. SELECT the existing speaker row; 404 if not present.
|
|
1087
|
-
* 2. Test "still visible": does the user still manage a non-human
|
|
1088
|
-
* agent that remains a speaker in this chat?
|
|
1089
|
-
* - If yes → UPDATE access_mode to 'watcher'.
|
|
1090
|
-
* - If no → DELETE the chat_membership row entirely.
|
|
1091
|
-
* 3. `chat_user_state` row (if any) is preserved either way per
|
|
1092
|
-
* §11.4 default — read state is remembered for re-add.
|
|
1093
|
-
*/
|
|
1094
|
-
async function leaveAsParticipant(db, chatId, humanAgentId) {
|
|
1095
|
-
return db.transaction(async (tx) => {
|
|
1096
|
-
const [existing] = await tx.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId))).limit(1);
|
|
1097
|
-
if (!existing || existing.accessMode !== "speaker") throw new NotFoundError("Not a participant of this chat");
|
|
1098
|
-
const result = await tx.execute(sql`
|
|
1099
|
-
SELECT EXISTS (
|
|
1100
|
-
SELECT 1
|
|
1101
|
-
FROM chat_membership cm
|
|
1102
|
-
JOIN agents a ON a.uuid = cm.agent_id
|
|
1103
|
-
JOIN members m ON m.id = a.manager_id
|
|
1104
|
-
WHERE cm.chat_id = ${chatId}
|
|
1105
|
-
AND cm.access_mode = 'speaker'
|
|
1106
|
-
AND m.agent_id = ${humanAgentId}
|
|
1107
|
-
AND m.status = 'active'
|
|
1108
|
-
AND a.type <> 'human'
|
|
1109
|
-
) AS visible
|
|
1110
|
-
`);
|
|
1111
|
-
if (!Boolean(result[0]?.visible)) {
|
|
1112
|
-
await tx.delete(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId)));
|
|
1113
|
-
return {
|
|
1114
|
-
chatId,
|
|
1115
|
-
membershipKind: null
|
|
1116
|
-
};
|
|
1117
|
-
}
|
|
1118
|
-
await tx.update(chatMembership).set({ accessMode: "watcher" }).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId)));
|
|
1119
|
-
return {
|
|
1120
|
-
chatId,
|
|
1121
|
-
membershipKind: "watching"
|
|
1122
|
-
};
|
|
1123
|
-
});
|
|
1124
|
-
}
|
|
1125
|
-
/**
|
|
1126
|
-
* Resolve the membership row of the human agent for the given chat.
|
|
1127
|
-
* Returns one of: 'participant' (speaker), 'watching' (watcher),
|
|
1128
|
-
* or null (no row).
|
|
1129
|
-
*
|
|
1130
|
-
* Used by `/me/chats/:chatId/join` to refuse a join when the user
|
|
1131
|
-
* has neither a watcher row nor a participant row, and isn't
|
|
1132
|
-
* otherwise authorised (admin in the chat's org).
|
|
1133
|
-
*/
|
|
1134
|
-
async function resolveChatMembership(db, chatId, humanAgentId) {
|
|
1135
|
-
const [row] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, humanAgentId))).limit(1);
|
|
1136
|
-
if (!row) return null;
|
|
1137
|
-
return row.accessMode === "speaker" ? "participant" : "watching";
|
|
1138
|
-
}
|
|
1139
|
-
/**
|
|
1140
|
-
* Used by `/me/chats/:chatId/join`. Throw 409 if already a speaker
|
|
1141
|
-
* (no work to do) and 403 if no row at all (admin override is
|
|
1142
|
-
* resolved at the route layer; this helper only reports the membership
|
|
1143
|
-
* state).
|
|
1144
|
-
*/
|
|
1145
|
-
function ensureCanJoin(membership) {
|
|
1146
|
-
if (membership === "participant") throw new ConflictError("Already a participant in this chat");
|
|
1147
|
-
if (membership === null) throw new ForbiddenError("Not a watcher of this chat — open the chat from your workspace before joining");
|
|
1148
|
-
}
|
|
1149
|
-
/**
|
|
1150
|
-
* Names beginning with `__` are reserved for Hub-internal pseudo agents.
|
|
1151
|
-
* User-facing creation must not be able to squat on them, otherwise
|
|
1152
|
-
* internal traffic could be routed through a real account.
|
|
1153
|
-
*/
|
|
1154
|
-
const RESERVED_AGENT_NAME_PREFIX = "__";
|
|
1155
|
-
/**
|
|
1156
|
-
* Derive the relative URL clients should use to fetch a manager-uploaded
|
|
1157
|
-
* avatar image. Returns `null` when no image is set. Embeds the upload
|
|
1158
|
-
* timestamp as `?v=<epoch>` so a fresh upload busts any browser cache
|
|
1159
|
-
* that may have memoised the previous version.
|
|
1160
|
-
*
|
|
1161
|
-
* Auth: the image route is intentionally public read — the URL leaks no
|
|
1162
|
-
* more than the agent's UUID, which is already required to address it.
|
|
1163
|
-
* Keeping it unauthenticated lets `<img src>` render without bespoke
|
|
1164
|
-
* fetch-and-blob plumbing.
|
|
1165
|
-
*/
|
|
1166
|
-
function agentAvatarImageUrl(uuid, updatedAt) {
|
|
1167
|
-
if (!updatedAt) return null;
|
|
1168
|
-
return `/api/v1/agents/${uuid}/avatar?v=${updatedAt.getTime()}`;
|
|
1169
|
-
}
|
|
1170
|
-
/**
|
|
1171
|
-
* Resolve the public avatar image URL for an agent, considering both the
|
|
1172
|
-
* manager-uploaded image and — for human agents — the user's external
|
|
1173
|
-
* avatar URL (e.g. GitHub `users.avatar_url` injected by OAuth). Returns
|
|
1174
|
-
* `null` when neither source is available; the renderer then falls back
|
|
1175
|
-
* to color + initial.
|
|
1176
|
-
*
|
|
1177
|
-
* Priority: uploaded image > human user's avatar > null. The "upload
|
|
1178
|
-
* wins" rule gives users explicit control: once they upload a custom
|
|
1179
|
-
* avatar for their human agent it always shows, regardless of any later
|
|
1180
|
-
* GitHub avatar change.
|
|
1181
|
-
*/
|
|
1182
|
-
function resolveAvatarImageUrl(args) {
|
|
1183
|
-
const uploaded = agentAvatarImageUrl(args.uuid, args.avatarImageUpdatedAt);
|
|
1184
|
-
if (uploaded) return uploaded;
|
|
1185
|
-
if (args.type === AGENT_TYPES.HUMAN && args.userAvatarUrl) return args.userAvatarUrl;
|
|
1186
|
-
return null;
|
|
1187
|
-
}
|
|
1188
|
-
/**
|
|
1189
|
-
* Look up the external user-avatar URL backing a human agent via the
|
|
1190
|
-
* `members.agent_id → members.user_id → users.avatar_url` path. Returns
|
|
1191
|
-
* `null` for non-human agents or when the user has no avatar URL
|
|
1192
|
-
* captured (e.g. signed in without GitHub OAuth). Used by single-agent
|
|
1193
|
-
* API responses; list endpoints inline the join in their SELECT.
|
|
1194
|
-
*/
|
|
1195
|
-
async function fetchUserAvatarForHumanAgent(db, agent) {
|
|
1196
|
-
if (agent.type !== AGENT_TYPES.HUMAN) return null;
|
|
1197
|
-
const [row] = await db.select({ avatarUrl: users.avatarUrl }).from(members).innerJoin(users, eq(members.userId, users.id)).where(eq(members.agentId, agent.uuid)).limit(1);
|
|
1198
|
-
return row?.avatarUrl ?? null;
|
|
1199
|
-
}
|
|
1200
|
-
/**
|
|
1201
|
-
* True iff `clients.metadata.capabilities` is a non-empty object — i.e. the
|
|
1202
|
-
* client has reported at least one runtime probe result. Used to distinguish
|
|
1203
|
-
* "we don't know what's installed yet" (empty / never reported) from
|
|
1204
|
-
* "client explicitly reports this provider is missing".
|
|
1205
|
-
*/
|
|
1206
|
-
function clientCapabilitiesReported(metadata) {
|
|
1207
|
-
if (!metadata || typeof metadata !== "object") return false;
|
|
1208
|
-
const caps = metadata.capabilities;
|
|
1209
|
-
if (!caps || typeof caps !== "object") return false;
|
|
1210
|
-
return Object.keys(caps).length > 0;
|
|
1211
|
-
}
|
|
1212
|
-
/**
|
|
1213
|
-
* Inspect a `clients.metadata.capabilities` blob (jsonb) for a specific
|
|
1214
|
-
* runtime provider entry. Capabilities live under the `metadata.capabilities`
|
|
1215
|
-
* subkey (Option C); the column is unstructured at the DB layer, so we
|
|
1216
|
-
* defensively narrow before key access.
|
|
1217
|
-
*
|
|
1218
|
-
* "Supports" requires the entry's SDK to be **available** — `state: "ok"` or
|
|
1219
|
-
* `state: "unauthenticated"`. A `missing` or `error` entry is *reported* but
|
|
1220
|
-
* not usable, so we explicitly reject those rather than treating mere key
|
|
1221
|
-
* presence as support. Auth state is left to the user to fix at runtime
|
|
1222
|
-
* (the re-bind dialog surfaces an `unauthenticated` hint).
|
|
1223
|
-
*/
|
|
1224
|
-
function clientSupportsRuntimeProvider(metadata, provider) {
|
|
1225
|
-
if (!metadata || typeof metadata !== "object") return false;
|
|
1226
|
-
const caps = metadata.capabilities;
|
|
1227
|
-
if (!caps || typeof caps !== "object") return false;
|
|
1228
|
-
const entry = caps[provider];
|
|
1229
|
-
if (!entry || typeof entry !== "object") return false;
|
|
1230
|
-
return entry.available === true;
|
|
1231
|
-
}
|
|
1232
|
-
/** Default visibility per agent type. */
|
|
1233
|
-
function defaultVisibility(type) {
|
|
1234
|
-
switch (type) {
|
|
1235
|
-
case "human":
|
|
1236
|
-
case "autonomous_agent": return AGENT_VISIBILITY.ORGANIZATION;
|
|
1237
|
-
case "personal_assistant": return AGENT_VISIBILITY.PRIVATE;
|
|
1238
|
-
default: return AGENT_VISIBILITY.PRIVATE;
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
/**
|
|
1242
|
-
* Resolve + validate the client that will own the new agent.
|
|
1243
|
-
*
|
|
1244
|
-
* Rule (unified-user-token, post-first-bind relaxation):
|
|
1245
|
-
* - Human agents represent the member themselves and have no runtime; a
|
|
1246
|
-
* missing `clientId` is required and the column stays NULL.
|
|
1247
|
-
* - Non-human agents MAY omit `clientId` at creation; the row stays NULL
|
|
1248
|
-
* and is claimed on the first WS bind (see `api/agent/ws-client.ts`).
|
|
1249
|
-
* - When a non-human agent IS created with a `clientId`, the pinned client
|
|
1250
|
-
* must already be owned by the manager's user (Rule R-RUN).
|
|
1251
|
-
*/
|
|
1252
|
-
/**
|
|
1253
|
-
* Check that a client's reported capabilities show the given runtime provider
|
|
1254
|
-
* as **available** (SDK installed, regardless of auth state).
|
|
1255
|
-
*
|
|
1256
|
-
* Tri-state semantics by `clients.metadata.capabilities` shape:
|
|
1257
|
-
* - empty / absent — client hasn't probed yet (newly registered or pre-P2
|
|
1258
|
-
* install). Treat as "unknown" and allow; the in-band repair path
|
|
1259
|
-
* (RUNTIME_PROVIDER_MISMATCH on bind) catches actual incompatibility.
|
|
1260
|
-
* - reported, entry shows `state: ok | unauthenticated` (i.e. `available:
|
|
1261
|
-
* true`) — allow.
|
|
1262
|
-
* - reported, entry missing OR `state: missing | error` — block unless
|
|
1263
|
-
* `force` is set. We deliberately do NOT treat mere key presence as
|
|
1264
|
-
* support: probeCapabilities() always emits an entry per built-in
|
|
1265
|
-
* provider, including `{ state: "missing" }` for absent SDKs.
|
|
1266
|
-
*
|
|
1267
|
-
* Skipped entirely for human agents (no clientId) and when `force` is set
|
|
1268
|
-
* (e.g. operator overrides for an offline client).
|
|
1269
|
-
*/
|
|
1270
|
-
async function ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, options = {}) {
|
|
1271
|
-
if (clientId === null) return;
|
|
1272
|
-
if (options.force) return;
|
|
1273
|
-
const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
1274
|
-
if (!client) return;
|
|
1275
|
-
if (!clientCapabilitiesReported(client.metadata)) return;
|
|
1276
|
-
if (!clientSupportsRuntimeProvider(client.metadata, runtimeProvider)) throw new BadRequestError(`Client "${clientId}" does not have runtime provider "${runtimeProvider}" available. Install the matching SDK on that machine and re-run capability detection, or retry with \`force: true\` if the client is offline / capabilities are stale.`);
|
|
1277
|
-
}
|
|
1278
|
-
async function resolveAgentClient(db, data) {
|
|
1279
|
-
if (data.type === "human") {
|
|
1280
|
-
if (data.clientId) throw new BadRequestError("Human agents cannot be pinned to a client");
|
|
1281
|
-
return null;
|
|
1282
|
-
}
|
|
1283
|
-
if (!data.clientId) return null;
|
|
1284
|
-
const [manager] = await db.select({ userId: members.userId }).from(members).where(eq(members.id, data.managerId)).limit(1);
|
|
1285
|
-
if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
|
|
1286
|
-
const [client] = await db.select({
|
|
1287
|
-
id: clients.id,
|
|
1288
|
-
userId: clients.userId
|
|
1289
|
-
}).from(clients).where(eq(clients.id, data.clientId)).limit(1);
|
|
1290
|
-
if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
|
|
1291
|
-
if (!client.userId) throw new BadRequestError(`Client "${data.clientId}" has not been claimed by a user yet. Have the operator run \`first-tree-hub connect <token>\` on that machine before pinning an agent to it.`);
|
|
1292
|
-
if (client.userId !== manager.userId) throw new ForbiddenError(`Client "${data.clientId}" is not owned by the manager's user — pick a client belonging to that user.`);
|
|
1293
|
-
return client.id;
|
|
1294
|
-
}
|
|
1295
|
-
/**
|
|
1296
|
-
* Validate a `delegateMention` write at the service layer. Two checks:
|
|
1297
|
-
* 1. Target uuid must resolve to an existing agent — dangling references
|
|
1298
|
-
* would silently break webhook delegation at runtime.
|
|
1299
|
-
* 2. Target must belong to the same organization as the source agent —
|
|
1300
|
-
* cross-org delegate links are rejected here at the source so the
|
|
1301
|
-
* database never accumulates dirty rows. The webhook router has a
|
|
1302
|
-
* defense-in-depth check that filters them at fan-out time, but this
|
|
1303
|
-
* keeps the data clean and gives the admin UI an immediate 422 instead
|
|
1304
|
-
* of a silent runtime drop.
|
|
1305
|
-
*
|
|
1306
|
-
* `null` clears the field — handled by the caller; we are only invoked when
|
|
1307
|
-
* the caller wrote a non-null uuid.
|
|
1308
|
-
*/
|
|
1309
|
-
async function validateDelegateMentionTarget(db, targetUuid, sourceOrgId) {
|
|
1310
|
-
const [target] = await db.select({
|
|
1311
|
-
uuid: agents.uuid,
|
|
1312
|
-
organizationId: agents.organizationId
|
|
1313
|
-
}).from(agents).where(eq(agents.uuid, targetUuid)).limit(1);
|
|
1314
|
-
if (!target) throw new BadRequestError(`delegateMention target "${targetUuid}" not found`);
|
|
1315
|
-
if (target.organizationId !== sourceOrgId) throw new BadRequestError("delegateMention target must belong to the same organization as the agent");
|
|
1316
|
-
}
|
|
1317
|
-
/**
|
|
1318
|
-
* Service-layer guard: `delegateMention` is only available for `human` agents.
|
|
1319
|
-
* Mirrors the Web UI in `identity-section.tsx`, which only renders the
|
|
1320
|
-
* delegate-mention selector when `agent.type === "human"`. Without this
|
|
1321
|
-
* server-side check, CLI / Admin API / internal scripts could write
|
|
1322
|
-
* delegateMention onto non-human rows, silently re-enabling the
|
|
1323
|
-
* autonomous-agent-self-mention path that resolveAudience would then fan
|
|
1324
|
-
* out. Called from `createAgent` / `updateAgent` before
|
|
1325
|
-
* `validateDelegateMentionTarget` so a wrong source type fails fast without
|
|
1326
|
-
* the target lookup round-trip.
|
|
1327
|
-
*/
|
|
1328
|
-
function assertDelegateMentionAllowed(sourceType) {
|
|
1329
|
-
if (sourceType !== AGENT_TYPES.HUMAN) throw new BadRequestError("delegateMention can only be set on human agents");
|
|
1330
|
-
}
|
|
1331
|
-
/**
|
|
1332
|
-
* Pick the first admin member in the org for internal system agents. Throws
|
|
1333
|
-
* if the org has no admin — the caller should surface the error so an admin
|
|
1334
|
-
* is created before the system tries to register more agents.
|
|
1335
|
-
*/
|
|
1336
|
-
async function resolveFallbackManagerId(db, orgId) {
|
|
1337
|
-
const [row] = await db.select({ id: members.id }).from(members).where(and(eq(members.organizationId, orgId), eq(members.role, "admin"))).orderBy(members.createdAt).limit(1);
|
|
1338
|
-
if (!row) throw new BadRequestError(`Cannot create agent in organization "${orgId}" — no admin member exists. Create an admin member first (see \`first-tree-hub onboard\`).`);
|
|
1339
|
-
return row.id;
|
|
1340
|
-
}
|
|
1341
|
-
async function createAgent(db, data, options = {}) {
|
|
1342
|
-
const uuid = uuidv7();
|
|
1343
|
-
const name = data.name ?? null;
|
|
1344
|
-
const runtimeProvider = data.runtimeProvider ?? "claude-code";
|
|
1345
|
-
if (name?.startsWith(RESERVED_AGENT_NAME_PREFIX)) throw new BadRequestError(`Agent name "${name}" is reserved — names starting with "${RESERVED_AGENT_NAME_PREFIX}" are Hub-internal`);
|
|
1346
|
-
if (name && isReservedAgentName(name)) throw new BadRequestError(`Agent name "${name}" is reserved — pick a different one.`);
|
|
1347
|
-
const inboxId = `inbox_${uuid}`;
|
|
1348
|
-
let orgId;
|
|
1349
|
-
let managerId;
|
|
1350
|
-
if (data.managerId && data.organizationId) {
|
|
1351
|
-
orgId = data.organizationId;
|
|
1352
|
-
managerId = data.managerId;
|
|
1353
|
-
} else if (data.managerId) {
|
|
1354
|
-
const [manager] = await db.select({
|
|
1355
|
-
id: members.id,
|
|
1356
|
-
organizationId: members.organizationId
|
|
1357
|
-
}).from(members).where(eq(members.id, data.managerId)).limit(1);
|
|
1358
|
-
if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
|
|
1359
|
-
orgId = manager.organizationId;
|
|
1360
|
-
managerId = manager.id;
|
|
1361
|
-
} else {
|
|
1362
|
-
orgId = data.organizationId ?? await resolveDefaultOrgId(db);
|
|
1363
|
-
managerId = await resolveFallbackManagerId(db, orgId);
|
|
1364
|
-
}
|
|
1365
|
-
const clientId = await resolveAgentClient(db, {
|
|
1366
|
-
clientId: data.clientId,
|
|
1367
|
-
managerId,
|
|
1368
|
-
type: data.type
|
|
1369
|
-
});
|
|
1370
|
-
await ensureClientSupportsRuntimeProvider(db, clientId, runtimeProvider, { force: options.force });
|
|
1371
|
-
if (data.delegateMention) {
|
|
1372
|
-
assertDelegateMentionAllowed(data.type);
|
|
1373
|
-
await validateDelegateMentionTarget(db, data.delegateMention, orgId);
|
|
1374
|
-
}
|
|
1375
|
-
const [org] = await db.select({ maxAgents: organizations.maxAgents }).from(organizations).where(eq(organizations.id, orgId)).limit(1);
|
|
1376
|
-
if (org && org.maxAgents > 0) {
|
|
1377
|
-
if (((await db.select({ value: count() }).from(agents).where(and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED))))[0]?.value ?? 0) >= org.maxAgents) throw new ForbiddenError(`Organization "${orgId}" has reached its agent limit (${org.maxAgents}). Upgrade your plan or delete unused agents.`);
|
|
1378
|
-
}
|
|
1379
|
-
const resolvedDisplayName = data.displayName?.trim() || name || "Unnamed Agent";
|
|
1380
|
-
try {
|
|
1381
|
-
return await db.transaction(async (tx) => {
|
|
1382
|
-
const [row] = await tx.insert(agents).values({
|
|
1383
|
-
uuid,
|
|
1384
|
-
name,
|
|
1385
|
-
organizationId: orgId,
|
|
1386
|
-
type: data.type,
|
|
1387
|
-
displayName: resolvedDisplayName,
|
|
1388
|
-
delegateMention: data.delegateMention ?? null,
|
|
1389
|
-
inboxId,
|
|
1390
|
-
source: data.source ?? null,
|
|
1391
|
-
visibility: data.visibility ?? defaultVisibility(data.type),
|
|
1392
|
-
metadata: data.metadata ?? {},
|
|
1393
|
-
managerId,
|
|
1394
|
-
clientId,
|
|
1395
|
-
runtimeProvider
|
|
1396
|
-
}).returning();
|
|
1397
|
-
if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
1398
|
-
const initialPayload = defaultRuntimeConfigPayload(runtimeProvider);
|
|
1399
|
-
if (data.gitRepos && data.gitRepos.length > 0) initialPayload.gitRepos = data.gitRepos;
|
|
1400
|
-
await tx.insert(agentConfigs).values({
|
|
1401
|
-
agentId: row.uuid,
|
|
1402
|
-
version: 1,
|
|
1403
|
-
payload: initialPayload,
|
|
1404
|
-
updatedBy: "system"
|
|
1405
|
-
}).onConflictDoNothing();
|
|
1406
|
-
return row;
|
|
1407
|
-
});
|
|
1408
|
-
} catch (err) {
|
|
1409
|
-
if ((err?.code ?? err?.cause?.code ?? "") === "23505" && name) throw new ConflictError(`Agent name "${name}" already exists in organization "${orgId}"`);
|
|
1410
|
-
throw err;
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
async function checkAgentNameAvailability(db, orgId, name) {
|
|
1414
|
-
if (!AGENT_NAME_REGEX.test(name)) return {
|
|
1415
|
-
available: false,
|
|
1416
|
-
reason: "invalid"
|
|
1417
|
-
};
|
|
1418
|
-
if (isReservedAgentName(name) || name.startsWith(RESERVED_AGENT_NAME_PREFIX)) return {
|
|
1419
|
-
available: false,
|
|
1420
|
-
reason: "reserved"
|
|
1421
|
-
};
|
|
1422
|
-
const [existing] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.organizationId, orgId), eq(agents.name, name), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
|
|
1423
|
-
return existing ? {
|
|
1424
|
-
available: false,
|
|
1425
|
-
reason: "taken"
|
|
1426
|
-
} : { available: true };
|
|
1427
|
-
}
|
|
1428
|
-
async function getAgent(db, uuid) {
|
|
1429
|
-
const [agent] = await db.select().from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
|
|
1430
|
-
if (!agent) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
1431
|
-
return agent;
|
|
1432
|
-
}
|
|
1433
|
-
/**
|
|
1434
|
-
* Admin-only variant: return every non-deleted agent in the org, ignoring
|
|
1435
|
-
* the visibility filter. Used by the `/admin` "All Agents" view so a team
|
|
1436
|
-
* admin can see and act on private agents owned by other members. The
|
|
1437
|
-
* route layer is responsible for gating this to admin callers — the
|
|
1438
|
-
* service does not enforce role by itself, but it does enforce org scope
|
|
1439
|
-
* and the not-deleted predicate.
|
|
1440
|
-
*/
|
|
1441
|
-
async function listAgentsForAdmin(db, scope, limit, cursor) {
|
|
1442
|
-
const conditions = [eq(agents.organizationId, scope.organizationId), ne(agents.status, AGENT_STATUSES.DELETED)];
|
|
1443
|
-
if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
|
|
1444
|
-
const where = and(...conditions);
|
|
1445
|
-
const rows = await db.select({
|
|
1446
|
-
uuid: agents.uuid,
|
|
1447
|
-
name: agents.name,
|
|
1448
|
-
organizationId: agents.organizationId,
|
|
1449
|
-
type: agents.type,
|
|
1450
|
-
displayName: agents.displayName,
|
|
1451
|
-
delegateMention: agents.delegateMention,
|
|
1452
|
-
inboxId: agents.inboxId,
|
|
1453
|
-
status: agents.status,
|
|
1454
|
-
visibility: agents.visibility,
|
|
1455
|
-
metadata: agents.metadata,
|
|
1456
|
-
managerId: agents.managerId,
|
|
1457
|
-
clientId: agents.clientId,
|
|
1458
|
-
runtimeProvider: agents.runtimeProvider,
|
|
1459
|
-
avatarColorToken: agents.avatarColorToken,
|
|
1460
|
-
avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
|
|
1461
|
-
userAvatarUrl: users.avatarUrl,
|
|
1462
|
-
createdAt: agents.createdAt,
|
|
1463
|
-
updatedAt: agents.updatedAt,
|
|
1464
|
-
presenceStatus: agentPresence.status,
|
|
1465
|
-
runtimeType: agentPresence.runtimeType,
|
|
1466
|
-
runtimeState: agentPresence.runtimeState,
|
|
1467
|
-
activeSessions: agentPresence.activeSessions
|
|
1468
|
-
}).from(agents).leftJoin(agentPresence, eq(agents.uuid, agentPresence.agentId)).leftJoin(members, eq(members.agentId, agents.uuid)).leftJoin(users, eq(users.id, members.userId)).where(where).orderBy(desc(agents.createdAt)).limit(limit + 1);
|
|
1469
|
-
const hasMore = rows.length > limit;
|
|
1470
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
1471
|
-
const last = items[items.length - 1];
|
|
1472
|
-
return {
|
|
1473
|
-
items,
|
|
1474
|
-
nextCursor: hasMore && last ? last.createdAt.toISOString() : null
|
|
1475
|
-
};
|
|
1476
|
-
}
|
|
1477
|
-
/**
|
|
1478
|
-
* List agents visible to a specific member.
|
|
1479
|
-
* Uses agentVisibilityCondition from access-control (same rules for all roles).
|
|
1480
|
-
*/
|
|
1481
|
-
async function listAgentsForMember(db, scope, limit, cursor, type) {
|
|
1482
|
-
const conditions = [agentVisibilityCondition(scope.organizationId, scope.memberId)];
|
|
1483
|
-
if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
|
|
1484
|
-
if (type) conditions.push(eq(agents.type, type));
|
|
1485
|
-
const where = and(...conditions);
|
|
1486
|
-
const rows = await db.select({
|
|
1487
|
-
uuid: agents.uuid,
|
|
1488
|
-
name: agents.name,
|
|
1489
|
-
organizationId: agents.organizationId,
|
|
1490
|
-
type: agents.type,
|
|
1491
|
-
displayName: agents.displayName,
|
|
1492
|
-
delegateMention: agents.delegateMention,
|
|
1493
|
-
inboxId: agents.inboxId,
|
|
1494
|
-
status: agents.status,
|
|
1495
|
-
visibility: agents.visibility,
|
|
1496
|
-
metadata: agents.metadata,
|
|
1497
|
-
managerId: agents.managerId,
|
|
1498
|
-
clientId: agents.clientId,
|
|
1499
|
-
runtimeProvider: agents.runtimeProvider,
|
|
1500
|
-
avatarColorToken: agents.avatarColorToken,
|
|
1501
|
-
avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
|
|
1502
|
-
userAvatarUrl: users.avatarUrl,
|
|
1503
|
-
createdAt: agents.createdAt,
|
|
1504
|
-
updatedAt: agents.updatedAt,
|
|
1505
|
-
presenceStatus: agentPresence.status,
|
|
1506
|
-
runtimeType: agentPresence.runtimeType,
|
|
1507
|
-
runtimeState: agentPresence.runtimeState,
|
|
1508
|
-
activeSessions: agentPresence.activeSessions
|
|
1509
|
-
}).from(agents).leftJoin(agentPresence, eq(agents.uuid, agentPresence.agentId)).leftJoin(members, eq(members.agentId, agents.uuid)).leftJoin(users, eq(users.id, members.userId)).where(where).orderBy(desc(agents.createdAt)).limit(limit + 1);
|
|
1510
|
-
const hasMore = rows.length > limit;
|
|
1511
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
1512
|
-
const last = items[items.length - 1];
|
|
1513
|
-
return {
|
|
1514
|
-
items,
|
|
1515
|
-
nextCursor: hasMore && last ? last.createdAt.toISOString() : null
|
|
1516
|
-
};
|
|
1517
|
-
}
|
|
1518
|
-
async function updateAgent(db, uuid, data) {
|
|
1519
|
-
const agent = await getAgent(db, uuid);
|
|
1520
|
-
if (data.clientId !== void 0) {
|
|
1521
|
-
if (data.clientId === null) throw new BadRequestError("clientId cannot be cleared — once bound, an agent stays bound to its client");
|
|
1522
|
-
if (agent.clientId !== null && agent.clientId !== data.clientId) throw new BadRequestError("clientId is immutable through this entry — cross-client moves go through rebindAgent (PATCH /agents/:uuid/rebind), which runs owner / org / capability checks atomically.");
|
|
1523
|
-
}
|
|
1524
|
-
const updates = { updatedAt: /* @__PURE__ */ new Date() };
|
|
1525
|
-
if (data.type !== void 0) {
|
|
1526
|
-
if (data.type !== AGENT_TYPES.HUMAN && agent.delegateMention !== null && data.delegateMention !== null) throw new BadRequestError("Cannot change type away from `human` while delegateMention is set — clear delegateMention in the same patch.");
|
|
1527
|
-
updates.type = data.type;
|
|
1528
|
-
}
|
|
1529
|
-
if (data.displayName !== void 0) updates.displayName = data.displayName;
|
|
1530
|
-
if (data.delegateMention !== void 0) {
|
|
1531
|
-
if (data.delegateMention !== null) {
|
|
1532
|
-
assertDelegateMentionAllowed(data.type ?? agent.type);
|
|
1533
|
-
await validateDelegateMentionTarget(db, data.delegateMention, agent.organizationId);
|
|
1534
|
-
}
|
|
1535
|
-
updates.delegateMention = data.delegateMention;
|
|
1536
|
-
}
|
|
1537
|
-
if (data.visibility !== void 0) updates.visibility = data.visibility;
|
|
1538
|
-
if (data.metadata !== void 0) updates.metadata = data.metadata;
|
|
1539
|
-
if (data.avatarColorToken !== void 0) updates.avatarColorToken = data.avatarColorToken;
|
|
1540
|
-
if (data.managerId !== void 0) {
|
|
1541
|
-
if (data.managerId === null) throw new BadRequestError("managerId cannot be cleared — every agent must have a manager");
|
|
1542
|
-
const [manager] = await db.select({
|
|
1543
|
-
id: members.id,
|
|
1544
|
-
organizationId: members.organizationId
|
|
1545
|
-
}).from(members).where(eq(members.id, data.managerId)).limit(1);
|
|
1546
|
-
if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
|
|
1547
|
-
if (manager.organizationId !== agent.organizationId) throw new BadRequestError("Manager must belong to the same organization as the agent");
|
|
1548
|
-
updates.managerId = data.managerId;
|
|
1549
|
-
}
|
|
1550
|
-
if (data.clientId !== void 0 && data.clientId !== null && agent.clientId === null) {
|
|
1551
|
-
const resolvedClientId = await resolveAgentClient(db, {
|
|
1552
|
-
clientId: data.clientId,
|
|
1553
|
-
managerId: updates.managerId ?? agent.managerId,
|
|
1554
|
-
type: agent.type
|
|
1555
|
-
});
|
|
1556
|
-
if (resolvedClientId !== null) updates.clientId = resolvedClientId;
|
|
1557
|
-
}
|
|
1558
|
-
const [updated] = await db.update(agents).set(updates).where(eq(agents.uuid, agent.uuid)).returning();
|
|
1559
|
-
if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
1560
|
-
if (data.managerId !== void 0 && data.managerId !== agent.managerId) await recomputeWatchersForAgent(db, agent.uuid);
|
|
1561
|
-
return updated;
|
|
1562
|
-
}
|
|
1563
|
-
/**
|
|
1564
|
-
* Atomically re-bind an agent to a new client and/or runtime provider.
|
|
1565
|
-
*
|
|
1566
|
-
* Validations: agent must exist and not be human; new client must belong to
|
|
1567
|
-
* the same owner (manager.userId) and same organization; client must report
|
|
1568
|
-
* the requested runtime provider in its capabilities (skipped under `force`).
|
|
1569
|
-
*
|
|
1570
|
-
* Intended caller: PATCH /agents/:uuid/rebind. The Web "Re-bind"
|
|
1571
|
-
* dialog routes both same-client runtime-only switches and cross-client
|
|
1572
|
-
* moves through this single entry.
|
|
1573
|
-
*
|
|
1574
|
-
* NOTE: active sessions on the previous client are not auto-suspended in P1.
|
|
1575
|
-
* P3 will wire in cross-service coordination (inbox + presence + session)
|
|
1576
|
-
* so the destination client can resume cleanly.
|
|
1577
|
-
*/
|
|
1578
|
-
async function rebindAgent(db, uuid, data) {
|
|
1579
|
-
const agent = await getAgent(db, uuid);
|
|
1580
|
-
if (agent.type === "human") throw new BadRequestError("Human agents have no runtime — they cannot be re-bound to a client.");
|
|
1581
|
-
const newClientId = await resolveAgentClient(db, {
|
|
1582
|
-
clientId: data.clientId,
|
|
1583
|
-
managerId: agent.managerId,
|
|
1584
|
-
type: agent.type
|
|
1585
|
-
});
|
|
1586
|
-
if (newClientId === null) throw new BadRequestError("Rebind requires a non-null clientId.");
|
|
1587
|
-
await ensureClientSupportsRuntimeProvider(db, newClientId, data.runtimeProvider, { force: data.force });
|
|
1588
|
-
const [updated] = await db.update(agents).set({
|
|
1589
|
-
clientId: newClientId,
|
|
1590
|
-
runtimeProvider: data.runtimeProvider,
|
|
1591
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1592
|
-
}).where(eq(agents.uuid, uuid)).returning();
|
|
1593
|
-
if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
1594
|
-
return updated;
|
|
1595
|
-
}
|
|
1596
|
-
/**
|
|
1597
|
-
* Reactivate a suspended agent.
|
|
1598
|
-
*/
|
|
1599
|
-
async function reactivateAgent(db, uuid) {
|
|
1600
|
-
const [existing] = await db.select({
|
|
1601
|
-
uuid: agents.uuid,
|
|
1602
|
-
status: agents.status
|
|
1603
|
-
}).from(agents).where(eq(agents.uuid, uuid)).limit(1);
|
|
1604
|
-
if (!existing || existing.status === AGENT_STATUSES.DELETED) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
1605
|
-
if (existing.status !== AGENT_STATUSES.SUSPENDED) throw new BadRequestError("Only suspended agents can be reactivated.");
|
|
1606
|
-
const [agent] = await db.update(agents).set({
|
|
1607
|
-
status: AGENT_STATUSES.ACTIVE,
|
|
1608
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1609
|
-
}).where(eq(agents.uuid, uuid)).returning();
|
|
1610
|
-
if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
1611
|
-
return agent;
|
|
1612
|
-
}
|
|
1613
|
-
/**
|
|
1614
|
-
* Suspend an agent. Once suspended, Rule R-RUN refuses every runtime bind
|
|
1615
|
-
* and every agent-selector-authorised HTTP call.
|
|
1616
|
-
*/
|
|
1617
|
-
async function suspendAgent(db, uuid) {
|
|
1618
|
-
const [agent] = await db.update(agents).set({
|
|
1619
|
-
status: AGENT_STATUSES.SUSPENDED,
|
|
1620
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1621
|
-
}).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning();
|
|
1622
|
-
if (!agent) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
1623
|
-
return agent;
|
|
1624
|
-
}
|
|
1625
|
-
/**
|
|
1626
|
-
* Delete an agent. Only allowed when status is "suspended". Sets name to NULL
|
|
1627
|
-
* so the name becomes reusable.
|
|
1628
|
-
*/
|
|
1629
|
-
async function deleteAgent(db, uuid) {
|
|
1630
|
-
const [existing] = await db.select({
|
|
1631
|
-
uuid: agents.uuid,
|
|
1632
|
-
status: agents.status
|
|
1633
|
-
}).from(agents).where(eq(agents.uuid, uuid)).limit(1);
|
|
1634
|
-
if (!existing || existing.status === AGENT_STATUSES.DELETED) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
1635
|
-
if (existing.status !== AGENT_STATUSES.SUSPENDED) throw new BadRequestError("Only suspended agents can be deleted. Suspend the agent first.");
|
|
1636
|
-
const [agent] = await db.update(agents).set({
|
|
1637
|
-
status: AGENT_STATUSES.DELETED,
|
|
1638
|
-
name: null,
|
|
1639
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1640
|
-
}).where(eq(agents.uuid, uuid)).returning();
|
|
1641
|
-
await db.delete(adapterConfigs).where(eq(adapterConfigs.agentId, uuid));
|
|
1642
|
-
await db.delete(adapterAgentMappings).where(eq(adapterAgentMappings.agentId, uuid));
|
|
1643
|
-
if (!agent) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
1644
|
-
return agent;
|
|
1645
|
-
}
|
|
1646
|
-
/**
|
|
1647
|
-
* Supported avatar-image MIME types. The web client always uploads WEBP after
|
|
1648
|
-
* its own resize step; we accept PNG/JPEG too so a caller using the raw HTTP
|
|
1649
|
-
* API (curl, scripts) doesn't have to re-encode. Anything else is rejected at
|
|
1650
|
-
* the boundary — we never store an unknown content type.
|
|
1651
|
-
*/
|
|
1652
|
-
const SUPPORTED_AVATAR_IMAGE_MIMES = [
|
|
1653
|
-
"image/webp",
|
|
1654
|
-
"image/png",
|
|
1655
|
-
"image/jpeg"
|
|
1656
|
-
];
|
|
1657
|
-
/** Hard server-side ceiling for the stored bytea blob. Client pre-resizes to ~50KB. */
|
|
1658
|
-
const MAX_AVATAR_IMAGE_BYTES = 512 * 1024;
|
|
1659
|
-
function isSupportedAvatarMime(mime) {
|
|
1660
|
-
return SUPPORTED_AVATAR_IMAGE_MIMES.find((m) => m === mime) !== void 0;
|
|
1661
|
-
}
|
|
1662
|
-
/**
|
|
1663
|
-
* Fetch the avatar image blob for an agent. Returns `null` when no image
|
|
1664
|
-
* is set (the column is NULL). The data + mime pair is always coherent
|
|
1665
|
-
* (set/cleared together by the service writes below).
|
|
1666
|
-
*/
|
|
1667
|
-
async function getAgentAvatarImage(db, uuid) {
|
|
1668
|
-
const [row] = await db.select({
|
|
1669
|
-
data: agents.avatarImageData,
|
|
1670
|
-
mime: agents.avatarImageMime,
|
|
1671
|
-
updatedAt: agents.avatarImageUpdatedAt
|
|
1672
|
-
}).from(agents).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
|
|
1673
|
-
if (!row || !row.data || !row.mime || !row.updatedAt) return null;
|
|
1674
|
-
return {
|
|
1675
|
-
data: row.data,
|
|
1676
|
-
mime: row.mime,
|
|
1677
|
-
updatedAt: row.updatedAt
|
|
1678
|
-
};
|
|
1679
|
-
}
|
|
1680
|
-
/** Replace (or set) an agent's avatar image. Validates mime + size. */
|
|
1681
|
-
async function setAgentAvatarImage(db, uuid, data, mime) {
|
|
1682
|
-
if (!isSupportedAvatarMime(mime)) throw new BadRequestError(`Unsupported avatar image type "${mime}". Use PNG, JPEG, or WEBP.`);
|
|
1683
|
-
if (data.length === 0) throw new BadRequestError("Avatar image payload is empty.");
|
|
1684
|
-
if (data.length > 524288) throw new BadRequestError(`Avatar image is too large (${data.length} bytes; max ${MAX_AVATAR_IMAGE_BYTES}).`);
|
|
1685
|
-
const now = /* @__PURE__ */ new Date();
|
|
1686
|
-
if ((await db.update(agents).set({
|
|
1687
|
-
avatarImageData: data,
|
|
1688
|
-
avatarImageMime: mime,
|
|
1689
|
-
avatarImageUpdatedAt: now,
|
|
1690
|
-
updatedAt: now
|
|
1691
|
-
}).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning({ uuid: agents.uuid })).length === 0) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
1692
|
-
return now;
|
|
1693
|
-
}
|
|
1694
|
-
/** Clear an agent's avatar image (falls back to color + initial). */
|
|
1695
|
-
async function clearAgentAvatarImage(db, uuid) {
|
|
1696
|
-
if ((await db.update(agents).set({
|
|
1697
|
-
avatarImageData: null,
|
|
1698
|
-
avatarImageMime: null,
|
|
1699
|
-
avatarImageUpdatedAt: null,
|
|
1700
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1701
|
-
}).where(and(eq(agents.uuid, uuid), ne(agents.status, AGENT_STATUSES.DELETED))).returning({ uuid: agents.uuid })).length === 0) throw new NotFoundError(`Agent "${uuid}" not found`);
|
|
1702
|
-
}
|
|
1703
|
-
/**
|
|
1704
|
-
* Server-side lifecycle tracker for `format=question` messages.
|
|
1705
|
-
*
|
|
1706
|
-
* Written when an agent emits a question through `sendMessage`; status
|
|
1707
|
-
* flips to `answered` when the user posts an answer, or to `superseded`
|
|
1708
|
-
* when the chat session is archived or its client is claimed away.
|
|
1709
|
-
*
|
|
1710
|
-
* Per the team's "integrity in service layer" convention, NO foreign-key
|
|
1711
|
-
* constraints — referential integrity is enforced by the question
|
|
1712
|
-
* service itself (chat-id / agent-id / message-id are validated at
|
|
1713
|
-
* write time and the lifecycle hooks supersede orphaned rows).
|
|
1714
|
-
*/
|
|
1715
|
-
const pendingQuestions = pgTable("pending_questions", {
|
|
1716
|
-
id: text("id").primaryKey(),
|
|
1717
|
-
agentId: text("agent_id").notNull(),
|
|
1718
|
-
chatId: text("chat_id").notNull(),
|
|
1719
|
-
messageId: text("message_id").notNull(),
|
|
1720
|
-
status: text("status").notNull().default("pending"),
|
|
1721
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
1722
|
-
answeredAt: timestamp("answered_at", { withTimezone: true }),
|
|
1723
|
-
supersededAt: timestamp("superseded_at", { withTimezone: true }),
|
|
1724
|
-
supersededReason: text("superseded_reason")
|
|
1725
|
-
}, (table) => [index("idx_pending_questions_agent_status").on(table.agentId, table.status), index("idx_pending_questions_chat_status").on(table.chatId, table.status)]);
|
|
1726
|
-
/**
|
|
1727
|
-
* Upsert session state + refresh presence aggregates + NOTIFY.
|
|
1728
|
-
*
|
|
1729
|
-
* `agent_chat_sessions.(agent_id, chat_id)` is a single-row "current session
|
|
1730
|
-
* state" cache, not a session history log. A new runtime session starting on
|
|
1731
|
-
* the same (agent, chat) pair MUST overwrite whatever ended before — including
|
|
1732
|
-
* an `evicted` row left by a previous terminate. The previous "revival
|
|
1733
|
-
* defense" conflated two concerns: "this runtime session ended" (which is
|
|
1734
|
-
* what `evicted` actually means) and "this chat is permanently archived for
|
|
1735
|
-
* this agent" (a chat-level decision that should live on `chats`, not here).
|
|
1736
|
-
* See proposals/hub-agent-messaging-reply-and-mentions §M2-session-lifecycle.
|
|
1737
|
-
*
|
|
1738
|
-
* Presence row contract: this function tolerates a missing `agent_presence`
|
|
1739
|
-
* row by using `INSERT ... ON CONFLICT DO UPDATE`. The predictive-write path
|
|
1740
|
-
* (sendMessage on first message) may target an agent whose client has never
|
|
1741
|
-
* bound, so a prior `update agent_presence ... where agentId` would silently
|
|
1742
|
-
* drop the activeSessions/totalSessions refresh. See PR #198 review §2.
|
|
1743
|
-
*/
|
|
1744
|
-
async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier, options) {
|
|
1745
|
-
const now = /* @__PURE__ */ new Date();
|
|
1746
|
-
let wrote = false;
|
|
1747
|
-
await db.transaction(async (tx) => {
|
|
1748
|
-
await tx.insert(agentChatSessions).values({
|
|
1749
|
-
agentId,
|
|
1750
|
-
chatId,
|
|
1751
|
-
state,
|
|
1752
|
-
updatedAt: now
|
|
1753
|
-
}).onConflictDoUpdate({
|
|
1754
|
-
target: [agentChatSessions.agentId, agentChatSessions.chatId],
|
|
1755
|
-
set: {
|
|
1756
|
-
state,
|
|
1757
|
-
updatedAt: now
|
|
1758
|
-
},
|
|
1759
|
-
setWhere: ne(agentChatSessions.state, state)
|
|
1760
|
-
});
|
|
1761
|
-
const [counts] = await tx.select({
|
|
1762
|
-
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
1763
|
-
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
1764
|
-
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
1765
|
-
const activeSessions = counts?.active ?? 0;
|
|
1766
|
-
const totalSessions = counts?.total ?? 0;
|
|
1767
|
-
const presenceSet = options?.touchPresenceLastSeen ?? true ? {
|
|
1768
|
-
activeSessions,
|
|
1769
|
-
totalSessions,
|
|
1770
|
-
lastSeenAt: now
|
|
1771
|
-
} : {
|
|
1772
|
-
activeSessions,
|
|
1773
|
-
totalSessions
|
|
1774
|
-
};
|
|
1775
|
-
await tx.insert(agentPresence).values({
|
|
1776
|
-
agentId,
|
|
1777
|
-
activeSessions,
|
|
1778
|
-
totalSessions
|
|
1779
|
-
}).onConflictDoUpdate({
|
|
1780
|
-
target: [agentPresence.agentId],
|
|
1781
|
-
set: presenceSet
|
|
1782
|
-
});
|
|
1783
|
-
wrote = true;
|
|
1784
|
-
});
|
|
1785
|
-
if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
|
|
1786
|
-
}
|
|
1787
|
-
async function resetActivity(db, agentId) {
|
|
1788
|
-
const now = /* @__PURE__ */ new Date();
|
|
1789
|
-
await db.update(agentPresence).set({
|
|
1790
|
-
runtimeState: "idle",
|
|
1791
|
-
runtimeUpdatedAt: now
|
|
1792
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
1793
|
-
}
|
|
1794
|
-
async function getActivityOverview(db) {
|
|
1795
|
-
const [agentCounts] = await db.select({
|
|
1796
|
-
total: sql`count(*)::int`,
|
|
1797
|
-
running: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} IS NOT NULL)::int`,
|
|
1798
|
-
idle: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'idle')::int`,
|
|
1799
|
-
working: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'working')::int`,
|
|
1800
|
-
blocked: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'blocked')::int`,
|
|
1801
|
-
error: sql`count(*) FILTER (WHERE ${agentPresence.runtimeState} = 'error')::int`
|
|
1802
|
-
}).from(agentPresence);
|
|
1803
|
-
const [clientCounts] = await db.select({ count: sql`count(*)::int` }).from(clients).where(eq(clients.status, "connected"));
|
|
1804
|
-
return {
|
|
1805
|
-
total: agentCounts?.total ?? 0,
|
|
1806
|
-
running: agentCounts?.running ?? 0,
|
|
1807
|
-
byState: {
|
|
1808
|
-
idle: agentCounts?.idle ?? 0,
|
|
1809
|
-
working: agentCounts?.working ?? 0,
|
|
1810
|
-
blocked: agentCounts?.blocked ?? 0,
|
|
1811
|
-
error: agentCounts?.error ?? 0
|
|
1812
|
-
},
|
|
1813
|
-
clients: clientCounts?.count ?? 0
|
|
1814
|
-
};
|
|
1815
|
-
}
|
|
1816
|
-
/**
|
|
1817
|
-
* List agents with active runtime state.
|
|
1818
|
-
* When scope is provided, filters to agents visible to the member.
|
|
1819
|
-
*/
|
|
1820
|
-
async function listAgentsWithRuntime(db, scope) {
|
|
1821
|
-
if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
|
|
1822
|
-
return db.select({
|
|
1823
|
-
agentId: agentPresence.agentId,
|
|
1824
|
-
status: agentPresence.status,
|
|
1825
|
-
instanceId: agentPresence.instanceId,
|
|
1826
|
-
connectedAt: agentPresence.connectedAt,
|
|
1827
|
-
lastSeenAt: agentPresence.lastSeenAt,
|
|
1828
|
-
clientId: agentPresence.clientId,
|
|
1829
|
-
runtimeType: agentPresence.runtimeType,
|
|
1830
|
-
runtimeVersion: agentPresence.runtimeVersion,
|
|
1831
|
-
runtimeState: agentPresence.runtimeState,
|
|
1832
|
-
activeSessions: agentPresence.activeSessions,
|
|
1833
|
-
totalSessions: agentPresence.totalSessions,
|
|
1834
|
-
runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
|
|
1835
|
-
type: agents.type,
|
|
1836
|
-
managerId: agents.managerId
|
|
1837
|
-
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope.organizationId, scope.memberId)));
|
|
1838
|
-
}
|
|
1839
|
-
/**
|
|
1840
|
-
* Chat-first workspace — append-only post-fan-out projection.
|
|
1841
|
-
*
|
|
1842
|
-
* The single sanctioned extension point on the message hot path. Called
|
|
1843
|
-
* from `services/message.ts` AFTER existing fan-out completes, inside the
|
|
1844
|
-
* same transaction. Four responsibilities:
|
|
1845
|
-
*
|
|
1846
|
-
* 1. Chats projection: roll forward `chats.last_message_at`,
|
|
1847
|
-
* `chats.last_message_preview`. Powers the conversation list cursor +
|
|
1848
|
-
* sort + preview.
|
|
1849
|
-
*
|
|
1850
|
-
* 2. Engagement auto-revive: flip `chat_user_state.engagement_status`
|
|
1851
|
-
* from `archived` → `active` for everyone watching this chat. `deleted`
|
|
1852
|
-
* rows are sticky and intentionally untouched.
|
|
1853
|
-
*
|
|
1854
|
-
* 3. Mention propagation: increment `unread_mention_count` for mentioned
|
|
1855
|
-
* speaking participants AND for watcher rows whose managed agent was
|
|
1856
|
-
* mentioned. Sender row is excluded.
|
|
1857
|
-
*
|
|
1858
|
-
* 4. Realtime kick: fire-and-forget `pg_notify('chat_message_events', …)`
|
|
1859
|
-
* so admin WS sockets can translate it into a `chat:message` frame.
|
|
1860
|
-
* Failure is swallowed — durable persistence is the correctness path.
|
|
1861
|
-
*
|
|
1862
|
-
* Strict invariants (see docs/chat-first-workspace-product-design.md
|
|
1863
|
-
* "Risk Constraints"):
|
|
1864
|
-
* - This module appends ONLY. Never edits existing fan-out / inbox /
|
|
1865
|
-
* mention-extraction code.
|
|
1866
|
-
* - Watcher rows (chat_membership with access_mode='watcher') are
|
|
1867
|
-
* NEVER added to inbox_entries here. Their counters in
|
|
1868
|
-
* chat_user_state are bumped purely as a per-user red-dot signal.
|
|
1869
|
-
* - Mention candidate set is `chat_membership` speakers only;
|
|
1870
|
-
* watchers are not direct `@`-mention targets.
|
|
1871
|
-
*/
|
|
1872
|
-
const { ACTIVE: ACTIVE$1, ARCHIVED: ARCHIVED$1 } = CHAT_ENGAGEMENT_STATUSES;
|
|
1873
|
-
let dispatcher = null;
|
|
1874
|
-
function registerChatMessageDispatcher(fn) {
|
|
1875
|
-
dispatcher = fn;
|
|
1876
|
-
}
|
|
1877
|
-
/**
|
|
1878
|
-
* Best-effort cross-process kick for the chat-first workspace. Call AFTER
|
|
1879
|
-
* the message transaction commits — never inside the tx. Failure logs +
|
|
1880
|
-
* drops; web reconnect refetches.
|
|
1881
|
-
*
|
|
1882
|
-
* Speakers also get an inbox NOTIFY through the existing path. They will
|
|
1883
|
-
* receive both, and the web client de-dupes naturally because both end up
|
|
1884
|
-
* invalidating the same query keys.
|
|
1885
|
-
*/
|
|
1886
|
-
function fireChatMessageKick(chatId, messageId) {
|
|
1887
|
-
if (!dispatcher) return;
|
|
1888
|
-
try {
|
|
1889
|
-
dispatcher(chatId, messageId);
|
|
1890
|
-
} catch {}
|
|
1891
|
-
}
|
|
1892
|
-
/**
|
|
1893
|
-
* Apply the post-fan-out projection. MUST be called inside the same
|
|
1894
|
-
* transaction as the message INSERT. Safe to call when `mentionedAgentIds`
|
|
1895
|
-
* is empty (degenerate case skips the mention UPDATEs).
|
|
1896
|
-
*/
|
|
1897
|
-
async function applyAfterFanOut(tx, input) {
|
|
1898
|
-
const { chatId, senderId, mentionedAgentIds, contentPreview, messageCreatedAt } = input;
|
|
1899
|
-
const previewClipped = contentPreview.length > 0 ? contentPreview.slice(0, 200) : null;
|
|
1900
|
-
const ts = messageCreatedAt ?? /* @__PURE__ */ new Date();
|
|
1901
|
-
await tx.update(chats).set({
|
|
1902
|
-
lastMessageAt: ts,
|
|
1903
|
-
lastMessagePreview: previewClipped
|
|
1904
|
-
}).where(eq(chats.id, chatId));
|
|
1905
|
-
await tx.execute(sql`
|
|
1906
|
-
UPDATE chat_user_state
|
|
1907
|
-
SET engagement_status = ${ACTIVE$1}
|
|
1908
|
-
WHERE chat_id = ${chatId}
|
|
1909
|
-
AND engagement_status = ${ARCHIVED$1}
|
|
1910
|
-
`);
|
|
1911
|
-
if (mentionedAgentIds.length === 0) return;
|
|
1912
|
-
const mentionedList = sql.join(mentionedAgentIds.map((id) => sql`${id}`), sql`, `);
|
|
1913
|
-
await tx.execute(sql`
|
|
1914
|
-
INSERT INTO chat_user_state (chat_id, agent_id, unread_mention_count)
|
|
1915
|
-
SELECT chat_id, agent_id, 1
|
|
1916
|
-
FROM (
|
|
1917
|
-
SELECT cm.chat_id, cm.agent_id
|
|
1918
|
-
FROM chat_membership cm
|
|
1919
|
-
WHERE cm.chat_id = ${chatId}
|
|
1920
|
-
AND cm.access_mode = 'speaker'
|
|
1921
|
-
AND cm.agent_id IN (${mentionedList})
|
|
1922
|
-
AND cm.agent_id <> ${senderId}
|
|
1923
|
-
UNION
|
|
1924
|
-
SELECT cm.chat_id, cm.agent_id
|
|
1925
|
-
FROM chat_membership cm
|
|
1926
|
-
JOIN members m ON m.agent_id = cm.agent_id
|
|
1927
|
-
JOIN agents a ON a.manager_id = m.id
|
|
1928
|
-
WHERE cm.chat_id = ${chatId}
|
|
1929
|
-
AND cm.access_mode = 'watcher'
|
|
1930
|
-
AND a.uuid IN (${mentionedList})
|
|
1931
|
-
AND a.type <> 'human'
|
|
1932
|
-
AND m.status = 'active'
|
|
1933
|
-
) targets
|
|
1934
|
-
ON CONFLICT (chat_id, agent_id)
|
|
1935
|
-
DO UPDATE SET unread_mention_count = chat_user_state.unread_mention_count + 1
|
|
1936
|
-
`);
|
|
1937
|
-
}
|
|
1938
|
-
const log$1 = createLogger("message");
|
|
1939
|
-
async function sendMessage(db, chatId, senderId, data, options = {}) {
|
|
1940
|
-
return withSpan("inbox.enqueue", messageAttrs({
|
|
1941
|
-
chatId,
|
|
1942
|
-
senderAgentId: senderId,
|
|
1943
|
-
source: data.source ?? void 0
|
|
1944
|
-
}), () => sendMessageInner(db, chatId, senderId, data, options));
|
|
1945
|
-
}
|
|
1946
|
-
async function sendMessageInner(db, chatId, senderId, data, options) {
|
|
1947
|
-
const txResult = await db.transaction(async (tx) => {
|
|
1948
|
-
const [participants, [chatRow], [senderRow]] = await Promise.all([
|
|
1949
|
-
tx.select({
|
|
1950
|
-
agentId: chatMembership.agentId,
|
|
1951
|
-
inboxId: agents.inboxId,
|
|
1952
|
-
mode: chatMembership.mode,
|
|
1953
|
-
name: agents.name
|
|
1954
|
-
}).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker"))),
|
|
1955
|
-
tx.select({ type: chats.type }).from(chats).where(eq(chats.id, chatId)).limit(1),
|
|
1956
|
-
tx.select({
|
|
1957
|
-
inboxId: agents.inboxId,
|
|
1958
|
-
organizationId: agents.organizationId,
|
|
1959
|
-
type: agents.type
|
|
1960
|
-
}).from(agents).where(eq(agents.uuid, senderId)).limit(1)
|
|
1961
|
-
]);
|
|
1962
|
-
const chatType = chatRow?.type ?? null;
|
|
1963
|
-
if (!senderRow) throw new NotFoundError(`Sender agent "${senderId}" not found`);
|
|
1964
|
-
let effectiveContent = data.content;
|
|
1965
|
-
if (senderRow.type !== "human" && typeof effectiveContent === "string") {
|
|
1966
|
-
const unwrapped = maybeUnwrapDoubleEncoded(effectiveContent);
|
|
1967
|
-
if (unwrapped !== null) {
|
|
1968
|
-
log$1.warn({
|
|
1969
|
-
metric: "double_encoded_content_unwrapped_total",
|
|
1970
|
-
chatId,
|
|
1971
|
-
senderId
|
|
1972
|
-
}, "agent sent JSON-encoded string content — unwrapping to restore markdown rendering");
|
|
1973
|
-
effectiveContent = unwrapped;
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
if (data.replyToInbox !== void 0 && data.replyToInbox !== null) {
|
|
1977
|
-
if (senderRow.inboxId !== data.replyToInbox) throw new BadRequestError("replyToInbox must reference the sender's own inbox");
|
|
1978
|
-
}
|
|
1979
|
-
const incomingMeta = data.metadata ?? {};
|
|
1980
|
-
const explicitMentionsRaw = incomingMeta.mentions;
|
|
1981
|
-
const explicitMentions = Array.isArray(explicitMentionsRaw) ? explicitMentionsRaw.filter((m) => typeof m === "string") : [];
|
|
1982
|
-
const contentText = typeof effectiveContent === "string" ? effectiveContent : "";
|
|
1983
|
-
const resolved = contentText ? extractMentions(contentText, participants) : [];
|
|
1984
|
-
const mergedMentions = [...new Set([...explicitMentions, ...resolved])];
|
|
1985
|
-
const metadataToStore = mergedMentions.length > 0 ? {
|
|
1986
|
-
...incomingMeta,
|
|
1987
|
-
mentions: mergedMentions
|
|
1988
|
-
} : incomingMeta;
|
|
1989
|
-
const dmAutoProjection = chatType === "direct" ? [...new Set([...mergedMentions, ...participants.filter((p) => p.agentId !== senderId).map((p) => p.agentId)])] : mergedMentions;
|
|
1990
|
-
const isAgentFinalText = data.purpose === "agent-final-text";
|
|
1991
|
-
if (options.enforceGroupMention && chatType === "group" && !isAgentFinalText) {
|
|
1992
|
-
if (mergedMentions.filter((id) => id !== senderId).length === 0) throw new BadRequestError("Sending to a group chat requires an explicit @mention. Use `first-tree-hub chat send <name>` to message a single agent, or @<name> in the content to address one or more group members.");
|
|
1993
|
-
}
|
|
1994
|
-
if (options.enforceGroupMention && !isAgentFinalText && typeof effectiveContent === "string") {
|
|
1995
|
-
const rawTokens = scanMentionTokens(effectiveContent);
|
|
1996
|
-
if (rawTokens.length > 0) {
|
|
1997
|
-
const speakerNames = new Set(participants.map((p) => p.name).filter((n) => typeof n === "string" && n.length > 0).map((n) => n.toLowerCase()));
|
|
1998
|
-
const unresolved = rawTokens.filter((t) => !speakerNames.has(t));
|
|
1999
|
-
if (unresolved.length > 0) {
|
|
2000
|
-
const sample = unresolved[0];
|
|
2001
|
-
throw new BadRequestError(`Cannot @-mention "${sample}" — they are not a participant of this chat. Use \`first-tree-hub chat send --direct ${sample} "..."\` to message them in a side conversation, or ask a human in this chat to add them as a participant first.`);
|
|
2002
|
-
}
|
|
2003
|
-
}
|
|
2004
|
-
}
|
|
2005
|
-
let outboundContent = effectiveContent;
|
|
2006
|
-
if (options.normalizeMentionsInContent && typeof outboundContent === "string") {
|
|
2007
|
-
const present = new Set(scanMentionTokens(outboundContent));
|
|
2008
|
-
const missingNames = [];
|
|
2009
|
-
for (const id of mergedMentions) {
|
|
2010
|
-
if (id === senderId) continue;
|
|
2011
|
-
const p = participants.find((q) => q.agentId === id);
|
|
2012
|
-
if (!p?.name) continue;
|
|
2013
|
-
if (present.has(p.name.toLowerCase())) continue;
|
|
2014
|
-
missingNames.push(p.name);
|
|
2015
|
-
}
|
|
2016
|
-
if (missingNames.length > 0) {
|
|
2017
|
-
const prefix = missingNames.map((n) => `@${n}`).join(" ");
|
|
2018
|
-
outboundContent = outboundContent.length > 0 ? `${prefix} ${outboundContent}` : prefix;
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
if (data.format === "question") await assertSenderMayEmitQuestion(tx, senderId);
|
|
2022
|
-
const isSilentSend = typeof outboundContent === "string" && outboundContent.replace(/^(@\S+\s*)+/, "").trim().length === 0;
|
|
2023
|
-
if (isSilentSend) log$1.info({
|
|
2024
|
-
chatId,
|
|
2025
|
-
senderId,
|
|
2026
|
-
source: data.source ?? null
|
|
2027
|
-
}, "silent send: empty content after mention strip — no fan-out wake-up");
|
|
2028
|
-
const projectionMentions = isSilentSend ? [] : dmAutoProjection;
|
|
2029
|
-
const messageId = randomUUID();
|
|
2030
|
-
const [msg] = await tx.insert(messages).values({
|
|
2031
|
-
id: messageId,
|
|
2032
|
-
chatId,
|
|
2033
|
-
senderId,
|
|
2034
|
-
format: data.format,
|
|
2035
|
-
content: outboundContent,
|
|
2036
|
-
metadata: metadataToStore,
|
|
2037
|
-
replyToInbox: data.replyToInbox ?? null,
|
|
2038
|
-
replyToChat: data.replyToChat ?? null,
|
|
2039
|
-
inReplyTo: data.inReplyTo ?? null,
|
|
2040
|
-
source: data.source ?? null
|
|
2041
|
-
}).returning();
|
|
2042
|
-
if (data.format === "question" && msg) await recordPendingQuestionFromMessage(tx, {
|
|
2043
|
-
agentId: senderId,
|
|
2044
|
-
chatId,
|
|
2045
|
-
messageId: msg.id,
|
|
2046
|
-
content: outboundContent
|
|
2047
|
-
});
|
|
2048
|
-
const mentionSet = new Set(mergedMentions);
|
|
2049
|
-
const fanout = participants.filter((p) => p.agentId !== senderId).map((p) => ({
|
|
2050
|
-
agentId: p.agentId,
|
|
2051
|
-
inboxId: p.inboxId,
|
|
2052
|
-
notify: !isSilentSend && !isAgentFinalText && (p.mode !== "mention_only" || mentionSet.has(p.agentId))
|
|
2053
|
-
}));
|
|
2054
|
-
if (fanout.length > 0) await tx.insert(inboxEntries).values(fanout.map((f) => ({
|
|
2055
|
-
inboxId: f.inboxId,
|
|
2056
|
-
messageId,
|
|
2057
|
-
chatId,
|
|
2058
|
-
notify: f.notify
|
|
2059
|
-
})));
|
|
2060
|
-
const notified = fanout.filter((f) => f.notify);
|
|
2061
|
-
const recipients = notified.map((f) => f.inboxId);
|
|
2062
|
-
const recipientAgentIds = notified.map((f) => f.agentId);
|
|
2063
|
-
if (data.inReplyTo) {
|
|
2064
|
-
const [original] = await tx.select({
|
|
2065
|
-
replyToInbox: messages.replyToInbox,
|
|
2066
|
-
replyToChat: messages.replyToChat
|
|
2067
|
-
}).from(messages).where(eq(messages.id, data.inReplyTo)).limit(1);
|
|
2068
|
-
if (original?.replyToInbox && original?.replyToChat) {
|
|
2069
|
-
const replyNotify = !isSilentSend;
|
|
2070
|
-
await tx.insert(inboxEntries).values({
|
|
2071
|
-
inboxId: original.replyToInbox,
|
|
2072
|
-
messageId,
|
|
2073
|
-
chatId: original.replyToChat,
|
|
2074
|
-
notify: replyNotify
|
|
2075
|
-
}).onConflictDoNothing();
|
|
2076
|
-
if (replyNotify && !recipients.includes(original.replyToInbox)) recipients.push(original.replyToInbox);
|
|
2077
|
-
}
|
|
2078
|
-
}
|
|
2079
|
-
await tx.update(chats).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(chats.id, chatId));
|
|
2080
|
-
if (!msg) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
2081
|
-
const previewText = typeof outboundContent === "string" ? outboundContent.trim() : "";
|
|
2082
|
-
await applyAfterFanOut(tx, {
|
|
2083
|
-
chatId,
|
|
2084
|
-
messageId: msg.id,
|
|
2085
|
-
senderId,
|
|
2086
|
-
mentionedAgentIds: projectionMentions,
|
|
2087
|
-
contentPreview: previewText,
|
|
2088
|
-
messageCreatedAt: msg.createdAt
|
|
2089
|
-
});
|
|
2090
|
-
return {
|
|
2091
|
-
message: msg,
|
|
2092
|
-
recipients,
|
|
2093
|
-
recipientAgentIds,
|
|
2094
|
-
organizationId: senderRow.organizationId
|
|
2095
|
-
};
|
|
2096
|
-
});
|
|
2097
|
-
const settled = await Promise.allSettled(txResult.recipientAgentIds.map((agentId) => upsertSessionState(db, agentId, chatId, "active", txResult.organizationId, void 0, { touchPresenceLastSeen: false })));
|
|
2098
|
-
for (let i = 0; i < settled.length; i++) {
|
|
2099
|
-
const r = settled[i];
|
|
2100
|
-
if (r?.status === "rejected") log$1.error({
|
|
2101
|
-
err: r.reason,
|
|
2102
|
-
chatId,
|
|
2103
|
-
agentId: txResult.recipientAgentIds[i]
|
|
2104
|
-
}, "predictive session activation failed");
|
|
2105
|
-
}
|
|
2106
|
-
fireChatMessageKick(chatId, txResult.message.id);
|
|
2107
|
-
observeLoopPattern(db, chatId).catch((err) => {
|
|
2108
|
-
log$1.error({
|
|
2109
|
-
err,
|
|
2110
|
-
chatId
|
|
2111
|
-
}, "loop pattern observation failed");
|
|
2112
|
-
});
|
|
2113
|
-
return {
|
|
2114
|
-
message: txResult.message,
|
|
2115
|
-
recipients: txResult.recipients
|
|
2116
|
-
};
|
|
2117
|
-
}
|
|
2118
|
-
/**
|
|
2119
|
-
* Detect agent-sent content that was JSON.stringify-ed once before reaching
|
|
2120
|
-
* the CLI / API. The bad shape is an outer `"..."` wrapper + interior `\n` /
|
|
2121
|
-
* `\"` escape sequences, which the UI renders as a quoted literal instead of
|
|
2122
|
-
* markdown (issue #389). Returns the unwrapped inner string on a confident
|
|
2123
|
-
* match, or `null` to leave the content alone.
|
|
2124
|
-
*
|
|
2125
|
-
* Match conditions (all required) — kept strict so legitimate human content
|
|
2126
|
-
* that happens to look like a quoted phrase is never touched. The caller is
|
|
2127
|
-
* additionally responsible for restricting this to non-human senders.
|
|
2128
|
-
*
|
|
2129
|
-
* - first and last char are `"`
|
|
2130
|
-
* - body contains at least one typical JSON escape sequence
|
|
2131
|
-
* (`\n`, `\r`, `\t`, `\"`, or `\\`)
|
|
2132
|
-
* - `JSON.parse` succeeds
|
|
2133
|
-
* - the parse result is a `string` (excludes `{...}`, `[...]`, numbers)
|
|
2134
|
-
*/
|
|
2135
|
-
function maybeUnwrapDoubleEncoded(content) {
|
|
2136
|
-
if (content.length < 4) return null;
|
|
2137
|
-
if (content.charCodeAt(0) !== 34) return null;
|
|
2138
|
-
if (content.charCodeAt(content.length - 1) !== 34) return null;
|
|
2139
|
-
if (!/\\[nrt"\\]/.test(content)) return null;
|
|
2140
|
-
try {
|
|
2141
|
-
const parsed = JSON.parse(content);
|
|
2142
|
-
return typeof parsed === "string" ? parsed : null;
|
|
2143
|
-
} catch {
|
|
2144
|
-
return null;
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
function stripMentionPrefix(content) {
|
|
2148
|
-
return content.replace(/^(@\S+\s*)+/, "").trim();
|
|
2149
|
-
}
|
|
2150
|
-
const defaultLoopObserver = (data) => {
|
|
2151
|
-
log$1.warn({
|
|
2152
|
-
metric: "loop_pattern_observed_total",
|
|
2153
|
-
...data
|
|
2154
|
-
}, "loop pattern observed (not blocked) — prompt discipline may be drifting");
|
|
2155
|
-
};
|
|
2156
|
-
/**
|
|
2157
|
-
* Pure observation: detect short, fast, two-agent ping-pong reply chains and
|
|
2158
|
-
* surface them via a structured log line. Does NOT modify the `notify` flag
|
|
2159
|
-
* or otherwise interfere with delivery — loop *prevention* lives client-side
|
|
2160
|
-
* (prompt + silent-turn protocol in `result-sink`). Six conjunctive
|
|
2161
|
-
* conditions (see design `agent-reply-loop-prevention-design.md` §3.4) so
|
|
2162
|
-
* normal multi-agent collaboration is never flagged:
|
|
2163
|
-
*
|
|
2164
|
-
* C1 — every message is `format=text`
|
|
2165
|
-
* C2 — no human sender in the window (any human reply resets the chain)
|
|
2166
|
-
* C3 — exactly two senders, perfectly alternating
|
|
2167
|
-
* C4 — strict `inReplyTo` chain across the whole window
|
|
2168
|
-
* C5 — every message body (after stripping leading `@<name>` tokens) is
|
|
2169
|
-
* ≤ `LOOP_OBSERVATION_SHORT_CHARS` characters
|
|
2170
|
-
* C6 — the whole window spans ≤ `LOOP_OBSERVATION_TIME_WINDOW_MS` ms
|
|
2171
|
-
*
|
|
2172
|
-
* Exported for direct test coverage of the detection logic; the `sendMessage`
|
|
2173
|
-
* call site uses the default observer.
|
|
2174
|
-
*/
|
|
2175
|
-
async function observeLoopPattern(db, chatId, observer = defaultLoopObserver) {
|
|
2176
|
-
const window = await db.select({
|
|
2177
|
-
id: messages.id,
|
|
2178
|
-
senderId: messages.senderId,
|
|
2179
|
-
content: messages.content,
|
|
2180
|
-
inReplyTo: messages.inReplyTo,
|
|
2181
|
-
createdAt: messages.createdAt,
|
|
2182
|
-
format: messages.format,
|
|
2183
|
-
senderType: agents.type
|
|
2184
|
-
}).from(messages).innerJoin(agents, eq(messages.senderId, agents.uuid)).where(eq(messages.chatId, chatId)).orderBy(desc(messages.createdAt)).limit(4);
|
|
2185
|
-
if (window.length < 4) return;
|
|
2186
|
-
if (window.some((m) => m.format !== "text")) return;
|
|
2187
|
-
if (window.some((m) => m.senderType === "human")) return;
|
|
2188
|
-
if (new Set(window.map((m) => m.senderId)).size !== 2) return;
|
|
2189
|
-
for (let i = 0; i < window.length - 1; i++) if (window[i]?.senderId === window[i + 1]?.senderId) return;
|
|
2190
|
-
for (let i = 0; i < window.length - 1; i++) if (window[i]?.inReplyTo !== window[i + 1]?.id) return;
|
|
2191
|
-
const lens = [];
|
|
2192
|
-
for (const m of window) {
|
|
2193
|
-
const text = typeof m.content === "string" ? m.content : null;
|
|
2194
|
-
if (text === null) return;
|
|
2195
|
-
const len = stripMentionPrefix(text).length;
|
|
2196
|
-
if (len > 10) return;
|
|
2197
|
-
lens.push(len);
|
|
2198
|
-
}
|
|
2199
|
-
const newest = window[0];
|
|
2200
|
-
const oldest = window[window.length - 1];
|
|
2201
|
-
if (!newest || !oldest) return;
|
|
2202
|
-
const spanMs = newest.createdAt.getTime() - oldest.createdAt.getTime();
|
|
2203
|
-
if (spanMs > 3e4) return;
|
|
2204
|
-
observer({
|
|
2205
|
-
chatId,
|
|
2206
|
-
recentMessageIds: window.map((m) => m.id),
|
|
2207
|
-
windowSpanMs: spanMs,
|
|
2208
|
-
contentLengths: lens
|
|
2209
|
-
});
|
|
2210
|
-
}
|
|
2211
|
-
async function sendToAgent(db, senderUuid, targetName, data) {
|
|
2212
|
-
const [sender] = await db.select({
|
|
2213
|
-
uuid: agents.uuid,
|
|
2214
|
-
organizationId: agents.organizationId
|
|
2215
|
-
}).from(agents).where(eq(agents.uuid, senderUuid)).limit(1);
|
|
2216
|
-
if (!sender) throw new NotFoundError(`Agent "${senderUuid}" not found`);
|
|
2217
|
-
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);
|
|
2218
|
-
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) ? " — `first-tree-hub chat send` expects an agent NAME, not a uuid. Run `first-tree-hub agent list` to see available names." : ""}`);
|
|
2219
|
-
const incomingMeta = data.metadata ?? {};
|
|
2220
|
-
const existingMentionsRaw = incomingMeta.mentions;
|
|
2221
|
-
const existingMentions = Array.isArray(existingMentionsRaw) ? existingMentionsRaw.filter((m) => typeof m === "string") : [];
|
|
2222
|
-
const mergedMentions = existingMentions.includes(target.uuid) ? existingMentions : [...existingMentions, target.uuid];
|
|
2223
|
-
const metadata = {
|
|
2224
|
-
...incomingMeta,
|
|
2225
|
-
mentions: mergedMentions
|
|
2226
|
-
};
|
|
2227
|
-
if (data.replyToChat) {
|
|
2228
|
-
const [targetIsMember, senderIsMember] = await Promise.all([isParticipant(db, data.replyToChat, target.uuid), isParticipant(db, data.replyToChat, senderUuid)]);
|
|
2229
|
-
if (targetIsMember && senderIsMember) return sendMessage(db, data.replyToChat, senderUuid, {
|
|
2230
|
-
format: data.format,
|
|
2231
|
-
content: data.content,
|
|
2232
|
-
metadata,
|
|
2233
|
-
replyToInbox: void 0,
|
|
2234
|
-
replyToChat: void 0,
|
|
2235
|
-
source: data.source
|
|
2236
|
-
}, { normalizeMentionsInContent: true });
|
|
2237
|
-
}
|
|
2238
|
-
if (!data.direct) throw new AgentSendNonMemberError(`Agent "${targetName}" is not a member of your current chat. Either open or reuse a side-conversation explicitly with \`first-tree-hub chat send --direct ${targetName} "..."\`, or ask a human in this chat to add ${targetName} as a participant.`);
|
|
2239
|
-
return sendMessage(db, (await findOrCreateDirectChat(db, senderUuid, target.uuid)).id, senderUuid, {
|
|
2240
|
-
format: data.format,
|
|
2241
|
-
content: data.content,
|
|
2242
|
-
metadata,
|
|
2243
|
-
replyToInbox: data.replyToInbox,
|
|
2244
|
-
replyToChat: data.replyToChat,
|
|
2245
|
-
source: data.source
|
|
2246
|
-
}, { normalizeMentionsInContent: true });
|
|
2247
|
-
}
|
|
2248
|
-
async function editMessage(db, chatId, messageId, senderId, data) {
|
|
2249
|
-
const [msg] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1);
|
|
2250
|
-
if (!msg) throw new NotFoundError(`Message "${messageId}" not found`);
|
|
2251
|
-
if (msg.chatId !== chatId) throw new NotFoundError(`Message "${messageId}" not found in this chat`);
|
|
2252
|
-
if (msg.senderId !== senderId) throw new ForbiddenError("Only the sender can edit a message");
|
|
2253
|
-
const setClause = {};
|
|
2254
|
-
if (data.format !== void 0) setClause.format = data.format;
|
|
2255
|
-
if (data.content !== void 0) setClause.content = data.content;
|
|
2256
|
-
const meta = msg.metadata ?? {};
|
|
2257
|
-
meta.editedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2258
|
-
setClause.metadata = meta;
|
|
2259
|
-
const [updated] = await db.update(messages).set(setClause).where(eq(messages.id, messageId)).returning();
|
|
2260
|
-
if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
2261
|
-
return updated;
|
|
2262
|
-
}
|
|
2263
|
-
async function listMessages(db, chatId, limit, cursor) {
|
|
2264
|
-
const where = cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(cursor))) : eq(messages.chatId, chatId);
|
|
2265
|
-
const rows = await db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(limit + 1);
|
|
2266
|
-
const hasMore = rows.length > limit;
|
|
2267
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
2268
|
-
const last = items[items.length - 1];
|
|
2269
|
-
return {
|
|
2270
|
-
items,
|
|
2271
|
-
nextCursor: hasMore && last ? last.createdAt.toISOString() : null
|
|
2272
|
-
};
|
|
2273
|
-
}
|
|
2274
|
-
const INBOX_CHANNEL = "inbox_notifications";
|
|
2275
|
-
const CONFIG_CHANNEL = "config_changes";
|
|
2276
|
-
const SESSION_STATE_CHANNEL = "session_state_changes";
|
|
2277
|
-
const SESSION_EVENT_CHANNEL = "session_event_changes";
|
|
2278
|
-
const RUNTIME_STATE_CHANNEL = "runtime_state_changes";
|
|
2279
|
-
/**
|
|
2280
|
-
* Chat-first workspace cross-process kick. Carries `<chatId>:<messageId>`.
|
|
2281
|
-
* Lets admin WS sockets translate every chat message (speaker AND watcher
|
|
2282
|
-
* audience) into a `chat:message` frame, without being coupled to the
|
|
2283
|
-
* inbox NOTIFY path that only reaches speakers.
|
|
2284
|
-
*/
|
|
2285
|
-
const CHAT_MESSAGE_CHANNEL = "chat_message_events";
|
|
2286
|
-
/**
|
|
2287
|
-
* Generic admin-broadcast envelope channel. Producers (e.g. notification.ts)
|
|
2288
|
-
* emit a JSON-stringified `AdminBroadcastPayload`; every server instance
|
|
2289
|
-
* LISTENs and hands the envelope to its local admin-socket fanout. Keeps
|
|
2290
|
-
* cross-instance and single-instance code paths identical from the admin WS
|
|
2291
|
-
* route's perspective.
|
|
2292
|
-
*/
|
|
2293
|
-
const ADMIN_BROADCAST_CHANNEL = "admin_broadcast_envelopes";
|
|
2294
|
-
function createNotifier(listenClient) {
|
|
2295
|
-
const subscriptions = /* @__PURE__ */ new Map();
|
|
2296
|
-
const configChangeHandlers = [];
|
|
2297
|
-
const sessionStateChangeHandlers = [];
|
|
2298
|
-
const sessionEventHandlers = [];
|
|
2299
|
-
const runtimeStateChangeHandlers = [];
|
|
2300
|
-
const chatMessageHandlers = [];
|
|
2301
|
-
const adminBroadcastHandlers = [];
|
|
2302
|
-
let unlistenInboxFn = null;
|
|
2303
|
-
let unlistenConfigFn = null;
|
|
2304
|
-
let unlistenSessionStateFn = null;
|
|
2305
|
-
let unlistenSessionEventFn = null;
|
|
2306
|
-
let unlistenRuntimeStateFn = null;
|
|
2307
|
-
let unlistenChatMessageFn = null;
|
|
2308
|
-
let unlistenAdminBroadcastFn = null;
|
|
2309
|
-
function handleNotification(payload) {
|
|
2310
|
-
const sepIdx = payload.indexOf(":");
|
|
2311
|
-
if (sepIdx === -1) return;
|
|
2312
|
-
const inboxId = payload.slice(0, sepIdx);
|
|
2313
|
-
const messageId = payload.slice(sepIdx + 1);
|
|
2314
|
-
const sockets = subscriptions.get(inboxId);
|
|
2315
|
-
if (!sockets) return;
|
|
2316
|
-
for (const [ws, pushHandler] of sockets) {
|
|
2317
|
-
if (ws.readyState !== ws.OPEN) continue;
|
|
2318
|
-
Promise.resolve(pushHandler(messageId)).catch(() => {});
|
|
2319
|
-
}
|
|
2320
|
-
}
|
|
2321
|
-
return {
|
|
2322
|
-
subscribe(inboxId, ws, pushHandler) {
|
|
2323
|
-
let map = subscriptions.get(inboxId);
|
|
2324
|
-
if (!map) {
|
|
2325
|
-
map = /* @__PURE__ */ new Map();
|
|
2326
|
-
subscriptions.set(inboxId, map);
|
|
2327
|
-
}
|
|
2328
|
-
map.set(ws, pushHandler);
|
|
2329
|
-
},
|
|
2330
|
-
unsubscribe(inboxId, ws) {
|
|
2331
|
-
const map = subscriptions.get(inboxId);
|
|
2332
|
-
if (map) {
|
|
2333
|
-
map.delete(ws);
|
|
2334
|
-
if (map.size === 0) subscriptions.delete(inboxId);
|
|
2335
|
-
}
|
|
2336
|
-
},
|
|
2337
|
-
async notify(inboxId, messageId) {
|
|
2338
|
-
try {
|
|
2339
|
-
await listenClient`SELECT pg_notify(${INBOX_CHANNEL}, ${`${inboxId}:${messageId}`})`;
|
|
2340
|
-
} catch {}
|
|
2341
|
-
},
|
|
2342
|
-
async notifyConfigChange(configType) {
|
|
2343
|
-
try {
|
|
2344
|
-
await listenClient`SELECT pg_notify(${CONFIG_CHANNEL}, ${configType})`;
|
|
2345
|
-
} catch {}
|
|
2346
|
-
},
|
|
2347
|
-
async notifySessionStateChange(agentId, chatId, state, organizationId) {
|
|
2348
|
-
try {
|
|
2349
|
-
await listenClient`SELECT pg_notify(${SESSION_STATE_CHANNEL}, ${`${agentId}:${chatId}:${state}:${organizationId}`})`;
|
|
2350
|
-
} catch {}
|
|
2351
|
-
},
|
|
2352
|
-
async notifySessionEvent(agentId, chatId, kind, organizationId) {
|
|
2353
|
-
try {
|
|
2354
|
-
await listenClient`SELECT pg_notify(${SESSION_EVENT_CHANNEL}, ${`${agentId}:${chatId}:${kind}:${organizationId}`})`;
|
|
2355
|
-
} catch {}
|
|
2356
|
-
},
|
|
2357
|
-
async notifyRuntimeStateChange(agentId, state, organizationId) {
|
|
2358
|
-
try {
|
|
2359
|
-
await listenClient`SELECT pg_notify(${RUNTIME_STATE_CHANNEL}, ${`${agentId}:${state}:${organizationId}`})`;
|
|
2360
|
-
} catch {}
|
|
2361
|
-
},
|
|
2362
|
-
async notifyChatMessage(chatId, messageId) {
|
|
2363
|
-
try {
|
|
2364
|
-
await listenClient`SELECT pg_notify(${CHAT_MESSAGE_CHANNEL}, ${`${chatId}:${messageId}`})`;
|
|
2365
|
-
} catch {}
|
|
2366
|
-
},
|
|
2367
|
-
async notifyAdminBroadcast(payload) {
|
|
2368
|
-
let encoded;
|
|
2369
|
-
try {
|
|
2370
|
-
encoded = JSON.stringify(payload);
|
|
2371
|
-
} catch {
|
|
2372
|
-
return;
|
|
2373
|
-
}
|
|
2374
|
-
if (encoded.length > 7500) {
|
|
2375
|
-
console.error(`[notifier] admin broadcast payload too large (${encoded.length} bytes); refusing to NOTIFY`);
|
|
2376
|
-
return;
|
|
2377
|
-
}
|
|
2378
|
-
try {
|
|
2379
|
-
await listenClient`SELECT pg_notify(${ADMIN_BROADCAST_CHANNEL}, ${encoded})`;
|
|
2380
|
-
} catch {}
|
|
2381
|
-
},
|
|
2382
|
-
async pushFrameToInbox(inboxId, frame) {
|
|
2383
|
-
const map = subscriptions.get(inboxId);
|
|
2384
|
-
if (!map) return 0;
|
|
2385
|
-
let queued = 0;
|
|
2386
|
-
const pending = [];
|
|
2387
|
-
for (const ws of map.keys()) {
|
|
2388
|
-
if (ws.readyState !== ws.OPEN) continue;
|
|
2389
|
-
pending.push(new Promise((resolve) => {
|
|
2390
|
-
ws.send(frame, (err) => {
|
|
2391
|
-
if (!err) queued += 1;
|
|
2392
|
-
resolve();
|
|
2393
|
-
});
|
|
2394
|
-
}));
|
|
2395
|
-
}
|
|
2396
|
-
await Promise.all(pending);
|
|
2397
|
-
return queued;
|
|
2398
|
-
},
|
|
2399
|
-
onConfigChange(handler) {
|
|
2400
|
-
configChangeHandlers.push(handler);
|
|
2401
|
-
},
|
|
2402
|
-
onSessionStateChange(handler) {
|
|
2403
|
-
sessionStateChangeHandlers.push(handler);
|
|
2404
|
-
},
|
|
2405
|
-
onSessionEvent(handler) {
|
|
2406
|
-
sessionEventHandlers.push(handler);
|
|
2407
|
-
},
|
|
2408
|
-
onRuntimeStateChange(handler) {
|
|
2409
|
-
runtimeStateChangeHandlers.push(handler);
|
|
2410
|
-
},
|
|
2411
|
-
onChatMessage(handler) {
|
|
2412
|
-
chatMessageHandlers.push(handler);
|
|
2413
|
-
},
|
|
2414
|
-
onAdminBroadcast(handler) {
|
|
2415
|
-
adminBroadcastHandlers.push(handler);
|
|
2416
|
-
},
|
|
2417
|
-
async start() {
|
|
2418
|
-
unlistenInboxFn = (await listenClient.listen(INBOX_CHANNEL, (payload) => {
|
|
2419
|
-
if (payload) handleNotification(payload);
|
|
2420
|
-
})).unlisten;
|
|
2421
|
-
unlistenConfigFn = (await listenClient.listen(CONFIG_CHANNEL, (payload) => {
|
|
2422
|
-
if (payload) for (const handler of configChangeHandlers) handler(payload);
|
|
2423
|
-
})).unlisten;
|
|
2424
|
-
unlistenSessionStateFn = (await listenClient.listen(SESSION_STATE_CHANNEL, (payload) => {
|
|
2425
|
-
if (payload) {
|
|
2426
|
-
const firstSep = payload.indexOf(":");
|
|
2427
|
-
const secondSep = payload.indexOf(":", firstSep + 1);
|
|
2428
|
-
const thirdSep = payload.indexOf(":", secondSep + 1);
|
|
2429
|
-
if (firstSep > 0 && secondSep > firstSep && thirdSep > secondSep) {
|
|
2430
|
-
const agentId = payload.slice(0, firstSep);
|
|
2431
|
-
const chatId = payload.slice(firstSep + 1, secondSep);
|
|
2432
|
-
const state = payload.slice(secondSep + 1, thirdSep);
|
|
2433
|
-
const organizationId = payload.slice(thirdSep + 1);
|
|
2434
|
-
for (const handler of sessionStateChangeHandlers) handler({
|
|
2435
|
-
agentId,
|
|
2436
|
-
chatId,
|
|
2437
|
-
state,
|
|
2438
|
-
organizationId
|
|
2439
|
-
});
|
|
2440
|
-
}
|
|
2441
|
-
}
|
|
2442
|
-
})).unlisten;
|
|
2443
|
-
unlistenSessionEventFn = (await listenClient.listen(SESSION_EVENT_CHANNEL, (payload) => {
|
|
2444
|
-
if (payload) {
|
|
2445
|
-
const firstSep = payload.indexOf(":");
|
|
2446
|
-
const secondSep = payload.indexOf(":", firstSep + 1);
|
|
2447
|
-
const thirdSep = payload.indexOf(":", secondSep + 1);
|
|
2448
|
-
if (firstSep > 0 && secondSep > firstSep && thirdSep > secondSep) {
|
|
2449
|
-
const agentId = payload.slice(0, firstSep);
|
|
2450
|
-
const chatId = payload.slice(firstSep + 1, secondSep);
|
|
2451
|
-
const kind = payload.slice(secondSep + 1, thirdSep);
|
|
2452
|
-
const organizationId = payload.slice(thirdSep + 1);
|
|
2453
|
-
for (const handler of sessionEventHandlers) try {
|
|
2454
|
-
handler({
|
|
2455
|
-
agentId,
|
|
2456
|
-
chatId,
|
|
2457
|
-
kind,
|
|
2458
|
-
organizationId
|
|
2459
|
-
});
|
|
2460
|
-
} catch {}
|
|
2461
|
-
}
|
|
2462
|
-
}
|
|
2463
|
-
})).unlisten;
|
|
2464
|
-
unlistenRuntimeStateFn = (await listenClient.listen(RUNTIME_STATE_CHANNEL, (payload) => {
|
|
2465
|
-
if (payload) {
|
|
2466
|
-
const firstSep = payload.indexOf(":");
|
|
2467
|
-
const secondSep = payload.indexOf(":", firstSep + 1);
|
|
2468
|
-
if (firstSep > 0 && secondSep > firstSep) {
|
|
2469
|
-
const agentId = payload.slice(0, firstSep);
|
|
2470
|
-
const state = payload.slice(firstSep + 1, secondSep);
|
|
2471
|
-
const organizationId = payload.slice(secondSep + 1);
|
|
2472
|
-
for (const handler of runtimeStateChangeHandlers) handler({
|
|
2473
|
-
agentId,
|
|
2474
|
-
state,
|
|
2475
|
-
organizationId
|
|
2476
|
-
});
|
|
2477
|
-
}
|
|
2478
|
-
}
|
|
2479
|
-
})).unlisten;
|
|
2480
|
-
unlistenChatMessageFn = (await listenClient.listen(CHAT_MESSAGE_CHANNEL, (payload) => {
|
|
2481
|
-
if (!payload) return;
|
|
2482
|
-
const sep = payload.indexOf(":");
|
|
2483
|
-
if (sep <= 0) return;
|
|
2484
|
-
const chatId = payload.slice(0, sep);
|
|
2485
|
-
const messageId = payload.slice(sep + 1);
|
|
2486
|
-
for (const handler of chatMessageHandlers) try {
|
|
2487
|
-
handler({
|
|
2488
|
-
chatId,
|
|
2489
|
-
messageId
|
|
2490
|
-
});
|
|
2491
|
-
} catch {}
|
|
2492
|
-
})).unlisten;
|
|
2493
|
-
unlistenAdminBroadcastFn = (await listenClient.listen(ADMIN_BROADCAST_CHANNEL, (payload) => {
|
|
2494
|
-
if (!payload) return;
|
|
2495
|
-
let parsed;
|
|
2496
|
-
try {
|
|
2497
|
-
parsed = JSON.parse(payload);
|
|
2498
|
-
} catch {
|
|
2499
|
-
return;
|
|
2500
|
-
}
|
|
2501
|
-
if (typeof parsed !== "object" || parsed === null) return;
|
|
2502
|
-
const envelope = parsed;
|
|
2503
|
-
for (const handler of adminBroadcastHandlers) try {
|
|
2504
|
-
handler(envelope);
|
|
2505
|
-
} catch {}
|
|
2506
|
-
})).unlisten;
|
|
2507
|
-
},
|
|
2508
|
-
async stop() {
|
|
2509
|
-
if (unlistenInboxFn) {
|
|
2510
|
-
await unlistenInboxFn();
|
|
2511
|
-
unlistenInboxFn = null;
|
|
2512
|
-
}
|
|
2513
|
-
if (unlistenConfigFn) {
|
|
2514
|
-
await unlistenConfigFn();
|
|
2515
|
-
unlistenConfigFn = null;
|
|
2516
|
-
}
|
|
2517
|
-
if (unlistenSessionStateFn) {
|
|
2518
|
-
await unlistenSessionStateFn();
|
|
2519
|
-
unlistenSessionStateFn = null;
|
|
2520
|
-
}
|
|
2521
|
-
if (unlistenSessionEventFn) {
|
|
2522
|
-
await unlistenSessionEventFn();
|
|
2523
|
-
unlistenSessionEventFn = null;
|
|
2524
|
-
}
|
|
2525
|
-
if (unlistenRuntimeStateFn) {
|
|
2526
|
-
await unlistenRuntimeStateFn();
|
|
2527
|
-
unlistenRuntimeStateFn = null;
|
|
2528
|
-
}
|
|
2529
|
-
if (unlistenChatMessageFn) {
|
|
2530
|
-
await unlistenChatMessageFn();
|
|
2531
|
-
unlistenChatMessageFn = null;
|
|
2532
|
-
}
|
|
2533
|
-
if (unlistenAdminBroadcastFn) {
|
|
2534
|
-
await unlistenAdminBroadcastFn();
|
|
2535
|
-
unlistenAdminBroadcastFn = null;
|
|
2536
|
-
}
|
|
2537
|
-
}
|
|
2538
|
-
};
|
|
2539
|
-
}
|
|
2540
|
-
/** Fire-and-forget: notify all recipients that a new message is available. */
|
|
2541
|
-
function notifyRecipients(notifier, recipients, messageId) {
|
|
2542
|
-
for (const inboxId of recipients) notifier.notify(inboxId, messageId).catch(() => {});
|
|
2543
|
-
}
|
|
2544
|
-
const log = createLogger("questions");
|
|
2545
|
-
/**
|
|
2546
|
-
* Insert a `pending_questions` row inside the same transaction that wrote a
|
|
2547
|
-
* `format=question` message. Caller is `sendMessage` after the message INSERT
|
|
2548
|
-
* returns, so a rollback drops both rows together. No-op (returns silently)
|
|
2549
|
-
* if the message content is not a valid `QuestionMessageContent` — the caller
|
|
2550
|
-
* will already have rejected such input upstream, but we defend in depth so a
|
|
2551
|
-
* malformed write never leaves a dangling pending row.
|
|
2552
|
-
*/
|
|
2553
|
-
async function recordPendingQuestionFromMessage(tx, args) {
|
|
2554
|
-
const parsed = questionMessageContentSchema.safeParse(args.content);
|
|
2555
|
-
if (!parsed.success) throw new BadRequestError("Invalid question message content", { "question.parse_error": parsed.error.message.slice(0, 200) });
|
|
2556
|
-
const { correlationId } = parsed.data;
|
|
2557
|
-
await tx.insert(pendingQuestions).values({
|
|
2558
|
-
id: correlationId,
|
|
2559
|
-
agentId: args.agentId,
|
|
2560
|
-
chatId: args.chatId,
|
|
2561
|
-
messageId: args.messageId,
|
|
2562
|
-
status: "pending"
|
|
2563
|
-
});
|
|
2564
|
-
}
|
|
2565
|
-
/**
|
|
2566
|
-
* Defensive write-side check: codex-runtime agents must never emit
|
|
2567
|
-
* `format=question` (Codex SDK 0.125 has no ask-user surface, so any such
|
|
2568
|
-
* message would be a runtime regression). Looks up the sender's
|
|
2569
|
-
* `runtime_provider` and rejects if it is `codex`. Throws `ForbiddenError`
|
|
2570
|
-
* (HTTP 403) so the bug surfaces loudly to the offending writer.
|
|
2571
|
-
*
|
|
2572
|
-
* Returns the runtime provider for telemetry / further checks (e.g. the
|
|
2573
|
-
* caller can attach it to the active span).
|
|
2574
|
-
*/
|
|
2575
|
-
async function assertSenderMayEmitQuestion(tx, senderAgentId) {
|
|
2576
|
-
const [row] = await tx.select({ runtimeProvider: agents.runtimeProvider }).from(agents).where(eq(agents.uuid, senderAgentId)).limit(1);
|
|
2577
|
-
if (!row) throw new NotFoundError(`Sender agent "${senderAgentId}" not found`);
|
|
2578
|
-
if (row.runtimeProvider === "codex") {
|
|
2579
|
-
log.error({
|
|
2580
|
-
agentId: senderAgentId,
|
|
2581
|
-
runtimeProvider: row.runtimeProvider
|
|
2582
|
-
}, "rejected format=question emit from codex-runtime agent");
|
|
2583
|
-
throw new ForbiddenError("Codex runtime cannot emit ask-user questions", {
|
|
2584
|
-
"question.codex_emit_attempt": true,
|
|
2585
|
-
"agent.id": senderAgentId
|
|
2586
|
-
});
|
|
2587
|
-
}
|
|
2588
|
-
return row.runtimeProvider;
|
|
2589
|
-
}
|
|
2590
|
-
/**
|
|
2591
|
-
* User-side answer submission. Atomically:
|
|
2592
|
-
* 1. Lock the `pending_questions` row by correlationId.
|
|
2593
|
-
* 2. Refuse if status !== "pending" (409 if already-answered, 410-shaped
|
|
2594
|
-
* 400 if superseded — both surface as ConflictError so the caller knows
|
|
2595
|
-
* the question is no longer answerable).
|
|
2596
|
-
* 3. Validate that the answer keys match the original `questions[]`.
|
|
2597
|
-
* 4. Flip status to `answered` INSIDE the lock-tx, before releasing the
|
|
2598
|
-
* row lock. This is the linearisation point: the second concurrent
|
|
2599
|
-
* submitter (waiting on the same row lock) will, on its turn, see
|
|
2600
|
-
* status=answered and exit with ConflictError BEFORE it can write a
|
|
2601
|
-
* second `format=question_answer` message.
|
|
2602
|
-
* 5. Send the `format=question_answer` message OUTSIDE the lock-tx
|
|
2603
|
-
* (sendMessage opens its own transaction; nesting wasn't supported by
|
|
2604
|
-
* the existing call site). At this point we hold an exclusive logical
|
|
2605
|
-
* claim — only one submitter ever reaches this step per correlationId.
|
|
2606
|
-
*
|
|
2607
|
-
* `submitterAgentId` is the human agent on whose behalf the answer is
|
|
2608
|
-
* written (it must be a participant of the question's chat). Returns the
|
|
2609
|
-
* created `question_answer` message id so the route can include it in the
|
|
2610
|
-
* 201 response.
|
|
2611
|
-
*
|
|
2612
|
-
* Failure semantics: if step 5 (sendMessage) fails after status was flipped,
|
|
2613
|
-
* we revert the row to `pending` so the user can retry. This is best-effort —
|
|
2614
|
-
* the revert UPDATE is guarded by `status='answered'` to avoid clobbering a
|
|
2615
|
-
* supersede that might race in. If the revert itself fails, the row is
|
|
2616
|
-
* stranded as `answered` with no answer message; an operator would need to
|
|
2617
|
-
* intervene, but a sendMessage failure (local DB tx) is already
|
|
2618
|
-
* extraordinarily rare.
|
|
2619
|
-
*/
|
|
2620
|
-
async function submitAnswer(db, notifier, args) {
|
|
2621
|
-
const questionRow = await db.transaction(async (tx) => {
|
|
2622
|
-
const [row] = await tx.select({
|
|
2623
|
-
id: pendingQuestions.id,
|
|
2624
|
-
status: pendingQuestions.status,
|
|
2625
|
-
chatId: pendingQuestions.chatId,
|
|
2626
|
-
agentId: pendingQuestions.agentId,
|
|
2627
|
-
messageId: pendingQuestions.messageId
|
|
2628
|
-
}).from(pendingQuestions).where(eq(pendingQuestions.id, args.correlationId)).for("update").limit(1);
|
|
2629
|
-
if (!row) throw new NotFoundError(`Question "${args.correlationId}" not found`);
|
|
2630
|
-
if (row.chatId !== args.chatId) throw new NotFoundError(`Question "${args.correlationId}" not found in this chat`);
|
|
2631
|
-
if (row.status !== "pending") throw new ConflictError(`Question "${args.correlationId}" is no longer pending`, { "question.status": row.status });
|
|
2632
|
-
const [msg] = await tx.select({ content: messages.content }).from(messages).where(eq(messages.id, row.messageId)).limit(1);
|
|
2633
|
-
if (!msg) throw new NotFoundError(`Question message "${row.messageId}" not found`);
|
|
2634
|
-
const parsedQuestion = questionMessageContentSchema.safeParse(msg.content);
|
|
2635
|
-
if (!parsedQuestion.success) throw new BadRequestError("Stored question content is malformed", { "question.parse_error": parsedQuestion.error.message.slice(0, 200) });
|
|
2636
|
-
const expectedKeys = new Set(parsedQuestion.data.questions.map((q) => q.question));
|
|
2637
|
-
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 });
|
|
2638
|
-
for (const key of expectedKeys) if (!(key in args.answers)) throw new BadRequestError(`Answer missing for question "${key}"`, { "question.id": args.correlationId });
|
|
2639
|
-
await tx.update(pendingQuestions).set({
|
|
2640
|
-
status: "answered",
|
|
2641
|
-
answeredAt: /* @__PURE__ */ new Date()
|
|
2642
|
-
}).where(eq(pendingQuestions.id, args.correlationId));
|
|
2643
|
-
return row;
|
|
2644
|
-
});
|
|
2645
|
-
const answerContent = {
|
|
2646
|
-
correlationId: args.correlationId,
|
|
2647
|
-
answers: args.answers
|
|
2648
|
-
};
|
|
2649
|
-
questionAnswerMessageContentSchema.parse(answerContent);
|
|
2650
|
-
let result;
|
|
2651
|
-
try {
|
|
2652
|
-
result = await sendMessage(db, args.chatId, args.submitterAgentId, {
|
|
2653
|
-
format: "question_answer",
|
|
2654
|
-
content: answerContent,
|
|
2655
|
-
inReplyTo: questionRow.messageId,
|
|
2656
|
-
source: "hub_ui"
|
|
2657
|
-
});
|
|
2658
|
-
} catch (err) {
|
|
2659
|
-
log.error({
|
|
2660
|
-
correlationId: args.correlationId,
|
|
2661
|
-
chatId: args.chatId,
|
|
2662
|
-
err: err instanceof Error ? err.message : String(err)
|
|
2663
|
-
}, "sendMessage failed after status flip; reverting pending_questions row to 'pending'");
|
|
2664
|
-
try {
|
|
2665
|
-
await db.update(pendingQuestions).set({
|
|
2666
|
-
status: "pending",
|
|
2667
|
-
answeredAt: null
|
|
2668
|
-
}).where(and(eq(pendingQuestions.id, args.correlationId), eq(pendingQuestions.status, "answered")));
|
|
2669
|
-
} catch (revertErr) {
|
|
2670
|
-
log.error({
|
|
2671
|
-
correlationId: args.correlationId,
|
|
2672
|
-
chatId: args.chatId,
|
|
2673
|
-
revertErr: revertErr instanceof Error ? revertErr.message : String(revertErr)
|
|
2674
|
-
}, "revert UPDATE also failed; row may be stranded as 'answered' without an answer message");
|
|
2675
|
-
}
|
|
2676
|
-
throw err;
|
|
2677
|
-
}
|
|
2678
|
-
if (notifier) notifyRecipients(notifier, result.recipients, result.message.id);
|
|
2679
|
-
return {
|
|
2680
|
-
messageId: result.message.id,
|
|
2681
|
-
recipients: result.recipients
|
|
2682
|
-
};
|
|
2683
|
-
}
|
|
2684
|
-
/**
|
|
2685
|
-
* Mark every pending row whose chat is `chatId` as superseded. Used when a
|
|
2686
|
-
* chat session is archived — the agent runtime that emitted the question
|
|
2687
|
-
* may already be gone, so leaving the row pending would block forever.
|
|
2688
|
-
*/
|
|
2689
|
-
async function markSupersededByChat(tx, chatId, reason = "chat_archived") {
|
|
2690
|
-
return (await tx.update(pendingQuestions).set({
|
|
2691
|
-
status: "superseded",
|
|
2692
|
-
supersededAt: /* @__PURE__ */ new Date(),
|
|
2693
|
-
supersededReason: reason
|
|
2694
|
-
}).where(and(eq(pendingQuestions.chatId, chatId), eq(pendingQuestions.status, "pending"))).returning({ id: pendingQuestions.id })).length;
|
|
2695
|
-
}
|
|
2696
|
-
/**
|
|
2697
|
-
* Mark every pending row owned by any of `agentIds` as superseded. Used when
|
|
2698
|
-
* the client carrying these agents is claimed by a new user — the previous
|
|
2699
|
-
* owner's runtime is detached and cannot deliver an answer back.
|
|
2700
|
-
*/
|
|
2701
|
-
async function markSupersededByAgents(tx, agentIds, reason = "client_claimed") {
|
|
2702
|
-
if (agentIds.length === 0) return 0;
|
|
2703
|
-
return (await tx.update(pendingQuestions).set({
|
|
2704
|
-
status: "superseded",
|
|
2705
|
-
supersededAt: /* @__PURE__ */ new Date(),
|
|
2706
|
-
supersededReason: reason
|
|
2707
|
-
}).where(and(inArray(pendingQuestions.agentId, agentIds), eq(pendingQuestions.status, "pending"))).returning({ id: pendingQuestions.id })).length;
|
|
2708
|
-
}
|
|
2709
|
-
/** Extract a plain-text summary from a message's JSONB content field.
|
|
2710
|
-
* Used as the auto-title fallback in chat list rendering — see
|
|
2711
|
-
* `me-chat.ts:resolveChatTitle` and `admin/chats.ts:getChat`.
|
|
2712
|
-
*
|
|
2713
|
-
* - `@<name>` mention tokens are stripped before truncation: in the
|
|
2714
|
-
* chat-first model they're routing/audience metadata, not part of
|
|
2715
|
-
* the user's intent. Leaving them in produces noisy titles like
|
|
2716
|
-
* "@hub-agent-01 帮我重构这个文件" or "你好 @hub-agent-02 看看".
|
|
2717
|
-
* - Whitespace runs (including those left behind by mention removal)
|
|
2718
|
-
* collapse to single spaces.
|
|
2719
|
-
* - If the cleaned text is empty (e.g., a message that's only
|
|
2720
|
-
* `@hub-agent-01`), returns null so the caller falls through to
|
|
2721
|
-
* the participant-join fallback.
|
|
2722
|
-
* - Slicing is code-point-aware (`Array.from + join`) so emoji /
|
|
2723
|
-
* surrogate pairs aren't split into garbled half-characters. */
|
|
2724
|
-
function extractSummary(content, maxLen = 50) {
|
|
2725
|
-
let text = "";
|
|
2726
|
-
if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
|
|
2727
|
-
else if (typeof content === "string") text = content;
|
|
2728
|
-
if (!text) return null;
|
|
2729
|
-
const cleaned = stripCode(text).replace(MENTION_REGEX, "").replace(/\s+/g, " ").trim();
|
|
2730
|
-
if (!cleaned) return null;
|
|
2731
|
-
return Array.from(cleaned).slice(0, maxLen).join("");
|
|
2732
|
-
}
|
|
2733
|
-
/** List sessions for a specific agent, with optional state filters. */
|
|
2734
|
-
async function listAgentSessions(db, agentId, filters) {
|
|
2735
|
-
const conditions = [eq(agentChatSessions.agentId, agentId)];
|
|
2736
|
-
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
2737
|
-
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
2738
|
-
const rows = await db.select({
|
|
2739
|
-
agentId: agentChatSessions.agentId,
|
|
2740
|
-
chatId: agentChatSessions.chatId,
|
|
2741
|
-
state: agentChatSessions.state,
|
|
2742
|
-
updatedAt: agentChatSessions.updatedAt,
|
|
2743
|
-
chatCreatedAt: chats.createdAt,
|
|
2744
|
-
chatTopic: chats.topic
|
|
2745
|
-
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
|
|
2746
|
-
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
2747
|
-
const agentRuntimeState = presence?.runtimeState ?? null;
|
|
2748
|
-
if (filters?.runtimeState && agentRuntimeState !== filters.runtimeState) return [];
|
|
2749
|
-
const chatIds = rows.map((r) => r.chatId);
|
|
2750
|
-
const messageCounts = chatIds.length > 0 ? await db.select({
|
|
2751
|
-
chatId: inboxEntries.chatId,
|
|
2752
|
-
count: sql`count(*)::int`
|
|
2753
|
-
}).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
|
|
2754
|
-
const countMap = new Map(messageCounts.map((r) => [r.chatId, r.count]));
|
|
2755
|
-
const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
2756
|
-
chatId: messages.chatId,
|
|
2757
|
-
content: messages.content
|
|
2758
|
-
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
2759
|
-
const summaryMap = /* @__PURE__ */ new Map();
|
|
2760
|
-
for (const row of firstMessages) {
|
|
2761
|
-
const summary = extractSummary(row.content);
|
|
2762
|
-
if (summary) summaryMap.set(row.chatId, summary);
|
|
2763
|
-
}
|
|
2764
|
-
return rows.map((r) => ({
|
|
2765
|
-
agentId: r.agentId,
|
|
2766
|
-
chatId: r.chatId,
|
|
2767
|
-
state: r.state,
|
|
2768
|
-
runtimeState: agentRuntimeState,
|
|
2769
|
-
startedAt: r.chatCreatedAt.toISOString(),
|
|
2770
|
-
lastActivityAt: r.updatedAt.toISOString(),
|
|
2771
|
-
messageCount: countMap.get(r.chatId) ?? 0,
|
|
2772
|
-
summary: summaryMap.get(r.chatId) ?? null,
|
|
2773
|
-
topic: r.chatTopic ?? null
|
|
2774
|
-
}));
|
|
2775
|
-
}
|
|
2776
|
-
/** Get a single session's detail. */
|
|
2777
|
-
async function getSession(db, agentId, chatId) {
|
|
2778
|
-
const [row] = await db.select({
|
|
2779
|
-
agentId: agentChatSessions.agentId,
|
|
2780
|
-
chatId: agentChatSessions.chatId,
|
|
2781
|
-
state: agentChatSessions.state,
|
|
2782
|
-
updatedAt: agentChatSessions.updatedAt,
|
|
2783
|
-
chatCreatedAt: chats.createdAt,
|
|
2784
|
-
chatTopic: chats.topic
|
|
2785
|
-
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
|
|
2786
|
-
if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
2787
|
-
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
2788
|
-
const [countRow] = await db.select({ count: sql`count(*)::int` }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), eq(inboxEntries.chatId, chatId)));
|
|
2789
|
-
const firstMsg = (await db.execute(sql`SELECT content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at ASC LIMIT 1`))[0];
|
|
2790
|
-
const summary = firstMsg ? extractSummary(firstMsg.content) : null;
|
|
2791
|
-
return {
|
|
2792
|
-
agentId: row.agentId,
|
|
2793
|
-
chatId: row.chatId,
|
|
2794
|
-
state: row.state,
|
|
2795
|
-
runtimeState: presence?.runtimeState ?? null,
|
|
2796
|
-
startedAt: row.chatCreatedAt.toISOString(),
|
|
2797
|
-
lastActivityAt: row.updatedAt.toISOString(),
|
|
2798
|
-
messageCount: countRow?.count ?? 0,
|
|
2799
|
-
summary,
|
|
2800
|
-
topic: row.chatTopic ?? null
|
|
2801
|
-
};
|
|
2802
|
-
}
|
|
2803
|
-
/** List all sessions across all agents, with pagination. Scoped to organization. */
|
|
2804
|
-
async function listAllSessions(db, limit, cursor, filters) {
|
|
2805
|
-
const conditions = [];
|
|
2806
|
-
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
2807
|
-
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
2808
|
-
if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
|
|
2809
|
-
if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
|
|
2810
|
-
if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
|
|
2811
|
-
const rows = await db.select({
|
|
2812
|
-
agentId: agentChatSessions.agentId,
|
|
2813
|
-
chatId: agentChatSessions.chatId,
|
|
2814
|
-
state: agentChatSessions.state,
|
|
2815
|
-
updatedAt: agentChatSessions.updatedAt,
|
|
2816
|
-
chatCreatedAt: chats.createdAt,
|
|
2817
|
-
chatTopic: chats.topic
|
|
2818
|
-
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).innerJoin(agents, eq(agentChatSessions.agentId, agents.uuid)).where(conditions.length > 0 ? and(...conditions) : void 0).orderBy(desc(agentChatSessions.updatedAt)).limit(limit + 1);
|
|
2819
|
-
const hasMore = rows.length > limit;
|
|
2820
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
2821
|
-
const agentIds = [...new Set(items.map((r) => r.agentId))];
|
|
2822
|
-
const presenceRows = agentIds.length > 0 ? await db.select({
|
|
2823
|
-
agentId: agentPresence.agentId,
|
|
2824
|
-
runtimeState: agentPresence.runtimeState
|
|
2825
|
-
}).from(agentPresence).where(inArray(agentPresence.agentId, agentIds)) : [];
|
|
2826
|
-
const runtimeMap = new Map(presenceRows.map((r) => [r.agentId, r.runtimeState]));
|
|
2827
|
-
const chatIds = [...new Set(items.map((r) => r.chatId))];
|
|
2828
|
-
const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
2829
|
-
chatId: messages.chatId,
|
|
2830
|
-
content: messages.content
|
|
2831
|
-
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
2832
|
-
const summaryMap = /* @__PURE__ */ new Map();
|
|
2833
|
-
for (const row of firstMessages) {
|
|
2834
|
-
const summary = extractSummary(row.content);
|
|
2835
|
-
if (summary) summaryMap.set(row.chatId, summary);
|
|
2836
|
-
}
|
|
2837
|
-
const last = items[items.length - 1];
|
|
2838
|
-
const nextCursor = hasMore && last ? last.updatedAt.toISOString() : null;
|
|
2839
|
-
return {
|
|
2840
|
-
items: items.map((r) => ({
|
|
2841
|
-
agentId: r.agentId,
|
|
2842
|
-
chatId: r.chatId,
|
|
2843
|
-
state: r.state,
|
|
2844
|
-
runtimeState: runtimeMap.get(r.agentId) ?? null,
|
|
2845
|
-
startedAt: r.chatCreatedAt.toISOString(),
|
|
2846
|
-
lastActivityAt: r.updatedAt.toISOString(),
|
|
2847
|
-
messageCount: 0,
|
|
2848
|
-
summary: summaryMap.get(r.chatId) ?? null,
|
|
2849
|
-
topic: r.chatTopic ?? null
|
|
2850
|
-
})),
|
|
2851
|
-
nextCursor
|
|
2852
|
-
};
|
|
2853
|
-
}
|
|
2854
|
-
/** Commit `active → suspended`. No-op on suspended/evicted. Throws if row is missing. */
|
|
2855
|
-
async function suspendSession(db, agentId, chatId, organizationId, notifier) {
|
|
2856
|
-
return transitionSessionState(db, agentId, chatId, "suspended", ["active"], organizationId, notifier);
|
|
2857
|
-
}
|
|
2858
|
-
/** Commit `suspended → evicted` (terminal — listings hide it, revival defense blocks resurrection). */
|
|
2859
|
-
async function archiveSession(db, agentId, chatId, organizationId, notifier) {
|
|
2860
|
-
return transitionSessionState(db, agentId, chatId, "evicted", ["suspended"], organizationId, notifier);
|
|
2861
|
-
}
|
|
2862
|
-
async function transitionSessionState(db, agentId, chatId, target, from, organizationId, notifier) {
|
|
2863
|
-
const now = /* @__PURE__ */ new Date();
|
|
2864
|
-
let finalState = null;
|
|
2865
|
-
let transitioned = false;
|
|
2866
|
-
await db.transaction(async (tx) => {
|
|
2867
|
-
const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
|
|
2868
|
-
if (!existing) return;
|
|
2869
|
-
const current = existing.state;
|
|
2870
|
-
finalState = current;
|
|
2871
|
-
if (!from.includes(current)) return;
|
|
2872
|
-
await tx.update(agentChatSessions).set({
|
|
2873
|
-
state: target,
|
|
2874
|
-
updatedAt: now
|
|
2875
|
-
}).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId)));
|
|
2876
|
-
const [counts] = await tx.select({
|
|
2877
|
-
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
2878
|
-
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
2879
|
-
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
2880
|
-
await tx.update(agentPresence).set({
|
|
2881
|
-
activeSessions: counts?.active ?? 0,
|
|
2882
|
-
totalSessions: counts?.total ?? 0,
|
|
2883
|
-
lastSeenAt: now
|
|
2884
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
2885
|
-
if (target === "evicted") await markSupersededByChat(tx, chatId, "chat_archived");
|
|
2886
|
-
finalState = target;
|
|
2887
|
-
transitioned = true;
|
|
2888
|
-
});
|
|
2889
|
-
if (finalState === null) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
2890
|
-
if (transitioned && notifier) notifier.notifySessionStateChange(agentId, chatId, target, organizationId).catch(() => {});
|
|
2891
|
-
return {
|
|
2892
|
-
state: finalState,
|
|
2893
|
-
transitioned
|
|
2894
|
-
};
|
|
2895
|
-
}
|
|
2896
|
-
/**
|
|
2897
|
-
* Filter sessions to only those where the given agent is also a participant in the chat.
|
|
2898
|
-
* Used when a non-manager views sessions of an org-visible agent — they should only see
|
|
2899
|
-
* sessions for chats they participate in.
|
|
2900
|
-
*/
|
|
2901
|
-
async function filterSessionsByParticipant(db, sessions, participantAgentId) {
|
|
2902
|
-
if (sessions.length === 0) return [];
|
|
2903
|
-
const chatIds = sessions.map((s) => s.chatId);
|
|
2904
|
-
const participantRows = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.agentId, participantAgentId), eq(chatMembership.accessMode, "speaker")));
|
|
2905
|
-
const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
|
|
2906
|
-
return sessions.filter((s) => allowedChatIds.has(s.chatId));
|
|
2907
|
-
}
|
|
2908
|
-
/**
|
|
2909
|
-
* Member-facing chat service backing `/me/chats*` endpoints (chat-first
|
|
2910
|
-
* workspace).
|
|
2911
|
-
*
|
|
2912
|
-
* Responsibilities:
|
|
2913
|
-
* - Cursor-paginated conversation list (single-stream JOIN over the
|
|
2914
|
-
* unified `chat_membership` + `chat_user_state` tables).
|
|
2915
|
-
* - Create a new chat (no dedupe, runs `recomputeChatWatchers` after).
|
|
2916
|
-
* - Add participants (idempotent, UPSERT into `chat_membership`,
|
|
2917
|
-
* runs `recomputeChatWatchers` after).
|
|
2918
|
-
* - Mark-read (UPSERT into `chat_user_state`).
|
|
2919
|
-
* - Join → watcher to speaker (delegates to `watcher.ts`).
|
|
2920
|
-
* - Leave → speaker to watcher or detach (delegates to `watcher.ts`).
|
|
2921
|
-
*
|
|
2922
|
-
* See proposals/chat-data-model-restructure.20260512.md §8 (schema)
|
|
2923
|
-
* and §11.1 (per-route mapping).
|
|
2924
|
-
*/
|
|
2925
|
-
function encodeCursor(lastMessageAt, chatId) {
|
|
2926
|
-
const payload = `${lastMessageAt ? lastMessageAt.toISOString() : ""}|${chatId}`;
|
|
2927
|
-
return Buffer.from(payload, "utf8").toString("base64url");
|
|
2928
|
-
}
|
|
2929
|
-
function decodeCursor(cursor) {
|
|
2930
|
-
try {
|
|
2931
|
-
const decoded = Buffer.from(cursor, "base64url").toString("utf8");
|
|
2932
|
-
const sep = decoded.indexOf("|");
|
|
2933
|
-
if (sep < 0) return null;
|
|
2934
|
-
const tsPart = decoded.slice(0, sep);
|
|
2935
|
-
const chatId = decoded.slice(sep + 1);
|
|
2936
|
-
if (!chatId) return null;
|
|
2937
|
-
const lastMessageAt = tsPart.length > 0 ? new Date(tsPart) : null;
|
|
2938
|
-
if (lastMessageAt && Number.isNaN(lastMessageAt.getTime())) return null;
|
|
2939
|
-
return {
|
|
2940
|
-
lastMessageAt,
|
|
2941
|
-
chatId
|
|
2942
|
-
};
|
|
2943
|
-
} catch {
|
|
2944
|
-
return null;
|
|
2945
|
-
}
|
|
2946
|
-
}
|
|
2947
|
-
const { ACTIVE, ARCHIVED, DELETED } = CHAT_ENGAGEMENT_STATUSES;
|
|
2948
|
-
/**
|
|
2949
|
-
* SQL predicate for each engagement view tab. `deleted` is never a valid view
|
|
2950
|
-
* value — deleted rows are reachable only through `GET /chats/:chatId` + the
|
|
2951
|
-
* Restore banner on the chat detail page.
|
|
2952
|
-
*/
|
|
2953
|
-
const ENGAGEMENT_VIEW_PREDICATE = {
|
|
2954
|
-
active: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ACTIVE}`,
|
|
2955
|
-
archived: sql`COALESCE(cus.engagement_status, ${ACTIVE}) = ${ARCHIVED}`,
|
|
2956
|
-
all: sql`COALESCE(cus.engagement_status, ${ACTIVE}) IN (${ACTIVE}, ${ARCHIVED})`
|
|
2957
|
-
};
|
|
2958
|
-
/**
|
|
2959
|
-
* Write the caller's engagement state for this chat. UPSERT into
|
|
2960
|
-
* `chat_user_state` — the row may not yet exist (the user might not have
|
|
2961
|
-
* marked-read or been @-mentioned), so an INSERT with the engagement value
|
|
2962
|
-
* is the first write; subsequent transitions are UPDATEs.
|
|
2963
|
-
*
|
|
2964
|
-
* Idempotent. Mirrors the UPSERT shape used by `markMeChatRead`.
|
|
2965
|
-
*/
|
|
2966
|
-
async function setChatEngagement(db, chatId, agentId, status) {
|
|
2967
|
-
await db.insert(chatUserState).values({
|
|
2968
|
-
chatId,
|
|
2969
|
-
agentId,
|
|
2970
|
-
unreadMentionCount: 0,
|
|
2971
|
-
engagementStatus: status
|
|
2972
|
-
}).onConflictDoUpdate({
|
|
2973
|
-
target: [chatUserState.chatId, chatUserState.agentId],
|
|
2974
|
-
set: { engagementStatus: status }
|
|
2975
|
-
});
|
|
2976
|
-
}
|
|
2977
|
-
/**
|
|
2978
|
-
* Read the caller's engagement state. Returns `'active'` when no
|
|
2979
|
-
* `chat_user_state` row exists yet (lazy-materialised; matches the SQL
|
|
2980
|
-
* `COALESCE(..., 'active')` used elsewhere).
|
|
2981
|
-
*/
|
|
2982
|
-
async function getCallerEngagement(db, chatId, agentId) {
|
|
2983
|
-
const [row] = await db.select({ engagementStatus: chatUserState.engagementStatus }).from(chatUserState).where(and(eq(chatUserState.chatId, chatId), eq(chatUserState.agentId, agentId))).limit(1);
|
|
2984
|
-
return row?.engagementStatus ?? ACTIVE;
|
|
2985
|
-
}
|
|
2986
|
-
const KNOWN_NON_MANUAL_PREDICATE = sql`(
|
|
2987
|
-
(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' IN (${sql.join(GITHUB_ENTITY_TYPES.map((t) => sql`${t}`), sql.raw(", "))}))
|
|
2988
|
-
OR c.metadata->>'source' = 'feishu'
|
|
2989
|
-
)`;
|
|
2990
|
-
const chatSourceSqlExpression = sql`CASE
|
|
2991
|
-
${sql.join(GITHUB_ENTITY_TYPES.map((t) => sql`WHEN c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = ${t} THEN ${`github_${t}`}`), sql.raw("\n "))}
|
|
2992
|
-
WHEN c.metadata->>'source' = 'feishu' THEN 'feishu'
|
|
2993
|
-
ELSE 'manual'
|
|
2994
|
-
END`;
|
|
2995
|
-
function sourceFilterSql(source) {
|
|
2996
|
-
switch (source) {
|
|
2997
|
-
case "manual": return sql`(${KNOWN_NON_MANUAL_PREDICATE}) IS NOT TRUE`;
|
|
2998
|
-
case "github_issue": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'issue')`;
|
|
2999
|
-
case "github_pull_request": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'pull_request')`;
|
|
3000
|
-
case "github_discussion": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'discussion')`;
|
|
3001
|
-
case "github_commit": return sql`(c.metadata->>'source' = 'github' AND c.metadata->>'entityType' = 'commit')`;
|
|
3002
|
-
case "feishu": return sql`(c.metadata->>'source' = 'feishu')`;
|
|
3003
|
-
}
|
|
3004
|
-
}
|
|
3005
|
-
/**
|
|
3006
|
-
* GET /me/chats — cursor-paginated conversation list.
|
|
3007
|
-
*
|
|
3008
|
-
* SQL strategy:
|
|
3009
|
-
* - Single-stream query: `chats JOIN chat_membership LEFT JOIN
|
|
3010
|
-
* chat_user_state`. The membership row carries access_mode
|
|
3011
|
-
* (speaker → "participant" / watcher → "watching"); the user
|
|
3012
|
-
* state row supplies the unread counter (COALESCE → 0 when
|
|
3013
|
-
* row is missing).
|
|
3014
|
-
* - Filter `parent_chat_id IS NULL` (nested chats not surfaced in v1).
|
|
3015
|
-
* - Filter `c.organization_id = ?` to defend against historical
|
|
3016
|
-
* cross-org pollution rows that may still reference the caller
|
|
3017
|
-
* (see fix/cross-org-direct-chat-pollution).
|
|
3018
|
-
* - Sort `(last_message_at DESC NULLS LAST, chat_id DESC)`.
|
|
3019
|
-
* - Cursor narrows the result to rows STRICTLY before the cursor.
|
|
3020
|
-
* - Followed by a participants-list lookup for the page only.
|
|
3021
|
-
*/
|
|
3022
|
-
async function listMeChats(db, humanAgentId, organizationId, query) {
|
|
3023
|
-
const limit = query.limit;
|
|
3024
|
-
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
|
3025
|
-
if (query.cursor && !cursor) throw new BadRequestError("Invalid cursor");
|
|
3026
|
-
const filterUnreadOnly = query.filter === "unread";
|
|
3027
|
-
const filterWatchingOnly = query.filter === "watching";
|
|
3028
|
-
const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
|
|
3029
|
-
const sourcePredicate = query.source ? sourceFilterSql(query.source) : sql`TRUE`;
|
|
3030
|
-
const cursorTsIso = cursor?.lastMessageAt ? cursor.lastMessageAt.toISOString() : null;
|
|
3031
|
-
const cursorPredicate = !cursor ? sql`TRUE` : cursor.lastMessageAt === null ? sql`(c.last_message_at IS NULL AND c.id < ${cursor.chatId})` : sql`(c.last_message_at IS NULL
|
|
3032
|
-
OR c.last_message_at < ${cursorTsIso}::timestamptz
|
|
3033
|
-
OR (c.last_message_at = ${cursorTsIso}::timestamptz AND c.id < ${cursor.chatId}))`;
|
|
3034
|
-
const rawRows = await db.execute(sql`
|
|
3035
|
-
SELECT
|
|
3036
|
-
c.id AS chat_id,
|
|
3037
|
-
c.type AS type,
|
|
3038
|
-
c.topic AS topic,
|
|
3039
|
-
c.parent_chat_id AS parent_chat_id,
|
|
3040
|
-
c.last_message_at AS last_message_at,
|
|
3041
|
-
c.last_message_preview AS last_message_preview,
|
|
3042
|
-
(SELECT count(*) FROM chat_membership
|
|
3043
|
-
WHERE chat_id = c.id AND access_mode = 'speaker') AS participant_count,
|
|
3044
|
-
cm.access_mode AS access_mode,
|
|
3045
|
-
COALESCE(cus.unread_mention_count, 0) AS unread_mention_count,
|
|
3046
|
-
COALESCE(cus.engagement_status, ${ACTIVE}) AS engagement_status
|
|
3047
|
-
FROM chats c
|
|
3048
|
-
JOIN chat_membership cm
|
|
3049
|
-
ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
|
|
3050
|
-
LEFT JOIN chat_user_state cus
|
|
3051
|
-
ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
|
|
3052
|
-
WHERE c.parent_chat_id IS NULL
|
|
3053
|
-
/* Scope to the caller's org. Without this, cross-org dirty
|
|
3054
|
-
chats whose chat_membership still references the caller's
|
|
3055
|
-
human agent (historical pollution — see
|
|
3056
|
-
fix/cross-org-direct-chat-pollution) would leak into the
|
|
3057
|
-
list and 404 on click via requireChatAccess. */
|
|
3058
|
-
AND c.organization_id = ${organizationId}
|
|
3059
|
-
AND (${!filterUnreadOnly}::bool OR COALESCE(cus.unread_mention_count, 0) > 0)
|
|
3060
|
-
AND (${!filterWatchingOnly}::bool OR cm.access_mode = 'watcher')
|
|
3061
|
-
AND ${engagementPredicate}
|
|
3062
|
-
AND ${sourcePredicate}
|
|
3063
|
-
AND ${cursorPredicate}
|
|
3064
|
-
ORDER BY c.last_message_at DESC NULLS LAST, c.id DESC
|
|
3065
|
-
LIMIT ${limit + 1}
|
|
3066
|
-
`);
|
|
3067
|
-
const toDate = (v) => {
|
|
3068
|
-
if (v === null) return null;
|
|
3069
|
-
return v instanceof Date ? v : new Date(v);
|
|
3070
|
-
};
|
|
3071
|
-
const hasMore = rawRows.length > limit;
|
|
3072
|
-
const pageRaw = hasMore ? rawRows.slice(0, limit) : rawRows;
|
|
3073
|
-
const last = pageRaw[pageRaw.length - 1];
|
|
3074
|
-
const nextCursor = hasMore && last ? encodeCursor(toDate(last.last_message_at), last.chat_id) : null;
|
|
3075
|
-
if (pageRaw.length === 0) return {
|
|
3076
|
-
rows: [],
|
|
3077
|
-
nextCursor: null
|
|
3078
|
-
};
|
|
3079
|
-
const chatIds = pageRaw.map((r) => r.chat_id);
|
|
3080
|
-
const participantRows = await db.select({
|
|
3081
|
-
chatId: chatMembership.chatId,
|
|
3082
|
-
agentId: chatMembership.agentId,
|
|
3083
|
-
displayName: agents.displayName,
|
|
3084
|
-
type: agents.type,
|
|
3085
|
-
avatarColorToken: agents.avatarColorToken,
|
|
3086
|
-
avatarImageUpdatedAt: agents.avatarImageUpdatedAt,
|
|
3087
|
-
sessionState: agentChatSessions.state
|
|
3088
|
-
}).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).leftJoin(agentChatSessions, and(eq(agentChatSessions.agentId, chatMembership.agentId), eq(agentChatSessions.chatId, chatMembership.chatId))).where(and(inArray(chatMembership.chatId, chatIds), eq(chatMembership.accessMode, "speaker")));
|
|
3089
|
-
const participantsByChat = /* @__PURE__ */ new Map();
|
|
3090
|
-
const engagedByChat = /* @__PURE__ */ new Map();
|
|
3091
|
-
for (const p of participantRows) {
|
|
3092
|
-
const list = participantsByChat.get(p.chatId) ?? [];
|
|
3093
|
-
list.push({
|
|
3094
|
-
agentId: p.agentId,
|
|
3095
|
-
displayName: p.displayName,
|
|
3096
|
-
type: p.type,
|
|
3097
|
-
avatarColorToken: p.avatarColorToken,
|
|
3098
|
-
avatarImageUrl: agentAvatarImageUrl(p.agentId, p.avatarImageUpdatedAt)
|
|
3099
|
-
});
|
|
3100
|
-
participantsByChat.set(p.chatId, list);
|
|
3101
|
-
if (p.sessionState === "active") {
|
|
3102
|
-
const engaged = engagedByChat.get(p.chatId) ?? [];
|
|
3103
|
-
engaged.push(p.agentId);
|
|
3104
|
-
engagedByChat.set(p.chatId, engaged);
|
|
3105
|
-
}
|
|
3106
|
-
}
|
|
3107
|
-
const liveActivityByChat = await deriveLiveActivity(db, chatIds);
|
|
3108
|
-
const firstMessageRows = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
3109
|
-
chatId: messages.chatId,
|
|
3110
|
-
content: messages.content
|
|
3111
|
-
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
3112
|
-
const firstMessageSummary = /* @__PURE__ */ new Map();
|
|
3113
|
-
for (const row of firstMessageRows) {
|
|
3114
|
-
const s = extractSummary(row.content);
|
|
3115
|
-
if (s) firstMessageSummary.set(row.chatId, s);
|
|
3116
|
-
}
|
|
3117
|
-
return {
|
|
3118
|
-
rows: pageRaw.map((r) => {
|
|
3119
|
-
const participants = participantsByChat.get(r.chat_id) ?? [];
|
|
3120
|
-
const title = resolveChatTitle(r.topic, firstMessageSummary.get(r.chat_id) ?? null, participants, humanAgentId);
|
|
3121
|
-
const isSpeaker = r.access_mode === "speaker";
|
|
3122
|
-
return {
|
|
3123
|
-
chatId: r.chat_id,
|
|
3124
|
-
type: r.type,
|
|
3125
|
-
membershipKind: isSpeaker ? "participant" : "watching",
|
|
3126
|
-
title,
|
|
3127
|
-
topic: r.topic,
|
|
3128
|
-
participants,
|
|
3129
|
-
participantCount: Number(r.participant_count),
|
|
3130
|
-
lastMessageAt: toDate(r.last_message_at)?.toISOString() ?? null,
|
|
3131
|
-
lastMessagePreview: r.last_message_preview,
|
|
3132
|
-
unreadMentionCount: r.unread_mention_count,
|
|
3133
|
-
canReply: isSpeaker,
|
|
3134
|
-
engagementStatus: r.engagement_status,
|
|
3135
|
-
engagedAgentIds: engagedByChat.get(r.chat_id) ?? [],
|
|
3136
|
-
liveActivity: liveActivityByChat.get(r.chat_id) ?? null
|
|
3137
|
-
};
|
|
3138
|
-
}),
|
|
3139
|
-
nextCursor
|
|
3140
|
-
};
|
|
3141
|
-
}
|
|
3142
|
-
/**
|
|
3143
|
-
* Per-chat live activity, derived from the most recent `session_events` row.
|
|
3144
|
-
*
|
|
3145
|
-
* Returns a chatId → LiveActivity map; chats with no activity (or where the
|
|
3146
|
-
* latest event is terminal / stale) are absent from the map (caller treats
|
|
3147
|
-
* absence as null).
|
|
3148
|
-
*/
|
|
3149
|
-
async function deriveLiveActivity(db, chatIds) {
|
|
3150
|
-
if (chatIds.length === 0) return /* @__PURE__ */ new Map();
|
|
3151
|
-
const chatIdInClause = sql.join(chatIds.map((id) => sql`${id}`), sql`, `);
|
|
3152
|
-
const rows = (await db.execute(sql`
|
|
3153
|
-
SELECT acs.agent_id AS agent_id,
|
|
3154
|
-
acs.chat_id AS chat_id,
|
|
3155
|
-
e.kind AS kind,
|
|
3156
|
-
e.payload AS payload,
|
|
3157
|
-
e.created_at AS created_at
|
|
3158
|
-
FROM agent_chat_sessions acs
|
|
3159
|
-
CROSS JOIN LATERAL (
|
|
3160
|
-
SELECT kind, payload, created_at, seq
|
|
3161
|
-
FROM session_events se
|
|
3162
|
-
WHERE se.agent_id = acs.agent_id
|
|
3163
|
-
AND se.chat_id = acs.chat_id
|
|
3164
|
-
ORDER BY se.seq DESC
|
|
3165
|
-
LIMIT 1
|
|
3166
|
-
) e
|
|
3167
|
-
WHERE acs.chat_id IN (${chatIdInClause})
|
|
3168
|
-
AND acs.state <> 'evicted'
|
|
3169
|
-
`)).map((r) => ({
|
|
3170
|
-
agent_id: r.agent_id,
|
|
3171
|
-
chat_id: r.chat_id,
|
|
3172
|
-
kind: r.kind,
|
|
3173
|
-
payload: r.payload,
|
|
3174
|
-
created_at: r.created_at
|
|
3175
|
-
}));
|
|
3176
|
-
const now = Date.now();
|
|
3177
|
-
const byChat = /* @__PURE__ */ new Map();
|
|
3178
|
-
for (const row of rows) {
|
|
3179
|
-
const activity = toLiveActivity(row);
|
|
3180
|
-
if (!activity) continue;
|
|
3181
|
-
const createdAtMs = new Date(row.created_at).getTime();
|
|
3182
|
-
if (now - createdAtMs > 6e4) continue;
|
|
3183
|
-
const existing = byChat.get(row.chat_id);
|
|
3184
|
-
if (!existing || createdAtMs > existing.createdAtMs) byChat.set(row.chat_id, {
|
|
3185
|
-
activity,
|
|
3186
|
-
createdAtMs
|
|
3187
|
-
});
|
|
3188
|
-
}
|
|
3189
|
-
const out = /* @__PURE__ */ new Map();
|
|
3190
|
-
for (const [chatId, { activity }] of byChat) out.set(chatId, activity);
|
|
3191
|
-
return out;
|
|
3192
|
-
}
|
|
3193
|
-
/**
|
|
3194
|
-
* Translate a `session_events` row into a `LiveActivity`, or null when the
|
|
3195
|
-
* kind is terminal (`turn_end` / `error`) or unrecognised. Pure & exported
|
|
3196
|
-
* for unit testing.
|
|
3197
|
-
*/
|
|
3198
|
-
function toLiveActivity(row) {
|
|
3199
|
-
const startedAt = new Date(row.created_at).toISOString();
|
|
3200
|
-
switch (row.kind) {
|
|
3201
|
-
case "tool_call": {
|
|
3202
|
-
const payload = row.payload ?? {};
|
|
3203
|
-
const label = typeof payload.name === "string" && payload.name.length > 0 ? payload.name : "Tool";
|
|
3204
|
-
return {
|
|
3205
|
-
agentId: row.agent_id,
|
|
3206
|
-
kind: "tool_call",
|
|
3207
|
-
label,
|
|
3208
|
-
startedAt
|
|
3209
|
-
};
|
|
3210
|
-
}
|
|
3211
|
-
case "thinking": return {
|
|
3212
|
-
agentId: row.agent_id,
|
|
3213
|
-
kind: "thinking",
|
|
3214
|
-
label: "Thinking",
|
|
3215
|
-
startedAt
|
|
3216
|
-
};
|
|
3217
|
-
case "assistant_text": return {
|
|
3218
|
-
agentId: row.agent_id,
|
|
3219
|
-
kind: "assistant_text",
|
|
3220
|
-
label: "Writing",
|
|
3221
|
-
startedAt
|
|
3222
|
-
};
|
|
3223
|
-
default: return null;
|
|
3224
|
-
}
|
|
3225
|
-
}
|
|
3226
|
-
/**
|
|
3227
|
-
* Title resolution priority:
|
|
3228
|
-
*
|
|
3229
|
-
* 1. `chat.topic` (manual, set via `PATCH /chats/:chatId`)
|
|
3230
|
-
* 2. First message summary (auto, ≤ 50 chars from `extractSummary`)
|
|
3231
|
-
* 3. Participant join (fallback when chat has no messages yet)
|
|
3232
|
-
*/
|
|
3233
|
-
function resolveChatTitle(topic, firstMessageSummary, participants, selfAgentId) {
|
|
3234
|
-
if (topic && topic.length > 0) return topic;
|
|
3235
|
-
if (firstMessageSummary && firstMessageSummary.length > 0) return firstMessageSummary;
|
|
3236
|
-
const others = participants.filter((p) => p.agentId !== selfAgentId);
|
|
3237
|
-
if (others.length === 0) return "Empty chat";
|
|
3238
|
-
if (others.length <= 3) return others.map((p) => p.displayName).join(", ");
|
|
3239
|
-
return `${others[0]?.displayName}, ${others[1]?.displayName} +${others.length - 2}`;
|
|
3240
|
-
}
|
|
3241
|
-
async function createMeChat(db, humanAgentId, organizationId, body) {
|
|
3242
|
-
const distinctIds = [...new Set(body.participantIds)].filter((id) => id !== humanAgentId);
|
|
3243
|
-
if (distinctIds.length === 0) throw new BadRequestError("At least one non-self participant required");
|
|
3244
|
-
const allIds = [humanAgentId, ...distinctIds];
|
|
3245
|
-
const found = await db.select({
|
|
3246
|
-
uuid: agents.uuid,
|
|
3247
|
-
organizationId: agents.organizationId,
|
|
3248
|
-
type: agents.type,
|
|
3249
|
-
visibility: agents.visibility,
|
|
3250
|
-
managerId: agents.managerId
|
|
3251
|
-
}).from(agents).where(inArray(agents.uuid, allIds));
|
|
3252
|
-
if (found.length !== allIds.length) {
|
|
3253
|
-
const foundSet = new Set(found.map((a) => a.uuid));
|
|
3254
|
-
throw new BadRequestError(`Agents not found: ${allIds.filter((id) => !foundSet.has(id)).join(", ")}`);
|
|
3255
|
-
}
|
|
3256
|
-
const crossOrg = found.filter((a) => a.organizationId !== organizationId);
|
|
3257
|
-
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.uuid).join(", ")}`);
|
|
3258
|
-
const caller = found.find((a) => a.uuid === humanAgentId);
|
|
3259
|
-
if (!caller) throw new BadRequestError("Caller agent not found in the chat's organization");
|
|
3260
|
-
const privateNotOwned = found.filter((a) => a.uuid !== humanAgentId && a.visibility === AGENT_VISIBILITY.PRIVATE && a.managerId !== caller.managerId);
|
|
3261
|
-
if (privateNotOwned.length > 0) throw new ForbiddenError(`Only the owner can add a private agent to a chat: ${privateNotOwned.map((a) => a.uuid).join(", ")}`);
|
|
3262
|
-
const chatType = distinctIds.length === 1 ? "direct" : "group";
|
|
3263
|
-
const chatId = randomUUID();
|
|
3264
|
-
const topic = body.topic ?? null;
|
|
3265
|
-
await db.transaction(async (tx) => {
|
|
3266
|
-
await tx.insert(chats).values({
|
|
3267
|
-
id: chatId,
|
|
3268
|
-
organizationId,
|
|
3269
|
-
type: chatType,
|
|
3270
|
-
topic
|
|
3271
|
-
});
|
|
3272
|
-
await addChatParticipants(tx, chatId, allIds.map((agentId) => ({
|
|
3273
|
-
agentId,
|
|
3274
|
-
role: agentId === humanAgentId ? "owner" : "member"
|
|
3275
|
-
})));
|
|
3276
|
-
await recomputeChatWatchers(tx, chatId);
|
|
3277
|
-
});
|
|
3278
|
-
invalidateChatAudience(chatId);
|
|
3279
|
-
return { chatId };
|
|
3280
|
-
}
|
|
3281
|
-
async function addMeChatParticipants(db, chatId, callerHumanAgentId, callerOrganizationId, body) {
|
|
3282
|
-
const distinct = [...new Set(body.participantIds)];
|
|
3283
|
-
if (distinct.length === 0) throw new BadRequestError("At least one participant required");
|
|
3284
|
-
const [chat] = await db.select({
|
|
3285
|
-
id: chats.id,
|
|
3286
|
-
organizationId: chats.organizationId,
|
|
3287
|
-
type: chats.type
|
|
3288
|
-
}).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
3289
|
-
if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
3290
|
-
if (chat.organizationId !== callerOrganizationId) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
3291
|
-
const [callerRow] = await db.select({ ownerMemberId: agents.managerId }).from(chatMembership).innerJoin(agents, eq(agents.uuid, chatMembership.agentId)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, callerHumanAgentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
|
|
3292
|
-
if (!callerRow) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
3293
|
-
const callerMemberId = callerRow.ownerMemberId;
|
|
3294
|
-
const found = await db.select({
|
|
3295
|
-
uuid: agents.uuid,
|
|
3296
|
-
organizationId: agents.organizationId,
|
|
3297
|
-
type: agents.type,
|
|
3298
|
-
visibility: agents.visibility,
|
|
3299
|
-
managerId: agents.managerId
|
|
3300
|
-
}).from(agents).where(inArray(agents.uuid, distinct));
|
|
3301
|
-
if (found.length !== distinct.length) {
|
|
3302
|
-
const foundSet = new Set(found.map((a) => a.uuid));
|
|
3303
|
-
throw new BadRequestError(`Agents not found: ${distinct.filter((id) => !foundSet.has(id)).join(", ")}`);
|
|
3304
|
-
}
|
|
3305
|
-
const crossOrg = found.filter((a) => a.organizationId !== chat.organizationId);
|
|
3306
|
-
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization participant rejected: ${crossOrg.map((a) => a.uuid).join(", ")}`);
|
|
3307
|
-
const privateNotOwned = found.filter((a) => a.visibility === AGENT_VISIBILITY.PRIVATE && a.managerId !== callerMemberId);
|
|
3308
|
-
if (privateNotOwned.length > 0) throw new ForbiddenError(`Only the owner can add a private agent to a chat: ${privateNotOwned.map((a) => a.uuid).join(", ")}`);
|
|
3309
|
-
await db.transaction(async (tx) => {
|
|
3310
|
-
const existingSpeakers = await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
3311
|
-
const existingSpeakerSet = new Set(existingSpeakers.map((e) => e.agentId));
|
|
3312
|
-
const toUpsert = distinct.filter((id) => !existingSpeakerSet.has(id));
|
|
3313
|
-
if (toUpsert.length === 0) {
|
|
3314
|
-
await recomputeChatWatchers(tx, chatId);
|
|
3315
|
-
return;
|
|
3316
|
-
}
|
|
3317
|
-
if (existingSpeakers.length + toUpsert.length >= 3 && chat.type === "direct") await changeChatType(tx, chatId, "group");
|
|
3318
|
-
await addChatParticipants(tx, chatId, toUpsert.map((agentId) => ({
|
|
3319
|
-
agentId,
|
|
3320
|
-
role: "member"
|
|
3321
|
-
})), { upgradeWatcherToSpeaker: true });
|
|
3322
|
-
await recomputeChatWatchers(tx, chatId);
|
|
3323
|
-
});
|
|
3324
|
-
invalidateChatAudience(chatId);
|
|
3325
|
-
}
|
|
3326
|
-
async function markMeChatRead(db, chatId, humanAgentId) {
|
|
3327
|
-
const now = /* @__PURE__ */ new Date();
|
|
3328
|
-
await db.insert(chatUserState).values({
|
|
3329
|
-
chatId,
|
|
3330
|
-
agentId: humanAgentId,
|
|
3331
|
-
lastReadAt: now,
|
|
3332
|
-
unreadMentionCount: 0
|
|
3333
|
-
}).onConflictDoUpdate({
|
|
3334
|
-
target: [chatUserState.chatId, chatUserState.agentId],
|
|
3335
|
-
set: {
|
|
3336
|
-
lastReadAt: now,
|
|
3337
|
-
unreadMentionCount: 0
|
|
3338
|
-
}
|
|
3339
|
-
});
|
|
3340
|
-
return {
|
|
3341
|
-
chatId,
|
|
3342
|
-
lastReadAt: now.toISOString(),
|
|
3343
|
-
unreadMentionCount: 0
|
|
3344
|
-
};
|
|
3345
|
-
}
|
|
3346
|
-
/**
|
|
3347
|
-
* Bump `unread_mention_count` to at least 1 so the chat shows up as unread
|
|
3348
|
-
* in the conversation list (and is matched by `?filter=unread`). Idempotent:
|
|
3349
|
-
* if the row already has a positive count, it stays as-is. `last_read_at`
|
|
3350
|
-
* is intentionally untouched — this is a UI affordance, not a "rewind the
|
|
3351
|
-
* read cursor" operation.
|
|
3352
|
-
*
|
|
3353
|
-
* Contract note — semantic overload: the column is named `unread_mention_count`
|
|
3354
|
-
* but is co-opted here as a generic "manual unread" flag. Every existing
|
|
3355
|
-
* consumer (conversation list bold styling, `?filter=unread`, source-counts,
|
|
3356
|
-
* the bell badge) only checks `> 0`, so the exact value carries no meaning
|
|
3357
|
-
* for callers. If a future feature ever renders the literal mention count
|
|
3358
|
-
* (e.g. a "N mentions" pill), it must NOT read this column directly — it
|
|
3359
|
-
* needs a separate mention-only counter, otherwise a manually-marked-unread
|
|
3360
|
-
* chat would show a fictitious "1 mention".
|
|
3361
|
-
*/
|
|
3362
|
-
async function markMeChatUnread(db, chatId, humanAgentId) {
|
|
3363
|
-
await db.insert(chatUserState).values({
|
|
3364
|
-
chatId,
|
|
3365
|
-
agentId: humanAgentId,
|
|
3366
|
-
unreadMentionCount: 1
|
|
3367
|
-
}).onConflictDoUpdate({
|
|
3368
|
-
target: [chatUserState.chatId, chatUserState.agentId],
|
|
3369
|
-
set: { unreadMentionCount: sql`GREATEST(${chatUserState.unreadMentionCount}, 1)` }
|
|
3370
|
-
});
|
|
3371
|
-
const [row] = await db.select({ unreadMentionCount: chatUserState.unreadMentionCount }).from(chatUserState).where(and(eq(chatUserState.chatId, chatId), eq(chatUserState.agentId, humanAgentId))).limit(1);
|
|
3372
|
-
return {
|
|
3373
|
-
chatId,
|
|
3374
|
-
unreadMentionCount: row?.unreadMentionCount ?? 1
|
|
3375
|
-
};
|
|
3376
|
-
}
|
|
3377
|
-
async function joinMeChat(db, chatId, humanAgentId) {
|
|
3378
|
-
ensureCanJoin(await resolveChatMembership(db, chatId, humanAgentId));
|
|
3379
|
-
await joinAsParticipant(db, chatId, humanAgentId);
|
|
3380
|
-
invalidateChatAudience(chatId);
|
|
3381
|
-
}
|
|
3382
|
-
async function leaveMeChat(db, chatId, humanAgentId) {
|
|
3383
|
-
const result = await leaveAsParticipant(db, chatId, humanAgentId);
|
|
3384
|
-
invalidateChatAudience(chatId);
|
|
3385
|
-
return result;
|
|
3386
|
-
}
|
|
3387
|
-
/**
|
|
3388
|
-
* Used by future bell-badge / list-pill counts. The partial index
|
|
3389
|
-
* `idx_user_state_unread WHERE unread_mention_count > 0` bounds the
|
|
3390
|
-
* driving scan; we then join `chat_membership` + `chats` so the badge
|
|
3391
|
-
* stays consistent with `listMeChats`.
|
|
3392
|
-
*
|
|
3393
|
-
* Why the joins (not just a single-table count): per §11.4 a user's
|
|
3394
|
-
* `chat_user_state` row is **preserved on detach** so read state
|
|
3395
|
-
* survives a leave/rejoin cycle. Without the membership join, any
|
|
3396
|
-
* preserved row with `unread_mention_count > 0` would keep
|
|
3397
|
-
* contributing to the badge even though the chat no longer appears in
|
|
3398
|
-
* the list. The `chats` join applies the same org-scoping +
|
|
3399
|
-
* `parent_chat_id IS NULL` filter as `listMeChats` so the two counts
|
|
3400
|
-
* cannot drift in the cross-org pollution or nested-chat cases either.
|
|
3401
|
-
*
|
|
3402
|
-
* Engagement parity: deleted chats are excluded from `listMeChats`
|
|
3403
|
-
* (any `engagement` view), so the badge must exclude them too — otherwise
|
|
3404
|
-
* the user sees an unread red dot for a chat they've removed from view.
|
|
3405
|
-
*/
|
|
3406
|
-
/**
|
|
3407
|
-
* Per-source aggregate for the conversation-list tag bar.
|
|
3408
|
-
*
|
|
3409
|
-
* Returns one row per source the caller has at least one chat for, plus an
|
|
3410
|
-
* always-present `manual` entry (zero counts when there are no manual chats —
|
|
3411
|
-
* the workspace UI uses `manual` as its default tab and must render it even
|
|
3412
|
-
* when empty).
|
|
3413
|
-
*
|
|
3414
|
-
* Filtering matches `listMeChats` for the corresponding tab so the badges
|
|
3415
|
-
* cannot drift from the list: same membership join, same `parent_chat_id IS
|
|
3416
|
-
* NULL` and `organization_id` scopes, same engagement view, same
|
|
3417
|
-
* `chat_user_state.unread_mention_count` source.
|
|
3418
|
-
*/
|
|
3419
|
-
async function listMeChatSourceCounts(db, humanAgentId, organizationId, query) {
|
|
3420
|
-
const engagementPredicate = ENGAGEMENT_VIEW_PREDICATE[query.engagement];
|
|
3421
|
-
const rows = await db.execute(sql`
|
|
3422
|
-
SELECT
|
|
3423
|
-
${chatSourceSqlExpression} AS source,
|
|
3424
|
-
count(*)::int AS chat_count,
|
|
3425
|
-
count(*) FILTER (WHERE COALESCE(cus.unread_mention_count, 0) > 0)::int AS unread_chat_count
|
|
3426
|
-
FROM chats c
|
|
3427
|
-
JOIN chat_membership cm
|
|
3428
|
-
ON cm.chat_id = c.id AND cm.agent_id = ${humanAgentId}
|
|
3429
|
-
LEFT JOIN chat_user_state cus
|
|
3430
|
-
ON cus.chat_id = c.id AND cus.agent_id = ${humanAgentId}
|
|
3431
|
-
WHERE c.parent_chat_id IS NULL
|
|
3432
|
-
AND c.organization_id = ${organizationId}
|
|
3433
|
-
AND ${engagementPredicate}
|
|
3434
|
-
GROUP BY 1
|
|
3435
|
-
`);
|
|
3436
|
-
const counts = {};
|
|
3437
|
-
for (const row of rows) counts[row.source] = {
|
|
3438
|
-
chatCount: Number(row.chat_count),
|
|
3439
|
-
unreadChatCount: Number(row.unread_chat_count)
|
|
3440
|
-
};
|
|
3441
|
-
if (!counts.manual) counts.manual = {
|
|
3442
|
-
chatCount: 0,
|
|
3443
|
-
unreadChatCount: 0
|
|
3444
|
-
};
|
|
3445
|
-
return { counts };
|
|
3446
|
-
}
|
|
3447
|
-
async function createChat(db, creatorId, data) {
|
|
3448
|
-
const chatId = randomUUID();
|
|
3449
|
-
const allParticipantIds = new Set([creatorId, ...data.participantIds]);
|
|
3450
|
-
const existingAgents = await db.select({
|
|
3451
|
-
id: agents.uuid,
|
|
3452
|
-
organizationId: agents.organizationId,
|
|
3453
|
-
type: agents.type,
|
|
3454
|
-
visibility: agents.visibility,
|
|
3455
|
-
managerId: agents.managerId
|
|
3456
|
-
}).from(agents).where(inArray(agents.uuid, [...allParticipantIds]));
|
|
3457
|
-
if (existingAgents.length !== allParticipantIds.size) {
|
|
3458
|
-
const found = new Set(existingAgents.map((a) => a.id));
|
|
3459
|
-
throw new BadRequestError(`Agents not found: ${[...allParticipantIds].filter((id) => !found.has(id)).join(", ")}`);
|
|
3460
|
-
}
|
|
3461
|
-
const creator = existingAgents.find((a) => a.id === creatorId);
|
|
3462
|
-
if (!creator) throw new Error("Unexpected: creator not in existingAgents");
|
|
3463
|
-
const orgId = creator.organizationId;
|
|
3464
|
-
const crossOrg = existingAgents.filter((a) => a.organizationId !== orgId);
|
|
3465
|
-
if (crossOrg.length > 0) throw new BadRequestError(`Cross-organization chat not allowed: ${crossOrg.map((a) => a.id).join(", ")}`);
|
|
3466
|
-
const privateNotOwned = existingAgents.filter((a) => a.id !== creatorId && a.visibility === AGENT_VISIBILITY.PRIVATE && a.managerId !== creator.managerId);
|
|
3467
|
-
if (privateNotOwned.length > 0) throw new ForbiddenError(`Only the owner can add a private agent to a chat: ${privateNotOwned.map((a) => a.id).join(", ")}`);
|
|
3468
|
-
return db.transaction(async (tx) => {
|
|
3469
|
-
const [chat] = await tx.insert(chats).values({
|
|
3470
|
-
id: chatId,
|
|
3471
|
-
organizationId: orgId,
|
|
3472
|
-
type: data.type,
|
|
3473
|
-
topic: data.topic ?? null,
|
|
3474
|
-
metadata: data.metadata ?? {}
|
|
3475
|
-
}).returning();
|
|
3476
|
-
await addChatParticipants(tx, chatId, [...allParticipantIds].map((agentId) => ({
|
|
3477
|
-
agentId,
|
|
3478
|
-
role: agentId === creatorId ? "owner" : "member"
|
|
3479
|
-
})));
|
|
3480
|
-
await recomputeChatWatchers(tx, chatId);
|
|
3481
|
-
const participants = await tx.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
3482
|
-
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
3483
|
-
return {
|
|
3484
|
-
...chat,
|
|
3485
|
-
participants
|
|
3486
|
-
};
|
|
3487
|
-
});
|
|
3488
|
-
}
|
|
3489
|
-
async function getChat(db, chatId) {
|
|
3490
|
-
const [chat] = await db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
3491
|
-
if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
3492
|
-
return chat;
|
|
3493
|
-
}
|
|
3494
|
-
/**
|
|
3495
|
-
* Read a chat row + speaker participants + server-resolved display
|
|
3496
|
-
* metadata (`title`, `firstMessagePreview`) so the agent route can return
|
|
3497
|
-
* a payload that matches the wire `chatDetailSchema` contract.
|
|
3498
|
-
*
|
|
3499
|
-
* `selfAgentId` only affects the participant-join fallback in
|
|
3500
|
-
* `resolveChatTitle` (e.g. `"alice, bob"` excluding self when topic + first
|
|
3501
|
-
* message are both empty). Callers that don't have a self agent (admin
|
|
3502
|
-
* paths) can pass `null` — the fallback degrades to "all displayNames".
|
|
3503
|
-
*/
|
|
3504
|
-
async function getChatDetail(db, chatId, selfAgentId = null) {
|
|
3505
|
-
const chat = await getChat(db, chatId);
|
|
3506
|
-
const participantRows = await db.select({
|
|
3507
|
-
agentId: chatMembership.agentId,
|
|
3508
|
-
role: chatMembership.role,
|
|
3509
|
-
mode: chatMembership.mode,
|
|
3510
|
-
joinedAt: chatMembership.joinedAt,
|
|
3511
|
-
name: agents.name,
|
|
3512
|
-
displayName: agents.displayName,
|
|
3513
|
-
type: agents.type
|
|
3514
|
-
}).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
3515
|
-
const [firstMessageRow] = await db.select({ content: messages.content }).from(messages).where(eq(messages.chatId, chatId)).orderBy(messages.createdAt, messages.id).limit(1);
|
|
3516
|
-
const firstMessagePreview = firstMessageRow ? extractSummary(firstMessageRow.content) : null;
|
|
3517
|
-
const title = resolveChatTitle(chat.topic, firstMessagePreview, participantRows, selfAgentId ?? "");
|
|
3518
|
-
const participants = participantRows.map((p) => ({
|
|
3519
|
-
chatId,
|
|
3520
|
-
agentId: p.agentId,
|
|
3521
|
-
role: p.role,
|
|
3522
|
-
mode: p.mode,
|
|
3523
|
-
joinedAt: p.joinedAt,
|
|
3524
|
-
name: p.name,
|
|
3525
|
-
displayName: p.displayName,
|
|
3526
|
-
type: p.type
|
|
3527
|
-
}));
|
|
3528
|
-
const viewerMembershipKind = await resolveViewerMembershipKind(db, chatId, selfAgentId);
|
|
3529
|
-
return {
|
|
3530
|
-
...chat,
|
|
3531
|
-
participants,
|
|
3532
|
-
title,
|
|
3533
|
-
firstMessagePreview,
|
|
3534
|
-
viewerMembershipKind
|
|
3535
|
-
};
|
|
3536
|
-
}
|
|
3537
|
-
async function resolveViewerMembershipKind(db, chatId, viewerAgentId) {
|
|
3538
|
-
if (!viewerAgentId) return null;
|
|
3539
|
-
const [row] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, viewerAgentId))).limit(1);
|
|
3540
|
-
if (!row) return null;
|
|
3541
|
-
return row.accessMode === "speaker" ? "participant" : "watching";
|
|
3542
|
-
}
|
|
3543
|
-
async function listChats(db, agentId, limit, cursor) {
|
|
3544
|
-
const chatIds = (await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker")))).map((r) => r.chatId);
|
|
3545
|
-
if (chatIds.length === 0) return {
|
|
3546
|
-
items: [],
|
|
3547
|
-
nextCursor: null
|
|
3548
|
-
};
|
|
3549
|
-
const where = cursor ? and(inArray(chats.id, chatIds), lt(chats.updatedAt, new Date(cursor))) : inArray(chats.id, chatIds);
|
|
3550
|
-
const rows = await db.select().from(chats).where(where).orderBy(desc(chats.updatedAt)).limit(limit + 1);
|
|
3551
|
-
const hasMore = rows.length > limit;
|
|
3552
|
-
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
3553
|
-
const last = items[items.length - 1];
|
|
3554
|
-
return {
|
|
3555
|
-
items,
|
|
3556
|
-
nextCursor: hasMore && last ? last.updatedAt.toISOString() : null
|
|
3557
|
-
};
|
|
3558
|
-
}
|
|
3559
|
-
/**
|
|
3560
|
-
* List participants of a chat with their agent names — used by the client
|
|
3561
|
-
* runtime to resolve `@<name>` mentions against the authoritative participant
|
|
3562
|
-
* set (see proposals/hub-agent-messaging-reply-and-mentions §4).
|
|
3563
|
-
*/
|
|
3564
|
-
async function listChatParticipantsWithNames(db, chatId) {
|
|
3565
|
-
return await db.select({
|
|
3566
|
-
agentId: chatMembership.agentId,
|
|
3567
|
-
role: chatMembership.role,
|
|
3568
|
-
mode: chatMembership.mode,
|
|
3569
|
-
joinedAt: chatMembership.joinedAt,
|
|
3570
|
-
name: agents.name,
|
|
3571
|
-
displayName: agents.displayName,
|
|
3572
|
-
type: agents.type
|
|
3573
|
-
}).from(chatMembership).innerJoin(agents, eq(chatMembership.agentId, agents.uuid)).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
3574
|
-
}
|
|
3575
|
-
async function assertParticipant(db, chatId, agentId) {
|
|
3576
|
-
const [row] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
|
|
3577
|
-
if (!row) throw new ForbiddenError("Not a participant of this chat");
|
|
3578
|
-
}
|
|
3579
|
-
/**
|
|
3580
|
-
* Non-throwing membership check. Used by routing logic that needs to fall
|
|
3581
|
-
* back to a different chat when the candidate target isn't a member of the
|
|
3582
|
-
* caller's current chat (see `sendToAgent`'s current-chat routing branch).
|
|
3583
|
-
*/
|
|
3584
|
-
async function isParticipant(db, chatId, agentId) {
|
|
3585
|
-
const [row] = await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId), eq(chatMembership.accessMode, "speaker"))).limit(1);
|
|
3586
|
-
return Boolean(row);
|
|
3587
|
-
}
|
|
3588
|
-
/** Ensure an agent is a speaker of a chat. Silently adds them if not already. */
|
|
3589
|
-
async function ensureParticipant(db, chatId, agentId) {
|
|
3590
|
-
const [existing] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, agentId))).limit(1);
|
|
3591
|
-
if (existing?.accessMode === "speaker") return;
|
|
3592
|
-
await db.transaction(async (tx) => {
|
|
3593
|
-
if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
|
|
3594
|
-
await addChatParticipants(tx, chatId, [{ agentId }], { upgradeWatcherToSpeaker: true });
|
|
3595
|
-
await recomputeChatWatchers(tx, chatId);
|
|
3596
|
-
});
|
|
3597
|
-
invalidateChatAudience(chatId);
|
|
3598
|
-
}
|
|
3599
|
-
async function addParticipant(db, chatId, requesterId, data) {
|
|
3600
|
-
const chat = await getChat(db, chatId);
|
|
3601
|
-
await assertParticipant(db, chatId, requesterId);
|
|
3602
|
-
const [targetAgent] = await db.select({
|
|
3603
|
-
id: agents.uuid,
|
|
3604
|
-
organizationId: agents.organizationId,
|
|
3605
|
-
inboxId: agents.inboxId,
|
|
3606
|
-
visibility: agents.visibility,
|
|
3607
|
-
managerId: agents.managerId
|
|
3608
|
-
}).from(agents).where(eq(agents.uuid, data.agentId)).limit(1);
|
|
3609
|
-
if (!targetAgent) throw new NotFoundError(`Agent "${data.agentId}" not found`);
|
|
3610
|
-
if (targetAgent.organizationId !== chat.organizationId) throw new BadRequestError("Cannot add agent from different organization");
|
|
3611
|
-
if (targetAgent.visibility === AGENT_VISIBILITY.PRIVATE && targetAgent.id !== requesterId) {
|
|
3612
|
-
const [requester] = await db.select({ managerId: agents.managerId }).from(agents).where(eq(agents.uuid, requesterId)).limit(1);
|
|
3613
|
-
if (!requester) throw new NotFoundError(`Agent "${requesterId}" not found`);
|
|
3614
|
-
if (requester.managerId !== targetAgent.managerId) throw new ForbiddenError(`Only the owner can add a private agent to a chat: ${targetAgent.id}`);
|
|
3615
|
-
}
|
|
3616
|
-
const [existing] = await db.select({ accessMode: chatMembership.accessMode }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, data.agentId))).limit(1);
|
|
3617
|
-
if (existing?.accessMode === "speaker") throw new ConflictError(`Agent "${data.agentId}" is already a participant`);
|
|
3618
|
-
await db.transaction(async (tx) => {
|
|
3619
|
-
if (wouldUpgradeToGroup((await tx.select({ agentId: chatMembership.agentId }).from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).length, 1)) await changeChatType(tx, chatId, "group");
|
|
3620
|
-
await addChatParticipants(tx, chatId, [{ agentId: data.agentId }], { upgradeWatcherToSpeaker: true });
|
|
3621
|
-
await recomputeChatWatchers(tx, chatId);
|
|
3622
|
-
await backfillSilentContextForNewParticipants(tx, chatId, [{ inboxId: targetAgent.inboxId }]);
|
|
3623
|
-
});
|
|
3624
|
-
invalidateChatAudience(chatId);
|
|
3625
|
-
return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
3626
|
-
}
|
|
3627
|
-
async function removeParticipant(db, chatId, requesterId, targetAgentId) {
|
|
3628
|
-
await assertParticipant(db, chatId, requesterId);
|
|
3629
|
-
if (requesterId === targetAgentId) throw new BadRequestError("Cannot remove yourself from a chat");
|
|
3630
|
-
const [removed] = await db.delete(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, targetAgentId), eq(chatMembership.accessMode, "speaker"))).returning();
|
|
3631
|
-
if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
|
|
3632
|
-
await recomputeChatWatchers(db, chatId);
|
|
3633
|
-
invalidateChatAudience(chatId);
|
|
3634
|
-
return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
3635
|
-
}
|
|
3636
|
-
/**
|
|
3637
|
-
* List chats visible to a member, grouped by agent.
|
|
3638
|
-
* A member sees chats where:
|
|
3639
|
-
* 1. Their human agent is a participant, OR
|
|
3640
|
-
* 2. Any agent they manage (managerId = memberId) is a participant (supervision)
|
|
3641
|
-
*/
|
|
3642
|
-
async function listChatsForMember(db, memberId, humanAgentId) {
|
|
3643
|
-
const managedAgents = await db.select({
|
|
3644
|
-
uuid: agents.uuid,
|
|
3645
|
-
name: agents.name,
|
|
3646
|
-
type: agents.type,
|
|
3647
|
-
displayName: agents.displayName
|
|
3648
|
-
}).from(agents).where(eq(agents.managerId, memberId));
|
|
3649
|
-
const agentMap = /* @__PURE__ */ new Map();
|
|
3650
|
-
for (const a of managedAgents) agentMap.set(a.uuid, a);
|
|
3651
|
-
if (!agentMap.has(humanAgentId)) {
|
|
3652
|
-
const [ha] = await db.select({
|
|
3653
|
-
uuid: agents.uuid,
|
|
3654
|
-
name: agents.name,
|
|
3655
|
-
type: agents.type,
|
|
3656
|
-
displayName: agents.displayName
|
|
3657
|
-
}).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
|
|
3658
|
-
if (ha) agentMap.set(ha.uuid, ha);
|
|
3659
|
-
}
|
|
3660
|
-
const agentIds = [...agentMap.keys()];
|
|
3661
|
-
if (agentIds.length === 0) return [];
|
|
3662
|
-
const participations = await db.select({
|
|
3663
|
-
chatId: chatMembership.chatId,
|
|
3664
|
-
agentId: chatMembership.agentId,
|
|
3665
|
-
role: chatMembership.role,
|
|
3666
|
-
mode: chatMembership.mode
|
|
3667
|
-
}).from(chatMembership).where(and(inArray(chatMembership.agentId, agentIds), eq(chatMembership.accessMode, "speaker")));
|
|
3668
|
-
if (participations.length === 0) return [];
|
|
3669
|
-
const chatIds = [...new Set(participations.map((p) => p.chatId))];
|
|
3670
|
-
const agentChatMap = /* @__PURE__ */ new Map();
|
|
3671
|
-
for (const p of participations) {
|
|
3672
|
-
const list = agentChatMap.get(p.agentId) ?? [];
|
|
3673
|
-
list.push(p.chatId);
|
|
3674
|
-
agentChatMap.set(p.agentId, list);
|
|
3675
|
-
}
|
|
3676
|
-
const chatRows = await db.select({
|
|
3677
|
-
id: chats.id,
|
|
3678
|
-
type: chats.type,
|
|
3679
|
-
topic: chats.topic,
|
|
3680
|
-
metadata: chats.metadata,
|
|
3681
|
-
createdAt: chats.createdAt,
|
|
3682
|
-
updatedAt: chats.updatedAt,
|
|
3683
|
-
participantCount: sql`(SELECT count(*)::int FROM chat_membership WHERE chat_id = ${chats.id} AND access_mode = 'speaker')`
|
|
3684
|
-
}).from(chats).where(inArray(chats.id, chatIds)).orderBy(desc(chats.updatedAt));
|
|
3685
|
-
const chatMap = new Map(chatRows.map((c) => [c.id, c]));
|
|
3686
|
-
const humanParticipantChatIds = new Set(participations.filter((p) => p.agentId === humanAgentId).map((p) => p.chatId));
|
|
3687
|
-
const result = [];
|
|
3688
|
-
for (const [agentId, agentChatIds] of agentChatMap) {
|
|
3689
|
-
const agentInfo = agentMap.get(agentId);
|
|
3690
|
-
if (!agentInfo) continue;
|
|
3691
|
-
const agentChats = agentChatIds.map((chatId) => {
|
|
3692
|
-
const chat = chatMap.get(chatId);
|
|
3693
|
-
if (!chat) return null;
|
|
3694
|
-
const isSupervisionOnly = agentId !== humanAgentId && !humanParticipantChatIds.has(chatId);
|
|
3695
|
-
return {
|
|
3696
|
-
id: chat.id,
|
|
3697
|
-
type: chat.type,
|
|
3698
|
-
topic: chat.topic,
|
|
3699
|
-
participantCount: chat.participantCount,
|
|
3700
|
-
isSupervisionOnly,
|
|
3701
|
-
createdAt: chat.createdAt.toISOString(),
|
|
3702
|
-
updatedAt: chat.updatedAt.toISOString()
|
|
3703
|
-
};
|
|
3704
|
-
}).filter((c) => c !== null);
|
|
3705
|
-
if (agentChats.length > 0) result.push({
|
|
3706
|
-
agent: agentInfo,
|
|
3707
|
-
chats: agentChats
|
|
3708
|
-
});
|
|
3709
|
-
}
|
|
3710
|
-
return result;
|
|
3711
|
-
}
|
|
3712
|
-
/**
|
|
3713
|
-
* Manager joins a chat. Adds their human agent as a participant.
|
|
3714
|
-
* Requires the member to have supervision rights (manages at least one existing participant).
|
|
3715
|
-
*/
|
|
3716
|
-
async function joinChat(db, chatId, memberId, humanAgentId) {
|
|
3717
|
-
const chat = await getChat(db, chatId);
|
|
3718
|
-
const participantAgentIds = (await db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")))).map((p) => p.agentId);
|
|
3719
|
-
if (participantAgentIds.length === 0) throw new NotFoundError("Chat has no participants");
|
|
3720
|
-
if (participantAgentIds.includes(humanAgentId)) throw new ConflictError("Already a participant in this chat");
|
|
3721
|
-
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");
|
|
3722
|
-
const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
|
|
3723
|
-
if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
|
|
3724
|
-
await db.transaction(async (tx) => {
|
|
3725
|
-
if (wouldUpgradeToGroup(participantAgentIds.length, 1)) await changeChatType(tx, chatId, "group");
|
|
3726
|
-
await addChatParticipants(tx, chatId, [{
|
|
3727
|
-
agentId: humanAgentId,
|
|
3728
|
-
role: "member"
|
|
3729
|
-
}], {
|
|
3730
|
-
assertHuman: true,
|
|
3731
|
-
upgradeWatcherToSpeaker: true
|
|
3732
|
-
});
|
|
3733
|
-
await recomputeChatWatchers(tx, chatId);
|
|
3734
|
-
});
|
|
3735
|
-
invalidateChatAudience(chatId);
|
|
3736
|
-
return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
3737
|
-
}
|
|
3738
|
-
/**
|
|
3739
|
-
* Manager leaves a chat. Removes their human agent from participants.
|
|
3740
|
-
* Only allowed if the human agent is a participant.
|
|
3741
|
-
*
|
|
3742
|
-
* Delegates the participant→watcher transition to `leaveAsParticipant`
|
|
3743
|
-
* so admin-side and `/me/chats/:id/leave` share one canonical path. The
|
|
3744
|
-
* earlier "recompute then UPDATE-back state" variant violated the design
|
|
3745
|
-
* rule that recompute is only for set rebuild — never on a transition
|
|
3746
|
-
* path (review #228 issue #2). The returned participant list is fetched
|
|
3747
|
-
* after the tx commits, matching the admin route's existing contract.
|
|
3748
|
-
*/
|
|
3749
|
-
async function leaveChat(db, chatId, humanAgentId) {
|
|
3750
|
-
await leaveAsParticipant(db, chatId, humanAgentId);
|
|
3751
|
-
invalidateChatAudience(chatId);
|
|
3752
|
-
return db.select().from(chatMembership).where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.accessMode, "speaker")));
|
|
3753
|
-
}
|
|
3754
|
-
async function findOrCreateDirectChat(db, agentAId, agentBId) {
|
|
3755
|
-
const ends = await db.select({
|
|
3756
|
-
uuid: agents.uuid,
|
|
3757
|
-
organizationId: agents.organizationId,
|
|
3758
|
-
type: agents.type,
|
|
3759
|
-
visibility: agents.visibility,
|
|
3760
|
-
managerId: agents.managerId
|
|
3761
|
-
}).from(agents).where(inArray(agents.uuid, [agentAId, agentBId]));
|
|
3762
|
-
const agentA = ends.find((a) => a.uuid === agentAId);
|
|
3763
|
-
if (!agentA) throw new NotFoundError(`Agent "${agentAId}" not found`);
|
|
3764
|
-
const agentB = ends.find((a) => a.uuid === agentBId);
|
|
3765
|
-
if (!agentB) throw new NotFoundError(`Agent "${agentBId}" not found`);
|
|
3766
|
-
if (agentA.organizationId !== agentB.organizationId) throw new BadRequestError(`Cannot create direct chat across organizations: agent "${agentAId}" (org "${agentA.organizationId}") vs agent "${agentBId}" (org "${agentB.organizationId}")`);
|
|
3767
|
-
const orgId = agentA.organizationId;
|
|
3768
|
-
const commonChatIds = (await db.select({ chatId: chatMembership.chatId }).from(chatMembership).where(and(inArray(chatMembership.agentId, [agentAId, agentBId]), eq(chatMembership.accessMode, "speaker"))).groupBy(chatMembership.chatId).having(sql`COUNT(DISTINCT ${chatMembership.agentId}) = 2`)).map((r) => r.chatId);
|
|
3769
|
-
if (commonChatIds.length > 0) {
|
|
3770
|
-
const directChats = await db.select().from(chats).where(and(inArray(chats.id, commonChatIds), eq(chats.type, "direct"), eq(chats.organizationId, orgId))).orderBy(chats.createdAt, chats.id).limit(1);
|
|
3771
|
-
if (directChats.length > 0 && directChats[0]) return directChats[0];
|
|
3772
|
-
}
|
|
3773
|
-
if ((agentA.visibility === AGENT_VISIBILITY.PRIVATE || agentB.visibility === AGENT_VISIBILITY.PRIVATE) && agentA.managerId !== agentB.managerId) throw new ForbiddenError(`Cannot open a direct chat with a private agent across owners: "${agentAId}" ↔ "${agentBId}"`);
|
|
3774
|
-
const chatId = randomUUID();
|
|
3775
|
-
return db.transaction(async (tx) => {
|
|
3776
|
-
const [chat] = await tx.insert(chats).values({
|
|
3777
|
-
id: chatId,
|
|
3778
|
-
organizationId: orgId,
|
|
3779
|
-
type: "direct"
|
|
3780
|
-
}).returning();
|
|
3781
|
-
await addChatParticipants(tx, chatId, [{
|
|
3782
|
-
agentId: agentAId,
|
|
3783
|
-
role: "member"
|
|
3784
|
-
}, {
|
|
3785
|
-
agentId: agentBId,
|
|
3786
|
-
role: "member"
|
|
3787
|
-
}]);
|
|
3788
|
-
await recomputeChatWatchers(tx, chatId);
|
|
3789
|
-
if (!chat) throw new Error("Unexpected: INSERT RETURNING produced no row");
|
|
3790
|
-
return chat;
|
|
3791
|
-
});
|
|
3792
|
-
}
|
|
3793
|
-
/** Server instance heartbeat. Used to detect crashed instances and clean up associated agent_presence records. */
|
|
3794
|
-
const serverInstances = pgTable("server_instances", {
|
|
3795
|
-
instanceId: text("instance_id").primaryKey(),
|
|
3796
|
-
lastHeartbeat: timestamp("last_heartbeat", { withTimezone: true }).notNull().defaultNow()
|
|
3797
|
-
});
|
|
3798
|
-
/** Common field reset when agent goes offline or is unbound. */
|
|
3799
|
-
function runtimeFieldsReset(now) {
|
|
3800
|
-
return {
|
|
3801
|
-
runtimeState: null,
|
|
3802
|
-
activeSessions: null,
|
|
3803
|
-
totalSessions: null,
|
|
3804
|
-
runtimeUpdatedAt: now,
|
|
3805
|
-
lastSeenAt: now
|
|
3806
|
-
};
|
|
3807
|
-
}
|
|
3808
|
-
async function setOffline(db, agentId) {
|
|
3809
|
-
const now = /* @__PURE__ */ new Date();
|
|
3810
|
-
await db.update(agentPresence).set({
|
|
3811
|
-
status: "offline",
|
|
3812
|
-
instanceId: null,
|
|
3813
|
-
...runtimeFieldsReset(now)
|
|
3814
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
3815
|
-
}
|
|
3816
|
-
async function getPresence(db, agentId) {
|
|
3817
|
-
const [row] = await db.select().from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
3818
|
-
return row ?? null;
|
|
3819
|
-
}
|
|
3820
|
-
async function getOnlineCount(db) {
|
|
3821
|
-
const [result] = await db.select({ count: sql`count(*)::int` }).from(agentPresence).where(eq(agentPresence.status, "online"));
|
|
3822
|
-
return result?.count ?? 0;
|
|
3823
|
-
}
|
|
3824
|
-
async function bindAgent(db, agentId, data) {
|
|
3825
|
-
const now = /* @__PURE__ */ new Date();
|
|
3826
|
-
await db.insert(agentPresence).values({
|
|
3827
|
-
agentId,
|
|
3828
|
-
status: "online",
|
|
3829
|
-
instanceId: data.instanceId,
|
|
3830
|
-
clientId: data.clientId,
|
|
3831
|
-
runtimeType: data.runtimeType,
|
|
3832
|
-
runtimeVersion: data.runtimeVersion ?? null,
|
|
3833
|
-
runtimeState: "idle",
|
|
3834
|
-
connectedAt: now,
|
|
3835
|
-
lastSeenAt: now,
|
|
3836
|
-
runtimeUpdatedAt: now
|
|
3837
|
-
}).onConflictDoUpdate({
|
|
3838
|
-
target: agentPresence.agentId,
|
|
3839
|
-
set: {
|
|
3840
|
-
status: "online",
|
|
3841
|
-
instanceId: data.instanceId,
|
|
3842
|
-
clientId: data.clientId,
|
|
3843
|
-
runtimeType: data.runtimeType,
|
|
3844
|
-
runtimeVersion: data.runtimeVersion ?? null,
|
|
3845
|
-
runtimeState: "idle",
|
|
3846
|
-
activeSessions: null,
|
|
3847
|
-
totalSessions: null,
|
|
3848
|
-
connectedAt: now,
|
|
3849
|
-
lastSeenAt: now,
|
|
3850
|
-
runtimeUpdatedAt: now
|
|
3851
|
-
}
|
|
3852
|
-
});
|
|
3853
|
-
}
|
|
3854
|
-
async function unbindAgent(db, agentId) {
|
|
3855
|
-
const now = /* @__PURE__ */ new Date();
|
|
3856
|
-
await db.update(agentPresence).set({
|
|
3857
|
-
status: "offline",
|
|
3858
|
-
clientId: null,
|
|
3859
|
-
...runtimeFieldsReset(now)
|
|
3860
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
3861
|
-
}
|
|
3862
|
-
/** Set runtime state directly from client-reported value.
|
|
3863
|
-
*
|
|
3864
|
-
* When an org-scoped notifier is provided, emit a PG NOTIFY on the
|
|
3865
|
-
* `runtime_state_changes` channel so the pulse aggregator (and any future
|
|
3866
|
-
* admin-side consumers) can observe the transition. Fire-and-forget to match
|
|
3867
|
-
* notifier semantics elsewhere in this module. */
|
|
3868
|
-
async function setRuntimeState(db, agentId, runtimeState, options) {
|
|
3869
|
-
const now = /* @__PURE__ */ new Date();
|
|
3870
|
-
await db.update(agentPresence).set({
|
|
3871
|
-
runtimeState,
|
|
3872
|
-
runtimeUpdatedAt: now,
|
|
3873
|
-
lastSeenAt: now
|
|
3874
|
-
}).where(eq(agentPresence.agentId, agentId));
|
|
3875
|
-
if (options?.notifier && options.organizationId) options.notifier.notifyRuntimeStateChange(agentId, runtimeState, options.organizationId).catch(() => {});
|
|
3876
|
-
}
|
|
3877
|
-
/** Touch agent last_seen_at on heartbeat (per-agent liveness). */
|
|
3878
|
-
async function touchAgent(db, agentId) {
|
|
3879
|
-
await db.update(agentPresence).set({ lastSeenAt: /* @__PURE__ */ new Date() }).where(eq(agentPresence.agentId, agentId));
|
|
3880
|
-
}
|
|
3881
|
-
async function heartbeatInstance(db, instanceId) {
|
|
3882
|
-
await db.insert(serverInstances).values({
|
|
3883
|
-
instanceId,
|
|
3884
|
-
lastHeartbeat: /* @__PURE__ */ new Date()
|
|
3885
|
-
}).onConflictDoUpdate({
|
|
3886
|
-
target: serverInstances.instanceId,
|
|
3887
|
-
set: { lastHeartbeat: /* @__PURE__ */ new Date() }
|
|
3888
|
-
});
|
|
3889
|
-
}
|
|
3890
|
-
/**
|
|
3891
|
-
* M1: Mark agents as offline whose last_seen_at is older than staleSeconds.
|
|
3892
|
-
* Unlike cleanupStalePresence (which checks instance liveness), this checks
|
|
3893
|
-
* per-agent heartbeat liveness — detecting agents that stopped heartbeating
|
|
3894
|
-
* while the client process may still be alive.
|
|
3895
|
-
*
|
|
3896
|
-
* Returns the list of agent IDs that were marked stale (for notification in Step 6).
|
|
3897
|
-
*/
|
|
3898
|
-
async function markStaleAgents(db, staleSeconds = 60) {
|
|
3899
|
-
return (await db.execute(sql`
|
|
3900
|
-
UPDATE agent_presence SET
|
|
3901
|
-
status = 'offline',
|
|
3902
|
-
client_id = NULL,
|
|
3903
|
-
runtime_state = NULL,
|
|
3904
|
-
active_sessions = NULL,
|
|
3905
|
-
total_sessions = NULL,
|
|
3906
|
-
runtime_updated_at = NOW()
|
|
3907
|
-
WHERE status = 'online'
|
|
3908
|
-
AND last_seen_at < NOW() - make_interval(secs => ${staleSeconds})
|
|
3909
|
-
RETURNING agent_id
|
|
3910
|
-
`)).map((r) => r.agent_id);
|
|
3911
|
-
}
|
|
3912
|
-
async function cleanupStalePresence(db, staleSeconds = 60) {
|
|
3913
|
-
return (await db.execute(sql`
|
|
3914
|
-
UPDATE agent_presence SET status = 'offline', instance_id = NULL,
|
|
3915
|
-
runtime_state = NULL,
|
|
3916
|
-
active_sessions = NULL, total_sessions = NULL,
|
|
3917
|
-
runtime_updated_at = NOW()
|
|
3918
|
-
WHERE instance_id IN (
|
|
3919
|
-
SELECT instance_id FROM server_instances
|
|
3920
|
-
WHERE last_heartbeat < NOW() - make_interval(secs => ${staleSeconds})
|
|
3921
|
-
)
|
|
3922
|
-
AND status = 'online'
|
|
3923
|
-
RETURNING agent_id
|
|
3924
|
-
`)).length;
|
|
3925
|
-
}
|
|
3926
|
-
/**
|
|
3927
|
-
* Assert the caller can act on this client. Throws 404 for both "not found"
|
|
3928
|
-
* and "not yours" to prevent UUID enumeration. The client is owned by exactly
|
|
3929
|
-
* one user; cross-user admin access is no longer supported by this code path
|
|
3930
|
-
* (see decouple-client-from-identity-design §4.10.5 option A). Cross-user
|
|
3931
|
-
* ownership transfer goes through `claimClient` in PR-B.
|
|
3932
|
-
*/
|
|
3933
|
-
async function assertClientOwner(db, clientId, scope) {
|
|
3934
|
-
const [row] = await db.select({
|
|
3935
|
-
id: clients.id,
|
|
3936
|
-
userId: clients.userId
|
|
3937
|
-
}).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
3938
|
-
if (!row || row.userId !== scope.userId) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
3939
|
-
}
|
|
3940
|
-
/**
|
|
3941
|
-
* Upsert the clients row for a given `client_id` under an authenticated user.
|
|
3942
|
-
*
|
|
3943
|
-
* Claim semantics (decouple-client-from-identity §4.1.1):
|
|
3944
|
-
* - New client_id → INSERT with the authenticated user_id. `organization_id`
|
|
3945
|
-
* is written as a placeholder (NOT NULL legacy column; no longer consumed
|
|
3946
|
-
* by any read path) sourced from the caller-supplied JWT default org.
|
|
3947
|
-
* - Existing row with the same user_id → refresh runtime columns.
|
|
3948
|
-
* `organization_id` is **not** updated on conflict, so the placeholder set
|
|
3949
|
-
* at first insert sticks for the row's lifetime.
|
|
3950
|
-
* - Existing row with a different user_id → raises
|
|
3951
|
-
* {@link ClientUserMismatchError} (WS close 4403). The CLI guides the
|
|
3952
|
-
* operator through `first-tree-hub client claim --confirm` to take
|
|
3953
|
-
* ownership, which unpins the previous owner's agents from the machine.
|
|
3954
|
-
*/
|
|
3955
|
-
async function registerClient(db, data) {
|
|
3956
|
-
const now = /* @__PURE__ */ new Date();
|
|
3957
|
-
const [existing] = await db.select({
|
|
3958
|
-
id: clients.id,
|
|
3959
|
-
userId: clients.userId
|
|
3960
|
-
}).from(clients).where(eq(clients.id, data.clientId)).limit(1);
|
|
3961
|
-
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.`);
|
|
3962
|
-
await db.insert(clients).values({
|
|
3963
|
-
id: data.clientId,
|
|
3964
|
-
userId: data.userId,
|
|
3965
|
-
organizationId: data.organizationId,
|
|
3966
|
-
status: "connected",
|
|
3967
|
-
instanceId: data.instanceId,
|
|
3968
|
-
hostname: data.hostname ?? null,
|
|
3969
|
-
os: data.os ?? null,
|
|
3970
|
-
sdkVersion: data.sdkVersion ?? null,
|
|
3971
|
-
connectedAt: now,
|
|
3972
|
-
lastSeenAt: now
|
|
3973
|
-
}).onConflictDoUpdate({
|
|
3974
|
-
target: clients.id,
|
|
3975
|
-
set: {
|
|
3976
|
-
userId: data.userId,
|
|
3977
|
-
status: "connected",
|
|
3978
|
-
instanceId: data.instanceId,
|
|
3979
|
-
hostname: data.hostname ?? null,
|
|
3980
|
-
os: data.os ?? null,
|
|
3981
|
-
sdkVersion: data.sdkVersion ?? null,
|
|
3982
|
-
connectedAt: now,
|
|
3983
|
-
lastSeenAt: now
|
|
3984
|
-
}
|
|
3985
|
-
});
|
|
3986
|
-
}
|
|
3987
|
-
/**
|
|
3988
|
-
* Transfer ownership of a client row to a new user, unpinning any agents
|
|
3989
|
-
* whose manager belonged to the previous owner. Atomic: caller is guaranteed
|
|
3990
|
-
* either a fully-applied ownership flip + bulk unpin, or no change. Idempotent
|
|
3991
|
-
* when `newUserId` already owns the row.
|
|
3992
|
-
*
|
|
3993
|
-
* Manager → user resolution goes through the members JOIN (the agents table
|
|
3994
|
-
* carries only `manager_id`); cross-org agents under the same previous owner
|
|
3995
|
-
* are unpinned together (decouple-client-from-identity §4.4).
|
|
3996
|
-
*
|
|
3997
|
-
* Caller is responsible for the caller-side authorization (the new owner must
|
|
3998
|
-
* be the authenticated request's user). The structured log
|
|
3999
|
-
* `event: client.owner_transfer` is emitted by the caller after the
|
|
4000
|
-
* transaction commits, using the returned `previousUserId` /
|
|
4001
|
-
* `unpinnedAgentIds`.
|
|
4002
|
-
*/
|
|
4003
|
-
async function claimClient(db, clientId, newUserId) {
|
|
4004
|
-
return db.transaction(async (tx) => {
|
|
4005
|
-
const [locked] = await tx.execute(sql`SELECT id, user_id FROM clients WHERE id = ${clientId} FOR UPDATE`);
|
|
4006
|
-
if (!locked) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
4007
|
-
const previousUserId = locked.user_id;
|
|
4008
|
-
if (previousUserId === newUserId) return {
|
|
4009
|
-
previousUserId,
|
|
4010
|
-
unpinnedAgentIds: []
|
|
4011
|
-
};
|
|
4012
|
-
let unpinnedAgentIds = [];
|
|
4013
|
-
if (previousUserId !== null) {
|
|
4014
|
-
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);
|
|
4015
|
-
if (unpinnedAgentIds.length > 0) {
|
|
4016
|
-
const now = /* @__PURE__ */ new Date();
|
|
4017
|
-
await tx.update(agents).set({
|
|
4018
|
-
clientId: null,
|
|
4019
|
-
updatedAt: now
|
|
4020
|
-
}).where(inArray(agents.uuid, unpinnedAgentIds));
|
|
4021
|
-
await tx.update(agentPresence).set({
|
|
4022
|
-
status: "offline",
|
|
4023
|
-
clientId: null,
|
|
4024
|
-
...runtimeFieldsReset(now)
|
|
4025
|
-
}).where(inArray(agentPresence.agentId, unpinnedAgentIds));
|
|
4026
|
-
await markSupersededByAgents(tx, unpinnedAgentIds, "client_claimed");
|
|
4027
|
-
}
|
|
4028
|
-
}
|
|
4029
|
-
await tx.update(clients).set({ userId: newUserId }).where(eq(clients.id, clientId));
|
|
4030
|
-
return {
|
|
4031
|
-
previousUserId,
|
|
4032
|
-
unpinnedAgentIds
|
|
4033
|
-
};
|
|
4034
|
-
});
|
|
4035
|
-
}
|
|
4036
|
-
async function disconnectClient(db, clientId) {
|
|
4037
|
-
const now = /* @__PURE__ */ new Date();
|
|
4038
|
-
await db.update(agentPresence).set({
|
|
4039
|
-
status: "offline",
|
|
4040
|
-
clientId: null,
|
|
4041
|
-
...runtimeFieldsReset(now)
|
|
4042
|
-
}).where(eq(agentPresence.clientId, clientId));
|
|
4043
|
-
await db.update(clients).set({
|
|
4044
|
-
status: "disconnected",
|
|
4045
|
-
lastSeenAt: now
|
|
4046
|
-
}).where(eq(clients.id, clientId));
|
|
4047
|
-
}
|
|
4048
|
-
async function heartbeatClient(db, clientId) {
|
|
4049
|
-
await db.update(clients).set({ lastSeenAt: /* @__PURE__ */ new Date() }).where(eq(clients.id, clientId));
|
|
4050
|
-
}
|
|
4051
|
-
async function getClient(db, clientId) {
|
|
4052
|
-
const [row] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
4053
|
-
return row ?? null;
|
|
4054
|
-
}
|
|
4055
|
-
/**
|
|
4056
|
-
* List the active agents currently pinned to a client. Used by the WS
|
|
4057
|
-
* registration handshake to backfill `agent:pinned` notifications missed while
|
|
4058
|
-
* the client was offline — without it, an admin who pinned an agent during a
|
|
4059
|
-
* client outage would still need a manual `first-tree-hub agent add`.
|
|
4060
|
-
*
|
|
4061
|
-
* Excludes soft-deleted agents (status = "deleted"). Human agents are
|
|
4062
|
-
* naturally excluded by the `clientId` filter — they never carry a clientId.
|
|
4063
|
-
*/
|
|
4064
|
-
async function listActiveAgentsPinnedToClient(db, clientId) {
|
|
4065
|
-
return db.select({
|
|
4066
|
-
uuid: agents.uuid,
|
|
4067
|
-
name: agents.name,
|
|
4068
|
-
displayName: agents.displayName,
|
|
4069
|
-
type: agents.type,
|
|
4070
|
-
runtimeProvider: agents.runtimeProvider
|
|
4071
|
-
}).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
|
|
4072
|
-
}
|
|
4073
|
-
/**
|
|
4074
|
-
* Member-scoped: every active agent pinned to a client owned by this user.
|
|
4075
|
-
* Used by client startup to reconcile its local YAML against the authoritative
|
|
4076
|
-
* `agents.runtime_provider`. Cross-org by design — a client is owned by a
|
|
4077
|
-
* user, not an org (decouple-client-from-identity §4.1).
|
|
4078
|
-
*/
|
|
4079
|
-
async function listMyPinnedAgents(db, scope) {
|
|
4080
|
-
return (await db.select({
|
|
4081
|
-
agentId: agents.uuid,
|
|
4082
|
-
clientId: agents.clientId,
|
|
4083
|
-
runtimeProvider: agents.runtimeProvider
|
|
4084
|
-
}).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) => ({
|
|
4085
|
-
agentId: r.agentId,
|
|
4086
|
-
clientId: r.clientId,
|
|
4087
|
-
runtimeProvider: r.runtimeProvider
|
|
4088
|
-
}));
|
|
4089
|
-
}
|
|
4090
|
-
/**
|
|
4091
|
-
* Replace this client's capabilities snapshot. Capabilities live under
|
|
4092
|
-
* `clients.metadata.capabilities` (Option C — no dedicated column); other
|
|
4093
|
-
* `metadata` subkeys are preserved on merge.
|
|
4094
|
-
*
|
|
4095
|
-
* Caller is expected to have already passed `assertClientOwner`.
|
|
4096
|
-
*/
|
|
4097
|
-
async function updateClientCapabilities(db, clientId, capabilities) {
|
|
4098
|
-
const parsed = clientCapabilitiesSchema.safeParse(capabilities);
|
|
4099
|
-
if (!parsed.success) throw new BadRequestError(`Invalid capabilities payload: ${parsed.error.message}`);
|
|
4100
|
-
const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
4101
|
-
if (!client) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
4102
|
-
const merged = {
|
|
4103
|
-
...client.metadata ?? {},
|
|
4104
|
-
capabilities: parsed.data
|
|
4105
|
-
};
|
|
4106
|
-
await db.update(clients).set({ metadata: merged }).where(eq(clients.id, clientId));
|
|
4107
|
-
}
|
|
4108
|
-
/**
|
|
4109
|
-
* Scope-aware client listing. Returns the caller's own clients (cross-org —
|
|
4110
|
-
* a client is owned by a user, not an org). The admin route adds a separate
|
|
4111
|
-
* `?organizationId=` cross-user view via {@link listClientsForOrgAdmin}.
|
|
4112
|
-
*/
|
|
4113
|
-
async function listClients(db, scope) {
|
|
4114
|
-
return attachAgentCounts(db, await db.select().from(clients).where(eq(clients.userId, scope.userId)));
|
|
4115
|
-
}
|
|
4116
|
-
/**
|
|
4117
|
-
* Admin-only cross-user listing: every client owned by an active member of
|
|
4118
|
-
* `orgId`. Joining `clients → members.user_id` instead of `clients.organization_id`
|
|
4119
|
-
* keeps the read path consistent with the rule that connection has no
|
|
4120
|
-
* runtime relationship to organization (decouple-client-from-identity §A).
|
|
4121
|
-
*
|
|
4122
|
-
* The caller must verify admin role realtime via `requireMemberInOrg` before
|
|
4123
|
-
* invoking this function — the service does not re-check, so it is
|
|
4124
|
-
* unsafe to expose without that gate.
|
|
4125
|
-
*/
|
|
4126
|
-
async function listClientsForOrgAdmin(db, orgId) {
|
|
4127
|
-
return attachAgentCounts(db, await db.select({
|
|
4128
|
-
id: clients.id,
|
|
4129
|
-
userId: clients.userId,
|
|
4130
|
-
organizationId: clients.organizationId,
|
|
4131
|
-
status: clients.status,
|
|
4132
|
-
sdkVersion: clients.sdkVersion,
|
|
4133
|
-
hostname: clients.hostname,
|
|
4134
|
-
os: clients.os,
|
|
4135
|
-
instanceId: clients.instanceId,
|
|
4136
|
-
connectedAt: clients.connectedAt,
|
|
4137
|
-
lastSeenAt: clients.lastSeenAt,
|
|
4138
|
-
metadata: clients.metadata
|
|
4139
|
-
}).from(clients).innerJoin(members, eq(members.userId, clients.userId)).where(and(eq(members.organizationId, orgId), eq(members.status, "active"))));
|
|
4140
|
-
}
|
|
4141
|
-
/**
|
|
4142
|
-
* Infer whether the client's locally-cached refresh token can plausibly
|
|
4143
|
-
* still mint access tokens. Used by the Web admin dashboard to render an
|
|
4144
|
-
* "AUTH EXPIRED" pill on rows whose offline duration has exceeded the
|
|
4145
|
-
* server's configured refresh-token TTL.
|
|
4146
|
-
*
|
|
4147
|
-
* Uses `lastSeenAt` (not `connectedAt`) because a healthy long-lived
|
|
4148
|
-
* client slides the refresh token continuously, so the absolute connect
|
|
4149
|
-
* time is no proxy for liveness. `lastSeenAt` is updated on register,
|
|
4150
|
-
* heartbeat, and the final disconnect — it lower-bounds the issue time
|
|
4151
|
-
* of the refresh token the client most likely still holds.
|
|
4152
|
-
*
|
|
4153
|
-
* Pure function, no DB access; the column-less design means there's no
|
|
4154
|
-
* server-side revocation path yet — every "expired" decision is purely
|
|
4155
|
-
* time-based. If we ever want admin-driven revocation, add a column
|
|
4156
|
-
* back and OR its value into this function.
|
|
4157
|
-
*/
|
|
4158
|
-
function deriveAuthState(row, refreshTokenExpirySeconds) {
|
|
4159
|
-
if (row.status === "disconnected") {
|
|
4160
|
-
if (Date.now() - row.lastSeenAt.getTime() > refreshTokenExpirySeconds * 1e3) return "expired";
|
|
4161
|
-
}
|
|
4162
|
-
return "ok";
|
|
4163
|
-
}
|
|
4164
|
-
async function attachAgentCounts(db, rows) {
|
|
4165
|
-
const counts = await db.select({
|
|
4166
|
-
clientId: agents.clientId,
|
|
4167
|
-
count: sql`count(*)::int`
|
|
4168
|
-
}).from(agents).where(and(sql`${agents.clientId} IS NOT NULL`, ne(agents.status, "deleted"))).groupBy(agents.clientId);
|
|
4169
|
-
const countMap = new Map(counts.map((c) => [c.clientId, c.count]));
|
|
4170
|
-
return rows.map((row) => ({
|
|
4171
|
-
...row,
|
|
4172
|
-
agentCount: countMap.get(row.id) ?? 0
|
|
4173
|
-
}));
|
|
4174
|
-
}
|
|
4175
|
-
/**
|
|
4176
|
-
* Retire a client row. Refuses while any non-deleted agent is still pinned to
|
|
4177
|
-
* it — per proposal M12, the operator must delete the agents first
|
|
4178
|
-
* (no reassign in this milestone). Throws {@link ConflictError} with the
|
|
4179
|
-
* pinned agent list so the UI can show the exact names.
|
|
4180
|
-
*
|
|
4181
|
-
* Runs in a single transaction with `SELECT … FOR UPDATE` on the client row
|
|
4182
|
-
* so a concurrent `createAgent(clientId=X)` cannot land between the pinned
|
|
4183
|
-
* check and the DELETE — otherwise the agents.client_id RESTRICT FK would
|
|
4184
|
-
* surface as a raw PG 23503 instead of the ConflictError the caller expects.
|
|
4185
|
-
*/
|
|
4186
|
-
async function retireClient(db, clientId) {
|
|
4187
|
-
await db.transaction(async (tx) => {
|
|
4188
|
-
const [locked] = await tx.execute(sql`SELECT id FROM clients WHERE id = ${clientId} FOR UPDATE`);
|
|
4189
|
-
if (!locked) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
4190
|
-
const pinned = await tx.select({
|
|
4191
|
-
uuid: agents.uuid,
|
|
4192
|
-
name: agents.name
|
|
4193
|
-
}).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
|
|
4194
|
-
if (pinned.length > 0) {
|
|
4195
|
-
const names = pinned.map((a) => a.name ?? a.uuid).join(", ");
|
|
4196
|
-
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).`);
|
|
4197
|
-
}
|
|
4198
|
-
await tx.update(agents).set({ clientId: null }).where(and(eq(agents.clientId, clientId), eq(agents.status, "deleted")));
|
|
4199
|
-
await tx.delete(clients).where(eq(clients.id, clientId));
|
|
4200
|
-
});
|
|
4201
|
-
}
|
|
4202
|
-
/**
|
|
4203
|
-
* System-scope sweep: mark clients as disconnected when their last-seen
|
|
4204
|
-
* server instance stopped sending heartbeats. Runs globally across all orgs
|
|
4205
|
-
* by design — it is invoked only by internal timers, never from a
|
|
4206
|
-
* user-scoped request, so the per-org filter the read paths enforce does not
|
|
4207
|
-
* apply. Org isolation on the data these clients belong to is still
|
|
4208
|
-
* enforced at the read paths (see `assertClientOwner` / `listClients`).
|
|
4209
|
-
*/
|
|
4210
|
-
async function cleanupStaleClients(db, staleSeconds = 60) {
|
|
4211
|
-
const result = await db.execute(sql`
|
|
4212
|
-
UPDATE clients SET status = 'disconnected'
|
|
4213
|
-
WHERE instance_id IN (
|
|
4214
|
-
SELECT instance_id FROM server_instances
|
|
4215
|
-
WHERE last_heartbeat < NOW() - make_interval(secs => ${staleSeconds})
|
|
4216
|
-
)
|
|
4217
|
-
AND status = 'connected'
|
|
4218
|
-
RETURNING id
|
|
4219
|
-
`);
|
|
4220
|
-
if (result.length > 0) {
|
|
4221
|
-
const staleIds = result.map((r) => r.id);
|
|
4222
|
-
await db.update(agentPresence).set({
|
|
4223
|
-
status: "offline",
|
|
4224
|
-
...runtimeFieldsReset(/* @__PURE__ */ new Date())
|
|
4225
|
-
}).where(inArray(agentPresence.clientId, staleIds));
|
|
4226
|
-
}
|
|
4227
|
-
return result.length;
|
|
4228
|
-
}
|
|
4229
|
-
//#endregion
|
|
4230
|
-
export { getSession as $, suspendSession as $t, createChat as A, pollInbox as At, fetchUserAvatarForHumanAgent as B, resolveAvatarImageUrl as Bt, claimBacklogForPush as C, markMeChatRead as Ct, clearAgentAvatarImage as D, messages as Dt, cleanupStalePresence as E, members as Et, disconnectClient as F, registerChatMessageDispatcher as Ft, getAgentAvatarImage as G, sendToAgent as Gt, findOrCreateDirectChat as H, resolveDefaultOrgId as Ht, editMessage as I, registerClient as It, getChatDetail as J, setChatEngagement as Jt, getCachedAudience as K, serverInstances as Kt, ensureDefaultOrganization as L, removeParticipant as Lt, createNotifier as M, reactivateAgent as Mt, deleteAgent as N, rebindAgent as Nt, clients as O, notifyRecipients as Ot, deriveAuthState as P, recomputeWatchersForMember as Pt, getPresence as Q, suspendAgent as Qt, ensureParticipant as R, resetActivity as Rt, claimAndBuildForPush as S, listMyPinnedAgents as St, cleanupStaleClients as T, markStaleAgents as Tt, getActivityOverview as U, retireClient as Ut, filterSessionsByParticipant as V, resolveChatTitle as Vt, getAgent as W, sendMessage as Wt, getOnlineCount as X, setRuntimeState as Xt, getClient as Y, setOffline as Yt, getOrganization as Z, submitAnswer as Zt, bindAgent as _, listClients as _t, adapterConfigs as a, upsertSessionState as an, leaveChat as at, chats as b, listMeChats as bt, addParticipant as c, listAgentSessions as ct, agentConfigs as d, listAgentsManagedByUser as dt, touchAgent as en, heartbeatClient as et, agentPresence as f, listAgentsWithRuntime as ft, assertParticipant as g, listChatsForMember as gt, assertClientOwner as h, listChats as ht, adapterAgentMappings as i, updateOrganization as in, joinMeChat as it, createMeChat as j, pruneStaleSilentEntries as jt, createAgent as k, pendingQuestions as kt, agentAvatarImageUrl as l, listAgentsForAdmin as lt, archiveSession as m, listChatParticipantsWithNames as mt, SUPPORTED_AVATAR_IMAGE_MIMES as n, updateAgent as nn, inboxEntries as nt, addChatParticipants as o, leaveMeChat as ot, agents as p, listAllSessions as pt, getCallerEngagement as q, setAgentAvatarImage as qt, ackEntryByIdForBoundAgents as r, updateClientCapabilities as rn, joinChat as rt, addMeChatParticipants as s, listActiveAgentsPinnedToClient as st, MAX_AVATAR_IMAGE_BYTES as t, unbindAgent as tn, heartbeatInstance as tt, agentChatSessions as u, listAgentsForMember as ut, chatMembership as v, listClientsForOrgAdmin as vt, claimClient as w, markMeChatUnread as wt, checkAgentNameAvailability as x, listMessages as xt, chatUserState as y, listMeChatSourceCounts as yt, extractSummary as z, resetTimedOutEntries as zt };
|