@yeaft/webchat-agent 1.0.446 → 1.0.448
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/local-runtime/server/context.js +3 -22
- package/local-runtime/server/database.js +1 -0
- package/local-runtime/server/db/agent-inventory-db.js +122 -0
- package/local-runtime/server/db/connection.js +59 -0
- package/local-runtime/server/handlers/agent-sync.js +12 -1
- package/local-runtime/server/handlers/client-conversation.js +1 -0
- package/local-runtime/server/routes/admin-routes.js +78 -15
- package/local-runtime/server/ws-agent.js +41 -3
- package/local-runtime/server/ws-client.js +1 -7
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +208 -233
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/archive/turn-archive.js +1 -1
- package/yeaft/cli.js +15 -17
- package/yeaft/config.js +2 -3
- package/yeaft/conversation/internal-control.js +2 -0
- package/yeaft/conversation/persist.js +22 -216
- package/yeaft/conversation/visible-entry.js +1 -1
- package/yeaft/effort.js +0 -2
- package/yeaft/engine.js +31 -376
- package/yeaft/history-window.js +570 -0
- package/yeaft/llm/adapter.js +1 -1
- package/yeaft/llm/anthropic.js +1 -1
- package/yeaft/llm/models-dev.js +2 -1
- package/yeaft/llm/openai-responses.js +1 -1
- package/yeaft/llm/router.js +2 -3
- package/yeaft/llm/usage-accounting.js +2 -2
- package/yeaft/pair-sanitize.js +3 -3
- package/yeaft/prompts.js +3 -4
- package/yeaft/session.js +0 -51
- package/yeaft/stdio-protocol.js +0 -1
- package/yeaft/stop-hooks.js +5 -6
- package/yeaft/turn-utils.js +5 -5
- package/yeaft/web-bridge.js +57 -135
- package/yeaft/compact/compactor.js +0 -283
- package/yeaft/compact/orchestrator.js +0 -141
- package/yeaft/compact/partition.js +0 -94
- package/yeaft/compact/triggers.js +0 -54
- package/yeaft/compact/turn-group.js +0 -85
- package/yeaft/history-compact.js +0 -764
|
@@ -122,14 +122,6 @@ export function clearYeaftDebugRequestsForClient(clientId) {
|
|
|
122
122
|
// only; heartbeat/control frames are deliberately excluded.
|
|
123
123
|
export const userStatsDeltas = new Map();
|
|
124
124
|
|
|
125
|
-
const HEARTBEAT_MESSAGE_TYPES = new Set([
|
|
126
|
-
'ping',
|
|
127
|
-
'pong',
|
|
128
|
-
'ping_session',
|
|
129
|
-
'pong_session',
|
|
130
|
-
'client_hello'
|
|
131
|
-
]);
|
|
132
|
-
|
|
133
125
|
const OUTBOUND_MESSAGE_TRAFFIC_TYPES = new Set([
|
|
134
126
|
'claude_output',
|
|
135
127
|
'yeaft_output',
|
|
@@ -158,24 +150,10 @@ function getOrCreateDelta(userId) {
|
|
|
158
150
|
return delta;
|
|
159
151
|
}
|
|
160
152
|
|
|
161
|
-
export function isHeartbeatMessageType(type) {
|
|
162
|
-
return HEARTBEAT_MESSAGE_TYPES.has(type);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
153
|
export function isOutboundMessageTraffic(type) {
|
|
166
154
|
return OUTBOUND_MESSAGE_TRAFFIC_TYPES.has(type);
|
|
167
155
|
}
|
|
168
156
|
|
|
169
|
-
/**
|
|
170
|
-
* Record a non-heartbeat WS request received from a user. This is kept for
|
|
171
|
-
* operator diagnostics; dashboard traffic comes from user-turn/message bytes.
|
|
172
|
-
*/
|
|
173
|
-
export function trackRequest(userId, bytesReceived, messageType = '') {
|
|
174
|
-
if (!userId || isHeartbeatMessageType(messageType)) return;
|
|
175
|
-
const delta = getOrCreateDelta(userId);
|
|
176
|
-
delta.requests++;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
157
|
/**
|
|
180
158
|
* Record outbound message bytes sent to a user via WS.
|
|
181
159
|
*/
|
|
@@ -198,7 +176,10 @@ export function trackBytesSent(userId, bytesSent, messageType = '') {
|
|
|
198
176
|
export function trackUserTurn(userId, bytesReceived = 0) {
|
|
199
177
|
if (!userId) return;
|
|
200
178
|
const delta = getOrCreateDelta(userId);
|
|
179
|
+
// Both counters are recorded at the validated user-turn boundary. The old
|
|
180
|
+
// WS ingress counter saw history/control frames and is intentionally gone.
|
|
201
181
|
delta.messages++;
|
|
182
|
+
delta.requests++;
|
|
202
183
|
delta.bytesReceived += Math.max(0, Number(bytesReceived) || 0);
|
|
203
184
|
}
|
|
204
185
|
|
|
@@ -8,6 +8,7 @@ export { yeaftProjectDb, YeaftProjectDbError } from './db/yeaft-project-db.js';
|
|
|
8
8
|
export { sessionUiMetadataDb } from './db/session-ui-metadata-db.js';
|
|
9
9
|
export { messageDb } from './db/message-db.js';
|
|
10
10
|
export { userStatsDb } from './db/user-stats-db.js';
|
|
11
|
+
export { agentInventoryDb } from './db/agent-inventory-db.js';
|
|
11
12
|
export { expertDb } from './db/expert-db.js';
|
|
12
13
|
export { identityDb } from './db/identity-db.js';
|
|
13
14
|
export { closeDb } from './db/connection.js';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { stmts } from './connection.js';
|
|
2
|
+
|
|
3
|
+
function jsonArray(value) {
|
|
4
|
+
return JSON.stringify(Array.isArray(value) ? value : []);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function jsonObject(value) {
|
|
8
|
+
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseJson(value, fallback) {
|
|
12
|
+
if (typeof value !== 'string' || !value) return fallback;
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(value);
|
|
15
|
+
return parsed ?? fallback;
|
|
16
|
+
} catch {
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseCapabilities(value) {
|
|
22
|
+
const parsed = parseJson(value, []);
|
|
23
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseMetrics(value) {
|
|
27
|
+
const parsed = parseJson(value, {});
|
|
28
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mapRow(row) {
|
|
32
|
+
if (!row) return null;
|
|
33
|
+
return {
|
|
34
|
+
id: row.id,
|
|
35
|
+
instanceId: row.instance_id || null,
|
|
36
|
+
ownerId: row.owner_id || null,
|
|
37
|
+
name: row.name,
|
|
38
|
+
workDir: row.work_dir || '',
|
|
39
|
+
version: row.version || null,
|
|
40
|
+
platform: row.platform || null,
|
|
41
|
+
capabilities: parseCapabilities(row.capabilities_json),
|
|
42
|
+
capabilityMetadataProvided: row.capability_metadata_provided === 1,
|
|
43
|
+
metrics: parseMetrics(row.metrics_json),
|
|
44
|
+
metricsUpdatedAt: row.metrics_updated_at || null,
|
|
45
|
+
lastSeenAt: row.last_seen_at || null,
|
|
46
|
+
lastConnectedAt: row.last_connected_at || null,
|
|
47
|
+
updatedAt: row.updated_at || null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validTimestamp(value, fallback = Date.now()) {
|
|
52
|
+
const timestamp = Number(value);
|
|
53
|
+
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Durable, admin-only last-known Agent inventory.
|
|
58
|
+
*
|
|
59
|
+
* The inventory never decides whether an Agent is online. That remains a
|
|
60
|
+
* property of the current owner-scoped WebSocket in `context.agents`; these
|
|
61
|
+
* rows only preserve identity and last-seen metadata across disconnects and
|
|
62
|
+
* Server restarts.
|
|
63
|
+
*/
|
|
64
|
+
export const agentInventoryDb = {
|
|
65
|
+
/**
|
|
66
|
+
* Insert or replace the durable metadata for one Agent connection.
|
|
67
|
+
* @param {{id:string, instanceId?:string|null, ownerId?:string|null, name:string,
|
|
68
|
+
* workDir?:string, version?:string|null, platform?:string|null,
|
|
69
|
+
* capabilities?:string[], capabilityMetadataProvided?:boolean,
|
|
70
|
+
* metrics?:object, metricsUpdatedAt?:number|null, lastSeenAt?:number,
|
|
71
|
+
* lastConnectedAt?:number, updatedAt?:number}} agent
|
|
72
|
+
*/
|
|
73
|
+
upsert(agent) {
|
|
74
|
+
if (!agent?.id || !agent?.name) return false;
|
|
75
|
+
const now = validTimestamp(agent.updatedAt);
|
|
76
|
+
const lastSeenAt = validTimestamp(agent.lastSeenAt, now);
|
|
77
|
+
const lastConnectedAt = validTimestamp(agent.lastConnectedAt, now);
|
|
78
|
+
stmts.upsertAgentInventory.run(
|
|
79
|
+
agent.id,
|
|
80
|
+
agent.instanceId || null,
|
|
81
|
+
agent.ownerId || null,
|
|
82
|
+
agent.name,
|
|
83
|
+
agent.workDir || '',
|
|
84
|
+
agent.version || null,
|
|
85
|
+
agent.platform || null,
|
|
86
|
+
jsonArray(agent.capabilities),
|
|
87
|
+
agent.capabilityMetadataProvided ? 1 : 0,
|
|
88
|
+
agent.metrics == null ? null : jsonObject(agent.metrics),
|
|
89
|
+
agent.metricsUpdatedAt ? validTimestamp(agent.metricsUpdatedAt, now) : null,
|
|
90
|
+
lastSeenAt,
|
|
91
|
+
lastConnectedAt,
|
|
92
|
+
now,
|
|
93
|
+
);
|
|
94
|
+
return true;
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/** Persist only a new last-seen timestamp for a live inventory row. */
|
|
98
|
+
touch(id, lastSeenAt = Date.now()) {
|
|
99
|
+
if (!id) return false;
|
|
100
|
+
const timestamp = validTimestamp(lastSeenAt);
|
|
101
|
+
const result = stmts.touchAgentInventory.run(timestamp, timestamp, id);
|
|
102
|
+
return result.changes > 0;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/** Persist a cumulative metrics snapshot without changing connection metadata. */
|
|
106
|
+
updateMetrics(id, metrics = {}, metricsUpdatedAt = Date.now()) {
|
|
107
|
+
if (!id) return false;
|
|
108
|
+
const timestamp = validTimestamp(metricsUpdatedAt);
|
|
109
|
+
const result = stmts.updateAgentInventoryMetrics.run(
|
|
110
|
+
jsonObject(metrics),
|
|
111
|
+
timestamp,
|
|
112
|
+
timestamp,
|
|
113
|
+
id,
|
|
114
|
+
);
|
|
115
|
+
return result.changes > 0;
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
/** Return all historical Agent records, newest last-seen first. */
|
|
119
|
+
getAll() {
|
|
120
|
+
return stmts.getAllAgentInventory.all().map(mapRow);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
@@ -134,6 +134,27 @@ db.exec(`
|
|
|
134
134
|
PRIMARY KEY (user_id, agent_instance_id, metric_epoch)
|
|
135
135
|
);
|
|
136
136
|
|
|
137
|
+
-- Durable last-known Agent inventory for admin operations. online is not
|
|
138
|
+
-- stored here: it is derived from the current owner-scoped WebSocket map.
|
|
139
|
+
-- Keeping the inventory separate from sessions preserves historical Agents
|
|
140
|
+
-- after their socket disconnects and across Server restarts.
|
|
141
|
+
CREATE TABLE IF NOT EXISTS agent_inventory (
|
|
142
|
+
id TEXT PRIMARY KEY,
|
|
143
|
+
instance_id TEXT,
|
|
144
|
+
owner_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
|
145
|
+
name TEXT NOT NULL,
|
|
146
|
+
work_dir TEXT,
|
|
147
|
+
version TEXT,
|
|
148
|
+
platform TEXT,
|
|
149
|
+
capabilities_json TEXT NOT NULL DEFAULT '[]',
|
|
150
|
+
capability_metadata_provided INTEGER NOT NULL DEFAULT 0,
|
|
151
|
+
metrics_json TEXT NOT NULL DEFAULT '{}',
|
|
152
|
+
metrics_updated_at INTEGER,
|
|
153
|
+
last_seen_at INTEGER NOT NULL,
|
|
154
|
+
last_connected_at INTEGER NOT NULL,
|
|
155
|
+
updated_at INTEGER NOT NULL
|
|
156
|
+
);
|
|
157
|
+
|
|
137
158
|
-- 基本索引(不依赖迁移列)
|
|
138
159
|
CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_id);
|
|
139
160
|
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC);
|
|
@@ -152,6 +173,7 @@ db.exec(`
|
|
|
152
173
|
-- concurrent reads continue during the build; writers will block briefly.
|
|
153
174
|
CREATE INDEX IF NOT EXISTS idx_messages_session_role_id ON messages(session_id, role, id DESC);
|
|
154
175
|
CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON daily_stats(date);
|
|
176
|
+
CREATE INDEX IF NOT EXISTS idx_agent_inventory_owner_seen ON agent_inventory(owner_id, last_seen_at DESC);
|
|
155
177
|
|
|
156
178
|
-- Managed Sandbox control-plane state. Runtime resources are deliberately
|
|
157
179
|
-- not created on this mixed-use Server Host; qualified Controllers report
|
|
@@ -1266,6 +1288,43 @@ export const stmts = {
|
|
|
1266
1288
|
updated_at = excluded.updated_at
|
|
1267
1289
|
`),
|
|
1268
1290
|
|
|
1291
|
+
// Durable Admin Agent inventory. `online` is derived from context.agents.
|
|
1292
|
+
upsertAgentInventory: db.prepare(`
|
|
1293
|
+
INSERT INTO agent_inventory (
|
|
1294
|
+
id, instance_id, owner_id, name, work_dir, version, platform,
|
|
1295
|
+
capabilities_json, capability_metadata_provided, metrics_json,
|
|
1296
|
+
metrics_updated_at, last_seen_at, last_connected_at, updated_at
|
|
1297
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, '{}'), ?, ?, ?, ?)
|
|
1298
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1299
|
+
instance_id = excluded.instance_id,
|
|
1300
|
+
owner_id = excluded.owner_id,
|
|
1301
|
+
name = excluded.name,
|
|
1302
|
+
work_dir = excluded.work_dir,
|
|
1303
|
+
version = excluded.version,
|
|
1304
|
+
platform = excluded.platform,
|
|
1305
|
+
capabilities_json = excluded.capabilities_json,
|
|
1306
|
+
capability_metadata_provided = excluded.capability_metadata_provided,
|
|
1307
|
+
metrics_json = CASE WHEN excluded.metrics_updated_at IS NULL THEN agent_inventory.metrics_json ELSE excluded.metrics_json END,
|
|
1308
|
+
metrics_updated_at = COALESCE(excluded.metrics_updated_at, agent_inventory.metrics_updated_at),
|
|
1309
|
+
last_seen_at = excluded.last_seen_at,
|
|
1310
|
+
last_connected_at = excluded.last_connected_at,
|
|
1311
|
+
updated_at = excluded.updated_at
|
|
1312
|
+
`),
|
|
1313
|
+
|
|
1314
|
+
touchAgentInventory: db.prepare(`
|
|
1315
|
+
UPDATE agent_inventory SET last_seen_at = ?, updated_at = ? WHERE id = ?
|
|
1316
|
+
`),
|
|
1317
|
+
|
|
1318
|
+
updateAgentInventoryMetrics: db.prepare(`
|
|
1319
|
+
UPDATE agent_inventory
|
|
1320
|
+
SET metrics_json = ?, metrics_updated_at = ?, updated_at = ?
|
|
1321
|
+
WHERE id = ?
|
|
1322
|
+
`),
|
|
1323
|
+
|
|
1324
|
+
getAllAgentInventory: db.prepare(`
|
|
1325
|
+
SELECT * FROM agent_inventory ORDER BY last_seen_at DESC, name ASC, id ASC
|
|
1326
|
+
`),
|
|
1327
|
+
|
|
1269
1328
|
// Identity 操作 (multi-provider SSO)
|
|
1270
1329
|
insertIdentity: db.prepare(`
|
|
1271
1330
|
INSERT INTO user_identities (id, user_id, provider, subject, email, display_name, created_at, last_login_at)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CONFIG } from '../config.js';
|
|
2
|
-
import { sessionDb, userStatsDb } from '../database.js';
|
|
2
|
+
import { agentInventoryDb, sessionDb, userStatsDb } from '../database.js';
|
|
3
3
|
import { agents, webClients } from '../context.js';
|
|
4
4
|
import { sendToWebClient, broadcastAgentList } from '../ws-utils.js';
|
|
5
5
|
import {
|
|
@@ -82,6 +82,11 @@ export async function handleAgentSync(agentId, agent, msg) {
|
|
|
82
82
|
: null;
|
|
83
83
|
if (!capabilities || capabilities.length === 0) break;
|
|
84
84
|
agent.capabilities = capabilities;
|
|
85
|
+
try {
|
|
86
|
+
agentInventoryDb.upsert({ ...agent, id: agentId });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error(`[AgentInventory] Failed to persist capabilities for ${agentId}:`, error.message);
|
|
89
|
+
}
|
|
85
90
|
// Transport encryption negotiation is connection-scoped and immutable.
|
|
86
91
|
// A runtime capability refresh must never flip framing mid-connection.
|
|
87
92
|
await broadcastAgentList();
|
|
@@ -91,6 +96,12 @@ export async function handleAgentSync(agentId, agent, msg) {
|
|
|
91
96
|
case 'agent_metrics': {
|
|
92
97
|
agent.metrics = normalizeAgentMetrics(msg.metrics || {});
|
|
93
98
|
agent.metricsUpdatedAt = Date.now();
|
|
99
|
+
try {
|
|
100
|
+
const persisted = agentInventoryDb.updateMetrics(agentId, agent.metrics, agent.metricsUpdatedAt);
|
|
101
|
+
if (persisted === false) agentInventoryDb.upsert({ ...agent, id: agentId });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error(`[AgentInventory] Failed to persist metrics for ${agentId}:`, error.message);
|
|
104
|
+
}
|
|
94
105
|
if (agent.ownerId && agent.metrics.metricEpoch) {
|
|
95
106
|
try {
|
|
96
107
|
userStatsDb.recordAgentTokenSnapshot(
|
|
@@ -1246,6 +1246,7 @@ export async function handleClientConversation(clientId, client, msg, checkAgent
|
|
|
1246
1246
|
await sendToWebClient(client, { type: 'btw_error', conversationId: btwConvId, error: 'Permission denied' });
|
|
1247
1247
|
return;
|
|
1248
1248
|
}
|
|
1249
|
+
trackUserTurn(client.userId, Buffer.byteLength(JSON.stringify(msg)));
|
|
1249
1250
|
await forwardToAgent(client.currentAgent, {
|
|
1250
1251
|
type: 'btw_question',
|
|
1251
1252
|
conversationId: btwConvId,
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { WebSocket } from 'ws';
|
|
2
|
-
import { userStatsDb } from '../database.js';
|
|
2
|
+
import { agentInventoryDb, userDb, userStatsDb } from '../database.js';
|
|
3
3
|
import { agents, webClients, userStatsDeltas } from '../context.js';
|
|
4
4
|
|
|
5
5
|
const toNumber = (value) => Number(value) || 0;
|
|
6
6
|
|
|
7
|
+
function onlineUserIds() {
|
|
8
|
+
const ids = new Set();
|
|
9
|
+
for (const [, client] of webClients) {
|
|
10
|
+
if (client.authenticated && client.userId && client.ws?.readyState === WebSocket.OPEN) {
|
|
11
|
+
ids.add(client.userId);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return ids;
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
function emptyAgentMetrics() {
|
|
8
18
|
return {
|
|
9
19
|
totalTurns: 0,
|
|
@@ -73,7 +83,9 @@ function mergePendingUserStats(stats) {
|
|
|
73
83
|
}
|
|
74
84
|
row.message_count = toNumber(row.message_count) + toNumber(delta.messages);
|
|
75
85
|
row.session_count = toNumber(row.session_count) + toNumber(delta.sessions);
|
|
76
|
-
|
|
86
|
+
// request_count is a legacy field and may contain old control-frame
|
|
87
|
+
// traffic. The current dashboard request metric is the user-turn count.
|
|
88
|
+
row.request_count = toNumber(row.message_count);
|
|
77
89
|
row.bytes_sent = toNumber(row.bytes_sent) + toNumber(delta.bytesSent);
|
|
78
90
|
row.bytes_received = toNumber(row.bytes_received) + toNumber(delta.bytesReceived);
|
|
79
91
|
}
|
|
@@ -94,12 +106,7 @@ export function registerAdminRoutes(app, { requireAuth, requireAdmin }) {
|
|
|
94
106
|
app.get('/api/admin/dashboard', requireAuth, requireAdmin, (req, res) => {
|
|
95
107
|
try {
|
|
96
108
|
const totals = userStatsDb.getDashboardTotals();
|
|
97
|
-
const onlineUsers =
|
|
98
|
-
for (const [, client] of webClients) {
|
|
99
|
-
if (client.authenticated && client.userId) {
|
|
100
|
-
onlineUsers.add(client.userId);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
109
|
+
const onlineUsers = onlineUserIds();
|
|
103
110
|
let onlineAgents = 0;
|
|
104
111
|
for (const [, agent] of agents) {
|
|
105
112
|
if (agent.ws.readyState === WebSocket.OPEN) onlineAgents++;
|
|
@@ -130,15 +137,43 @@ export function registerAdminRoutes(app, { requireAuth, requireAdmin }) {
|
|
|
130
137
|
try {
|
|
131
138
|
const period = req.query.period || 'all';
|
|
132
139
|
const validPeriods = ['today', 'week', 'month', 'all'];
|
|
140
|
+
const activeUserIds = onlineUserIds();
|
|
133
141
|
const stats = mergePendingUserStats(userStatsDb.getByPeriod(validPeriods.includes(period) ? period : 'all'));
|
|
142
|
+
const statsByUserId = new Map(stats.map(row => [row.user_id, row]));
|
|
143
|
+
// Include users with no usage rows yet so the Active filter reflects all
|
|
144
|
+
// currently connected users, not only users who have sent a prompt.
|
|
145
|
+
for (const user of userDb.getAll()) {
|
|
146
|
+
if (!statsByUserId.has(user.id)) {
|
|
147
|
+
stats.push({
|
|
148
|
+
user_id: user.id,
|
|
149
|
+
username: user.username,
|
|
150
|
+
display_name: user.display_name,
|
|
151
|
+
role: user.role,
|
|
152
|
+
message_count: 0,
|
|
153
|
+
session_count: 0,
|
|
154
|
+
request_count: 0,
|
|
155
|
+
bytes_sent: 0,
|
|
156
|
+
bytes_received: 0,
|
|
157
|
+
input_tokens: 0,
|
|
158
|
+
output_tokens: 0,
|
|
159
|
+
cache_read_tokens: 0,
|
|
160
|
+
cache_write_tokens: 0,
|
|
161
|
+
total_tokens: 0,
|
|
162
|
+
last_login_at: user.last_login_at,
|
|
163
|
+
updated_at: user.created_at,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
134
167
|
res.json(stats.map(s => ({
|
|
135
168
|
userId: s.user_id,
|
|
136
169
|
username: s.username,
|
|
137
170
|
displayName: s.display_name,
|
|
138
171
|
role: s.role,
|
|
139
172
|
messageCount: s.message_count,
|
|
140
|
-
|
|
141
|
-
|
|
173
|
+
// request_count predates the user-turn boundary and includes old
|
|
174
|
+
// control/echo traffic. Use the canonical user-turn count for the UI.
|
|
175
|
+
requestCount: toNumber(s.message_count),
|
|
176
|
+
active: activeUserIds.has(s.user_id),
|
|
142
177
|
bytesSent: s.bytes_sent,
|
|
143
178
|
bytesReceived: s.bytes_received,
|
|
144
179
|
inputTokens: s.input_tokens,
|
|
@@ -155,24 +190,52 @@ export function registerAdminRoutes(app, { requireAuth, requireAdmin }) {
|
|
|
155
190
|
}
|
|
156
191
|
});
|
|
157
192
|
|
|
158
|
-
// GET /api/admin/agents —
|
|
193
|
+
// GET /api/admin/agents — durable inventory with live connection overlay.
|
|
194
|
+
// The route is admin-only; `online` still comes exclusively from the current
|
|
195
|
+
// owner-scoped WebSocket in `agents`, never from persisted inventory state.
|
|
159
196
|
app.get('/api/admin/agents', requireAuth, requireAdmin, (req, res) => {
|
|
160
197
|
try {
|
|
161
|
-
const
|
|
198
|
+
const liveAgents = new Map(Array.from(agents.entries()).map(([id, agent]) => [id, {
|
|
162
199
|
id,
|
|
200
|
+
instanceId: agent.instanceId || null,
|
|
163
201
|
name: agent.name,
|
|
164
202
|
workDir: agent.workDir,
|
|
165
|
-
online: agent.ws
|
|
203
|
+
online: agent.ws?.readyState === WebSocket.OPEN,
|
|
166
204
|
status: agent.status || 'ready',
|
|
167
205
|
latency: agent.latency || null,
|
|
168
206
|
version: agent.version || null,
|
|
207
|
+
platform: agent.platform || null,
|
|
169
208
|
ownerId: agent.ownerId || null,
|
|
170
209
|
ownerUsername: agent.ownerUsername || null,
|
|
171
210
|
capabilities: agent.capabilities || [],
|
|
211
|
+
capabilityMetadataProvided: agent.capabilityMetadataProvided === true,
|
|
172
212
|
conversationCount: agent.conversations?.size || 0,
|
|
213
|
+
lastSeenAt: agent.lastSeenAt || null,
|
|
214
|
+
lastConnectedAt: agent.lastConnectedAt || null,
|
|
173
215
|
metrics: safeAgentMetrics(agent.metrics || {}),
|
|
174
|
-
metricsUpdatedAt: agent.metricsUpdatedAt || null
|
|
175
|
-
}));
|
|
216
|
+
metricsUpdatedAt: agent.metricsUpdatedAt || null,
|
|
217
|
+
}]));
|
|
218
|
+
const ownerUsernames = new Map(userDb.getAll().map(user => [user.id, user.username]));
|
|
219
|
+
const inventory = agentInventoryDb.getAll();
|
|
220
|
+
const agentList = inventory.map(record => {
|
|
221
|
+
const live = liveAgents.get(record.id);
|
|
222
|
+
if (!live) {
|
|
223
|
+
return {
|
|
224
|
+
...record,
|
|
225
|
+
ownerUsername: record.ownerId ? (ownerUsernames.get(record.ownerId) || null) : null,
|
|
226
|
+
online: false,
|
|
227
|
+
status: 'offline',
|
|
228
|
+
latency: null,
|
|
229
|
+
conversationCount: 0,
|
|
230
|
+
metrics: safeAgentMetrics(record.metrics || {}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
liveAgents.delete(record.id);
|
|
234
|
+
return live;
|
|
235
|
+
});
|
|
236
|
+
// Keep a best-effort fallback for a live record whose initial inventory
|
|
237
|
+
// write failed; the next heartbeat/connection write repairs the row.
|
|
238
|
+
agentList.push(...liveAgents.values());
|
|
176
239
|
res.json(agentList);
|
|
177
240
|
} catch (e) {
|
|
178
241
|
console.error('[Admin] Agents error:', e.message);
|
|
@@ -5,7 +5,7 @@ import { verifyAgent } from './auth.js';
|
|
|
5
5
|
|
|
6
6
|
import { encodeKey } from './encryption.js';
|
|
7
7
|
import { agents, pendingAgentConnections } from './context.js';
|
|
8
|
-
import { userDb } from './database.js';
|
|
8
|
+
import { agentInventoryDb, userDb } from './database.js';
|
|
9
9
|
import {
|
|
10
10
|
parseMessage, broadcastAgentList, clearAgentDirCache
|
|
11
11
|
} from './ws-utils.js';
|
|
@@ -52,6 +52,35 @@ function pruneAgentConnectionGenerations() {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function persistAgentInventory(agentId, agent) {
|
|
56
|
+
if (!agentInventoryDb?.upsert || !agent?.name) return;
|
|
57
|
+
try {
|
|
58
|
+
agentInventoryDb.upsert({
|
|
59
|
+
...agent,
|
|
60
|
+
id: agentId,
|
|
61
|
+
lastSeenAt: agent.lastSeenAt,
|
|
62
|
+
lastConnectedAt: agent.lastConnectedAt,
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// The inventory is an admin read model. Never reject a live Agent because
|
|
66
|
+
// this best-effort durability write failed.
|
|
67
|
+
console.error(`[AgentInventory] Failed to persist ${agentId}:`, error.message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function persistAgentHeartbeat(agentId, agent, now = Date.now()) {
|
|
72
|
+
if (!agentInventoryDb?.touch || !agent) return;
|
|
73
|
+
const lastPersistedAt = Number(agent.inventoryLastPersistedAt) || 0;
|
|
74
|
+
if (lastPersistedAt && now - lastPersistedAt < 10_000) return;
|
|
75
|
+
agent.inventoryLastPersistedAt = now;
|
|
76
|
+
try {
|
|
77
|
+
const touched = agentInventoryDb.touch(agentId, now);
|
|
78
|
+
if (touched === false) persistAgentInventory(agentId, agent);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(`[AgentInventory] Failed to persist heartbeat for ${agentId}:`, error.message);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
55
84
|
export function handleAgentConnection(ws, url) {
|
|
56
85
|
const clientAgentId = url.searchParams.get('id') || randomUUID();
|
|
57
86
|
const agentName = url.searchParams.get('name') || `Agent-${clientAgentId.slice(0, 8)}`;
|
|
@@ -183,7 +212,9 @@ export function handleAgentConnection(ws, url) {
|
|
|
183
212
|
ws.close(1008, 'Account disabled');
|
|
184
213
|
return;
|
|
185
214
|
}
|
|
186
|
-
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
markAgentHeartbeatSeen(agent, now);
|
|
217
|
+
persistAgentHeartbeat(resolvedAgentId, agent, now);
|
|
187
218
|
const msg = await parseMessage(data, agent.sessionKey);
|
|
188
219
|
if (msg) {
|
|
189
220
|
console.log(`[Agent] Received message from ${resolvedAgentId}: ${msg.type}`);
|
|
@@ -235,6 +266,9 @@ export function handleAgentConnection(ws, url) {
|
|
|
235
266
|
function handleAgentDisconnect(agentId, agentName, ws) {
|
|
236
267
|
const agent = agents.get(agentId);
|
|
237
268
|
if (!agent || agent.ws !== ws) return;
|
|
269
|
+
// Capture the last in-memory heartbeat before removing the live socket. The
|
|
270
|
+
// durable row remains an offline historical record; it never grants access.
|
|
271
|
+
persistAgentInventory(agentId, agent);
|
|
238
272
|
// Phase 4: 清理目录缓存
|
|
239
273
|
clearAgentDirCache(agentId);
|
|
240
274
|
clearWorkbenchCorrelationsForAgent(agentId);
|
|
@@ -280,6 +314,7 @@ function completeAgentRegistration(ws, agentId, agentName, workDir, sessionKey,
|
|
|
280
314
|
sessionKey,
|
|
281
315
|
isAlive: true,
|
|
282
316
|
lastSeenAt: Date.now(),
|
|
317
|
+
lastConnectedAt: Date.now(),
|
|
283
318
|
capabilities: effectiveCapabilities,
|
|
284
319
|
// The fallback list supports old UI features but cannot prove that an
|
|
285
320
|
// Agent made an explicit capability claim.
|
|
@@ -305,6 +340,7 @@ function completeAgentRegistration(ws, agentId, agentName, workDir, sessionKey,
|
|
|
305
340
|
}
|
|
306
341
|
}, 30000);
|
|
307
342
|
agents.get(agentId)._syncTimeout = syncTimeout;
|
|
343
|
+
persistAgentInventory(agentId, agents.get(agentId));
|
|
308
344
|
|
|
309
345
|
if (existingAgent?.ws && existingAgent.ws !== ws) {
|
|
310
346
|
clearBrowserRuntimeForAgent(agentId);
|
|
@@ -315,7 +351,9 @@ function completeAgentRegistration(ws, agentId, agentName, workDir, sessionKey,
|
|
|
315
351
|
ws.on('pong', () => {
|
|
316
352
|
const agent = agents.get(agentId);
|
|
317
353
|
if (agent?.ws === ws) {
|
|
318
|
-
|
|
354
|
+
const now = Date.now();
|
|
355
|
+
markAgentHeartbeatSeen(agent, now);
|
|
356
|
+
persistAgentHeartbeat(agentId, agent, now);
|
|
319
357
|
if (agent.pingSentAt) {
|
|
320
358
|
agent.latency = Date.now() - agent.pingSentAt;
|
|
321
359
|
agent.pingSentAt = null;
|
|
@@ -5,7 +5,7 @@ import { generateSkipAuthSession } from './auth.js';
|
|
|
5
5
|
import { authenticateRequest } from './auth/request-auth.js';
|
|
6
6
|
import { encodeKey } from './encryption.js';
|
|
7
7
|
import { userDb } from './database.js';
|
|
8
|
-
import { agents, clearYeaftDebugRequestsForClient, webClients
|
|
8
|
+
import { agents, clearYeaftDebugRequestsForClient, webClients } from './context.js';
|
|
9
9
|
import {
|
|
10
10
|
applyClientHello,
|
|
11
11
|
BROWSER_RUNTIME_PROTOCOL,
|
|
@@ -136,12 +136,6 @@ export function handleWebConnection(ws, url, req = {}) {
|
|
|
136
136
|
const client = webClients.get(clientId);
|
|
137
137
|
const msg = await parseMessage(data, client?.sessionKey);
|
|
138
138
|
if (!msg) return;
|
|
139
|
-
// Stats tracking: exclude heartbeat/control frames. User-turn bytes are
|
|
140
|
-
// accounted at the send handlers, not here, so pings and dashboard polling
|
|
141
|
-
// don't inflate traffic.
|
|
142
|
-
if (!isHeartbeatMessageType(msg.type)) {
|
|
143
|
-
trackRequest(client?.userId, data.length || 0, msg.type);
|
|
144
|
-
}
|
|
145
139
|
if (msg.perfTraceId) {
|
|
146
140
|
recordPerfTraceEvent({
|
|
147
141
|
traceId: msg.perfTraceId,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.448"}
|