@yeaft/webchat-agent 1.0.446 → 1.0.447

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.
@@ -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
- row.request_count = toNumber(row.request_count) + toNumber(delta.requests);
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 = new Set();
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
- sessionCount: s.session_count,
141
- requestCount: s.request_count,
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 — full agent list (no owner filtering)
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 agentList = Array.from(agents.entries()).map(([id, agent]) => ({
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.readyState === WebSocket.OPEN,
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
- markAgentHeartbeatSeen(agent);
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
- markAgentHeartbeatSeen(agent);
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, isHeartbeatMessageType, trackRequest } from './context.js';
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.446"}
1
+ {"version":"1.0.447"}