agent-relay-server 0.4.23 → 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 +935 -12
- package/public/index.html +353 -32
- package/src/db.ts +98 -0
- package/src/routes.ts +231 -3
- package/src/security.ts +1 -0
- package/src/types.ts +38 -0
package/src/db.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { VERSION } from "./config.ts";
|
|
4
4
|
import type {
|
|
5
5
|
AgentCard,
|
|
6
|
+
ActivityEvent,
|
|
7
|
+
ActivityEventInput,
|
|
6
8
|
AgentSessionGuard,
|
|
7
9
|
CreatePairInput,
|
|
8
10
|
HealthCheck,
|
|
@@ -168,6 +170,27 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
168
170
|
PRIMARY KEY (operator_id, peer_id)
|
|
169
171
|
);
|
|
170
172
|
CREATE INDEX IF NOT EXISTS idx_inbox_drafts_operator ON inbox_drafts(operator_id);
|
|
173
|
+
|
|
174
|
+
CREATE TABLE IF NOT EXISTS activity_events (
|
|
175
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
176
|
+
operator_id TEXT,
|
|
177
|
+
client_id TEXT UNIQUE,
|
|
178
|
+
kind TEXT NOT NULL,
|
|
179
|
+
title TEXT NOT NULL,
|
|
180
|
+
body TEXT,
|
|
181
|
+
meta_text TEXT,
|
|
182
|
+
icon TEXT,
|
|
183
|
+
view TEXT,
|
|
184
|
+
peer_id TEXT,
|
|
185
|
+
message_id INTEGER,
|
|
186
|
+
pair_id TEXT,
|
|
187
|
+
task_id INTEGER,
|
|
188
|
+
agent_id TEXT,
|
|
189
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
190
|
+
created_at INTEGER NOT NULL
|
|
191
|
+
);
|
|
192
|
+
CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
|
|
193
|
+
CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
|
|
171
194
|
`);
|
|
172
195
|
|
|
173
196
|
// Migrations
|
|
@@ -438,6 +461,27 @@ function rowToInboxDraft(row: any): InboxDraft {
|
|
|
438
461
|
};
|
|
439
462
|
}
|
|
440
463
|
|
|
464
|
+
function rowToActivityEvent(row: any): ActivityEvent {
|
|
465
|
+
return {
|
|
466
|
+
id: row.id,
|
|
467
|
+
operatorId: row.operator_id ?? undefined,
|
|
468
|
+
clientId: row.client_id ?? undefined,
|
|
469
|
+
kind: row.kind,
|
|
470
|
+
title: row.title,
|
|
471
|
+
body: row.body ?? undefined,
|
|
472
|
+
meta: row.meta_text ?? undefined,
|
|
473
|
+
icon: row.icon ?? undefined,
|
|
474
|
+
view: row.view ?? undefined,
|
|
475
|
+
peer: row.peer_id ?? undefined,
|
|
476
|
+
messageId: row.message_id ?? undefined,
|
|
477
|
+
pairId: row.pair_id ?? undefined,
|
|
478
|
+
taskId: row.task_id ?? undefined,
|
|
479
|
+
agentId: row.agent_id ?? undefined,
|
|
480
|
+
metadata: parseJson(row.metadata, {}),
|
|
481
|
+
createdAt: row.created_at,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
441
485
|
const MSG_SELECT = `SELECT m.*, (
|
|
442
486
|
SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
|
|
443
487
|
) AS read_by_agents FROM messages m`;
|
|
@@ -1576,6 +1620,60 @@ export function deleteInboxDraft(operatorId: string, peerId: string): boolean {
|
|
|
1576
1620
|
return db.prepare("DELETE FROM inbox_drafts WHERE operator_id = ? AND peer_id = ?").run(operatorId, peerId).changes > 0;
|
|
1577
1621
|
}
|
|
1578
1622
|
|
|
1623
|
+
export function listActivityEvents(input: {
|
|
1624
|
+
operatorId?: string;
|
|
1625
|
+
limit?: number;
|
|
1626
|
+
since?: number;
|
|
1627
|
+
} = {}): ActivityEvent[] {
|
|
1628
|
+
const conditions: string[] = [];
|
|
1629
|
+
const params: any[] = [];
|
|
1630
|
+
if (input.operatorId) {
|
|
1631
|
+
conditions.push("operator_id = ?");
|
|
1632
|
+
params.push(input.operatorId);
|
|
1633
|
+
}
|
|
1634
|
+
if (input.since !== undefined) {
|
|
1635
|
+
conditions.push("created_at >= ?");
|
|
1636
|
+
params.push(input.since);
|
|
1637
|
+
}
|
|
1638
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1639
|
+
params.push(input.limit ?? 200);
|
|
1640
|
+
return (db.prepare(
|
|
1641
|
+
`SELECT * FROM activity_events ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
|
|
1642
|
+
).all(...params) as any[]).map(rowToActivityEvent);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
|
|
1646
|
+
if (input.clientId) {
|
|
1647
|
+
const existing = db.prepare("SELECT * FROM activity_events WHERE client_id = ?").get(input.clientId) as any | undefined;
|
|
1648
|
+
if (existing) return rowToActivityEvent(existing);
|
|
1649
|
+
}
|
|
1650
|
+
const now = Date.now();
|
|
1651
|
+
const result = db.prepare(`
|
|
1652
|
+
INSERT INTO activity_events (
|
|
1653
|
+
operator_id, client_id, kind, title, body, meta_text, icon, view, peer_id,
|
|
1654
|
+
message_id, pair_id, task_id, agent_id, metadata, created_at
|
|
1655
|
+
)
|
|
1656
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1657
|
+
`).run(
|
|
1658
|
+
input.operatorId ?? null,
|
|
1659
|
+
input.clientId ?? null,
|
|
1660
|
+
input.kind,
|
|
1661
|
+
input.title,
|
|
1662
|
+
input.body ?? null,
|
|
1663
|
+
input.meta ?? null,
|
|
1664
|
+
input.icon ?? null,
|
|
1665
|
+
input.view ?? null,
|
|
1666
|
+
input.peer ?? null,
|
|
1667
|
+
input.messageId ?? null,
|
|
1668
|
+
input.pairId ?? null,
|
|
1669
|
+
input.taskId ?? null,
|
|
1670
|
+
input.agentId ?? null,
|
|
1671
|
+
JSON.stringify(input.metadata ?? {}),
|
|
1672
|
+
now,
|
|
1673
|
+
);
|
|
1674
|
+
return rowToActivityEvent(db.prepare("SELECT * FROM activity_events WHERE id = ?").get(Number(result.lastInsertRowid)));
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1579
1677
|
export function deleteMessage(id: number): boolean {
|
|
1580
1678
|
return db.transaction(() => {
|
|
1581
1679
|
// Break reply_to references from children so the FK doesn't block delete.
|
package/src/routes.ts
CHANGED
|
@@ -40,12 +40,16 @@ import {
|
|
|
40
40
|
setInboxThreadState,
|
|
41
41
|
setInboxDraft,
|
|
42
42
|
deleteInboxDraft,
|
|
43
|
+
listActivityEvents,
|
|
44
|
+
createActivityEvent,
|
|
43
45
|
createCallbackDelivery,
|
|
44
46
|
finishCallbackDelivery,
|
|
47
|
+
reapStaleAgents,
|
|
48
|
+
releaseExpiredClaims,
|
|
45
49
|
validateAgentSession,
|
|
46
50
|
ValidationError,
|
|
47
51
|
} from "./db";
|
|
48
|
-
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";
|
|
49
53
|
import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
|
|
50
54
|
import {
|
|
51
55
|
getIntegrationAuth,
|
|
@@ -58,6 +62,7 @@ import {
|
|
|
58
62
|
emitAgentStatus,
|
|
59
63
|
emitAgentRemoved,
|
|
60
64
|
emitMessageClaimed,
|
|
65
|
+
emitMessageClaimReleased,
|
|
61
66
|
emitMessageDeleted,
|
|
62
67
|
emitTaskChanged,
|
|
63
68
|
} from "./sse";
|
|
@@ -147,6 +152,7 @@ const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
|
|
|
147
152
|
const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
|
|
148
153
|
const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
|
|
149
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;
|
|
150
156
|
const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
|
|
151
157
|
|
|
152
158
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -427,6 +433,26 @@ function normalizeInboxDraftInput(body: unknown): {
|
|
|
427
433
|
};
|
|
428
434
|
}
|
|
429
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
|
+
|
|
430
456
|
function pairErrorStatus(code: string): number {
|
|
431
457
|
if (code === "not_found") return 404;
|
|
432
458
|
if (code === "busy") return 409;
|
|
@@ -489,14 +515,39 @@ async function postCallback(deliveryId: number, url: string, payload: unknown):
|
|
|
489
515
|
}
|
|
490
516
|
}
|
|
491
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
|
+
|
|
492
529
|
// --- Agent routes ---
|
|
493
530
|
|
|
494
531
|
const postAgent: Handler = async (req) => {
|
|
495
532
|
const parsed = await parseBody<unknown>(req);
|
|
496
533
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
497
534
|
try {
|
|
498
|
-
const
|
|
535
|
+
const input = normalizeAgentInput(parsed.body);
|
|
536
|
+
const existing = getAgent(input.id);
|
|
537
|
+
const agent = upsertAgent(input);
|
|
499
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
|
+
}
|
|
500
551
|
return json(agent, 201);
|
|
501
552
|
} catch (e) {
|
|
502
553
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -542,6 +593,15 @@ const patchAgentStatus: Handler = async (req, params) => {
|
|
|
542
593
|
throw e;
|
|
543
594
|
}
|
|
544
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
|
+
});
|
|
545
605
|
return json({ ok: true });
|
|
546
606
|
};
|
|
547
607
|
|
|
@@ -600,6 +660,15 @@ const patchAgentReady: Handler = async (req, params) => {
|
|
|
600
660
|
throw e;
|
|
601
661
|
}
|
|
602
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
|
+
});
|
|
603
672
|
return json({ ok: true });
|
|
604
673
|
};
|
|
605
674
|
|
|
@@ -626,7 +695,20 @@ const postMessage: Handler = async (req) => {
|
|
|
626
695
|
input.idempotencyKey = cleanString(req.headers.get("Idempotency-Key") ?? undefined, "idempotencyKey", { max: 240 });
|
|
627
696
|
}
|
|
628
697
|
const result = sendMessageWithResult(input);
|
|
629
|
-
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
|
+
}
|
|
630
712
|
return json(result.message, result.created ? 201 : 200);
|
|
631
713
|
} catch (e) {
|
|
632
714
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -725,6 +807,17 @@ const postClaimMessage: Handler = async (req, params) => {
|
|
|
725
807
|
const result = claimMessage(id, body.agentId, guard);
|
|
726
808
|
if (result.ok) {
|
|
727
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
|
+
});
|
|
728
821
|
if (result.task) {
|
|
729
822
|
emitTaskChanged(result.task, "task.claimed");
|
|
730
823
|
void dispatchTaskCallbacks(result.task.id, "task.claimed");
|
|
@@ -830,6 +923,37 @@ const deleteInboxDraftRoute: Handler = (req) => {
|
|
|
830
923
|
}
|
|
831
924
|
};
|
|
832
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
|
+
|
|
833
957
|
// --- Tasks and integrations ---
|
|
834
958
|
|
|
835
959
|
const postIntegrationEvent: Handler = async (req) => {
|
|
@@ -904,6 +1028,17 @@ const postClaimTask: Handler = async (req, params) => {
|
|
|
904
1028
|
return error(result.error!, status);
|
|
905
1029
|
}
|
|
906
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
|
+
});
|
|
907
1042
|
if (result.task!.messageId) {
|
|
908
1043
|
emitMessageClaimed(result.task!.messageId, agentId, getMessage(result.task!.messageId)?.claimExpiresAt);
|
|
909
1044
|
}
|
|
@@ -946,6 +1081,17 @@ const patchTaskStatus: Handler = async (req, params) => {
|
|
|
946
1081
|
const result = updateTaskStatus(id, normalizeTaskStatusInput(parsed.body));
|
|
947
1082
|
if (!result.ok) return error(result.error!, result.error === "task not found" ? 404 : agentSessionStatus(result.error));
|
|
948
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
|
+
});
|
|
949
1095
|
void dispatchTaskCallbacks(id, "task.status");
|
|
950
1096
|
return json({ task: result.task, event: result.event });
|
|
951
1097
|
} catch (e) {
|
|
@@ -963,6 +1109,17 @@ const postPair: Handler = async (req) => {
|
|
|
963
1109
|
const result = createPair(normalizeCreatePairInput(parsed.body));
|
|
964
1110
|
if (!result.ok) return pairError(result);
|
|
965
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
|
+
});
|
|
966
1123
|
return json({ pair: result.pair, invite: result.invite }, 201);
|
|
967
1124
|
} catch (e) {
|
|
968
1125
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -994,6 +1151,17 @@ const postAcceptPair: Handler = async (req, params) => {
|
|
|
994
1151
|
const result = acceptPair(params.id!, normalizePairActionInput(parsed.body));
|
|
995
1152
|
if (!result.ok) return pairError(result);
|
|
996
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
|
+
});
|
|
997
1165
|
return json(result.pair);
|
|
998
1166
|
} catch (e) {
|
|
999
1167
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -1008,6 +1176,17 @@ const postRejectPair: Handler = async (req, params) => {
|
|
|
1008
1176
|
const result = rejectPair(params.id!, normalizePairActionInput(parsed.body));
|
|
1009
1177
|
if (!result.ok) return pairError(result);
|
|
1010
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
|
+
});
|
|
1011
1190
|
return json(result.pair);
|
|
1012
1191
|
} catch (e) {
|
|
1013
1192
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -1022,6 +1201,17 @@ const postHangupPair: Handler = async (req, params) => {
|
|
|
1022
1201
|
const result = endPair(params.id!, normalizePairActionInput(parsed.body));
|
|
1023
1202
|
if (!result.ok) return pairError(result);
|
|
1024
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
|
+
});
|
|
1025
1215
|
return json(result.pair);
|
|
1026
1216
|
} catch (e) {
|
|
1027
1217
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -1036,6 +1226,18 @@ const postPairMessage: Handler = async (req, params) => {
|
|
|
1036
1226
|
const result = sendPairMessage(params.id!, normalizePairMessageInput(parsed.body));
|
|
1037
1227
|
if (!result.ok) return pairError(result);
|
|
1038
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
|
+
});
|
|
1039
1241
|
return json({ pair: result.pair, message: result.message }, 201);
|
|
1040
1242
|
} catch (e) {
|
|
1041
1243
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
@@ -1056,6 +1258,28 @@ const getEvents: Handler = (req) => {
|
|
|
1056
1258
|
const getStatsRoute: Handler = () => json(getStats());
|
|
1057
1259
|
const getHealthRoute: Handler = () => json(getHealth());
|
|
1058
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
|
+
|
|
1059
1283
|
// --- Router ---
|
|
1060
1284
|
|
|
1061
1285
|
interface Route {
|
|
@@ -1106,6 +1330,9 @@ const routes: Route[] = [
|
|
|
1106
1330
|
route("PUT", "/api/inbox/drafts", putInboxDraft),
|
|
1107
1331
|
route("DELETE", "/api/inbox/drafts", deleteInboxDraftRoute),
|
|
1108
1332
|
|
|
1333
|
+
route("GET", "/api/activity", getActivityEvents),
|
|
1334
|
+
route("POST", "/api/activity", postActivityEvent),
|
|
1335
|
+
|
|
1109
1336
|
route("POST", "/api/integrations/events", postIntegrationEvent),
|
|
1110
1337
|
route("GET", "/api/tasks", getTasks),
|
|
1111
1338
|
route("GET", "/api/tasks/:id", getTaskById),
|
|
@@ -1125,6 +1352,7 @@ const routes: Route[] = [
|
|
|
1125
1352
|
route("GET", "/api/events", getEvents),
|
|
1126
1353
|
route("GET", "/api/stats", getStatsRoute),
|
|
1127
1354
|
route("GET", "/api/health", getHealthRoute),
|
|
1355
|
+
route("POST", "/api/system/reap", postSystemReap),
|
|
1128
1356
|
];
|
|
1129
1357
|
|
|
1130
1358
|
export function matchRoute(
|
package/src/security.ts
CHANGED
|
@@ -97,6 +97,7 @@ 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";
|
|
100
101
|
if (pathname.startsWith("/api/inbox")) return method === "GET" ? "messages:read" : "messages:write";
|
|
101
102
|
if (pathname.startsWith("/api/messages")) return method === "GET" ? "messages:read" : "messages:write";
|
|
102
103
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "tasks:read" : "tasks:write";
|
package/src/types.ts
CHANGED
|
@@ -207,6 +207,44 @@ export interface InboxState {
|
|
|
207
207
|
drafts: InboxDraft[];
|
|
208
208
|
}
|
|
209
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
|
+
|
|
210
248
|
export interface HealthCheck {
|
|
211
249
|
name: string;
|
|
212
250
|
status: "ok" | "warn" | "error";
|