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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -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">Total Agents</div>
343
- <div class="h1 mb-0" x-text="stats.agents ?? 0"></div>
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">Active</div>
356
- <div class="h1 mb-0 text-success" x-text="onlineCount"></div>
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-circle-check ms-auto stat-card"></i>
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.prepare(`
970
- INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
971
- VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $now, $now)
972
- ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
973
- mode = $mode,
974
- priority = $priority,
975
- updated_at = $now
976
- `).run({
977
- $id: id,
978
- $channelId: input.channelId,
979
- $conversationKey: conversationKey,
980
- $conversationId: input.conversationId ?? null,
981
- $targetType: input.target.type,
982
- $targetId: targetId,
983
- $mode: input.mode ?? "exclusive",
984
- $priority: input.priority ?? 0,
985
- $now: now,
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[];