agent-relay-server 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -14
- package/package.json +18 -1
- package/public/index.html +979 -2575
- package/public/manifest.webmanifest +6 -6
- package/public/sw.js +16 -10
- package/recipes/code-review.yaml +26 -0
- package/recipes/debug.yaml +20 -0
- package/recipes/feature.yaml +26 -0
- package/recipes/refactor.yaml +20 -0
- package/recipes/test.yaml +20 -0
- package/runner/src/adapter.ts +69 -0
- package/runner/src/config.ts +144 -0
- package/scripts/orchestrator-spawn-smoke.ts +2 -9
- package/src/agent-spawn.ts +2 -94
- package/src/automations.ts +774 -0
- package/src/bus-outbox.ts +75 -0
- package/src/bus.ts +439 -0
- package/src/cli.ts +251 -5
- package/src/commands-db.ts +160 -0
- package/src/config.ts +1 -1
- package/src/connectors.ts +29 -9
- package/src/daemon.ts +1 -0
- package/src/db.ts +241 -34
- package/src/events.ts +33 -0
- package/src/index.ts +94 -5
- package/src/recipe-db.ts +163 -0
- package/src/recipe-loader.ts +100 -0
- package/src/recipe-runner.ts +206 -0
- package/src/recipe-validator.ts +85 -0
- package/src/routes.ts +649 -155
- package/src/security.ts +128 -2
- package/src/sse.ts +42 -31
- package/src/token-db.ts +96 -0
- package/src/types.ts +1 -493
- package/src/upgrade.ts +14 -28
- package/public/dashboard/actions.js +0 -819
- package/public/dashboard/api.js +0 -336
- package/public/dashboard/app.js +0 -34
- package/public/dashboard/charts.js +0 -128
- package/public/dashboard/computed.js +0 -693
- package/public/dashboard/constants.js +0 -28
- package/public/dashboard/display.js +0 -345
- package/public/dashboard/state.js +0 -129
- package/public/dashboard/utils.js +0 -207
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { getDb } from "./db";
|
|
2
|
+
|
|
3
|
+
export interface BusEvent {
|
|
4
|
+
seq: number;
|
|
5
|
+
eventType: string;
|
|
6
|
+
source: string;
|
|
7
|
+
subject?: string;
|
|
8
|
+
data: Record<string, unknown>;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface OutboxRow {
|
|
13
|
+
seq: number;
|
|
14
|
+
event_type: string;
|
|
15
|
+
source: string;
|
|
16
|
+
subject: string | null;
|
|
17
|
+
data: string;
|
|
18
|
+
timestamp: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function appendEvent(type: string, source: string, data: unknown, subject?: string): number {
|
|
22
|
+
const timestamp = Date.now();
|
|
23
|
+
const result = getDb().prepare(`
|
|
24
|
+
INSERT INTO bus_outbox (event_type, source, subject, data, timestamp)
|
|
25
|
+
VALUES (?, ?, ?, ?, ?)
|
|
26
|
+
`).run(type, source, subject ?? null, JSON.stringify(data ?? {}), timestamp);
|
|
27
|
+
return Number(result.lastInsertRowid);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function replayEvents(since: number, limit = 500): BusEvent[] {
|
|
31
|
+
const rows = getDb().prepare(`
|
|
32
|
+
SELECT seq, event_type, source, subject, data, timestamp
|
|
33
|
+
FROM bus_outbox
|
|
34
|
+
WHERE seq > ?
|
|
35
|
+
ORDER BY seq ASC
|
|
36
|
+
LIMIT ?
|
|
37
|
+
`).all(since, limit) as OutboxRow[];
|
|
38
|
+
return rows.map(rowToEvent);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function pruneOutbox(retentionMs = 60 * 60 * 1000): number {
|
|
42
|
+
const threshold = Date.now() - retentionMs;
|
|
43
|
+
const result = getDb().prepare("DELETE FROM bus_outbox WHERE timestamp < ?").run(threshold);
|
|
44
|
+
return result.changes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getOutboxCursor(): number {
|
|
48
|
+
const row = getDb().prepare("SELECT coalesce(max(seq), 0) AS cursor FROM bus_outbox").get() as { cursor: number };
|
|
49
|
+
return row.cursor;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getOldestOutboxCursor(): number {
|
|
53
|
+
const row = getDb().prepare("SELECT min(seq) AS cursor FROM bus_outbox").get() as { cursor: number | null };
|
|
54
|
+
return row.cursor ?? 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function rowToEvent(row: OutboxRow): BusEvent {
|
|
58
|
+
return {
|
|
59
|
+
seq: row.seq,
|
|
60
|
+
eventType: row.event_type,
|
|
61
|
+
source: row.source,
|
|
62
|
+
subject: row.subject ?? undefined,
|
|
63
|
+
data: parseData(row.data),
|
|
64
|
+
timestamp: row.timestamp,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseData(raw: string): Record<string, unknown> {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(raw);
|
|
71
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : { value: parsed };
|
|
72
|
+
} catch {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/bus.ts
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import type { Server, ServerWebSocket } from "bun";
|
|
2
|
+
import { getAgent, getDb, heartbeat, markReady, orphanTasksForAgent, setStatus, upsertAgent, validateAgentSession } from "./db";
|
|
3
|
+
import { getOldestOutboxCursor, getOutboxCursor, replayEvents, type BusEvent } from "./bus-outbox";
|
|
4
|
+
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
5
|
+
import { createCommand, getCommand, updateCommand } from "./commands-db";
|
|
6
|
+
import { applyCommandToRecipe } from "./recipe-runner";
|
|
7
|
+
import {
|
|
8
|
+
BusProtocolError,
|
|
9
|
+
parseBusFrame,
|
|
10
|
+
validateClientFrame,
|
|
11
|
+
type BusFrame,
|
|
12
|
+
type RegisterFrame,
|
|
13
|
+
} from "agent-relay-sdk/protocol";
|
|
14
|
+
import { getComponentAuth, hasComponentScope, isAuthorized, isOriginAllowed, unauthorized } from "./security";
|
|
15
|
+
import type { AgentCard, Command, Message, Task } from "./types";
|
|
16
|
+
|
|
17
|
+
interface BusSocketData {
|
|
18
|
+
id: string;
|
|
19
|
+
componentScopes?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type BusWebSocket = ServerWebSocket<BusSocketData>;
|
|
23
|
+
|
|
24
|
+
interface BusConnection {
|
|
25
|
+
id: string;
|
|
26
|
+
componentId: string;
|
|
27
|
+
agentId?: string;
|
|
28
|
+
role: string;
|
|
29
|
+
componentScopes?: string[];
|
|
30
|
+
instanceId: string;
|
|
31
|
+
epoch: number;
|
|
32
|
+
ws: BusWebSocket;
|
|
33
|
+
subscriptions: Set<string>;
|
|
34
|
+
lastAck: number;
|
|
35
|
+
registeredAt: number;
|
|
36
|
+
transportReconnect: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const busConnections = new Map<string, BusConnection>();
|
|
40
|
+
|
|
41
|
+
subscribeRelayEvents((event) => broadcastRelayEvent(event));
|
|
42
|
+
|
|
43
|
+
export function handleBusUpgrade(req: Request, server: Server<BusSocketData>): Response | undefined {
|
|
44
|
+
if (!isOriginAllowed(req)) {
|
|
45
|
+
return Response.json({ error: "origin not allowed" }, { status: 403 });
|
|
46
|
+
}
|
|
47
|
+
if (!isAuthorized(req)) return unauthorized(req);
|
|
48
|
+
const component = getComponentAuth(req);
|
|
49
|
+
const upgraded = server.upgrade(req, {
|
|
50
|
+
data: {
|
|
51
|
+
id: crypto.randomUUID(),
|
|
52
|
+
componentScopes: component?.scope,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function busHandleOpen(_ws: BusWebSocket): void {
|
|
60
|
+
// Registration is explicit. The socket is not visible to projections until
|
|
61
|
+
// a valid register frame establishes identity and subscriptions.
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function busHandleMessage(ws: BusWebSocket, data: string | Buffer): void {
|
|
65
|
+
let frame: ReturnType<typeof validateClientFrame>;
|
|
66
|
+
try {
|
|
67
|
+
frame = validateClientFrame(parseBusFrame(data));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
sendError(ws, undefined, protocolCode(error), error instanceof Error ? error.message : "invalid frame");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
handleFrame(ws, frame);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
sendError(ws, frame.id, "FRAME_FAILED", error instanceof Error ? error.message : String(error));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function busHandleClose(ws: BusWebSocket): void {
|
|
81
|
+
const conn = busConnections.get(ws.data.id);
|
|
82
|
+
busConnections.delete(ws.data.id);
|
|
83
|
+
if (conn?.agentId) {
|
|
84
|
+
if (!conn.transportReconnect) {
|
|
85
|
+
setStatus(conn.agentId, "stale", { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
86
|
+
emitAgentStatusEvent(conn.agentId);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getBusConnectionCount(): number {
|
|
92
|
+
return busConnections.size;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function expireStaleBusAgents(graceMs = Number(process.env.AGENT_RELAY_STALE_GRACE_MS) || 120_000): { agentIds: string[]; orphanedTasks: Task[] } {
|
|
96
|
+
const cutoff = Date.now() - graceMs;
|
|
97
|
+
const rows = getDb()
|
|
98
|
+
.prepare("SELECT id FROM agents WHERE status = 'stale' AND last_seen < ? AND id NOT IN ('user', 'system')")
|
|
99
|
+
.all(cutoff) as Array<{ id: string }>;
|
|
100
|
+
const orphanedTasks: Task[] = [];
|
|
101
|
+
for (const row of rows) {
|
|
102
|
+
setStatus(row.id, "offline");
|
|
103
|
+
emitAgentStatusEvent(row.id);
|
|
104
|
+
const tasks = orphanTasksForAgent(row.id);
|
|
105
|
+
for (const task of tasks) {
|
|
106
|
+
orphanedTasks.push(task);
|
|
107
|
+
emitRelayEvent({
|
|
108
|
+
type: "task.orphaned",
|
|
109
|
+
source: "agent-relay",
|
|
110
|
+
subject: String(task.id),
|
|
111
|
+
data: task as unknown as Record<string, unknown>,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { agentIds: rows.map((row) => row.id), orphanedTasks };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFrame>): void {
|
|
119
|
+
switch (frame.type) {
|
|
120
|
+
case "register":
|
|
121
|
+
handleRegister(ws, frame);
|
|
122
|
+
return;
|
|
123
|
+
case "heartbeat":
|
|
124
|
+
withRegistered(ws, frame, (conn) => {
|
|
125
|
+
if (conn.agentId) {
|
|
126
|
+
heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
127
|
+
if (frame.payload.status) setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
128
|
+
emitAgentStatusEvent(conn.agentId);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
case "subscribe":
|
|
133
|
+
withRegistered(ws, frame, (conn) => {
|
|
134
|
+
for (const event of frame.payload.events) conn.subscriptions.add(event);
|
|
135
|
+
send(ws, { type: "ack", payload: { frameId: frame.id } });
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
case "status":
|
|
139
|
+
withRegistered(ws, frame, (conn) => {
|
|
140
|
+
if (!conn.agentId) return sendError(ws, frame.id, "AGENT_REQUIRED", "status frames require an agent registration");
|
|
141
|
+
const guard = { instanceId: conn.instanceId, epoch: conn.epoch };
|
|
142
|
+
if (!setStatus(conn.agentId, frame.payload.agentStatus, guard)) {
|
|
143
|
+
return sendError(ws, frame.id, "AGENT_NOT_FOUND", "agent not found");
|
|
144
|
+
}
|
|
145
|
+
if (frame.payload.ready !== undefined && !markReady(conn.agentId, frame.payload.ready, guard)) {
|
|
146
|
+
return sendError(ws, frame.id, "AGENT_NOT_FOUND", "agent not found");
|
|
147
|
+
}
|
|
148
|
+
emitAgentStatusEvent(conn.agentId);
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
case "ack":
|
|
152
|
+
withRegistered(ws, frame, (conn) => {
|
|
153
|
+
const parsed = Number(frame.payload.frameId);
|
|
154
|
+
if (Number.isSafeInteger(parsed)) conn.lastAck = Math.max(conn.lastAck, parsed);
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
case "resume":
|
|
158
|
+
withRegistered(ws, frame, () => handleResume(ws, frame.id, frame.payload.since));
|
|
159
|
+
return;
|
|
160
|
+
case "command":
|
|
161
|
+
withRegistered(ws, frame, (conn) => handleCommandFrame(ws, conn, frame.id, frame.payload.commandType, frame.payload.target, frame.payload.params));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function handleCommandFrame(
|
|
167
|
+
ws: BusWebSocket,
|
|
168
|
+
conn: BusConnection,
|
|
169
|
+
frameId: string,
|
|
170
|
+
commandType: string,
|
|
171
|
+
target: string,
|
|
172
|
+
params: Record<string, unknown>,
|
|
173
|
+
): void {
|
|
174
|
+
if (conn.componentScopes && !hasComponentScope({ sub: conn.componentId, role: conn.role, scope: conn.componentScopes, iat: 0 }, "command:*")) {
|
|
175
|
+
sendCommandResult(ws, frameId, "rejected", undefined, "component token lacks command scope");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (commandType === "command.update") {
|
|
180
|
+
const command = getCommand(target);
|
|
181
|
+
if (!command) {
|
|
182
|
+
sendCommandResult(ws, frameId, "failed", undefined, "command not found");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const status = typeof params.status === "string" ? params.status : undefined;
|
|
186
|
+
const updated = updateCommand(command.id, {
|
|
187
|
+
status: status as any,
|
|
188
|
+
result: isRecord(params.result) ? params.result : undefined,
|
|
189
|
+
error: typeof params.error === "string" ? params.error : undefined,
|
|
190
|
+
});
|
|
191
|
+
if (updated) {
|
|
192
|
+
applyCommandToRecipe(updated);
|
|
193
|
+
emitCommandEvent(updated, `command.${updated.status}`);
|
|
194
|
+
}
|
|
195
|
+
sendCommandResult(ws, frameId, "succeeded", updated ? { command: updated } : undefined);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const command = createCommand({
|
|
200
|
+
type: commandType,
|
|
201
|
+
source: conn.agentId ?? conn.componentId,
|
|
202
|
+
target,
|
|
203
|
+
params,
|
|
204
|
+
});
|
|
205
|
+
emitCommandEvent(command, "command.requested");
|
|
206
|
+
sendCommandResult(ws, frameId, "succeeded", { command });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function handleRegister(ws: BusWebSocket, frame: RegisterFrame): void {
|
|
210
|
+
const payload = frame.payload;
|
|
211
|
+
const runnerManaged = payload.meta?.runnerManaged === true || typeof payload.meta?.runnerId === "string";
|
|
212
|
+
if (runnerManaged && !hasRunnerLifecycleCapabilities(payload.capabilities, payload.meta)) {
|
|
213
|
+
sendError(ws, frame.id, "INVALID_RUNNER_REGISTRATION", "runner agents must advertise hard restart/shutdown and semantic status capabilities");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
let epoch = 0;
|
|
217
|
+
if (payload.agentId) {
|
|
218
|
+
const label = stringMeta(payload.meta, "label");
|
|
219
|
+
const agent = upsertAgent({
|
|
220
|
+
id: payload.agentId,
|
|
221
|
+
name: stringMeta(payload.meta, "name") ?? payload.componentId,
|
|
222
|
+
kind: payload.role === "integration" ? "provider" : payload.role,
|
|
223
|
+
...(label ? { label } : {}),
|
|
224
|
+
tags: payload.tags,
|
|
225
|
+
machine: payload.machine,
|
|
226
|
+
capabilities: payload.capabilities,
|
|
227
|
+
status: runnerManaged ? "idle" : "online",
|
|
228
|
+
ready: true,
|
|
229
|
+
instanceId: payload.instanceId,
|
|
230
|
+
meta: payload.meta,
|
|
231
|
+
});
|
|
232
|
+
epoch = agent.epoch;
|
|
233
|
+
emitAgentStatusEvent(agent.id);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
busConnections.set(ws.data.id, {
|
|
237
|
+
id: ws.data.id,
|
|
238
|
+
componentId: payload.componentId,
|
|
239
|
+
agentId: payload.agentId,
|
|
240
|
+
role: payload.role,
|
|
241
|
+
componentScopes: ws.data.componentScopes,
|
|
242
|
+
instanceId: payload.instanceId,
|
|
243
|
+
epoch,
|
|
244
|
+
ws,
|
|
245
|
+
subscriptions: new Set(["message.*", "agent.status", "task.*", "command.*"]),
|
|
246
|
+
lastAck: 0,
|
|
247
|
+
registeredAt: Date.now(),
|
|
248
|
+
transportReconnect: runnerManaged && isRecord(payload.meta.lifecycleCapabilities) && payload.meta.lifecycleCapabilities.transportReconnect === true,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
send(ws, {
|
|
252
|
+
type: "registered",
|
|
253
|
+
payload: {
|
|
254
|
+
epoch,
|
|
255
|
+
cursor: getOutboxCursor(),
|
|
256
|
+
sessionId: ws.data.id,
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function hasRunnerLifecycleCapabilities(capabilities: string[], meta: Record<string, unknown>): boolean {
|
|
262
|
+
const lifecycle = isRecord(meta.lifecycleCapabilities) ? meta.lifecycleCapabilities : {};
|
|
263
|
+
return capabilities.includes("lifecycle.shutdown.hard") &&
|
|
264
|
+
capabilities.includes("lifecycle.restart.hard") &&
|
|
265
|
+
capabilities.includes("lifecycle.status.semantic") &&
|
|
266
|
+
lifecycle.shutdownHard === true &&
|
|
267
|
+
lifecycle.restartHard === true &&
|
|
268
|
+
lifecycle.semanticStatus === true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function handleResume(ws: BusWebSocket, frameId: string, since: number): void {
|
|
272
|
+
const oldest = getOldestOutboxCursor();
|
|
273
|
+
if (since > 0 && oldest > 0 && oldest > since + 1) {
|
|
274
|
+
sendError(ws, frameId, "REPLAY_GAP", "requested cursor is older than retained bus outbox events");
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const events = replayEvents(since).map(outboxToPayload);
|
|
278
|
+
send(ws, {
|
|
279
|
+
type: "resumed",
|
|
280
|
+
payload: {
|
|
281
|
+
events,
|
|
282
|
+
fromSeq: events[0]?.seq ?? since,
|
|
283
|
+
toSeq: events.at(-1)?.seq ?? since,
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function withRegistered(
|
|
289
|
+
ws: BusWebSocket,
|
|
290
|
+
frame: BusFrame,
|
|
291
|
+
fn: (conn: BusConnection) => void,
|
|
292
|
+
): void {
|
|
293
|
+
const conn = busConnections.get(ws.data.id);
|
|
294
|
+
if (!conn) {
|
|
295
|
+
sendError(ws, frame.id, "NOT_REGISTERED", "register before sending bus frames");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (conn.agentId) {
|
|
299
|
+
const session = validateAgentSession(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
300
|
+
if (!session.ok) {
|
|
301
|
+
sendError(ws, frame.id, "STALE_SESSION", session.error ?? "stale agent session");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
fn(conn);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function broadcastRelayEvent(event: RelayEvent): void {
|
|
309
|
+
for (const conn of busConnections.values()) {
|
|
310
|
+
if (!connectionWantsEvent(conn, event)) continue;
|
|
311
|
+
send(conn.ws, {
|
|
312
|
+
type: "event",
|
|
313
|
+
payload: {
|
|
314
|
+
seq: event.seq,
|
|
315
|
+
eventType: event.type,
|
|
316
|
+
source: event.source,
|
|
317
|
+
subject: event.subject,
|
|
318
|
+
data: event.data,
|
|
319
|
+
timestamp: event.timestamp,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function connectionWantsEvent(conn: BusConnection, event: RelayEvent): boolean {
|
|
326
|
+
if (!matchesSubscription(conn.subscriptions, event.type)) return false;
|
|
327
|
+
if (event.type === "message.new" && conn.agentId) {
|
|
328
|
+
return messageMatchesAgent(event.data as unknown as Message, conn.agentId);
|
|
329
|
+
}
|
|
330
|
+
if (event.type.startsWith("task.") && conn.agentId) {
|
|
331
|
+
const target = typeof event.data.target === "string" ? event.data.target : "";
|
|
332
|
+
return targetMatchesAgent(target, conn.agentId);
|
|
333
|
+
}
|
|
334
|
+
if (event.type.startsWith("command.")) {
|
|
335
|
+
const command = isRecord(event.data.command) ? event.data.command : undefined;
|
|
336
|
+
if (!command) return true;
|
|
337
|
+
const target = typeof command.target === "string" ? command.target : "";
|
|
338
|
+
const source = typeof command.source === "string" ? command.source : "";
|
|
339
|
+
return target === conn.componentId || target === conn.agentId || source === conn.componentId || source === conn.agentId;
|
|
340
|
+
}
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function matchesSubscription(subscriptions: Set<string>, eventType: string): boolean {
|
|
345
|
+
if (subscriptions.size === 0) return false;
|
|
346
|
+
for (const sub of subscriptions) {
|
|
347
|
+
if (sub === "*" || sub === eventType) return true;
|
|
348
|
+
if (sub.endsWith("*") && eventType.startsWith(sub.slice(0, -1))) return true;
|
|
349
|
+
}
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function messageMatchesAgent(msg: Message, agentId: string): boolean {
|
|
354
|
+
const agent = getAgent(agentId);
|
|
355
|
+
if (!agent) return false;
|
|
356
|
+
if (msg.claimable && msg.claimedBy && msg.claimedBy !== agentId) return false;
|
|
357
|
+
if (msg.to === agentId || msg.from === agentId) return true;
|
|
358
|
+
return targetMatchesAgent(msg.to, agentId, agent);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function targetMatchesAgent(target: string, agentId: string, knownAgent?: AgentCard): boolean {
|
|
362
|
+
const agent = knownAgent ?? getAgent(agentId);
|
|
363
|
+
if (!agent) return false;
|
|
364
|
+
if (target === agentId || target === "broadcast") return true;
|
|
365
|
+
if (target.startsWith("tag:") && agent.tags.includes(target.slice(4))) return true;
|
|
366
|
+
if (target.startsWith("cap:") && agent.capabilities.includes(target.slice(4))) return true;
|
|
367
|
+
if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function outboxToPayload(event: BusEvent) {
|
|
372
|
+
return {
|
|
373
|
+
seq: event.seq,
|
|
374
|
+
eventType: event.eventType,
|
|
375
|
+
source: event.source,
|
|
376
|
+
subject: event.subject,
|
|
377
|
+
data: event.data,
|
|
378
|
+
timestamp: event.timestamp,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function emitAgentStatusEvent(agentId: string): void {
|
|
383
|
+
const agent = getAgent(agentId);
|
|
384
|
+
const data = agent ?? { id: agentId, status: "offline" };
|
|
385
|
+
emitRelayEvent({
|
|
386
|
+
type: "agent.status",
|
|
387
|
+
source: "server",
|
|
388
|
+
subject: agentId,
|
|
389
|
+
data: data as unknown as Record<string, unknown>,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function emitCommandEvent(command: Command, type: string): void {
|
|
394
|
+
emitRelayEvent({
|
|
395
|
+
type,
|
|
396
|
+
source: command.source,
|
|
397
|
+
subject: command.id,
|
|
398
|
+
data: { command },
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function sendCommandResult(
|
|
403
|
+
ws: BusWebSocket,
|
|
404
|
+
commandId: string,
|
|
405
|
+
status: "succeeded" | "failed" | "rejected" | "timed_out",
|
|
406
|
+
result?: Record<string, unknown>,
|
|
407
|
+
error?: string,
|
|
408
|
+
): void {
|
|
409
|
+
send(ws, {
|
|
410
|
+
type: "command.result",
|
|
411
|
+
payload: {
|
|
412
|
+
commandId,
|
|
413
|
+
status,
|
|
414
|
+
result,
|
|
415
|
+
error,
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
421
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function send(ws: BusWebSocket, frame: Record<string, unknown>): void {
|
|
425
|
+
ws.send(JSON.stringify(frame));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function sendError(ws: BusWebSocket, frameId: string | undefined, code: string, message: string): void {
|
|
429
|
+
send(ws, { type: "error", payload: { frameId, code, message } });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function protocolCode(error: unknown): string {
|
|
433
|
+
return error instanceof BusProtocolError ? error.code : "INVALID_FRAME";
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function stringMeta(meta: Record<string, unknown>, key: string): string | undefined {
|
|
437
|
+
const value = meta[key];
|
|
438
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
439
|
+
}
|