@swarmroom/server 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/dist/__tests__/setup.d.ts +6 -0
- package/dist/__tests__/setup.js +88 -0
- package/dist/app.d.ts +3 -0
- package/dist/app.js +41 -0
- package/dist/db/index.d.ts +6 -0
- package/dist/db/index.js +62 -0
- package/dist/db/schema.d.ts +655 -0
- package/dist/db/schema.js +61 -0
- package/dist/db/seed.d.ts +1 -0
- package/dist/db/seed.js +15 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +30 -0
- package/dist/lib/names.d.ts +1 -0
- package/dist/lib/names.js +32 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +154 -0
- package/dist/mcp/transport.d.ts +2 -0
- package/dist/mcp/transport.js +27 -0
- package/dist/middleware/cors.d.ts +1 -0
- package/dist/middleware/cors.js +8 -0
- package/dist/middleware/error-handler.d.ts +2 -0
- package/dist/middleware/error-handler.js +10 -0
- package/dist/routes/__tests__/agents.test.d.ts +1 -0
- package/dist/routes/__tests__/agents.test.js +80 -0
- package/dist/routes/agents.d.ts +3 -0
- package/dist/routes/agents.js +100 -0
- package/dist/routes/health.d.ts +3 -0
- package/dist/routes/health.js +14 -0
- package/dist/routes/messages.d.ts +3 -0
- package/dist/routes/messages.js +65 -0
- package/dist/routes/projects.d.ts +3 -0
- package/dist/routes/projects.js +94 -0
- package/dist/routes/teams.d.ts +3 -0
- package/dist/routes/teams.js +94 -0
- package/dist/routes/well-known.d.ts +3 -0
- package/dist/routes/well-known.js +78 -0
- package/dist/routes/ws.d.ts +5 -0
- package/dist/routes/ws.js +19 -0
- package/dist/services/__tests__/agent-service.test.d.ts +1 -0
- package/dist/services/__tests__/agent-service.test.js +67 -0
- package/dist/services/__tests__/heartbeat-service.test.d.ts +1 -0
- package/dist/services/__tests__/heartbeat-service.test.js +76 -0
- package/dist/services/__tests__/message-service.test.d.ts +1 -0
- package/dist/services/__tests__/message-service.test.js +85 -0
- package/dist/services/agent-service.d.ts +74 -0
- package/dist/services/agent-service.js +131 -0
- package/dist/services/heartbeat-service.d.ts +2 -0
- package/dist/services/heartbeat-service.js +35 -0
- package/dist/services/mdns-browser.d.ts +2 -0
- package/dist/services/mdns-browser.js +53 -0
- package/dist/services/mdns-service.d.ts +2 -0
- package/dist/services/mdns-service.js +66 -0
- package/dist/services/message-service.d.ts +82 -0
- package/dist/services/message-service.js +172 -0
- package/dist/services/project-service.d.ts +130 -0
- package/dist/services/project-service.js +104 -0
- package/dist/services/team-service.d.ts +130 -0
- package/dist/services/team-service.js +104 -0
- package/dist/services/ws-manager.d.ts +19 -0
- package/dist/services/ws-manager.js +165 -0
- package/package.json +47 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
3
|
+
// ─── Agents ─────────────────────────────────────────────────────────────────
|
|
4
|
+
export const agents = sqliteTable("agents", {
|
|
5
|
+
id: text("id").primaryKey(),
|
|
6
|
+
name: text("name").unique().notNull(),
|
|
7
|
+
displayName: text("display_name"),
|
|
8
|
+
url: text("url").notNull(),
|
|
9
|
+
status: text("status").default("online"),
|
|
10
|
+
agentCard: text("agent_card"), // JSON blob
|
|
11
|
+
lastHeartbeat: integer("last_heartbeat"),
|
|
12
|
+
createdAt: integer("created_at").default(sql `(unixepoch() * 1000)`),
|
|
13
|
+
updatedAt: integer("updated_at"),
|
|
14
|
+
});
|
|
15
|
+
// ─── Messages ───────────────────────────────────────────────────────────────
|
|
16
|
+
export const messages = sqliteTable("messages", {
|
|
17
|
+
id: text("id").primaryKey(),
|
|
18
|
+
fromAgentId: text("from_agent_id").notNull(),
|
|
19
|
+
toAgentId: text("to_agent_id").notNull(),
|
|
20
|
+
senderType: text("sender_type").default("agent"),
|
|
21
|
+
content: text("content").notNull(),
|
|
22
|
+
type: text("type").default("notification"),
|
|
23
|
+
replyTo: text("reply_to"),
|
|
24
|
+
metadata: text("metadata"), // JSON blob
|
|
25
|
+
read: integer("read").default(0),
|
|
26
|
+
createdAt: integer("created_at").default(sql `(unixepoch() * 1000)`),
|
|
27
|
+
});
|
|
28
|
+
// ─── Teams ──────────────────────────────────────────────────────────────────
|
|
29
|
+
export const teams = sqliteTable("teams", {
|
|
30
|
+
id: text("id").primaryKey(),
|
|
31
|
+
name: text("name").unique().notNull(),
|
|
32
|
+
description: text("description"),
|
|
33
|
+
color: text("color").default("#6366f1"),
|
|
34
|
+
createdAt: integer("created_at").default(sql `(unixepoch() * 1000)`),
|
|
35
|
+
});
|
|
36
|
+
// ─── Project Groups ─────────────────────────────────────────────────────────
|
|
37
|
+
export const projectGroups = sqliteTable("project_groups", {
|
|
38
|
+
id: text("id").primaryKey(),
|
|
39
|
+
name: text("name").unique().notNull(),
|
|
40
|
+
description: text("description"),
|
|
41
|
+
repository: text("repository"),
|
|
42
|
+
createdAt: integer("created_at").default(sql `(unixepoch() * 1000)`),
|
|
43
|
+
});
|
|
44
|
+
// ─── Agent ↔ Team (Junction) ───────────────────────────────────────────────
|
|
45
|
+
export const agentTeams = sqliteTable("agent_teams", {
|
|
46
|
+
agentId: text("agent_id")
|
|
47
|
+
.notNull()
|
|
48
|
+
.references(() => agents.id),
|
|
49
|
+
teamId: text("team_id")
|
|
50
|
+
.notNull()
|
|
51
|
+
.references(() => teams.id),
|
|
52
|
+
}, (table) => [primaryKey({ columns: [table.agentId, table.teamId] })]);
|
|
53
|
+
// ─── Agent ↔ Project (Junction) ────────────────────────────────────────────
|
|
54
|
+
export const agentProjects = sqliteTable("agent_projects", {
|
|
55
|
+
agentId: text("agent_id")
|
|
56
|
+
.notNull()
|
|
57
|
+
.references(() => agents.id),
|
|
58
|
+
projectId: text("project_id")
|
|
59
|
+
.notNull()
|
|
60
|
+
.references(() => projectGroups.id),
|
|
61
|
+
}, (table) => [primaryKey({ columns: [table.agentId, table.projectId] })]);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/db/seed.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { db, teams } from "./index.js";
|
|
3
|
+
const seedTeams = [
|
|
4
|
+
{ id: randomUUID(), name: "Frontend", description: "Frontend development team", color: "#3b82f6" },
|
|
5
|
+
{ id: randomUUID(), name: "Backend", description: "Backend development team", color: "#10b981" },
|
|
6
|
+
{ id: randomUUID(), name: "QA", description: "Quality assurance team", color: "#f59e0b" },
|
|
7
|
+
{ id: randomUUID(), name: "DevOps", description: "DevOps and infrastructure team", color: "#8b5cf6" },
|
|
8
|
+
];
|
|
9
|
+
for (const team of seedTeams) {
|
|
10
|
+
db.insert(teams)
|
|
11
|
+
.values(team)
|
|
12
|
+
.onConflictDoNothing()
|
|
13
|
+
.run();
|
|
14
|
+
}
|
|
15
|
+
console.log(`Seeded ${seedTeams.length} teams`);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { serve } from '@hono/node-server';
|
|
2
|
+
import { createNodeWebSocket } from '@hono/node-ws';
|
|
3
|
+
import { DEFAULT_PORT } from '@swarmroom/shared';
|
|
4
|
+
import { app } from './app.js';
|
|
5
|
+
import { registerWsRoute } from './routes/ws.js';
|
|
6
|
+
import { startHeartbeatChecker, stopHeartbeatChecker, } from './services/heartbeat-service.js';
|
|
7
|
+
import { startMdns, stopMdns } from './services/mdns-service.js';
|
|
8
|
+
import { startBrowsing, stopBrowsing } from './services/mdns-browser.js';
|
|
9
|
+
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
|
10
|
+
registerWsRoute(app, upgradeWebSocket);
|
|
11
|
+
const port = Number(process.env.PORT) || DEFAULT_PORT;
|
|
12
|
+
console.log(`Starting SwarmRoom Hub on port ${port}...`);
|
|
13
|
+
const server = serve({
|
|
14
|
+
fetch: app.fetch,
|
|
15
|
+
port,
|
|
16
|
+
}, (info) => {
|
|
17
|
+
console.log(`SwarmRoom Hub listening on http://localhost:${info.port}`);
|
|
18
|
+
startHeartbeatChecker();
|
|
19
|
+
startMdns(info.port);
|
|
20
|
+
startBrowsing();
|
|
21
|
+
});
|
|
22
|
+
injectWebSocket(server);
|
|
23
|
+
async function shutdown() {
|
|
24
|
+
stopBrowsing();
|
|
25
|
+
await stopMdns();
|
|
26
|
+
stopHeartbeatChecker();
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
process.on('SIGTERM', () => { shutdown(); });
|
|
30
|
+
process.on('SIGINT', () => { shutdown(); });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateDisplayName(): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const ADJECTIVES = [
|
|
2
|
+
'Swift', 'Bright', 'Silent', 'Cosmic', 'Emerald',
|
|
3
|
+
'Golden', 'Crimson', 'Azure', 'Lunar', 'Solar',
|
|
4
|
+
'Iron', 'Crystal', 'Shadow', 'Amber', 'Silver',
|
|
5
|
+
'Frost', 'Thunder', 'Velvet', 'Obsidian', 'Sapphire',
|
|
6
|
+
'Neon', 'Scarlet', 'Cobalt', 'Jade', 'Onyx',
|
|
7
|
+
'Radiant', 'Vivid', 'Blazing', 'Mystic', 'Nimble',
|
|
8
|
+
'Rapid', 'Keen', 'Bold', 'Calm', 'Fierce',
|
|
9
|
+
'Noble', 'Phantom', 'Primal', 'Quiet', 'Royal',
|
|
10
|
+
'Stark', 'Turbo', 'Ultra', 'Warp', 'Zenith',
|
|
11
|
+
'Dusk', 'Dawn', 'Storm', 'Coral', 'Rune',
|
|
12
|
+
'Polar', 'Titan', 'Echo', 'Nova', 'Apex',
|
|
13
|
+
];
|
|
14
|
+
const NOUNS = [
|
|
15
|
+
'Falcon', 'Phoenix', 'Cascade', 'Nebula', 'Prism',
|
|
16
|
+
'Vortex', 'Cipher', 'Beacon', 'Spark', 'Pulse',
|
|
17
|
+
'Quasar', 'Raven', 'Lynx', 'Panda', 'Otter',
|
|
18
|
+
'Hawk', 'Wolf', 'Tiger', 'Cobra', 'Eagle',
|
|
19
|
+
'Arrow', 'Blade', 'Forge', 'Nexus', 'Orbit',
|
|
20
|
+
'Shard', 'Flux', 'Drift', 'Comet', 'Flare',
|
|
21
|
+
'Bolt', 'Surge', 'Wave', 'Reef', 'Peak',
|
|
22
|
+
'Ridge', 'Mesa', 'Dune', 'Grove', 'Crest',
|
|
23
|
+
'Arc', 'Gate', 'Lens', 'Core', 'Node',
|
|
24
|
+
'Link', 'Atlas', 'Helix', 'Opal', 'Pixel',
|
|
25
|
+
'Vertex', 'Lotus', 'Sonic', 'Rogue', 'Sage',
|
|
26
|
+
];
|
|
27
|
+
function pickRandom(arr) {
|
|
28
|
+
return arr[Math.floor(Math.random() * arr.length)];
|
|
29
|
+
}
|
|
30
|
+
export function generateDisplayName() {
|
|
31
|
+
return `${pickRandom(ADJECTIVES)}${pickRandom(NOUNS)}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod/v4';
|
|
3
|
+
import { MCP_TOOL_NAMES } from '@swarmroom/shared';
|
|
4
|
+
import { listAgents, getAgentById, } from '../services/agent-service.js';
|
|
5
|
+
import { createMessage, getMessagesForAgent, } from '../services/message-service.js';
|
|
6
|
+
import { listTeams } from '../services/team-service.js';
|
|
7
|
+
import { listProjects } from '../services/project-service.js';
|
|
8
|
+
function textResult(data) {
|
|
9
|
+
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
|
|
10
|
+
}
|
|
11
|
+
export const mcpServer = new McpServer({
|
|
12
|
+
name: 'swarmroom',
|
|
13
|
+
version: '0.1.0',
|
|
14
|
+
});
|
|
15
|
+
// ─── list_agents ─────────────────────────────────────────────────────────────
|
|
16
|
+
mcpServer.tool(MCP_TOOL_NAMES.LIST_AGENTS, 'List all agents registered in SwarmRoom. Optionally filter by status or team.', {
|
|
17
|
+
status: z.string().optional().describe('Filter by agent status (online, offline)'),
|
|
18
|
+
team_id: z.string().optional().describe('Filter by team ID'),
|
|
19
|
+
project_id: z.string().optional().describe('Filter by project ID'),
|
|
20
|
+
}, async ({ status, team_id, project_id }) => {
|
|
21
|
+
const agents = listAgents({
|
|
22
|
+
status: status ?? undefined,
|
|
23
|
+
teamId: team_id ?? undefined,
|
|
24
|
+
projectId: project_id ?? undefined,
|
|
25
|
+
});
|
|
26
|
+
return textResult(agents);
|
|
27
|
+
});
|
|
28
|
+
// ─── get_agent_info ──────────────────────────────────────────────────────────
|
|
29
|
+
mcpServer.tool(MCP_TOOL_NAMES.GET_AGENT_INFO, 'Get detailed information about a specific agent by ID or name.', {
|
|
30
|
+
id: z.string().optional().describe('Agent UUID'),
|
|
31
|
+
name: z.string().optional().describe('Agent name (exact match)'),
|
|
32
|
+
}, async ({ id, name }) => {
|
|
33
|
+
if (!id && !name) {
|
|
34
|
+
return textResult({ error: 'Provide either id or name' });
|
|
35
|
+
}
|
|
36
|
+
if (id) {
|
|
37
|
+
const agent = getAgentById(id);
|
|
38
|
+
if (!agent) {
|
|
39
|
+
return textResult({ error: `Agent not found: ${id}` });
|
|
40
|
+
}
|
|
41
|
+
return textResult(agent);
|
|
42
|
+
}
|
|
43
|
+
const agents = listAgents();
|
|
44
|
+
const match = agents.find((a) => a.name === name);
|
|
45
|
+
if (!match) {
|
|
46
|
+
return textResult({ error: `Agent not found with name: ${name}` });
|
|
47
|
+
}
|
|
48
|
+
const agent = getAgentById(match.id);
|
|
49
|
+
return textResult(agent);
|
|
50
|
+
});
|
|
51
|
+
// ─── send_message ────────────────────────────────────────────────────────────
|
|
52
|
+
mcpServer.tool(MCP_TOOL_NAMES.SEND_MESSAGE, 'Send a message from one agent to another.', {
|
|
53
|
+
from: z.string().describe('Sender agent ID'),
|
|
54
|
+
to: z.string().describe('Recipient agent ID or "broadcast"'),
|
|
55
|
+
content: z.string().describe('Message content'),
|
|
56
|
+
type: z
|
|
57
|
+
.enum(['notification', 'query', 'response', 'broadcast'])
|
|
58
|
+
.optional()
|
|
59
|
+
.describe('Message type (default: notification)'),
|
|
60
|
+
reply_to: z.string().optional().describe('ID of message being replied to'),
|
|
61
|
+
metadata: z
|
|
62
|
+
.record(z.string(), z.unknown())
|
|
63
|
+
.optional()
|
|
64
|
+
.describe('Optional metadata object'),
|
|
65
|
+
}, async ({ from, to, content, type, reply_to, metadata }) => {
|
|
66
|
+
try {
|
|
67
|
+
const result = createMessage({
|
|
68
|
+
from,
|
|
69
|
+
to,
|
|
70
|
+
senderType: 'agent',
|
|
71
|
+
content,
|
|
72
|
+
type: type ?? 'notification',
|
|
73
|
+
replyTo: reply_to ?? undefined,
|
|
74
|
+
metadata: metadata ?? undefined,
|
|
75
|
+
});
|
|
76
|
+
return textResult(result);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return textResult({ error: err.message });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
// ─── get_messages ────────────────────────────────────────────────────────────
|
|
83
|
+
mcpServer.tool(MCP_TOOL_NAMES.GET_MESSAGES, 'Get messages for a specific agent.', {
|
|
84
|
+
agent_id: z.string().describe('Agent ID to get messages for'),
|
|
85
|
+
since: z
|
|
86
|
+
.string()
|
|
87
|
+
.optional()
|
|
88
|
+
.describe('Timestamp (ms since epoch) — only return messages after this time'),
|
|
89
|
+
limit: z.number().optional().describe('Max number of messages to return (default 50)'),
|
|
90
|
+
type: z
|
|
91
|
+
.enum(['notification', 'query', 'response', 'broadcast'])
|
|
92
|
+
.optional()
|
|
93
|
+
.describe('Filter by message type'),
|
|
94
|
+
}, async ({ agent_id, since, limit, type }) => {
|
|
95
|
+
const messages = getMessagesForAgent(agent_id, {
|
|
96
|
+
since: since ?? undefined,
|
|
97
|
+
limit: limit ?? undefined,
|
|
98
|
+
type: type ?? undefined,
|
|
99
|
+
});
|
|
100
|
+
return textResult(messages);
|
|
101
|
+
});
|
|
102
|
+
// ─── query_agent ─────────────────────────────────────────────────────────────
|
|
103
|
+
mcpServer.tool(MCP_TOOL_NAMES.QUERY_AGENT, 'Send a query message to an agent and poll for a response. Returns the reply or times out.', {
|
|
104
|
+
from: z.string().describe('Sender agent ID'),
|
|
105
|
+
to: z.string().describe('Target agent ID to query'),
|
|
106
|
+
content: z.string().describe('Query content'),
|
|
107
|
+
timeout_seconds: z
|
|
108
|
+
.number()
|
|
109
|
+
.optional()
|
|
110
|
+
.describe('How long to wait for a reply in seconds (default 30)'),
|
|
111
|
+
}, async ({ from, to, content, timeout_seconds }) => {
|
|
112
|
+
try {
|
|
113
|
+
const queryMsg = createMessage({
|
|
114
|
+
from,
|
|
115
|
+
to,
|
|
116
|
+
senderType: 'agent',
|
|
117
|
+
content,
|
|
118
|
+
type: 'query',
|
|
119
|
+
});
|
|
120
|
+
const sentMsg = Array.isArray(queryMsg) ? queryMsg[0] : queryMsg;
|
|
121
|
+
if (!sentMsg) {
|
|
122
|
+
return textResult({ error: 'Failed to send query message' });
|
|
123
|
+
}
|
|
124
|
+
const timeout = (timeout_seconds ?? 30) * 1000;
|
|
125
|
+
const pollInterval = 500;
|
|
126
|
+
const deadline = Date.now() + timeout;
|
|
127
|
+
while (Date.now() < deadline) {
|
|
128
|
+
const replies = getMessagesForAgent(from, { type: 'response' });
|
|
129
|
+
const reply = replies.find((m) => m.replyTo === sentMsg.id);
|
|
130
|
+
if (reply) {
|
|
131
|
+
return textResult({ query: sentMsg, reply });
|
|
132
|
+
}
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
134
|
+
}
|
|
135
|
+
return textResult({
|
|
136
|
+
query: sentMsg,
|
|
137
|
+
reply: null,
|
|
138
|
+
error: `No response received within ${timeout_seconds ?? 30} seconds`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
return textResult({ error: err.message });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
// ─── list_teams ──────────────────────────────────────────────────────────────
|
|
146
|
+
mcpServer.tool(MCP_TOOL_NAMES.LIST_TEAMS, 'List all teams with member counts.', {}, async () => {
|
|
147
|
+
const teams = listTeams();
|
|
148
|
+
return textResult(teams);
|
|
149
|
+
});
|
|
150
|
+
// ─── list_projects ───────────────────────────────────────────────────────────
|
|
151
|
+
mcpServer.tool(MCP_TOOL_NAMES.LIST_PROJECTS, 'List all project groups with member counts.', {}, async () => {
|
|
152
|
+
const projects = listProjects();
|
|
153
|
+
return textResult(projects);
|
|
154
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
3
|
+
import { mcpServer } from './server.js';
|
|
4
|
+
const transports = new Map();
|
|
5
|
+
function createSessionTransport() {
|
|
6
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
7
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
8
|
+
onsessioninitialized: (sessionId) => {
|
|
9
|
+
transports.set(sessionId, transport);
|
|
10
|
+
},
|
|
11
|
+
onsessionclosed: (sessionId) => {
|
|
12
|
+
transports.delete(sessionId);
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
return transport;
|
|
16
|
+
}
|
|
17
|
+
export const mcpRoute = new Hono();
|
|
18
|
+
mcpRoute.all('/', async (c) => {
|
|
19
|
+
const sessionId = c.req.header('mcp-session-id');
|
|
20
|
+
if (sessionId && transports.has(sessionId)) {
|
|
21
|
+
const transport = transports.get(sessionId);
|
|
22
|
+
return transport.handleRequest(c.req.raw);
|
|
23
|
+
}
|
|
24
|
+
const transport = createSessionTransport();
|
|
25
|
+
await mcpServer.connect(transport);
|
|
26
|
+
return transport.handleRequest(c.req.raw);
|
|
27
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const corsMiddleware: import("hono").MiddlewareHandler;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const errorHandler = (err, c) => {
|
|
2
|
+
console.error(`[ERROR] ${err.message}`, err.stack);
|
|
3
|
+
const status = 'status' in err && typeof err.status === 'number' ? err.status : 500;
|
|
4
|
+
return c.json({
|
|
5
|
+
success: false,
|
|
6
|
+
error: {
|
|
7
|
+
message: status === 500 ? 'Internal Server Error' : err.message,
|
|
8
|
+
},
|
|
9
|
+
}, status);
|
|
10
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { errorHandler } from '../../middleware/error-handler.js';
|
|
4
|
+
import { agentsRoute } from '../../routes/agents.js';
|
|
5
|
+
function createTestApp() {
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
app.onError(errorHandler);
|
|
8
|
+
app.route('/api/agents', agentsRoute);
|
|
9
|
+
return app;
|
|
10
|
+
}
|
|
11
|
+
describe('Agents Route', () => {
|
|
12
|
+
it('POST /api/agents → 201 with agent data', async () => {
|
|
13
|
+
const app = createTestApp();
|
|
14
|
+
const res = await app.request('/api/agents', {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
body: JSON.stringify({ name: 'route-agent', url: 'http://localhost:8000' }),
|
|
18
|
+
});
|
|
19
|
+
expect(res.status).toBe(201);
|
|
20
|
+
const json = await res.json();
|
|
21
|
+
expect(json.success).toBe(true);
|
|
22
|
+
expect(json.data.name).toBe('route-agent');
|
|
23
|
+
expect(json.data.id).toBeDefined();
|
|
24
|
+
expect(json.data.displayName).toBeTruthy();
|
|
25
|
+
expect(json.data.status).toBe('online');
|
|
26
|
+
});
|
|
27
|
+
it('GET /api/agents → 200 with list', async () => {
|
|
28
|
+
const app = createTestApp();
|
|
29
|
+
await app.request('/api/agents', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
body: JSON.stringify({ name: 'list-agent-1', url: 'http://a' }),
|
|
33
|
+
});
|
|
34
|
+
await app.request('/api/agents', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify({ name: 'list-agent-2', url: 'http://b' }),
|
|
38
|
+
});
|
|
39
|
+
const res = await app.request('/api/agents');
|
|
40
|
+
expect(res.status).toBe(200);
|
|
41
|
+
const json = await res.json();
|
|
42
|
+
expect(json.success).toBe(true);
|
|
43
|
+
expect(json.data).toHaveLength(2);
|
|
44
|
+
});
|
|
45
|
+
it('GET /api/agents/:id → 200 with agent detail', async () => {
|
|
46
|
+
const app = createTestApp();
|
|
47
|
+
const createRes = await app.request('/api/agents', {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ name: 'detail-agent', url: 'http://detail' }),
|
|
51
|
+
});
|
|
52
|
+
const created = await createRes.json();
|
|
53
|
+
const id = created.data.id;
|
|
54
|
+
const res = await app.request(`/api/agents/${id}`);
|
|
55
|
+
expect(res.status).toBe(200);
|
|
56
|
+
const json = await res.json();
|
|
57
|
+
expect(json.success).toBe(true);
|
|
58
|
+
expect(json.data.id).toBe(id);
|
|
59
|
+
expect(json.data.name).toBe('detail-agent');
|
|
60
|
+
expect(json.data.teamIds).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
it('GET /api/agents/:id → 404 for nonexistent agent', async () => {
|
|
63
|
+
const app = createTestApp();
|
|
64
|
+
const res = await app.request('/api/agents/nonexistent-id');
|
|
65
|
+
expect(res.status).toBe(404);
|
|
66
|
+
const json = await res.json();
|
|
67
|
+
expect(json.success).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
it('POST /api/agents → 400 for invalid body', async () => {
|
|
70
|
+
const app = createTestApp();
|
|
71
|
+
const res = await app.request('/api/agents', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({ invalid: true }),
|
|
75
|
+
});
|
|
76
|
+
expect(res.status).toBe(400);
|
|
77
|
+
const json = await res.json();
|
|
78
|
+
expect(json.success).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { HTTPException } from 'hono/http-exception';
|
|
3
|
+
import { eq } from 'drizzle-orm';
|
|
4
|
+
import { RegisterAgentRequestSchema, HeartbeatRequestSchema, HEARTBEAT_INTERVAL_MS, } from '@swarmroom/shared';
|
|
5
|
+
import { db, agents } from '../db/index.js';
|
|
6
|
+
import { createAgent, listAgents, getAgentById, updateAgent, deregisterAgent, } from '../services/agent-service.js';
|
|
7
|
+
import { broadcastAgentOnline, broadcastAgentOffline } from '../services/ws-manager.js';
|
|
8
|
+
const agentsRoute = new Hono();
|
|
9
|
+
agentsRoute.post('/', async (c) => {
|
|
10
|
+
const body = await c.req.json();
|
|
11
|
+
const parsed = RegisterAgentRequestSchema.safeParse(body);
|
|
12
|
+
if (!parsed.success) {
|
|
13
|
+
throw new HTTPException(400, {
|
|
14
|
+
message: `Invalid request body: ${parsed.error.issues.map((i) => i.message).join(', ')}`,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const agent = createAgent(parsed.data);
|
|
19
|
+
if (agent) {
|
|
20
|
+
broadcastAgentOnline({ id: agent.id, name: agent.name });
|
|
21
|
+
}
|
|
22
|
+
return c.json({ success: true, data: agent }, 201);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (err instanceof Error && err.message.includes('UNIQUE constraint failed')) {
|
|
26
|
+
throw new HTTPException(409, {
|
|
27
|
+
message: `Agent with name "${parsed.data.name}" already exists`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
agentsRoute.get('/', (c) => {
|
|
34
|
+
const status = c.req.query('status');
|
|
35
|
+
const teamId = c.req.query('teamId');
|
|
36
|
+
const projectId = c.req.query('projectId');
|
|
37
|
+
const result = listAgents({ status, teamId, projectId });
|
|
38
|
+
return c.json({ success: true, data: result });
|
|
39
|
+
});
|
|
40
|
+
agentsRoute.get('/:id', (c) => {
|
|
41
|
+
const id = c.req.param('id');
|
|
42
|
+
const agent = getAgentById(id);
|
|
43
|
+
if (!agent) {
|
|
44
|
+
throw new HTTPException(404, { message: `Agent "${id}" not found` });
|
|
45
|
+
}
|
|
46
|
+
return c.json({ success: true, data: agent });
|
|
47
|
+
});
|
|
48
|
+
agentsRoute.patch('/:id', async (c) => {
|
|
49
|
+
const id = c.req.param('id');
|
|
50
|
+
const body = await c.req.json();
|
|
51
|
+
const updates = {};
|
|
52
|
+
if (body.name !== undefined)
|
|
53
|
+
updates.name = body.name;
|
|
54
|
+
if (body.url !== undefined)
|
|
55
|
+
updates.url = body.url;
|
|
56
|
+
if (body.status !== undefined)
|
|
57
|
+
updates.status = body.status;
|
|
58
|
+
if (body.agentCard !== undefined) {
|
|
59
|
+
updates.agentCard = JSON.stringify(body.agentCard);
|
|
60
|
+
}
|
|
61
|
+
const agent = updateAgent(id, updates);
|
|
62
|
+
if (!agent) {
|
|
63
|
+
throw new HTTPException(404, { message: `Agent "${id}" not found` });
|
|
64
|
+
}
|
|
65
|
+
return c.json({ success: true, data: agent });
|
|
66
|
+
});
|
|
67
|
+
agentsRoute.delete('/:id', (c) => {
|
|
68
|
+
const id = c.req.param('id');
|
|
69
|
+
const agent = deregisterAgent(id);
|
|
70
|
+
if (!agent) {
|
|
71
|
+
throw new HTTPException(404, { message: `Agent "${id}" not found` });
|
|
72
|
+
}
|
|
73
|
+
broadcastAgentOffline({ id: agent.id, name: agent.name });
|
|
74
|
+
return c.json({ success: true, data: agent });
|
|
75
|
+
});
|
|
76
|
+
agentsRoute.post('/:id/heartbeat', async (c) => {
|
|
77
|
+
const id = c.req.param('id');
|
|
78
|
+
const body = await c.req.json();
|
|
79
|
+
const parsed = HeartbeatRequestSchema.safeParse(body);
|
|
80
|
+
if (!parsed.success) {
|
|
81
|
+
throw new HTTPException(400, {
|
|
82
|
+
message: `Invalid request body: ${parsed.error.issues.map((i) => i.message).join(', ')}`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const existing = db.select({ id: agents.id }).from(agents).where(eq(agents.id, id)).get();
|
|
86
|
+
if (!existing) {
|
|
87
|
+
throw new HTTPException(404, { message: `Agent "${id}" not found` });
|
|
88
|
+
}
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const updates = {
|
|
91
|
+
lastHeartbeat: now,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
};
|
|
94
|
+
if (parsed.data.status) {
|
|
95
|
+
updates.status = parsed.data.status;
|
|
96
|
+
}
|
|
97
|
+
db.update(agents).set(updates).where(eq(agents.id, id)).run();
|
|
98
|
+
return c.json({ status: 'ok', interval: HEARTBEAT_INTERVAL_MS });
|
|
99
|
+
});
|
|
100
|
+
export { agentsRoute };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { getAgentCount } from '../services/agent-service.js';
|
|
3
|
+
const health = new Hono();
|
|
4
|
+
const startTime = Date.now();
|
|
5
|
+
health.get('/health', (c) => {
|
|
6
|
+
const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000);
|
|
7
|
+
return c.json({
|
|
8
|
+
status: 'ok',
|
|
9
|
+
version: '0.1.0',
|
|
10
|
+
uptime: uptimeSeconds,
|
|
11
|
+
agentCount: getAgentCount(),
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
export { health };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { HTTPException } from 'hono/http-exception';
|
|
3
|
+
import { SendMessageRequestSchema } from '@swarmroom/shared';
|
|
4
|
+
import { createMessage, getMessagesForAgent, getMessageById, markMessageAsRead, getConversation, MessageSizeError, InvalidReplyError, } from '../services/message-service.js';
|
|
5
|
+
const messagesRoute = new Hono();
|
|
6
|
+
messagesRoute.post('/', async (c) => {
|
|
7
|
+
const body = await c.req.json();
|
|
8
|
+
const parsed = SendMessageRequestSchema.safeParse(body);
|
|
9
|
+
if (!parsed.success) {
|
|
10
|
+
throw new HTTPException(400, {
|
|
11
|
+
message: `Invalid request body: ${parsed.error.issues.map((i) => i.message).join(', ')}`,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const result = createMessage(parsed.data);
|
|
16
|
+
return c.json({ success: true, data: result }, 201);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err instanceof MessageSizeError) {
|
|
20
|
+
throw new HTTPException(400, { message: err.message });
|
|
21
|
+
}
|
|
22
|
+
if (err instanceof InvalidReplyError) {
|
|
23
|
+
throw new HTTPException(400, { message: err.message });
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
messagesRoute.get('/', (c) => {
|
|
29
|
+
const agentId = c.req.query('agentId');
|
|
30
|
+
if (!agentId) {
|
|
31
|
+
throw new HTTPException(400, { message: 'Query parameter "agentId" is required' });
|
|
32
|
+
}
|
|
33
|
+
const since = c.req.query('since');
|
|
34
|
+
const limitParam = c.req.query('limit');
|
|
35
|
+
const type = c.req.query('type');
|
|
36
|
+
const limit = limitParam ? Number(limitParam) : 50;
|
|
37
|
+
if (Number.isNaN(limit) || limit < 1) {
|
|
38
|
+
throw new HTTPException(400, { message: 'Query parameter "limit" must be a positive integer' });
|
|
39
|
+
}
|
|
40
|
+
const result = getMessagesForAgent(agentId, { since: since ?? undefined, limit, type: type ?? undefined });
|
|
41
|
+
return c.json({ success: true, data: result });
|
|
42
|
+
});
|
|
43
|
+
messagesRoute.get('/conversation/:agentA/:agentB', (c) => {
|
|
44
|
+
const agentA = c.req.param('agentA');
|
|
45
|
+
const agentB = c.req.param('agentB');
|
|
46
|
+
const result = getConversation(agentA, agentB);
|
|
47
|
+
return c.json({ success: true, data: result });
|
|
48
|
+
});
|
|
49
|
+
messagesRoute.get('/:id', (c) => {
|
|
50
|
+
const id = c.req.param('id');
|
|
51
|
+
const message = getMessageById(id);
|
|
52
|
+
if (!message) {
|
|
53
|
+
throw new HTTPException(404, { message: `Message "${id}" not found` });
|
|
54
|
+
}
|
|
55
|
+
return c.json({ success: true, data: message });
|
|
56
|
+
});
|
|
57
|
+
messagesRoute.patch('/:id/read', (c) => {
|
|
58
|
+
const id = c.req.param('id');
|
|
59
|
+
const message = markMessageAsRead(id);
|
|
60
|
+
if (!message) {
|
|
61
|
+
throw new HTTPException(404, { message: `Message "${id}" not found` });
|
|
62
|
+
}
|
|
63
|
+
return c.json({ success: true, data: message });
|
|
64
|
+
});
|
|
65
|
+
export { messagesRoute };
|