agent-relay-server 0.32.2 → 0.32.3
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/package.json +1 -1
- package/src/ratchet-files.ts +37 -0
- package/src/routes/_shared.ts +376 -0
- package/src/routes/activity.ts +61 -0
- package/src/routes/agent-profiles.ts +47 -0
- package/src/routes/agent-sessions.ts +488 -0
- package/src/routes/agents-spawn.ts +274 -0
- package/src/routes/agents.ts +251 -0
- package/src/routes/artifacts.ts +226 -0
- package/src/routes/automations.ts +83 -0
- package/src/routes/commands.ts +317 -0
- package/src/routes/config.ts +66 -0
- package/src/routes/connectors.ts +108 -0
- package/src/routes/inbox.ts +142 -0
- package/src/routes/index.ts +293 -0
- package/src/routes/insights.ts +81 -0
- package/src/routes/integrations.ts +592 -0
- package/src/routes/memory.ts +337 -0
- package/src/routes/messages.ts +529 -0
- package/src/routes/orchestrator-bootstrap.ts +100 -0
- package/src/routes/orchestrator-proxy.ts +160 -0
- package/src/routes/orchestrator.ts +490 -0
- package/src/routes/pairs.ts +197 -0
- package/src/routes/provider-config.ts +112 -0
- package/src/routes/recipes.ts +113 -0
- package/src/routes/spawn-policy.ts +231 -0
- package/src/routes/spec.ts +54 -0
- package/src/routes/sse.ts +9 -0
- package/src/routes/stats.ts +32 -0
- package/src/routes/steward.ts +45 -0
- package/src/routes/tasks.ts +174 -0
- package/src/routes/tokens.ts +311 -0
- package/src/routes/workspaces.ts +355 -0
- package/src/routes.ts +3 -6822
- package/src/validation.ts +134 -0
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Shared file enumeration for the ratchet tests (duplication + file-size).
|
|
2
|
+
//
|
|
3
|
+
// Kept in ONE place so the two ratchets can never drift on *which* files they
|
|
4
|
+
// scan — a file-size ratchet that duplicated its own harness would be the very
|
|
5
|
+
// thing it exists to prevent (see issue #300). Both `duplication-ratchet.test.ts`
|
|
6
|
+
// and `file-size-ratchet.test.ts` import from here.
|
|
7
|
+
|
|
8
|
+
import { Glob } from "bun";
|
|
9
|
+
|
|
10
|
+
// Source roots scanned by the ratchets. Mirror real package layout; generated
|
|
11
|
+
// and bundled output (public/, dist/, node_modules/) is excluded below.
|
|
12
|
+
export const RATCHET_ROOTS = [
|
|
13
|
+
"src",
|
|
14
|
+
"sdk/src",
|
|
15
|
+
"client",
|
|
16
|
+
"orchestrator/src",
|
|
17
|
+
"runner/src",
|
|
18
|
+
"connectors",
|
|
19
|
+
"dashboard/src",
|
|
20
|
+
"scripts",
|
|
21
|
+
"examples",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// All tracked first-party .ts/.tsx source files, excluding tests, type
|
|
25
|
+
// declarations, and build output. Paths are returned relative to repo root.
|
|
26
|
+
export function ratchetSourceFiles(): string[] {
|
|
27
|
+
const files: string[] = [];
|
|
28
|
+
for (const root of RATCHET_ROOTS) {
|
|
29
|
+
const glob = new Glob("**/*.{ts,tsx}");
|
|
30
|
+
for (const rel of glob.scanSync({ cwd: root, onlyFiles: true })) {
|
|
31
|
+
if (rel.includes("node_modules") || rel.includes("dist/")) continue;
|
|
32
|
+
if (rel.endsWith(".test.ts") || rel.endsWith(".test.tsx") || rel.endsWith(".d.ts")) continue;
|
|
33
|
+
files.push(`${root}/${rel}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return files;
|
|
37
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// Auto-split from routes.ts (#299). Domain: _shared.
|
|
2
|
+
import { MAX_BODY_BYTES, getIntegrationTokens } from "../config";
|
|
3
|
+
import { MemoryBrokerContractError } from "../memory-broker-contract";
|
|
4
|
+
import { ValidationError, createActivityEvent, createCallbackDelivery, finishCallbackDelivery, getAgent, getTask, normalizeReactionEmoji, sendMessageWithResult } from "../db";
|
|
5
|
+
import { cleanEpoch, cleanString, optionalEnum } from "../validation";
|
|
6
|
+
import { emitActivityEvent, emitMessageQueued, emitNewMessage } from "../sse";
|
|
7
|
+
import { emitRelayEvent } from "../events";
|
|
8
|
+
import { errMessage, isRecord } from "agent-relay-sdk";
|
|
9
|
+
import { generateSpawnRequestId } from "../spawn-command";
|
|
10
|
+
import { getComponentAuth, getIntegrationAuth, isAuthorized, isRequestAuthorizedFor } from "../security";
|
|
11
|
+
import { readBodyBytes } from "../http-body";
|
|
12
|
+
import { type ActivityEventInput, type AgentSessionGuard, type ArtifactKind, type AttachmentRef, type Command, type MemoryBrokerContext, type Message } from "../types";
|
|
13
|
+
|
|
14
|
+
export type Handler = (
|
|
15
|
+
req: Request,
|
|
16
|
+
params: Record<string, string>
|
|
17
|
+
) => Response | Promise<Response>;
|
|
18
|
+
|
|
19
|
+
export const json = (data: unknown, status = 200) =>
|
|
20
|
+
Response.json(data, { status });
|
|
21
|
+
|
|
22
|
+
export const error = (msg: string, status = 400) =>
|
|
23
|
+
json({ error: msg }, status);
|
|
24
|
+
|
|
25
|
+
export function authorizeRoute(req: Request, check: Parameters<typeof isRequestAuthorizedFor>[1]): Response | null {
|
|
26
|
+
if (!hasPresentedCredential(req)) return null;
|
|
27
|
+
return isRequestAuthorizedFor(req, check) ? null : error("forbidden", 403);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasPresentedCredential(req: Request): boolean {
|
|
31
|
+
return Boolean(req.headers.get("authorization") || req.headers.get("x-agent-relay-token") || new URL(req.url).searchParams.get("token"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function authAuditMetadata(req: Request): Record<string, unknown> {
|
|
35
|
+
const component = getComponentAuth(req);
|
|
36
|
+
if (component) {
|
|
37
|
+
return {
|
|
38
|
+
auth: {
|
|
39
|
+
type: "component",
|
|
40
|
+
sub: component.sub,
|
|
41
|
+
role: component.role,
|
|
42
|
+
jti: component.jti,
|
|
43
|
+
scope: component.scope,
|
|
44
|
+
constraints: component.constraints,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const integration = getIntegrationAuth(req);
|
|
49
|
+
if (integration) {
|
|
50
|
+
return {
|
|
51
|
+
auth: {
|
|
52
|
+
type: "integration",
|
|
53
|
+
name: integration.name,
|
|
54
|
+
scopes: integration.scopes,
|
|
55
|
+
targets: integration.targets,
|
|
56
|
+
channels: integration.channels,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return hasPresentedCredential(req) ? { auth: { type: "root" } } : {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isRootCredentialRequest(req: Request): boolean {
|
|
64
|
+
return hasPresentedCredential(req) && isAuthorized(req) && !getComponentAuth(req) && !getIntegrationAuth(req);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sanitizeAttribution(raw: unknown): string | undefined {
|
|
68
|
+
if (typeof raw !== "string") return undefined;
|
|
69
|
+
// eslint-disable-next-line no-control-regex
|
|
70
|
+
const cleaned = raw.replace(/[\x00-\x1f\x7f]/g, "").trim();
|
|
71
|
+
if (!cleaned) return undefined;
|
|
72
|
+
return cleaned.length > 120 ? cleaned.slice(0, 120) : cleaned;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function dashboardAttribution(req: Request, surface?: unknown): Record<string, unknown> {
|
|
76
|
+
const out: Record<string, unknown> = {};
|
|
77
|
+
const client = sanitizeAttribution(req.headers.get("x-dashboard-client-id"));
|
|
78
|
+
const session = sanitizeAttribution(req.headers.get("x-dashboard-session-id"));
|
|
79
|
+
const view = sanitizeAttribution(req.headers.get("x-dashboard-view"));
|
|
80
|
+
const component = isRecord(surface) ? sanitizeAttribution(surface.component) : undefined;
|
|
81
|
+
if (client) out.client = client;
|
|
82
|
+
if (session) out.session = session;
|
|
83
|
+
if (view) out.view = view;
|
|
84
|
+
if (component) out.component = component;
|
|
85
|
+
if (Object.keys(out).length === 0) return {};
|
|
86
|
+
out.route = `${req.method} ${new URL(req.url).pathname}`;
|
|
87
|
+
return { surface: out };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type ParseBodyResult<T> =
|
|
91
|
+
| { ok: true; body: T | null }
|
|
92
|
+
| { ok: false; status: number; error: string };
|
|
93
|
+
|
|
94
|
+
export async function parseBody<T>(req: Request): Promise<ParseBodyResult<T>> {
|
|
95
|
+
if (!req.body) return { ok: true, body: null };
|
|
96
|
+
const read = await readBodyBytes(req.body, MAX_BODY_BYTES);
|
|
97
|
+
if (!read.ok) return { ok: false, status: read.status, error: read.error };
|
|
98
|
+
if (read.bytes.byteLength === 0) return { ok: true, body: null };
|
|
99
|
+
try {
|
|
100
|
+
const decoded = new TextDecoder().decode(read.bytes);
|
|
101
|
+
return { ok: true, body: JSON.parse(decoded) as T };
|
|
102
|
+
} catch {
|
|
103
|
+
return { ok: false, status: 400, error: "invalid JSON body" };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseId(raw: string | undefined): number | null {
|
|
108
|
+
if (!raw) return null;
|
|
109
|
+
const n = Number(raw);
|
|
110
|
+
if (!Number.isInteger(n) || n <= 0 || n > Number.MAX_SAFE_INTEGER) return null;
|
|
111
|
+
return n;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function parseQueryInt(
|
|
115
|
+
raw: string | null,
|
|
116
|
+
opts: { min: number; max: number }
|
|
117
|
+
): number | null {
|
|
118
|
+
if (raw === null) return null;
|
|
119
|
+
if (!/^-?\d+$/.test(raw)) return Number.NaN;
|
|
120
|
+
|
|
121
|
+
const n = Number(raw);
|
|
122
|
+
if (!Number.isSafeInteger(n)) return Number.NaN;
|
|
123
|
+
if (n < opts.min || n > opts.max) return Number.NaN;
|
|
124
|
+
return n;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as const;
|
|
128
|
+
|
|
129
|
+
export const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext", "resume", "interrupt"] as const;
|
|
130
|
+
|
|
131
|
+
export const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "orphaned", "done", "failed", "canceled"] as const;
|
|
132
|
+
|
|
133
|
+
export const VALID_ARTIFACT_KINDS = ["image", "audio", "video", "document", "archive", "other"] as const;
|
|
134
|
+
|
|
135
|
+
export const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
|
|
136
|
+
|
|
137
|
+
export function normalizeAgentSessionGuard(req: Request, body: unknown): AgentSessionGuard | undefined {
|
|
138
|
+
const record = isRecord(body) ? body : {};
|
|
139
|
+
const headerInstance = req.headers.get("x-agent-relay-instance-id") ?? undefined;
|
|
140
|
+
const headerEpoch = req.headers.get("x-agent-relay-epoch") ?? undefined;
|
|
141
|
+
const instanceId = cleanString(headerInstance ?? record.instanceId, "instanceId", { max: 200 });
|
|
142
|
+
const rawEpoch = headerEpoch === undefined ? record.epoch : Number(headerEpoch);
|
|
143
|
+
const epoch = cleanEpoch(rawEpoch, "epoch");
|
|
144
|
+
|
|
145
|
+
if (!instanceId && epoch === undefined) return undefined;
|
|
146
|
+
if (!instanceId) throw new ValidationError("instanceId required when epoch is provided");
|
|
147
|
+
return { instanceId, epoch };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function agentSessionStatus(errorMessage: string | undefined): number {
|
|
151
|
+
if (errorMessage === "agent not found") return 404;
|
|
152
|
+
if (errorMessage === "stale agent instance") return 409;
|
|
153
|
+
return 400;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function withPayloadAttachments(
|
|
157
|
+
payload: Record<string, unknown>,
|
|
158
|
+
attachments: Array<Record<string, unknown>> | undefined,
|
|
159
|
+
): Record<string, unknown> {
|
|
160
|
+
if (!attachments) return payload;
|
|
161
|
+
if (payload.attachments !== undefined) {
|
|
162
|
+
throw new ValidationError("attachments must be provided either top-level or in payload.attachments, not both");
|
|
163
|
+
}
|
|
164
|
+
return { ...payload, attachments };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function validateChannelAttachmentSourceRef(ref: unknown, field: string): void {
|
|
168
|
+
if (!isRecord(ref)) throw new ValidationError(`${field} must be an object`);
|
|
169
|
+
const type = cleanString(ref.type, `${field}.type`, { required: true, max: 40 });
|
|
170
|
+
if (type !== "relay-blob" && type !== "external-url" && type !== "channel-file") {
|
|
171
|
+
throw new ValidationError(`${field}.type must be relay-blob, external-url, or channel-file`);
|
|
172
|
+
}
|
|
173
|
+
if (type === "external-url") cleanString(ref.url, `${field}.url`, { required: true, max: 2048 });
|
|
174
|
+
if (type === "relay-blob" || type === "channel-file") cleanString(ref.id, `${field}.id`, { required: true, max: 400 });
|
|
175
|
+
if (type === "channel-file") {
|
|
176
|
+
cleanString(ref.provider, `${field}.provider`, { max: 80 });
|
|
177
|
+
cleanString(ref.uniqueId, `${field}.uniqueId`, { max: 400 });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function cleanAttachmentRefs(value: unknown, field = "attachments"): AttachmentRef[] | undefined {
|
|
182
|
+
if (value === undefined || value === null) return undefined;
|
|
183
|
+
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
|
|
184
|
+
return value.map((item, index) => {
|
|
185
|
+
if (!isRecord(item)) throw new ValidationError(`${field}[${index}] must be an object`);
|
|
186
|
+
const ref = item.ref;
|
|
187
|
+
if (ref !== undefined) validateChannelAttachmentSourceRef(ref, `${field}[${index}].ref`);
|
|
188
|
+
const normalizedRef = isRecord(ref) ? { ref: { ...ref } as AttachmentRef["ref"] } : {};
|
|
189
|
+
return {
|
|
190
|
+
artifactId: cleanString(item.artifactId, `${field}[${index}].artifactId`, { required: true, max: 120 })!,
|
|
191
|
+
kind: optionalEnum(item.kind, `${field}[${index}].kind`, VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
|
|
192
|
+
role: optionalEnum(item.role, `${field}[${index}].role`, VALID_ARTIFACT_ROLES) as "media" | "patch" | "report" | "log" | "output" | "input" | undefined,
|
|
193
|
+
title: cleanString(item.title, `${field}[${index}].title`, { max: 240 }),
|
|
194
|
+
...normalizedRef,
|
|
195
|
+
...(isRecord(item.metadata) ? { metadata: item.metadata } : {}),
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function metaString(meta: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
201
|
+
const value = meta?.[key];
|
|
202
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function dispatchTaskCallbacks(taskId: number, eventType: string): Promise<void> {
|
|
206
|
+
const task = getTask(taskId);
|
|
207
|
+
if (!task) return;
|
|
208
|
+
const requestedTarget = typeof task.metadata?.relayRequestedTarget === "string" ? task.metadata.relayRequestedTarget : undefined;
|
|
209
|
+
const integrations = getIntegrationTokens()
|
|
210
|
+
.filter((integration) => integration.name === task.source)
|
|
211
|
+
.filter((integration) => integration.callbackUrl)
|
|
212
|
+
.filter((integration) => !integration.targets?.length || integration.targets.includes(task.target) || Boolean(requestedTarget && integration.targets.includes(requestedTarget)))
|
|
213
|
+
.filter((integration) => !integration.channels?.length || !task.channel || integration.channels.includes(task.channel));
|
|
214
|
+
|
|
215
|
+
for (const integration of integrations) {
|
|
216
|
+
const payload = { event: eventType, task };
|
|
217
|
+
const deliveryId = createCallbackDelivery(task.id, integration.callbackUrl!, eventType, payload);
|
|
218
|
+
void postCallback(deliveryId, integration.callbackUrl!, payload);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function postCallback(deliveryId: number, url: string, payload: unknown): Promise<void> {
|
|
223
|
+
const controller = new AbortController();
|
|
224
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
225
|
+
try {
|
|
226
|
+
const response = await fetch(url, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { "Content-Type": "application/json" },
|
|
229
|
+
body: JSON.stringify(payload),
|
|
230
|
+
signal: controller.signal,
|
|
231
|
+
});
|
|
232
|
+
finishCallbackDelivery(deliveryId, response.ok, response.ok ? undefined : `${response.status} ${response.statusText}`);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
finishCallbackDelivery(deliveryId, false, errMessage(e));
|
|
235
|
+
} finally {
|
|
236
|
+
clearTimeout(timeout);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function auditEvent(input: ActivityEventInput): void {
|
|
241
|
+
try {
|
|
242
|
+
const event = createActivityEvent({
|
|
243
|
+
...input,
|
|
244
|
+
metadata: { source: "server", ...(input.metadata ?? {}) },
|
|
245
|
+
});
|
|
246
|
+
emitActivityEvent(event);
|
|
247
|
+
} catch {
|
|
248
|
+
// Audit trail writes must never block relay behavior.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function memoryContext(req: Request): MemoryBrokerContext {
|
|
253
|
+
const component = getComponentAuth(req);
|
|
254
|
+
if (component) {
|
|
255
|
+
return { now: Date.now(), actor: component.sub, scopes: component.scope, relayUrl: new URL(req.url).origin };
|
|
256
|
+
}
|
|
257
|
+
const integration = getIntegrationAuth(req);
|
|
258
|
+
if (integration) {
|
|
259
|
+
return { now: Date.now(), actor: integration.name, scopes: integration.scopes, relayUrl: new URL(req.url).origin };
|
|
260
|
+
}
|
|
261
|
+
return { now: Date.now(), actor: "server", scopes: ["*"], relayUrl: new URL(req.url).origin };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function memoryErrorResponse(e: unknown): Response {
|
|
265
|
+
if (e instanceof ValidationError || e instanceof MemoryBrokerContractError) return error(e.message, 400);
|
|
266
|
+
throw e;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export type AgentControlAction = (typeof VALID_AGENT_ACTIONS)[number];
|
|
270
|
+
|
|
271
|
+
export function agentControlActionIcon(action: AgentControlAction): string {
|
|
272
|
+
if (action === "shutdown") return "ti-power";
|
|
273
|
+
if (action === "compact") return "ti-compress";
|
|
274
|
+
if (action === "clearContext") return "ti-eraser";
|
|
275
|
+
if (action === "resume") return "ti-player-play";
|
|
276
|
+
if (action === "interrupt") return "ti-player-stop";
|
|
277
|
+
return "ti-refresh";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
|
|
281
|
+
if (value === undefined || value === null) return undefined;
|
|
282
|
+
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
|
|
283
|
+
if (JSON.stringify(value).length > 65_536) throw new ValidationError(`${field} is too large`);
|
|
284
|
+
return value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function cleanSafeNumber(value: unknown): number | undefined {
|
|
288
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function commandEventType(command: Command): string {
|
|
292
|
+
if (command.status === "pending") return "command.requested";
|
|
293
|
+
return `command.${command.status}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function emitCommand(command: Command): void {
|
|
297
|
+
emitRelayEvent({
|
|
298
|
+
type: commandEventType(command),
|
|
299
|
+
source: command.source,
|
|
300
|
+
subject: command.id,
|
|
301
|
+
data: { command },
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function normalizeConfigPathParam(raw: string | undefined, field: string): string {
|
|
306
|
+
return cleanString(raw, field, { required: true, max: 240 })!;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function spawnRequestId(): string {
|
|
310
|
+
return generateSpawnRequestId();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function reactionEmoji(value: unknown, field = "emoji"): string {
|
|
314
|
+
const emoji = cleanString(value, field, { required: true, max: 64 })!;
|
|
315
|
+
const normalized = normalizeReactionEmoji(emoji);
|
|
316
|
+
if (!normalized.trim()) throw new ValidationError(`${field} required`);
|
|
317
|
+
return normalized;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function reactionActorView(actorId: string): Record<string, unknown> {
|
|
321
|
+
const agent = getAgent(actorId);
|
|
322
|
+
return {
|
|
323
|
+
id: actorId,
|
|
324
|
+
kind: actorId === "user" ? "human" : agent?.kind === "channel" ? "channel" : "agent",
|
|
325
|
+
displayName: agent?.label ?? agent?.name ?? actorId,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function reactionNotificationSender(parent: Message, actorId: string): string {
|
|
330
|
+
if (getAgent(actorId)) return actorId;
|
|
331
|
+
return parent.channel && getAgent(parent.channel) ? parent.channel : "user";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function sendReactionNotificationToAuthor(parent: Message, actorId: string, emoji: string, action: "add" | "remove"): void {
|
|
335
|
+
if (parent.from === "user" || parent.from === "system") return;
|
|
336
|
+
const targetAgent = getAgent(parent.from);
|
|
337
|
+
if (!targetAgent || targetAgent.kind === "channel") return;
|
|
338
|
+
const from = reactionNotificationSender(parent, actorId);
|
|
339
|
+
if (from === parent.from) return;
|
|
340
|
+
const now = Date.now();
|
|
341
|
+
const actor = reactionActorView(actorId);
|
|
342
|
+
const verb = action === "remove" ? "removed reaction" : "reacted";
|
|
343
|
+
const result = sendMessageWithResult({
|
|
344
|
+
from,
|
|
345
|
+
to: parent.from,
|
|
346
|
+
kind: "system",
|
|
347
|
+
body: `${String(actor.displayName ?? actorId)} ${verb} ${emoji} to message #${parent.id}.`,
|
|
348
|
+
replyTo: parent.id,
|
|
349
|
+
idempotencyKey: `reaction-notify:${parent.id}:${actorId}:${emoji}:${action}:${now}`,
|
|
350
|
+
payload: {
|
|
351
|
+
schema: "agent-relay.reaction.v1",
|
|
352
|
+
reactionNotification: true,
|
|
353
|
+
event: {
|
|
354
|
+
id: `relay:${parent.id}:reaction-notify:${actorId}:${emoji}:${action}:${now}`,
|
|
355
|
+
type: "message.reaction",
|
|
356
|
+
ts: new Date(now).toISOString(),
|
|
357
|
+
},
|
|
358
|
+
reaction: {
|
|
359
|
+
emoji,
|
|
360
|
+
action,
|
|
361
|
+
actor,
|
|
362
|
+
target: {
|
|
363
|
+
relayMessageId: parent.id,
|
|
364
|
+
from: parent.from,
|
|
365
|
+
to: parent.to,
|
|
366
|
+
kind: parent.kind,
|
|
367
|
+
bodyPreview: parent.body.length > 240 ? `${parent.body.slice(0, 240)}\n[truncated]` : parent.body,
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
if (result.created) {
|
|
373
|
+
if (result.message.deliveryStatus === "queued") emitMessageQueued(result.message);
|
|
374
|
+
else emitNewMessage(result.message);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Auto-split from routes.ts (#299). Domain: activity.
|
|
2
|
+
import { ValidationError, createActivityEvent, listActivityEvents } from "../db";
|
|
3
|
+
import { cleanMeta, cleanOperatorId, cleanPositiveId, cleanString, optionalEnum } from "../validation";
|
|
4
|
+
import { emitActivityEvent } from "../sse";
|
|
5
|
+
import { error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
|
|
6
|
+
import { isRecord } from "agent-relay-sdk";
|
|
7
|
+
import { type ActivityEventInput, type ActivityKind } from "../types";
|
|
8
|
+
|
|
9
|
+
const VALID_ACTIVITY_KINDS = ["message", "reply", "question", "operator", "pair", "task", "state"] as const;
|
|
10
|
+
|
|
11
|
+
function normalizeActivityInput(body: unknown): ActivityEventInput {
|
|
12
|
+
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
13
|
+
return {
|
|
14
|
+
operatorId: cleanOperatorId(body.operatorId),
|
|
15
|
+
clientId: cleanString(body.clientId, "clientId", { max: 240 }),
|
|
16
|
+
kind: optionalEnum(body.kind, "kind", VALID_ACTIVITY_KINDS, "operator") as ActivityKind,
|
|
17
|
+
title: cleanString(body.title, "title", { required: true, max: 200 })!,
|
|
18
|
+
body: cleanString(body.body, "body", { max: 1000 }),
|
|
19
|
+
meta: cleanString(body.meta, "meta", { max: 500 }),
|
|
20
|
+
icon: cleanString(body.icon, "icon", { max: 80 }),
|
|
21
|
+
view: cleanString(body.view, "view", { max: 80 }),
|
|
22
|
+
peer: cleanString(body.peer, "peer", { max: 200 }),
|
|
23
|
+
messageId: cleanPositiveId(body.messageId, "messageId"),
|
|
24
|
+
pairId: cleanString(body.pairId, "pairId", { max: 120 }),
|
|
25
|
+
taskId: cleanPositiveId(body.taskId, "taskId"),
|
|
26
|
+
agentId: cleanString(body.agentId, "agentId", { max: 200 }),
|
|
27
|
+
metadata: cleanMeta(body.metadata),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const getActivityEvents: Handler = (req) => {
|
|
32
|
+
const url = new URL(req.url);
|
|
33
|
+
try {
|
|
34
|
+
const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
|
|
35
|
+
if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
|
|
36
|
+
const sinceRaw = parseQueryInt(url.searchParams.get("since"), { min: 0, max: Number.MAX_SAFE_INTEGER });
|
|
37
|
+
if (Number.isNaN(sinceRaw)) return error("since must be a non-negative integer");
|
|
38
|
+
return json(listActivityEvents({
|
|
39
|
+
operatorId: cleanString(url.searchParams.get("operatorId") ?? undefined, "operatorId", { max: 200 }),
|
|
40
|
+
agentId: cleanString(url.searchParams.get("agentId") ?? undefined, "agentId", { max: 200 }),
|
|
41
|
+
limit: limitRaw ?? 200,
|
|
42
|
+
since: sinceRaw ?? undefined,
|
|
43
|
+
}));
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const postActivityEvent: Handler = async (req) => {
|
|
51
|
+
const parsed = await parseBody<unknown>(req);
|
|
52
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
53
|
+
try {
|
|
54
|
+
const event = createActivityEvent(normalizeActivityInput(parsed.body));
|
|
55
|
+
emitActivityEvent(event);
|
|
56
|
+
return json(event, 201);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Auto-split from routes.ts (#299). Domain: agent-profiles.
|
|
2
|
+
import { ValidationError } from "../db";
|
|
3
|
+
import { cleanString } from "../validation";
|
|
4
|
+
import { deleteAgentProfile, getAgentProfile, listAgentProfiles, setAgentProfile } from "../config-store";
|
|
5
|
+
import { emitConfigChanged } from "../sse";
|
|
6
|
+
import { error, json, normalizeConfigPathParam, parseBody, type Handler } from "./_shared";
|
|
7
|
+
import { isRecord } from "agent-relay-sdk";
|
|
8
|
+
import { type AgentProfile } from "../types";
|
|
9
|
+
|
|
10
|
+
export const getAgentProfilesRoute: Handler = () => json(listAgentProfiles());
|
|
11
|
+
|
|
12
|
+
export const getAgentProfileRoute: Handler = (_req, params) => {
|
|
13
|
+
const name = normalizeConfigPathParam(params.name, "name");
|
|
14
|
+
const profile = getAgentProfile(name);
|
|
15
|
+
return profile ? json(profile) : error("agent profile not found", 404);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const putAgentProfileRoute: Handler = async (req, params) => {
|
|
19
|
+
const parsed = await parseBody<unknown>(req);
|
|
20
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
21
|
+
try {
|
|
22
|
+
if (!isRecord(parsed.body)) throw new ValidationError("agent profile body required");
|
|
23
|
+
const name = normalizeConfigPathParam(params.name, "name");
|
|
24
|
+
const updatedBy = cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 });
|
|
25
|
+
const profile = setAgentProfile({ ...parsed.body, name } as AgentProfile, updatedBy);
|
|
26
|
+
emitConfigChanged(profile.namespace, profile.key, profile.version);
|
|
27
|
+
return json(profile, profile.version === 1 ? 201 : 200);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const deleteAgentProfileRoute: Handler = (req, params) => {
|
|
35
|
+
try {
|
|
36
|
+
const name = normalizeConfigPathParam(params.name, "name");
|
|
37
|
+
const existing = getAgentProfile(name);
|
|
38
|
+
if (!existing || existing.value.builtIn) return error(existing ? "built-in agent profiles are managed by Relay" : "agent profile not found", existing ? 400 : 404);
|
|
39
|
+
const updatedBy = cleanString(new URL(req.url).searchParams.get("updatedBy") ?? undefined, "updatedBy", { max: 200 });
|
|
40
|
+
deleteAgentProfile(name, updatedBy);
|
|
41
|
+
emitConfigChanged(existing.namespace, existing.key, existing.version + 1);
|
|
42
|
+
return json({ ok: true });
|
|
43
|
+
} catch (e) {
|
|
44
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
};
|