agent-relay-server 0.3.12 → 0.4.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/README.md +237 -22
- package/bin/agent-relay-codex.ts +79 -6
- package/codex/README.md +18 -3
- package/codex/hooks/session-start.ts +2 -2
- package/codex/live-sidecar.ts +2 -4
- package/codex/plugin/.codex-plugin/plugin.json +1 -1
- package/codex/plugin/skills/agent-relay/SKILL.md +1 -0
- package/codex/relay.ts +8 -3
- package/examples/integrations/github-issue.ts +54 -0
- package/examples/integrations/ops-alert.sh +27 -0
- package/examples/integrations/prometheus-alertmanager.ts +61 -0
- package/examples/integrations/support-ticket.sh +28 -0
- package/package.json +5 -4
- package/public/dashboard.js +701 -0
- package/public/index.html +143 -504
- package/src/cli.ts +217 -0
- package/src/config.ts +38 -0
- package/src/daemon.ts +453 -0
- package/src/db.ts +442 -16
- package/src/index.ts +96 -70
- package/src/routes.ts +334 -17
- package/src/security.ts +103 -0
- package/src/setup.ts +187 -0
- package/src/sse.ts +18 -2
- package/src/types.ts +67 -1
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
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
if (await file.exists()) return new Response(file);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
93
117
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
});
|
|
118
|
+
console.log(`agent-relay ${VERSION} running on http://${HOST}:${PORT}`);
|
|
119
|
+
}
|
|
97
120
|
|
|
98
|
-
|
|
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,
|
|
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<
|
|
340
|
+
const parsed = await parseBody<unknown>(req);
|
|
119
341
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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<
|
|
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
|
];
|