agent-relay-server 0.3.12 → 0.4.1

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/index.ts CHANGED
@@ -11,88 +11,114 @@ import {
11
11
  DAY_MS,
12
12
  VERSION,
13
13
  } from "./config";
14
+ import {
15
+ applyCors,
16
+ assertSafeNetworkConfig,
17
+ corsPreflight,
18
+ getIntegrationAuth,
19
+ isAuthorized,
20
+ isOriginAllowed,
21
+ unauthorized,
22
+ } from "./security";
23
+ import { handleCli } from "./cli";
14
24
 
15
- const PORT = Number(process.env.PORT) || 4850;
16
- const HOST = process.env.HOST || "127.0.0.1";
17
- const DB_PATH = process.env.DB_PATH || "agent-relay.db";
18
- const RETENTION_DAYS = Number(process.env.RETENTION_DAYS) || 30;
19
- const LOG_REQUESTS = process.env.AGENT_RELAY_LOG_REQUESTS === "1";
25
+ async function main(): Promise<void> {
26
+ const result = await handleCli(process.argv.slice(2));
27
+ if (result === "handled") return;
28
+ startServer();
29
+ }
20
30
 
21
- initDb(DB_PATH);
31
+ function startServer(): void {
32
+ const PORT = Number(process.env.PORT) || 4850;
33
+ const HOST = process.env.HOST || "127.0.0.1";
34
+ const DB_PATH = process.env.DB_PATH || "agent-relay.db";
35
+ const RETENTION_DAYS = Number(process.env.RETENTION_DAYS) || 30;
36
+ const LOG_REQUESTS = process.env.AGENT_RELAY_LOG_REQUESTS === "1";
22
37
 
23
- setInterval(() => {
24
- const reaped = reapStaleAgents(STALE_TTL_MS);
25
- if (reaped.length > 0) {
26
- console.log(`reaped ${reaped.length} stale agent(s)`);
27
- for (const id of reaped) emitAgentStatus(id);
28
- }
29
- const pruned = pruneOfflineAgents(OFFLINE_PRUNE_MS);
30
- if (pruned.length > 0) {
31
- console.log(`pruned ${pruned.length} offline agent(s)`);
32
- for (const id of pruned) emitAgentRemoved(id);
33
- }
34
- }, REAP_INTERVAL_MS);
38
+ assertSafeNetworkConfig(HOST);
39
+ initDb(DB_PATH);
35
40
 
36
- // Daily message prune
37
- setInterval(() => {
38
- const pruned = pruneOldMessages(RETENTION_DAYS * DAY_MS);
39
- if (pruned > 0) console.log(`pruned ${pruned} old message(s)`);
40
- }, DAY_MS);
41
+ setInterval(() => {
42
+ const reaped = reapStaleAgents(STALE_TTL_MS);
43
+ if (reaped.length > 0) {
44
+ console.log(`reaped ${reaped.length} stale agent(s)`);
45
+ for (const id of reaped) emitAgentStatus(id);
46
+ }
47
+ const pruned = pruneOfflineAgents(OFFLINE_PRUNE_MS);
48
+ if (pruned.length > 0) {
49
+ console.log(`pruned ${pruned.length} offline agent(s)`);
50
+ for (const id of pruned) emitAgentRemoved(id);
51
+ }
52
+ }, REAP_INTERVAL_MS);
41
53
 
42
- const publicDir = resolve(import.meta.dir, "../public");
43
- const publicDirPrefix = publicDir + sep;
54
+ // Daily message prune
55
+ setInterval(() => {
56
+ const pruned = pruneOldMessages(RETENTION_DAYS * DAY_MS);
57
+ if (pruned > 0) console.log(`pruned ${pruned} old message(s)`);
58
+ }, DAY_MS);
44
59
 
45
- Bun.serve({
46
- port: PORT,
47
- hostname: HOST,
48
- async fetch(req) {
49
- const url = new URL(req.url);
60
+ const publicDir = resolve(import.meta.dir, "../public");
61
+ const publicDirPrefix = publicDir + sep;
50
62
 
51
- // CORS
52
- if (req.method === "OPTIONS") {
53
- return new Response(null, {
54
- headers: {
55
- "Access-Control-Allow-Origin": "*",
56
- "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
57
- "Access-Control-Allow-Headers": "Content-Type",
58
- },
59
- });
60
- }
63
+ Bun.serve({
64
+ port: PORT,
65
+ hostname: HOST,
66
+ async fetch(req) {
67
+ const url = new URL(req.url);
61
68
 
62
- // Body size guard for write methods
63
- if (req.method === "POST" || req.method === "PATCH" || req.method === "PUT") {
64
- const len = Number(req.headers.get("content-length") ?? 0);
65
- if (len > MAX_BODY_BYTES) {
66
- return Response.json(
67
- { error: `request body exceeds ${MAX_BODY_BYTES} bytes` },
68
- { status: 413 },
69
- );
69
+ if (req.method === "OPTIONS") {
70
+ return corsPreflight(req);
71
+ }
72
+ if (!isOriginAllowed(req)) {
73
+ return Response.json({ error: "origin not allowed" }, { status: 403 });
70
74
  }
71
- }
72
75
 
73
- // API routes
74
- const matched = matchRoute(req.method, url.pathname);
75
- if (matched) {
76
- const response = await matched.handler(req, matched.params);
77
- response.headers.set("Access-Control-Allow-Origin", "*");
78
- if (LOG_REQUESTS && url.pathname.startsWith("/api/")) {
79
- console.log(`${req.method} ${url.pathname} ${response.status}`);
76
+ // Body size guard for write methods
77
+ if (req.method === "POST" || req.method === "PATCH" || req.method === "PUT") {
78
+ const len = Number(req.headers.get("content-length") ?? 0);
79
+ if (len > MAX_BODY_BYTES) {
80
+ return Response.json(
81
+ { error: `request body exceeds ${MAX_BODY_BYTES} bytes` },
82
+ { status: 413 },
83
+ );
84
+ }
80
85
  }
81
- return response;
82
- }
83
86
 
84
- // Dashboard — serve static files, rejecting path traversal and directory requests
85
- let requested = url.pathname === "/" ? "/index.html" : url.pathname;
86
- if (requested.endsWith("/")) requested += "index.html";
87
- const resolved = resolve(publicDir, `.${requested}`);
88
- if (!resolved.startsWith(publicDirPrefix)) {
87
+ // API routes
88
+ const matched = matchRoute(req.method, url.pathname);
89
+ if (matched) {
90
+ const integrationAuth = getIntegrationAuth(req);
91
+ if (!isAuthorized(req)) {
92
+ if (!integrationAuth || !url.pathname.startsWith("/api/integrations/")) {
93
+ return unauthorized(req);
94
+ }
95
+ }
96
+ const response = await matched.handler(req, matched.params);
97
+ applyCors(req, response);
98
+ if (LOG_REQUESTS && url.pathname.startsWith("/api/")) {
99
+ console.log(`${req.method} ${url.pathname} → ${response.status}`);
100
+ }
101
+ return response;
102
+ }
103
+
104
+ // Dashboard — serve static files, rejecting path traversal and directory requests
105
+ let requested = url.pathname === "/" ? "/index.html" : url.pathname;
106
+ if (requested.endsWith("/")) requested += "index.html";
107
+ const resolved = resolve(publicDir, `.${requested}`);
108
+ if (!resolved.startsWith(publicDirPrefix)) {
109
+ return Response.json({ error: "not found" }, { status: 404 });
110
+ }
111
+ const file = Bun.file(resolved);
112
+ if (await file.exists()) return new Response(file);
113
+
89
114
  return Response.json({ error: "not found" }, { status: 404 });
90
- }
91
- const file = Bun.file(resolved);
92
- if (await file.exists()) return new Response(file);
115
+ },
116
+ });
93
117
 
94
- return Response.json({ error: "not found" }, { status: 404 });
95
- },
96
- });
118
+ console.log(`agent-relay ${VERSION} running on http://${HOST}:${PORT}`);
119
+ }
97
120
 
98
- console.log(`agent-relay ${VERSION} running on http://${HOST}:${PORT}`);
121
+ main().catch((error) => {
122
+ console.error(error instanceof Error ? error.message : String(error));
123
+ process.exit(1);
124
+ });
package/src/routes.ts CHANGED
@@ -18,10 +18,23 @@ import {
18
18
  deleteMessage,
19
19
  getStats,
20
20
  getLatestMessageId,
21
+ ingestIntegrationEvent,
22
+ listTasks,
23
+ getTask,
24
+ listTaskEvents,
25
+ claimTask,
26
+ updateTaskStatus,
27
+ createCallbackDelivery,
28
+ finishCallbackDelivery,
21
29
  ValidationError,
22
30
  } from "./db";
23
- import type { RegisterAgentInput, SendMessageInput, PollQuery } from "./types";
24
- import { MAX_BODY_BYTES } from "./config";
31
+ import type { IntegrationEventInput, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
32
+ import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
33
+ import {
34
+ getIntegrationAuth,
35
+ hasIntegrationScope,
36
+ isIntegrationAllowed,
37
+ } from "./security";
25
38
  import {
26
39
  createSSEStream,
27
40
  emitNewMessage,
@@ -29,6 +42,7 @@ import {
29
42
  emitAgentRemoved,
30
43
  emitMessageClaimed,
31
44
  emitMessageDeleted,
45
+ emitTaskChanged,
32
46
  } from "./sse";
33
47
 
34
48
  type Handler = (
@@ -112,16 +126,227 @@ function parseQueryInt(
112
126
  return n;
113
127
  }
114
128
 
129
+ const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
130
+ const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
131
+ const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
132
+ const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
133
+
134
+ function isRecord(value: unknown): value is Record<string, unknown> {
135
+ return typeof value === "object" && value !== null && !Array.isArray(value);
136
+ }
137
+
138
+ function cleanString(
139
+ value: unknown,
140
+ field: string,
141
+ opts: { required?: boolean; max?: number } = {},
142
+ ): string | undefined {
143
+ if (value === undefined || value === null) {
144
+ if (opts.required) throw new ValidationError(`${field} required`);
145
+ return undefined;
146
+ }
147
+ if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
148
+ const trimmed = value.trim();
149
+ if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
150
+ if (opts.max && trimmed.length > opts.max) {
151
+ throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
152
+ }
153
+ return trimmed || undefined;
154
+ }
155
+
156
+ function cleanNullableString(value: unknown, field: string, max: number): string | null | undefined {
157
+ if (value === undefined) return undefined;
158
+ if (value === null) return null;
159
+ return cleanString(value, field, { max }) ?? null;
160
+ }
161
+
162
+ function cleanStringArray(value: unknown, field: string): string[] | undefined {
163
+ if (value === undefined || value === null) return undefined;
164
+ if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
165
+ const cleaned = value.map((item) => cleanString(item, `${field} item`, { max: 80 })).filter(Boolean) as string[];
166
+ if (cleaned.length > 50) throw new ValidationError(`${field} can contain at most 50 values`);
167
+ return [...new Set(cleaned)];
168
+ }
169
+
170
+ function cleanMeta(value: unknown): Record<string, unknown> | undefined {
171
+ if (value === undefined || value === null) return undefined;
172
+ if (!isRecord(value)) throw new ValidationError("meta must be an object");
173
+ if (JSON.stringify(value).length > 8192) throw new ValidationError("meta is too large");
174
+ return value;
175
+ }
176
+
177
+ function cleanEnum<T extends readonly string[]>(
178
+ value: unknown,
179
+ field: string,
180
+ valid: T,
181
+ fallback?: T[number],
182
+ ): T[number] | undefined {
183
+ if (value === undefined || value === null) return fallback;
184
+ if (typeof value !== "string" || !valid.includes(value)) {
185
+ throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
186
+ }
187
+ return value as T[number];
188
+ }
189
+
190
+ function cleanPositiveId(value: unknown, field: string): number | undefined {
191
+ if (value === undefined || value === null) return undefined;
192
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
193
+ throw new ValidationError(`${field} must be a positive integer`);
194
+ }
195
+ return value;
196
+ }
197
+
198
+ function normalizeAgentInput(body: unknown): RegisterAgentInput {
199
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
200
+ const status = cleanString(body.status, "status", { max: 20 });
201
+ if (status && !VALID_AGENT_STATUSES.includes(status as any)) {
202
+ throw new ValidationError(`status must be one of: ${VALID_AGENT_STATUSES.join(", ")}`);
203
+ }
204
+ if (body.ready !== undefined && typeof body.ready !== "boolean") {
205
+ throw new ValidationError("ready must be a boolean");
206
+ }
207
+
208
+ const input: RegisterAgentInput = {
209
+ id: cleanString(body.id, "id", { required: true, max: 200 })!,
210
+ name: cleanString(body.name, "name", { required: true, max: 200 })!,
211
+ status: status as RegisterAgentInput["status"] | undefined,
212
+ ready: body.ready as boolean | undefined,
213
+ };
214
+
215
+ const label = cleanNullableString(body.label, "label", 120);
216
+ if (label !== undefined) input.label = label;
217
+ const tags = cleanStringArray(body.tags, "tags");
218
+ if (tags) input.tags = tags;
219
+ const capabilities = cleanStringArray(body.capabilities, "capabilities");
220
+ if (capabilities) input.capabilities = capabilities;
221
+ const machine = cleanString(body.machine, "machine", { max: 120 });
222
+ if (machine) input.machine = machine;
223
+ const rig = cleanString(body.rig, "rig", { max: 120 });
224
+ if (rig) input.rig = rig;
225
+ const meta = cleanMeta(body.meta);
226
+ if (meta) input.meta = meta;
227
+
228
+ return input;
229
+ }
230
+
231
+ function normalizeMessageInput(body: unknown): SendMessageInput {
232
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
233
+ const type = cleanString(body.type, "type", { max: 20 });
234
+ if (type && !VALID_MSG_TYPES.includes(type)) {
235
+ throw new ValidationError(`type must be one of: ${VALID_MSG_TYPES.join(", ")}`);
236
+ }
237
+ if (body.claimable !== undefined && typeof body.claimable !== "boolean") {
238
+ throw new ValidationError("claimable must be a boolean");
239
+ }
240
+
241
+ const input: SendMessageInput = {
242
+ from: cleanString(body.from, "from", { required: true, max: 200 })!,
243
+ to: cleanString(body.to, "to", { required: true, max: 200 })!,
244
+ body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
245
+ type: type as SendMessageInput["type"] | undefined,
246
+ replyTo: cleanPositiveId(body.replyTo, "replyTo"),
247
+ claimable: body.claimable as boolean | undefined,
248
+ };
249
+
250
+ const channel = cleanString(body.channel, "channel", { max: 120 });
251
+ if (channel) input.channel = channel;
252
+ const subject = cleanString(body.subject, "subject", { max: 200 });
253
+ if (subject) input.subject = subject;
254
+ const meta = cleanMeta(body.meta);
255
+ if (meta) input.meta = meta;
256
+
257
+ return input;
258
+ }
259
+
260
+ function normalizeIntegrationEvent(body: unknown): IntegrationEventInput {
261
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
262
+ const status = cleanEnum(body.status, "status", [...VALID_TASK_STATUSES, "resolved"] as const);
263
+ return {
264
+ source: cleanString(body.source, "source", { max: 120 }),
265
+ type: cleanString(body.type, "type", { max: 80 }) ?? "event",
266
+ severity: cleanEnum(body.severity, "severity", VALID_TASK_SEVERITIES, "info"),
267
+ status,
268
+ title: cleanString(body.title, "title", { required: true, max: 240 })!,
269
+ body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
270
+ target: cleanString(body.target, "target", { required: true, max: 200 })!,
271
+ channel: cleanString(body.channel, "channel", { max: 120 }),
272
+ dedupeKey: cleanString(body.dedupeKey, "dedupeKey", { max: 240 }),
273
+ externalUrl: cleanString(body.externalUrl, "externalUrl", { max: 1000 }),
274
+ metadata: cleanMeta(body.metadata),
275
+ };
276
+ }
277
+
278
+ function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
279
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
280
+ const status = cleanEnum(body.status, "status", VALID_TASK_STATUSES);
281
+ if (!status) throw new ValidationError("status required");
282
+ return {
283
+ status: status as TaskStatus,
284
+ agentId: cleanString(body.agentId, "agentId", { max: 200 }),
285
+ result: cleanString(body.result, "result", { max: MAX_BODY_BYTES }),
286
+ body: cleanString(body.body, "body", { max: MAX_BODY_BYTES }),
287
+ metadata: cleanMeta(body.metadata),
288
+ };
289
+ }
290
+
291
+ function checkIntegrationRateLimit(name: string): boolean {
292
+ const now = Date.now();
293
+ const windowMs = 60_000;
294
+ const bucket = integrationRateBuckets.get(name);
295
+ if (!bucket || now - bucket.windowStart >= windowMs) {
296
+ integrationRateBuckets.set(name, { windowStart: now, count: 1 });
297
+ return true;
298
+ }
299
+ bucket.count += 1;
300
+ return bucket.count <= INTEGRATION_RATE_LIMIT_PER_MINUTE;
301
+ }
302
+
303
+ async function dispatchTaskCallbacks(taskId: number, eventType: string): Promise<void> {
304
+ const task = getTask(taskId);
305
+ if (!task) return;
306
+ const integrations = getIntegrationTokens()
307
+ .filter((integration) => integration.name === task.source)
308
+ .filter((integration) => integration.callbackUrl)
309
+ .filter((integration) => !integration.targets?.length || integration.targets.includes(task.target))
310
+ .filter((integration) => !integration.channels?.length || !task.channel || integration.channels.includes(task.channel));
311
+
312
+ for (const integration of integrations) {
313
+ const payload = { event: eventType, task };
314
+ const deliveryId = createCallbackDelivery(task.id, integration.callbackUrl!, eventType, payload);
315
+ void postCallback(deliveryId, integration.callbackUrl!, payload);
316
+ }
317
+ }
318
+
319
+ async function postCallback(deliveryId: number, url: string, payload: unknown): Promise<void> {
320
+ const controller = new AbortController();
321
+ const timeout = setTimeout(() => controller.abort(), 3000);
322
+ try {
323
+ const response = await fetch(url, {
324
+ method: "POST",
325
+ headers: { "Content-Type": "application/json" },
326
+ body: JSON.stringify(payload),
327
+ signal: controller.signal,
328
+ });
329
+ finishCallbackDelivery(deliveryId, response.ok, response.ok ? undefined : `${response.status} ${response.statusText}`);
330
+ } catch (e) {
331
+ finishCallbackDelivery(deliveryId, false, e instanceof Error ? e.message : String(e));
332
+ } finally {
333
+ clearTimeout(timeout);
334
+ }
335
+ }
336
+
115
337
  // --- Agent routes ---
116
338
 
117
339
  const postAgent: Handler = async (req) => {
118
- const parsed = await parseBody<RegisterAgentInput>(req);
340
+ const parsed = await parseBody<unknown>(req);
119
341
  if (!parsed.ok) return error(parsed.error, parsed.status);
120
- const body = parsed.body;
121
- if (!body?.id || !body?.name) return error("id and name required");
122
- const agent = upsertAgent(body);
123
- emitAgentStatus(agent.id);
124
- return json(agent, 201);
342
+ try {
343
+ const agent = upsertAgent(normalizeAgentInput(parsed.body));
344
+ emitAgentStatus(agent.id);
345
+ return json(agent, 201);
346
+ } catch (e) {
347
+ if (e instanceof ValidationError) return error(e.message, 400);
348
+ throw e;
349
+ }
125
350
  };
126
351
 
127
352
  const getAgents: Handler = (req) => {
@@ -196,17 +421,10 @@ const deleteAgentById: Handler = (_req, params) => {
196
421
  const VALID_MSG_TYPES = ["message", "system"];
197
422
 
198
423
  const postMessage: Handler = async (req) => {
199
- const parsed = await parseBody<SendMessageInput>(req);
424
+ const parsed = await parseBody<unknown>(req);
200
425
  if (!parsed.ok) return error(parsed.error, parsed.status);
201
- const body = parsed.body;
202
- if (!body?.from || !body?.to || !body?.body) {
203
- return error("from, to, and body required");
204
- }
205
- if (body.type && !VALID_MSG_TYPES.includes(body.type)) {
206
- return error(`type must be one of: ${VALID_MSG_TYPES.join(", ")}`);
207
- }
208
426
  try {
209
- const msg = sendMessage(body);
427
+ const msg = sendMessage(normalizeMessageInput(parsed.body));
210
428
  emitNewMessage(msg);
211
429
  return json(msg, 201);
212
430
  } catch (e) {
@@ -299,6 +517,10 @@ const postClaimMessage: Handler = async (req, params) => {
299
517
  const result = claimMessage(id, body.agentId);
300
518
  if (result.ok) {
301
519
  emitMessageClaimed(id, body.agentId);
520
+ if (result.task) {
521
+ emitTaskChanged(result.task, "task.claimed");
522
+ void dispatchTaskCallbacks(result.task.id, "task.claimed");
523
+ }
302
524
  return json({ ok: true });
303
525
  }
304
526
  const status =
@@ -329,6 +551,94 @@ const deleteMessageById: Handler = (_req, params) => {
329
551
 
330
552
  const getCursorRoute: Handler = () => json({ latestId: getLatestMessageId() });
331
553
 
554
+ // --- Tasks and integrations ---
555
+
556
+ const postIntegrationEvent: Handler = async (req) => {
557
+ const auth = getIntegrationAuth(req);
558
+ if (!auth) return error("integration token required", 401);
559
+ if (!checkIntegrationRateLimit(auth.name)) return error("integration rate limit exceeded", 429);
560
+ if (!hasIntegrationScope(auth, "tasks:create") && !hasIntegrationScope(auth, "events:create")) {
561
+ return error("integration token missing tasks:create scope", 403);
562
+ }
563
+
564
+ const parsed = await parseBody<unknown>(req);
565
+ if (!parsed.ok) return error(parsed.error, parsed.status);
566
+ try {
567
+ const input = { ...normalizeIntegrationEvent(parsed.body), source: auth.name };
568
+ if (!isIntegrationAllowed(auth, { target: input.target, channel: input.channel })) {
569
+ return error("integration token cannot target this task", 403);
570
+ }
571
+ const result = ingestIntegrationEvent(input, auth.name);
572
+ if (result.message) emitNewMessage(result.message);
573
+ emitTaskChanged(result.task, result.created ? "task.created" : "task.updated");
574
+ void dispatchTaskCallbacks(result.task.id, result.created ? "task.created" : "task.updated");
575
+ return json(result, result.created ? 201 : 200);
576
+ } catch (e) {
577
+ if (e instanceof ValidationError) return error(e.message, 400);
578
+ throw e;
579
+ }
580
+ };
581
+
582
+ const getTasks: Handler = (req) => {
583
+ const url = new URL(req.url);
584
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
585
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
586
+ return json(listTasks({
587
+ status: url.searchParams.get("status") ?? undefined,
588
+ source: url.searchParams.get("source") ?? undefined,
589
+ target: url.searchParams.get("target") ?? undefined,
590
+ limit: limitRaw ?? 100,
591
+ }));
592
+ };
593
+
594
+ const getTaskById: Handler = (_req, params) => {
595
+ const id = parseId(params.id);
596
+ if (id === null) return error("invalid task id");
597
+ const task = getTask(id);
598
+ return task ? json(task) : error("task not found", 404);
599
+ };
600
+
601
+ const getTaskEvents: Handler = (_req, params) => {
602
+ const id = parseId(params.id);
603
+ if (id === null) return error("invalid task id");
604
+ if (!getTask(id)) return error("task not found", 404);
605
+ return json(listTaskEvents(id));
606
+ };
607
+
608
+ const postClaimTask: Handler = async (req, params) => {
609
+ const id = parseId(params.id);
610
+ if (id === null) return error("invalid task id");
611
+ const parsed = await parseBody<{ agentId: string }>(req);
612
+ if (!parsed.ok) return error(parsed.error, parsed.status);
613
+ const agentId = parsed.body?.agentId;
614
+ if (!agentId) return error("agentId required");
615
+ const result = claimTask(id, agentId);
616
+ if (!result.ok) {
617
+ const status = result.error === "task not found" ? 404 : result.error?.includes("race") ? 409 : 400;
618
+ return error(result.error!, status);
619
+ }
620
+ emitTaskChanged(result.task!, "task.claimed");
621
+ void dispatchTaskCallbacks(id, "task.claimed");
622
+ return json(result.task);
623
+ };
624
+
625
+ const patchTaskStatus: Handler = async (req, params) => {
626
+ const id = parseId(params.id);
627
+ if (id === null) return error("invalid task id");
628
+ const parsed = await parseBody<unknown>(req);
629
+ if (!parsed.ok) return error(parsed.error, parsed.status);
630
+ try {
631
+ const result = updateTaskStatus(id, normalizeTaskStatusInput(parsed.body));
632
+ if (!result.ok) return error(result.error!, result.error === "task not found" ? 404 : 400);
633
+ emitTaskChanged(result.task!, "task.status");
634
+ void dispatchTaskCallbacks(id, "task.status");
635
+ return json({ task: result.task, event: result.event });
636
+ } catch (e) {
637
+ if (e instanceof ValidationError) return error(e.message, 400);
638
+ throw e;
639
+ }
640
+ };
641
+
332
642
  // --- SSE ---
333
643
 
334
644
  const getEvents: Handler = (req) => {
@@ -384,6 +694,13 @@ const routes: Route[] = [
384
694
  route("PATCH", "/api/messages/:id", patchMessage),
385
695
  route("DELETE", "/api/messages/:id", deleteMessageById),
386
696
 
697
+ route("POST", "/api/integrations/events", postIntegrationEvent),
698
+ route("GET", "/api/tasks", getTasks),
699
+ route("GET", "/api/tasks/:id", getTaskById),
700
+ route("GET", "/api/tasks/:id/events", getTaskEvents),
701
+ route("POST", "/api/tasks/:id/claim", postClaimTask),
702
+ route("PATCH", "/api/tasks/:id/status", patchTaskStatus),
703
+
387
704
  route("GET", "/api/events", getEvents),
388
705
  route("GET", "/api/stats", getStatsRoute),
389
706
  ];