agent-relay-server 0.6.0 → 0.6.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/package.json +1 -1
- package/public/dashboard.js +13 -0
- package/public/index.html +13 -5
- package/src/db.ts +153 -19
- package/src/types.ts +17 -0
package/package.json
CHANGED
package/public/dashboard.js
CHANGED
|
@@ -1147,6 +1147,11 @@
|
|
|
1147
1147
|
|
|
1148
1148
|
function getChannelCards() {
|
|
1149
1149
|
return [...(this.channels || [])].sort((a, b) => {
|
|
1150
|
+
const healthRank = { error: 0, warning: 1, ok: 2 };
|
|
1151
|
+
const aHealth = a.targetHealth?.status || "ok";
|
|
1152
|
+
const bHealth = b.targetHealth?.status || "ok";
|
|
1153
|
+
const healthDiff = (healthRank[aHealth] ?? 2) - (healthRank[bHealth] ?? 2);
|
|
1154
|
+
if (healthDiff !== 0) return healthDiff;
|
|
1150
1155
|
const readyDiff = Number(Boolean(b.ready)) - Number(Boolean(a.ready));
|
|
1151
1156
|
if (readyDiff !== 0) return readyDiff;
|
|
1152
1157
|
const statusDiff = String(a.status || "").localeCompare(String(b.status || ""));
|
|
@@ -1290,6 +1295,11 @@
|
|
|
1290
1295
|
{ label: "Run reaper", icon: "ti-broom", api: "POST", path: "/system/reap" },
|
|
1291
1296
|
{ label: "Show stale", icon: "ti-filter", view: "agents", preset: "offline_stale" }
|
|
1292
1297
|
);
|
|
1298
|
+
} else if (check.name === "channel-delivery-targets") {
|
|
1299
|
+
base.actions.unshift(
|
|
1300
|
+
{ label: "Open channels", icon: "ti-messages", view: "channels" },
|
|
1301
|
+
{ label: "Run reaper", icon: "ti-broom", api: "POST", path: "/system/reap" }
|
|
1302
|
+
);
|
|
1293
1303
|
} else if (check.name === "expired-message-claims" || check.name === "expired-task-claims" || check.name === "offline-claimed-tasks") {
|
|
1294
1304
|
base.actions.unshift(
|
|
1295
1305
|
{ label: "Run reaper", icon: "ti-broom", api: "POST", path: "/system/reap" },
|
|
@@ -1306,6 +1316,7 @@
|
|
|
1306
1316
|
if (check.name === "expired-message-claims") return "Claimable messages may be stuck until the reaper releases expired claims.";
|
|
1307
1317
|
if (check.name === "expired-task-claims") return "Tasks can appear owned by agents that no longer hold a live lease.";
|
|
1308
1318
|
if (check.name === "offline-claimed-tasks") return "Offline agents are still shown as owners for active work.";
|
|
1319
|
+
if (check.name === "channel-delivery-targets") return "Inbound channel messages may be accepted but routed to no live delivery agent.";
|
|
1309
1320
|
return "Relay health is degraded for this check.";
|
|
1310
1321
|
}
|
|
1311
1322
|
|
|
@@ -1660,6 +1671,8 @@
|
|
|
1660
1671
|
|
|
1661
1672
|
function channelPresence(channel) {
|
|
1662
1673
|
if (!channel) return { label: "unknown", tone: "secondary", icon: "ti-plug-off" };
|
|
1674
|
+
if (channel.targetHealth?.status === "error") return { label: "target broken", tone: "danger", icon: "ti-alert-triangle" };
|
|
1675
|
+
if (channel.targetHealth?.status === "warning") return { label: "target warning", tone: "warning", icon: "ti-alert-circle" };
|
|
1663
1676
|
if (channel.status === "offline") return { label: "offline", tone: "secondary", icon: "ti-plug-off" };
|
|
1664
1677
|
if (!channel.ready) return { label: "not ready", tone: "warning", icon: "ti-loader" };
|
|
1665
1678
|
if (channel.status === "busy") return { label: "busy", tone: "warning", icon: "ti-activity" };
|
package/public/index.html
CHANGED
|
@@ -339,8 +339,8 @@
|
|
|
339
339
|
<div class="card-body">
|
|
340
340
|
<div class="d-flex align-items-center">
|
|
341
341
|
<div>
|
|
342
|
-
<div class="text-secondary small">
|
|
343
|
-
<div class="h1 mb-0" x-text="
|
|
342
|
+
<div class="text-secondary small">Agents</div>
|
|
343
|
+
<div class="h1 mb-0" x-text="onlineCount"></div>
|
|
344
344
|
</div>
|
|
345
345
|
<i class="ti ti-robot ms-auto stat-card"></i>
|
|
346
346
|
</div>
|
|
@@ -352,10 +352,10 @@
|
|
|
352
352
|
<div class="card-body">
|
|
353
353
|
<div class="d-flex align-items-center">
|
|
354
354
|
<div>
|
|
355
|
-
<div class="text-secondary small">
|
|
356
|
-
<div class="h1 mb-0 text-
|
|
355
|
+
<div class="text-secondary small">Busy</div>
|
|
356
|
+
<div class="h1 mb-0 text-warning" x-text="busyAgentCount"></div>
|
|
357
357
|
</div>
|
|
358
|
-
<i class="ti ti-
|
|
358
|
+
<i class="ti ti-activity ms-auto stat-card"></i>
|
|
359
359
|
</div>
|
|
360
360
|
</div>
|
|
361
361
|
</div>
|
|
@@ -859,6 +859,8 @@ agent-relay-orchestrator</pre>
|
|
|
859
859
|
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
|
860
860
|
<h2 class="page-title mb-0">Channels</h2>
|
|
861
861
|
<span class="badge bg-success-lt" x-show="channelCards.filter((item) => item.ready).length > 0" x-text="channelCards.filter((item) => item.ready).length + ' ready'"></span>
|
|
862
|
+
<span class="badge bg-danger-lt" x-show="channelCards.filter((item) => item.targetHealth?.status === 'error').length > 0" x-text="channelCards.filter((item) => item.targetHealth?.status === 'error').length + ' target issue' + (channelCards.filter((item) => item.targetHealth?.status === 'error').length === 1 ? '' : 's')"></span>
|
|
863
|
+
<span class="badge bg-warning-lt" x-show="channelCards.filter((item) => item.targetHealth?.status === 'warning').length > 0" x-text="channelCards.filter((item) => item.targetHealth?.status === 'warning').length + ' target warning' + (channelCards.filter((item) => item.targetHealth?.status === 'warning').length === 1 ? '' : 's')"></span>
|
|
862
864
|
<div class="ms-auto d-flex gap-2 align-items-center">
|
|
863
865
|
<button class="btn btn-sm btn-ghost-secondary" @click="fetchChannels()">
|
|
864
866
|
<i class="ti ti-refresh"></i>
|
|
@@ -887,6 +889,12 @@ agent-relay-orchestrator</pre>
|
|
|
887
889
|
<span class="badge bg-cyan-lt" x-text="channel.direction"></span>
|
|
888
890
|
<span class="badge bg-secondary-lt" x-text="displayTarget(channel.target || channel.agentId)"></span>
|
|
889
891
|
</div>
|
|
892
|
+
<div class="alert py-2 px-2 mt-2 mb-0"
|
|
893
|
+
:class="channel.targetHealth?.status === 'error' ? 'alert-danger' : 'alert-warning'"
|
|
894
|
+
x-show="channel.targetHealth && channel.targetHealth.status !== 'ok'">
|
|
895
|
+
<i class="ti me-1" :class="channel.targetHealth?.status === 'error' ? 'ti-alert-triangle' : 'ti-alert-circle'"></i>
|
|
896
|
+
<span x-text="channel.targetHealth?.detail"></span>
|
|
897
|
+
</div>
|
|
890
898
|
<div class="d-flex gap-1 mt-2 flex-wrap" x-show="channel.capabilities?.length">
|
|
891
899
|
<template x-for="capability in (channel.capabilities || [])" :key="capability">
|
|
892
900
|
<span class="badge bg-secondary-lt" x-text="capability"></span>
|
package/src/db.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
ChannelBindingMode,
|
|
12
12
|
ChannelRouteTarget,
|
|
13
13
|
ChannelSummary,
|
|
14
|
+
ChannelTargetHealth,
|
|
14
15
|
CreatePairInput,
|
|
15
16
|
HealthCheck,
|
|
16
17
|
HealthReport,
|
|
@@ -628,6 +629,94 @@ function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
|
|
|
628
629
|
return "";
|
|
629
630
|
}
|
|
630
631
|
|
|
632
|
+
function channelTargetMatches(target: ChannelRouteTarget): AgentCard[] {
|
|
633
|
+
const candidates = listAgents().filter((agent) => (
|
|
634
|
+
agent.id !== "user" &&
|
|
635
|
+
agent.id !== "system" &&
|
|
636
|
+
agent.kind !== "channel" &&
|
|
637
|
+
agent.meta?.kind !== "channel"
|
|
638
|
+
));
|
|
639
|
+
if (target.type === "agent" || target.type === "orchestrator") {
|
|
640
|
+
const agent = getAgent(target.id);
|
|
641
|
+
return agent ? [agent] : [];
|
|
642
|
+
}
|
|
643
|
+
if (target.type === "label") return candidates.filter((agent) => agent.label === target.id);
|
|
644
|
+
if (target.type === "tag") return candidates.filter((agent) => agent.tags.includes(target.id));
|
|
645
|
+
if (target.type === "capability") return candidates.filter((agent) => agent.capabilities.includes(target.id));
|
|
646
|
+
if (target.type === "broadcast") return candidates;
|
|
647
|
+
return [];
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function channelTargetMatchSnapshot(agent: AgentCard): ChannelTargetHealth["matches"][number] {
|
|
651
|
+
return {
|
|
652
|
+
id: agent.id,
|
|
653
|
+
name: agent.name,
|
|
654
|
+
status: agent.status,
|
|
655
|
+
ready: agent.ready,
|
|
656
|
+
lastSeen: agent.lastSeen,
|
|
657
|
+
label: agent.label,
|
|
658
|
+
tags: agent.tags,
|
|
659
|
+
capabilities: agent.capabilities,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function isHealthyChannelTarget(agent: AgentCard, now: number): boolean {
|
|
664
|
+
return isDeliveryAgent(agent) && agent.ready && agent.lastSeen > now - STALE_TTL_MS;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function describeTarget(target: ChannelRouteTarget): string {
|
|
668
|
+
return bindingTargetToLegacyTarget(target);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()): ChannelTargetHealth {
|
|
672
|
+
const target = binding.target;
|
|
673
|
+
const targetLabel = describeTarget(target);
|
|
674
|
+
const matches = channelTargetMatches(target);
|
|
675
|
+
const snapshots = matches.map(channelTargetMatchSnapshot);
|
|
676
|
+
|
|
677
|
+
if (target.type === "agent" || target.type === "orchestrator") {
|
|
678
|
+
const agent = matches[0];
|
|
679
|
+
if (!agent) {
|
|
680
|
+
return { status: "error", detail: `Target ${targetLabel} is not registered`, target, matches: [] };
|
|
681
|
+
}
|
|
682
|
+
if (agent.id !== "user" && agent.id !== "system" && (agent.kind === "channel" || agent.meta?.kind === "channel")) {
|
|
683
|
+
return { status: "error", detail: `Target ${targetLabel} is a channel, not a delivery agent`, target, matches: snapshots };
|
|
684
|
+
}
|
|
685
|
+
if (agent.status === "offline") {
|
|
686
|
+
return { status: "error", detail: `Target ${targetLabel} is offline`, target, matches: snapshots };
|
|
687
|
+
}
|
|
688
|
+
if (!agent.ready) {
|
|
689
|
+
return { status: "warning", detail: `Target ${targetLabel} is online but not ready`, target, matches: snapshots };
|
|
690
|
+
}
|
|
691
|
+
if (agent.id !== "user" && agent.id !== "system" && agent.lastSeen <= now - STALE_TTL_MS) {
|
|
692
|
+
return { status: "warning", detail: `Target ${targetLabel} heartbeat is stale`, target, matches: snapshots };
|
|
693
|
+
}
|
|
694
|
+
return { status: "ok", detail: `Target ${targetLabel} is online and ready`, target, matches: snapshots };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const healthyMatches = matches.filter((agent) => isHealthyChannelTarget(agent, now));
|
|
698
|
+
if (matches.length === 0) {
|
|
699
|
+
return { status: "error", detail: `Target ${targetLabel} has no matching agents`, target, matches: [] };
|
|
700
|
+
}
|
|
701
|
+
if (healthyMatches.length === 0) {
|
|
702
|
+
return { status: "error", detail: `Target ${targetLabel} has no online ready agents`, target, matches: snapshots };
|
|
703
|
+
}
|
|
704
|
+
if (binding.mode === "exclusive" && healthyMatches.length > 1) {
|
|
705
|
+
return {
|
|
706
|
+
status: "warning",
|
|
707
|
+
detail: `Target ${targetLabel} matches ${healthyMatches.length} online ready agents for an exclusive binding`,
|
|
708
|
+
target,
|
|
709
|
+
matches: snapshots,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
return {
|
|
713
|
+
status: "ok",
|
|
714
|
+
detail: `Target ${targetLabel} has ${healthyMatches.length} online ready agent${healthyMatches.length === 1 ? "" : "s"}`,
|
|
715
|
+
target,
|
|
716
|
+
matches: snapshots,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
631
720
|
function rowToChannelSummary(row: any): ChannelSummary {
|
|
632
721
|
const binding = row.binding_id ? rowToChannelBinding({
|
|
633
722
|
id: row.binding_id,
|
|
@@ -653,6 +742,7 @@ function rowToChannelSummary(row: any): ChannelSummary {
|
|
|
653
742
|
direction: row.direction,
|
|
654
743
|
target: binding ? bindingTargetToLegacyTarget(binding.target) : undefined,
|
|
655
744
|
binding,
|
|
745
|
+
targetHealth: binding ? channelTargetHealth(binding) : undefined,
|
|
656
746
|
topicChannels: parseStringArray(row.topic_channels),
|
|
657
747
|
capabilities: parseStringArray(row.channel_capabilities),
|
|
658
748
|
tags: parseStringArray(row.agent_tags),
|
|
@@ -965,25 +1055,31 @@ export function upsertChannelBinding(input: {
|
|
|
965
1055
|
const targetId = input.target.type === "broadcast" ? "" : input.target.id;
|
|
966
1056
|
const targetKey = input.target.type === "broadcast" ? "broadcast" : `${input.target.type}:${targetId}`;
|
|
967
1057
|
const id = `${input.channelId}:${conversationKey || "default"}:${targetKey}`;
|
|
1058
|
+
const mode = input.mode ?? "exclusive";
|
|
968
1059
|
const now = Date.now();
|
|
969
|
-
db.
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1060
|
+
db.transaction(() => {
|
|
1061
|
+
if (mode === "exclusive") {
|
|
1062
|
+
db.prepare("DELETE FROM channel_bindings WHERE channel_id = ? AND conversation_key = ?").run(input.channelId, conversationKey);
|
|
1063
|
+
}
|
|
1064
|
+
db.prepare(`
|
|
1065
|
+
INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
|
|
1066
|
+
VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $now, $now)
|
|
1067
|
+
ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
|
|
1068
|
+
mode = $mode,
|
|
1069
|
+
priority = $priority,
|
|
1070
|
+
updated_at = $now
|
|
1071
|
+
`).run({
|
|
1072
|
+
$id: id,
|
|
1073
|
+
$channelId: input.channelId,
|
|
1074
|
+
$conversationKey: conversationKey,
|
|
1075
|
+
$conversationId: input.conversationId ?? null,
|
|
1076
|
+
$targetType: input.target.type,
|
|
1077
|
+
$targetId: targetId,
|
|
1078
|
+
$mode: mode,
|
|
1079
|
+
$priority: input.priority ?? 0,
|
|
1080
|
+
$now: now,
|
|
1081
|
+
});
|
|
1082
|
+
})();
|
|
987
1083
|
|
|
988
1084
|
const row = db.prepare("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
|
|
989
1085
|
return rowToChannelBinding(row);
|
|
@@ -1733,6 +1829,21 @@ function isChannelAgentId(agentId: string): boolean {
|
|
|
1733
1829
|
));
|
|
1734
1830
|
}
|
|
1735
1831
|
|
|
1832
|
+
function legacyChannelTargets(agent: AgentCard | null | undefined): string[] {
|
|
1833
|
+
if (!agent || !isChannelAgentId(agent.id)) return [];
|
|
1834
|
+
const aliases = new Set<string>();
|
|
1835
|
+
const provider = channelProviderForAgent(agent);
|
|
1836
|
+
if (provider && provider !== "custom") aliases.add(provider);
|
|
1837
|
+
const channelType = stringValue(agent.meta?.channelType);
|
|
1838
|
+
if (channelType) aliases.add(channelType);
|
|
1839
|
+
const transport = stringValue(agent.meta?.transport);
|
|
1840
|
+
if (transport) aliases.add(transport);
|
|
1841
|
+
const providerTag = agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length);
|
|
1842
|
+
if (providerTag) aliases.add(providerTag);
|
|
1843
|
+
aliases.delete(agent.id);
|
|
1844
|
+
return [...aliases];
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1736
1847
|
function matchingDeliveryAgents(target: string): AgentCard[] {
|
|
1737
1848
|
if (!target) return [];
|
|
1738
1849
|
const candidates = listAgents().filter(isDeliveryAgent);
|
|
@@ -1963,11 +2074,15 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
1963
2074
|
const agentTags = agent?.tags ?? [];
|
|
1964
2075
|
const agentCaps = agent?.capabilities ?? [];
|
|
1965
2076
|
const agentLabel = agent?.label;
|
|
2077
|
+
const agentLegacyChannelTargets = legacyChannelTargets(agent);
|
|
1966
2078
|
|
|
1967
2079
|
const conditions: string[] = [];
|
|
1968
2080
|
const params: any[] = [];
|
|
1969
2081
|
|
|
1970
|
-
// Build target matching: direct + broadcast + tag + capability + label
|
|
2082
|
+
// Build target matching: direct + broadcast + tag + capability + label.
|
|
2083
|
+
// Channel agents also accept legacy bare provider targets (for example
|
|
2084
|
+
// "telegram") so older clients keep working after canonical IDs became
|
|
2085
|
+
// provider:account ("telegram:default").
|
|
1971
2086
|
const targetClauses = ["to_target = ?", "to_target = 'broadcast'"];
|
|
1972
2087
|
params.push(query.for);
|
|
1973
2088
|
|
|
@@ -1983,6 +2098,10 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
1983
2098
|
targetClauses.push("to_target = ?");
|
|
1984
2099
|
params.push(`label:${agentLabel}`);
|
|
1985
2100
|
}
|
|
2101
|
+
for (const target of agentLegacyChannelTargets) {
|
|
2102
|
+
targetClauses.push("to_target = ?");
|
|
2103
|
+
params.push(target);
|
|
2104
|
+
}
|
|
1986
2105
|
conditions.push(`(${targetClauses.join(" OR ")})`);
|
|
1987
2106
|
|
|
1988
2107
|
// Hide active claims held by someone else, but let expired claims surface so
|
|
@@ -2267,6 +2386,21 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
2267
2386
|
detail: offlineClaimedTasks > 0 ? `${offlineClaimedTasks} active task(s) are claimed by offline agents` : undefined,
|
|
2268
2387
|
});
|
|
2269
2388
|
|
|
2389
|
+
const unhealthyChannelTargets = listChannels().filter((channel) => channel.targetHealth && channel.targetHealth.status !== "ok");
|
|
2390
|
+
const channelTargetErrors = unhealthyChannelTargets.filter((channel) => channel.targetHealth?.status === "error");
|
|
2391
|
+
const channelTargetDetail = unhealthyChannelTargets
|
|
2392
|
+
.slice(0, 3)
|
|
2393
|
+
.map((channel) => `${channel.name}: ${channel.targetHealth?.detail}`)
|
|
2394
|
+
.join("; ");
|
|
2395
|
+
checks.push({
|
|
2396
|
+
name: "channel-delivery-targets",
|
|
2397
|
+
status: channelTargetErrors.length > 0 ? "error" : unhealthyChannelTargets.length > 0 ? "warn" : "ok",
|
|
2398
|
+
count: unhealthyChannelTargets.length,
|
|
2399
|
+
detail: unhealthyChannelTargets.length > 0
|
|
2400
|
+
? `${unhealthyChannelTargets.length} channel delivery target issue(s): ${channelTargetDetail}${unhealthyChannelTargets.length > 3 ? "; ..." : ""}`
|
|
2401
|
+
: undefined,
|
|
2402
|
+
});
|
|
2403
|
+
|
|
2270
2404
|
const status = checks.some((check) => check.status === "error")
|
|
2271
2405
|
? "error"
|
|
2272
2406
|
: checks.some((check) => check.status === "warn")
|
package/src/types.ts
CHANGED
|
@@ -290,6 +290,22 @@ export interface ChannelBinding {
|
|
|
290
290
|
updatedAt: number;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
export interface ChannelTargetHealth {
|
|
294
|
+
status: "ok" | "warning" | "error";
|
|
295
|
+
detail: string;
|
|
296
|
+
target: ChannelRouteTarget;
|
|
297
|
+
matches: Array<{
|
|
298
|
+
id: string;
|
|
299
|
+
name: string;
|
|
300
|
+
status: AgentCard["status"];
|
|
301
|
+
ready: boolean;
|
|
302
|
+
lastSeen: number;
|
|
303
|
+
label?: string;
|
|
304
|
+
tags: string[];
|
|
305
|
+
capabilities: string[];
|
|
306
|
+
}>;
|
|
307
|
+
}
|
|
308
|
+
|
|
293
309
|
export interface ChannelSummary {
|
|
294
310
|
id: string;
|
|
295
311
|
name: string;
|
|
@@ -302,6 +318,7 @@ export interface ChannelSummary {
|
|
|
302
318
|
direction: ChannelDirection;
|
|
303
319
|
target?: string;
|
|
304
320
|
binding?: ChannelBinding;
|
|
321
|
+
targetHealth?: ChannelTargetHealth;
|
|
305
322
|
topicChannels: string[];
|
|
306
323
|
capabilities: string[];
|
|
307
324
|
tags: string[];
|