@agent-team-foundation/first-tree-hub 0.8.3 → 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-CxoH-s16.mjs → core-DoIprl2f.mjs} +589 -180
- package/dist/{feishu-BOISS0DK.mjs → feishu-n9Y2yGTT.mjs} +77 -13
- 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-7iSpxOWW.js +0 -333
- package/dist/web/assets/index-Cze8BC63.css +0 -1
|
@@ -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(),
|
|
@@ -714,7 +720,13 @@ z.object({
|
|
|
714
720
|
organizationId: z.string(),
|
|
715
721
|
agents: z.record(z.string(), z.array(pulseBucketSchema).length(32))
|
|
716
722
|
});
|
|
717
|
-
const sessionEventKind = z.enum([
|
|
723
|
+
const sessionEventKind = z.enum([
|
|
724
|
+
"tool_call",
|
|
725
|
+
"error",
|
|
726
|
+
"assistant_text",
|
|
727
|
+
"thinking",
|
|
728
|
+
"turn_end"
|
|
729
|
+
]);
|
|
718
730
|
const toolCallEventPayload = z.object({
|
|
719
731
|
toolUseId: z.string(),
|
|
720
732
|
name: z.string(),
|
|
@@ -735,20 +747,61 @@ const errorEventPayload = z.object({
|
|
|
735
747
|
]),
|
|
736
748
|
message: z.string().max(2e3)
|
|
737
749
|
});
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
750
|
+
/**
|
|
751
|
+
* A text block emitted by the model within an assistant message. These are
|
|
752
|
+
* transient "in-progress" events used to render the assistant's reply body
|
|
753
|
+
* while a turn is still running. The final turn result is forwarded as a
|
|
754
|
+
* regular chat message (not an event); the frontend hides all assistant_text
|
|
755
|
+
* events for turns that have completed (i.e. once `turn_end` has been emitted).
|
|
756
|
+
*/
|
|
757
|
+
const assistantTextEventPayload = z.object({ text: z.string().max(8e3) });
|
|
758
|
+
/**
|
|
759
|
+
* Marker emitted when the model produces a `thinking` content block.
|
|
760
|
+
* We intentionally do NOT persist the thinking content — only a presence
|
|
761
|
+
* signal so the UI can render a lightweight "Thinking…" status indicator.
|
|
762
|
+
*/
|
|
763
|
+
const thinkingEventPayload = z.object({});
|
|
764
|
+
/**
|
|
765
|
+
* Turn boundary marker. Emitted once per completed query turn, regardless of
|
|
766
|
+
* success/failure, so the frontend can group events into turns and collapse
|
|
767
|
+
* completed turns to show only the final result message.
|
|
768
|
+
*/
|
|
769
|
+
const turnEndEventPayload = z.object({ status: z.enum(["success", "error"]) });
|
|
770
|
+
const sessionEventSchema = z.discriminatedUnion("kind", [
|
|
771
|
+
z.object({
|
|
772
|
+
kind: z.literal("tool_call"),
|
|
773
|
+
payload: toolCallEventPayload
|
|
774
|
+
}),
|
|
775
|
+
z.object({
|
|
776
|
+
kind: z.literal("error"),
|
|
777
|
+
payload: errorEventPayload
|
|
778
|
+
}),
|
|
779
|
+
z.object({
|
|
780
|
+
kind: z.literal("assistant_text"),
|
|
781
|
+
payload: assistantTextEventPayload
|
|
782
|
+
}),
|
|
783
|
+
z.object({
|
|
784
|
+
kind: z.literal("thinking"),
|
|
785
|
+
payload: thinkingEventPayload
|
|
786
|
+
}),
|
|
787
|
+
z.object({
|
|
788
|
+
kind: z.literal("turn_end"),
|
|
789
|
+
payload: turnEndEventPayload
|
|
790
|
+
})
|
|
791
|
+
]);
|
|
745
792
|
z.object({
|
|
746
793
|
id: z.string(),
|
|
747
794
|
agentId: z.string(),
|
|
748
795
|
chatId: z.string(),
|
|
749
796
|
seq: z.number().int().positive(),
|
|
750
797
|
kind: sessionEventKind,
|
|
751
|
-
payload: z.union([
|
|
798
|
+
payload: z.union([
|
|
799
|
+
toolCallEventPayload,
|
|
800
|
+
errorEventPayload,
|
|
801
|
+
assistantTextEventPayload,
|
|
802
|
+
thinkingEventPayload,
|
|
803
|
+
turnEndEventPayload
|
|
804
|
+
]),
|
|
752
805
|
createdAt: z.string()
|
|
753
806
|
});
|
|
754
807
|
z.object({
|
|
@@ -760,6 +813,16 @@ z.object({
|
|
|
760
813
|
agentId: z.string(),
|
|
761
814
|
chatId: z.string()
|
|
762
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
|
+
});
|
|
763
826
|
const orgStatsSchema = z.object({
|
|
764
827
|
organizationId: z.string(),
|
|
765
828
|
agentCount: z.number(),
|
|
@@ -1148,6 +1211,15 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1148
1211
|
chatId
|
|
1149
1212
|
}));
|
|
1150
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
|
+
}
|
|
1151
1223
|
async disconnect() {
|
|
1152
1224
|
this.closing = true;
|
|
1153
1225
|
this.clearTimers();
|
|
@@ -1325,7 +1397,7 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1325
1397
|
}
|
|
1326
1398
|
return;
|
|
1327
1399
|
}
|
|
1328
|
-
if (type === "session:suspend" || type === "session:
|
|
1400
|
+
if (type === "session:suspend" || type === "session:terminate") {
|
|
1329
1401
|
const agentId = msg.agentId;
|
|
1330
1402
|
const chatId = msg.chatId;
|
|
1331
1403
|
if (agentId && chatId) this.emit("session:command", {
|
|
@@ -1335,6 +1407,15 @@ var ClientConnection = class extends EventEmitter {
|
|
|
1335
1407
|
});
|
|
1336
1408
|
return;
|
|
1337
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
|
+
}
|
|
1338
1419
|
if (type === "new_message") {
|
|
1339
1420
|
const inboxId = msg.inboxId;
|
|
1340
1421
|
if (inboxId) this.emit("agent:message", inboxId, msg);
|
|
@@ -2272,6 +2353,7 @@ function cleanWorkspaces(workspaceRoot, activeChatIds, ttlMs = DEFAULT_WORKSPACE
|
|
|
2272
2353
|
}
|
|
2273
2354
|
const MAX_RETRIES = 2;
|
|
2274
2355
|
const TOOL_RESULT_PREVIEW_LIMIT = 400;
|
|
2356
|
+
const ASSISTANT_TEXT_EVENT_LIMIT = 8e3;
|
|
2275
2357
|
function extractContentBlocks(message) {
|
|
2276
2358
|
if (!message || typeof message !== "object") return [];
|
|
2277
2359
|
const inner = message.message;
|
|
@@ -2289,6 +2371,15 @@ function isToolResultBlock(block) {
|
|
|
2289
2371
|
const b = block;
|
|
2290
2372
|
return b.type === "tool_result" && typeof b.tool_use_id === "string";
|
|
2291
2373
|
}
|
|
2374
|
+
function isTextBlock(block) {
|
|
2375
|
+
if (!block || typeof block !== "object") return false;
|
|
2376
|
+
const b = block;
|
|
2377
|
+
return b.type === "text" && typeof b.text === "string";
|
|
2378
|
+
}
|
|
2379
|
+
function isThinkingBlock(block) {
|
|
2380
|
+
if (!block || typeof block !== "object") return false;
|
|
2381
|
+
return block.type === "thinking";
|
|
2382
|
+
}
|
|
2292
2383
|
function isResultMessage(message) {
|
|
2293
2384
|
if (!message || typeof message !== "object") return false;
|
|
2294
2385
|
const m = message;
|
|
@@ -2331,31 +2422,39 @@ function createToolCallProcessor(emit) {
|
|
|
2331
2422
|
onMessage(message) {
|
|
2332
2423
|
if (!message || typeof message !== "object") return;
|
|
2333
2424
|
const type = message.type;
|
|
2334
|
-
if (type === "assistant")
|
|
2335
|
-
if (
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2425
|
+
if (type === "assistant") {
|
|
2426
|
+
for (const block of extractContentBlocks(message)) if (isToolUseBlock(block)) {
|
|
2427
|
+
pending.set(block.id, {
|
|
2428
|
+
toolUseId: block.id,
|
|
2429
|
+
name: block.name,
|
|
2430
|
+
args: block.input,
|
|
2431
|
+
startedAt: Date.now()
|
|
2432
|
+
});
|
|
2433
|
+
emit({
|
|
2434
|
+
kind: "tool_call",
|
|
2435
|
+
payload: {
|
|
2436
|
+
toolUseId: block.id,
|
|
2437
|
+
name: block.name,
|
|
2438
|
+
args: block.input,
|
|
2439
|
+
status: "pending"
|
|
2440
|
+
}
|
|
2441
|
+
});
|
|
2442
|
+
} else if (isTextBlock(block)) {
|
|
2443
|
+
const text = block.text.trim();
|
|
2444
|
+
if (text.length === 0) continue;
|
|
2445
|
+
emit({
|
|
2446
|
+
kind: "assistant_text",
|
|
2447
|
+
payload: { text: text.slice(0, ASSISTANT_TEXT_EVENT_LIMIT) }
|
|
2448
|
+
});
|
|
2449
|
+
} else if (isThinkingBlock(block)) emit({
|
|
2450
|
+
kind: "thinking",
|
|
2451
|
+
payload: {}
|
|
2341
2452
|
});
|
|
2342
|
-
}
|
|
2343
|
-
else if (type === "user") {
|
|
2453
|
+
} else if (type === "user") {
|
|
2344
2454
|
for (const block of extractContentBlocks(message)) if (isToolResultBlock(block)) pairResult(block);
|
|
2345
2455
|
}
|
|
2346
2456
|
},
|
|
2347
2457
|
flush() {
|
|
2348
|
-
if (pending.size === 0) return;
|
|
2349
|
-
for (const entry of pending.values()) emit({
|
|
2350
|
-
kind: "tool_call",
|
|
2351
|
-
payload: {
|
|
2352
|
-
toolUseId: entry.toolUseId,
|
|
2353
|
-
name: entry.name,
|
|
2354
|
-
args: entry.args,
|
|
2355
|
-
status: "pending",
|
|
2356
|
-
durationMs: Date.now() - entry.startedAt
|
|
2357
|
-
}
|
|
2358
|
-
});
|
|
2359
2458
|
pending.clear();
|
|
2360
2459
|
}
|
|
2361
2460
|
};
|
|
@@ -2561,25 +2660,37 @@ const createClaudeCodeHandler = (config) => {
|
|
|
2561
2660
|
retryCount = 0;
|
|
2562
2661
|
if (message.result && sessionCtx.chatId) {
|
|
2563
2662
|
const resultText = message.result;
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2663
|
+
try {
|
|
2664
|
+
await sessionCtx.sdk.sendMessage(sessionCtx.chatId, {
|
|
2665
|
+
format: "text",
|
|
2666
|
+
content: resultText
|
|
2667
|
+
});
|
|
2568
2668
|
sessionCtx.log("Result forwarded to chat");
|
|
2569
2669
|
sessionCtx.reportSessionCompletion();
|
|
2570
|
-
|
|
2670
|
+
sessionCtx.emitEvent({
|
|
2671
|
+
kind: "turn_end",
|
|
2672
|
+
payload: { status: "success" }
|
|
2673
|
+
});
|
|
2674
|
+
} catch (err) {
|
|
2571
2675
|
const reason = err instanceof Error ? err.message : String(err);
|
|
2572
2676
|
sessionCtx.log(`Failed to forward result: ${reason}`);
|
|
2573
|
-
const
|
|
2677
|
+
const forwardErrMessage = `Result forward failed: ${reason}\n---\n${resultText.slice(0, 1500)}`.slice(0, 2e3);
|
|
2574
2678
|
sessionCtx.emitEvent({
|
|
2575
2679
|
kind: "error",
|
|
2576
2680
|
payload: {
|
|
2577
2681
|
source: "runtime",
|
|
2578
|
-
message
|
|
2682
|
+
message: forwardErrMessage
|
|
2579
2683
|
}
|
|
2580
2684
|
});
|
|
2581
|
-
|
|
2582
|
-
|
|
2685
|
+
sessionCtx.emitEvent({
|
|
2686
|
+
kind: "turn_end",
|
|
2687
|
+
payload: { status: "error" }
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
} else sessionCtx.emitEvent({
|
|
2691
|
+
kind: "turn_end",
|
|
2692
|
+
payload: { status: "success" }
|
|
2693
|
+
});
|
|
2583
2694
|
} else {
|
|
2584
2695
|
const errors = message.errors ? message.errors.join("; ") : message.subtype;
|
|
2585
2696
|
const errorLog = `Query result error: ${errors} (subtype=${message.subtype}, turns=${message.num_turns ?? "?"}, duration=${message.duration_ms ?? "?"}ms)`;
|
|
@@ -2591,6 +2702,10 @@ const createClaudeCodeHandler = (config) => {
|
|
|
2591
2702
|
message: errors
|
|
2592
2703
|
}
|
|
2593
2704
|
});
|
|
2705
|
+
sessionCtx.emitEvent({
|
|
2706
|
+
kind: "turn_end",
|
|
2707
|
+
payload: { status: "error" }
|
|
2708
|
+
});
|
|
2594
2709
|
}
|
|
2595
2710
|
sessionCtx.setRuntimeState("idle");
|
|
2596
2711
|
}
|
|
@@ -3053,39 +3168,49 @@ var SessionManager = class {
|
|
|
3053
3168
|
const message = this.extractMessage(entry);
|
|
3054
3169
|
await this.routeMessage(chatId, message, entry.id);
|
|
3055
3170
|
}
|
|
3056
|
-
/** Handle a session command
|
|
3171
|
+
/** Handle a server-issued session command. Terminate drops all local state without reporting back. */
|
|
3057
3172
|
async handleCommand(chatId, command) {
|
|
3058
|
-
const session = this.sessions.get(chatId);
|
|
3059
3173
|
if (command === "session:suspend") {
|
|
3174
|
+
const session = this.sessions.get(chatId);
|
|
3060
3175
|
if (session?.status === "active") {
|
|
3061
3176
|
this.config.log(`Session ${chatId}: suspend command received`);
|
|
3062
3177
|
this.suspendSession(session);
|
|
3063
3178
|
}
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
await session.handler.shutdown();
|
|
3075
|
-
}
|
|
3076
|
-
this.addEvictedMapping(chatId, {
|
|
3077
|
-
claudeSessionId: session.claudeSessionId,
|
|
3078
|
-
lastActivity: session.lastActivity
|
|
3079
|
-
});
|
|
3080
|
-
this.sessions.delete(chatId);
|
|
3081
|
-
this.sessionRuntimeStates.delete(chatId);
|
|
3082
|
-
this.recomputeRuntimeState();
|
|
3083
|
-
this.notifySessionState(chatId, "evicted");
|
|
3084
|
-
this.persistRegistry();
|
|
3085
|
-
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(() => {});
|
|
3086
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();
|
|
3087
3198
|
}
|
|
3088
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
|
+
}
|
|
3089
3214
|
/** Shut down all sessions gracefully. */
|
|
3090
3215
|
async shutdown() {
|
|
3091
3216
|
if (this.idleTimer) {
|
|
@@ -3286,8 +3411,6 @@ var SessionManager = class {
|
|
|
3286
3411
|
this._activeCount--;
|
|
3287
3412
|
candidate.session.handler.shutdown().catch(() => {});
|
|
3288
3413
|
}
|
|
3289
|
-
candidate.session.status = "evicted";
|
|
3290
|
-
this.notifySessionState(candidate.key, "evicted");
|
|
3291
3414
|
this.sessions.delete(candidate.key);
|
|
3292
3415
|
this.sessionRuntimeStates.delete(candidate.key);
|
|
3293
3416
|
this.recomputeRuntimeState();
|
|
@@ -3424,6 +3547,7 @@ var AgentSlot = class {
|
|
|
3424
3547
|
sdk = null;
|
|
3425
3548
|
agentConfigCache = null;
|
|
3426
3549
|
pollingTimer = null;
|
|
3550
|
+
reconcileTimer = null;
|
|
3427
3551
|
listeners = [];
|
|
3428
3552
|
constructor(config) {
|
|
3429
3553
|
this.config = config;
|
|
@@ -3459,16 +3583,26 @@ var AgentSlot = class {
|
|
|
3459
3583
|
if (agentId === this.config.agentId) this.pullAndDispatch();
|
|
3460
3584
|
};
|
|
3461
3585
|
const onBound = (boundAgent) => {
|
|
3462
|
-
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);
|
|
3463
3593
|
};
|
|
3464
3594
|
this.clientConnection.on("agent:message", onMessage);
|
|
3465
3595
|
this.clientConnection.on("agent:bound", onBound);
|
|
3596
|
+
this.clientConnection.on("session:reconcile:result", onReconcileResult);
|
|
3466
3597
|
this.listeners.push({
|
|
3467
3598
|
event: "agent:message",
|
|
3468
3599
|
fn: onMessage
|
|
3469
3600
|
}, {
|
|
3470
3601
|
event: "agent:bound",
|
|
3471
3602
|
fn: onBound
|
|
3603
|
+
}, {
|
|
3604
|
+
event: "session:reconcile:result",
|
|
3605
|
+
fn: onReconcileResult
|
|
3472
3606
|
});
|
|
3473
3607
|
const registryPath = join(DEFAULT_DATA_DIR, "sessions", `${this.config.name}.json`);
|
|
3474
3608
|
const gitMirrorManager = createGitMirrorManager({
|
|
@@ -3511,6 +3645,7 @@ var AgentSlot = class {
|
|
|
3511
3645
|
fn: onCommand
|
|
3512
3646
|
});
|
|
3513
3647
|
this.startPolling();
|
|
3648
|
+
this.startReconcileLoop();
|
|
3514
3649
|
return agent;
|
|
3515
3650
|
}
|
|
3516
3651
|
async stop() {
|
|
@@ -3518,8 +3653,13 @@ var AgentSlot = class {
|
|
|
3518
3653
|
clearInterval(this.pollingTimer);
|
|
3519
3654
|
this.pollingTimer = null;
|
|
3520
3655
|
}
|
|
3656
|
+
if (this.reconcileTimer) {
|
|
3657
|
+
clearInterval(this.reconcileTimer);
|
|
3658
|
+
this.reconcileTimer = null;
|
|
3659
|
+
}
|
|
3521
3660
|
for (const entry of this.listeners) if (entry.event === "agent:message") this.clientConnection.off(entry.event, entry.fn);
|
|
3522
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);
|
|
3523
3663
|
else this.clientConnection.off(entry.event, entry.fn);
|
|
3524
3664
|
this.listeners = [];
|
|
3525
3665
|
await this.clientConnection.unbindAgent(this.config.agentId);
|
|
@@ -3550,6 +3690,16 @@ var AgentSlot = class {
|
|
|
3550
3690
|
}, 5e3);
|
|
3551
3691
|
this.pullAndDispatch();
|
|
3552
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
|
+
}
|
|
3553
3703
|
async pullAndDispatch() {
|
|
3554
3704
|
if (!this.sdk || !this.sessionManager) return;
|
|
3555
3705
|
try {
|
|
@@ -3578,7 +3728,8 @@ z.object({
|
|
|
3578
3728
|
}).passthrough();
|
|
3579
3729
|
const sessionConfigSchema = z.object({
|
|
3580
3730
|
idle_timeout: z.number().int().positive().default(IDLE_TIMEOUT_MS / 1e3),
|
|
3581
|
-
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)
|
|
3582
3733
|
}).passthrough();
|
|
3583
3734
|
const agentSlotConfigSchema = z.object({
|
|
3584
3735
|
agentId: z.string().min(1),
|
|
@@ -3727,7 +3878,8 @@ var ClientRuntime = class {
|
|
|
3727
3878
|
handlerFactory,
|
|
3728
3879
|
session: {
|
|
3729
3880
|
idle_timeout: config.session.idle_timeout,
|
|
3730
|
-
max_sessions: config.session.max_sessions
|
|
3881
|
+
max_sessions: config.session.max_sessions,
|
|
3882
|
+
reconcile_interval_seconds: 300
|
|
3731
3883
|
},
|
|
3732
3884
|
concurrency: config.concurrency,
|
|
3733
3885
|
clientConnection: this.connection
|
|
@@ -4521,7 +4673,7 @@ async function onboardCreate(args) {
|
|
|
4521
4673
|
}
|
|
4522
4674
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
4523
4675
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
4524
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
4676
|
+
const { bindFeishuBot } = await import("./feishu-n9Y2yGTT.mjs").then((n) => n.r);
|
|
4525
4677
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
4526
4678
|
if (!targetAgentUuid) process.stderr.write(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
4527
4679
|
else {
|
|
@@ -4662,7 +4814,7 @@ function setNestedByDot(obj, dotPath, value) {
|
|
|
4662
4814
|
if (lastKey !== void 0) current[lastKey] = value;
|
|
4663
4815
|
}
|
|
4664
4816
|
//#endregion
|
|
4665
|
-
//#region ../server/dist/app-
|
|
4817
|
+
//#region ../server/dist/app-C6y-ySN9.mjs
|
|
4666
4818
|
var __defProp = Object.defineProperty;
|
|
4667
4819
|
var __exportAll = (all, no_symbols) => {
|
|
4668
4820
|
let target = {};
|
|
@@ -5199,6 +5351,8 @@ function parseId$1(raw) {
|
|
|
5199
5351
|
async function adminAdapterMappingRoutes(app) {
|
|
5200
5352
|
app.get("/", async (request) => {
|
|
5201
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));
|
|
5202
5356
|
return (await app.db.select({
|
|
5203
5357
|
id: adapterAgentMappings.id,
|
|
5204
5358
|
platform: adapterAgentMappings.platform,
|
|
@@ -5207,7 +5361,7 @@ async function adminAdapterMappingRoutes(app) {
|
|
|
5207
5361
|
boundVia: adapterAgentMappings.boundVia,
|
|
5208
5362
|
displayName: adapterAgentMappings.displayName,
|
|
5209
5363
|
createdAt: adapterAgentMappings.createdAt
|
|
5210
|
-
}).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) => ({
|
|
5211
5365
|
id: r.id,
|
|
5212
5366
|
platform: r.platform,
|
|
5213
5367
|
externalUserId: r.externalUserId,
|
|
@@ -5255,11 +5409,6 @@ async function adminAdapterMappingRoutes(app) {
|
|
|
5255
5409
|
return reply.status(204).send();
|
|
5256
5410
|
});
|
|
5257
5411
|
}
|
|
5258
|
-
async function adminAdapterStatusRoutes(app) {
|
|
5259
|
-
app.get("/", async () => {
|
|
5260
|
-
return app.adapterManager.getBotStatuses();
|
|
5261
|
-
});
|
|
5262
|
-
}
|
|
5263
5412
|
/** Bot credentials for external platform adapters. Credentials are encrypted at application layer (AES-256-GCM). */
|
|
5264
5413
|
const adapterConfigs = pgTable("adapter_configs", {
|
|
5265
5414
|
id: serial("id").primaryKey(),
|
|
@@ -5270,6 +5419,17 @@ const adapterConfigs = pgTable("adapter_configs", {
|
|
|
5270
5419
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
5271
5420
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
5272
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
|
+
}
|
|
5273
5433
|
const ALGORITHM = "aes-256-gcm";
|
|
5274
5434
|
const IV_LENGTH = 12;
|
|
5275
5435
|
const AUTH_TAG_LENGTH = 16;
|
|
@@ -5383,6 +5543,30 @@ function toResponse(row) {
|
|
|
5383
5543
|
async function listAdapterConfigs(db) {
|
|
5384
5544
|
return (await db.select().from(adapterConfigs).orderBy(desc(adapterConfigs.createdAt))).map(toResponse);
|
|
5385
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
|
+
}
|
|
5386
5570
|
async function getAdapterConfig(db, id) {
|
|
5387
5571
|
const [row] = await db.select().from(adapterConfigs).where(eq(adapterConfigs.id, id)).limit(1);
|
|
5388
5572
|
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
@@ -5432,8 +5616,9 @@ function parseId(raw) {
|
|
|
5432
5616
|
return id;
|
|
5433
5617
|
}
|
|
5434
5618
|
async function adminAdapterRoutes(app) {
|
|
5435
|
-
app.get("/", async () => {
|
|
5436
|
-
|
|
5619
|
+
app.get("/", async (request) => {
|
|
5620
|
+
const scope = memberScope(request);
|
|
5621
|
+
return (await listAdapterConfigsForMember(app.db, scope)).map((c) => ({
|
|
5437
5622
|
...c,
|
|
5438
5623
|
createdAt: c.createdAt.toISOString(),
|
|
5439
5624
|
updatedAt: c.updatedAt.toISOString()
|
|
@@ -5695,6 +5880,47 @@ async function getAgent(db, uuid) {
|
|
|
5695
5880
|
return agent;
|
|
5696
5881
|
}
|
|
5697
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
|
+
/**
|
|
5698
5924
|
* List agents visible to a specific member.
|
|
5699
5925
|
* Uses agentVisibilityCondition from access-control (same rules for all roles).
|
|
5700
5926
|
*/
|
|
@@ -6190,17 +6416,31 @@ async function cleanupStalePresence(db, staleSeconds = 60) {
|
|
|
6190
6416
|
`)).length;
|
|
6191
6417
|
}
|
|
6192
6418
|
/**
|
|
6193
|
-
* Assert the caller
|
|
6419
|
+
* Assert the caller can act on this client. Throws 404 for both "not found"
|
|
6194
6420
|
* and "not yours" to prevent UUID enumeration across org/user boundaries.
|
|
6195
|
-
*
|
|
6196
|
-
*
|
|
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.
|
|
6197
6431
|
*/
|
|
6198
|
-
async function assertClientOwner(db, clientId,
|
|
6432
|
+
async function assertClientOwner(db, clientId, scope) {
|
|
6199
6433
|
const [row] = await db.select({
|
|
6200
6434
|
id: clients.id,
|
|
6201
6435
|
userId: clients.userId
|
|
6202
6436
|
}).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
6203
|
-
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`);
|
|
6204
6444
|
}
|
|
6205
6445
|
/**
|
|
6206
6446
|
* Upsert the clients row for a given `client_id` under an authenticated user.
|
|
@@ -6280,8 +6520,32 @@ async function listActiveAgentsPinnedToClient(db, clientId) {
|
|
|
6280
6520
|
type: agents.type
|
|
6281
6521
|
}).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
|
|
6282
6522
|
}
|
|
6283
|
-
|
|
6284
|
-
|
|
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));
|
|
6285
6549
|
const counts = await db.select({
|
|
6286
6550
|
clientId: agents.clientId,
|
|
6287
6551
|
count: sql`count(*)::int`
|
|
@@ -6746,6 +7010,33 @@ async function adminAgentRoutes(app) {
|
|
|
6746
7010
|
nextCursor: result.nextCursor
|
|
6747
7011
|
};
|
|
6748
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
|
+
});
|
|
6749
7040
|
app.post("/", async (request, reply) => {
|
|
6750
7041
|
const scope = memberScope(request);
|
|
6751
7042
|
const body = createAgentSchema.parse(request.body);
|
|
@@ -7032,6 +7323,24 @@ async function adminChatRoutes(app) {
|
|
|
7032
7323
|
}))
|
|
7033
7324
|
};
|
|
7034
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
|
+
});
|
|
7035
7344
|
/** List messages in a chat with delivery status (requires participation or supervision) */
|
|
7036
7345
|
app.get("/:chatId/messages", async (request) => {
|
|
7037
7346
|
const { chatId } = request.params;
|
|
@@ -7151,10 +7460,18 @@ const agentChatSessions = pgTable("agent_chat_sessions", {
|
|
|
7151
7460
|
state: text("state").notNull(),
|
|
7152
7461
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
7153
7462
|
}, (table) => [primaryKey({ columns: [table.agentId, table.chatId] })]);
|
|
7154
|
-
/**
|
|
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
|
+
*/
|
|
7155
7469
|
async function upsertSessionState(db, agentId, chatId, state, organizationId, notifier) {
|
|
7156
7470
|
const now = /* @__PURE__ */ new Date();
|
|
7471
|
+
let wrote = false;
|
|
7157
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;
|
|
7158
7475
|
await tx.insert(agentChatSessions).values({
|
|
7159
7476
|
agentId,
|
|
7160
7477
|
chatId,
|
|
@@ -7178,8 +7495,9 @@ async function upsertSessionState(db, agentId, chatId, state, organizationId, no
|
|
|
7178
7495
|
totalSessions,
|
|
7179
7496
|
lastSeenAt: now
|
|
7180
7497
|
}).where(eq(agentPresence.agentId, agentId));
|
|
7498
|
+
wrote = true;
|
|
7181
7499
|
});
|
|
7182
|
-
if (notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
|
|
7500
|
+
if (wrote && notifier) notifier.notifySessionStateChange(agentId, chatId, state, organizationId).catch(() => {});
|
|
7183
7501
|
}
|
|
7184
7502
|
async function resetActivity(db, agentId) {
|
|
7185
7503
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -7232,22 +7550,6 @@ async function listAgentsWithRuntime(db, scope) {
|
|
|
7232
7550
|
type: agents.type
|
|
7233
7551
|
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
|
|
7234
7552
|
}
|
|
7235
|
-
/**
|
|
7236
|
-
* Clean up stale session rows from agent_chat_sessions.
|
|
7237
|
-
* Removes evicted rows older than staleSeconds and suspended rows older than staleSeconds.
|
|
7238
|
-
* Returns the number of rows deleted.
|
|
7239
|
-
*/
|
|
7240
|
-
async function cleanupStaleSessions(db, staleSeconds = 604800) {
|
|
7241
|
-
return (await db.execute(sql`
|
|
7242
|
-
WITH deleted AS (
|
|
7243
|
-
DELETE FROM agent_chat_sessions
|
|
7244
|
-
WHERE state IN ('evicted', 'suspended')
|
|
7245
|
-
AND updated_at < NOW() - make_interval(secs => ${staleSeconds})
|
|
7246
|
-
RETURNING 1
|
|
7247
|
-
)
|
|
7248
|
-
SELECT count(*)::int AS cnt FROM deleted
|
|
7249
|
-
`))[0]?.cnt ?? 0;
|
|
7250
|
-
}
|
|
7251
7553
|
/** Serialize a Date to ISO string, or null. */
|
|
7252
7554
|
function serializeDate(d) {
|
|
7253
7555
|
return d ? d.toISOString() : null;
|
|
@@ -7255,7 +7557,11 @@ function serializeDate(d) {
|
|
|
7255
7557
|
async function adminClientRoutes(app) {
|
|
7256
7558
|
app.get("/", async (request) => {
|
|
7257
7559
|
const scope = memberScope(request);
|
|
7258
|
-
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) => ({
|
|
7259
7565
|
id: c.id,
|
|
7260
7566
|
userId: c.userId,
|
|
7261
7567
|
status: c.status,
|
|
@@ -7269,7 +7575,7 @@ async function adminClientRoutes(app) {
|
|
|
7269
7575
|
});
|
|
7270
7576
|
app.get("/:clientId", async (request) => {
|
|
7271
7577
|
const scope = memberScope(request);
|
|
7272
|
-
await assertClientOwner(app.db, request.params.clientId, scope
|
|
7578
|
+
await assertClientOwner(app.db, request.params.clientId, scope);
|
|
7273
7579
|
const client = await getClient(app.db, request.params.clientId);
|
|
7274
7580
|
if (!client) throw new Error("unreachable: client missing after owner check");
|
|
7275
7581
|
return {
|
|
@@ -7286,7 +7592,7 @@ async function adminClientRoutes(app) {
|
|
|
7286
7592
|
app.post("/:clientId/disconnect", async (request) => {
|
|
7287
7593
|
const scope = memberScope(request);
|
|
7288
7594
|
const { clientId } = request.params;
|
|
7289
|
-
await assertClientOwner(app.db, clientId, scope
|
|
7595
|
+
await assertClientOwner(app.db, clientId, scope);
|
|
7290
7596
|
const agentIds = forceDisconnectClient(clientId);
|
|
7291
7597
|
await disconnectClient(app.db, clientId);
|
|
7292
7598
|
return {
|
|
@@ -7297,7 +7603,7 @@ async function adminClientRoutes(app) {
|
|
|
7297
7603
|
app.delete("/:clientId", async (request, reply) => {
|
|
7298
7604
|
const scope = memberScope(request);
|
|
7299
7605
|
const { clientId } = request.params;
|
|
7300
|
-
await assertClientOwner(app.db, clientId, scope
|
|
7606
|
+
await assertClientOwner(app.db, clientId, scope);
|
|
7301
7607
|
await retireClient(app.db, clientId);
|
|
7302
7608
|
forceDisconnectClient(clientId);
|
|
7303
7609
|
await disconnectClient(app.db, clientId);
|
|
@@ -7577,16 +7883,26 @@ async function adminOverviewRoutes(app) {
|
|
|
7577
7883
|
};
|
|
7578
7884
|
});
|
|
7579
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
|
+
}
|
|
7580
7894
|
/** List sessions for a specific agent, with optional state filters. */
|
|
7581
7895
|
async function listAgentSessions(db, agentId, filters) {
|
|
7582
7896
|
const conditions = [eq(agentChatSessions.agentId, agentId)];
|
|
7583
7897
|
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
7898
|
+
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
7584
7899
|
const rows = await db.select({
|
|
7585
7900
|
agentId: agentChatSessions.agentId,
|
|
7586
7901
|
chatId: agentChatSessions.chatId,
|
|
7587
7902
|
state: agentChatSessions.state,
|
|
7588
7903
|
updatedAt: agentChatSessions.updatedAt,
|
|
7589
|
-
chatCreatedAt: chats.createdAt
|
|
7904
|
+
chatCreatedAt: chats.createdAt,
|
|
7905
|
+
chatTopic: chats.topic
|
|
7590
7906
|
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(...conditions)).orderBy(desc(agentChatSessions.updatedAt));
|
|
7591
7907
|
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
7592
7908
|
const agentRuntimeState = presence?.runtimeState ?? null;
|
|
@@ -7597,6 +7913,15 @@ async function listAgentSessions(db, agentId, filters) {
|
|
|
7597
7913
|
count: sql`count(*)::int`
|
|
7598
7914
|
}).from(inboxEntries).where(and(eq(inboxEntries.inboxId, sql`(SELECT inbox_id FROM agents WHERE uuid = ${agentId})`), inArray(inboxEntries.chatId, chatIds))).groupBy(inboxEntries.chatId) : [];
|
|
7599
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
|
+
}
|
|
7600
7925
|
return rows.map((r) => ({
|
|
7601
7926
|
agentId: r.agentId,
|
|
7602
7927
|
chatId: r.chatId,
|
|
@@ -7604,7 +7929,9 @@ async function listAgentSessions(db, agentId, filters) {
|
|
|
7604
7929
|
runtimeState: agentRuntimeState,
|
|
7605
7930
|
startedAt: r.chatCreatedAt.toISOString(),
|
|
7606
7931
|
lastActivityAt: r.updatedAt.toISOString(),
|
|
7607
|
-
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
|
|
7608
7935
|
}));
|
|
7609
7936
|
}
|
|
7610
7937
|
/** Get a single session's detail. */
|
|
@@ -7614,11 +7941,14 @@ async function getSession(db, agentId, chatId) {
|
|
|
7614
7941
|
chatId: agentChatSessions.chatId,
|
|
7615
7942
|
state: agentChatSessions.state,
|
|
7616
7943
|
updatedAt: agentChatSessions.updatedAt,
|
|
7617
|
-
chatCreatedAt: chats.createdAt
|
|
7944
|
+
chatCreatedAt: chats.createdAt,
|
|
7945
|
+
chatTopic: chats.topic
|
|
7618
7946
|
}).from(agentChatSessions).innerJoin(chats, eq(agentChatSessions.chatId, chats.id)).where(and(eq(agentChatSessions.agentId, agentId), eq(agentChatSessions.chatId, chatId))).limit(1);
|
|
7619
7947
|
if (!row) throw new NotFoundError(`Session (${agentId}, ${chatId}) not found`);
|
|
7620
7948
|
const [presence] = await db.select({ runtimeState: agentPresence.runtimeState }).from(agentPresence).where(eq(agentPresence.agentId, agentId)).limit(1);
|
|
7621
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;
|
|
7622
7952
|
return {
|
|
7623
7953
|
agentId: row.agentId,
|
|
7624
7954
|
chatId: row.chatId,
|
|
@@ -7626,13 +7956,16 @@ async function getSession(db, agentId, chatId) {
|
|
|
7626
7956
|
runtimeState: presence?.runtimeState ?? null,
|
|
7627
7957
|
startedAt: row.chatCreatedAt.toISOString(),
|
|
7628
7958
|
lastActivityAt: row.updatedAt.toISOString(),
|
|
7629
|
-
messageCount: countRow?.count ?? 0
|
|
7959
|
+
messageCount: countRow?.count ?? 0,
|
|
7960
|
+
summary,
|
|
7961
|
+
topic: row.chatTopic ?? null
|
|
7630
7962
|
};
|
|
7631
7963
|
}
|
|
7632
7964
|
/** List all sessions across all agents, with pagination. Scoped to organization. */
|
|
7633
7965
|
async function listAllSessions(db, limit, cursor, filters) {
|
|
7634
7966
|
const conditions = [];
|
|
7635
7967
|
if (filters?.state) conditions.push(eq(agentChatSessions.state, filters.state));
|
|
7968
|
+
else conditions.push(ne(agentChatSessions.state, "evicted"));
|
|
7636
7969
|
if (filters?.agentId) conditions.push(eq(agentChatSessions.agentId, filters.agentId));
|
|
7637
7970
|
if (filters?.organizationId) conditions.push(eq(agents.organizationId, filters.organizationId));
|
|
7638
7971
|
if (cursor) conditions.push(sql`${agentChatSessions.updatedAt} < ${new Date(cursor)}`);
|
|
@@ -7661,11 +7994,54 @@ async function listAllSessions(db, limit, cursor, filters) {
|
|
|
7661
7994
|
runtimeState: runtimeMap.get(r.agentId) ?? null,
|
|
7662
7995
|
startedAt: r.chatCreatedAt.toISOString(),
|
|
7663
7996
|
lastActivityAt: r.updatedAt.toISOString(),
|
|
7664
|
-
messageCount: 0
|
|
7997
|
+
messageCount: 0,
|
|
7998
|
+
summary: null,
|
|
7999
|
+
topic: null
|
|
7665
8000
|
})),
|
|
7666
8001
|
nextCursor
|
|
7667
8002
|
};
|
|
7668
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
|
+
}
|
|
7669
8045
|
/**
|
|
7670
8046
|
* Filter sessions to only those where the given agent is also a participant in the chat.
|
|
7671
8047
|
* Used when a non-manager views sessions of an org-visible agent — they should only see
|
|
@@ -7742,13 +8118,20 @@ async function appendEvent(db, agentId, chatId, event) {
|
|
|
7742
8118
|
throw new Error(`session_events seq contention on ${agentId}/${chatId}`);
|
|
7743
8119
|
}
|
|
7744
8120
|
/**
|
|
7745
|
-
* List events for a session
|
|
7746
|
-
*
|
|
8121
|
+
* List events for a session with cursor pagination.
|
|
8122
|
+
*
|
|
8123
|
+
* - `direction: "asc"` (default) walks oldest → newest; cursor is the last
|
|
8124
|
+
* seq seen on the previous page (next page starts at seq > cursor).
|
|
8125
|
+
* - `direction: "desc"` walks newest → oldest; cursor is the last seq seen
|
|
8126
|
+
* on the previous page (next page starts at seq < cursor). The chat UI
|
|
8127
|
+
* uses desc so its turn-grouping filter always sees the most recent
|
|
8128
|
+
* `turn_end` even when the chat has thousands of events.
|
|
7747
8129
|
*/
|
|
7748
8130
|
async function listEvents(db, agentId, chatId, options) {
|
|
7749
8131
|
const limit = Math.min(Math.max(options?.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
|
|
8132
|
+
const direction = options?.direction ?? "asc";
|
|
7750
8133
|
const conditions = [eq(sessionEvents.agentId, agentId), eq(sessionEvents.chatId, chatId)];
|
|
7751
|
-
if (options?.cursor !== void 0) conditions.push(gt(sessionEvents.seq, options.cursor));
|
|
8134
|
+
if (options?.cursor !== void 0) conditions.push(direction === "desc" ? lt(sessionEvents.seq, options.cursor) : gt(sessionEvents.seq, options.cursor));
|
|
7752
8135
|
const rows = await db.select({
|
|
7753
8136
|
id: sessionEvents.id,
|
|
7754
8137
|
agentId: sessionEvents.agentId,
|
|
@@ -7757,7 +8140,7 @@ async function listEvents(db, agentId, chatId, options) {
|
|
|
7757
8140
|
kind: sessionEvents.kind,
|
|
7758
8141
|
payload: sessionEvents.payload,
|
|
7759
8142
|
createdAt: sessionEvents.createdAt
|
|
7760
|
-
}).from(sessionEvents).where(and(...conditions)).orderBy(asc(sessionEvents.seq)).limit(limit + 1);
|
|
8143
|
+
}).from(sessionEvents).where(and(...conditions)).orderBy(direction === "desc" ? desc(sessionEvents.seq) : asc(sessionEvents.seq)).limit(limit + 1);
|
|
7761
8144
|
const hasMore = rows.length > limit;
|
|
7762
8145
|
const items = (hasMore ? rows.slice(0, limit) : rows).map(rowToEvent);
|
|
7763
8146
|
const last = items[items.length - 1];
|
|
@@ -7820,61 +8203,60 @@ async function adminSessionRoutes(app) {
|
|
|
7820
8203
|
await assertChatAccess(app.db, scope, request.params.chatId);
|
|
7821
8204
|
return getSession(app.db, request.params.agentId, request.params.chatId);
|
|
7822
8205
|
});
|
|
7823
|
-
/**
|
|
8206
|
+
/**
|
|
8207
|
+
* GET /admin/sessions/agents/:agentId/:chatId/events — session event stream,
|
|
8208
|
+
* paged by `seq`. `direction=desc` returns newest-first; the chat UI uses
|
|
8209
|
+
* this so its turn-grouping filter always sees the latest `turn_end`
|
|
8210
|
+
* regardless of total event count.
|
|
8211
|
+
*/
|
|
7824
8212
|
app.get("/agents/:agentId/:chatId/events", async (request) => {
|
|
7825
8213
|
const scope = memberScope(request);
|
|
7826
8214
|
await assertAgentVisible(app.db, scope, request.params.agentId);
|
|
7827
8215
|
await assertChatAccess(app.db, scope, request.params.chatId);
|
|
7828
8216
|
const limit = request.query.limit !== void 0 ? Number.parseInt(request.query.limit, 10) : void 0;
|
|
7829
8217
|
const cursor = request.query.cursor !== void 0 ? Number.parseInt(request.query.cursor, 10) : void 0;
|
|
8218
|
+
const direction = request.query.direction === "desc" ? "desc" : "asc";
|
|
7830
8219
|
return listEvents(app.db, request.params.agentId, request.params.chatId, {
|
|
7831
8220
|
limit: Number.isFinite(limit) ? limit : void 0,
|
|
7832
|
-
cursor: Number.isFinite(cursor) ? cursor : void 0
|
|
8221
|
+
cursor: Number.isFinite(cursor) ? cursor : void 0,
|
|
8222
|
+
direction
|
|
7833
8223
|
});
|
|
7834
8224
|
});
|
|
7835
|
-
/** POST /admin/sessions/agents/:agentId/:chatId/suspend —
|
|
8225
|
+
/** POST /admin/sessions/agents/:agentId/:chatId/suspend — commit first, WS-send best-effort. */
|
|
7836
8226
|
app.post("/agents/:agentId/:chatId/suspend", async (request, reply) => {
|
|
7837
8227
|
const { agentId, chatId } = request.params;
|
|
7838
8228
|
await assertCanManage(app.db, memberScope(request), agentId);
|
|
7839
|
-
|
|
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, {
|
|
7840
8232
|
type: "session:suspend",
|
|
7841
8233
|
chatId
|
|
7842
|
-
})) throw new ConflictError("Agent is not connected — session command requires a live connection");
|
|
7843
|
-
return reply.status(202).send({
|
|
7844
|
-
status: "sent",
|
|
7845
|
-
command: "suspend",
|
|
7846
|
-
agentId,
|
|
7847
|
-
chatId
|
|
7848
8234
|
});
|
|
7849
|
-
|
|
7850
|
-
/** POST /admin/sessions/agents/:agentId/:chatId/resume — resume a session */
|
|
7851
|
-
app.post("/agents/:agentId/:chatId/resume", async (request, reply) => {
|
|
7852
|
-
const { agentId, chatId } = request.params;
|
|
7853
|
-
await assertCanManage(app.db, memberScope(request), agentId);
|
|
7854
|
-
if (!sendToAgent$1(agentId, {
|
|
7855
|
-
type: "session:resume",
|
|
7856
|
-
chatId
|
|
7857
|
-
})) throw new ConflictError("Agent is not connected — session command requires a live connection");
|
|
7858
|
-
return reply.status(202).send({
|
|
7859
|
-
status: "sent",
|
|
7860
|
-
command: "resume",
|
|
8235
|
+
return reply.status(200).send({
|
|
7861
8236
|
agentId,
|
|
7862
|
-
chatId
|
|
8237
|
+
chatId,
|
|
8238
|
+
state: result.state,
|
|
8239
|
+
transitioned: result.transitioned
|
|
7863
8240
|
});
|
|
7864
8241
|
});
|
|
7865
|
-
/** POST /admin/sessions/agents/:agentId/:chatId/terminate —
|
|
8242
|
+
/** POST /admin/sessions/agents/:agentId/:chatId/terminate — archive; clear events + best-effort WS. */
|
|
7866
8243
|
app.post("/agents/:agentId/:chatId/terminate", async (request, reply) => {
|
|
7867
8244
|
const { agentId, chatId } = request.params;
|
|
7868
8245
|
await assertCanManage(app.db, memberScope(request), agentId);
|
|
7869
|
-
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
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({
|
|
7876
8256
|
agentId,
|
|
7877
|
-
chatId
|
|
8257
|
+
chatId,
|
|
8258
|
+
state: result.state,
|
|
8259
|
+
transitioned: result.transitioned
|
|
7878
8260
|
});
|
|
7879
8261
|
});
|
|
7880
8262
|
}
|
|
@@ -9323,9 +9705,47 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
9323
9705
|
}));
|
|
9324
9706
|
return;
|
|
9325
9707
|
}
|
|
9326
|
-
const
|
|
9327
|
-
if (
|
|
9328
|
-
|
|
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
|
+
}));
|
|
9329
9749
|
} else if (type === "runtime:state") {
|
|
9330
9750
|
const agentId = parsed.data.agentId;
|
|
9331
9751
|
if (!agentId || !boundAgents.has(agentId)) {
|
|
@@ -9744,14 +10164,18 @@ async function getMember(db, id) {
|
|
|
9744
10164
|
createdAt: row.createdAt.toISOString()
|
|
9745
10165
|
};
|
|
9746
10166
|
}
|
|
9747
|
-
async function updateMember(db, id, data) {
|
|
9748
|
-
if (
|
|
9749
|
-
|
|
9750
|
-
|
|
9751
|
-
|
|
9752
|
-
|
|
9753
|
-
|
|
9754
|
-
|
|
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
|
+
});
|
|
9755
10179
|
return getMember(db, id);
|
|
9756
10180
|
}
|
|
9757
10181
|
async function deleteMember(db, id) {
|
|
@@ -9789,7 +10213,8 @@ async function memberRoutes(app) {
|
|
|
9789
10213
|
app.patch("/:id", async (request) => {
|
|
9790
10214
|
requireAdmin(request);
|
|
9791
10215
|
const body = updateMemberSchema.parse(request.body);
|
|
9792
|
-
|
|
10216
|
+
const m = requireMember(request);
|
|
10217
|
+
return updateMember(app.db, request.params.id, body, m.organizationId);
|
|
9793
10218
|
});
|
|
9794
10219
|
app.delete("/:id", async (request, reply) => {
|
|
9795
10220
|
requireAdmin(request);
|
|
@@ -10870,7 +11295,6 @@ function createBackgroundTasks(app, instanceId, adapterManager, kaelRuntime) {
|
|
|
10870
11295
|
let heartbeatTimer = null;
|
|
10871
11296
|
let adapterOutboundTimer = null;
|
|
10872
11297
|
let kaelOutboundTimer = null;
|
|
10873
|
-
let sessionCleanupTimer = null;
|
|
10874
11298
|
return {
|
|
10875
11299
|
start() {
|
|
10876
11300
|
inboxTimer = setInterval(async () => {
|
|
@@ -10915,14 +11339,6 @@ function createBackgroundTasks(app, instanceId, adapterManager, kaelRuntime) {
|
|
|
10915
11339
|
log.error({ err }, "kael outbound processing failed");
|
|
10916
11340
|
}
|
|
10917
11341
|
}, 5e3);
|
|
10918
|
-
sessionCleanupTimer = setInterval(async () => {
|
|
10919
|
-
try {
|
|
10920
|
-
const deleted = await cleanupStaleSessions(app.db);
|
|
10921
|
-
if (deleted > 0) log.info({ count: deleted }, "cleaned up stale sessions");
|
|
10922
|
-
} catch (err) {
|
|
10923
|
-
log.error({ err }, "failed to clean up stale sessions");
|
|
10924
|
-
}
|
|
10925
|
-
}, 36e5);
|
|
10926
11342
|
heartbeatInstance(app.db, instanceId).catch((err) => {
|
|
10927
11343
|
log.error({ err }, "failed initial heartbeat");
|
|
10928
11344
|
});
|
|
@@ -10944,10 +11360,6 @@ function createBackgroundTasks(app, instanceId, adapterManager, kaelRuntime) {
|
|
|
10944
11360
|
clearInterval(kaelOutboundTimer);
|
|
10945
11361
|
kaelOutboundTimer = null;
|
|
10946
11362
|
}
|
|
10947
|
-
if (sessionCleanupTimer) {
|
|
10948
|
-
clearInterval(sessionCleanupTimer);
|
|
10949
|
-
sessionCleanupTimer = null;
|
|
10950
|
-
}
|
|
10951
11363
|
}
|
|
10952
11364
|
};
|
|
10953
11365
|
}
|
|
@@ -11564,17 +11976,14 @@ async function buildApp(config) {
|
|
|
11564
11976
|
}, { prefix: "/admin/overview" });
|
|
11565
11977
|
await api.register(async (adminApp) => {
|
|
11566
11978
|
adminApp.addHook("onRequest", memberAuth);
|
|
11567
|
-
adminApp.addHook("onRequest", adminOnly);
|
|
11568
11979
|
await adminApp.register(adminAdapterRoutes);
|
|
11569
11980
|
}, { prefix: "/admin/adapters" });
|
|
11570
11981
|
await api.register(async (adminApp) => {
|
|
11571
11982
|
adminApp.addHook("onRequest", memberAuth);
|
|
11572
|
-
adminApp.addHook("onRequest", adminOnly);
|
|
11573
11983
|
await adminApp.register(adminAdapterMappingRoutes);
|
|
11574
11984
|
}, { prefix: "/admin/adapter-mappings" });
|
|
11575
11985
|
await api.register(async (adminApp) => {
|
|
11576
11986
|
adminApp.addHook("onRequest", memberAuth);
|
|
11577
|
-
adminApp.addHook("onRequest", adminOnly);
|
|
11578
11987
|
await adminApp.register(adminAdapterStatusRoutes);
|
|
11579
11988
|
}, { prefix: "/admin/adapters/status" });
|
|
11580
11989
|
await api.register(async (memberApp) => {
|