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.
Files changed (91) hide show
  1. package/.env.json +21 -0
  2. package/README.md +257 -0
  3. package/data/management-console.db +0 -0
  4. package/data/management-console.db-shm +0 -0
  5. package/data/management-console.db-wal +0 -0
  6. package/dist/assets/index-CV_wjCAG.js +464 -0
  7. package/dist/assets/index-DfMPB0eV.css +1 -0
  8. package/dist/index.html +13 -0
  9. package/docs/spec.md +199 -0
  10. package/index.html +12 -0
  11. package/package.json +37 -0
  12. package/scripts/reset-kernel.js +59 -0
  13. package/scripts/reset-password.js +22 -0
  14. package/server/fakes.js +57 -0
  15. package/server/index.js +21 -0
  16. package/server/src/api/middleware/auth.js +29 -0
  17. package/server/src/api/middleware/internal.js +44 -0
  18. package/server/src/api/routes/index.js +677 -0
  19. package/server/src/app.js +90 -0
  20. package/server/src/background/index.js +106 -0
  21. package/server/src/background/protocol.js +15 -0
  22. package/server/src/config/env.js +90 -0
  23. package/server/src/db/index.js +501 -0
  24. package/server/src/infra/mqtt/management-rpc-client.js +213 -0
  25. package/server/src/infra/providers/hzg-provider-client.js +39 -0
  26. package/server/src/infra/s3/object-storage.js +97 -0
  27. package/server/src/services/agent-quota.js +54 -0
  28. package/server/src/services/agent-service.js +696 -0
  29. package/server/src/services/agent-status-sync-service.js +132 -0
  30. package/server/src/services/audit-log-service.js +39 -0
  31. package/server/src/services/auth-service.js +153 -0
  32. package/server/src/services/catalog-sync-service.js +712 -0
  33. package/server/src/services/external-service.js +308 -0
  34. package/server/src/services/kernel-reset-service.js +86 -0
  35. package/server/src/services/portal-service.js +555 -0
  36. package/server/src/services/system-service.js +580 -0
  37. package/server/src/services/topic-ping-service.js +282 -0
  38. package/server/src/utils/errors.js +36 -0
  39. package/server/src/utils/security.js +22 -0
  40. package/server/test/agent-service-alignment.test.js +316 -0
  41. package/server/test/agent-service-create.test.js +662 -0
  42. package/server/test/agent-status-sync-service.test.js +167 -0
  43. package/server/test/agent-update-audit.test.js +63 -0
  44. package/server/test/auth-middleware.test.js +71 -0
  45. package/server/test/background-services.test.js +160 -0
  46. package/server/test/catalog-sync-service.test.js +920 -0
  47. package/server/test/db-reset-migration.test.js +123 -0
  48. package/server/test/env-config.test.js +68 -0
  49. package/server/test/external-service.test.js +380 -0
  50. package/server/test/hzg-provider-client.test.js +50 -0
  51. package/server/test/internal-auth-middleware.test.js +66 -0
  52. package/server/test/kernel-reset-service.test.js +112 -0
  53. package/server/test/management-rpc-client.test.js +105 -0
  54. package/server/test/portal-service-access-tokens.test.js +121 -0
  55. package/server/test/portal-service-alignment.test.js +318 -0
  56. package/server/test/portal-service-management-logs.test.js +114 -0
  57. package/server/test/reset-kernel-cli.test.js +23 -0
  58. package/server/test/service-api-auth-middleware.test.js +59 -0
  59. package/server/test/system-service-alignment.test.js +265 -0
  60. package/server/test/topic-ping-service.test.js +182 -0
  61. package/server/test/usage-refresh-audit-route.test.js +82 -0
  62. package/src/App.jsx +1 -0
  63. package/src/api.js +1 -0
  64. package/src/app/App.jsx +346 -0
  65. package/src/app/api-client.js +112 -0
  66. package/src/components/AppShell.jsx +117 -0
  67. package/src/components/CardTitleWithReload.jsx +20 -0
  68. package/src/components/DeleteActionButton.jsx +31 -0
  69. package/src/main.jsx +14 -0
  70. package/src/pages/AgentsPage.jsx +647 -0
  71. package/src/pages/AiosUsersPage.jsx +151 -0
  72. package/src/pages/DashboardPage.jsx +72 -0
  73. package/src/pages/LoginPage.jsx +41 -0
  74. package/src/pages/SettingsPage.jsx +431 -0
  75. package/src/pages/SkillsPage.jsx +175 -0
  76. package/src/pages/SystemLogsPage.jsx +349 -0
  77. package/src/pages/SystemsPage.jsx +498 -0
  78. package/src/pages/TemplatesPage.jsx +207 -0
  79. package/src/pages/UserManagementPage.jsx +25 -0
  80. package/src/pages/UsersPage.jsx +192 -0
  81. package/src/pages/system-logs/SystemLogsTabs.jsx +362 -0
  82. package/src/styles.css +222 -0
  83. package/src/utils/format.js +63 -0
  84. package/test/.reports/fast-2026-05-25T08-32-39-420Z.json +299 -0
  85. package/test/integration/common.js +208 -0
  86. package/test/integration/fast.js +135 -0
  87. package/test/integration/full.js +306 -0
  88. package/test/run-browser-e2e.js +212 -0
  89. package/test/run-jasmine.js +21 -0
  90. package/test/setup.js +1 -0
  91. package/vite.config.js +12 -0
@@ -0,0 +1,114 @@
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-portal-test-"));
6
+ process.env.AIOS_WEB_DATA_DIR = dataDir;
7
+ const { PortalService } = await import("../src/services/portal-service.js");
8
+
9
+ function createDb(rows) {
10
+ return {
11
+ prepare(sql) {
12
+ return {
13
+ get() {
14
+ if (sql.includes("COUNT(*) AS count FROM management_requests")) {
15
+ return { count: rows.length };
16
+ }
17
+ return null;
18
+ },
19
+ all(limit, offset) {
20
+ if (sql.includes("FROM management_requests")) {
21
+ return rows.slice(offset, offset + limit);
22
+ }
23
+ return [];
24
+ },
25
+ run() {
26
+ return { lastInsertRowid: 1 };
27
+ }
28
+ };
29
+ }
30
+ };
31
+ }
32
+
33
+ it("lists kernel management requests with parsed payloads and response time", () => {
34
+ const service = new PortalService({
35
+ db: createDb([
36
+ {
37
+ id: 2,
38
+ request_id: "req-2",
39
+ action: "gateway.status",
40
+ params_json: "{\"verbose\":true}",
41
+ ok: 1,
42
+ result_json: "{\"status\":\"ok\"}",
43
+ error_json: null,
44
+ created_at: "2026-05-28T01:00:00.000Z",
45
+ completed_at: "2026-05-28T01:00:01.250Z"
46
+ },
47
+ {
48
+ id: 1,
49
+ request_id: "req-1",
50
+ action: "agent.list",
51
+ params_json: "{}",
52
+ ok: 0,
53
+ result_json: null,
54
+ error_json: "{\"message\":\"failed\"}",
55
+ created_at: "2026-05-28T00:00:00.000Z",
56
+ completed_at: "2026-05-28T00:00:00.400Z"
57
+ }
58
+ ]),
59
+ objectStorage: {},
60
+ rpcClient: {},
61
+ authService: {}
62
+ });
63
+
64
+ const result = service.listManagementRequests({ page: 1, pageSize: 1 });
65
+
66
+ expect(result.total).toBe(2);
67
+ expect(result.page).toBe(1);
68
+ expect(result.pageSize).toBe(1);
69
+ expect(result.items).toEqual([
70
+ jasmine.objectContaining({
71
+ request_id: "req-2",
72
+ action: "gateway.status",
73
+ ok: true,
74
+ params: { verbose: true },
75
+ result: { status: "ok" },
76
+ error: null,
77
+ response_time_ms: 1250
78
+ })
79
+ ]);
80
+ });
81
+
82
+ it("keeps pending requests unresolved and returns null duration when incomplete", () => {
83
+ const service = new PortalService({
84
+ db: createDb([
85
+ {
86
+ id: 3,
87
+ request_id: "req-pending",
88
+ action: "diagnostics.run",
89
+ params_json: "{\"deep\":true}",
90
+ ok: null,
91
+ result_json: null,
92
+ error_json: null,
93
+ created_at: "2026-05-28T02:00:00.000Z",
94
+ completed_at: null
95
+ }
96
+ ]),
97
+ objectStorage: {},
98
+ rpcClient: {},
99
+ authService: {}
100
+ });
101
+
102
+ const result = service.listManagementRequests();
103
+
104
+ expect(result.items).toEqual([
105
+ jasmine.objectContaining({
106
+ request_id: "req-pending",
107
+ ok: null,
108
+ params: { deep: true },
109
+ result: null,
110
+ error: null,
111
+ response_time_ms: null
112
+ })
113
+ ]);
114
+ });
@@ -0,0 +1,23 @@
1
+ import { parseResetKernelArgs } from "../../scripts/reset-kernel.js";
2
+
3
+ it("parses dry-run and confirm flags for kernel reset cli", () => {
4
+ expect(parseResetKernelArgs([])).toEqual({
5
+ dryRun: false,
6
+ confirmed: false
7
+ });
8
+
9
+ expect(parseResetKernelArgs(["--dry-run"])).toEqual({
10
+ dryRun: true,
11
+ confirmed: false
12
+ });
13
+
14
+ expect(parseResetKernelArgs(["--confirm-reset"])).toEqual({
15
+ dryRun: false,
16
+ confirmed: true
17
+ });
18
+
19
+ expect(parseResetKernelArgs(["--confirm-reset", "--dry-run"])).toEqual({
20
+ dryRun: true,
21
+ confirmed: true
22
+ });
23
+ });
@@ -0,0 +1,59 @@
1
+ import { createServiceApiAuthMiddleware } from "../src/api/middleware/internal.js";
2
+
3
+ function runMiddleware(middleware, { headers = {}, remoteAddress = "" } = {}) {
4
+ return new Promise((resolve) => {
5
+ middleware({
6
+ headers,
7
+ socket: {
8
+ remoteAddress
9
+ }
10
+ }, {}, (error) => resolve(error ?? null));
11
+ });
12
+ }
13
+
14
+ it("bypasses service api auth for localhost requests", async () => {
15
+ const middleware = createServiceApiAuthMiddleware({
16
+ hasAccessToken() {
17
+ return false;
18
+ }
19
+ });
20
+
21
+ const ipv4Error = await runMiddleware(middleware, {
22
+ remoteAddress: "127.0.0.1"
23
+ });
24
+ expect(ipv4Error).toBeNull();
25
+
26
+ const ipv6Error = await runMiddleware(middleware, {
27
+ remoteAddress: "::1"
28
+ });
29
+ expect(ipv6Error).toBeNull();
30
+ });
31
+
32
+ it("requires bearer token for non-local service api requests", async () => {
33
+ const middleware = createServiceApiAuthMiddleware({
34
+ hasAccessToken(token) {
35
+ return token === "sk-abcdef";
36
+ }
37
+ });
38
+
39
+ const missingError = await runMiddleware(middleware, {
40
+ remoteAddress: "10.0.0.8"
41
+ });
42
+ expect(missingError.status).toBe(401);
43
+
44
+ const invalidError = await runMiddleware(middleware, {
45
+ remoteAddress: "10.0.0.8",
46
+ headers: {
47
+ authorization: "Bearer sk-other"
48
+ }
49
+ });
50
+ expect(invalidError.status).toBe(403);
51
+
52
+ const okError = await runMiddleware(middleware, {
53
+ remoteAddress: "10.0.0.8",
54
+ headers: {
55
+ authorization: "Bearer sk-abcdef"
56
+ }
57
+ });
58
+ expect(okError).toBeNull();
59
+ });
@@ -0,0 +1,265 @@
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-system-test-"));
6
+ process.env.AIOS_WEB_DATA_DIR = dataDir;
7
+ const { SystemService } = await import("../src/services/system-service.js");
8
+
9
+ function createDb({ currentSystem = null, createdSystem = null } = {}) {
10
+ const statements = [];
11
+ return {
12
+ statements,
13
+ prepare(sql) {
14
+ return {
15
+ run: (...args) => {
16
+ statements.push({ sql, args });
17
+ return { lastInsertRowid: 1 };
18
+ },
19
+ get: (...args) => {
20
+ if (sql.includes("FROM artifacts")) {
21
+ return currentSystem?.ontology_artifact_id ? {
22
+ bucket: "admin-in",
23
+ object_key: "ontology/existing.zip"
24
+ } : null;
25
+ }
26
+ if (sql.includes("WHERE application_name = ?")) {
27
+ return null;
28
+ }
29
+ if (sql.includes("FROM business_systems s") || sql.includes("SELECT * FROM business_systems WHERE id = ?")) {
30
+ if (Number(args[0]) === 1 && createdSystem) {
31
+ return createdSystem;
32
+ }
33
+ return currentSystem;
34
+ }
35
+ return null;
36
+ },
37
+ all: () => []
38
+ };
39
+ }
40
+ };
41
+ }
42
+
43
+ it("maps website fields to management-service apps.create params", async () => {
44
+ const createdSystem = {
45
+ id: 1,
46
+ provider: "hzg",
47
+ application_name: "demo-app",
48
+ description: "desc",
49
+ ontology_artifact_id: 9,
50
+ scheme: "https",
51
+ host: "example.com",
52
+ port: 443,
53
+ status: "active",
54
+ last_connectivity_test_status: "unknown",
55
+ last_connectivity_test_result_json: "{}",
56
+ updated_at: "2026-05-27T00:00:00.000Z"
57
+ };
58
+ const db = createDb({ createdSystem });
59
+ const rpcCalls = [];
60
+ const objectStorage = {
61
+ async uploadAdminArtifact() {
62
+ return {
63
+ bucket: "admin-in",
64
+ objectKey: "ontology/demo.zip"
65
+ };
66
+ }
67
+ };
68
+ const service = new SystemService({
69
+ db,
70
+ objectStorage,
71
+ rpcClient: {
72
+ async call(action, params) {
73
+ rpcCalls.push({ action, params });
74
+ if (action === "apps.list") {
75
+ return {
76
+ items: [{
77
+ id: "demo-app"
78
+ }]
79
+ };
80
+ }
81
+ return {};
82
+ }
83
+ }
84
+ });
85
+
86
+ const zipBuffer = Buffer.from("PK\u0005\u0006" + "\u0000".repeat(18), "binary");
87
+
88
+ await expectAsync(service.createSystem({
89
+ payload: {
90
+ provider: "hzg",
91
+ application_name: "demo-app"
92
+ },
93
+ file: null,
94
+ createdBy: 1
95
+ })).toBeRejectedWithError(/协议/);
96
+
97
+ service.persistOntologyArtifact = async () => ({
98
+ id: 9,
99
+ bucket: "admin-in",
100
+ objectKey: "ontology/demo.zip"
101
+ });
102
+
103
+ const result = await service.createSystem({
104
+ payload: {
105
+ provider: "hzg",
106
+ application_name: "demo-app",
107
+ description: "desc",
108
+ scheme: "https",
109
+ host: "example.com",
110
+ port: 443,
111
+ status: "active"
112
+ },
113
+ file: {
114
+ buffer: zipBuffer,
115
+ originalname: "demo.zip",
116
+ mimetype: "application/zip",
117
+ size: zipBuffer.length
118
+ },
119
+ createdBy: 1
120
+ });
121
+
122
+ expect(result.system.application_name).toBe("demo-app");
123
+ expect(result.system.base_url).toBe("https://example.com:443/demo-app");
124
+ expect(result.kernelOntologyDeleted).toBe(true);
125
+ expect(result.deletedKernelOntologyNames).toEqual(["demo-app"]);
126
+ expect(rpcCalls.length).toBe(3);
127
+ expect(rpcCalls[0]).toEqual({
128
+ action: "apps.list",
129
+ params: {}
130
+ });
131
+ expect(rpcCalls[1]).toEqual({
132
+ action: "apps.delete",
133
+ params: {
134
+ systemId: "demo-app"
135
+ }
136
+ });
137
+ expect(rpcCalls[2].action).toBe("apps.create");
138
+ expect(rpcCalls[2].params).toEqual({
139
+ systemId: "demo-app",
140
+ name: "demo-app",
141
+ provider: "hzg",
142
+ ontologyName: "demo-app",
143
+ description: "desc",
144
+ applicationEndpoint: "https://example.com:443/demo-app",
145
+ sessionServiceEndpoint: "https://example.com:443/demo-app",
146
+ status: "active",
147
+ bucket: "admin-in",
148
+ objectKey: "ontology/demo.zip",
149
+ replaceOntology: true
150
+ });
151
+ });
152
+
153
+ it("maps scheme/host/port fields to apps.update params", async () => {
154
+ const currentSystem = {
155
+ id: 1,
156
+ provider: "hzg",
157
+ application_name: "demo-app",
158
+ description: "old desc",
159
+ ontology_artifact_id: 7,
160
+ scheme: "https",
161
+ host: "old.example.com",
162
+ port: 443,
163
+ status: "disabled"
164
+ };
165
+ const updatedSystem = {
166
+ ...currentSystem,
167
+ description: "new desc",
168
+ host: "new.example.com",
169
+ status: "active"
170
+ };
171
+ const db = createDb({ currentSystem, createdSystem: updatedSystem });
172
+ const rpcCalls = [];
173
+ const service = new SystemService({
174
+ db,
175
+ objectStorage: {},
176
+ rpcClient: {
177
+ async call(action, params) {
178
+ rpcCalls.push({ action, params });
179
+ if (action === "apps.list") {
180
+ return {
181
+ items: [{
182
+ id: "demo-app"
183
+ }]
184
+ };
185
+ }
186
+ return {};
187
+ }
188
+ }
189
+ });
190
+
191
+ const result = await service.updateSystem(1, {
192
+ payload: {
193
+ description: "new desc",
194
+ host: "new.example.com",
195
+ status: "active"
196
+ },
197
+ file: null,
198
+ createdBy: 1
199
+ });
200
+
201
+ expect(result.system.application_name).toBe("demo-app");
202
+ expect(result.kernelOntologyDeleted).toBe(true);
203
+ expect(result.deletedKernelOntologyNames).toEqual(["demo-app"]);
204
+ expect(rpcCalls.length).toBe(3);
205
+ expect(rpcCalls[0]).toEqual({
206
+ action: "apps.list",
207
+ params: {}
208
+ });
209
+ expect(rpcCalls[1]).toEqual({
210
+ action: "apps.delete",
211
+ params: {
212
+ systemId: "demo-app"
213
+ }
214
+ });
215
+ expect(rpcCalls[2].action).toBe("apps.create");
216
+ expect(rpcCalls[2].params).toEqual({
217
+ systemId: "demo-app",
218
+ name: "demo-app",
219
+ provider: "hzg",
220
+ ontologyName: "demo-app",
221
+ description: "new desc",
222
+ applicationEndpoint: "https://new.example.com:443/demo-app",
223
+ sessionServiceEndpoint: "https://new.example.com:443/demo-app",
224
+ status: "active",
225
+ bucket: "admin-in",
226
+ objectKey: "ontology/existing.zip",
227
+ replaceOntology: true
228
+ });
229
+ });
230
+
231
+ it("rejects deleting built-in ontology systems", async () => {
232
+ const currentSystem = {
233
+ id: 1,
234
+ provider: "hzg",
235
+ application_name: "demo-app",
236
+ description: "desc",
237
+ ontology_artifact_id: 7,
238
+ scheme: "https",
239
+ host: "example.com",
240
+ port: 443,
241
+ status: "active",
242
+ is_builtin: 1
243
+ };
244
+ const db = createDb({ currentSystem });
245
+ const rpcCalls = [];
246
+ const service = new SystemService({
247
+ db,
248
+ objectStorage: {},
249
+ rpcClient: {
250
+ async call(action, params) {
251
+ rpcCalls.push({ action, params });
252
+ return {};
253
+ }
254
+ }
255
+ });
256
+
257
+ try {
258
+ await service.deleteSystem(1);
259
+ fail("Expected deleteSystem to reject");
260
+ } catch (error) {
261
+ expect(error.status).toBe(409);
262
+ expect(error.code).toBe("conflict");
263
+ }
264
+ expect(rpcCalls).toEqual([]);
265
+ });
@@ -0,0 +1,182 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ const { TopicPingService } = await import("../src/services/topic-ping-service.js");
4
+
5
+ class MockMqttClient extends EventEmitter {
6
+ constructor() {
7
+ super();
8
+ this.subscriptions = [];
9
+ this.publications = [];
10
+ }
11
+
12
+ subscribe(topic, _options, callback) {
13
+ this.subscriptions.push(topic);
14
+ callback?.(null);
15
+ }
16
+
17
+ publish(topic, payload, _options, callback) {
18
+ this.publications.push({ topic, payload: JSON.parse(payload) });
19
+ callback?.(null);
20
+ queueMicrotask(() => {
21
+ const prompt = this.publications[0].payload.text;
22
+ const token = (prompt.match(/PONG ([A-Z0-9]+)/i)?.[1] || "").toUpperCase();
23
+ this.emit("message", this.subscriptions[0], Buffer.from(JSON.stringify({
24
+ sessionId: this.publications[0].payload.sessionId,
25
+ kind: "final",
26
+ text: `PONG ${token}`
27
+ }), "utf8"));
28
+ });
29
+ }
30
+
31
+ end(_force, _options, callback) {
32
+ callback?.();
33
+ }
34
+ }
35
+
36
+ it("pings admin topics and returns response time", async () => {
37
+ const client = new MockMqttClient();
38
+ const service = new TopicPingService({
39
+ db: {
40
+ prepare() {
41
+ return {
42
+ get() {
43
+ return null;
44
+ }
45
+ };
46
+ }
47
+ },
48
+ env: {
49
+ mqtt: {
50
+ brokerUrl: "mqtt://broker",
51
+ username: "",
52
+ password: "",
53
+ adminInboundTopic: "aios/admin/inbound",
54
+ adminOutboundTopic: "aios/admin/outbound",
55
+ agentInboundTopicTemplate: "aios/agent/{agentId}/inbound",
56
+ agentOutboundTopicTemplate: "aios/agent/{agentId}/outbound"
57
+ }
58
+ },
59
+ mqttFactory: {
60
+ connect() {
61
+ queueMicrotask(() => client.emit("connect"));
62
+ return client;
63
+ }
64
+ }
65
+ });
66
+
67
+ const result = await service.pingAdmin(1000);
68
+
69
+ expect(result.ok).toBe(true);
70
+ expect(result.inboundTopic).toBe("aios/admin/inbound");
71
+ expect(result.outboundTopic).toBe("aios/admin/outbound");
72
+ expect(typeof result.responseTimeMs).toBe("number");
73
+ expect(client.subscriptions).toEqual(["aios/admin/outbound"]);
74
+ expect(client.publications[0].topic).toBe("aios/admin/inbound");
75
+ });
76
+
77
+ it("waits until agent ping succeeds", async () => {
78
+ const service = new TopicPingService({
79
+ db: {
80
+ prepare() {
81
+ return {
82
+ get() {
83
+ return {
84
+ slug: "agent-a",
85
+ remote_state_json: JSON.stringify({
86
+ inboundTopic: "aios/agent/agent-a/inbound",
87
+ outboundTopic: "aios/agent/agent-a/outbound"
88
+ })
89
+ };
90
+ }
91
+ };
92
+ }
93
+ },
94
+ env: {
95
+ mqtt: {
96
+ brokerUrl: "mqtt://broker",
97
+ agentInboundTopicTemplate: "aios/agent/{agentId}/inbound",
98
+ agentOutboundTopicTemplate: "aios/agent/{agentId}/outbound"
99
+ }
100
+ }
101
+ });
102
+
103
+ let attempt = 0;
104
+ service.pingAgent = async () => {
105
+ attempt += 1;
106
+ if (attempt < 3) {
107
+ throw new Error("not ready");
108
+ }
109
+ return { ok: true, responseTimeMs: 12 };
110
+ };
111
+
112
+ const result = await service.waitForAgentReady("agent-a", {
113
+ timeoutMs: 1000,
114
+ intervalMs: 1,
115
+ pingTimeoutMs: 100
116
+ });
117
+
118
+ expect(result.ok).toBe(true);
119
+ expect(result.responseTimeMs).toBe(12);
120
+ expect(result.waitedMs).toEqual(jasmine.any(Number));
121
+ expect(attempt).toBe(3);
122
+ });
123
+
124
+ it("waits until agent runtime becomes active without pinging the model", async () => {
125
+ const listeners = new Map();
126
+ const service = new TopicPingService({
127
+ db: {
128
+ prepare() {
129
+ return {
130
+ get() {
131
+ return {
132
+ slug: "agent-a",
133
+ remote_state_json: JSON.stringify({
134
+ inboundTopic: "aios/agent/agent-a/inbound",
135
+ outboundTopic: "aios/agent/agent-a/outbound"
136
+ })
137
+ };
138
+ }
139
+ };
140
+ }
141
+ },
142
+ env: {
143
+ mqtt: {
144
+ brokerUrl: "mqtt://broker",
145
+ agentInboundTopicTemplate: "aios/agent/{agentId}/inbound",
146
+ agentOutboundTopicTemplate: "aios/agent/{agentId}/outbound"
147
+ }
148
+ },
149
+ rpcClient: {
150
+ on(event, handler) {
151
+ listeners.set(event, handler);
152
+ },
153
+ off(event, handler) {
154
+ if (listeners.get(event) === handler) {
155
+ listeners.delete(event);
156
+ }
157
+ }
158
+ }
159
+ });
160
+
161
+ setTimeout(() => {
162
+ listeners.get("outbound_message")?.({
163
+ requestId: "req-1",
164
+ ok: true,
165
+ request: {
166
+ action: "agent.create",
167
+ params: {
168
+ agentId: "agent-a"
169
+ }
170
+ }
171
+ });
172
+ }, 5);
173
+
174
+ const result = await service.waitForAgentRuntimeReady("agent-a", {
175
+ timeoutMs: 200
176
+ });
177
+
178
+ expect(result.ok).toBe(true);
179
+ expect(result.inboundTopic).toBe("aios/agent/agent-a/inbound");
180
+ expect(result.outboundTopic).toBe("aios/agent/agent-a/outbound");
181
+ expect(result.waitedMs).toEqual(jasmine.any(Number));
182
+ });