agent-relay-server 0.4.39 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/package.json +1 -1
- package/public/dashboard.js +233 -17
- package/public/icons/agent-relay-192.png +0 -0
- package/public/icons/agent-relay-512.png +0 -0
- package/public/icons/agent-relay.svg +14 -0
- package/public/index.html +276 -4
- package/public/manifest.webmanifest +33 -0
- package/public/sw.js +58 -0
- package/src/cli.ts +80 -17
- package/src/connectors.ts +256 -0
- package/src/db.ts +544 -25
- package/src/index.ts +25 -1
- package/src/routes.ts +632 -26
- package/src/security.ts +2 -1
- package/src/sse.ts +21 -1
- package/src/types.ts +152 -3
package/src/routes.ts
CHANGED
|
@@ -42,15 +42,35 @@ import {
|
|
|
42
42
|
setInboxDraft,
|
|
43
43
|
deleteInboxDraft,
|
|
44
44
|
listActivityEvents,
|
|
45
|
+
listChannels,
|
|
46
|
+
listChannelBindings,
|
|
47
|
+
getChannel,
|
|
48
|
+
resolveChannelRoutes,
|
|
49
|
+
upsertChannelBinding,
|
|
45
50
|
createActivityEvent,
|
|
46
51
|
createCallbackDelivery,
|
|
47
52
|
finishCallbackDelivery,
|
|
48
53
|
reapStaleAgents,
|
|
54
|
+
reapStaleOrchestrators,
|
|
49
55
|
releaseExpiredClaims,
|
|
50
56
|
validateAgentSession,
|
|
57
|
+
upsertOrchestrator,
|
|
58
|
+
getOrchestrator,
|
|
59
|
+
listOrchestrators,
|
|
60
|
+
orchestratorHeartbeat,
|
|
61
|
+
updateManagedAgents,
|
|
62
|
+
deleteOrchestrator,
|
|
51
63
|
ValidationError,
|
|
52
64
|
} from "./db";
|
|
53
|
-
import
|
|
65
|
+
import {
|
|
66
|
+
getConnector,
|
|
67
|
+
listConnectors,
|
|
68
|
+
readConnectorConfig,
|
|
69
|
+
registerConnectorManifest,
|
|
70
|
+
runConnectorAction,
|
|
71
|
+
writeConnectorConfig,
|
|
72
|
+
} from "./connectors";
|
|
73
|
+
import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnProvider, TaskStatus, TaskStatusInput } from "./types";
|
|
54
74
|
import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
|
|
55
75
|
import { listHostDirectories, spawnCodexAgent, type CodexSpawnApprovalMode } from "./agent-spawn";
|
|
56
76
|
import {
|
|
@@ -67,6 +87,9 @@ import {
|
|
|
67
87
|
emitMessageClaimReleased,
|
|
68
88
|
emitMessageDeleted,
|
|
69
89
|
emitTaskChanged,
|
|
90
|
+
emitChannelActivity,
|
|
91
|
+
emitOrchestratorStatus,
|
|
92
|
+
emitOrchestratorRemoved,
|
|
70
93
|
} from "./sse";
|
|
71
94
|
|
|
72
95
|
type Handler = (
|
|
@@ -151,9 +174,13 @@ function parseQueryInt(
|
|
|
151
174
|
}
|
|
152
175
|
|
|
153
176
|
const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
|
|
177
|
+
const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
|
|
178
|
+
const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator"] as const;
|
|
179
|
+
const VALID_CHANNEL_BINDING_MODES = ["exclusive", "claimable", "broadcast"] as const;
|
|
154
180
|
const VALID_AGENT_ACTIONS = ["restart", "shutdown"] as const;
|
|
155
181
|
const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
|
|
156
182
|
const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
|
|
183
|
+
const VALID_CONNECTOR_ACTIONS = ["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"] as const;
|
|
157
184
|
const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
|
|
158
185
|
const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
|
|
159
186
|
const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
|
|
@@ -273,6 +300,7 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
|
|
|
273
300
|
const input: RegisterAgentInput = {
|
|
274
301
|
id: cleanString(body.id, "id", { required: true, max: 200 })!,
|
|
275
302
|
name: cleanString(body.name, "name", { required: true, max: 200 })!,
|
|
303
|
+
kind: cleanEnum(body.kind, "kind", VALID_AGENT_KINDS) as AgentKind | undefined,
|
|
276
304
|
status: status as RegisterAgentInput["status"] | undefined,
|
|
277
305
|
ready: body.ready as boolean | undefined,
|
|
278
306
|
};
|
|
@@ -297,9 +325,9 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
|
|
|
297
325
|
|
|
298
326
|
function normalizeMessageInput(body: unknown): SendMessageInput {
|
|
299
327
|
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
302
|
-
throw new ValidationError(`
|
|
328
|
+
const kind = cleanString(body.kind, "kind", { max: 40 });
|
|
329
|
+
if (kind && !VALID_MSG_KINDS.includes(kind)) {
|
|
330
|
+
throw new ValidationError(`kind must be one of: ${VALID_MSG_KINDS.join(", ")}`);
|
|
303
331
|
}
|
|
304
332
|
if (body.claimable !== undefined && typeof body.claimable !== "boolean") {
|
|
305
333
|
throw new ValidationError("claimable must be a boolean");
|
|
@@ -309,7 +337,7 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
|
|
|
309
337
|
from: cleanString(body.from, "from", { required: true, max: 200 })!,
|
|
310
338
|
to: cleanString(body.to, "to", { required: true, max: 200 })!,
|
|
311
339
|
body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
|
|
312
|
-
|
|
340
|
+
kind: kind as SendMessageInput["kind"] | undefined,
|
|
313
341
|
replyTo: cleanPositiveId(body.replyTo, "replyTo"),
|
|
314
342
|
claimable: body.claimable as boolean | undefined,
|
|
315
343
|
idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }),
|
|
@@ -321,10 +349,168 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
|
|
|
321
349
|
if (subject) input.subject = subject;
|
|
322
350
|
const meta = cleanMeta(body.meta);
|
|
323
351
|
if (meta) input.meta = meta;
|
|
352
|
+
const payload = cleanMeta(body.payload);
|
|
353
|
+
if (payload) input.payload = payload;
|
|
324
354
|
|
|
325
355
|
return input;
|
|
326
356
|
}
|
|
327
357
|
|
|
358
|
+
function normalizeChannelBindingInput(body: unknown): {
|
|
359
|
+
channelId: string;
|
|
360
|
+
conversationId?: string;
|
|
361
|
+
target: ChannelRouteTarget;
|
|
362
|
+
mode?: ChannelBindingMode;
|
|
363
|
+
priority?: number;
|
|
364
|
+
} {
|
|
365
|
+
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
366
|
+
const channelId = cleanString(body.channelId, "channelId", { required: true, max: 200 })!;
|
|
367
|
+
const conversationId = cleanString(body.conversationId, "conversationId", { max: 300 });
|
|
368
|
+
const mode = cleanEnum(body.mode, "mode", VALID_CHANNEL_BINDING_MODES, "exclusive") as ChannelBindingMode | undefined;
|
|
369
|
+
const priority = typeof body.priority === "number" && Number.isSafeInteger(body.priority) ? body.priority : undefined;
|
|
370
|
+
|
|
371
|
+
let target: ChannelRouteTarget | undefined;
|
|
372
|
+
if (typeof body.target === "string") {
|
|
373
|
+
target = routeTargetFromAddress(body.target);
|
|
374
|
+
} else if (isRecord(body.target)) {
|
|
375
|
+
const type = cleanEnum(body.target.type, "target.type", VALID_CHANNEL_BINDING_TARGET_TYPES)!;
|
|
376
|
+
const id = type === "broadcast" ? undefined : cleanString(body.target.id, "target.id", { required: true, max: 240 })!;
|
|
377
|
+
if (type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
|
|
378
|
+
target = type === "broadcast" ? { type: "broadcast" } : { type, id } as ChannelRouteTarget;
|
|
379
|
+
}
|
|
380
|
+
if (!target) throw new ValidationError("target required");
|
|
381
|
+
|
|
382
|
+
return { channelId, conversationId, target, mode, priority };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function routeTargetFromAddress(target: string): ChannelRouteTarget {
|
|
386
|
+
if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
|
|
387
|
+
if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
|
|
388
|
+
if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
|
|
389
|
+
if (target === "broadcast") return { type: "broadcast" };
|
|
390
|
+
if (target.startsWith("orchestrator:")) throw new ValidationError("orchestrator channel targets are not supported yet");
|
|
391
|
+
return { type: "agent", id: target };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function messageTargetForChannelTarget(target: ChannelRouteTarget): string {
|
|
395
|
+
if (target.type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
|
|
396
|
+
if (target.type === "label") return `label:${target.id}`;
|
|
397
|
+
if (target.type === "tag") return `tag:${target.id}`;
|
|
398
|
+
if (target.type === "capability") return `cap:${target.id}`;
|
|
399
|
+
if (target.type === "broadcast") return "broadcast";
|
|
400
|
+
return target.id;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function payloadConversationId(payload: Record<string, unknown>): string | undefined {
|
|
404
|
+
const conversation = payload.conversation;
|
|
405
|
+
if (isRecord(conversation)) return cleanString(conversation.id, "payload.conversation.id", { max: 300 });
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function payloadDedupeKey(payload: Record<string, unknown>): string | undefined {
|
|
410
|
+
const dedupe = payload.dedupe;
|
|
411
|
+
if (isRecord(dedupe)) return cleanString(dedupe.key, "payload.dedupe.key", { max: 240 });
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const EPHEMERAL_CHANNEL_EVENT_TYPES = new Set([
|
|
416
|
+
"typing.started",
|
|
417
|
+
"typing.stopped",
|
|
418
|
+
"message.read",
|
|
419
|
+
"message.delivered",
|
|
420
|
+
"presence.updated",
|
|
421
|
+
]);
|
|
422
|
+
|
|
423
|
+
function channelPayloadEventType(payload: Record<string, unknown>): string {
|
|
424
|
+
const event = payload.event;
|
|
425
|
+
if (!isRecord(event)) throw new ValidationError("payload.event must be an object");
|
|
426
|
+
return cleanString(event.type, "payload.event.type", { required: true, max: 80 })!;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function validateChannelEnvelope(payload: Record<string, unknown>, channel: ChannelSummary): void {
|
|
430
|
+
const schema = cleanString(payload.schema, "payload.schema", { required: true, max: 80 });
|
|
431
|
+
if (schema !== "agent-relay.channel.v1") throw new ValidationError("payload.schema must be agent-relay.channel.v1");
|
|
432
|
+
|
|
433
|
+
const payloadChannel = payload.channel;
|
|
434
|
+
if (!isRecord(payloadChannel)) throw new ValidationError("payload.channel must be an object");
|
|
435
|
+
const provider = cleanString(payloadChannel.provider, "payload.channel.provider", { required: true, max: 80 });
|
|
436
|
+
const accountId = cleanString(payloadChannel.accountId, "payload.channel.accountId", { required: true, max: 200 });
|
|
437
|
+
const agentId = cleanString(payloadChannel.agentId, "payload.channel.agentId", { required: true, max: 200 });
|
|
438
|
+
if (provider !== channel.type || accountId !== channel.accountId || agentId !== channel.agentId) {
|
|
439
|
+
throw new ValidationError("payload.channel does not match channel identity");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const direction = cleanString(payload.direction, "payload.direction", { required: true, max: 20 });
|
|
443
|
+
if (direction !== "inbound" && direction !== "outbound") throw new ValidationError("payload.direction must be inbound or outbound");
|
|
444
|
+
|
|
445
|
+
const conversation = payload.conversation;
|
|
446
|
+
if (!isRecord(conversation)) throw new ValidationError("payload.conversation must be an object");
|
|
447
|
+
cleanString(conversation.id, "payload.conversation.id", { required: true, max: 300 });
|
|
448
|
+
cleanString(conversation.type, "payload.conversation.type", { required: true, max: 80 });
|
|
449
|
+
|
|
450
|
+
const actor = payload.actor;
|
|
451
|
+
if (!isRecord(actor)) throw new ValidationError("payload.actor must be an object");
|
|
452
|
+
cleanString(actor.id, "payload.actor.id", { required: true, max: 240 });
|
|
453
|
+
cleanString(actor.kind, "payload.actor.kind", { required: true, max: 80 });
|
|
454
|
+
|
|
455
|
+
channelPayloadEventType(payload);
|
|
456
|
+
validateChannelAttachmentRefs(payload);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function validateChannelPayloadContent(payload: Record<string, unknown>, allowActivity: boolean): void {
|
|
460
|
+
const hasMessage = isRecord(payload.message);
|
|
461
|
+
const hasAttachments = Array.isArray(payload.attachments);
|
|
462
|
+
const hasReaction = isRecord(payload.reaction);
|
|
463
|
+
const hasInteraction = isRecord(payload.interaction);
|
|
464
|
+
const hasActivity = isRecord(payload.activity);
|
|
465
|
+
if (!hasMessage && !hasAttachments && !hasReaction && !hasInteraction && !hasActivity) {
|
|
466
|
+
throw new ValidationError("channel payload must include message, attachments, reaction, interaction, or activity");
|
|
467
|
+
}
|
|
468
|
+
if (!allowActivity && hasActivity) throw new ValidationError("channel activity must use /api/channels/:id/activities");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function validateChannelAttachmentRefs(payload: Record<string, unknown>): void {
|
|
472
|
+
if (payload.attachments === undefined) return;
|
|
473
|
+
if (!Array.isArray(payload.attachments)) throw new ValidationError("payload.attachments must be an array");
|
|
474
|
+
for (const [index, item] of payload.attachments.entries()) {
|
|
475
|
+
if (!isRecord(item)) throw new ValidationError(`payload.attachments[${index}] must be an object`);
|
|
476
|
+
const ref = item.ref;
|
|
477
|
+
if (!isRecord(ref)) throw new ValidationError(`payload.attachments[${index}].ref must be an object`);
|
|
478
|
+
const type = cleanString(ref.type, `payload.attachments[${index}].ref.type`, { required: true, max: 40 });
|
|
479
|
+
if (type !== "relay-blob" && type !== "external-url" && type !== "channel-file") {
|
|
480
|
+
throw new ValidationError(`payload.attachments[${index}].ref.type must be relay-blob, external-url, or channel-file`);
|
|
481
|
+
}
|
|
482
|
+
if (type === "external-url") cleanString(ref.url, `payload.attachments[${index}].ref.url`, { required: true, max: 2048 });
|
|
483
|
+
if (type === "relay-blob" || type === "channel-file") cleanString(ref.id, `payload.attachments[${index}].ref.id`, { required: true, max: 400 });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function normalizeChannelEventBody(body: unknown): {
|
|
488
|
+
body: string;
|
|
489
|
+
payload: Record<string, unknown>;
|
|
490
|
+
conversationId?: string;
|
|
491
|
+
idempotencyKey?: string;
|
|
492
|
+
} {
|
|
493
|
+
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
494
|
+
const payload = cleanMeta(body.payload) ?? body;
|
|
495
|
+
const fallbackBody = cleanString(body.body, "body", { max: MAX_BODY_BYTES });
|
|
496
|
+
const text = isRecord(payload.message) ? cleanString(payload.message.text, "payload.message.text", { max: MAX_BODY_BYTES }) : undefined;
|
|
497
|
+
const conversationId = cleanString(body.conversationId, "conversationId", { max: 300 }) ?? payloadConversationId(payload);
|
|
498
|
+
return {
|
|
499
|
+
body: fallbackBody ?? text ?? "Channel event",
|
|
500
|
+
payload,
|
|
501
|
+
conversationId,
|
|
502
|
+
idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }) ?? payloadDedupeKey(payload),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function requireChannelSession(req: Request, body: unknown, channel: ChannelSummary): Response | undefined {
|
|
507
|
+
const guard = normalizeAgentSessionGuard(req, body);
|
|
508
|
+
if (!guard || (!guard.instanceId && guard.epoch === undefined)) return error("channel session guard required", 400);
|
|
509
|
+
const session = validateAgentSession(channel.agentId, guard);
|
|
510
|
+
if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
|
|
328
514
|
function normalizeIntegrationEvent(body: unknown): IntegrationEventInput {
|
|
329
515
|
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
330
516
|
const status = cleanEnum(body.status, "status", [...VALID_TASK_STATUSES, "resolved"] as const);
|
|
@@ -512,7 +698,8 @@ function metaDirection(meta: Record<string, unknown> | undefined): ChannelDirect
|
|
|
512
698
|
|
|
513
699
|
function isChannelAgent(agent: AgentCard): boolean {
|
|
514
700
|
const kind = metaString(agent.meta, "kind");
|
|
515
|
-
return kind === "channel" ||
|
|
701
|
+
return agent.kind === "channel" ||
|
|
702
|
+
kind === "channel" ||
|
|
516
703
|
kind === "communication-channel" ||
|
|
517
704
|
agent.tags.includes("channel") ||
|
|
518
705
|
agent.capabilities.includes("channel");
|
|
@@ -522,6 +709,7 @@ function agentToChannel(agent: AgentCard): ChannelSummary {
|
|
|
522
709
|
const type = metaString(agent.meta, "channelType") ?? metaString(agent.meta, "type") ?? agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ?? "custom";
|
|
523
710
|
const transport = metaString(agent.meta, "transport") ?? type;
|
|
524
711
|
const topicChannels = metaStringArray(agent.meta, "topicChannels");
|
|
712
|
+
const accountId = metaString(agent.meta, "accountId") ?? (agent.id.startsWith(`${type}:`) ? agent.id.slice(type.length + 1) : agent.id);
|
|
525
713
|
|
|
526
714
|
return {
|
|
527
715
|
id: metaString(agent.meta, "channelId") ?? agent.id,
|
|
@@ -529,6 +717,7 @@ function agentToChannel(agent: AgentCard): ChannelSummary {
|
|
|
529
717
|
type,
|
|
530
718
|
transport,
|
|
531
719
|
agentId: agent.id,
|
|
720
|
+
accountId,
|
|
532
721
|
status: agent.status,
|
|
533
722
|
ready: agent.ready,
|
|
534
723
|
direction: metaDirection(agent.meta),
|
|
@@ -764,17 +953,19 @@ const postAgentAction: Handler = async (req, params) => {
|
|
|
764
953
|
const msg = sendMessage({
|
|
765
954
|
from: "system",
|
|
766
955
|
to: agent.id,
|
|
767
|
-
|
|
956
|
+
kind: "control",
|
|
768
957
|
subject: title,
|
|
769
958
|
body: action === "restart"
|
|
770
959
|
? "Dashboard requested that this agent restart its relay-managed session now."
|
|
771
960
|
: "Dashboard requested that this agent shut down its relay-managed session now.",
|
|
772
|
-
|
|
961
|
+
payload: {
|
|
773
962
|
agentControl: {
|
|
774
963
|
action,
|
|
775
964
|
requestedBy: "dashboard",
|
|
776
965
|
requestedAt: Date.now(),
|
|
777
966
|
},
|
|
967
|
+
},
|
|
968
|
+
meta: {
|
|
778
969
|
delivery: "interrupt",
|
|
779
970
|
priority: "urgent",
|
|
780
971
|
},
|
|
@@ -804,11 +995,47 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
804
995
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
805
996
|
try {
|
|
806
997
|
if (!isRecord(parsed.body)) return error("provider required");
|
|
807
|
-
const provider = cleanEnum(parsed.body.provider, "provider", VALID_AGENT_SPAWN_PROVIDERS);
|
|
808
|
-
if (provider
|
|
998
|
+
const provider = cleanEnum(parsed.body.provider, "provider", [...VALID_AGENT_SPAWN_PROVIDERS, "claude"] as const);
|
|
999
|
+
if (!provider) return error("provider required");
|
|
809
1000
|
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_CODEX_SPAWN_APPROVALS, "guarded") as CodexSpawnApprovalMode;
|
|
810
1001
|
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
811
1002
|
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
1003
|
+
|
|
1004
|
+
// Check for an online orchestrator that supports this provider
|
|
1005
|
+
const orchestrators = listOrchestrators().filter(
|
|
1006
|
+
(o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
|
|
1007
|
+
);
|
|
1008
|
+
if (orchestrators.length > 0) {
|
|
1009
|
+
// Route through the first available orchestrator
|
|
1010
|
+
const orch = orchestrators[0]!;
|
|
1011
|
+
const msg = sendMessage({
|
|
1012
|
+
from: "system",
|
|
1013
|
+
to: orch.agentId,
|
|
1014
|
+
kind: "control",
|
|
1015
|
+
subject: "Spawn agent",
|
|
1016
|
+
body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
|
|
1017
|
+
payload: { orchestratorControl: { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() } },
|
|
1018
|
+
meta: {
|
|
1019
|
+
delivery: "interrupt",
|
|
1020
|
+
priority: "urgent",
|
|
1021
|
+
},
|
|
1022
|
+
});
|
|
1023
|
+
emitNewMessage(msg);
|
|
1024
|
+
auditEvent({
|
|
1025
|
+
clientId: "server-agent-spawn-" + provider + "-" + Date.now(),
|
|
1026
|
+
kind: "state",
|
|
1027
|
+
title: `${provider} agent spawn requested (via ${orch.id})`,
|
|
1028
|
+
body: cwd || orch.baseDir,
|
|
1029
|
+
meta: orch.id,
|
|
1030
|
+
icon: "ti-plus",
|
|
1031
|
+
view: "agents",
|
|
1032
|
+
metadata: { provider, orchestratorId: orch.id, approvalMode },
|
|
1033
|
+
});
|
|
1034
|
+
return json({ ok: true, orchestratorId: orch.id, provider, message: msg }, 202);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Fallback: direct spawn for codex only (no orchestrator)
|
|
1038
|
+
if (provider !== "codex") return error("no orchestrator available for provider: " + provider);
|
|
812
1039
|
const relayUrl = process.env.AGENT_RELAY_SPAWN_RELAY_URL || process.env.AGENT_RELAY_URL || `http://127.0.0.1:${process.env.PORT || "4850"}`;
|
|
813
1040
|
const token = req.headers.get("X-Agent-Relay-Token") ?? req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
|
|
814
1041
|
const result = spawnCodexAgent({
|
|
@@ -822,7 +1049,7 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
822
1049
|
auditEvent({
|
|
823
1050
|
clientId: "server-agent-spawn-codex-" + Date.now(),
|
|
824
1051
|
kind: "state",
|
|
825
|
-
title: "Codex agent spawn requested",
|
|
1052
|
+
title: "Codex agent spawn requested (direct)",
|
|
826
1053
|
body: result.cwd,
|
|
827
1054
|
meta: result.pid ? `pid ${result.pid}` : "dry run",
|
|
828
1055
|
icon: "ti-plus",
|
|
@@ -855,9 +1082,215 @@ const getHostDirectories: Handler = (req) => {
|
|
|
855
1082
|
}
|
|
856
1083
|
};
|
|
857
1084
|
|
|
1085
|
+
// --- Orchestrator routes ---
|
|
1086
|
+
|
|
1087
|
+
const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
|
|
1088
|
+
const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
|
|
1089
|
+
|
|
1090
|
+
const postOrchestrator: Handler = async (req) => {
|
|
1091
|
+
const parsed = await parseBody<unknown>(req);
|
|
1092
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1093
|
+
try {
|
|
1094
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
1095
|
+
const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
|
|
1096
|
+
const hostname = cleanString(parsed.body.hostname, "hostname", { required: true, max: 120 })!;
|
|
1097
|
+
const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
|
|
1098
|
+
const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
|
|
1099
|
+
if (providers) {
|
|
1100
|
+
for (const p of providers) {
|
|
1101
|
+
if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
|
|
1102
|
+
return error(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys");
|
|
1107
|
+
const meta = cleanMeta(parsed.body.meta);
|
|
1108
|
+
const orch = upsertOrchestrator({ id, hostname, providers: providers ?? ["claude", "codex"], baseDir, envKeys, meta });
|
|
1109
|
+
auditEvent({
|
|
1110
|
+
clientId: "server-orchestrator-register-" + id + "-" + Date.now(),
|
|
1111
|
+
kind: "state",
|
|
1112
|
+
title: "Orchestrator registered",
|
|
1113
|
+
body: hostname,
|
|
1114
|
+
meta: id,
|
|
1115
|
+
icon: "ti-server-2",
|
|
1116
|
+
view: "orchestrators",
|
|
1117
|
+
metadata: { orchestratorId: id, providers: orch.providers },
|
|
1118
|
+
});
|
|
1119
|
+
return json(orch, 201);
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1122
|
+
throw e;
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
const getOrchestrators: Handler = () => {
|
|
1127
|
+
return json(listOrchestrators());
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
const getOrchestratorById: Handler = (_req, params) => {
|
|
1131
|
+
const orch = getOrchestrator(params.id!);
|
|
1132
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
1133
|
+
return json(orch);
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
const postOrchestratorHeartbeat: Handler = (_req, params) => {
|
|
1137
|
+
const orch = orchestratorHeartbeat(params.id!);
|
|
1138
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
1139
|
+
return json({ ok: true });
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
1143
|
+
const parsed = await parseBody<unknown>(req);
|
|
1144
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1145
|
+
try {
|
|
1146
|
+
const orch = getOrchestrator(params.id!);
|
|
1147
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
1148
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
1149
|
+
const agents = parsed.body.agents;
|
|
1150
|
+
if (!Array.isArray(agents)) return error("agents must be an array");
|
|
1151
|
+
const cleaned: ManagedAgent[] = agents.map((a: any) => {
|
|
1152
|
+
if (!isRecord(a)) throw new ValidationError("each agent must be an object");
|
|
1153
|
+
return {
|
|
1154
|
+
agentId: cleanString(a.agentId, "agentId", { max: 240 }) || "",
|
|
1155
|
+
provider: cleanEnum(a.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider,
|
|
1156
|
+
tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!,
|
|
1157
|
+
cwd: cleanString(a.cwd, "cwd", { required: true, max: 500 })!,
|
|
1158
|
+
label: cleanString(a.label, "label", { max: 120 }),
|
|
1159
|
+
approvalMode: (cleanEnum(a.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") ?? "guarded") as SpawnApprovalMode,
|
|
1160
|
+
pid: typeof a.pid === "number" && Number.isSafeInteger(a.pid) ? a.pid : undefined,
|
|
1161
|
+
startedAt: typeof a.startedAt === "number" ? a.startedAt : Date.now(),
|
|
1162
|
+
};
|
|
1163
|
+
});
|
|
1164
|
+
const updated = updateManagedAgents(params.id!, cleaned);
|
|
1165
|
+
if (updated) emitOrchestratorStatus(params.id!);
|
|
1166
|
+
return json(updated);
|
|
1167
|
+
} catch (e) {
|
|
1168
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1169
|
+
throw e;
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
1174
|
+
const parsed = await parseBody<unknown>(req);
|
|
1175
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1176
|
+
try {
|
|
1177
|
+
const orch = getOrchestrator(params.id!);
|
|
1178
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
1179
|
+
if (orch.status !== "online") return error("orchestrator is offline", 409);
|
|
1180
|
+
|
|
1181
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
1182
|
+
const provider = cleanEnum(parsed.body.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider;
|
|
1183
|
+
if (!orch.providers.includes(provider)) {
|
|
1184
|
+
return error(`orchestrator does not support provider: ${provider}`);
|
|
1185
|
+
}
|
|
1186
|
+
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
1187
|
+
if (cwd && !cwd.startsWith(orch.baseDir)) {
|
|
1188
|
+
return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
|
|
1189
|
+
}
|
|
1190
|
+
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
1191
|
+
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
|
|
1192
|
+
const prompt = cleanString(parsed.body.prompt, "prompt", { max: 4000 });
|
|
1193
|
+
|
|
1194
|
+
// Send control message to orchestrator's agent inbox
|
|
1195
|
+
const msg = sendMessage({
|
|
1196
|
+
from: "system",
|
|
1197
|
+
to: orch.agentId,
|
|
1198
|
+
kind: "control",
|
|
1199
|
+
subject: "Spawn agent",
|
|
1200
|
+
body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
|
|
1201
|
+
payload: {
|
|
1202
|
+
orchestratorControl: {
|
|
1203
|
+
action: "spawn",
|
|
1204
|
+
provider,
|
|
1205
|
+
cwd: cwd || orch.baseDir,
|
|
1206
|
+
label,
|
|
1207
|
+
approvalMode,
|
|
1208
|
+
prompt,
|
|
1209
|
+
requestedBy: "dashboard",
|
|
1210
|
+
requestedAt: Date.now(),
|
|
1211
|
+
},
|
|
1212
|
+
},
|
|
1213
|
+
meta: {
|
|
1214
|
+
delivery: "interrupt",
|
|
1215
|
+
priority: "urgent",
|
|
1216
|
+
},
|
|
1217
|
+
});
|
|
1218
|
+
emitNewMessage(msg);
|
|
1219
|
+
auditEvent({
|
|
1220
|
+
clientId: "server-orchestrator-spawn-" + orch.id + "-" + Date.now(),
|
|
1221
|
+
kind: "state",
|
|
1222
|
+
title: `Spawn ${provider} agent requested`,
|
|
1223
|
+
body: cwd || orch.baseDir,
|
|
1224
|
+
meta: orch.id,
|
|
1225
|
+
icon: "ti-plus",
|
|
1226
|
+
view: "orchestrators",
|
|
1227
|
+
metadata: { orchestratorId: orch.id, provider, approvalMode, label },
|
|
1228
|
+
});
|
|
1229
|
+
return json({ ok: true, orchestratorId: orch.id, message: msg }, 202);
|
|
1230
|
+
} catch (e) {
|
|
1231
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1232
|
+
throw e;
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
const postOrchestratorAction: Handler = async (req, params) => {
|
|
1237
|
+
const parsed = await parseBody<unknown>(req);
|
|
1238
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1239
|
+
try {
|
|
1240
|
+
const orch = getOrchestrator(params.id!);
|
|
1241
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
1242
|
+
|
|
1243
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
1244
|
+
const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown"] as const);
|
|
1245
|
+
if (!action) return error("action required");
|
|
1246
|
+
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
1247
|
+
|
|
1248
|
+
const msg = sendMessage({
|
|
1249
|
+
from: "system",
|
|
1250
|
+
to: orch.agentId,
|
|
1251
|
+
kind: "control",
|
|
1252
|
+
subject: action === "restart" ? "Restart agent" : "Shutdown agent",
|
|
1253
|
+
body: agentId ? `${action} ${agentId}` : `${action} all managed agents`,
|
|
1254
|
+
payload: {
|
|
1255
|
+
orchestratorControl: {
|
|
1256
|
+
action,
|
|
1257
|
+
agentId,
|
|
1258
|
+
requestedBy: "dashboard",
|
|
1259
|
+
requestedAt: Date.now(),
|
|
1260
|
+
},
|
|
1261
|
+
},
|
|
1262
|
+
meta: {
|
|
1263
|
+
delivery: "interrupt",
|
|
1264
|
+
priority: "urgent",
|
|
1265
|
+
},
|
|
1266
|
+
});
|
|
1267
|
+
emitNewMessage(msg);
|
|
1268
|
+
auditEvent({
|
|
1269
|
+
clientId: "server-orchestrator-action-" + orch.id + "-" + action + "-" + Date.now(),
|
|
1270
|
+
kind: "state",
|
|
1271
|
+
title: `Agent ${action} requested`,
|
|
1272
|
+
body: agentId || "all",
|
|
1273
|
+
meta: orch.id,
|
|
1274
|
+
icon: action === "restart" ? "ti-refresh" : "ti-power",
|
|
1275
|
+
view: "orchestrators",
|
|
1276
|
+
metadata: { orchestratorId: orch.id, action, agentId },
|
|
1277
|
+
});
|
|
1278
|
+
return json({ ok: true, action, message: msg }, 202);
|
|
1279
|
+
} catch (e) {
|
|
1280
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1281
|
+
throw e;
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
const deleteOrchestratorById: Handler = (_req, params) => {
|
|
1286
|
+
const deleted = deleteOrchestrator(params.id!);
|
|
1287
|
+
if (!deleted) return error("orchestrator not found", 404);
|
|
1288
|
+
return json({ ok: true });
|
|
1289
|
+
};
|
|
1290
|
+
|
|
858
1291
|
// --- Message routes ---
|
|
859
1292
|
|
|
860
|
-
const
|
|
1293
|
+
const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system"];
|
|
861
1294
|
|
|
862
1295
|
const postMessage: Handler = async (req) => {
|
|
863
1296
|
const parsed = await parseBody<unknown>(req);
|
|
@@ -870,13 +1303,14 @@ const postMessage: Handler = async (req) => {
|
|
|
870
1303
|
const result = sendMessageWithResult(input);
|
|
871
1304
|
if (result.created) {
|
|
872
1305
|
emitNewMessage(result.message);
|
|
1306
|
+
const isWork = result.message.kind === "task";
|
|
873
1307
|
auditEvent({
|
|
874
1308
|
clientId: "server-message-" + result.message.id,
|
|
875
|
-
kind:
|
|
876
|
-
title:
|
|
1309
|
+
kind: isWork ? "task" : "message",
|
|
1310
|
+
title: isWork ? "Task message sent" : "Message sent",
|
|
877
1311
|
body: result.message.subject || result.message.body,
|
|
878
1312
|
meta: `${result.message.from} -> ${result.message.to}`,
|
|
879
|
-
icon:
|
|
1313
|
+
icon: isWork ? "ti-hand-grab" : "ti-send",
|
|
880
1314
|
view: "messages",
|
|
881
1315
|
messageId: result.message.id,
|
|
882
1316
|
agentId: result.message.from,
|
|
@@ -898,7 +1332,7 @@ const postSystemBroadcast: Handler = async (req) => {
|
|
|
898
1332
|
const msg = sendMessage({
|
|
899
1333
|
from: "system",
|
|
900
1334
|
to: "broadcast",
|
|
901
|
-
|
|
1335
|
+
kind: "system",
|
|
902
1336
|
subject: body.subject ?? undefined,
|
|
903
1337
|
body: body.body,
|
|
904
1338
|
});
|
|
@@ -1127,6 +1561,67 @@ const postActivityEvent: Handler = async (req) => {
|
|
|
1127
1561
|
}
|
|
1128
1562
|
};
|
|
1129
1563
|
|
|
1564
|
+
// --- Connectors ---
|
|
1565
|
+
|
|
1566
|
+
const getConnectors: Handler = () => json(listConnectors());
|
|
1567
|
+
|
|
1568
|
+
const getConnectorById: Handler = (_req, params) => {
|
|
1569
|
+
const connector = getConnector(params.id!);
|
|
1570
|
+
return connector ? json(connector) : error("connector not found", 404);
|
|
1571
|
+
};
|
|
1572
|
+
|
|
1573
|
+
const postConnectorRegister: Handler = async (req) => {
|
|
1574
|
+
const parsed = await parseBody<unknown>(req);
|
|
1575
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1576
|
+
try {
|
|
1577
|
+
if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
|
|
1578
|
+
const manifest = isRecord(parsed.body.manifest) ? parsed.body.manifest : parsed.body;
|
|
1579
|
+
const config = isRecord(parsed.body.config) ? parsed.body.config : undefined;
|
|
1580
|
+
const state = isRecord(parsed.body.state) ? parsed.body.state : undefined;
|
|
1581
|
+
return json(registerConnectorManifest(manifest as any, { config, state }), 201);
|
|
1582
|
+
} catch (e) {
|
|
1583
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1584
|
+
throw e;
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
|
|
1588
|
+
const postConnectorAction: Handler = async (req, params) => {
|
|
1589
|
+
const parsed = await parseBody<unknown>(req);
|
|
1590
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1591
|
+
try {
|
|
1592
|
+
if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
|
|
1593
|
+
const action = cleanEnum(parsed.body.action, "action", VALID_CONNECTOR_ACTIONS)!;
|
|
1594
|
+
const result = runConnectorAction(params.id!, action);
|
|
1595
|
+
return json(result, result.ok ? 200 : 502);
|
|
1596
|
+
} catch (e) {
|
|
1597
|
+
if (e instanceof ValidationError) return error(e.message, e.message.includes("not found") ? 404 : 400);
|
|
1598
|
+
throw e;
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
|
|
1602
|
+
const getConnectorConfig: Handler = (_req, params) => {
|
|
1603
|
+
try {
|
|
1604
|
+
if (!getConnector(params.id!)) return error("connector not found", 404);
|
|
1605
|
+
return json(readConnectorConfig(params.id!));
|
|
1606
|
+
} catch (e) {
|
|
1607
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1608
|
+
throw e;
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
const putConnectorConfig: Handler = async (req, params) => {
|
|
1613
|
+
const parsed = await parseBody<unknown>(req);
|
|
1614
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1615
|
+
try {
|
|
1616
|
+
if (!getConnector(params.id!)) return error("connector not found", 404);
|
|
1617
|
+
if (!isRecord(parsed.body)) throw new ValidationError("connector config must be an object");
|
|
1618
|
+
return json(writeConnectorConfig(params.id!, parsed.body));
|
|
1619
|
+
} catch (e) {
|
|
1620
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1621
|
+
throw e;
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1130
1625
|
// --- Tasks and integrations ---
|
|
1131
1626
|
|
|
1132
1627
|
const getIntegrations: Handler = () => {
|
|
@@ -1169,16 +1664,104 @@ const getIntegrations: Handler = () => {
|
|
|
1169
1664
|
};
|
|
1170
1665
|
|
|
1171
1666
|
const getChannels: Handler = () => {
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1667
|
+
return json(listChannels());
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
const getChannelBindings: Handler = (req) => {
|
|
1671
|
+
const url = new URL(req.url);
|
|
1672
|
+
const channelId = cleanString(url.searchParams.get("channelId") ?? undefined, "channelId", { max: 200 });
|
|
1673
|
+
return json(listChannelBindings(channelId));
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
const postChannelBinding: Handler = async (req) => {
|
|
1677
|
+
const parsed = await parseBody<unknown>(req);
|
|
1678
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1679
|
+
try {
|
|
1680
|
+
const input = normalizeChannelBindingInput(parsed.body);
|
|
1681
|
+
const channel = getChannel(input.channelId);
|
|
1682
|
+
if (!channel) return error("channel not found", 404);
|
|
1683
|
+
const sessionError = requireChannelSession(req, parsed.body, channel);
|
|
1684
|
+
if (sessionError) return sessionError;
|
|
1685
|
+
const binding = upsertChannelBinding(input);
|
|
1686
|
+
return json(binding, 201);
|
|
1687
|
+
} catch (e) {
|
|
1688
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1689
|
+
throw e;
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
function scopedChannelIdempotencyKey(key: string | undefined, binding: ChannelBinding): string | undefined {
|
|
1694
|
+
return key ? `${key}:${binding.id}` : undefined;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
const postChannelEvent: Handler = async (req, params) => {
|
|
1698
|
+
const parsed = await parseBody<unknown>(req);
|
|
1699
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1700
|
+
try {
|
|
1701
|
+
const channel = getChannel(params.id!);
|
|
1702
|
+
if (!channel) return error("channel not found", 404);
|
|
1703
|
+
const sessionError = requireChannelSession(req, parsed.body, channel);
|
|
1704
|
+
if (sessionError) return sessionError;
|
|
1705
|
+
const input = normalizeChannelEventBody(parsed.body);
|
|
1706
|
+
validateChannelEnvelope(input.payload, channel);
|
|
1707
|
+
validateChannelPayloadContent(input.payload, false);
|
|
1708
|
+
const eventType = channelPayloadEventType(input.payload);
|
|
1709
|
+
if (EPHEMERAL_CHANNEL_EVENT_TYPES.has(eventType)) {
|
|
1710
|
+
return error("ephemeral channel activity must use /api/channels/:id/activities", 400);
|
|
1711
|
+
}
|
|
1712
|
+
const bindings = resolveChannelRoutes(channel.id, input.conversationId);
|
|
1713
|
+
if (!bindings.length) return error("channel has no binding", 409);
|
|
1714
|
+
const results = bindings.map((binding) => sendMessageWithResult({
|
|
1715
|
+
from: channel.agentId,
|
|
1716
|
+
to: messageTargetForChannelTarget(binding.target),
|
|
1717
|
+
kind: "channel.event",
|
|
1718
|
+
channel: channel.id,
|
|
1719
|
+
body: input.body,
|
|
1720
|
+
payload: input.payload,
|
|
1721
|
+
idempotencyKey: scopedChannelIdempotencyKey(input.idempotencyKey, binding),
|
|
1722
|
+
claimable: binding.mode === "claimable",
|
|
1723
|
+
}));
|
|
1724
|
+
for (const result of results) {
|
|
1725
|
+
if (result.created) emitNewMessage(result.message);
|
|
1726
|
+
}
|
|
1727
|
+
return json({
|
|
1728
|
+
messages: results.map((result) => result.message),
|
|
1729
|
+
bindings,
|
|
1730
|
+
created: results.some((result) => result.created),
|
|
1731
|
+
}, results.some((result) => result.created) ? 201 : 200);
|
|
1732
|
+
} catch (e) {
|
|
1733
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1734
|
+
throw e;
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1180
1737
|
|
|
1181
|
-
|
|
1738
|
+
const postChannelActivity: Handler = async (req, params) => {
|
|
1739
|
+
const parsed = await parseBody<unknown>(req);
|
|
1740
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1741
|
+
try {
|
|
1742
|
+
const channel = getChannel(params.id!);
|
|
1743
|
+
if (!channel) return error("channel not found", 404);
|
|
1744
|
+
const sessionError = requireChannelSession(req, parsed.body, channel);
|
|
1745
|
+
if (sessionError) return sessionError;
|
|
1746
|
+
const input = normalizeChannelEventBody(parsed.body);
|
|
1747
|
+
validateChannelEnvelope(input.payload, channel);
|
|
1748
|
+
validateChannelPayloadContent(input.payload, true);
|
|
1749
|
+
const eventType = channelPayloadEventType(input.payload);
|
|
1750
|
+
if (!EPHEMERAL_CHANNEL_EVENT_TYPES.has(eventType) && !input.payload.activity) {
|
|
1751
|
+
return error("channel activity payload must include an ephemeral event type or activity object", 400);
|
|
1752
|
+
}
|
|
1753
|
+
const activity = {
|
|
1754
|
+
channelId: channel.id,
|
|
1755
|
+
conversationId: input.conversationId,
|
|
1756
|
+
payload: input.payload,
|
|
1757
|
+
createdAt: Date.now(),
|
|
1758
|
+
};
|
|
1759
|
+
emitChannelActivity(activity);
|
|
1760
|
+
return json({ ok: true, activity }, 202);
|
|
1761
|
+
} catch (e) {
|
|
1762
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1763
|
+
throw e;
|
|
1764
|
+
}
|
|
1182
1765
|
};
|
|
1183
1766
|
|
|
1184
1767
|
const postIntegrationEvent: Handler = async (req) => {
|
|
@@ -1486,20 +2069,23 @@ const getHealthRoute: Handler = () => json(getHealth());
|
|
|
1486
2069
|
const postSystemReap: Handler = () => {
|
|
1487
2070
|
const released = releaseExpiredClaims();
|
|
1488
2071
|
const reapedAgentIds = reapStaleAgents();
|
|
2072
|
+
const reapedOrchestratorIds = reapStaleOrchestrators();
|
|
1489
2073
|
for (const id of released.messageIds) emitMessageClaimReleased(id);
|
|
1490
2074
|
for (const task of released.tasks) emitTaskChanged(task, "task.updated");
|
|
1491
2075
|
for (const id of reapedAgentIds) emitAgentStatus(id);
|
|
2076
|
+
for (const id of reapedOrchestratorIds) emitOrchestratorStatus(id);
|
|
1492
2077
|
auditEvent({
|
|
1493
2078
|
clientId: "server-system-reap-" + Date.now(),
|
|
1494
2079
|
kind: "state",
|
|
1495
2080
|
title: "Maintenance reaper run",
|
|
1496
|
-
body: `${reapedAgentIds.length} stale agent(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s)`,
|
|
2081
|
+
body: `${reapedAgentIds.length} stale agent(s), ${reapedOrchestratorIds.length} stale orchestrator(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s)`,
|
|
1497
2082
|
icon: "ti-broom",
|
|
1498
2083
|
view: "activity",
|
|
1499
2084
|
});
|
|
1500
2085
|
return json({
|
|
1501
2086
|
ok: true,
|
|
1502
2087
|
reapedAgentIds,
|
|
2088
|
+
reapedOrchestratorIds,
|
|
1503
2089
|
releasedMessageIds: released.messageIds,
|
|
1504
2090
|
releasedTaskIds: released.tasks.map((task) => task.id),
|
|
1505
2091
|
});
|
|
@@ -1542,6 +2128,15 @@ const routes: Route[] = [
|
|
|
1542
2128
|
route("POST", "/api/agents/:id/actions", postAgentAction),
|
|
1543
2129
|
route("DELETE", "/api/agents/:id", deleteAgentById),
|
|
1544
2130
|
|
|
2131
|
+
route("POST", "/api/orchestrators", postOrchestrator),
|
|
2132
|
+
route("GET", "/api/orchestrators", getOrchestrators),
|
|
2133
|
+
route("GET", "/api/orchestrators/:id", getOrchestratorById),
|
|
2134
|
+
route("POST", "/api/orchestrators/:id/heartbeat", postOrchestratorHeartbeat),
|
|
2135
|
+
route("PATCH", "/api/orchestrators/:id/agents", patchOrchestratorAgents),
|
|
2136
|
+
route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
|
|
2137
|
+
route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
|
|
2138
|
+
route("DELETE", "/api/orchestrators/:id", deleteOrchestratorById),
|
|
2139
|
+
|
|
1545
2140
|
route("POST", "/api/system/broadcast", postSystemBroadcast),
|
|
1546
2141
|
route("POST", "/api/messages", postMessage),
|
|
1547
2142
|
route("GET", "/api/messages", getMessages),
|
|
@@ -1561,7 +2156,18 @@ const routes: Route[] = [
|
|
|
1561
2156
|
route("GET", "/api/activity", getActivityEvents),
|
|
1562
2157
|
route("POST", "/api/activity", postActivityEvent),
|
|
1563
2158
|
|
|
2159
|
+
route("GET", "/api/connectors", getConnectors),
|
|
2160
|
+
route("POST", "/api/connectors", postConnectorRegister),
|
|
2161
|
+
route("GET", "/api/connectors/:id", getConnectorById),
|
|
2162
|
+
route("POST", "/api/connectors/:id/actions", postConnectorAction),
|
|
2163
|
+
route("GET", "/api/connectors/:id/config", getConnectorConfig),
|
|
2164
|
+
route("PUT", "/api/connectors/:id/config", putConnectorConfig),
|
|
2165
|
+
|
|
1564
2166
|
route("GET", "/api/channels", getChannels),
|
|
2167
|
+
route("POST", "/api/channels/:id/events", postChannelEvent),
|
|
2168
|
+
route("POST", "/api/channels/:id/activities", postChannelActivity),
|
|
2169
|
+
route("GET", "/api/channel-bindings", getChannelBindings),
|
|
2170
|
+
route("POST", "/api/channel-bindings", postChannelBinding),
|
|
1565
2171
|
route("GET", "/api/integrations", getIntegrations),
|
|
1566
2172
|
route("POST", "/api/integrations/events", postIntegrationEvent),
|
|
1567
2173
|
route("GET", "/api/tasks", getTasks),
|