@agent-team-foundation/first-tree-hub 0.8.4 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.mjs +3 -7
- package/dist/{core-VW2Qfs73.mjs → core-DoIprl2f.mjs} +450 -136
- package/dist/{feishu-OezhDY7x.mjs → feishu-n9Y2yGTT.mjs} +21 -4
- package/dist/index.mjs +2 -2
- package/dist/web/assets/index-CIVitOsR.css +1 -0
- package/dist/web/assets/index-CVfrxdFe.js +361 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-9xygtFGL.css +0 -1
- package/dist/web/assets/index-DpobwdHT.js +0 -333
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { m as __toESM } from "./esm-CYu4tXXn.mjs";
|
|
2
2
|
import { C as setConfigValue, S as serverConfigSchema, _ as loadAgents, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, x as resolveConfigReadonly } from "./bootstrap-99vUYmLs.mjs";
|
|
3
3
|
import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-CJzDFY_G-CmvgUuzc.mjs";
|
|
4
|
-
import { $ as
|
|
4
|
+
import { $ as updateAdapterConfigSchema, A as createMemberSchema, B as notificationQuerySchema, C as agentTypeSchema$1, D as createAdapterMappingSchema, E as createAdapterConfigSchema, F as inboxPollQuerySchema, G as sendMessageSchema, H as refreshTokenSchema, I as isRedactedEnvValue, J as sessionEventMessageSchema, K as sendToAgentSchema, L as linkTaskChatSchema, M as createTaskSchema, N as delegateFeishuUserSchema, O as createAgentSchema, P as dryRunAgentRuntimeConfigSchema, Q as taskListQuerySchema, R as loginSchema, S as agentRuntimeConfigPayloadSchema$1, T as connectTokenExchangeSchema, U as runtimeStateMessageSchema, V as paginationQuerySchema, W as selfServiceFeishuBotSchema, X as sessionReconcileRequestSchema, Y as sessionEventSchema$1, Z as sessionStateMessageSchema, _ as addParticipantSchema, a as AGENT_SELECTOR_HEADER$1, at as updateSystemConfigSchema, b as agentBindRequestSchema, c as AGENT_TYPES, d as SYSTEM_CONFIG_DEFAULTS, et as updateAgentRuntimeConfigSchema, f as TASK_CREATOR_TYPES, g as WS_AUTH_FRAME_TIMEOUT_MS, h as TASK_TERMINAL_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateOrganizationSchema, j as createOrganizationSchema, k as createChatSchema, l as AGENT_VISIBILITY, m as TASK_STATUSES, nt as updateChatSchema, o as AGENT_SOURCES, ot as updateTaskStatusSchema, p as TASK_HEALTH_SIGNALS, q as sessionCompletionMessageSchema, rt as updateMemberSchema, s as AGENT_STATUSES, st as wsAuthFrameSchema, tt as updateAgentSchema, u as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, v as adminCreateTaskSchema, w as clientRegisterSchema, x as agentPinnedMessageSchema$1, y as adminUpdateTaskSchema, z as messageSourceSchema$1 } from "./feishu-n9Y2yGTT.mjs";
|
|
5
5
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
7
|
import { ZodError, z } from "zod";
|
|
@@ -230,14 +230,16 @@ const runtimeStateSchema = z.enum([
|
|
|
230
230
|
"blocked",
|
|
231
231
|
"error"
|
|
232
232
|
]);
|
|
233
|
-
|
|
233
|
+
z.enum([
|
|
234
234
|
"active",
|
|
235
235
|
"suspended",
|
|
236
236
|
"evicted"
|
|
237
237
|
]);
|
|
238
|
+
/** Wire-level states a client may report. `evicted` from a stale client is rejected. */
|
|
239
|
+
const clientSessionStateSchema = z.enum(["active", "suspended"]);
|
|
238
240
|
z.object({
|
|
239
241
|
chatId: z.string().min(1),
|
|
240
|
-
state:
|
|
242
|
+
state: clientSessionStateSchema
|
|
241
243
|
});
|
|
242
244
|
z.object({ runtimeState: runtimeStateSchema });
|
|
243
245
|
z.object({
|
|
@@ -526,6 +528,7 @@ z.object({
|
|
|
526
528
|
createdAt: z.string(),
|
|
527
529
|
updatedAt: z.string()
|
|
528
530
|
}).extend({ participants: z.array(chatParticipantSchema) });
|
|
531
|
+
z.object({ topic: z.string().trim().max(500).nullable() });
|
|
529
532
|
z.object({
|
|
530
533
|
agentId: z.string().min(1),
|
|
531
534
|
mode: z.enum(["full", "mention_only"]).default("full")
|
|
@@ -641,7 +644,10 @@ z.object({
|
|
|
641
644
|
displayName: z.string().min(1).max(200),
|
|
642
645
|
role: memberRoleSchema.default("member")
|
|
643
646
|
});
|
|
644
|
-
z.object({
|
|
647
|
+
z.object({
|
|
648
|
+
role: memberRoleSchema.optional(),
|
|
649
|
+
displayName: z.string().min(1).max(200).optional()
|
|
650
|
+
});
|
|
645
651
|
memberSchema.extend({
|
|
646
652
|
username: z.string(),
|
|
647
653
|
displayName: z.string(),
|
|
@@ -807,6 +813,16 @@ z.object({
|
|
|
807
813
|
agentId: z.string(),
|
|
808
814
|
chatId: z.string()
|
|
809
815
|
});
|
|
816
|
+
z.object({
|
|
817
|
+
type: z.literal("session:reconcile"),
|
|
818
|
+
agentId: z.string().min(1),
|
|
819
|
+
chatIds: z.array(z.string().min(1)).max(500)
|
|
820
|
+
});
|
|
821
|
+
z.object({
|
|
822
|
+
type: z.literal("session:reconcile:result"),
|
|
823
|
+
agentId: z.string().min(1),
|
|
824
|
+
staleChatIds: z.array(z.string().min(1))
|
|
825
|
+
});
|
|
810
826
|
const orgStatsSchema = z.object({
|
|
811
827
|
organizationId: z.string(),
|
|
812
828
|
agentCount: z.number(),
|
|
@@ -1195,6 +1211,15 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1195
1211
|
chatId
|
|
1196
1212
|
}));
|
|
1197
1213
|
}
|
|
1214
|
+
/** Ask the server which of the supplied chatIds the client should drop. */
|
|
1215
|
+
sendSessionReconcile(agentId, chatIds) {
|
|
1216
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
1217
|
+
this.ws.send(JSON.stringify({
|
|
1218
|
+
type: "session:reconcile",
|
|
1219
|
+
agentId,
|
|
1220
|
+
chatIds
|
|
1221
|
+
}));
|
|
1222
|
+
}
|
|
1198
1223
|
async disconnect() {
|
|
1199
1224
|
this.closing = true;
|
|
1200
1225
|
this.clearTimers();
|
|
@@ -1372,7 +1397,7 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1372
1397
|
}
|
|
1373
1398
|
return;
|
|
1374
1399
|
}
|
|
1375
|
-
if (type === "session:suspend" || type === "session:
|
|
1400
|
+
if (type === "session:suspend" || type === "session:terminate") {
|
|
1376
1401
|
const agentId = msg.agentId;
|
|
1377
1402
|
const chatId = msg.chatId;
|
|
1378
1403
|
if (agentId && chatId) this.emit("session:command", {
|
|
@@ -1382,6 +1407,15 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1382
1407
|
});
|
|
1383
1408
|
return;
|
|
1384
1409
|
}
|
|
1410
|
+
if (type === "session:reconcile:result") {
|
|
1411
|
+
const agentId = msg.agentId;
|
|
1412
|
+
const staleChatIds = Array.isArray(msg.staleChatIds) ? msg.staleChatIds : null;
|
|
1413
|
+
if (agentId && staleChatIds) this.emit("session:reconcile:result", {
|
|
1414
|
+
agentId,
|
|
1415
|
+
staleChatIds
|
|
1416
|
+
});
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1385
1419
|
if (type === "new_message") {
|
|
1386
1420
|
const inboxId = msg.inboxId;
|
|
1387
1421
|
if (inboxId) this.emit("agent:message", inboxId, msg);
|
|
@@ -3134,39 +3168,49 @@ var SessionManager = class {
|
|
|
3134
3168
|
const message = this.extractMessage(entry);
|
|
3135
3169
|
await this.routeMessage(chatId, message, entry.id);
|
|
3136
3170
|
}
|
|
3137
|
-
/** Handle a session command
|
|
3171
|
+
/** Handle a server-issued session command. Terminate drops all local state without reporting back. */
|
|
3138
3172
|
async handleCommand(chatId, command) {
|
|
3139
|
-
const session = this.sessions.get(chatId);
|
|
3140
3173
|
if (command === "session:suspend") {
|
|
3174
|
+
const session = this.sessions.get(chatId);
|
|
3141
3175
|
if (session?.status === "active") {
|
|
3142
3176
|
this.config.log(`Session ${chatId}: suspend command received`);
|
|
3143
3177
|
this.suspendSession(session);
|
|
3144
3178
|
}
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
await session.handler.shutdown();
|
|
3156
|
-
}
|
|
3157
|
-
this.addEvictedMapping(chatId, {
|
|
3158
|
-
claudeSessionId: session.claudeSessionId,
|
|
3159
|
-
lastActivity: session.lastActivity
|
|
3160
|
-
});
|
|
3161
|
-
this.sessions.delete(chatId);
|
|
3162
|
-
this.sessionRuntimeStates.delete(chatId);
|
|
3163
|
-
this.recomputeRuntimeState();
|
|
3164
|
-
this.notifySessionState(chatId, "evicted");
|
|
3165
|
-
this.persistRegistry();
|
|
3166
|
-
this.drainPendingQueue();
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
if (command === "session:terminate") {
|
|
3182
|
+
const session = this.sessions.get(chatId);
|
|
3183
|
+
const hadMapping = this.evictedMappings.has(chatId);
|
|
3184
|
+
if (!session && !hadMapping) return;
|
|
3185
|
+
this.config.log(`Session ${chatId}: terminate command received`);
|
|
3186
|
+
if (session?.status === "active") {
|
|
3187
|
+
this._activeCount--;
|
|
3188
|
+
await session.handler.shutdown().catch(() => {});
|
|
3167
3189
|
}
|
|
3190
|
+
this.sessions.delete(chatId);
|
|
3191
|
+
this.evictedMappings.delete(chatId);
|
|
3192
|
+
this.sessionRuntimeStates.delete(chatId);
|
|
3193
|
+
this.lastReportedStates.delete(chatId);
|
|
3194
|
+
for (let i = this.pendingQueue.length - 1; i >= 0; i--) if (this.pendingQueue[i]?.chatId === chatId) this.pendingQueue.splice(i, 1);
|
|
3195
|
+
this.recomputeRuntimeState();
|
|
3196
|
+
this.persistRegistry();
|
|
3197
|
+
this.drainPendingQueue();
|
|
3168
3198
|
}
|
|
3169
3199
|
}
|
|
3200
|
+
/** Chat IDs this client still holds locally (sessions + evictedMappings). */
|
|
3201
|
+
getHeldChatIds() {
|
|
3202
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3203
|
+
for (const id of this.sessions.keys()) ids.add(id);
|
|
3204
|
+
for (const id of this.evictedMappings.keys()) ids.add(id);
|
|
3205
|
+
return [...ids];
|
|
3206
|
+
}
|
|
3207
|
+
/**
|
|
3208
|
+
* Apply a server-declared stale list from `session:reconcile:result` — treat
|
|
3209
|
+
* each chatId as if a `session:terminate` command had arrived.
|
|
3210
|
+
*/
|
|
3211
|
+
applyStaleChatIds(staleChatIds) {
|
|
3212
|
+
for (const id of staleChatIds) this.handleCommand(id, "session:terminate");
|
|
3213
|
+
}
|
|
3170
3214
|
/** Shut down all sessions gracefully. */
|
|
3171
3215
|
async shutdown() {
|
|
3172
3216
|
if (this.idleTimer) {
|
|
@@ -3367,8 +3411,6 @@ var SessionManager = class {
|
|
|
3367
3411
|
this._activeCount--;
|
|
3368
3412
|
candidate.session.handler.shutdown().catch(() => {});
|
|
3369
3413
|
}
|
|
3370
|
-
candidate.session.status = "evicted";
|
|
3371
|
-
this.notifySessionState(candidate.key, "evicted");
|
|
3372
3414
|
this.sessions.delete(candidate.key);
|
|
3373
3415
|
this.sessionRuntimeStates.delete(candidate.key);
|
|
3374
3416
|
this.recomputeRuntimeState();
|
|
@@ -3505,6 +3547,7 @@ var AgentSlot = class {
|
|
|
3505
3547
|
sdk = null;
|
|
3506
3548
|
agentConfigCache = null;
|
|
3507
3549
|
pollingTimer = null;
|
|
3550
|
+
reconcileTimer = null;
|
|
3508
3551
|
listeners = [];
|
|
3509
3552
|
constructor(config) {
|
|
3510
3553
|
this.config = config;
|
|
@@ -3540,16 +3583,26 @@ var AgentSlot = class {
|
|
|
3540
3583
|
if (agentId === this.config.agentId) this.pullAndDispatch();
|
|
3541
3584
|
};
|
|
3542
3585
|
const onBound = (boundAgent) => {
|
|
3543
|
-
if (boundAgent.agentId === this.config.agentId)
|
|
3586
|
+
if (boundAgent.agentId === this.config.agentId) {
|
|
3587
|
+
this.fullStateSync();
|
|
3588
|
+
setTimeout(() => this.reconcileNow(), 5e3);
|
|
3589
|
+
}
|
|
3590
|
+
};
|
|
3591
|
+
const onReconcileResult = (result) => {
|
|
3592
|
+
if (result.agentId === this.config.agentId && this.sessionManager) this.sessionManager.applyStaleChatIds(result.staleChatIds);
|
|
3544
3593
|
};
|
|
3545
3594
|
this.clientConnection.on("agent:message", onMessage);
|
|
3546
3595
|
this.clientConnection.on("agent:bound", onBound);
|
|
3596
|
+
this.clientConnection.on("session:reconcile:result", onReconcileResult);
|
|
3547
3597
|
this.listeners.push({
|
|
3548
3598
|
event: "agent:message",
|
|
3549
3599
|
fn: onMessage
|
|
3550
3600
|
}, {
|
|
3551
3601
|
event: "agent:bound",
|
|
3552
3602
|
fn: onBound
|
|
3603
|
+
}, {
|
|
3604
|
+
event: "session:reconcile:result",
|
|
3605
|
+
fn: onReconcileResult
|
|
3553
3606
|
});
|
|
3554
3607
|
const registryPath = join(DEFAULT_DATA_DIR, "sessions", `${this.config.name}.json`);
|
|
3555
3608
|
const gitMirrorManager = createGitMirrorManager({
|
|
@@ -3592,6 +3645,7 @@ var AgentSlot = class {
|
|
|
3592
3645
|
fn: onCommand
|
|
3593
3646
|
});
|
|
3594
3647
|
this.startPolling();
|
|
3648
|
+
this.startReconcileLoop();
|
|
3595
3649
|
return agent;
|
|
3596
3650
|
}
|
|
3597
3651
|
async stop() {
|
|
@@ -3599,8 +3653,13 @@ var AgentSlot = class {
|
|
|
3599
3653
|
clearInterval(this.pollingTimer);
|
|
3600
3654
|
this.pollingTimer = null;
|
|
3601
3655
|
}
|
|
3656
|
+
if (this.reconcileTimer) {
|
|
3657
|
+
clearInterval(this.reconcileTimer);
|
|
3658
|
+
this.reconcileTimer = null;
|
|
3659
|
+
}
|
|
3602
3660
|
for (const entry of this.listeners) if (entry.event === "agent:message") this.clientConnection.off(entry.event, entry.fn);
|
|
3603
3661
|
else if (entry.event === "agent:bound") this.clientConnection.off(entry.event, entry.fn);
|
|
3662
|
+
else if (entry.event === "session:reconcile:result") this.clientConnection.off(entry.event, entry.fn);
|
|
3604
3663
|
else this.clientConnection.off(entry.event, entry.fn);
|
|
3605
3664
|
this.listeners = [];
|
|
3606
3665
|
await this.clientConnection.unbindAgent(this.config.agentId);
|
|
@@ -3631,6 +3690,16 @@ var AgentSlot = class {
|
|
|
3631
3690
|
}, 5e3);
|
|
3632
3691
|
this.pullAndDispatch();
|
|
3633
3692
|
}
|
|
3693
|
+
startReconcileLoop() {
|
|
3694
|
+
const intervalSec = this.config.session.reconcile_interval_seconds ?? 300;
|
|
3695
|
+
this.reconcileTimer = setInterval(() => this.reconcileNow(), intervalSec * 1e3);
|
|
3696
|
+
}
|
|
3697
|
+
reconcileNow() {
|
|
3698
|
+
if (!this.sessionManager) return;
|
|
3699
|
+
const chatIds = this.sessionManager.getHeldChatIds();
|
|
3700
|
+
if (chatIds.length === 0) return;
|
|
3701
|
+
this.clientConnection.sendSessionReconcile(this.config.agentId, chatIds);
|
|
3702
|
+
}
|
|
3634
3703
|
async pullAndDispatch() {
|
|
3635
3704
|
if (!this.sdk || !this.sessionManager) return;
|
|
3636
3705
|
try {
|
|
@@ -3659,7 +3728,8 @@ z.object({
|
|
|
3659
3728
|
}).passthrough();
|
|
3660
3729
|
const sessionConfigSchema = z.object({
|
|
3661
3730
|
idle_timeout: z.number().int().positive().default(IDLE_TIMEOUT_MS / 1e3),
|
|
3662
|
-
max_sessions: z.number().int().positive().default(50)
|
|
3731
|
+
max_sessions: z.number().int().positive().default(50),
|
|
3732
|
+
reconcile_interval_seconds: z.number().int().min(30).max(3600).default(300)
|
|
3663
3733
|
}).passthrough();
|
|
3664
3734
|
const agentSlotConfigSchema = z.object({
|
|
3665
3735
|
agentId: z.string().min(1),
|
|
@@ -3808,7 +3878,8 @@ var ClientRuntime = class {
|
|
|
3808
3878
|
handlerFactory,
|
|
3809
3879
|
session: {
|
|
3810
3880
|
idle_timeout: config.session.idle_timeout,
|
|
3811
|
-
max_sessions: config.session.max_sessions
|
|
3881
|
+
max_sessions: config.session.max_sessions,
|
|
3882
|
+
reconcile_interval_seconds: 300
|
|
3812
3883
|
},
|
|
3813
3884
|
concurrency: config.concurrency,
|
|
3814
3885
|
clientConnection: this.connection
|
|
@@ -4602,7 +4673,7 @@ async function onboardCreate(args) {
|
|
|
4602
4673
|
}
|
|
4603
4674
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
4604
4675
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
4605
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
4676
|
+
const { bindFeishuBot } = await import("./feishu-n9Y2yGTT.mjs").then((n) => n.r);
|
|
4606
4677
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
4607
4678
|
if (!targetAgentUuid) process.stderr.write(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
4608
4679
|
else {
|
|
@@ -4743,7 +4814,7 @@ function setNestedByDot(obj, dotPath, value) {
|
|
|
4743
4814
|
if (lastKey !== void 0) current[lastKey] = value;
|
|
4744
4815
|
}
|
|
4745
4816
|
//#endregion
|
|
4746
|
-
//#region ../server/dist/app-
|
|
4817
|
+
//#region ../server/dist/app-C6y-ySN9.mjs
|
|
4747
4818
|
var __defProp = Object.defineProperty;
|
|
4748
4819
|
var __exportAll = (all, no_symbols) => {
|
|
4749
4820
|
let target = {};
|
|
@@ -5280,6 +5351,8 @@ function parseId$1(raw) {
|
|
|
5280
5351
|
async function adminAdapterMappingRoutes(app) {
|
|
5281
5352
|
app.get("/", async (request) => {
|
|
5282
5353
|
const scope = memberScope(request);
|
|
5354
|
+
const conditions = [eq(agents.organizationId, scope.organizationId)];
|
|
5355
|
+
if (scope.role !== "admin") conditions.push(eq(agents.managerId, scope.memberId));
|
|
5283
5356
|
return (await app.db.select({
|
|
5284
5357
|
id: adapterAgentMappings.id,
|
|
5285
5358
|
platform: adapterAgentMappings.platform,
|
|
@@ -5288,7 +5361,7 @@ async function adminAdapterMappingRoutes(app) {
|
|
|
5288
5361
|
boundVia: adapterAgentMappings.boundVia,
|
|
5289
5362
|
displayName: adapterAgentMappings.displayName,
|
|
5290
5363
|
createdAt: adapterAgentMappings.createdAt
|
|
5291
|
-
}).from(adapterAgentMappings).innerJoin(agents, eq(agents.uuid, adapterAgentMappings.agentId)).where(
|
|
5364
|
+
}).from(adapterAgentMappings).innerJoin(agents, eq(agents.uuid, adapterAgentMappings.agentId)).where(and(...conditions)).orderBy(desc(adapterAgentMappings.createdAt))).map((r) => ({
|
|
5292
5365
|
id: r.id,
|
|
5293
5366
|
platform: r.platform,
|
|
5294
5367
|
externalUserId: r.externalUserId,
|
|
@@ -5336,11 +5409,6 @@ async function adminAdapterMappingRoutes(app) {
|
|
|
5336
5409
|
return reply.status(204).send();
|
|
5337
5410
|
});
|
|
5338
5411
|
}
|
|
5339
|
-
async function adminAdapterStatusRoutes(app) {
|
|
5340
|
-
app.get("/", async () => {
|
|
5341
|
-
return app.adapterManager.getBotStatuses();
|
|
5342
|
-
});
|
|
5343
|
-
}
|
|
5344
5412
|
/** Bot credentials for external platform adapters. Credentials are encrypted at application layer (AES-256-GCM). */
|
|
5345
5413
|
const adapterConfigs = pgTable("adapter_configs", {
|
|
5346
5414
|
id: serial("id").primaryKey(),
|
|
@@ -5351,6 +5419,17 @@ const adapterConfigs = pgTable("adapter_configs", {
|
|
|
5351
5419
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
5352
5420
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
5353
5421
|
}, (t) => [unique("uq_adapter_configs_agent_platform").on(t.agentId, t.platform)]);
|
|
5422
|
+
async function adminAdapterStatusRoutes(app) {
|
|
5423
|
+
app.get("/", async (request) => {
|
|
5424
|
+
const scope = memberScope(request);
|
|
5425
|
+
const conditions = [eq(agents.organizationId, scope.organizationId), ne(agents.status, "deleted")];
|
|
5426
|
+
if (scope.role !== "admin") conditions.push(eq(agents.managerId, scope.memberId));
|
|
5427
|
+
const visibleRows = await app.db.select({ id: adapterConfigs.id }).from(adapterConfigs).innerJoin(agents, eq(agents.uuid, adapterConfigs.agentId)).where(and(...conditions));
|
|
5428
|
+
const visibleIds = new Set(visibleRows.map((r) => r.id));
|
|
5429
|
+
if (visibleIds.size === 0) return [];
|
|
5430
|
+
return app.adapterManager.getBotStatuses().filter((s) => visibleIds.has(s.configId));
|
|
5431
|
+
});
|
|
5432
|
+
}
|
|
5354
5433
|
const ALGORITHM = "aes-256-gcm";
|
|
5355
5434
|
const IV_LENGTH = 12;
|
|
5356
5435
|
const AUTH_TAG_LENGTH = 16;
|
|
@@ -5464,6 +5543,30 @@ function toResponse(row) {
|
|
|
5464
5543
|
async function listAdapterConfigs(db) {
|
|
5465
5544
|
return (await db.select().from(adapterConfigs).orderBy(desc(adapterConfigs.createdAt))).map(toResponse);
|
|
5466
5545
|
}
|
|
5546
|
+
/**
|
|
5547
|
+
* Scoped variant used by the member-facing admin route.
|
|
5548
|
+
*
|
|
5549
|
+
* - admin: every adapter config whose agent belongs to the caller's org.
|
|
5550
|
+
* - non-admin: only adapter configs bound to agents the caller manages
|
|
5551
|
+
* (Rule: bindings follow manageability).
|
|
5552
|
+
*
|
|
5553
|
+
* Kept as a separate function so internal self-service callers
|
|
5554
|
+
* (`agent/feishu-bot.ts`) that only need a raw read don't accidentally pay
|
|
5555
|
+
* for the join.
|
|
5556
|
+
*/
|
|
5557
|
+
async function listAdapterConfigsForMember(db, scope) {
|
|
5558
|
+
const conditions = [eq(agents.organizationId, scope.organizationId), ne(agents.status, "deleted")];
|
|
5559
|
+
if (scope.role !== "admin") conditions.push(eq(agents.managerId, scope.memberId));
|
|
5560
|
+
return (await db.select({
|
|
5561
|
+
id: adapterConfigs.id,
|
|
5562
|
+
platform: adapterConfigs.platform,
|
|
5563
|
+
agentId: adapterConfigs.agentId,
|
|
5564
|
+
credentials: adapterConfigs.credentials,
|
|
5565
|
+
status: adapterConfigs.status,
|
|
5566
|
+
createdAt: adapterConfigs.createdAt,
|
|
5567
|
+
updatedAt: adapterConfigs.updatedAt
|
|
5568
|
+
}).from(adapterConfigs).innerJoin(agents, eq(agents.uuid, adapterConfigs.agentId)).where(and(...conditions)).orderBy(desc(adapterConfigs.createdAt))).map(toResponse);
|
|
5569
|
+
}
|
|
5467
5570
|
async function getAdapterConfig(db, id) {
|
|
5468
5571
|
const [row] = await db.select().from(adapterConfigs).where(eq(adapterConfigs.id, id)).limit(1);
|
|
5469
5572
|
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
@@ -5513,8 +5616,9 @@ function parseId(raw) {
|
|
|
5513
5616
|
return id;
|
|
5514
5617
|
}
|
|
5515
5618
|
async function adminAdapterRoutes(app) {
|
|
5516
|
-
app.get("/", async () => {
|
|
5517
|
-
|
|
5619
|
+
app.get("/", async (request) => {
|
|
5620
|
+
const scope = memberScope(request);
|
|
5621
|
+
return (await listAdapterConfigsForMember(app.db, scope)).map((c) => ({
|
|
5518
5622
|
...c,
|
|
5519
5623
|
createdAt: c.createdAt.toISOString(),
|
|
5520
5624
|
updatedAt: c.updatedAt.toISOString()
|
|
@@ -5776,6 +5880,47 @@ async function getAgent(db, uuid) {
|
|
|
5776
5880
|
return agent;
|
|
5777
5881
|
}
|
|
5778
5882
|
/**
|
|
5883
|
+
* Admin-only variant: return every non-deleted agent in the org, ignoring
|
|
5884
|
+
* the visibility filter. Used by the `/admin` "All Agents" view so a team
|
|
5885
|
+
* admin can see and act on private agents owned by other members. The
|
|
5886
|
+
* route layer is responsible for gating this to admin callers — the
|
|
5887
|
+
* service does not enforce role by itself, but it does enforce org scope
|
|
5888
|
+
* and the not-deleted predicate.
|
|
5889
|
+
*/
|
|
5890
|
+
async function listAgentsForAdmin(db, scope, limit, cursor) {
|
|
5891
|
+
const conditions = [eq(agents.organizationId, scope.organizationId), ne(agents.status, AGENT_STATUSES.DELETED)];
|
|
5892
|
+
if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
|
|
5893
|
+
const where = and(...conditions);
|
|
5894
|
+
const rows = await db.select({
|
|
5895
|
+
uuid: agents.uuid,
|
|
5896
|
+
name: agents.name,
|
|
5897
|
+
organizationId: agents.organizationId,
|
|
5898
|
+
type: agents.type,
|
|
5899
|
+
displayName: agents.displayName,
|
|
5900
|
+
delegateMention: agents.delegateMention,
|
|
5901
|
+
inboxId: agents.inboxId,
|
|
5902
|
+
status: agents.status,
|
|
5903
|
+
cloudUserId: agents.cloudUserId,
|
|
5904
|
+
visibility: agents.visibility,
|
|
5905
|
+
metadata: agents.metadata,
|
|
5906
|
+
managerId: agents.managerId,
|
|
5907
|
+
clientId: agents.clientId,
|
|
5908
|
+
createdAt: agents.createdAt,
|
|
5909
|
+
updatedAt: agents.updatedAt,
|
|
5910
|
+
presenceStatus: agentPresence.status,
|
|
5911
|
+
runtimeType: agentPresence.runtimeType,
|
|
5912
|
+
runtimeState: agentPresence.runtimeState,
|
|
5913
|
+
activeSessions: agentPresence.activeSessions
|
|
5914
|
+
}).from(agents).leftJoin(agentPresence, eq(agents.uuid, agentPresence.agentId)).where(where).orderBy(desc(agents.createdAt)).limit(limit + 1);
|
|
5915
|
+
const hasMore = rows.length > limit;
|
|
5916
|
+
const items = hasMore ? rows.slice(0, limit) : rows;
|
|
5917
|
+
const last = items[items.length - 1];
|
|
5918
|
+
return {
|
|
5919
|
+
items,
|
|
5920
|
+
nextCursor: hasMore && last ? last.createdAt.toISOString() : null
|
|
5921
|
+
};
|
|
5922
|
+
}
|
|
5923
|
+
/**
|
|
5779
5924
|
* List agents visible to a specific member.
|
|
5780
5925
|
* Uses agentVisibilityCondition from access-control (same rules for all roles).
|
|
5781
5926
|
*/
|
|
@@ -6271,17 +6416,31 @@ async function cleanupStalePresence(db, staleSeconds = 60) {
|
|
|
6271
6416
|
`)).length;
|
|
6272
6417
|
}
|
|
6273
6418
|
/**
|
|
6274
|
-
* Assert the caller
|
|
6419
|
+
* Assert the caller can act on this client. Throws 404 for both "not found"
|
|
6275
6420
|
* and "not yours" to prevent UUID enumeration across org/user boundaries.
|
|
6276
|
-
*
|
|
6277
|
-
*
|
|
6421
|
+
*
|
|
6422
|
+
* - member: owner match (`row.user_id == scope.userId`).
|
|
6423
|
+
* - admin: any client whose owner is a member of the admin's own org.
|
|
6424
|
+
*
|
|
6425
|
+
* Legacy unclaimed rows (`user_id IS NULL`) have no org association we can
|
|
6426
|
+
* verify — we explicitly refuse to grant admin access to them so a
|
|
6427
|
+
* cross-tenant admin can't operate on another org's orphan rows. These
|
|
6428
|
+
* orphans are surfaced for self-service re-registration only; the owning
|
|
6429
|
+
* operator must claim the row via `first-tree-hub connect` before any
|
|
6430
|
+
* admin action becomes available.
|
|
6278
6431
|
*/
|
|
6279
|
-
async function assertClientOwner(db, clientId,
|
|
6432
|
+
async function assertClientOwner(db, clientId, scope) {
|
|
6280
6433
|
const [row] = await db.select({
|
|
6281
6434
|
id: clients.id,
|
|
6282
6435
|
userId: clients.userId
|
|
6283
6436
|
}).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
6284
|
-
if (!row
|
|
6437
|
+
if (!row) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
6438
|
+
if (row.userId === scope.userId) return;
|
|
6439
|
+
if (scope.role === "admin" && row.userId !== null) {
|
|
6440
|
+
const [sibling] = await db.select({ id: members.id }).from(members).where(and(eq(members.userId, row.userId), eq(members.organizationId, scope.organizationId))).limit(1);
|
|
6441
|
+
if (sibling) return;
|
|
6442
|
+
}
|
|
6443
|
+
throw new NotFoundError(`Client "${clientId}" not found`);
|
|
6285
6444
|
}
|
|
6286
6445
|
/**
|
|
6287
6446
|
* Upsert the clients row for a given `client_id` under an authenticated user.
|
|
@@ -6361,8 +6520,32 @@ async function listActiveAgentsPinnedToClient(db, clientId) {
|
|
|
6361
6520
|
type: agents.type
|
|
6362
6521
|
}).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
|
|
6363
6522
|
}
|
|
6364
|
-
|
|
6365
|
-
|
|
6523
|
+
/**
|
|
6524
|
+
* Scope-aware client listing.
|
|
6525
|
+
*
|
|
6526
|
+
* - member: only rows where `user_id = scope.userId`.
|
|
6527
|
+
* - admin: every claimed row whose owner is a member of the caller's
|
|
6528
|
+
* organization.
|
|
6529
|
+
*
|
|
6530
|
+
* Legacy unclaimed rows (`user_id IS NULL`) are intentionally hidden from
|
|
6531
|
+
* both roles — the `clients` table has no org column, so we cannot verify
|
|
6532
|
+
* which org an orphan belongs to. Exposing them to admin would leak
|
|
6533
|
+
* orphans across tenants. The owning operator reclaims the row via
|
|
6534
|
+
* `first-tree-hub connect`, after which it appears in their list.
|
|
6535
|
+
*/
|
|
6536
|
+
async function listClients(db, scope) {
|
|
6537
|
+
const rows = scope.role === "admin" ? await db.selectDistinct({
|
|
6538
|
+
id: clients.id,
|
|
6539
|
+
userId: clients.userId,
|
|
6540
|
+
status: clients.status,
|
|
6541
|
+
sdkVersion: clients.sdkVersion,
|
|
6542
|
+
hostname: clients.hostname,
|
|
6543
|
+
os: clients.os,
|
|
6544
|
+
instanceId: clients.instanceId,
|
|
6545
|
+
connectedAt: clients.connectedAt,
|
|
6546
|
+
lastSeenAt: clients.lastSeenAt,
|
|
6547
|
+
metadata: clients.metadata
|
|
6548
|
+
}).from(clients).innerJoin(members, eq(members.userId, clients.userId)).where(eq(members.organizationId, scope.organizationId)) : await db.select().from(clients).where(eq(clients.userId, scope.userId));
|
|
6366
6549
|
const counts = await db.select({
|
|
6367
6550
|
clientId: agents.clientId,
|
|
6368
6551
|
count: sql`count(*)::int`
|
|
@@ -6827,6 +7010,33 @@ async function adminAgentRoutes(app) {
|
|
|
6827
7010
|
nextCursor: result.nextCursor
|
|
6828
7011
|
};
|
|
6829
7012
|
});
|
|
7013
|
+
/**
|
|
7014
|
+
* Admin-only: every agent in the caller's org, skipping the visibility
|
|
7015
|
+
* filter applied on the regular `/agents` list. Private agents owned by
|
|
7016
|
+
* other members show up here so an admin can reassign or troubleshoot.
|
|
7017
|
+
* Role gating is enforced here — the parent route group does NOT add
|
|
7018
|
+
* adminOnly because the member-facing `GET /` is shared.
|
|
7019
|
+
*/
|
|
7020
|
+
app.get("/all", async (request) => {
|
|
7021
|
+
const scope = memberScope(request);
|
|
7022
|
+
if (scope.role !== "admin") throw new ForbiddenError("Admin role required");
|
|
7023
|
+
const query = paginationQuerySchema.parse(request.query);
|
|
7024
|
+
const result = await listAgentsForAdmin(app.db, scope, query.limit, query.cursor);
|
|
7025
|
+
return {
|
|
7026
|
+
items: result.items.map((a) => ({
|
|
7027
|
+
...a,
|
|
7028
|
+
managerId: a.managerId ?? null,
|
|
7029
|
+
presenceStatus: a.presenceStatus ?? "offline",
|
|
7030
|
+
createdAt: a.createdAt.toISOString(),
|
|
7031
|
+
updatedAt: a.updatedAt.toISOString(),
|
|
7032
|
+
clientId: a.clientId ?? null,
|
|
7033
|
+
runtimeType: a.runtimeType ?? null,
|
|
7034
|
+
runtimeState: a.runtimeState ?? null,
|
|
7035
|
+
activeSessions: a.activeSessions ?? null
|
|
7036
|
+
})),
|
|
7037
|
+
nextCursor: result.nextCursor
|
|
7038
|
+
};
|
|
7039
|
+
});
|
|
6830
7040
|
app.post("/", async (request, reply) => {
|
|
6831
7041
|
const scope = memberScope(request);
|
|
6832
7042
|
const body = createAgentSchema.parse(request.body);
|
|
@@ -7113,6 +7323,24 @@ async function adminChatRoutes(app) {
|
|
|
7113
7323
|
}))
|
|
7114
7324
|
};
|
|
7115
7325
|
});
|
|
7326
|
+
/** Rename (or clear) a chat's topic. Requires participation or supervision — same gate as reading it. */
|
|
7327
|
+
app.patch("/:chatId", async (request) => {
|
|
7328
|
+
const { chatId } = request.params;
|
|
7329
|
+
const scope = memberScope(request);
|
|
7330
|
+
await assertChatAccess(app.db, scope, chatId);
|
|
7331
|
+
const body = updateChatSchema.parse(request.body);
|
|
7332
|
+
const nextTopic = body.topic && body.topic.length > 0 ? body.topic : null;
|
|
7333
|
+
const [updated] = await app.db.update(chats).set({
|
|
7334
|
+
topic: nextTopic,
|
|
7335
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
7336
|
+
}).where(eq(chats.id, chatId)).returning();
|
|
7337
|
+
if (!updated) throw new Error("Unexpected: chat missing after update");
|
|
7338
|
+
return {
|
|
7339
|
+
...updated,
|
|
7340
|
+
createdAt: updated.createdAt.toISOString(),
|
|
7341
|
+
updatedAt: updated.updatedAt.toISOString()
|
|
7342
|
+
};
|
|
7343
|
+
});
|
|
7116
7344
|
/** List messages in a chat with delivery status (requires participation or supervision) */
|
|
7117
7345
|
app.get("/:chatId/messages", async (request) => {
|
|
7118
7346
|
const { chatId } = request.params;
|
|
@@ -7232,10 +7460,18 @@ const agentChatSessions = pgTable("agent_chat_sessions", {
|
|
|
7232
7460
|
state: text("state").notNull(),
|
|
7233
7461
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
7234
7462
|
}, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
|
|
7235
|
-
/**
|
|
7463
|
+
/**
|
|
7464
|
+
* Upsert session state + refresh presence aggregates + NOTIFY.
|
|
7465
|
+
*
|
|
7466
|
+
* Revival defense: an admin-terminated (`evicted`) row is immutable; a client
|
|
7467
|
+
* report for the same chatId is silently dropped after the FOR UPDATE check.
|
|
7468
|
+
*/
|
|
7236
7469
|
async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier) {
|
|
7237
7470
|
const now = /* @__PURE__ */ new Date();
|
|
7471
|
+
let wrote = false;
|
|
7238
7472
|
await db.transaction(async (tx) => {
|
|
7473
|
+
const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
|
|
7474
|
+
if (existing?.state === "evicted") return;
|
|
7239
7475
|
await tx.insert(agentChatSessions).values({
|
|
7240
7476
|
agentId,
|
|
7241
7477
|
chatId,
|
|
@@ -7259,8 +7495,9 @@ async function upsertSessionState(db, agentId, chatId, state, organizationId, no
|
|
|
7259
7495
|
totalSessions,
|
|
7260
7496
|
lastSeenAt: now
|
|
7261
7497
|
}).where(eq(agentPresence.agentId, agentId));
|
|
7498
|
+
wrote = true;
|
|
7262
7499
|
});
|
|
7263
|
-
if (notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
|
|
7500
|
+
if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
|
|
7264
7501
|
}
|
|
7265
7502
|
async function resetActivity(db, agentId) {
|
|
7266
7503
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -7313,22 +7550,6 @@ async function listAgentsWithRuntime(db, scope) {
|
|
|
7313
7550
|
type: agents.type
|
|
7314
7551
|
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
|
|
7315
7552
|
}
|
|
7316
|
-
/**
|
|
7317
|
-
* Clean up stale session rows from agent_chat_sessions.
|
|
7318
|
-
* Removes evicted rows older than staleSeconds and suspended rows older than staleSeconds.
|
|
7319
|
-
* Returns the number of rows deleted.
|
|
7320
|
-
*/
|
|
7321
|
-
async function cleanupStaleSessions(db, staleSeconds = 604800) {
|
|
7322
|
-
return (await db.execute(sql`
|
|
7323
|
-
WITH deleted AS (
|
|
7324
|
-
DELETE FROM agent_chat_sessions
|
|
7325
|
-
WHERE state IN ('evicted', 'suspended')
|
|
7326
|
-
AND updated_at < NOW() - make_interval(secs => ${staleSeconds})
|
|
7327
|
-
RETURNING 1
|
|
7328
|
-
)
|
|
7329
|
-
SELECT count(*)::int AS cnt FROM deleted
|
|
7330
|
-
`))[0]?.cnt ?? 0;
|
|
7331
|
-
}
|
|
7332
7553
|
/** Serialize a Date to ISO string, or null. */
|
|
7333
7554
|
function serializeDate(d) {
|
|
7334
7555
|
return d ? d.toISOString() : null;
|
|
@@ -7336,7 +7557,11 @@ function serializeDate(d) {
|
|
|
7336
7557
|
async function adminClientRoutes(app) {
|
|
7337
7558
|
app.get("/", async (request) => {
|
|
7338
7559
|
const scope = memberScope(request);
|
|
7339
|
-
return (await listClients(app.db,
|
|
7560
|
+
return (await listClients(app.db, {
|
|
7561
|
+
userId: scope.userId,
|
|
7562
|
+
organizationId: scope.organizationId,
|
|
7563
|
+
role: scope.role
|
|
7564
|
+
})).map((c) => ({
|
|
7340
7565
|
id: c.id,
|
|
7341
7566
|
userId: c.userId,
|
|
7342
7567
|
status: c.status,
|
|
@@ -7350,7 +7575,7 @@ async function adminClientRoutes(app) {
|
|
|
7350
7575
|
});
|
|
7351
7576
|
app.get("/:clientId", async (request) => {
|
|
7352
7577
|
const scope = memberScope(request);
|
|
7353
|
-
await assertClientOwner(app.db, request.params.clientId, scope
|
|
7578
|
+
await assertClientOwner(app.db, request.params.clientId, scope);
|
|
7354
7579
|
const client = await getClient(app.db, request.params.clientId);
|
|
7355
7580
|
if (!client) throw new Error("unreachable: client missing after owner check");
|
|
7356
7581
|
return {
|
|
@@ -7367,7 +7592,7 @@ async function adminClientRoutes(app) {
|
|
|
7367
7592
|
app.post("/:clientId/disconnect", async (request) => {
|
|
7368
7593
|
const scope = memberScope(request);
|
|
7369
7594
|
const { clientId } = request.params;
|
|
7370
|
-
await assertClientOwner(app.db, clientId, scope
|
|
7595
|
+
await assertClientOwner(app.db, clientId, scope);
|
|
7371
7596
|
const agentIds = forceDisconnectClient(clientId);
|
|
7372
7597
|
await disconnectClient(app.db, clientId);
|
|
7373
7598
|
return {
|
|
@@ -7378,7 +7603,7 @@ async function adminClientRoutes(app) {
|
|
|
7378
7603
|
app.delete("/:clientId", async (request, reply) => {
|
|
7379
7604
|
const scope = memberScope(request);
|
|
7380
7605
|
const { clientId } = request.params;
|
|
7381
|
-
await assertClientOwner(app.db, clientId, scope
|
|
7606
|
+
await assertClientOwner(app.db, clientId, scope);
|
|
7382
7607
|
await retireClient(app.db, clientId);
|
|
7383
7608
|
forceDisconnectClient(clientId);
|
|
7384
7609
|
await disconnectClient(app.db, clientId);
|
|
@@ -7658,16 +7883,26 @@ async function adminOverviewRoutes(app) {
|
|
|
7658
7883
|
};
|
|
7659
7884
|
});
|
|
7660
7885
|
}
|
|
7886
|
+
const SUMMARY_MAX_LENGTH = 50;
|
|
7887
|
+
/** Extract a plain-text summary from a message's JSONB content field. */
|
|
7888
|
+
function extractSummary(content, maxLen = SUMMARY_MAX_LENGTH) {
|
|
7889
|
+
let text = "";
|
|
7890
|
+
if (typeof content === "object" && content !== null && "text" in content) text = String(content.text ?? "");
|
|
7891
|
+
else if (typeof content === "string") text = content;
|
|
7892
|
+
return text ? text.slice(0, maxLen) : null;
|
|
7893
|
+
}
|
|
7661
7894
|
/** List sessions for a specific agent, with optional state filters. */
|
|
7662
7895
|
async function listAgentSessions(db, agentId, filters) {
|
|
7663
7896
|
const conditions = [eq(agentChatSessions.agentId, agentId)];
|
|
7664
7897
|
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
7898
|
+
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
7665
7899
|
const rows = await db.select({
|
|
7666
7900
|
agentId: agentChatSessions.agentId,
|
|
7667
7901
|
chatId: agentChatSessions.chatId,
|
|
7668
7902
|
state: agentChatSessions.state,
|
|
7669
7903
|
updatedAt: agentChatSessions.updatedAt,
|
|
7670
|
-
chatCreatedAt: chats.createdAt
|
|
7904
|
+
chatCreatedAt: chats.createdAt,
|
|
7905
|
+
chatTopic: chats.topic
|
|
7671
7906
|
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
|
|
7672
7907
|
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
7673
7908
|
const agentRuntimeState = presence?.runtimeState ?? null;
|
|
@@ -7678,6 +7913,15 @@ async function listAgentSessions(db, agentId, filters) {
|
|
|
7678
7913
|
count: sql`count(*)::int`
|
|
7679
7914
|
}).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
|
|
7680
7915
|
const countMap = new Map(messageCounts.map((r) => [r.chatId, r.count]));
|
|
7916
|
+
const firstMessages = chatIds.length > 0 ? await db.selectDistinctOn([messages.chatId], {
|
|
7917
|
+
chatId: messages.chatId,
|
|
7918
|
+
content: messages.content
|
|
7919
|
+
}).from(messages).where(inArray(messages.chatId, chatIds)).orderBy(messages.chatId, messages.createdAt) : [];
|
|
7920
|
+
const summaryMap = /* @__PURE__ */ new Map();
|
|
7921
|
+
for (const row of firstMessages) {
|
|
7922
|
+
const summary = extractSummary(row.content);
|
|
7923
|
+
if (summary) summaryMap.set(row.chatId, summary);
|
|
7924
|
+
}
|
|
7681
7925
|
return rows.map((r) => ({
|
|
7682
7926
|
agentId: r.agentId,
|
|
7683
7927
|
chatId: r.chatId,
|
|
@@ -7685,7 +7929,9 @@ async function listAgentSessions(db, agentId, filters) {
|
|
|
7685
7929
|
runtimeState: agentRuntimeState,
|
|
7686
7930
|
startedAt: r.chatCreatedAt.toISOString(),
|
|
7687
7931
|
lastActivityAt: r.updatedAt.toISOString(),
|
|
7688
|
-
messageCount: countMap.get(r.chatId) ?? 0
|
|
7932
|
+
messageCount: countMap.get(r.chatId) ?? 0,
|
|
7933
|
+
summary: summaryMap.get(r.chatId) ?? null,
|
|
7934
|
+
topic: r.chatTopic ?? null
|
|
7689
7935
|
}));
|
|
7690
7936
|
}
|
|
7691
7937
|
/** Get a single session's detail. */
|
|
@@ -7695,11 +7941,14 @@ async function getSession(db, agentId, chatId) {
|
|
|
7695
7941
|
chatId: agentChatSessions.chatId,
|
|
7696
7942
|
state: agentChatSessions.state,
|
|
7697
7943
|
updatedAt: agentChatSessions.updatedAt,
|
|
7698
|
-
chatCreatedAt: chats.createdAt
|
|
7944
|
+
chatCreatedAt: chats.createdAt,
|
|
7945
|
+
chatTopic: chats.topic
|
|
7699
7946
|
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
|
|
7700
7947
|
if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
7701
7948
|
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
7702
7949
|
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)));
|
|
7950
|
+
const firstMsg = (await db.execute(sql`SELECT content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at ASC LIMIT 1`))[0];
|
|
7951
|
+
const summary = firstMsg ? extractSummary(firstMsg.content) : null;
|
|
7703
7952
|
return {
|
|
7704
7953
|
agentId: row.agentId,
|
|
7705
7954
|
chatId: row.chatId,
|
|
@@ -7707,13 +7956,16 @@ async function getSession(db, agentId, chatId) {
|
|
|
7707
7956
|
runtimeState: presence?.runtimeState ?? null,
|
|
7708
7957
|
startedAt: row.chatCreatedAt.toISOString(),
|
|
7709
7958
|
lastActivityAt: row.updatedAt.toISOString(),
|
|
7710
|
-
messageCount: countRow?.count ?? 0
|
|
7959
|
+
messageCount: countRow?.count ?? 0,
|
|
7960
|
+
summary,
|
|
7961
|
+
topic: row.chatTopic ?? null
|
|
7711
7962
|
};
|
|
7712
7963
|
}
|
|
7713
7964
|
/** List all sessions across all agents, with pagination. Scoped to organization. */
|
|
7714
7965
|
async function listAllSessions(db, limit, cursor, filters) {
|
|
7715
7966
|
const conditions = [];
|
|
7716
7967
|
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
7968
|
+
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
7717
7969
|
if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
|
|
7718
7970
|
if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
|
|
7719
7971
|
if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
|
|
@@ -7742,11 +7994,54 @@ async function listAllSessions(db, limit, cursor, filters) {
|
|
|
7742
7994
|
runtimeState: runtimeMap.get(r.agentId) ?? null,
|
|
7743
7995
|
startedAt: r.chatCreatedAt.toISOString(),
|
|
7744
7996
|
lastActivityAt: r.updatedAt.toISOString(),
|
|
7745
|
-
messageCount: 0
|
|
7997
|
+
messageCount: 0,
|
|
7998
|
+
summary: null,
|
|
7999
|
+
topic: null
|
|
7746
8000
|
})),
|
|
7747
8001
|
nextCursor
|
|
7748
8002
|
};
|
|
7749
8003
|
}
|
|
8004
|
+
/** Commit `active → suspended`. No-op on suspended/evicted. Throws if row is missing. */
|
|
8005
|
+
async function suspendSession(db, agentId, chatId, organizationId, notifier) {
|
|
8006
|
+
return transitionSessionState(db, agentId, chatId, "suspended", ["active"], organizationId, notifier);
|
|
8007
|
+
}
|
|
8008
|
+
/** Commit `suspended → evicted` (terminal — listings hide it, revival defense blocks resurrection). */
|
|
8009
|
+
async function archiveSession(db, agentId, chatId, organizationId, notifier) {
|
|
8010
|
+
return transitionSessionState(db, agentId, chatId, "evicted", ["suspended"], organizationId, notifier);
|
|
8011
|
+
}
|
|
8012
|
+
async function transitionSessionState(db, agentId, chatId, target, from, organizationId, notifier) {
|
|
8013
|
+
const now = /* @__PURE__ */ new Date();
|
|
8014
|
+
let finalState = null;
|
|
8015
|
+
let transitioned = false;
|
|
8016
|
+
await db.transaction(async (tx) => {
|
|
8017
|
+
const [existing] = await tx.select({ state: agentChatSessions.state }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).for("update");
|
|
8018
|
+
if (!existing) return;
|
|
8019
|
+
const current = existing.state;
|
|
8020
|
+
finalState = current;
|
|
8021
|
+
if (!from.includes(current)) return;
|
|
8022
|
+
await tx.update(agentChatSessions).set({
|
|
8023
|
+
state: target,
|
|
8024
|
+
updatedAt: now
|
|
8025
|
+
}).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId)));
|
|
8026
|
+
const [counts] = await tx.select({
|
|
8027
|
+
active: sql`count(*) FILTER (WHERE ${agentChatSessions.state} = 'active')::int`,
|
|
8028
|
+
total: sql`count(*) FILTER (WHERE ${agentChatSessions.state} != 'evicted')::int`
|
|
8029
|
+
}).from(agentChatSessions).where(eq(agentChatSessions.agentId, agentId));
|
|
8030
|
+
await tx.update(agentPresence).set({
|
|
8031
|
+
activeSessions: counts?.active ?? 0,
|
|
8032
|
+
totalSessions: counts?.total ?? 0,
|
|
8033
|
+
lastSeenAt: now
|
|
8034
|
+
}).where(eq(agentPresence.agentId, agentId));
|
|
8035
|
+
finalState = target;
|
|
8036
|
+
transitioned = true;
|
|
8037
|
+
});
|
|
8038
|
+
if (finalState === null) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
8039
|
+
if (transitioned && notifier) notifier.notifySessionStateChange(agentId, chatId, target, organizationId).catch(() => {});
|
|
8040
|
+
return {
|
|
8041
|
+
state: finalState,
|
|
8042
|
+
transitioned
|
|
8043
|
+
};
|
|
8044
|
+
}
|
|
7750
8045
|
/**
|
|
7751
8046
|
* Filter sessions to only those where the given agent is also a participant in the chat.
|
|
7752
8047
|
* Used when a non-manager views sessions of an org-visible agent — they should only see
|
|
@@ -7927,49 +8222,41 @@ async function adminSessionRoutes(app) {
|
|
|
7927
8222
|
direction
|
|
7928
8223
|
});
|
|
7929
8224
|
});
|
|
7930
|
-
/** POST /admin/sessions/agents/:agentId/:chatId/suspend —
|
|
8225
|
+
/** POST /admin/sessions/agents/:agentId/:chatId/suspend — commit first, WS-send best-effort. */
|
|
7931
8226
|
app.post("/agents/:agentId/:chatId/suspend", async (request, reply) => {
|
|
7932
8227
|
const { agentId, chatId } = request.params;
|
|
7933
8228
|
await assertCanManage(app.db, memberScope(request), agentId);
|
|
7934
|
-
|
|
8229
|
+
const member = requireMember(request);
|
|
8230
|
+
const result = await suspendSession(app.db, agentId, chatId, member.organizationId, app.notifier);
|
|
8231
|
+
if (result.transitioned) sendToAgent$1(agentId, {
|
|
7935
8232
|
type: "session:suspend",
|
|
7936
8233
|
chatId
|
|
7937
|
-
})) throw new ConflictError("Agent is not connected — session command requires a live connection");
|
|
7938
|
-
return reply.status(202).send({
|
|
7939
|
-
status: "sent",
|
|
7940
|
-
command: "suspend",
|
|
7941
|
-
agentId,
|
|
7942
|
-
chatId
|
|
7943
8234
|
});
|
|
7944
|
-
|
|
7945
|
-
/** POST /admin/sessions/agents/:agentId/:chatId/resume — resume a session */
|
|
7946
|
-
app.post("/agents/:agentId/:chatId/resume", async (request, reply) => {
|
|
7947
|
-
const { agentId, chatId } = request.params;
|
|
7948
|
-
await assertCanManage(app.db, memberScope(request), agentId);
|
|
7949
|
-
if (!sendToAgent$1(agentId, {
|
|
7950
|
-
type: "session:resume",
|
|
7951
|
-
chatId
|
|
7952
|
-
})) throw new ConflictError("Agent is not connected — session command requires a live connection");
|
|
7953
|
-
return reply.status(202).send({
|
|
7954
|
-
status: "sent",
|
|
7955
|
-
command: "resume",
|
|
8235
|
+
return reply.status(200).send({
|
|
7956
8236
|
agentId,
|
|
7957
|
-
chatId
|
|
8237
|
+
chatId,
|
|
8238
|
+
state: result.state,
|
|
8239
|
+
transitioned: result.transitioned
|
|
7958
8240
|
});
|
|
7959
8241
|
});
|
|
7960
|
-
/** POST /admin/sessions/agents/:agentId/:chatId/terminate —
|
|
8242
|
+
/** POST /admin/sessions/agents/:agentId/:chatId/terminate — archive; clear events + best-effort WS. */
|
|
7961
8243
|
app.post("/agents/:agentId/:chatId/terminate", async (request, reply) => {
|
|
7962
8244
|
const { agentId, chatId } = request.params;
|
|
7963
8245
|
await assertCanManage(app.db, memberScope(request), agentId);
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
8246
|
+
const member = requireMember(request);
|
|
8247
|
+
const result = await archiveSession(app.db, agentId, chatId, member.organizationId, app.notifier);
|
|
8248
|
+
if (result.transitioned) {
|
|
8249
|
+
clearEvents(app.db, agentId, chatId).catch(() => {});
|
|
8250
|
+
sendToAgent$1(agentId, {
|
|
8251
|
+
type: "session:terminate",
|
|
8252
|
+
chatId
|
|
8253
|
+
});
|
|
8254
|
+
}
|
|
8255
|
+
return reply.status(200).send({
|
|
7971
8256
|
agentId,
|
|
7972
|
-
chatId
|
|
8257
|
+
chatId,
|
|
8258
|
+
state: result.state,
|
|
8259
|
+
transitioned: result.transitioned
|
|
7973
8260
|
});
|
|
7974
8261
|
});
|
|
7975
8262
|
}
|
|
@@ -9418,9 +9705,47 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
9418
9705
|
}));
|
|
9419
9706
|
return;
|
|
9420
9707
|
}
|
|
9421
|
-
const
|
|
9422
|
-
if (
|
|
9423
|
-
|
|
9708
|
+
const payloadResult = sessionStateMessageSchema.safeParse(msg);
|
|
9709
|
+
if (!payloadResult.success) {
|
|
9710
|
+
socket.send(JSON.stringify({
|
|
9711
|
+
type: "error",
|
|
9712
|
+
message: "Unsupported session state from client; client upgrade required"
|
|
9713
|
+
}));
|
|
9714
|
+
const rawState = msg.state;
|
|
9715
|
+
app.log.warn({
|
|
9716
|
+
clientId,
|
|
9717
|
+
agentId,
|
|
9718
|
+
rawState
|
|
9719
|
+
}, "session:state rejected — stale client wire");
|
|
9720
|
+
return;
|
|
9721
|
+
}
|
|
9722
|
+
await upsertSessionState(app.db, agentId, payloadResult.data.chatId, payloadResult.data.state, session.organizationId, notifier);
|
|
9723
|
+
} else if (type === "session:reconcile") {
|
|
9724
|
+
const agentId = parsed.data.agentId;
|
|
9725
|
+
if (!agentId || !boundAgents.has(agentId)) {
|
|
9726
|
+
socket.send(JSON.stringify({
|
|
9727
|
+
type: "error",
|
|
9728
|
+
message: "Agent not bound"
|
|
9729
|
+
}));
|
|
9730
|
+
return;
|
|
9731
|
+
}
|
|
9732
|
+
const payloadResult = sessionReconcileRequestSchema.safeParse(msg);
|
|
9733
|
+
if (!payloadResult.success) {
|
|
9734
|
+
socket.send(JSON.stringify({
|
|
9735
|
+
type: "error",
|
|
9736
|
+
message: "Malformed session:reconcile frame"
|
|
9737
|
+
}));
|
|
9738
|
+
return;
|
|
9739
|
+
}
|
|
9740
|
+
const { chatIds } = payloadResult.data;
|
|
9741
|
+
const aliveRows = chatIds.length ? await app.db.select({ chatId: agentChatSessions.chatId }).from(agentChatSessions).where(and(eq(agentChatSessions.agentId, agentId), inArray(agentChatSessions.chatId, chatIds), ne(agentChatSessions.state, "evicted"))) : [];
|
|
9742
|
+
const alive = new Set(aliveRows.map((r) => r.chatId));
|
|
9743
|
+
const staleChatIds = chatIds.filter((id) => !alive.has(id));
|
|
9744
|
+
socket.send(JSON.stringify({
|
|
9745
|
+
type: "session:reconcile:result",
|
|
9746
|
+
agentId,
|
|
9747
|
+
staleChatIds
|
|
9748
|
+
}));
|
|
9424
9749
|
} else if (type === "runtime:state") {
|
|
9425
9750
|
const agentId = parsed.data.agentId;
|
|
9426
9751
|
if (!agentId || !boundAgents.has(agentId)) {
|
|
@@ -9839,14 +10164,18 @@ async function getMember(db, id) {
|
|
|
9839
10164
|
createdAt: row.createdAt.toISOString()
|
|
9840
10165
|
};
|
|
9841
10166
|
}
|
|
9842
|
-
async function updateMember(db, id, data) {
|
|
9843
|
-
if (
|
|
9844
|
-
|
|
9845
|
-
|
|
9846
|
-
|
|
9847
|
-
|
|
9848
|
-
|
|
9849
|
-
|
|
10167
|
+
async function updateMember(db, id, data, callerOrgId) {
|
|
10168
|
+
if (data.role === void 0 && data.displayName === void 0) return getMember(db, id);
|
|
10169
|
+
const current = await getMember(db, id);
|
|
10170
|
+
if (callerOrgId && current.organizationId !== callerOrgId) throw new NotFoundError(`Member "${id}" not found`);
|
|
10171
|
+
if (data.role === "member" && current.role === "admin") await assertNotLastAdmin(db, current.organizationId, id);
|
|
10172
|
+
await db.transaction(async (tx) => {
|
|
10173
|
+
if (data.role !== void 0 && data.role !== current.role) await tx.update(members).set({ role: data.role }).where(eq(members.id, id));
|
|
10174
|
+
if (data.displayName !== void 0 && data.displayName !== current.displayName) {
|
|
10175
|
+
await tx.update(users).set({ displayName: data.displayName }).where(eq(users.id, current.userId));
|
|
10176
|
+
await tx.update(agents).set({ displayName: data.displayName }).where(eq(agents.uuid, current.agentId));
|
|
10177
|
+
}
|
|
10178
|
+
});
|
|
9850
10179
|
return getMember(db, id);
|
|
9851
10180
|
}
|
|
9852
10181
|
async function deleteMember(db, id) {
|
|
@@ -9884,7 +10213,8 @@ async function memberRoutes(app) {
|
|
|
9884
10213
|
app.patch("/:id", async (request) => {
|
|
9885
10214
|
requireAdmin(request);
|
|
9886
10215
|
const body = updateMemberSchema.parse(request.body);
|
|
9887
|
-
|
|
10216
|
+
const m = requireMember(request);
|
|
10217
|
+
return updateMember(app.db, request.params.id, body, m.organizationId);
|
|
9888
10218
|
});
|
|
9889
10219
|
app.delete("/:id", async (request, reply) => {
|
|
9890
10220
|
requireAdmin(request);
|
|
@@ -10965,7 +11295,6 @@ function createBackgroundTasks(app, instanceId, adapterManager, kaelRuntime) {
|
|
|
10965
11295
|
let heartbeatTimer = null;
|
|
10966
11296
|
let adapterOutboundTimer = null;
|
|
10967
11297
|
let kaelOutboundTimer = null;
|
|
10968
|
-
let sessionCleanupTimer = null;
|
|
10969
11298
|
return {
|
|
10970
11299
|
start() {
|
|
10971
11300
|
inboxTimer = setInterval(async () => {
|
|
@@ -11010,14 +11339,6 @@ function createBackgroundTasks(app, instanceId, adapterManager, kaelRuntime) {
|
|
|
11010
11339
|
log.error({ err }, "kael outbound processing failed");
|
|
11011
11340
|
}
|
|
11012
11341
|
}, 5e3);
|
|
11013
|
-
sessionCleanupTimer = setInterval(async () => {
|
|
11014
|
-
try {
|
|
11015
|
-
const deleted = await cleanupStaleSessions(app.db);
|
|
11016
|
-
if (deleted > 0) log.info({ count: deleted }, "cleaned up stale sessions");
|
|
11017
|
-
} catch (err) {
|
|
11018
|
-
log.error({ err }, "failed to clean up stale sessions");
|
|
11019
|
-
}
|
|
11020
|
-
}, 36e5);
|
|
11021
11342
|
heartbeatInstance(app.db, instanceId).catch((err) => {
|
|
11022
11343
|
log.error({ err }, "failed initial heartbeat");
|
|
11023
11344
|
});
|
|
@@ -11039,10 +11360,6 @@ function createBackgroundTasks(app, instanceId, adapterManager, kaelRuntime) {
|
|
|
11039
11360
|
clearInterval(kaelOutboundTimer);
|
|
11040
11361
|
kaelOutboundTimer = null;
|
|
11041
11362
|
}
|
|
11042
|
-
if (sessionCleanupTimer) {
|
|
11043
|
-
clearInterval(sessionCleanupTimer);
|
|
11044
|
-
sessionCleanupTimer = null;
|
|
11045
|
-
}
|
|
11046
11363
|
}
|
|
11047
11364
|
};
|
|
11048
11365
|
}
|
|
@@ -11659,17 +11976,14 @@ async function buildApp(config) {
|
|
|
11659
11976
|
}, { prefix: "/admin/overview" });
|
|
11660
11977
|
await api.register(async (adminApp) => {
|
|
11661
11978
|
adminApp.addHook("onRequest", memberAuth);
|
|
11662
|
-
adminApp.addHook("onRequest", adminOnly);
|
|
11663
11979
|
await adminApp.register(adminAdapterRoutes);
|
|
11664
11980
|
}, { prefix: "/admin/adapters" });
|
|
11665
11981
|
await api.register(async (adminApp) => {
|
|
11666
11982
|
adminApp.addHook("onRequest", memberAuth);
|
|
11667
|
-
adminApp.addHook("onRequest", adminOnly);
|
|
11668
11983
|
await adminApp.register(adminAdapterMappingRoutes);
|
|
11669
11984
|
}, { prefix: "/admin/adapter-mappings" });
|
|
11670
11985
|
await api.register(async (adminApp) => {
|
|
11671
11986
|
adminApp.addHook("onRequest", memberAuth);
|
|
11672
|
-
adminApp.addHook("onRequest", adminOnly);
|
|
11673
11987
|
await adminApp.register(adminAdapterStatusRoutes);
|
|
11674
11988
|
}, { prefix: "/admin/adapters/status" });
|
|
11675
11989
|
await api.register(async (memberApp) => {
|