agent-relay-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/src/routes.ts ADDED
@@ -0,0 +1,343 @@
1
+ import {
2
+ upsertAgent,
3
+ getAgent,
4
+ listAgents,
5
+ findAgentsByCapability,
6
+ setStatus,
7
+ setLabel,
8
+ heartbeat,
9
+ deleteAgent,
10
+ sendMessage,
11
+ getMessage,
12
+ getThread,
13
+ claimMessage,
14
+ listRecentMessages,
15
+ pollMessages,
16
+ markRead,
17
+ deleteMessage,
18
+ getStats,
19
+ getLatestMessageId,
20
+ ValidationError,
21
+ } from "./db";
22
+ import type { RegisterAgentInput, SendMessageInput, PollQuery } from "./types";
23
+ import { MAX_BODY_BYTES } from "./config";
24
+
25
+ type Handler = (
26
+ req: Request,
27
+ params: Record<string, string>
28
+ ) => Response | Promise<Response>;
29
+
30
+ const json = (data: unknown, status = 200) =>
31
+ Response.json(data, { status });
32
+
33
+ const error = (msg: string, status = 400) =>
34
+ json({ error: msg }, status);
35
+
36
+ type ParseBodyResult<T> =
37
+ | { ok: true; body: T | null }
38
+ | { ok: false; status: number; error: string };
39
+
40
+ async function parseBody<T>(req: Request): Promise<ParseBodyResult<T>> {
41
+ if (!req.body) return { ok: true, body: null };
42
+
43
+ const reader = req.body.getReader();
44
+ const chunks: Uint8Array[] = [];
45
+ let totalBytes = 0;
46
+
47
+ while (true) {
48
+ const { done, value } = await reader.read();
49
+ if (done) break;
50
+ if (!value) continue;
51
+
52
+ totalBytes += value.byteLength;
53
+ if (totalBytes > MAX_BODY_BYTES) {
54
+ try {
55
+ await reader.cancel();
56
+ } catch {
57
+ // Ignore cancellation errors — we already know this request is too large.
58
+ }
59
+ return {
60
+ ok: false,
61
+ status: 413,
62
+ error: `request body exceeds ${MAX_BODY_BYTES} bytes`,
63
+ };
64
+ }
65
+
66
+ chunks.push(value);
67
+ }
68
+
69
+ if (totalBytes === 0) return { ok: true, body: null };
70
+
71
+ const merged = new Uint8Array(totalBytes);
72
+ let offset = 0;
73
+ for (const chunk of chunks) {
74
+ merged.set(chunk, offset);
75
+ offset += chunk.byteLength;
76
+ }
77
+
78
+ try {
79
+ const decoded = new TextDecoder().decode(merged);
80
+ return { ok: true, body: JSON.parse(decoded) as T };
81
+ } catch {
82
+ return { ok: false, status: 400, error: "invalid JSON body" };
83
+ }
84
+ }
85
+
86
+ function parseId(raw: string | undefined): number | null {
87
+ if (!raw) return null;
88
+ const n = Number(raw);
89
+ if (!Number.isInteger(n) || n <= 0 || n > Number.MAX_SAFE_INTEGER) return null;
90
+ return n;
91
+ }
92
+
93
+ function parseQueryInt(
94
+ raw: string | null,
95
+ opts: { min: number; max: number }
96
+ ): number | null {
97
+ if (raw === null) return null;
98
+ if (!/^-?\d+$/.test(raw)) return Number.NaN;
99
+
100
+ const n = Number(raw);
101
+ if (!Number.isSafeInteger(n)) return Number.NaN;
102
+ if (n < opts.min || n > opts.max) return Number.NaN;
103
+ return n;
104
+ }
105
+
106
+ // --- Agent routes ---
107
+
108
+ const postAgent: Handler = async (req) => {
109
+ const parsed = await parseBody<RegisterAgentInput>(req);
110
+ if (!parsed.ok) return error(parsed.error, parsed.status);
111
+ const body = parsed.body;
112
+ if (!body?.id || !body?.name) return error("id and name required");
113
+ return json(upsertAgent(body), 201);
114
+ };
115
+
116
+ const getAgents: Handler = (req) => {
117
+ const url = new URL(req.url);
118
+ const tag = url.searchParams.get("tag") ?? undefined;
119
+ const machine = url.searchParams.get("machine") ?? undefined;
120
+ const status = url.searchParams.get("status") ?? undefined;
121
+ return json(listAgents({ tag, machine, status }));
122
+ };
123
+
124
+ const findAgents: Handler = (req) => {
125
+ const url = new URL(req.url);
126
+ const capability = url.searchParams.get("capability");
127
+ if (!capability) return error("capability query param required");
128
+ const onlineOnly = url.searchParams.get("all") !== "true";
129
+ return json(findAgentsByCapability(capability, onlineOnly));
130
+ };
131
+
132
+ const getAgentById: Handler = (_req, params) => {
133
+ const agent = getAgent(params.id!);
134
+ return agent ? json(agent) : error("agent not found", 404);
135
+ };
136
+
137
+ const patchAgentStatus: Handler = async (req, params) => {
138
+ const parsed = await parseBody<{ status: string }>(req);
139
+ if (!parsed.ok) return error(parsed.error, parsed.status);
140
+ const body = parsed.body;
141
+ if (!body?.status) return error("status required");
142
+ const valid = ["online", "idle", "busy", "offline"];
143
+ if (!valid.includes(body.status)) return error(`status must be one of: ${valid.join(", ")}`);
144
+ return setStatus(params.id!, body.status as any)
145
+ ? json({ ok: true })
146
+ : error("agent not found", 404);
147
+ };
148
+
149
+ const postHeartbeat: Handler = (_req, params) => {
150
+ return heartbeat(params.id!) ? json({ ok: true }) : error("agent not found", 404);
151
+ };
152
+
153
+ const patchAgentLabel: Handler = async (req, params) => {
154
+ const parsed = await parseBody<{ label: string | null }>(req);
155
+ if (!parsed.ok) return error(parsed.error, parsed.status);
156
+ const body = parsed.body;
157
+ if (body === null || !("label" in body)) return error("label field required (string or null)");
158
+ return setLabel(params.id!, body.label)
159
+ ? json({ ok: true })
160
+ : error("agent not found", 404);
161
+ };
162
+
163
+ const deleteAgentById: Handler = (_req, params) => {
164
+ const result = deleteAgent(params.id!);
165
+ if (result.ok) return json({ ok: true });
166
+ const status = result.error === "agent not found" ? 404 : 400;
167
+ return error(result.error!, status);
168
+ };
169
+
170
+ // --- Message routes ---
171
+
172
+ const postMessage: Handler = async (req) => {
173
+ const parsed = await parseBody<SendMessageInput>(req);
174
+ if (!parsed.ok) return error(parsed.error, parsed.status);
175
+ const body = parsed.body;
176
+ if (!body?.from || !body?.to || !body?.body) {
177
+ return error("from, to, and body required");
178
+ }
179
+ try {
180
+ return json(sendMessage(body), 201);
181
+ } catch (e) {
182
+ if (e instanceof ValidationError) return error(e.message, 400);
183
+ throw e;
184
+ }
185
+ };
186
+
187
+ const getMessages: Handler = (req) => {
188
+ const url = new URL(req.url);
189
+ const forAgent = url.searchParams.get("for");
190
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
191
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
192
+ const limit = limitRaw ?? undefined;
193
+
194
+ const sinceRaw = parseQueryInt(url.searchParams.get("since"), {
195
+ min: 0,
196
+ max: Number.MAX_SAFE_INTEGER,
197
+ });
198
+ if (Number.isNaN(sinceRaw)) return error("since must be a non-negative integer");
199
+ const since = sinceRaw ?? undefined;
200
+
201
+ const sinceIdRaw = parseQueryInt(url.searchParams.get("sinceId"), {
202
+ min: 1,
203
+ max: Number.MAX_SAFE_INTEGER,
204
+ });
205
+ if (Number.isNaN(sinceIdRaw)) return error("sinceId must be a positive integer");
206
+ const sinceId = sinceIdRaw ?? undefined;
207
+
208
+ const channel = url.searchParams.get("channel") ?? undefined;
209
+
210
+ // No agent filter — return all recent messages
211
+ if (!forAgent) {
212
+ return json(listRecentMessages(limit ?? 100, since, channel));
213
+ }
214
+
215
+ return json(pollMessages({
216
+ for: forAgent,
217
+ since,
218
+ sinceId,
219
+ unread: url.searchParams.get("unread") === "true",
220
+ channel,
221
+ limit,
222
+ }));
223
+ };
224
+
225
+ const getMessageById: Handler = (_req, params) => {
226
+ const id = parseId(params.id);
227
+ if (id === null) return error("invalid message id");
228
+ const msg = getMessage(id);
229
+ return msg ? json(msg) : error("message not found", 404);
230
+ };
231
+
232
+ const getMessageThread: Handler = (_req, params) => {
233
+ const id = parseId(params.id);
234
+ if (id === null) return error("invalid message id");
235
+ const thread = getThread(id);
236
+ if (!thread.length) return error("message not found", 404);
237
+ return json(thread);
238
+ };
239
+
240
+ const postClaimMessage: Handler = async (req, params) => {
241
+ const id = parseId(params.id);
242
+ if (id === null) return error("invalid message id");
243
+ const parsed = await parseBody<{ agentId: string }>(req);
244
+ if (!parsed.ok) return error(parsed.error, parsed.status);
245
+ const body = parsed.body;
246
+ if (!body?.agentId) return error("agentId required");
247
+ const result = claimMessage(id, body.agentId);
248
+ if (result.ok) return json({ ok: true });
249
+ const status =
250
+ result.error === "message not found" ? 404 :
251
+ result.error === "claiming agent not found" ? 400 :
252
+ result.error === "message is not claimable" ? 400 :
253
+ 409;
254
+ return error(result.error!, status);
255
+ };
256
+
257
+ const patchMessage: Handler = async (req, params) => {
258
+ const id = parseId(params.id);
259
+ if (id === null) return error("invalid message id");
260
+ const parsed = await parseBody<{ readBy: string }>(req);
261
+ if (!parsed.ok) return error(parsed.error, parsed.status);
262
+ const body = parsed.body;
263
+ if (!body?.readBy) return error("readBy required");
264
+ return markRead(id, body.readBy) ? json({ ok: true }) : error("message not found", 404);
265
+ };
266
+
267
+ const deleteMessageById: Handler = (_req, params) => {
268
+ const id = parseId(params.id);
269
+ if (id === null) return error("invalid message id");
270
+ return deleteMessage(id) ? json({ ok: true }) : error("message not found", 404);
271
+ };
272
+
273
+ const getCursorRoute: Handler = () => json({ latestId: getLatestMessageId() });
274
+
275
+ // --- Stats ---
276
+
277
+ const getStatsRoute: Handler = () => json(getStats());
278
+
279
+ // --- Router ---
280
+
281
+ interface Route {
282
+ method: string;
283
+ pattern: RegExp;
284
+ paramNames: string[];
285
+ handler: Handler;
286
+ }
287
+
288
+ function route(method: string, path: string, handler: Handler): Route {
289
+ const paramNames: string[] = [];
290
+ const pattern = new RegExp(
291
+ "^" +
292
+ path.replace(/:(\w+)/g, (_, name) => {
293
+ paramNames.push(name);
294
+ return "([^/]+)";
295
+ }) +
296
+ "$"
297
+ );
298
+ return { method, pattern, paramNames, handler };
299
+ }
300
+
301
+ const routes: Route[] = [
302
+ route("POST", "/api/agents", postAgent),
303
+ route("GET", "/api/agents", getAgents),
304
+ route("GET", "/api/agents/find", findAgents),
305
+ route("GET", "/api/agents/:id", getAgentById),
306
+ route("PATCH", "/api/agents/:id/status", patchAgentStatus),
307
+ route("PATCH", "/api/agents/:id/label", patchAgentLabel),
308
+ route("POST", "/api/agents/:id/heartbeat", postHeartbeat),
309
+ route("DELETE", "/api/agents/:id", deleteAgentById),
310
+
311
+ route("POST", "/api/messages", postMessage),
312
+ route("GET", "/api/messages", getMessages),
313
+ route("GET", "/api/messages/cursor", getCursorRoute),
314
+ route("GET", "/api/messages/:id", getMessageById),
315
+ route("GET", "/api/messages/:id/thread", getMessageThread),
316
+ route("POST", "/api/messages/:id/claim", postClaimMessage),
317
+ route("PATCH", "/api/messages/:id", patchMessage),
318
+ route("DELETE", "/api/messages/:id", deleteMessageById),
319
+
320
+ route("GET", "/api/stats", getStatsRoute),
321
+ ];
322
+
323
+ export function matchRoute(
324
+ method: string,
325
+ pathname: string
326
+ ): { handler: Handler; params: Record<string, string> } | null {
327
+ for (const r of routes) {
328
+ if (r.method !== method) continue;
329
+ const match = pathname.match(r.pattern);
330
+ if (!match) continue;
331
+
332
+ const params: Record<string, string> = {};
333
+ try {
334
+ r.paramNames.forEach((name, i) => {
335
+ params[name] = decodeURIComponent(match[i + 1]!);
336
+ });
337
+ } catch {
338
+ return { handler: () => error("invalid url encoding"), params: {} };
339
+ }
340
+ return { handler: r.handler, params };
341
+ }
342
+ return null;
343
+ }
package/src/types.ts ADDED
@@ -0,0 +1,62 @@
1
+ export interface AgentCard {
2
+ id: string;
3
+ name: string;
4
+ label?: string; // human-friendly alias; acts as a fan-out target ("label:foo")
5
+ tags: string[];
6
+ machine?: string;
7
+ rig?: string;
8
+ capabilities: string[];
9
+ status: "online" | "idle" | "busy" | "offline";
10
+ meta?: Record<string, unknown>;
11
+ lastSeen: number;
12
+ createdAt: number;
13
+ }
14
+
15
+ export interface Message {
16
+ id: number;
17
+ from: string;
18
+ to: string; // agent-id | "tag:<name>" | "broadcast" | "cap:<name>"
19
+ channel?: string;
20
+ subject?: string;
21
+ body: string;
22
+ threadId?: number;
23
+ replyTo?: number;
24
+ claimable?: boolean;
25
+ claimedBy?: string;
26
+ claimedAt?: number;
27
+ meta?: Record<string, unknown>;
28
+ readBy: string[];
29
+ createdAt: number;
30
+ }
31
+
32
+ export interface SendMessageInput {
33
+ from: string;
34
+ to: string;
35
+ channel?: string;
36
+ subject?: string;
37
+ body: string;
38
+ replyTo?: number;
39
+ claimable?: boolean;
40
+ meta?: Record<string, unknown>;
41
+ }
42
+
43
+ export interface PollQuery {
44
+ for: string; // agent-id
45
+ since?: number; // unix ms (createdAt cursor)
46
+ sinceId?: number; // monotonic message id cursor (preferred — avoids same-ms collisions)
47
+ unread?: boolean;
48
+ channel?: string;
49
+ limit?: number;
50
+ }
51
+
52
+ export interface RegisterAgentInput {
53
+ id: string;
54
+ name: string;
55
+ label?: string;
56
+ tags?: string[];
57
+ machine?: string;
58
+ rig?: string;
59
+ capabilities?: string[];
60
+ status?: AgentCard["status"];
61
+ meta?: Record<string, unknown>;
62
+ }