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,112 @@
1
+ import { KernelResetService } from "../src/services/kernel-reset-service.js";
2
+
3
+ it("resets kernel in agent-ontology-template order", async () => {
4
+ const calls = [];
5
+ const service = new KernelResetService({
6
+ rpcClient: {
7
+ async call(action, params) {
8
+ calls.push({ action, params });
9
+
10
+ if (action === "agent.list") {
11
+ return {
12
+ items: [
13
+ { agentId: "agent-a" },
14
+ { agentId: "agent-b" }
15
+ ]
16
+ };
17
+ }
18
+
19
+ if (action === "ontology.list") {
20
+ return {
21
+ items: [
22
+ { name: "crm" },
23
+ { name: "builtin-crm", "is-built-in": true }
24
+ ]
25
+ };
26
+ }
27
+
28
+ if (action === "agent.template.list") {
29
+ return {
30
+ items: [
31
+ { templateName: "default" },
32
+ { templateName: "sales" },
33
+ { templateName: "builtin-template", "is-built-in": true }
34
+ ]
35
+ };
36
+ }
37
+
38
+ return {};
39
+ }
40
+ }
41
+ });
42
+
43
+ const result = await service.resetKernel();
44
+
45
+ expect(result).toEqual({
46
+ deletedAgents: ["agent-a", "agent-b"],
47
+ deletedOntologies: ["crm"],
48
+ deletedTemplates: ["default", "sales"],
49
+ dryRun: false
50
+ });
51
+ expect(calls).toEqual([
52
+ { action: "agent.list", params: {} },
53
+ { action: "ontology.list", params: {} },
54
+ { action: "agent.template.list", params: {} },
55
+ { action: "agent.delete", params: { agentId: "agent-a" } },
56
+ { action: "agent.delete", params: { agentId: "agent-b" } },
57
+ { action: "apps.delete", params: { systemId: "crm" } },
58
+ { action: "agent.template.delete", params: { templateName: "default" } },
59
+ { action: "agent.template.delete", params: { templateName: "sales" } }
60
+ ]);
61
+ });
62
+
63
+ it("supports dry-run without executing delete commands", async () => {
64
+ const calls = [];
65
+ const service = new KernelResetService({
66
+ rpcClient: {
67
+ async call(action, params) {
68
+ calls.push({ action, params });
69
+
70
+ if (action === "agent.list") {
71
+ return {
72
+ items: [{ agentId: "agent-a" }]
73
+ };
74
+ }
75
+
76
+ if (action === "ontology.list") {
77
+ return {
78
+ items: [
79
+ { name: "crm" },
80
+ { name: "builtin-crm", "is-built-in": true }
81
+ ]
82
+ };
83
+ }
84
+
85
+ if (action === "agent.template.list") {
86
+ return {
87
+ items: [
88
+ { templateName: "default" },
89
+ { templateName: "builtin-template", "is-built-in": true }
90
+ ]
91
+ };
92
+ }
93
+
94
+ throw new Error(`Unexpected action: ${action}`);
95
+ }
96
+ }
97
+ });
98
+
99
+ const result = await service.resetKernel({ dryRun: true });
100
+
101
+ expect(result).toEqual({
102
+ deletedAgents: ["agent-a"],
103
+ deletedOntologies: ["crm"],
104
+ deletedTemplates: ["default"],
105
+ dryRun: true
106
+ });
107
+ expect(calls).toEqual([
108
+ { action: "agent.list", params: {} },
109
+ { action: "ontology.list", params: {} },
110
+ { action: "agent.template.list", params: {} }
111
+ ]);
112
+ });
@@ -0,0 +1,105 @@
1
+ import { ManagementRpcClient } from "../src/infra/mqtt/management-rpc-client.js";
2
+ import { createFakeDb, createFakeMqttFactory } from "../fakes.js";
3
+
4
+ it("publishes a request and resolves the matching response", async () => {
5
+ const env = {
6
+ mqtt: {
7
+ brokerUrl: "mqtt://broker",
8
+ username: "user",
9
+ password: "pass",
10
+ adminInboundTopic: "admin/in",
11
+ adminOutboundTopic: "admin/out"
12
+ }
13
+ };
14
+ const db = createFakeDb();
15
+ const mqttFactory = createFakeMqttFactory();
16
+ const rpcClient = new ManagementRpcClient({ env, db, mqttFactory });
17
+
18
+ await rpcClient.start();
19
+ const client = mqttFactory.clients[0];
20
+
21
+ const pending = rpcClient.call("agent.list", { includeDisabled: true }, 1000);
22
+ const published = client.publishes[0];
23
+ const request = JSON.parse(published.payload);
24
+
25
+ client.emit(
26
+ "message",
27
+ env.mqtt.adminOutboundTopic,
28
+ Buffer.from(
29
+ JSON.stringify({
30
+ requestId: request.requestId,
31
+ ok: true,
32
+ result: { items: [{ agentId: "alpha" }] }
33
+ })
34
+ )
35
+ );
36
+
37
+ await expectAsync(pending).toBeResolved();
38
+ expect(await pending).toEqual({ items: [{ agentId: "alpha" }] });
39
+ expect(db.statements.some((item) => item.sql.includes("INSERT INTO management_requests"))).toBeTrue();
40
+ expect(db.statements.some((item) => item.sql.includes("UPDATE management_requests"))).toBeTrue();
41
+
42
+ await rpcClient.stop();
43
+ });
44
+
45
+ it("marks timed out requests as failed", async () => {
46
+ const env = {
47
+ mqtt: {
48
+ brokerUrl: "mqtt://broker",
49
+ adminInboundTopic: "admin/in",
50
+ adminOutboundTopic: "admin/out"
51
+ }
52
+ };
53
+ const db = createFakeDb();
54
+ const mqttFactory = createFakeMqttFactory();
55
+ const rpcClient = new ManagementRpcClient({ env, db, mqttFactory });
56
+
57
+ await rpcClient.start();
58
+
59
+ try {
60
+ await rpcClient.call("agent.usage", { agentId: "alpha" }, 10);
61
+ fail("Expected call to time out");
62
+ } catch (error) {
63
+ expect(error.message).toContain("超时");
64
+ expect(error.details).toEqual(jasmine.objectContaining({
65
+ timeout: true,
66
+ action: "agent.usage"
67
+ }));
68
+ }
69
+
70
+ const failureUpdate = db.statements.find((item) => item.sql.includes("UPDATE management_requests"));
71
+ expect(failureUpdate).toBeDefined();
72
+ const [errorJson] = failureUpdate.args;
73
+ expect(JSON.parse(errorJson).message).toContain("超时");
74
+
75
+ await rpcClient.stop();
76
+ });
77
+
78
+ it("uses management timeout from env by default", async () => {
79
+ const env = {
80
+ managementTimeoutMs: 10,
81
+ mqtt: {
82
+ brokerUrl: "mqtt://broker",
83
+ adminInboundTopic: "admin/in",
84
+ adminOutboundTopic: "admin/out"
85
+ }
86
+ };
87
+ const db = createFakeDb();
88
+ const mqttFactory = createFakeMqttFactory();
89
+ const rpcClient = new ManagementRpcClient({ env, db, mqttFactory });
90
+
91
+ await rpcClient.start();
92
+
93
+ try {
94
+ await rpcClient.call("agent.list", { includeDisabled: true });
95
+ fail("Expected call to time out");
96
+ } catch (error) {
97
+ expect(error.message).toContain("超时");
98
+ expect(error.details).toEqual(jasmine.objectContaining({
99
+ timeout: true,
100
+ action: "agent.list"
101
+ }));
102
+ }
103
+
104
+ await rpcClient.stop();
105
+ });
@@ -0,0 +1,121 @@
1
+ import { PortalService } from "../src/services/portal-service.js";
2
+
3
+ function createDb({ count = 1, exists = true } = {}) {
4
+ const rows = new Map();
5
+ const deleted = [];
6
+ const inserted = [];
7
+
8
+ return {
9
+ deleted,
10
+ inserted,
11
+ prepare(sql) {
12
+ if (sql.includes("SELECT token, created_at, updated_at") && sql.includes("FROM access_tokens") && sql.includes("WHERE token = ?")) {
13
+ return {
14
+ get(token) {
15
+ return rows.get(token) || null;
16
+ }
17
+ };
18
+ }
19
+
20
+ if (sql.includes("SELECT token FROM access_tokens WHERE token = ?")) {
21
+ return {
22
+ get(token) {
23
+ return exists || rows.has(token) ? { token } : null;
24
+ }
25
+ };
26
+ }
27
+
28
+ if (sql.includes("SELECT COUNT(*) AS count FROM access_tokens")) {
29
+ return {
30
+ get() {
31
+ return { count };
32
+ }
33
+ };
34
+ }
35
+
36
+ if (sql.includes("INSERT INTO access_tokens")) {
37
+ return {
38
+ run(token, createdAt, updatedAt) {
39
+ const row = { token, created_at: createdAt, updated_at: updatedAt };
40
+ rows.set(token, row);
41
+ inserted.push(row);
42
+ return {};
43
+ }
44
+ };
45
+ }
46
+
47
+ if (sql.includes("DELETE FROM access_tokens WHERE token = ?")) {
48
+ return {
49
+ run(token) {
50
+ deleted.push(token);
51
+ rows.delete(token);
52
+ return {};
53
+ }
54
+ };
55
+ }
56
+
57
+ if (sql.includes("SELECT token, created_at, updated_at") && sql.includes("FROM access_tokens")) {
58
+ return {
59
+ all() {
60
+ return Array.from(rows.values());
61
+ }
62
+ };
63
+ }
64
+
65
+ return {
66
+ get() {
67
+ return null;
68
+ },
69
+ all() {
70
+ return [];
71
+ },
72
+ run() {
73
+ return {};
74
+ }
75
+ };
76
+ }
77
+ };
78
+ }
79
+
80
+ it("creates a random access token in database", () => {
81
+ const db = createDb({ exists: false });
82
+ const service = new PortalService({
83
+ db,
84
+ objectStorage: {},
85
+ rpcClient: {},
86
+ authService: {}
87
+ });
88
+
89
+ const created = service.createAccessToken();
90
+
91
+ expect(created.token.startsWith("sk-")).toBeTrue();
92
+ expect(db.inserted.length).toBe(1);
93
+ expect(db.inserted[0].token).toBe(created.token);
94
+ });
95
+
96
+ it("deletes an access token when more than one token remains", () => {
97
+ const db = createDb({ count: 2, exists: true });
98
+ const service = new PortalService({
99
+ db,
100
+ objectStorage: {},
101
+ rpcClient: {},
102
+ authService: {}
103
+ });
104
+
105
+ const result = service.deleteAccessToken("sk-demo");
106
+
107
+ expect(result).toEqual({ ok: true });
108
+ expect(db.deleted).toEqual(["sk-demo"]);
109
+ });
110
+
111
+ it("rejects deleting the last access token", () => {
112
+ const db = createDb({ count: 1, exists: true });
113
+ const service = new PortalService({
114
+ db,
115
+ objectStorage: {},
116
+ rpcClient: {},
117
+ authService: {}
118
+ });
119
+
120
+ expect(() => service.deleteAccessToken("sk-demo")).toThrowError("至少需要保留一个访问令牌");
121
+ });
@@ -0,0 +1,318 @@
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() {
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: () => null,
20
+ all: () => []
21
+ };
22
+ }
23
+ };
24
+ }
25
+
26
+ it("returns dashboard placeholders before agent and usage startup sync complete", () => {
27
+ const scalarValues = new Map([
28
+ ["SELECT status, last_success_at FROM agent_sync_state WHERE id = 1", { status: "running", last_success_at: null }],
29
+ ["SELECT status, last_success_at FROM usage_refresh_state WHERE id = 1", { status: "idle", last_success_at: null }],
30
+ ["SELECT COUNT(*) AS count FROM agents", { count: 0 }],
31
+ ["SELECT COUNT(*) AS count FROM agent_templates", { count: 2 }],
32
+ ["SELECT COUNT(*) AS count FROM skills", { count: 3 }],
33
+ ["SELECT COUNT(*) AS count FROM business_systems", { count: 4 }],
34
+ ["SELECT COALESCE(SUM(COALESCE(json_extract(usage_snapshot_json, '$.usage.daily'), 0)), 0) AS total FROM agents", { total: 0 }]
35
+ ]);
36
+ const db = {
37
+ prepare(sql) {
38
+ const normalized = sql.replace(/\s+/g, " ").trim();
39
+ return {
40
+ get: () => scalarValues.get(normalized) || null,
41
+ all: () => [],
42
+ run: () => ({ lastInsertRowid: 1 })
43
+ };
44
+ }
45
+ };
46
+ const service = new PortalService({
47
+ db,
48
+ objectStorage: {},
49
+ rpcClient: {},
50
+ authService: {}
51
+ });
52
+
53
+ const dashboard = service.getDashboard();
54
+
55
+ expect(dashboard.stats).toEqual({
56
+ agents: null,
57
+ templates: 2,
58
+ skills: 3,
59
+ systems: 4,
60
+ today_tokens: null
61
+ });
62
+ });
63
+
64
+ it("returns dashboard metrics after agent and usage startup sync complete", () => {
65
+ const scalarValues = new Map([
66
+ ["SELECT status, last_success_at FROM agent_sync_state WHERE id = 1", { status: "success", last_success_at: "2026-05-27T08:00:00.000Z" }],
67
+ ["SELECT status, last_success_at FROM usage_refresh_state WHERE id = 1", { status: "success", last_success_at: "2026-05-27T08:01:00.000Z" }],
68
+ ["SELECT COUNT(*) AS count FROM agents", { count: 5 }],
69
+ ["SELECT COUNT(*) AS count FROM agent_templates", { count: 2 }],
70
+ ["SELECT COUNT(*) AS count FROM skills", { count: 3 }],
71
+ ["SELECT COUNT(*) AS count FROM business_systems", { count: 4 }],
72
+ ["SELECT COALESCE(SUM(COALESCE(json_extract(usage_snapshot_json, '$.usage.daily'), 0)), 0) AS total FROM agents", { total: 123 }]
73
+ ]);
74
+ const db = {
75
+ prepare(sql) {
76
+ const normalized = sql.replace(/\s+/g, " ").trim();
77
+ return {
78
+ get: () => scalarValues.get(normalized) || null,
79
+ all: () => [],
80
+ run: () => ({ lastInsertRowid: 1 })
81
+ };
82
+ }
83
+ };
84
+ const service = new PortalService({
85
+ db,
86
+ objectStorage: {},
87
+ rpcClient: {},
88
+ authService: {}
89
+ });
90
+
91
+ const dashboard = service.getDashboard();
92
+
93
+ expect(dashboard.stats).toEqual({
94
+ agents: 5,
95
+ templates: 2,
96
+ skills: 3,
97
+ systems: 4,
98
+ today_tokens: 123
99
+ });
100
+ });
101
+
102
+ it("requires slug for global skill installation", async () => {
103
+ const db = createDb();
104
+ const rpcCalls = [];
105
+ const service = new PortalService({
106
+ db,
107
+ objectStorage: {
108
+ async uploadAdminArtifact() {
109
+ return {
110
+ bucket: "admin-in",
111
+ objectKey: "skill/demo.zip"
112
+ };
113
+ }
114
+ },
115
+ rpcClient: {
116
+ async call(action, params) {
117
+ rpcCalls.push({ action, params });
118
+ return {};
119
+ }
120
+ },
121
+ authService: {}
122
+ });
123
+
124
+ const zipBuffer = Buffer.from("PK\u0005\u0006" + "\u0000".repeat(18), "binary");
125
+ try {
126
+ await service.createSkill({
127
+ payload: {
128
+ slug: ""
129
+ },
130
+ file: {
131
+ buffer: zipBuffer,
132
+ originalname: "demo.zip",
133
+ mimetype: "application/zip",
134
+ size: zipBuffer.length
135
+ },
136
+ createdBy: 1
137
+ });
138
+ fail("Expected createSkill to reject");
139
+ } catch (error) {
140
+ expect(error.message).toContain("技能ID");
141
+ }
142
+ expect(rpcCalls.length).toBe(0);
143
+ });
144
+
145
+ it("installs uploaded global skill through skills.global.install.local", async () => {
146
+ const db = createDb();
147
+ const rpcCalls = [];
148
+ const service = new PortalService({
149
+ db,
150
+ objectStorage: {
151
+ async uploadAdminArtifact() {
152
+ return {
153
+ bucket: "admin-in",
154
+ objectKey: "skill/demo.zip"
155
+ };
156
+ }
157
+ },
158
+ rpcClient: {
159
+ async call(action, params) {
160
+ rpcCalls.push({ action, params });
161
+ return {};
162
+ }
163
+ },
164
+ authService: {}
165
+ });
166
+ service.listSkills = () => [{ id: 1 }];
167
+
168
+ const zip = new (await import("adm-zip")).default();
169
+ zip.addFile("SKILL.md", Buffer.from("# Demo Skill\n", "utf8"));
170
+
171
+ const result = await service.createSkill({
172
+ payload: {
173
+ slug: "skill-a"
174
+ },
175
+ file: {
176
+ buffer: zip.toBuffer(),
177
+ originalname: "demo.zip",
178
+ mimetype: "application/zip",
179
+ size: zip.toBuffer().length
180
+ },
181
+ createdBy: 1
182
+ });
183
+
184
+ expect(result).toEqual({ id: 1 });
185
+ expect(rpcCalls.length).toBe(1);
186
+ expect(rpcCalls[0].action).toBe("skills.global.install.local");
187
+ expect(rpcCalls[0].params).toEqual({
188
+ slug: "skill-a",
189
+ bucket: "admin-in",
190
+ objectKey: "skill/demo.zip",
191
+ force: false
192
+ });
193
+ });
194
+
195
+ it("deletes installed global skill through skills.global.delete using skill slug only", async () => {
196
+ const rpcCalls = [];
197
+ const db = {
198
+ prepare(sql) {
199
+ return {
200
+ run: () => ({ changes: 1 }),
201
+ get: () => {
202
+ if (sql.includes("SELECT * FROM skills WHERE id = ?")) {
203
+ return {
204
+ id: 1,
205
+ slug: "skill-a",
206
+ remote_status: "installed"
207
+ };
208
+ }
209
+ return null;
210
+ },
211
+ all: () => []
212
+ };
213
+ }
214
+ };
215
+ const service = new PortalService({
216
+ db,
217
+ objectStorage: {},
218
+ rpcClient: {
219
+ async call(action, params) {
220
+ rpcCalls.push({ action, params });
221
+ return {};
222
+ }
223
+ },
224
+ authService: {}
225
+ });
226
+
227
+ const result = await service.deleteSkill(1);
228
+
229
+ expect(result).toEqual({ ok: true });
230
+ expect(rpcCalls).toEqual([{
231
+ action: "skills.global.delete",
232
+ params: { slug: "skill-a" }
233
+ }]);
234
+ });
235
+
236
+ it("rejects deleting built-in templates", async () => {
237
+ const rpcCalls = [];
238
+ const db = {
239
+ prepare(sql) {
240
+ return {
241
+ run: () => ({ changes: 1 }),
242
+ get: () => {
243
+ if (sql.includes("SELECT * FROM agent_templates WHERE template_name = ?")) {
244
+ return {
245
+ template_name: "default",
246
+ is_builtin: 1
247
+ };
248
+ }
249
+ return null;
250
+ },
251
+ all: () => []
252
+ };
253
+ }
254
+ };
255
+ const service = new PortalService({
256
+ db,
257
+ objectStorage: {},
258
+ rpcClient: {
259
+ async call(action, params) {
260
+ rpcCalls.push({ action, params });
261
+ return {};
262
+ }
263
+ },
264
+ authService: {}
265
+ });
266
+
267
+ try {
268
+ await service.deleteTemplate("default");
269
+ fail("Expected deleteTemplate to reject");
270
+ } catch (error) {
271
+ expect(error.status).toBe(409);
272
+ expect(error.code).toBe("conflict");
273
+ }
274
+ expect(rpcCalls).toEqual([]);
275
+ });
276
+
277
+ it("rejects deleting built-in global skills", async () => {
278
+ const rpcCalls = [];
279
+ const db = {
280
+ prepare(sql) {
281
+ return {
282
+ run: () => ({ changes: 1 }),
283
+ get: () => {
284
+ if (sql.includes("SELECT * FROM skills WHERE id = ?")) {
285
+ return {
286
+ id: 1,
287
+ slug: "skill-a",
288
+ remote_status: "installed",
289
+ is_builtin: 1
290
+ };
291
+ }
292
+ return null;
293
+ },
294
+ all: () => []
295
+ };
296
+ }
297
+ };
298
+ const service = new PortalService({
299
+ db,
300
+ objectStorage: {},
301
+ rpcClient: {
302
+ async call(action, params) {
303
+ rpcCalls.push({ action, params });
304
+ return {};
305
+ }
306
+ },
307
+ authService: {}
308
+ });
309
+
310
+ try {
311
+ await service.deleteSkill(1);
312
+ fail("Expected deleteSkill to reject");
313
+ } catch (error) {
314
+ expect(error.status).toBe(409);
315
+ expect(error.code).toBe("conflict");
316
+ }
317
+ expect(rpcCalls).toEqual([]);
318
+ });