aios-management-web 0.1.0
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/.env.json +21 -0
- package/README.md +257 -0
- package/data/management-console.db +0 -0
- package/data/management-console.db-shm +0 -0
- package/data/management-console.db-wal +0 -0
- package/dist/assets/index-CV_wjCAG.js +464 -0
- package/dist/assets/index-DfMPB0eV.css +1 -0
- package/dist/index.html +13 -0
- package/docs/spec.md +199 -0
- package/index.html +12 -0
- package/package.json +37 -0
- package/scripts/reset-kernel.js +59 -0
- package/scripts/reset-password.js +22 -0
- package/server/fakes.js +57 -0
- package/server/index.js +21 -0
- package/server/src/api/middleware/auth.js +29 -0
- package/server/src/api/middleware/internal.js +44 -0
- package/server/src/api/routes/index.js +677 -0
- package/server/src/app.js +90 -0
- package/server/src/background/index.js +106 -0
- package/server/src/background/protocol.js +15 -0
- package/server/src/config/env.js +90 -0
- package/server/src/db/index.js +501 -0
- package/server/src/infra/mqtt/management-rpc-client.js +213 -0
- package/server/src/infra/providers/hzg-provider-client.js +39 -0
- package/server/src/infra/s3/object-storage.js +97 -0
- package/server/src/services/agent-quota.js +54 -0
- package/server/src/services/agent-service.js +696 -0
- package/server/src/services/agent-status-sync-service.js +132 -0
- package/server/src/services/audit-log-service.js +39 -0
- package/server/src/services/auth-service.js +153 -0
- package/server/src/services/catalog-sync-service.js +712 -0
- package/server/src/services/external-service.js +308 -0
- package/server/src/services/kernel-reset-service.js +86 -0
- package/server/src/services/portal-service.js +555 -0
- package/server/src/services/system-service.js +580 -0
- package/server/src/services/topic-ping-service.js +282 -0
- package/server/src/utils/errors.js +36 -0
- package/server/src/utils/security.js +22 -0
- package/server/test/agent-service-alignment.test.js +316 -0
- package/server/test/agent-service-create.test.js +662 -0
- package/server/test/agent-status-sync-service.test.js +167 -0
- package/server/test/agent-update-audit.test.js +63 -0
- package/server/test/auth-middleware.test.js +71 -0
- package/server/test/background-services.test.js +160 -0
- package/server/test/catalog-sync-service.test.js +920 -0
- package/server/test/db-reset-migration.test.js +123 -0
- package/server/test/env-config.test.js +68 -0
- package/server/test/external-service.test.js +380 -0
- package/server/test/hzg-provider-client.test.js +50 -0
- package/server/test/internal-auth-middleware.test.js +66 -0
- package/server/test/kernel-reset-service.test.js +112 -0
- package/server/test/management-rpc-client.test.js +105 -0
- package/server/test/portal-service-access-tokens.test.js +121 -0
- package/server/test/portal-service-alignment.test.js +318 -0
- package/server/test/portal-service-management-logs.test.js +114 -0
- package/server/test/reset-kernel-cli.test.js +23 -0
- package/server/test/service-api-auth-middleware.test.js +59 -0
- package/server/test/system-service-alignment.test.js +265 -0
- package/server/test/topic-ping-service.test.js +182 -0
- package/server/test/usage-refresh-audit-route.test.js +82 -0
- package/src/App.jsx +1 -0
- package/src/api.js +1 -0
- package/src/app/App.jsx +346 -0
- package/src/app/api-client.js +112 -0
- package/src/components/AppShell.jsx +117 -0
- package/src/components/CardTitleWithReload.jsx +20 -0
- package/src/components/DeleteActionButton.jsx +31 -0
- package/src/main.jsx +14 -0
- package/src/pages/AgentsPage.jsx +647 -0
- package/src/pages/AiosUsersPage.jsx +151 -0
- package/src/pages/DashboardPage.jsx +72 -0
- package/src/pages/LoginPage.jsx +41 -0
- package/src/pages/SettingsPage.jsx +431 -0
- package/src/pages/SkillsPage.jsx +175 -0
- package/src/pages/SystemLogsPage.jsx +349 -0
- package/src/pages/SystemsPage.jsx +498 -0
- package/src/pages/TemplatesPage.jsx +207 -0
- package/src/pages/UserManagementPage.jsx +25 -0
- package/src/pages/UsersPage.jsx +192 -0
- package/src/pages/system-logs/SystemLogsTabs.jsx +362 -0
- package/src/styles.css +222 -0
- package/src/utils/format.js +63 -0
- package/test/.reports/fast-2026-05-25T08-32-39-420Z.json +299 -0
- package/test/integration/common.js +208 -0
- package/test/integration/fast.js +135 -0
- package/test/integration/full.js +306 -0
- package/test/run-browser-e2e.js +212 -0
- package/test/run-jasmine.js +21 -0
- package/test/setup.js +1 -0
- package/vite.config.js +12 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import mqtt from "mqtt";
|
|
4
|
+
|
|
5
|
+
import { badRequest, notFound, serviceUnavailable } from "../utils/errors.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
8
|
+
|
|
9
|
+
function firstText(...values) {
|
|
10
|
+
for (const value of values) {
|
|
11
|
+
if (typeof value === "string" && value.trim()) {
|
|
12
|
+
return value.trim();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeInboundPayload(messageId, sessionId) {
|
|
20
|
+
return {
|
|
21
|
+
senderId: "aios-management-web",
|
|
22
|
+
sessionId,
|
|
23
|
+
disableBlockStreaming: true,
|
|
24
|
+
text: `请直接回复 PONG ${messageId}。不要附带任何其他内容,也不要调用任何工具。`
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function matchesPingReply(payload, sessionId, messageId) {
|
|
29
|
+
if (!payload || typeof payload !== "object") {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (String(payload.sessionId || "").trim() !== sessionId) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const text = String(payload.text || "").trim().toUpperCase();
|
|
38
|
+
return text.includes(`PONG ${messageId}`.toUpperCase());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class TopicPingService {
|
|
42
|
+
constructor({ db, env, mqttFactory = mqtt, rpcClient = null }) {
|
|
43
|
+
this.db = db;
|
|
44
|
+
this.env = env;
|
|
45
|
+
this.mqttFactory = mqttFactory;
|
|
46
|
+
this.rpcClient = rpcClient;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
isConfigured() {
|
|
50
|
+
return Boolean(this.env?.mqtt?.brokerUrl);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async pingAdmin(timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
54
|
+
const inboundTopic = firstText(this.env?.mqtt?.adminInboundTopic);
|
|
55
|
+
const outboundTopic = firstText(this.env?.mqtt?.adminOutboundTopic);
|
|
56
|
+
if (!inboundTopic || !outboundTopic) {
|
|
57
|
+
throw badRequest("缺少 admin inbound/outbound topic");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return await this.pingTopics({ inboundTopic, outboundTopic, target: "admin" }, timeoutMs);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async pingAgent(agentId, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
64
|
+
const normalizedAgentId = String(agentId || "").trim();
|
|
65
|
+
if (!normalizedAgentId) {
|
|
66
|
+
throw badRequest("缺少数字员工 ID");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const row = this.db.prepare("SELECT * FROM agents WHERE slug = ?").get(normalizedAgentId);
|
|
70
|
+
if (!row) {
|
|
71
|
+
throw notFound(`数字员工不存在:${normalizedAgentId}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const remoteState = JSON.parse(row.remote_state_json || "{}");
|
|
75
|
+
const inboundTopic = firstText(
|
|
76
|
+
remoteState?.inboundTopic,
|
|
77
|
+
remoteState?.["inbound-topic"],
|
|
78
|
+
this.env?.mqtt?.agentInboundTopicTemplate?.replaceAll("{agentId}", normalizedAgentId)
|
|
79
|
+
);
|
|
80
|
+
const outboundTopic = firstText(
|
|
81
|
+
remoteState?.outboundTopic,
|
|
82
|
+
remoteState?.["outbound-topic"],
|
|
83
|
+
this.env?.mqtt?.agentOutboundTopicTemplate?.replaceAll("{agentId}", normalizedAgentId)
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (!inboundTopic || !outboundTopic) {
|
|
87
|
+
throw badRequest(`数字员工 ${normalizedAgentId} 缺少 inbound/outbound topic`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return await this.pingTopics({
|
|
91
|
+
inboundTopic,
|
|
92
|
+
outboundTopic,
|
|
93
|
+
target: normalizedAgentId
|
|
94
|
+
}, timeoutMs);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async waitForAgentReady(agentId, {
|
|
98
|
+
timeoutMs = 90000,
|
|
99
|
+
intervalMs = 2000,
|
|
100
|
+
pingTimeoutMs = DEFAULT_TIMEOUT_MS
|
|
101
|
+
} = {}) {
|
|
102
|
+
const startedAt = Date.now();
|
|
103
|
+
let lastError = null;
|
|
104
|
+
|
|
105
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
106
|
+
try {
|
|
107
|
+
const result = await this.pingAgent(agentId, pingTimeoutMs);
|
|
108
|
+
return {
|
|
109
|
+
...result,
|
|
110
|
+
waitedMs: Date.now() - startedAt
|
|
111
|
+
};
|
|
112
|
+
} catch (error) {
|
|
113
|
+
lastError = error;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
throw serviceUnavailable(`数字员工 ${agentId} 在超时时间内仍未就绪`, {
|
|
120
|
+
agentId,
|
|
121
|
+
timeoutMs,
|
|
122
|
+
cause: lastError?.message || String(lastError || "")
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async waitForAgentRuntimeReady(agentId, {
|
|
127
|
+
timeoutMs = 60000
|
|
128
|
+
} = {}) {
|
|
129
|
+
const normalizedAgentId = String(agentId || "").trim();
|
|
130
|
+
const row = this.db.prepare("SELECT * FROM agents WHERE slug = ?").get(normalizedAgentId);
|
|
131
|
+
if (!row) {
|
|
132
|
+
throw notFound(`数字员工不存在:${normalizedAgentId}`);
|
|
133
|
+
}
|
|
134
|
+
if (!this.rpcClient || typeof this.rpcClient.on !== "function" || typeof this.rpcClient.off !== "function") {
|
|
135
|
+
throw serviceUnavailable("管理端 outbound 监听未配置");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const remoteState = JSON.parse(row.remote_state_json || "{}");
|
|
139
|
+
const inboundTopic = firstText(
|
|
140
|
+
remoteState?.inboundTopic,
|
|
141
|
+
remoteState?.["inbound-topic"],
|
|
142
|
+
this.env?.mqtt?.agentInboundTopicTemplate?.replaceAll("{agentId}", normalizedAgentId)
|
|
143
|
+
);
|
|
144
|
+
const outboundTopic = firstText(
|
|
145
|
+
remoteState?.outboundTopic,
|
|
146
|
+
remoteState?.["outbound-topic"],
|
|
147
|
+
this.env?.mqtt?.agentOutboundTopicTemplate?.replaceAll("{agentId}", normalizedAgentId)
|
|
148
|
+
);
|
|
149
|
+
const startedAt = Date.now();
|
|
150
|
+
|
|
151
|
+
return await new Promise((resolve, reject) => {
|
|
152
|
+
const cleanup = () => {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
this.rpcClient.off("outbound_message", handleMessage);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const handleMessage = (message) => {
|
|
158
|
+
if (message?.request?.action !== "agent.create") {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const requestedAgentId = String(message?.request?.params?.agentId || "").trim();
|
|
163
|
+
if (requestedAgentId !== normalizedAgentId) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
cleanup();
|
|
168
|
+
resolve({
|
|
169
|
+
ok: true,
|
|
170
|
+
waitedMs: Date.now() - startedAt,
|
|
171
|
+
inboundTopic,
|
|
172
|
+
outboundTopic,
|
|
173
|
+
reply: message
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const timer = setTimeout(() => {
|
|
178
|
+
cleanup();
|
|
179
|
+
reject(serviceUnavailable(`数字员工 ${normalizedAgentId} 在超时时间内仍未进入运行态`, {
|
|
180
|
+
agentId: normalizedAgentId,
|
|
181
|
+
timeoutMs
|
|
182
|
+
}));
|
|
183
|
+
}, timeoutMs);
|
|
184
|
+
|
|
185
|
+
this.rpcClient.on("outbound_message", handleMessage);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async pingTopics({ inboundTopic, outboundTopic, target }, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
190
|
+
if (!this.isConfigured()) {
|
|
191
|
+
throw serviceUnavailable("MQTT broker 未配置");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const connect = this.mqttFactory.connect || this.mqttFactory;
|
|
195
|
+
const client = connect(this.env.mqtt.brokerUrl, {
|
|
196
|
+
clientId: `aios-web-ping-${randomUUID().slice(0, 8)}`,
|
|
197
|
+
username: this.env.mqtt.username || undefined,
|
|
198
|
+
password: this.env.mqtt.password || undefined,
|
|
199
|
+
clean: true,
|
|
200
|
+
reconnectPeriod: 0
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const messageId = randomUUID().slice(0, 8).toUpperCase();
|
|
204
|
+
const sessionId = `topic-ping-${randomUUID()}`;
|
|
205
|
+
const payload = normalizeInboundPayload(messageId, sessionId);
|
|
206
|
+
const startedAt = Date.now();
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
await new Promise((resolve, reject) => {
|
|
210
|
+
const cleanup = () => {
|
|
211
|
+
client.off("connect", onConnect);
|
|
212
|
+
client.off("error", onError);
|
|
213
|
+
};
|
|
214
|
+
const onConnect = () => {
|
|
215
|
+
cleanup();
|
|
216
|
+
resolve();
|
|
217
|
+
};
|
|
218
|
+
const onError = (error) => {
|
|
219
|
+
cleanup();
|
|
220
|
+
reject(error);
|
|
221
|
+
};
|
|
222
|
+
client.once("connect", onConnect);
|
|
223
|
+
client.once("error", onError);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await new Promise((resolve, reject) => {
|
|
227
|
+
client.subscribe(outboundTopic, { qos: 1 }, (error) => {
|
|
228
|
+
if (error) {
|
|
229
|
+
reject(error);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
resolve();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const result = await new Promise((resolve, reject) => {
|
|
237
|
+
const timer = setTimeout(() => {
|
|
238
|
+
client.off("message", onMessage);
|
|
239
|
+
reject(serviceUnavailable(`Ping 超时:${target}`, { responseTimeMs: Date.now() - startedAt }));
|
|
240
|
+
}, timeoutMs);
|
|
241
|
+
|
|
242
|
+
const onMessage = (_topic, raw) => {
|
|
243
|
+
try {
|
|
244
|
+
const message = JSON.parse(raw.toString("utf8"));
|
|
245
|
+
if (!matchesPingReply(message, sessionId, messageId)) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
client.off("message", onMessage);
|
|
251
|
+
resolve({
|
|
252
|
+
ok: true,
|
|
253
|
+
responseTimeMs: Date.now() - startedAt,
|
|
254
|
+
inboundTopic,
|
|
255
|
+
outboundTopic,
|
|
256
|
+
sessionId,
|
|
257
|
+
reply: message
|
|
258
|
+
});
|
|
259
|
+
} catch {
|
|
260
|
+
// Ignore unrelated malformed messages.
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
client.on("message", onMessage);
|
|
265
|
+
client.publish(inboundTopic, JSON.stringify(payload), { qos: 1, retain: false }, (error) => {
|
|
266
|
+
if (error) {
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
client.off("message", onMessage);
|
|
269
|
+
reject(serviceUnavailable("发送 ping 失败", {
|
|
270
|
+
cause: error,
|
|
271
|
+
responseTimeMs: Date.now() - startedAt
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return result;
|
|
278
|
+
} finally {
|
|
279
|
+
await new Promise((resolve) => client.end(false, {}, () => resolve()));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export class AppError extends Error {
|
|
2
|
+
constructor(status, code, message, details) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.status = status;
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.details = details;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function badRequest(message, details) {
|
|
11
|
+
return new AppError(400, "bad_request", message, details);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function unauthorized(message = "未授权访问") {
|
|
15
|
+
return new AppError(401, "unauthorized", message);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function forbidden(message = "无权限执行此操作") {
|
|
19
|
+
return new AppError(403, "forbidden", message);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function notFound(message) {
|
|
23
|
+
return new AppError(404, "not_found", message);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function conflict(message, details) {
|
|
27
|
+
return new AppError(409, "conflict", message, details);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function internalError(message, details) {
|
|
31
|
+
return new AppError(500, "internal_error", message, details);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function serviceUnavailable(message, details) {
|
|
35
|
+
return new AppError(503, "service_unavailable", message, details);
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function hashPassword(password) {
|
|
4
|
+
const salt = randomBytes(16).toString("hex");
|
|
5
|
+
const hash = scryptSync(password, salt, 64).toString("hex");
|
|
6
|
+
return `scrypt$${salt}$${hash}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function verifyPassword(password, storedHash) {
|
|
10
|
+
if (!storedHash || !storedHash.startsWith("scrypt$")) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const [, salt, expectedHash] = storedHash.split("$");
|
|
15
|
+
const actualHash = scryptSync(password, salt, 64);
|
|
16
|
+
const expected = Buffer.from(expectedHash, "hex");
|
|
17
|
+
return expected.length === actualHash.length && timingSafeEqual(expected, actualHash);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function newToken() {
|
|
21
|
+
return randomBytes(32).toString("hex");
|
|
22
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "aios-web-agent-test-"));
|
|
6
|
+
process.env.AIOS_WEB_DATA_DIR = dataDir;
|
|
7
|
+
const { AgentService } = await import("../src/services/agent-service.js");
|
|
8
|
+
|
|
9
|
+
function createDb(agentRow) {
|
|
10
|
+
return {
|
|
11
|
+
prepare(sql) {
|
|
12
|
+
if (sql.includes("WHERE status = 'normal'")) {
|
|
13
|
+
return {
|
|
14
|
+
all: () => [agentRow]
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (sql.includes("SELECT * FROM agents ORDER BY updated_at DESC")) {
|
|
19
|
+
return {
|
|
20
|
+
all: () => [agentRow]
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (sql.includes("UPDATE agents") && sql.includes("SET agent_name = ?, status = ?, remote_state_json = ?, updated_at = ?")) {
|
|
25
|
+
return {
|
|
26
|
+
run: (agentName, status, remoteStateJson, updatedAt, id) => {
|
|
27
|
+
if (agentRow.id === id) {
|
|
28
|
+
agentRow.agent_name = agentName;
|
|
29
|
+
agentRow.status = status;
|
|
30
|
+
agentRow.remote_state_json = remoteStateJson;
|
|
31
|
+
agentRow.updated_at = updatedAt;
|
|
32
|
+
}
|
|
33
|
+
return { changes: agentRow.id === id ? 1 : 0 };
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (sql.includes("SELECT u.id, u.username, u.display_name")) {
|
|
39
|
+
return {
|
|
40
|
+
all: () => []
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (sql.includes("SELECT s.slug")) {
|
|
45
|
+
return {
|
|
46
|
+
all: () => []
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
get: () => null,
|
|
52
|
+
all: () => [],
|
|
53
|
+
run: () => ({ lastInsertRowid: 1 })
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it("treats -1 quotas as unlimited when computing health", async () => {
|
|
60
|
+
const service = new AgentService({
|
|
61
|
+
db: createDb({
|
|
62
|
+
id: 1,
|
|
63
|
+
slug: "agent-a",
|
|
64
|
+
agent_name: "Agent A",
|
|
65
|
+
description: "demo",
|
|
66
|
+
docs_content: "",
|
|
67
|
+
template_name: "default",
|
|
68
|
+
status: "normal",
|
|
69
|
+
tags_json: "[]",
|
|
70
|
+
daily_limit: -1,
|
|
71
|
+
usage_snapshot_json: JSON.stringify({
|
|
72
|
+
usage: {
|
|
73
|
+
daily: 0,
|
|
74
|
+
weekly: 0,
|
|
75
|
+
monthly: 0,
|
|
76
|
+
total: 0
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
remote_state_json: JSON.stringify({
|
|
80
|
+
agentId: "agent-a",
|
|
81
|
+
templateName: "default"
|
|
82
|
+
}),
|
|
83
|
+
created_at: "2026-05-26T00:00:00.000Z",
|
|
84
|
+
updated_at: "2026-05-26T00:00:00.000Z"
|
|
85
|
+
}),
|
|
86
|
+
rpcClient: {},
|
|
87
|
+
objectStorage: {}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const [agent] = await service.listAgents();
|
|
91
|
+
expect(agent.health).toBe("normal");
|
|
92
|
+
expect(agent.agent_name).toBe("Agent A");
|
|
93
|
+
expect(agent.usage).toEqual({
|
|
94
|
+
daily: 0
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("reports normal health for normal agents", async () => {
|
|
99
|
+
const service = new AgentService({
|
|
100
|
+
db: createDb({
|
|
101
|
+
id: 3,
|
|
102
|
+
slug: "normal-agent",
|
|
103
|
+
agent_name: "Normal Agent",
|
|
104
|
+
description: "",
|
|
105
|
+
docs_content: "",
|
|
106
|
+
template_name: "default",
|
|
107
|
+
status: "normal",
|
|
108
|
+
tags_json: "[]",
|
|
109
|
+
daily_limit: -1,
|
|
110
|
+
usage_snapshot_json: "{}",
|
|
111
|
+
remote_state_json: "{}",
|
|
112
|
+
created_at: "2026-05-26T00:00:00.000Z",
|
|
113
|
+
updated_at: "2026-05-26T00:00:00.000Z"
|
|
114
|
+
}),
|
|
115
|
+
rpcClient: {},
|
|
116
|
+
objectStorage: {}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const [agent] = await service.listAgents();
|
|
120
|
+
expect(agent.health).toBe("normal");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("rejects setting an over-quota agent back to normal", async () => {
|
|
124
|
+
const agentRow = {
|
|
125
|
+
id: 6,
|
|
126
|
+
slug: "quota-agent",
|
|
127
|
+
agent_name: "Quota Agent",
|
|
128
|
+
description: "",
|
|
129
|
+
docs_content: "",
|
|
130
|
+
template_name: "default",
|
|
131
|
+
status: "overlimit",
|
|
132
|
+
tags_json: "[]",
|
|
133
|
+
daily_limit: 10,
|
|
134
|
+
usage_snapshot_json: JSON.stringify({
|
|
135
|
+
usage: { daily: 12, weekly: 0, monthly: 0, total: 0 }
|
|
136
|
+
}),
|
|
137
|
+
remote_state_json: "{}",
|
|
138
|
+
created_at: "2026-05-26T00:00:00.000Z",
|
|
139
|
+
updated_at: "2026-05-26T00:00:00.000Z"
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const db = createDb(agentRow);
|
|
143
|
+
const service = new AgentService({
|
|
144
|
+
db: {
|
|
145
|
+
prepare(sql) {
|
|
146
|
+
if (sql.includes("SELECT * FROM agents WHERE id = ?")) {
|
|
147
|
+
return {
|
|
148
|
+
get: () => agentRow
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return db.prepare(sql);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
rpcClient: {
|
|
155
|
+
async call() {
|
|
156
|
+
throw new Error("should not update runtime while quota is exceeded");
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
objectStorage: {}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await expectAsync(service.updateAgent(6, { status: "normal" })).toBeRejectedWithError(/daily quota exceeded/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("finds a single agent by slug", async () => {
|
|
166
|
+
const service = new AgentService({
|
|
167
|
+
db: createDb({
|
|
168
|
+
id: 3,
|
|
169
|
+
slug: "normal-agent",
|
|
170
|
+
agent_name: "Normal Agent",
|
|
171
|
+
description: "",
|
|
172
|
+
docs_content: "",
|
|
173
|
+
template_name: "default",
|
|
174
|
+
status: "normal",
|
|
175
|
+
tags_json: "[]",
|
|
176
|
+
daily_limit: -1,
|
|
177
|
+
usage_snapshot_json: JSON.stringify({
|
|
178
|
+
usage: { daily: 0, weekly: 0, monthly: 0, total: 0 }
|
|
179
|
+
}),
|
|
180
|
+
remote_state_json: JSON.stringify({
|
|
181
|
+
agentId: "normal-agent",
|
|
182
|
+
status: "normal"
|
|
183
|
+
}),
|
|
184
|
+
created_at: "2026-05-26T00:00:00.000Z",
|
|
185
|
+
updated_at: "2026-05-26T00:00:00.000Z"
|
|
186
|
+
}),
|
|
187
|
+
rpcClient: {},
|
|
188
|
+
objectStorage: {}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const agent = await service.getAgentBySlug("normal-agent");
|
|
192
|
+
expect(agent.slug).toBe("normal-agent");
|
|
193
|
+
expect(agent.status).toBe("normal");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("refreshes a single agent from remote status before returning it", async () => {
|
|
197
|
+
const service = new AgentService({
|
|
198
|
+
db: createDb({
|
|
199
|
+
id: 4,
|
|
200
|
+
slug: "created-agent",
|
|
201
|
+
agent_name: "Created Agent",
|
|
202
|
+
description: "",
|
|
203
|
+
docs_content: "",
|
|
204
|
+
template_name: "default",
|
|
205
|
+
status: "normal",
|
|
206
|
+
tags_json: "[]",
|
|
207
|
+
daily_limit: -1,
|
|
208
|
+
usage_snapshot_json: JSON.stringify({
|
|
209
|
+
usage: { daily: 0, weekly: 0, monthly: 0, total: 0 }
|
|
210
|
+
}),
|
|
211
|
+
remote_state_json: "{}",
|
|
212
|
+
created_at: "2026-05-26T00:00:00.000Z",
|
|
213
|
+
updated_at: "2026-05-26T00:00:00.000Z"
|
|
214
|
+
}),
|
|
215
|
+
rpcClient: {
|
|
216
|
+
isConfigured() {
|
|
217
|
+
return true;
|
|
218
|
+
},
|
|
219
|
+
async call(action, params) {
|
|
220
|
+
if (action === "agent.get") {
|
|
221
|
+
expect(params).toEqual({ agentId: "created-agent" });
|
|
222
|
+
return {
|
|
223
|
+
agentId: "created-agent",
|
|
224
|
+
name: "Created Agent",
|
|
225
|
+
status: "active"
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return { ok: true };
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
objectStorage: {}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const agent = await service.getAgentBySlug("created-agent");
|
|
235
|
+
expect(agent.status).toBe("normal");
|
|
236
|
+
expect(agent.remote_state).toEqual({
|
|
237
|
+
agentId: "created-agent",
|
|
238
|
+
name: "Created Agent",
|
|
239
|
+
status: "active"
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("builds active agent directory for cui consumers", async () => {
|
|
244
|
+
const service = new AgentService({
|
|
245
|
+
db: createDb({
|
|
246
|
+
id: 2,
|
|
247
|
+
slug: "finance-agent",
|
|
248
|
+
agent_name: "Finance Agent",
|
|
249
|
+
description: "",
|
|
250
|
+
docs_content: "",
|
|
251
|
+
template_name: "default",
|
|
252
|
+
status: "normal",
|
|
253
|
+
tags_json: "[]",
|
|
254
|
+
daily_limit: -1,
|
|
255
|
+
usage_snapshot_json: "{}",
|
|
256
|
+
remote_state_json: JSON.stringify({
|
|
257
|
+
name: "Finance Agent Remote",
|
|
258
|
+
inboundTopic: "topic/in",
|
|
259
|
+
"outbound-topic": "topic/out"
|
|
260
|
+
}),
|
|
261
|
+
created_at: "2026-05-26T00:00:00.000Z",
|
|
262
|
+
updated_at: "2026-05-26T00:00:00.000Z"
|
|
263
|
+
}),
|
|
264
|
+
rpcClient: {},
|
|
265
|
+
objectStorage: {}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
service.getPermissions = () => ([
|
|
269
|
+
{ username: "zhangsan" },
|
|
270
|
+
{ username: "lisi" }
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
const items = await service.listActiveAgentDirectory();
|
|
274
|
+
expect(items).toEqual([{
|
|
275
|
+
agent_id: "finance-agent",
|
|
276
|
+
agent_name: "Finance Agent",
|
|
277
|
+
status: "normal",
|
|
278
|
+
inbound_topic: "topic/in",
|
|
279
|
+
outbound_topic: "topic/out",
|
|
280
|
+
users: ["zhangsan", "lisi"]
|
|
281
|
+
}]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("only reports daily token usage fields", async () => {
|
|
285
|
+
const service = new AgentService({
|
|
286
|
+
db: createDb({
|
|
287
|
+
id: 5,
|
|
288
|
+
slug: "usage-agent",
|
|
289
|
+
agent_name: "Usage Agent",
|
|
290
|
+
description: "",
|
|
291
|
+
docs_content: "",
|
|
292
|
+
template_name: "default",
|
|
293
|
+
status: "normal",
|
|
294
|
+
tags_json: "[]",
|
|
295
|
+
daily_limit: -1,
|
|
296
|
+
usage_snapshot_json: JSON.stringify({
|
|
297
|
+
usage: {
|
|
298
|
+
daily: 12,
|
|
299
|
+
weekly: 34,
|
|
300
|
+
month: 5,
|
|
301
|
+
total: 56
|
|
302
|
+
}
|
|
303
|
+
}),
|
|
304
|
+
remote_state_json: "{}",
|
|
305
|
+
created_at: "2026-05-26T00:00:00.000Z",
|
|
306
|
+
updated_at: "2026-05-26T00:00:00.000Z"
|
|
307
|
+
}),
|
|
308
|
+
rpcClient: {},
|
|
309
|
+
objectStorage: {}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const [agent] = await service.listAgents();
|
|
313
|
+
expect(agent.usage).toEqual({
|
|
314
|
+
daily: 12
|
|
315
|
+
});
|
|
316
|
+
});
|