agent-relay-server 0.4.22 → 0.4.24
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/public/dashboard.js +1661 -17
- package/public/index.html +1026 -32
- package/src/db.ts +252 -1
- package/src/routes.ts +336 -3
- package/src/security.ts +3 -1
- package/src/types.ts +61 -0
package/src/routes.ts
CHANGED
|
@@ -36,12 +36,20 @@ import {
|
|
|
36
36
|
rejectPair,
|
|
37
37
|
endPair,
|
|
38
38
|
sendPairMessage,
|
|
39
|
+
getInboxState,
|
|
40
|
+
setInboxThreadState,
|
|
41
|
+
setInboxDraft,
|
|
42
|
+
deleteInboxDraft,
|
|
43
|
+
listActivityEvents,
|
|
44
|
+
createActivityEvent,
|
|
39
45
|
createCallbackDelivery,
|
|
40
46
|
finishCallbackDelivery,
|
|
47
|
+
reapStaleAgents,
|
|
48
|
+
releaseExpiredClaims,
|
|
41
49
|
validateAgentSession,
|
|
42
50
|
ValidationError,
|
|
43
51
|
} from "./db";
|
|
44
|
-
import type { AgentSessionGuard, CreatePairInput, IntegrationEventInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
|
|
52
|
+
import type { ActivityEventInput, ActivityKind, AgentSessionGuard, CreatePairInput, IntegrationEventInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
|
|
45
53
|
import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
|
|
46
54
|
import {
|
|
47
55
|
getIntegrationAuth,
|
|
@@ -54,6 +62,7 @@ import {
|
|
|
54
62
|
emitAgentStatus,
|
|
55
63
|
emitAgentRemoved,
|
|
56
64
|
emitMessageClaimed,
|
|
65
|
+
emitMessageClaimReleased,
|
|
57
66
|
emitMessageDeleted,
|
|
58
67
|
emitTaskChanged,
|
|
59
68
|
} from "./sse";
|
|
@@ -143,6 +152,7 @@ const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
|
|
|
143
152
|
const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
|
|
144
153
|
const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
|
|
145
154
|
const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
|
|
155
|
+
const VALID_ACTIVITY_KINDS = ["message", "reply", "question", "operator", "pair", "task", "state"] as const;
|
|
146
156
|
const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
|
|
147
157
|
|
|
148
158
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -209,6 +219,15 @@ function cleanPositiveId(value: unknown, field: string): number | undefined {
|
|
|
209
219
|
return value;
|
|
210
220
|
}
|
|
211
221
|
|
|
222
|
+
function cleanNullablePositiveId(value: unknown, field: string): number | null | undefined {
|
|
223
|
+
if (value === undefined) return undefined;
|
|
224
|
+
if (value === null) return null;
|
|
225
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
226
|
+
throw new ValidationError(`${field} must be a positive integer or null`);
|
|
227
|
+
}
|
|
228
|
+
return value;
|
|
229
|
+
}
|
|
230
|
+
|
|
212
231
|
function cleanEpoch(value: unknown, field: string): number | undefined {
|
|
213
232
|
if (value === undefined || value === null) return undefined;
|
|
214
233
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
@@ -370,6 +389,70 @@ function normalizePairMessageInput(body: unknown): PairMessageInput {
|
|
|
370
389
|
};
|
|
371
390
|
}
|
|
372
391
|
|
|
392
|
+
function cleanOperatorId(value: unknown): string {
|
|
393
|
+
return cleanString(value, "operatorId", { max: 200 }) || "user";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function normalizeInboxThreadStateInput(body: unknown): {
|
|
397
|
+
operatorId: string;
|
|
398
|
+
peerId: string;
|
|
399
|
+
readCursorMessageId?: number | null;
|
|
400
|
+
archivedAtMessageId?: number | null;
|
|
401
|
+
} {
|
|
402
|
+
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
403
|
+
const input: {
|
|
404
|
+
operatorId: string;
|
|
405
|
+
peerId: string;
|
|
406
|
+
readCursorMessageId?: number | null;
|
|
407
|
+
archivedAtMessageId?: number | null;
|
|
408
|
+
} = {
|
|
409
|
+
operatorId: cleanOperatorId(body.operatorId),
|
|
410
|
+
peerId: cleanString(body.peerId, "peerId", { required: true, max: 200 })!,
|
|
411
|
+
};
|
|
412
|
+
const readCursorMessageId = cleanNullablePositiveId(body.readCursorMessageId, "readCursorMessageId");
|
|
413
|
+
if (readCursorMessageId !== undefined) input.readCursorMessageId = readCursorMessageId;
|
|
414
|
+
const archivedAtMessageId = cleanNullablePositiveId(body.archivedAtMessageId, "archivedAtMessageId");
|
|
415
|
+
if (archivedAtMessageId !== undefined) input.archivedAtMessageId = archivedAtMessageId;
|
|
416
|
+
return input;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function normalizeInboxDraftInput(body: unknown): {
|
|
420
|
+
operatorId: string;
|
|
421
|
+
peerId: string;
|
|
422
|
+
body: string;
|
|
423
|
+
subject?: string;
|
|
424
|
+
channel?: string;
|
|
425
|
+
} {
|
|
426
|
+
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
427
|
+
return {
|
|
428
|
+
operatorId: cleanOperatorId(body.operatorId),
|
|
429
|
+
peerId: cleanString(body.peerId, "peerId", { required: true, max: 200 })!,
|
|
430
|
+
body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
|
|
431
|
+
subject: cleanString(body.subject, "subject", { max: 200 }),
|
|
432
|
+
channel: cleanString(body.channel, "channel", { max: 120 }),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function normalizeActivityInput(body: unknown): ActivityEventInput {
|
|
437
|
+
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
438
|
+
return {
|
|
439
|
+
operatorId: cleanOperatorId(body.operatorId),
|
|
440
|
+
clientId: cleanString(body.clientId, "clientId", { max: 240 }),
|
|
441
|
+
kind: cleanEnum(body.kind, "kind", VALID_ACTIVITY_KINDS, "operator") as ActivityKind,
|
|
442
|
+
title: cleanString(body.title, "title", { required: true, max: 200 })!,
|
|
443
|
+
body: cleanString(body.body, "body", { max: 1000 }),
|
|
444
|
+
meta: cleanString(body.meta, "meta", { max: 500 }),
|
|
445
|
+
icon: cleanString(body.icon, "icon", { max: 80 }),
|
|
446
|
+
view: cleanString(body.view, "view", { max: 80 }),
|
|
447
|
+
peer: cleanString(body.peer, "peer", { max: 200 }),
|
|
448
|
+
messageId: cleanPositiveId(body.messageId, "messageId"),
|
|
449
|
+
pairId: cleanString(body.pairId, "pairId", { max: 120 }),
|
|
450
|
+
taskId: cleanPositiveId(body.taskId, "taskId"),
|
|
451
|
+
agentId: cleanString(body.agentId, "agentId", { max: 200 }),
|
|
452
|
+
metadata: cleanMeta(body.metadata),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
373
456
|
function pairErrorStatus(code: string): number {
|
|
374
457
|
if (code === "not_found") return 404;
|
|
375
458
|
if (code === "busy") return 409;
|
|
@@ -432,14 +515,39 @@ async function postCallback(deliveryId: number, url: string, payload: unknown):
|
|
|
432
515
|
}
|
|
433
516
|
}
|
|
434
517
|
|
|
518
|
+
function auditEvent(input: ActivityEventInput): void {
|
|
519
|
+
try {
|
|
520
|
+
createActivityEvent({
|
|
521
|
+
...input,
|
|
522
|
+
metadata: { source: "server", ...(input.metadata ?? {}) },
|
|
523
|
+
});
|
|
524
|
+
} catch {
|
|
525
|
+
// Audit trail writes must never block relay behavior.
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
435
529
|
// --- Agent routes ---
|
|
436
530
|
|
|
437
531
|
const postAgent: Handler = async (req) => {
|
|
438
532
|
const parsed = await parseBody<unknown>(req);
|
|
439
533
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
440
534
|
try {
|
|
441
|
-
const
|
|
535
|
+
const input = normalizeAgentInput(parsed.body);
|
|
536
|
+
const existing = getAgent(input.id);
|
|
537
|
+
const agent = upsertAgent(input);
|
|
442
538
|
emitAgentStatus(agent.id);
|
|
539
|
+
if (!existing) {
|
|
540
|
+
auditEvent({
|
|
541
|
+
clientId: "server-agent-" + agent.id + "-registered",
|
|
542
|
+
kind: "state",
|
|
543
|
+
title: "Agent registered",
|
|
544
|
+
body: agent.name,
|
|
545
|
+
meta: agent.id,
|
|
546
|
+
icon: "ti-robot",
|
|
547
|
+
view: "agents",
|
|
548
|
+
agentId: agent.id,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
443
551
|
return json(agent, 201);
|
|
444
552
|
} catch (e) {
|
|
445
553
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -485,6 +593,15 @@ const patchAgentStatus: Handler = async (req, params) => {
|
|
|
485
593
|
throw e;
|
|
486
594
|
}
|
|
487
595
|
emitAgentStatus(params.id!);
|
|
596
|
+
auditEvent({
|
|
597
|
+
clientId: "server-agent-" + params.id! + "-status-" + body.status + "-" + Date.now(),
|
|
598
|
+
kind: "state",
|
|
599
|
+
title: "Agent " + body.status,
|
|
600
|
+
meta: params.id!,
|
|
601
|
+
icon: body.status === "offline" ? "ti-plug-off" : "ti-activity",
|
|
602
|
+
view: "agents",
|
|
603
|
+
agentId: params.id!,
|
|
604
|
+
});
|
|
488
605
|
return json({ ok: true });
|
|
489
606
|
};
|
|
490
607
|
|
|
@@ -543,6 +660,15 @@ const patchAgentReady: Handler = async (req, params) => {
|
|
|
543
660
|
throw e;
|
|
544
661
|
}
|
|
545
662
|
emitAgentStatus(params.id!);
|
|
663
|
+
auditEvent({
|
|
664
|
+
clientId: "server-agent-" + params.id! + "-ready-" + body.ready + "-" + Date.now(),
|
|
665
|
+
kind: "state",
|
|
666
|
+
title: body.ready ? "Agent ready" : "Agent not ready",
|
|
667
|
+
meta: params.id!,
|
|
668
|
+
icon: body.ready ? "ti-circle-check" : "ti-loader",
|
|
669
|
+
view: "agents",
|
|
670
|
+
agentId: params.id!,
|
|
671
|
+
});
|
|
546
672
|
return json({ ok: true });
|
|
547
673
|
};
|
|
548
674
|
|
|
@@ -569,7 +695,20 @@ const postMessage: Handler = async (req) => {
|
|
|
569
695
|
input.idempotencyKey = cleanString(req.headers.get("Idempotency-Key") ?? undefined, "idempotencyKey", { max: 240 });
|
|
570
696
|
}
|
|
571
697
|
const result = sendMessageWithResult(input);
|
|
572
|
-
if (result.created)
|
|
698
|
+
if (result.created) {
|
|
699
|
+
emitNewMessage(result.message);
|
|
700
|
+
auditEvent({
|
|
701
|
+
clientId: "server-message-" + result.message.id,
|
|
702
|
+
kind: result.message.claimable ? "task" : "message",
|
|
703
|
+
title: result.message.claimable ? "Claimable message sent" : "Message sent",
|
|
704
|
+
body: result.message.subject || result.message.body,
|
|
705
|
+
meta: `${result.message.from} -> ${result.message.to}`,
|
|
706
|
+
icon: result.message.claimable ? "ti-hand-grab" : "ti-send",
|
|
707
|
+
view: "messages",
|
|
708
|
+
messageId: result.message.id,
|
|
709
|
+
agentId: result.message.from,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
573
712
|
return json(result.message, result.created ? 201 : 200);
|
|
574
713
|
} catch (e) {
|
|
575
714
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -668,6 +807,17 @@ const postClaimMessage: Handler = async (req, params) => {
|
|
|
668
807
|
const result = claimMessage(id, body.agentId, guard);
|
|
669
808
|
if (result.ok) {
|
|
670
809
|
emitMessageClaimed(id, body.agentId, getMessage(id)?.claimExpiresAt);
|
|
810
|
+
auditEvent({
|
|
811
|
+
clientId: "server-message-" + id + "-claimed-" + body.agentId,
|
|
812
|
+
kind: "task",
|
|
813
|
+
title: "Message claimed",
|
|
814
|
+
body: "Message #" + id,
|
|
815
|
+
meta: "by " + body.agentId,
|
|
816
|
+
icon: "ti-user-check",
|
|
817
|
+
view: "work",
|
|
818
|
+
messageId: id,
|
|
819
|
+
agentId: body.agentId,
|
|
820
|
+
});
|
|
671
821
|
if (result.task) {
|
|
672
822
|
emitTaskChanged(result.task, "task.claimed");
|
|
673
823
|
void dispatchTaskCallbacks(result.task.id, "task.claimed");
|
|
@@ -730,6 +880,80 @@ const deleteMessageById: Handler = (_req, params) => {
|
|
|
730
880
|
|
|
731
881
|
const getCursorRoute: Handler = () => json({ latestId: getLatestMessageId() });
|
|
732
882
|
|
|
883
|
+
// --- Inbox operator state ---
|
|
884
|
+
|
|
885
|
+
const getInboxStateRoute: Handler = (req) => {
|
|
886
|
+
const url = new URL(req.url);
|
|
887
|
+
const operatorId = cleanOperatorId(url.searchParams.get("operatorId") ?? undefined);
|
|
888
|
+
return json(getInboxState(operatorId));
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
const patchInboxThreadState: Handler = async (req) => {
|
|
892
|
+
const parsed = await parseBody<unknown>(req);
|
|
893
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
894
|
+
try {
|
|
895
|
+
return json(setInboxThreadState(normalizeInboxThreadStateInput(parsed.body)));
|
|
896
|
+
} catch (e) {
|
|
897
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
898
|
+
throw e;
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
const putInboxDraft: Handler = async (req) => {
|
|
903
|
+
const parsed = await parseBody<unknown>(req);
|
|
904
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
905
|
+
try {
|
|
906
|
+
return json(setInboxDraft(normalizeInboxDraftInput(parsed.body)));
|
|
907
|
+
} catch (e) {
|
|
908
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
909
|
+
throw e;
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const deleteInboxDraftRoute: Handler = (req) => {
|
|
914
|
+
const url = new URL(req.url);
|
|
915
|
+
try {
|
|
916
|
+
const operatorId = cleanOperatorId(url.searchParams.get("operatorId") ?? undefined);
|
|
917
|
+
const peerId = cleanString(url.searchParams.get("peerId") ?? undefined, "peerId", { required: true, max: 200 })!;
|
|
918
|
+
deleteInboxDraft(operatorId, peerId);
|
|
919
|
+
return json({ ok: true });
|
|
920
|
+
} catch (e) {
|
|
921
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
922
|
+
throw e;
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
// --- Shared activity audit trail ---
|
|
927
|
+
|
|
928
|
+
const getActivityEvents: Handler = (req) => {
|
|
929
|
+
const url = new URL(req.url);
|
|
930
|
+
try {
|
|
931
|
+
const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
|
|
932
|
+
if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
|
|
933
|
+
const sinceRaw = parseQueryInt(url.searchParams.get("since"), { min: 0, max: Number.MAX_SAFE_INTEGER });
|
|
934
|
+
if (Number.isNaN(sinceRaw)) return error("since must be a non-negative integer");
|
|
935
|
+
return json(listActivityEvents({
|
|
936
|
+
operatorId: cleanString(url.searchParams.get("operatorId") ?? undefined, "operatorId", { max: 200 }),
|
|
937
|
+
limit: limitRaw ?? 200,
|
|
938
|
+
since: sinceRaw ?? undefined,
|
|
939
|
+
}));
|
|
940
|
+
} catch (e) {
|
|
941
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
942
|
+
throw e;
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
const postActivityEvent: Handler = async (req) => {
|
|
947
|
+
const parsed = await parseBody<unknown>(req);
|
|
948
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
949
|
+
try {
|
|
950
|
+
return json(createActivityEvent(normalizeActivityInput(parsed.body)), 201);
|
|
951
|
+
} catch (e) {
|
|
952
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
953
|
+
throw e;
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
733
957
|
// --- Tasks and integrations ---
|
|
734
958
|
|
|
735
959
|
const postIntegrationEvent: Handler = async (req) => {
|
|
@@ -804,6 +1028,17 @@ const postClaimTask: Handler = async (req, params) => {
|
|
|
804
1028
|
return error(result.error!, status);
|
|
805
1029
|
}
|
|
806
1030
|
emitTaskChanged(result.task!, "task.claimed");
|
|
1031
|
+
auditEvent({
|
|
1032
|
+
clientId: "server-task-" + id + "-claimed-" + agentId,
|
|
1033
|
+
kind: "task",
|
|
1034
|
+
title: "Task claimed",
|
|
1035
|
+
body: result.task!.title,
|
|
1036
|
+
meta: "by " + agentId,
|
|
1037
|
+
icon: "ti-user-check",
|
|
1038
|
+
view: "work",
|
|
1039
|
+
taskId: id,
|
|
1040
|
+
agentId,
|
|
1041
|
+
});
|
|
807
1042
|
if (result.task!.messageId) {
|
|
808
1043
|
emitMessageClaimed(result.task!.messageId, agentId, getMessage(result.task!.messageId)?.claimExpiresAt);
|
|
809
1044
|
}
|
|
@@ -846,6 +1081,17 @@ const patchTaskStatus: Handler = async (req, params) => {
|
|
|
846
1081
|
const result = updateTaskStatus(id, normalizeTaskStatusInput(parsed.body));
|
|
847
1082
|
if (!result.ok) return error(result.error!, result.error === "task not found" ? 404 : agentSessionStatus(result.error));
|
|
848
1083
|
emitTaskChanged(result.task!, "task.status");
|
|
1084
|
+
auditEvent({
|
|
1085
|
+
clientId: "server-task-" + id + "-status-" + result.task!.status + "-" + Date.now(),
|
|
1086
|
+
kind: "task",
|
|
1087
|
+
title: "Task " + result.task!.status,
|
|
1088
|
+
body: result.task!.title,
|
|
1089
|
+
meta: result.task!.claimedBy ? "by " + result.task!.claimedBy : result.task!.target,
|
|
1090
|
+
icon: "ti-arrows-exchange",
|
|
1091
|
+
view: "work",
|
|
1092
|
+
taskId: id,
|
|
1093
|
+
agentId: result.task!.claimedBy,
|
|
1094
|
+
});
|
|
849
1095
|
void dispatchTaskCallbacks(id, "task.status");
|
|
850
1096
|
return json({ task: result.task, event: result.event });
|
|
851
1097
|
} catch (e) {
|
|
@@ -863,6 +1109,17 @@ const postPair: Handler = async (req) => {
|
|
|
863
1109
|
const result = createPair(normalizeCreatePairInput(parsed.body));
|
|
864
1110
|
if (!result.ok) return pairError(result);
|
|
865
1111
|
emitNewMessage(result.invite);
|
|
1112
|
+
auditEvent({
|
|
1113
|
+
clientId: "server-pair-" + result.pair.id + "-invited",
|
|
1114
|
+
kind: "pair",
|
|
1115
|
+
title: "Pair invited",
|
|
1116
|
+
body: result.pair.objective,
|
|
1117
|
+
meta: `${result.pair.requesterId} <-> ${result.pair.targetId}`,
|
|
1118
|
+
icon: "ti-link-plus",
|
|
1119
|
+
view: "pairs",
|
|
1120
|
+
pairId: result.pair.id,
|
|
1121
|
+
agentId: result.pair.requesterId,
|
|
1122
|
+
});
|
|
866
1123
|
return json({ pair: result.pair, invite: result.invite }, 201);
|
|
867
1124
|
} catch (e) {
|
|
868
1125
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -894,6 +1151,17 @@ const postAcceptPair: Handler = async (req, params) => {
|
|
|
894
1151
|
const result = acceptPair(params.id!, normalizePairActionInput(parsed.body));
|
|
895
1152
|
if (!result.ok) return pairError(result);
|
|
896
1153
|
for (const notice of result.notices) emitNewMessage(notice);
|
|
1154
|
+
auditEvent({
|
|
1155
|
+
clientId: "server-pair-" + result.pair.id + "-accepted",
|
|
1156
|
+
kind: "pair",
|
|
1157
|
+
title: "Pair accepted",
|
|
1158
|
+
body: result.pair.objective,
|
|
1159
|
+
meta: `${result.pair.requesterId} <-> ${result.pair.targetId}`,
|
|
1160
|
+
icon: "ti-link",
|
|
1161
|
+
view: "pairs",
|
|
1162
|
+
pairId: result.pair.id,
|
|
1163
|
+
agentId: result.pair.targetId,
|
|
1164
|
+
});
|
|
897
1165
|
return json(result.pair);
|
|
898
1166
|
} catch (e) {
|
|
899
1167
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -908,6 +1176,17 @@ const postRejectPair: Handler = async (req, params) => {
|
|
|
908
1176
|
const result = rejectPair(params.id!, normalizePairActionInput(parsed.body));
|
|
909
1177
|
if (!result.ok) return pairError(result);
|
|
910
1178
|
emitNewMessage(result.notice);
|
|
1179
|
+
auditEvent({
|
|
1180
|
+
clientId: "server-pair-" + result.pair.id + "-rejected",
|
|
1181
|
+
kind: "pair",
|
|
1182
|
+
title: "Pair rejected",
|
|
1183
|
+
body: result.pair.objective,
|
|
1184
|
+
meta: `${result.pair.requesterId} <-> ${result.pair.targetId}`,
|
|
1185
|
+
icon: "ti-x",
|
|
1186
|
+
view: "pairs",
|
|
1187
|
+
pairId: result.pair.id,
|
|
1188
|
+
agentId: result.pair.targetId,
|
|
1189
|
+
});
|
|
911
1190
|
return json(result.pair);
|
|
912
1191
|
} catch (e) {
|
|
913
1192
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -922,6 +1201,17 @@ const postHangupPair: Handler = async (req, params) => {
|
|
|
922
1201
|
const result = endPair(params.id!, normalizePairActionInput(parsed.body));
|
|
923
1202
|
if (!result.ok) return pairError(result);
|
|
924
1203
|
if (result.notice) emitNewMessage(result.notice);
|
|
1204
|
+
auditEvent({
|
|
1205
|
+
clientId: "server-pair-" + result.pair.id + "-ended-" + Date.now(),
|
|
1206
|
+
kind: "pair",
|
|
1207
|
+
title: "Pair ended",
|
|
1208
|
+
body: result.pair.objective,
|
|
1209
|
+
meta: result.pair.endedBy ? "by " + result.pair.endedBy : `${result.pair.requesterId} <-> ${result.pair.targetId}`,
|
|
1210
|
+
icon: "ti-phone-off",
|
|
1211
|
+
view: "pairs",
|
|
1212
|
+
pairId: result.pair.id,
|
|
1213
|
+
agentId: result.pair.endedBy,
|
|
1214
|
+
});
|
|
925
1215
|
return json(result.pair);
|
|
926
1216
|
} catch (e) {
|
|
927
1217
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -936,6 +1226,18 @@ const postPairMessage: Handler = async (req, params) => {
|
|
|
936
1226
|
const result = sendPairMessage(params.id!, normalizePairMessageInput(parsed.body));
|
|
937
1227
|
if (!result.ok) return pairError(result);
|
|
938
1228
|
emitNewMessage(result.message);
|
|
1229
|
+
auditEvent({
|
|
1230
|
+
clientId: "server-pair-message-" + result.message.id,
|
|
1231
|
+
kind: "pair",
|
|
1232
|
+
title: "Pair message sent",
|
|
1233
|
+
body: result.message.subject || result.message.body,
|
|
1234
|
+
meta: "from " + result.message.from,
|
|
1235
|
+
icon: "ti-messages",
|
|
1236
|
+
view: "pairs",
|
|
1237
|
+
messageId: result.message.id,
|
|
1238
|
+
pairId: result.pair.id,
|
|
1239
|
+
agentId: result.message.from,
|
|
1240
|
+
});
|
|
939
1241
|
return json({ pair: result.pair, message: result.message }, 201);
|
|
940
1242
|
} catch (e) {
|
|
941
1243
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -956,6 +1258,28 @@ const getEvents: Handler = (req) => {
|
|
|
956
1258
|
const getStatsRoute: Handler = () => json(getStats());
|
|
957
1259
|
const getHealthRoute: Handler = () => json(getHealth());
|
|
958
1260
|
|
|
1261
|
+
const postSystemReap: Handler = () => {
|
|
1262
|
+
const released = releaseExpiredClaims();
|
|
1263
|
+
const reapedAgentIds = reapStaleAgents();
|
|
1264
|
+
for (const id of released.messageIds) emitMessageClaimReleased(id);
|
|
1265
|
+
for (const task of released.tasks) emitTaskChanged(task, "task.updated");
|
|
1266
|
+
for (const id of reapedAgentIds) emitAgentStatus(id);
|
|
1267
|
+
auditEvent({
|
|
1268
|
+
clientId: "server-system-reap-" + Date.now(),
|
|
1269
|
+
kind: "state",
|
|
1270
|
+
title: "Maintenance reaper run",
|
|
1271
|
+
body: `${reapedAgentIds.length} stale agent(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s)`,
|
|
1272
|
+
icon: "ti-broom",
|
|
1273
|
+
view: "activity",
|
|
1274
|
+
});
|
|
1275
|
+
return json({
|
|
1276
|
+
ok: true,
|
|
1277
|
+
reapedAgentIds,
|
|
1278
|
+
releasedMessageIds: released.messageIds,
|
|
1279
|
+
releasedTaskIds: released.tasks.map((task) => task.id),
|
|
1280
|
+
});
|
|
1281
|
+
};
|
|
1282
|
+
|
|
959
1283
|
// --- Router ---
|
|
960
1284
|
|
|
961
1285
|
interface Route {
|
|
@@ -1001,6 +1325,14 @@ const routes: Route[] = [
|
|
|
1001
1325
|
route("PATCH", "/api/messages/:id", patchMessage),
|
|
1002
1326
|
route("DELETE", "/api/messages/:id", deleteMessageById),
|
|
1003
1327
|
|
|
1328
|
+
route("GET", "/api/inbox/state", getInboxStateRoute),
|
|
1329
|
+
route("PATCH", "/api/inbox/threads", patchInboxThreadState),
|
|
1330
|
+
route("PUT", "/api/inbox/drafts", putInboxDraft),
|
|
1331
|
+
route("DELETE", "/api/inbox/drafts", deleteInboxDraftRoute),
|
|
1332
|
+
|
|
1333
|
+
route("GET", "/api/activity", getActivityEvents),
|
|
1334
|
+
route("POST", "/api/activity", postActivityEvent),
|
|
1335
|
+
|
|
1004
1336
|
route("POST", "/api/integrations/events", postIntegrationEvent),
|
|
1005
1337
|
route("GET", "/api/tasks", getTasks),
|
|
1006
1338
|
route("GET", "/api/tasks/:id", getTaskById),
|
|
@@ -1020,6 +1352,7 @@ const routes: Route[] = [
|
|
|
1020
1352
|
route("GET", "/api/events", getEvents),
|
|
1021
1353
|
route("GET", "/api/stats", getStatsRoute),
|
|
1022
1354
|
route("GET", "/api/health", getHealthRoute),
|
|
1355
|
+
route("POST", "/api/system/reap", postSystemReap),
|
|
1023
1356
|
];
|
|
1024
1357
|
|
|
1025
1358
|
export function matchRoute(
|
package/src/security.ts
CHANGED
|
@@ -55,7 +55,7 @@ export function corsPreflight(req: Request): Response {
|
|
|
55
55
|
|
|
56
56
|
const response = new Response(null, {
|
|
57
57
|
headers: {
|
|
58
|
-
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
58
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
59
59
|
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Agent-Relay-Token, Idempotency-Key, X-Agent-Relay-Instance-Id, X-Agent-Relay-Epoch",
|
|
60
60
|
"Access-Control-Max-Age": "600",
|
|
61
61
|
},
|
|
@@ -97,6 +97,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
97
97
|
if (pathname === "/api/events") return "events:read";
|
|
98
98
|
if (pathname.startsWith("/api/integrations/")) return method === "GET" ? "integrations:read" : "integrations:write";
|
|
99
99
|
if (pathname.startsWith("/api/agents")) return method === "GET" ? "agents:read" : "agents:write";
|
|
100
|
+
if (pathname.startsWith("/api/activity")) return method === "GET" ? "activity:read" : "activity:write";
|
|
101
|
+
if (pathname.startsWith("/api/inbox")) return method === "GET" ? "messages:read" : "messages:write";
|
|
100
102
|
if (pathname.startsWith("/api/messages")) return method === "GET" ? "messages:read" : "messages:write";
|
|
101
103
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "tasks:read" : "tasks:write";
|
|
102
104
|
if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
|
package/src/types.ts
CHANGED
|
@@ -184,6 +184,67 @@ export interface TaskStatusInput {
|
|
|
184
184
|
metadata?: Record<string, unknown>;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
export interface InboxThreadState {
|
|
188
|
+
operatorId: string;
|
|
189
|
+
peerId: string;
|
|
190
|
+
readCursorMessageId?: number;
|
|
191
|
+
archivedAtMessageId?: number;
|
|
192
|
+
updatedAt: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface InboxDraft {
|
|
196
|
+
operatorId: string;
|
|
197
|
+
peerId: string;
|
|
198
|
+
body: string;
|
|
199
|
+
subject?: string;
|
|
200
|
+
channel?: string;
|
|
201
|
+
updatedAt: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface InboxState {
|
|
205
|
+
operatorId: string;
|
|
206
|
+
threads: InboxThreadState[];
|
|
207
|
+
drafts: InboxDraft[];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type ActivityKind = "message" | "reply" | "question" | "operator" | "pair" | "task" | "state";
|
|
211
|
+
|
|
212
|
+
export interface ActivityEvent {
|
|
213
|
+
id: number;
|
|
214
|
+
operatorId?: string;
|
|
215
|
+
clientId?: string;
|
|
216
|
+
kind: ActivityKind;
|
|
217
|
+
title: string;
|
|
218
|
+
body?: string;
|
|
219
|
+
meta?: string;
|
|
220
|
+
icon?: string;
|
|
221
|
+
view?: string;
|
|
222
|
+
peer?: string;
|
|
223
|
+
messageId?: number;
|
|
224
|
+
pairId?: string;
|
|
225
|
+
taskId?: number;
|
|
226
|
+
agentId?: string;
|
|
227
|
+
metadata: Record<string, unknown>;
|
|
228
|
+
createdAt: number;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface ActivityEventInput {
|
|
232
|
+
operatorId?: string;
|
|
233
|
+
clientId?: string;
|
|
234
|
+
kind: ActivityKind;
|
|
235
|
+
title: string;
|
|
236
|
+
body?: string;
|
|
237
|
+
meta?: string;
|
|
238
|
+
icon?: string;
|
|
239
|
+
view?: string;
|
|
240
|
+
peer?: string;
|
|
241
|
+
messageId?: number;
|
|
242
|
+
pairId?: string;
|
|
243
|
+
taskId?: number;
|
|
244
|
+
agentId?: string;
|
|
245
|
+
metadata?: Record<string, unknown>;
|
|
246
|
+
}
|
|
247
|
+
|
|
187
248
|
export interface HealthCheck {
|
|
188
249
|
name: string;
|
|
189
250
|
status: "ok" | "warn" | "error";
|