@yeaft/webchat-agent 1.0.445 → 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.
- package/Dockerfile +5 -2
- package/cli.js +9 -0
- package/connection/upgrade.js +18 -0
- package/container-manager.js +1 -0
- package/index.js +5 -2
- package/local-runtime/agent/container-manager.js +1 -0
- 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/handlers/client-misc.js +15 -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 +217 -242
- 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/upgrade-command.js +19 -0
package/Dockerfile
CHANGED
|
@@ -4,7 +4,8 @@ ARG BUILD_VERSION=dev
|
|
|
4
4
|
ENV NODE_ENV=production \
|
|
5
5
|
HOME=/home/yeaft \
|
|
6
6
|
YEAFT_DIR=/home/yeaft/.yeaft \
|
|
7
|
-
WORK_DIR=/workspace
|
|
7
|
+
WORK_DIR=/workspace \
|
|
8
|
+
YEAFT_AGENT_RUNTIME=container_agent
|
|
8
9
|
|
|
9
10
|
RUN apt-get update \
|
|
10
11
|
&& apt-get install -y --no-install-recommends ca-certificates git openssh-client tini \
|
|
@@ -16,7 +17,9 @@ RUN apt-get update \
|
|
|
16
17
|
WORKDIR /app
|
|
17
18
|
COPY package.json package-lock.json ./
|
|
18
19
|
COPY agent/package.json ./agent/
|
|
19
|
-
RUN npm ci --workspace=agent --omit=dev
|
|
20
|
+
RUN npm_config_cache=/tmp/yeaft-npm-cache npm ci --workspace=agent --omit=dev \
|
|
21
|
+
&& rm -rf /tmp/yeaft-npm-cache \
|
|
22
|
+
&& install -d -m 0700 -o yeaft -g yeaft /home/yeaft/.npm
|
|
20
23
|
COPY --chown=yeaft:yeaft agent ./agent
|
|
21
24
|
RUN node -e "const fs=require('fs'); const p='agent/package.json'; const j=JSON.parse(fs.readFileSync(p)); j.version=process.argv[1].replace(/^v/, ''); fs.writeFileSync(p, JSON.stringify(j, null, 2) + '\\n')" "$BUILD_VERSION" \
|
|
22
25
|
&& chmod 0755 agent/container-entrypoint.sh
|
package/cli.js
CHANGED
|
@@ -38,9 +38,12 @@ import {
|
|
|
38
38
|
warnDeprecatedInstanceArg,
|
|
39
39
|
} from './service/config.js';
|
|
40
40
|
import {
|
|
41
|
+
buildContainerImageUpgradeMessage,
|
|
41
42
|
buildUpgradeInstallCommand,
|
|
42
43
|
buildUpgradeMetadataUrl,
|
|
43
44
|
buildUpgradeVersionCommand,
|
|
45
|
+
CONTAINER_IMAGE_UPGRADE_REASON,
|
|
46
|
+
isContainerAgentRuntime,
|
|
44
47
|
createWindowsUpgradeRun,
|
|
45
48
|
launchWindowsUpgradeScript,
|
|
46
49
|
prepareWindowsUpgradeRunner,
|
|
@@ -545,6 +548,7 @@ function parseAndStart(args) {
|
|
|
545
548
|
}
|
|
546
549
|
|
|
547
550
|
async function checkForUpdates() {
|
|
551
|
+
if (isContainerAgentRuntime()) return;
|
|
548
552
|
try {
|
|
549
553
|
const res = await fetch(buildUpgradeMetadataUrl(pkg.name));
|
|
550
554
|
if (!res.ok) return;
|
|
@@ -562,6 +566,11 @@ async function checkForUpdates() {
|
|
|
562
566
|
async function upgrade(args = []) {
|
|
563
567
|
warnDeprecatedInstanceArg(args);
|
|
564
568
|
const instanceId = resolveServiceInstanceId(args, process.env, { management: true });
|
|
569
|
+
if (isContainerAgentRuntime()) {
|
|
570
|
+
console.error(`${CONTAINER_IMAGE_UPGRADE_REASON}: ${buildContainerImageUpgradeMessage(pkg.version)}`);
|
|
571
|
+
process.exitCode = 1;
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
565
574
|
console.log(`Current version: ${pkg.version}`);
|
|
566
575
|
console.log('Checking for updates...');
|
|
567
576
|
|
package/connection/upgrade.js
CHANGED
|
@@ -8,6 +8,10 @@ import { getConfigDir, getServiceName, getPm2AppName, getLaunchdPlistPath, DEFAU
|
|
|
8
8
|
import {
|
|
9
9
|
buildUpgradeInstallCommand,
|
|
10
10
|
buildUpgradeMetadataArgs,
|
|
11
|
+
buildContainerImageUpgradeMessage,
|
|
12
|
+
CONTAINER_AGENT_CAPABILITY,
|
|
13
|
+
CONTAINER_IMAGE_UPGRADE_REASON,
|
|
14
|
+
isContainerAgentRuntime,
|
|
11
15
|
createWindowsUpgradeRun,
|
|
12
16
|
launchWindowsUpgradeScript,
|
|
13
17
|
prepareWindowsUpgradeRunner,
|
|
@@ -175,6 +179,20 @@ function compareCmp(cur, cmp) {
|
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
export async function handleUpgradeAgent() {
|
|
182
|
+
if (isContainerAgentRuntime() || ctx.agentCapabilities?.includes(CONTAINER_AGENT_CAPABILITY)) {
|
|
183
|
+
const version = ctx.agentVersion || null;
|
|
184
|
+
const error = buildContainerImageUpgradeMessage(version);
|
|
185
|
+
console.warn(`[Agent] Upgrade rejected (${CONTAINER_IMAGE_UPGRADE_REASON}): ${error}`);
|
|
186
|
+
await sendToServer({
|
|
187
|
+
type: 'upgrade_agent_ack',
|
|
188
|
+
success: false,
|
|
189
|
+
reason: CONTAINER_IMAGE_UPGRADE_REASON,
|
|
190
|
+
error,
|
|
191
|
+
version,
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
178
196
|
console.log('[Agent] Upgrade requested, checking for updates...');
|
|
179
197
|
try {
|
|
180
198
|
const pkgName = ctx.pkgName || '@yeaft/webchat-agent';
|
package/container-manager.js
CHANGED
|
@@ -122,6 +122,7 @@ export function buildCreateArgs({
|
|
|
122
122
|
'--mount', `type=bind,src=${resolve(secretFile)},dst=/run/yeaft-host-secret,readonly`,
|
|
123
123
|
'--env', `SERVER_URL=${serverUrl}`,
|
|
124
124
|
'--env', `AGENT_NAME=${agentName}`,
|
|
125
|
+
'--env', 'YEAFT_AGENT_RUNTIME=container_agent',
|
|
125
126
|
'--env', 'AGENT_SECRET_FILE=/run/yeaft-host-secret',
|
|
126
127
|
'--env', 'YEAFT_DIR=/home/yeaft/.yeaft',
|
|
127
128
|
'--env', 'WORK_DIR=/workspace',
|
package/index.js
CHANGED
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
import { loadNodePty } from './terminal.js';
|
|
21
21
|
import { connect } from './connection.js';
|
|
22
22
|
import { loadMcpServers } from './mcp.js';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
getAgentUpgradeCapability,
|
|
25
|
+
} from './upgrade-command.js';
|
|
24
26
|
import { loadConfig as loadYeaftConfig } from './yeaft/config.js';
|
|
25
27
|
import { initYeaftDir } from './yeaft/init.js';
|
|
26
28
|
import { updateBrowserRuntimeSettings } from './yeaft/config-api.js';
|
|
@@ -173,7 +175,8 @@ async function detectCapabilities() {
|
|
|
173
175
|
// agent build can speak plaintext WS frames. New servers see this and
|
|
174
176
|
// flip `agent.encryptOutbound = false`, stopping outbound encryption
|
|
175
177
|
// to this peer. Old servers ignore the unknown capability token.
|
|
176
|
-
const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'workbench_session_routes',
|
|
178
|
+
const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'workbench_session_routes', 'work_center', 'work_center_message_v2', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch', 'file_reference_resolution', 'yeaft_plugins', 'yeaft_managed_skills'];
|
|
179
|
+
capabilities.push(getAgentUpgradeCapability());
|
|
177
180
|
if (process.platform === 'linux') capabilities.push('work_item_attachments');
|
|
178
181
|
const pty = await loadNodePty();
|
|
179
182
|
if (pty) capabilities.push('terminal');
|
|
@@ -122,6 +122,7 @@ export function buildCreateArgs({
|
|
|
122
122
|
'--mount', `type=bind,src=${resolve(secretFile)},dst=/run/yeaft-host-secret,readonly`,
|
|
123
123
|
'--env', `SERVER_URL=${serverUrl}`,
|
|
124
124
|
'--env', `AGENT_NAME=${agentName}`,
|
|
125
|
+
'--env', 'YEAFT_AGENT_RUNTIME=container_agent',
|
|
125
126
|
'--env', 'AGENT_SECRET_FILE=/run/yeaft-host-secret',
|
|
126
127
|
'--env', 'YEAFT_DIR=/home/yeaft/.yeaft',
|
|
127
128
|
'--env', 'WORK_DIR=/workspace',
|
|
@@ -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,
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
// may receive remote upgrade commands. Version thresholds are insufficient:
|
|
14
14
|
// builds without this capability may still inherit the installed package cwd.
|
|
15
15
|
export const SAFE_REMOTE_UPGRADE_CAPABILITY = 'remote_upgrade_safe';
|
|
16
|
+
export const CONTAINER_AGENT_CAPABILITY = 'container_agent';
|
|
17
|
+
export const CONTAINER_IMAGE_UPGRADE_REASON = 'container_image_upgrade_required';
|
|
16
18
|
export { YEAFT_PLUGINS_CAPABILITY, YEAFT_PLUGINS_UNSUPPORTED_ERROR };
|
|
17
19
|
|
|
18
20
|
async function rejectUnsupportedYeaftPlugins(client, msg, agentId) {
|
|
@@ -59,6 +61,19 @@ export async function handleClientMisc(clientId, client, msg, checkAgentAccess)
|
|
|
59
61
|
if (!upgradeAgentId) break;
|
|
60
62
|
if (!await checkAgentAccess(upgradeAgentId)) break;
|
|
61
63
|
const upgradeAgent = agents.get(upgradeAgentId);
|
|
64
|
+
if (Array.isArray(upgradeAgent?.capabilities)
|
|
65
|
+
&& upgradeAgent.capabilities.includes(CONTAINER_AGENT_CAPABILITY)) {
|
|
66
|
+
await sendToWebClient(client, {
|
|
67
|
+
type: 'upgrade_agent_ack',
|
|
68
|
+
agentId: upgradeAgentId,
|
|
69
|
+
success: false,
|
|
70
|
+
reason: CONTAINER_IMAGE_UPGRADE_REASON,
|
|
71
|
+
version: upgradeAgent?.version || null,
|
|
72
|
+
requiredCapability: CONTAINER_AGENT_CAPABILITY,
|
|
73
|
+
error: 'Container Agent is managed as a Docker image. Pull the configured image and recreate the same container through the Server/Sandbox lifecycle while keeping its persistent volumes and host-side agent-secret file.',
|
|
74
|
+
});
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
62
77
|
if (requiresManualUpgradeBridge(upgradeAgent?.capabilities, upgradeAgent?.platform)) {
|
|
63
78
|
await sendToWebClient(client, {
|
|
64
79
|
type: 'upgrade_agent_ack',
|
|
@@ -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);
|