@agent-team-foundation/first-tree-hub 0.11.4 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/{bootstrap-D-Yf8yOc.mjs → bootstrap-C_K2CKXC.mjs} +7 -0
  2. package/dist/cli/index.mjs +73 -11
  3. package/dist/client-BPUdUaZT-CyCrpCTP.mjs +2033 -0
  4. package/dist/client-BhCtO2df-BGOu-rRN.mjs +7 -0
  5. package/dist/{dist-BQtAQNRD.mjs → dist-LgF7LHpE.mjs} +1 -1
  6. package/dist/{dist-ClFs4WMj.mjs → dist-UOZ6vMUW.mjs} +372 -197
  7. package/dist/drizzle/0033_onboarding_dismissed_at.sql +13 -0
  8. package/dist/drizzle/0034_pending_questions.sql +34 -0
  9. package/dist/drizzle/meta/_journal.json +14 -0
  10. package/dist/{errors-BmyRwN0Y-Dad3eV8F.mjs → errors-CF5evtJt-B0NTIVPt.mjs} +2 -1
  11. package/dist/{feishu-AI3pwmqN.mjs → feishu-C6qlhju2.mjs} +1 -1
  12. package/dist/{getMachineId-bsd-c2VImogj.mjs → getMachineId-bsd-BmasEOJr.mjs} +1 -1
  13. package/dist/{getMachineId-bsd-DyySs8xz.mjs → getMachineId-bsd-Dh3h0DDE.mjs} +1 -1
  14. package/dist/{getMachineId-darwin-Cl7TSzgO.mjs → getMachineId-darwin-CuhM3hfZ.mjs} +1 -1
  15. package/dist/{getMachineId-darwin-DKgI8b1d.mjs → getMachineId-darwin-D9wR0SLj.mjs} +1 -1
  16. package/dist/{getMachineId-linux-1OIMWfdh.mjs → getMachineId-linux-CYfb0oxZ.mjs} +1 -1
  17. package/dist/{getMachineId-linux-cT7EbP10.mjs → getMachineId-linux-D8ZaSjAC.mjs} +1 -1
  18. package/dist/{getMachineId-unsupported-CkX-YOG1.mjs → getMachineId-unsupported-Cu3iisaD.mjs} +1 -1
  19. package/dist/{getMachineId-unsupported-CmVlhzIo.mjs → getMachineId-unsupported-DZqI4ZT5.mjs} +1 -1
  20. package/dist/{getMachineId-win-C2cM60YT.mjs → getMachineId-win-8ZJbtrdf.mjs} +1 -1
  21. package/dist/{getMachineId-win-Chl03TYe.mjs → getMachineId-win-DT-hqwVp.mjs} +1 -1
  22. package/dist/index.mjs +9 -9
  23. package/dist/{invitation-Dnn5gGGX-DXryyvRG.mjs → invitation-Bg0TRiyx-BsZH4GCS.mjs} +2 -2
  24. package/dist/invitation-C299fxkP-KyCNax4T.mjs +4 -0
  25. package/dist/{observability-BAScT_5S-gw1ODB_o.mjs → observability-BAScT_5S-BcW9HgkG.mjs} +13 -13
  26. package/dist/{observability-CYsdAcoF.mjs → observability-eLA9iNK_.mjs} +3 -3
  27. package/dist/{saas-connect-CVoRK0Ex.mjs → saas-connect-Drn9g6cR.mjs} +1195 -1685
  28. package/dist/web/assets/index-B_Tf2I6v.css +1 -0
  29. package/dist/web/assets/{index-Bm6hgcvt.js → index-Bnyz7inW.js} +1 -1
  30. package/dist/web/assets/index-Dy3jIUX5.js +391 -0
  31. package/dist/web/index.html +2 -2
  32. package/package.json +1 -1
  33. package/dist/client-By1K4VVT-DuI6EnSh.mjs +0 -4
  34. package/dist/client-CLdRbuml-svTO0Eat.mjs +0 -524
  35. package/dist/invitation-DWlyNb8x-BvXubk24.mjs +0 -4
  36. package/dist/web/assets/index-fNb_M0nL.css +0 -1
  37. package/dist/web/assets/index-k2bWRKc-.js +0 -388
  38. /package/dist/{esm-Ci8E1Gtj.mjs → esm-iadMkGbV.mjs} +0 -0
  39. /package/dist/{src-aJMV60mR.mjs → src-DNBS5Yjj.mjs} +0 -0
@@ -16,8 +16,8 @@
16
16
  if (t === "dark" || (!t && m)) document.documentElement.classList.add("dark");
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-k2bWRKc-.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-fNb_M0nL.css">
19
+ <script type="module" crossorigin src="/assets/index-Dy3jIUX5.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-B_Tf2I6v.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-team-foundation/first-tree-hub",
3
- "version": "0.11.4",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "First Tree Hub — unified CLI for server, client, and agent management",
6
6
  "exports": {
@@ -1,4 +0,0 @@
1
- import "./dist-ClFs4WMj.mjs";
2
- import "./errors-BmyRwN0Y-Dad3eV8F.mjs";
3
- import { y as listMyPinnedAgents } from "./client-CLdRbuml-svTO0Eat.mjs";
4
- export { listMyPinnedAgents };
@@ -1,524 +0,0 @@
1
- import { w as clientCapabilitiesSchema } from "./dist-ClFs4WMj.mjs";
2
- import { a as ConflictError, i as ClientUserMismatchError, l as organizations, n as BadRequestError, s as NotFoundError, u as users } from "./errors-BmyRwN0Y-Dad3eV8F.mjs";
3
- import { and, eq, inArray, ne, sql } from "drizzle-orm";
4
- import { index, integer, jsonb, pgTable, text, timestamp, unique } from "drizzle-orm/pg-core";
5
- //#region ../server/dist/client-CLdRbuml.mjs
6
- /**
7
- * Client connections. A client is a single SDK process (AgentRuntime) that may
8
- * host multiple agents. From the unified-user-token milestone on, a client is
9
- * owned by a user — Rule R-RUN requires `clients.user_id == jwt.userId` for
10
- * every `agent:bind` request. `user_id` is nullable only to accommodate legacy
11
- * rows created before JWT-on-handshake; the WS handshake claims the row on
12
- * first re-register under an authenticated JWT (see `client:register` M13).
13
- *
14
- * A client is also bound to exactly one organization for its lifetime. The
15
- * `organization_id` column is populated on first registration from the
16
- * authenticated JWT's org claim and never changes thereafter. Re-registering
17
- * the same clientId under a JWT for a different org is rejected with
18
- * `CLIENT_ORG_MISMATCH` — the CLI responds by abandoning the local clientId
19
- * and registering a new one instead (see docs/multi-tenancy-hardening-design.md).
20
- */
21
- const clients = pgTable("clients", {
22
- id: text("id").primaryKey(),
23
- userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
24
- organizationId: text("organization_id").notNull().references(() => organizations.id),
25
- status: text("status").notNull().default("disconnected"),
26
- sdkVersion: text("sdk_version"),
27
- hostname: text("hostname"),
28
- os: text("os"),
29
- instanceId: text("instance_id"),
30
- connectedAt: timestamp("connected_at", { withTimezone: true }),
31
- lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
32
- metadata: jsonb("metadata").$type()
33
- }, (table) => [index("idx_clients_user").on(table.userId), index("idx_clients_org").on(table.organizationId)]);
34
- /** Agent registration. Each agent owns a unique inboxId for message delivery. */
35
- const agents = pgTable("agents", {
36
- uuid: text("uuid").primaryKey(),
37
- name: text("name"),
38
- organizationId: text("organization_id").notNull().references(() => organizations.id),
39
- type: text("type").notNull(),
40
- displayName: text("display_name").notNull(),
41
- delegateMention: text("delegate_mention"),
42
- inboxId: text("inbox_id").unique().notNull(),
43
- status: text("status").notNull().default("active"),
44
- source: text("source"),
45
- visibility: text("visibility").notNull().default("private"),
46
- metadata: jsonb("metadata").$type().notNull().default({}),
47
- managerId: text("manager_id").notNull(),
48
- clientId: text("client_id").references(() => clients.id, { onDelete: "restrict" }),
49
- runtimeProvider: text("runtime_provider").notNull().default("claude-code"),
50
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
51
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
52
- }, (table) => [
53
- index("idx_agents_org").on(table.organizationId),
54
- index("idx_agents_manager").on(table.managerId),
55
- index("idx_agents_visibility_org").on(table.organizationId, table.visibility),
56
- index("idx_agents_client").on(table.clientId),
57
- unique("uq_agents_org_name").on(table.organizationId, table.name)
58
- ]);
59
- /** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
60
- const members = pgTable("members", {
61
- id: text("id").primaryKey(),
62
- userId: text("user_id").notNull().references(() => users.id),
63
- organizationId: text("organization_id").notNull().references(() => organizations.id),
64
- agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
65
- role: text("role").notNull(),
66
- status: text("status").notNull().default("active"),
67
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
68
- }, (table) => [
69
- unique("uq_members_user_org").on(table.userId, table.organizationId),
70
- index("idx_members_user").on(table.userId),
71
- index("idx_members_org").on(table.organizationId)
72
- ]);
73
- /** Agent presence and runtime state. Tracked via WebSocket connections; stale entries are cleaned up using server_instances heartbeat. */
74
- const agentPresence = pgTable("agent_presence", {
75
- agentId: text("agent_id").primaryKey().references(() => agents.uuid, { onDelete: "cascade" }),
76
- status: text("status").notNull().default("offline"),
77
- instanceId: text("instance_id"),
78
- connectedAt: timestamp("connected_at", { withTimezone: true }),
79
- lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
80
- clientId: text("client_id").references(() => clients.id, { onDelete: "set null" }),
81
- runtimeType: text("runtime_type"),
82
- runtimeVersion: text("runtime_version"),
83
- runtimeState: text("runtime_state"),
84
- activeSessions: integer("active_sessions"),
85
- totalSessions: integer("total_sessions"),
86
- runtimeUpdatedAt: timestamp("runtime_updated_at", { withTimezone: true })
87
- });
88
- /** Server instance heartbeat. Used to detect crashed instances and clean up associated agent_presence records. */
89
- const serverInstances = pgTable("server_instances", {
90
- instanceId: text("instance_id").primaryKey(),
91
- lastHeartbeat: timestamp("last_heartbeat", { withTimezone: true }).notNull().defaultNow()
92
- });
93
- /** Common field reset when agent goes offline or is unbound. */
94
- function runtimeFieldsReset(now) {
95
- return {
96
- runtimeState: null,
97
- activeSessions: null,
98
- totalSessions: null,
99
- runtimeUpdatedAt: now,
100
- lastSeenAt: now
101
- };
102
- }
103
- async function setOffline(db, agentId) {
104
- const now = /* @__PURE__ */ new Date();
105
- await db.update(agentPresence).set({
106
- status: "offline",
107
- instanceId: null,
108
- ...runtimeFieldsReset(now)
109
- }).where(eq(agentPresence.agentId, agentId));
110
- }
111
- async function getPresence(db, agentId) {
112
- const [row] = await db.select().from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
113
- return row ?? null;
114
- }
115
- async function getOnlineCount(db) {
116
- const [result] = await db.select({ count: sql`count(*)::int` }).from(agentPresence).where(eq(agentPresence.status, "online"));
117
- return result?.count ?? 0;
118
- }
119
- async function bindAgent(db, agentId, data) {
120
- const now = /* @__PURE__ */ new Date();
121
- await db.insert(agentPresence).values({
122
- agentId,
123
- status: "online",
124
- instanceId: data.instanceId,
125
- clientId: data.clientId,
126
- runtimeType: data.runtimeType,
127
- runtimeVersion: data.runtimeVersion ?? null,
128
- runtimeState: "idle",
129
- connectedAt: now,
130
- lastSeenAt: now,
131
- runtimeUpdatedAt: now
132
- }).onConflictDoUpdate({
133
- target: agentPresence.agentId,
134
- set: {
135
- status: "online",
136
- instanceId: data.instanceId,
137
- clientId: data.clientId,
138
- runtimeType: data.runtimeType,
139
- runtimeVersion: data.runtimeVersion ?? null,
140
- runtimeState: "idle",
141
- activeSessions: null,
142
- totalSessions: null,
143
- connectedAt: now,
144
- lastSeenAt: now,
145
- runtimeUpdatedAt: now
146
- }
147
- });
148
- }
149
- async function unbindAgent(db, agentId) {
150
- const now = /* @__PURE__ */ new Date();
151
- await db.update(agentPresence).set({
152
- status: "offline",
153
- clientId: null,
154
- ...runtimeFieldsReset(now)
155
- }).where(eq(agentPresence.agentId, agentId));
156
- }
157
- /** Set runtime state directly from client-reported value.
158
- *
159
- * When an org-scoped notifier is provided, emit a PG NOTIFY on the
160
- * `runtime_state_changes` channel so the pulse aggregator (and any future
161
- * admin-side consumers) can observe the transition. Fire-and-forget to match
162
- * notifier semantics elsewhere in this module. */
163
- async function setRuntimeState(db, agentId, runtimeState, options) {
164
- const now = /* @__PURE__ */ new Date();
165
- await db.update(agentPresence).set({
166
- runtimeState,
167
- runtimeUpdatedAt: now,
168
- lastSeenAt: now
169
- }).where(eq(agentPresence.agentId, agentId));
170
- if (options?.notifier && options.organizationId) options.notifier.notifyRuntimeStateChange(agentId, runtimeState, options.organizationId).catch(() => {});
171
- }
172
- /** Touch agent last_seen_at on heartbeat (per-agent liveness). */
173
- async function touchAgent(db, agentId) {
174
- await db.update(agentPresence).set({ lastSeenAt: /* @__PURE__ */ new Date() }).where(eq(agentPresence.agentId, agentId));
175
- }
176
- async function heartbeatInstance(db, instanceId) {
177
- await db.insert(serverInstances).values({
178
- instanceId,
179
- lastHeartbeat: /* @__PURE__ */ new Date()
180
- }).onConflictDoUpdate({
181
- target: serverInstances.instanceId,
182
- set: { lastHeartbeat: /* @__PURE__ */ new Date() }
183
- });
184
- }
185
- /**
186
- * M1: Mark agents as offline whose last_seen_at is older than staleSeconds.
187
- * Unlike cleanupStalePresence (which checks instance liveness), this checks
188
- * per-agent heartbeat liveness — detecting agents that stopped heartbeating
189
- * while the client process may still be alive.
190
- *
191
- * Returns the list of agent IDs that were marked stale (for notification in Step 6).
192
- */
193
- async function markStaleAgents(db, staleSeconds = 60) {
194
- return (await db.execute(sql`
195
- UPDATE agent_presence SET
196
- status = 'offline',
197
- client_id = NULL,
198
- runtime_state = NULL,
199
- active_sessions = NULL,
200
- total_sessions = NULL,
201
- runtime_updated_at = NOW()
202
- WHERE status = 'online'
203
- AND last_seen_at < NOW() - make_interval(secs => ${staleSeconds})
204
- RETURNING agent_id
205
- `)).map((r) => r.agent_id);
206
- }
207
- async function cleanupStalePresence(db, staleSeconds = 60) {
208
- return (await db.execute(sql`
209
- UPDATE agent_presence SET status = 'offline', instance_id = NULL,
210
- runtime_state = NULL,
211
- active_sessions = NULL, total_sessions = NULL,
212
- runtime_updated_at = NOW()
213
- WHERE instance_id IN (
214
- SELECT instance_id FROM server_instances
215
- WHERE last_heartbeat < NOW() - make_interval(secs => ${staleSeconds})
216
- )
217
- AND status = 'online'
218
- RETURNING agent_id
219
- `)).length;
220
- }
221
- /**
222
- * Assert the caller can act on this client. Throws 404 for both "not found"
223
- * and "not yours" to prevent UUID enumeration. The client is owned by exactly
224
- * one user; cross-user admin access is no longer supported by this code path
225
- * (see decouple-client-from-identity-design §4.10.5 option A). Cross-user
226
- * ownership transfer goes through `claimClient` in PR-B.
227
- */
228
- async function assertClientOwner(db, clientId, scope) {
229
- const [row] = await db.select({
230
- id: clients.id,
231
- userId: clients.userId
232
- }).from(clients).where(eq(clients.id, clientId)).limit(1);
233
- if (!row || row.userId !== scope.userId) throw new NotFoundError(`Client "${clientId}" not found`);
234
- }
235
- /**
236
- * Upsert the clients row for a given `client_id` under an authenticated user.
237
- *
238
- * Claim semantics (decouple-client-from-identity §4.1.1):
239
- * - New client_id → INSERT with the authenticated user_id. `organization_id`
240
- * is written as a placeholder (NOT NULL legacy column; no longer consumed
241
- * by any read path) sourced from the caller-supplied JWT default org.
242
- * - Existing row with the same user_id → refresh runtime columns.
243
- * `organization_id` is **not** updated on conflict, so the placeholder set
244
- * at first insert sticks for the row's lifetime.
245
- * - Existing row with a different user_id → raises
246
- * {@link ClientUserMismatchError} (WS close 4403). The CLI guides the
247
- * operator through `first-tree-hub client claim --confirm` to take
248
- * ownership, which unpins the previous owner's agents from the machine.
249
- */
250
- async function registerClient(db, data) {
251
- const now = /* @__PURE__ */ new Date();
252
- const [existing] = await db.select({
253
- id: clients.id,
254
- userId: clients.userId
255
- }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
256
- 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.`);
257
- await db.insert(clients).values({
258
- id: data.clientId,
259
- userId: data.userId,
260
- organizationId: data.organizationId,
261
- status: "connected",
262
- instanceId: data.instanceId,
263
- hostname: data.hostname ?? null,
264
- os: data.os ?? null,
265
- sdkVersion: data.sdkVersion ?? null,
266
- connectedAt: now,
267
- lastSeenAt: now
268
- }).onConflictDoUpdate({
269
- target: clients.id,
270
- set: {
271
- userId: data.userId,
272
- status: "connected",
273
- instanceId: data.instanceId,
274
- hostname: data.hostname ?? null,
275
- os: data.os ?? null,
276
- sdkVersion: data.sdkVersion ?? null,
277
- connectedAt: now,
278
- lastSeenAt: now
279
- }
280
- });
281
- }
282
- /**
283
- * Transfer ownership of a client row to a new user, unpinning any agents
284
- * whose manager belonged to the previous owner. Atomic: caller is guaranteed
285
- * either a fully-applied ownership flip + bulk unpin, or no change. Idempotent
286
- * when `newUserId` already owns the row.
287
- *
288
- * Manager → user resolution goes through the members JOIN (the agents table
289
- * carries only `manager_id`); cross-org agents under the same previous owner
290
- * are unpinned together (decouple-client-from-identity §4.4).
291
- *
292
- * Caller is responsible for the caller-side authorization (the new owner must
293
- * be the authenticated request's user). The structured log
294
- * `event: client.owner_transfer` is emitted by the caller after the
295
- * transaction commits, using the returned `previousUserId` /
296
- * `unpinnedAgentIds`.
297
- */
298
- async function claimClient(db, clientId, newUserId) {
299
- return db.transaction(async (tx) => {
300
- const [locked] = await tx.execute(sql`SELECT id, user_id FROM clients WHERE id = ${clientId} FOR UPDATE`);
301
- if (!locked) throw new NotFoundError(`Client "${clientId}" not found`);
302
- const previousUserId = locked.user_id;
303
- if (previousUserId === newUserId) return {
304
- previousUserId,
305
- unpinnedAgentIds: []
306
- };
307
- let unpinnedAgentIds = [];
308
- if (previousUserId !== null) {
309
- 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);
310
- if (unpinnedAgentIds.length > 0) {
311
- const now = /* @__PURE__ */ new Date();
312
- await tx.update(agents).set({
313
- clientId: null,
314
- updatedAt: now
315
- }).where(inArray(agents.uuid, unpinnedAgentIds));
316
- await tx.update(agentPresence).set({
317
- status: "offline",
318
- clientId: null,
319
- ...runtimeFieldsReset(now)
320
- }).where(inArray(agentPresence.agentId, unpinnedAgentIds));
321
- }
322
- }
323
- await tx.update(clients).set({ userId: newUserId }).where(eq(clients.id, clientId));
324
- return {
325
- previousUserId,
326
- unpinnedAgentIds
327
- };
328
- });
329
- }
330
- async function disconnectClient(db, clientId) {
331
- const now = /* @__PURE__ */ new Date();
332
- await db.update(agentPresence).set({
333
- status: "offline",
334
- clientId: null,
335
- ...runtimeFieldsReset(now)
336
- }).where(eq(agentPresence.clientId, clientId));
337
- await db.update(clients).set({
338
- status: "disconnected",
339
- lastSeenAt: now
340
- }).where(eq(clients.id, clientId));
341
- }
342
- async function heartbeatClient(db, clientId) {
343
- await db.update(clients).set({ lastSeenAt: /* @__PURE__ */ new Date() }).where(eq(clients.id, clientId));
344
- }
345
- async function getClient(db, clientId) {
346
- const [row] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
347
- return row ?? null;
348
- }
349
- /**
350
- * List the active agents currently pinned to a client. Used by the WS
351
- * registration handshake to backfill `agent:pinned` notifications missed while
352
- * the client was offline — without it, an admin who pinned an agent during a
353
- * client outage would still need a manual `first-tree-hub agent add`.
354
- *
355
- * Excludes soft-deleted agents (status = "deleted"). Human agents are
356
- * naturally excluded by the `clientId` filter — they never carry a clientId.
357
- */
358
- async function listActiveAgentsPinnedToClient(db, clientId) {
359
- return db.select({
360
- uuid: agents.uuid,
361
- name: agents.name,
362
- displayName: agents.displayName,
363
- type: agents.type,
364
- runtimeProvider: agents.runtimeProvider
365
- }).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
366
- }
367
- /**
368
- * Member-scoped: every active agent pinned to a client owned by this user.
369
- * Used by client startup to reconcile its local YAML against the authoritative
370
- * `agents.runtime_provider`. Cross-org by design — a client is owned by a
371
- * user, not an org (decouple-client-from-identity §4.1).
372
- */
373
- async function listMyPinnedAgents(db, scope) {
374
- return (await db.select({
375
- agentId: agents.uuid,
376
- clientId: agents.clientId,
377
- runtimeProvider: agents.runtimeProvider
378
- }).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) => ({
379
- agentId: r.agentId,
380
- clientId: r.clientId,
381
- runtimeProvider: r.runtimeProvider
382
- }));
383
- }
384
- /**
385
- * Replace this client's capabilities snapshot. Capabilities live under
386
- * `clients.metadata.capabilities` (Option C — no dedicated column); other
387
- * `metadata` subkeys are preserved on merge.
388
- *
389
- * Caller is expected to have already passed `assertClientOwner`.
390
- */
391
- async function updateClientCapabilities(db, clientId, capabilities) {
392
- const parsed = clientCapabilitiesSchema.safeParse(capabilities);
393
- if (!parsed.success) throw new BadRequestError(`Invalid capabilities payload: ${parsed.error.message}`);
394
- const [client] = await db.select({ metadata: clients.metadata }).from(clients).where(eq(clients.id, clientId)).limit(1);
395
- if (!client) throw new NotFoundError(`Client "${clientId}" not found`);
396
- const merged = {
397
- ...client.metadata ?? {},
398
- capabilities: parsed.data
399
- };
400
- await db.update(clients).set({ metadata: merged }).where(eq(clients.id, clientId));
401
- }
402
- /**
403
- * Scope-aware client listing. Returns the caller's own clients (cross-org —
404
- * a client is owned by a user, not an org). The admin route adds a separate
405
- * `?organizationId=` cross-user view via {@link listClientsForOrgAdmin}.
406
- */
407
- async function listClients(db, scope) {
408
- return attachAgentCounts(db, await db.select().from(clients).where(eq(clients.userId, scope.userId)));
409
- }
410
- /**
411
- * Admin-only cross-user listing: every client owned by an active member of
412
- * `orgId`. Joining `clients → members.user_id` instead of `clients.organization_id`
413
- * keeps the read path consistent with the rule that connection has no
414
- * runtime relationship to organization (decouple-client-from-identity §A).
415
- *
416
- * The caller must verify admin role realtime via `requireMemberInOrg` before
417
- * invoking this function — the service does not re-check, so it is
418
- * unsafe to expose without that gate.
419
- */
420
- async function listClientsForOrgAdmin(db, orgId) {
421
- return attachAgentCounts(db, await db.select({
422
- id: clients.id,
423
- userId: clients.userId,
424
- organizationId: clients.organizationId,
425
- status: clients.status,
426
- sdkVersion: clients.sdkVersion,
427
- hostname: clients.hostname,
428
- os: clients.os,
429
- instanceId: clients.instanceId,
430
- connectedAt: clients.connectedAt,
431
- lastSeenAt: clients.lastSeenAt,
432
- metadata: clients.metadata
433
- }).from(clients).innerJoin(members, eq(members.userId, clients.userId)).where(and(eq(members.organizationId, orgId), eq(members.status, "active"))));
434
- }
435
- /**
436
- * Infer whether the client's locally-cached refresh token can plausibly
437
- * still mint access tokens. Used by the Web admin dashboard to render an
438
- * "AUTH EXPIRED" pill on rows whose offline duration has exceeded the
439
- * server's configured refresh-token TTL.
440
- *
441
- * Uses `lastSeenAt` (not `connectedAt`) because a healthy long-lived
442
- * client slides the refresh token continuously, so the absolute connect
443
- * time is no proxy for liveness. `lastSeenAt` is updated on register,
444
- * heartbeat, and the final disconnect — it lower-bounds the issue time
445
- * of the refresh token the client most likely still holds.
446
- *
447
- * Pure function, no DB access; the column-less design means there's no
448
- * server-side revocation path yet — every "expired" decision is purely
449
- * time-based. If we ever want admin-driven revocation, add a column
450
- * back and OR its value into this function.
451
- */
452
- function deriveAuthState(row, refreshTokenExpirySeconds) {
453
- if (row.status === "disconnected") {
454
- if (Date.now() - row.lastSeenAt.getTime() > refreshTokenExpirySeconds * 1e3) return "expired";
455
- }
456
- return "ok";
457
- }
458
- async function attachAgentCounts(db, rows) {
459
- const counts = await db.select({
460
- clientId: agents.clientId,
461
- count: sql`count(*)::int`
462
- }).from(agents).where(and(sql`${agents.clientId} IS NOT NULL`, ne(agents.status, "deleted"))).groupBy(agents.clientId);
463
- const countMap = new Map(counts.map((c) => [c.clientId, c.count]));
464
- return rows.map((row) => ({
465
- ...row,
466
- agentCount: countMap.get(row.id) ?? 0
467
- }));
468
- }
469
- /**
470
- * Retire a client row. Refuses while any non-deleted agent is still pinned to
471
- * it — per proposal M12, the operator must delete the agents first
472
- * (no reassign in this milestone). Throws {@link ConflictError} with the
473
- * pinned agent list so the UI can show the exact names.
474
- *
475
- * Runs in a single transaction with `SELECT … FOR UPDATE` on the client row
476
- * so a concurrent `createAgent(clientId=X)` cannot land between the pinned
477
- * check and the DELETE — otherwise the agents.client_id RESTRICT FK would
478
- * surface as a raw PG 23503 instead of the ConflictError the caller expects.
479
- */
480
- async function retireClient(db, clientId) {
481
- await db.transaction(async (tx) => {
482
- const [locked] = await tx.execute(sql`SELECT id FROM clients WHERE id = ${clientId} FOR UPDATE`);
483
- if (!locked) throw new NotFoundError(`Client "${clientId}" not found`);
484
- const pinned = await tx.select({
485
- uuid: agents.uuid,
486
- name: agents.name
487
- }).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
488
- if (pinned.length > 0) {
489
- const names = pinned.map((a) => a.name ?? a.uuid).join(", ");
490
- 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).`);
491
- }
492
- await tx.update(agents).set({ clientId: null }).where(and(eq(agents.clientId, clientId), eq(agents.status, "deleted")));
493
- await tx.delete(clients).where(eq(clients.id, clientId));
494
- });
495
- }
496
- /**
497
- * System-scope sweep: mark clients as disconnected when their last-seen
498
- * server instance stopped sending heartbeats. Runs globally across all orgs
499
- * by design — it is invoked only by internal timers, never from a
500
- * user-scoped request, so the per-org filter the read paths enforce does not
501
- * apply. Org isolation on the data these clients belong to is still
502
- * enforced at the read paths (see `assertClientOwner` / `listClients`).
503
- */
504
- async function cleanupStaleClients(db, staleSeconds = 60) {
505
- const result = await db.execute(sql`
506
- UPDATE clients SET status = 'disconnected'
507
- WHERE instance_id IN (
508
- SELECT instance_id FROM server_instances
509
- WHERE last_heartbeat < NOW() - make_interval(secs => ${staleSeconds})
510
- )
511
- AND status = 'connected'
512
- RETURNING id
513
- `);
514
- if (result.length > 0) {
515
- const staleIds = result.map((r) => r.id);
516
- await db.update(agentPresence).set({
517
- status: "offline",
518
- ...runtimeFieldsReset(/* @__PURE__ */ new Date())
519
- }).where(inArray(agentPresence.clientId, staleIds));
520
- }
521
- return result.length;
522
- }
523
- //#endregion
524
- export { retireClient as C, touchAgent as D, setRuntimeState as E, unbindAgent as O, registerClient as S, setOffline as T, listClients as _, claimClient as a, markStaleAgents as b, clients as c, getClient as d, getOnlineCount as f, listActiveAgentsPinnedToClient as g, heartbeatInstance as h, bindAgent as i, updateClientCapabilities as k, deriveAuthState as l, heartbeatClient as m, agents as n, cleanupStaleClients as o, getPresence as p, assertClientOwner as r, cleanupStalePresence as s, agentPresence as t, disconnectClient as u, listClientsForOrgAdmin as v, serverInstances as w, members as x, listMyPinnedAgents as y };
@@ -1,4 +0,0 @@
1
- import "./dist-ClFs4WMj.mjs";
2
- import "./errors-BmyRwN0Y-Dad3eV8F.mjs";
3
- import { s as previewInvitation } from "./invitation-Dnn5gGGX-DXryyvRG.mjs";
4
- export { previewInvitation };