agent-relay-server 0.4.27 → 0.4.28
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 +18 -1
- package/package.json +1 -1
- package/public/dashboard.js +60 -1
- package/public/index.html +169 -1
- package/src/db.ts +27 -0
- package/src/routes.ts +111 -1
- package/src/security.ts +3 -2
- package/src/types.ts +46 -0
package/README.md
CHANGED
|
@@ -40,6 +40,21 @@ Agent Relay has two parts:
|
|
|
40
40
|
|
|
41
41
|
Pick your provider: install the Claude Code plugin, the Codex connector, or both. The shared config is the same four env vars either way. See the [full mental model](docs/mental-model.md) for routing, identity, and task lifecycle details, or the [provider spec](docs/provider-spec.md) if you want to build your own integration.
|
|
42
42
|
|
|
43
|
+
## Extension Model
|
|
44
|
+
|
|
45
|
+
Agent Relay is built around four extension points:
|
|
46
|
+
|
|
47
|
+
> **Agents do work. Providers host agents. Integrations create work. Channels carry conversations.**
|
|
48
|
+
|
|
49
|
+
That split keeps the system small without making everything a one-off plugin:
|
|
50
|
+
|
|
51
|
+
- **Agents** are running AI sessions or workers that can receive messages, claim tasks, and produce results.
|
|
52
|
+
- **Providers** connect an AI runtime to the relay. The Claude plugin and Codex connector are providers.
|
|
53
|
+
- **Integrations** connect external systems that create or update work. CI, monitoring, support desks, and deployment tools fit here.
|
|
54
|
+
- **Channels** connect communication surfaces where humans and systems already talk. Telegram is the first channel; Slack, email, SMS, Matrix, Discord, and web chat are natural next ones.
|
|
55
|
+
|
|
56
|
+
If you want to contribute, pick the shape that matches your system. Building a new AI runtime adapter? Build a provider. Sending alerts or tickets into Agent Relay? Build an integration. Opening Agent Relay to another messaging app? Build a channel.
|
|
57
|
+
|
|
43
58
|
## Quick Start
|
|
44
59
|
|
|
45
60
|
### 1. Start the relay server
|
|
@@ -193,7 +208,7 @@ When an integration has `callbackUrl`, Agent Relay posts task lifecycle events
|
|
|
193
208
|
|
|
194
209
|
Integration tokens can also be used as scoped API tokens when the full admin
|
|
195
210
|
`AGENT_RELAY_TOKEN` would be too broad. Supported scopes include `stats:read`,
|
|
196
|
-
`health:read`, `events:read`, `agents:read`, `agents:write`, `messages:read`,
|
|
211
|
+
`health:read`, `events:read`, `channels:read`, `agents:read`, `agents:write`, `messages:read`,
|
|
197
212
|
`messages:write`, `tasks:read`, `tasks:write`, `pairs:read`, `pairs:write`,
|
|
198
213
|
`system:write`, and `*`.
|
|
199
214
|
The event ingress endpoint also accepts the legacy `tasks:create` and
|
|
@@ -370,6 +385,8 @@ relay asks for a more specific target such as `id:...`, `label:...`, `tag:...`,
|
|
|
370
385
|
|
|
371
386
|
| Method | Path | Purpose |
|
|
372
387
|
|--------|------|---------|
|
|
388
|
+
| `GET` | `/channels` | List registered communication channels such as Telegram |
|
|
389
|
+
| `GET` | `/integrations` | List configured and observed task/system integrations |
|
|
373
390
|
| `POST` | `/integrations/events` | Integration event ingress |
|
|
374
391
|
| `GET` | `/tasks` | List tasks |
|
|
375
392
|
| `GET` | `/tasks/:id` | Get one task |
|
package/package.json
CHANGED
package/public/dashboard.js
CHANGED
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
pairs: [],
|
|
51
51
|
messages: [],
|
|
52
52
|
tasks: [],
|
|
53
|
+
integrations: [],
|
|
54
|
+
channels: [],
|
|
53
55
|
taskEvents: [],
|
|
54
56
|
taskEventCache: {},
|
|
55
57
|
stats: {},
|
|
@@ -211,6 +213,8 @@
|
|
|
211
213
|
if (view === "activity") await Promise.all([this.fetchMessages(), this.fetchPairs(), this.fetchTasks(), this.fetchActivityEvents()]);
|
|
212
214
|
if (view === "work") await Promise.all([this.fetchMessages(), this.fetchTasks()]);
|
|
213
215
|
if (view === "pairs") this.fetchPairs();
|
|
216
|
+
if (view === "channels") await this.fetchChannels();
|
|
217
|
+
if (view === "integrations") await this.fetchIntegrations();
|
|
214
218
|
if (view === "tasks") this.fetchTasks();
|
|
215
219
|
},
|
|
216
220
|
|
|
@@ -353,7 +357,7 @@
|
|
|
353
357
|
},
|
|
354
358
|
|
|
355
359
|
async refresh() {
|
|
356
|
-
await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchInboxState(), this.fetchActivityEvents()]);
|
|
360
|
+
await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchChannels(), this.fetchIntegrations(), this.fetchInboxState(), this.fetchActivityEvents()]);
|
|
357
361
|
},
|
|
358
362
|
|
|
359
363
|
async refreshLiveData() {
|
|
@@ -424,6 +428,18 @@
|
|
|
424
428
|
} catch {}
|
|
425
429
|
},
|
|
426
430
|
|
|
431
|
+
async fetchIntegrations() {
|
|
432
|
+
try {
|
|
433
|
+
this.integrations = await this.api("GET", "/integrations");
|
|
434
|
+
} catch {}
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
async fetchChannels() {
|
|
438
|
+
try {
|
|
439
|
+
this.channels = await this.api("GET", "/channels");
|
|
440
|
+
} catch {}
|
|
441
|
+
},
|
|
442
|
+
|
|
427
443
|
async fetchInboxState() {
|
|
428
444
|
try {
|
|
429
445
|
const state = await this.api("GET", "/inbox/state?operatorId=" + encodeURIComponent(INBOX_OPERATOR_ID));
|
|
@@ -486,6 +502,8 @@
|
|
|
486
502
|
attentionAgentCount: { get: getAttentionAgentCount },
|
|
487
503
|
activityItems: { get: getActivityItems },
|
|
488
504
|
workQueueItems: { get: getWorkQueueItems },
|
|
505
|
+
channelCards: { get: getChannelCards },
|
|
506
|
+
integrationCards: { get: getIntegrationCards },
|
|
489
507
|
filteredMessages: { get: getFilteredMessages },
|
|
490
508
|
groupedMessages: { get: getGroupedMessages },
|
|
491
509
|
filteredTasks: { get: getFilteredTasks },
|
|
@@ -1004,6 +1022,28 @@
|
|
|
1004
1022
|
return this.tasks.filter((task) => !CLOSED_TASK_STATUSES.has(task.status));
|
|
1005
1023
|
}
|
|
1006
1024
|
|
|
1025
|
+
function getIntegrationCards() {
|
|
1026
|
+
return [...(this.integrations || [])].sort((a, b) => {
|
|
1027
|
+
const aStats = a.taskStats || {};
|
|
1028
|
+
const bStats = b.taskStats || {};
|
|
1029
|
+
const openDiff = (bStats.openTasks || 0) - (aStats.openTasks || 0);
|
|
1030
|
+
if (openDiff !== 0) return openDiff;
|
|
1031
|
+
const waitingDiff = (bStats.waitingTasks || 0) - (aStats.waitingTasks || 0);
|
|
1032
|
+
if (waitingDiff !== 0) return waitingDiff;
|
|
1033
|
+
return String(a.name || "").localeCompare(String(b.name || ""));
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function getChannelCards() {
|
|
1038
|
+
return [...(this.channels || [])].sort((a, b) => {
|
|
1039
|
+
const readyDiff = Number(Boolean(b.ready)) - Number(Boolean(a.ready));
|
|
1040
|
+
if (readyDiff !== 0) return readyDiff;
|
|
1041
|
+
const statusDiff = String(a.status || "").localeCompare(String(b.status || ""));
|
|
1042
|
+
if (statusDiff !== 0) return statusDiff;
|
|
1043
|
+
return String(a.name || "").localeCompare(String(b.name || ""));
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1007
1047
|
function getComposeAgents() {
|
|
1008
1048
|
const list = visibleAgents(this);
|
|
1009
1049
|
return this.showOffline ? list : list.filter((agent) => agent.status !== "offline");
|
|
@@ -1177,6 +1217,8 @@
|
|
|
1177
1217
|
agentStatusClass,
|
|
1178
1218
|
severityClass,
|
|
1179
1219
|
agentStatusTitle,
|
|
1220
|
+
channelPresence,
|
|
1221
|
+
integrationPresence,
|
|
1180
1222
|
timeAgo,
|
|
1181
1223
|
fmtTime,
|
|
1182
1224
|
healthAlertClass,
|
|
@@ -1389,6 +1431,23 @@
|
|
|
1389
1431
|
return "bg-info-lt";
|
|
1390
1432
|
}
|
|
1391
1433
|
|
|
1434
|
+
function integrationPresence(integration) {
|
|
1435
|
+
const stats = integration?.taskStats || {};
|
|
1436
|
+
if ((stats.waitingTasks || 0) > 0) return { label: "waiting", tone: "warning", icon: "ti-alert-circle" };
|
|
1437
|
+
if ((stats.openTasks || 0) > 0) return { label: "active", tone: "info", icon: "ti-activity" };
|
|
1438
|
+
if (!integration?.configured) return { label: "observed only", tone: "secondary", icon: "ti-eye" };
|
|
1439
|
+
if (integration.observed) return { label: "quiet", tone: "success", icon: "ti-circle-check" };
|
|
1440
|
+
return { label: "configured", tone: "primary", icon: "ti-plug-connected" };
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function channelPresence(channel) {
|
|
1444
|
+
if (!channel) return { label: "unknown", tone: "secondary", icon: "ti-plug-off" };
|
|
1445
|
+
if (channel.status === "offline") return { label: "offline", tone: "secondary", icon: "ti-plug-off" };
|
|
1446
|
+
if (!channel.ready) return { label: "not ready", tone: "warning", icon: "ti-loader" };
|
|
1447
|
+
if (channel.status === "busy") return { label: "busy", tone: "warning", icon: "ti-activity" };
|
|
1448
|
+
return { label: "ready", tone: "success", icon: "ti-circle-check" };
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1392
1451
|
function agentStatusTitle(agent) {
|
|
1393
1452
|
if (!agent) return "";
|
|
1394
1453
|
if (agent.status === "offline") return "offline";
|
package/public/index.html
CHANGED
|
@@ -190,6 +190,16 @@
|
|
|
190
190
|
<span class="badge bg-warning text-white ms-auto" x-show="attentionAgentCount > 0" x-text="attentionAgentCount"></span>
|
|
191
191
|
<span class="badge bg-success text-white ms-1" x-text="onlineCount"></span>
|
|
192
192
|
</a>
|
|
193
|
+
<a href="#" class="nav-link" :class="{ active: view === 'channels' }" @click.prevent="switchView('channels')">
|
|
194
|
+
<i class="ti ti-messages"></i>Channels
|
|
195
|
+
<span class="badge bg-success text-white ms-auto" x-show="channelCards.filter((item) => item.ready).length > 0" x-text="channelCards.filter((item) => item.ready).length"></span>
|
|
196
|
+
<span class="badge bg-secondary text-white ms-1" x-text="channelCards.length"></span>
|
|
197
|
+
</a>
|
|
198
|
+
<a href="#" class="nav-link" :class="{ active: view === 'integrations' }" @click.prevent="switchView('integrations')">
|
|
199
|
+
<i class="ti ti-plug-connected"></i>Integrations
|
|
200
|
+
<span class="badge bg-warning text-white ms-auto" x-show="integrationCards.some((item) => (item.taskStats?.waitingTasks || 0) > 0)" x-text="integrationCards.reduce((total, item) => total + (item.taskStats?.waitingTasks || 0), 0)"></span>
|
|
201
|
+
<span class="badge bg-secondary text-white ms-1" x-text="integrationCards.length"></span>
|
|
202
|
+
</a>
|
|
193
203
|
<a href="#" class="nav-link" :class="{ active: view === 'inbox' }" @click.prevent="switchView('inbox')">
|
|
194
204
|
<i class="ti ti-inbox"></i>Inbox
|
|
195
205
|
<span class="badge bg-danger text-white ms-auto" x-show="attentionSummary.unreadInbox > 0" x-text="attentionSummary.unreadInbox"></span>
|
|
@@ -251,7 +261,7 @@
|
|
|
251
261
|
|
|
252
262
|
<!-- Mobile nav -->
|
|
253
263
|
<div class="mobile-nav d-none border-bottom p-2 gap-1 position-fixed top-0 w-100 bg-dark" style="z-index:50">
|
|
254
|
-
<template x-for="v in ['overview','agents','inbox','activity','pairs','messages','work','tasks','analytics']">
|
|
264
|
+
<template x-for="v in ['overview','agents','channels','integrations','inbox','activity','pairs','messages','work','tasks','analytics']">
|
|
255
265
|
<button class="btn btn-sm" :class="view === v ? 'btn-primary' : 'btn-ghost-secondary'" @click="switchView(v)" x-text="v.charAt(0).toUpperCase() + v.slice(1)"></button>
|
|
256
266
|
</template>
|
|
257
267
|
</div>
|
|
@@ -660,6 +670,164 @@
|
|
|
660
670
|
</template>
|
|
661
671
|
</div>
|
|
662
672
|
|
|
673
|
+
<!-- ==================== CHANNELS ==================== -->
|
|
674
|
+
<div x-show="view === 'channels'" x-cloak class="fade-in">
|
|
675
|
+
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
|
676
|
+
<h2 class="page-title mb-0">Channels</h2>
|
|
677
|
+
<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>
|
|
678
|
+
<div class="ms-auto d-flex gap-2 align-items-center">
|
|
679
|
+
<button class="btn btn-sm btn-ghost-secondary" @click="fetchChannels()">
|
|
680
|
+
<i class="ti ti-refresh"></i>
|
|
681
|
+
</button>
|
|
682
|
+
</div>
|
|
683
|
+
</div>
|
|
684
|
+
|
|
685
|
+
<div class="row g-3">
|
|
686
|
+
<template x-for="channel in channelCards" :key="channel.id">
|
|
687
|
+
<div class="col-md-6 col-xl-4">
|
|
688
|
+
<div class="card">
|
|
689
|
+
<div class="card-body">
|
|
690
|
+
<div class="d-flex align-items-start gap-2">
|
|
691
|
+
<span class="agent-type-icon agent mt-0">
|
|
692
|
+
<i class="ti" :class="channelPresence(channel).icon"></i>
|
|
693
|
+
</span>
|
|
694
|
+
<div class="flex-grow-1 min-width-0">
|
|
695
|
+
<div class="d-flex align-items-center gap-2">
|
|
696
|
+
<span class="fw-bold text-truncate" x-text="channel.name"></span>
|
|
697
|
+
<span class="badge" :class="'bg-' + channelPresence(channel).tone + '-lt'">
|
|
698
|
+
<i class="ti me-1" :class="channelPresence(channel).icon"></i><span x-text="channelPresence(channel).label"></span>
|
|
699
|
+
</span>
|
|
700
|
+
</div>
|
|
701
|
+
<div class="d-flex gap-1 mt-2 flex-wrap">
|
|
702
|
+
<span class="badge bg-info-lt" x-text="channel.type"></span>
|
|
703
|
+
<span class="badge bg-cyan-lt" x-text="channel.direction"></span>
|
|
704
|
+
<span class="badge bg-secondary-lt" x-text="displayTarget(channel.target || channel.agentId)"></span>
|
|
705
|
+
</div>
|
|
706
|
+
<div class="d-flex gap-1 mt-2 flex-wrap" x-show="channel.capabilities?.length">
|
|
707
|
+
<template x-for="capability in (channel.capabilities || [])" :key="capability">
|
|
708
|
+
<span class="badge bg-secondary-lt" x-text="capability"></span>
|
|
709
|
+
</template>
|
|
710
|
+
</div>
|
|
711
|
+
<div class="d-flex gap-1 mt-2 flex-wrap" x-show="channel.topicChannels?.length">
|
|
712
|
+
<template x-for="topic in (channel.topicChannels || [])" :key="topic">
|
|
713
|
+
<span class="badge bg-warning-lt" x-text="'#' + topic"></span>
|
|
714
|
+
</template>
|
|
715
|
+
</div>
|
|
716
|
+
<div class="text-secondary small mt-2 d-flex gap-2 flex-wrap">
|
|
717
|
+
<span x-text="'Agent: ' + channel.agentId"></span>
|
|
718
|
+
<span x-text="'Last seen: ' + timeAgo(channel.lastSeen)"></span>
|
|
719
|
+
</div>
|
|
720
|
+
</div>
|
|
721
|
+
<button class="btn btn-sm btn-ghost-secondary p-1" title="Show agent" @click="openAgentDetail(agentsById[channel.agentId])">
|
|
722
|
+
<i class="ti ti-robot"></i>
|
|
723
|
+
</button>
|
|
724
|
+
</div>
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
</div>
|
|
728
|
+
</template>
|
|
729
|
+
</div>
|
|
730
|
+
|
|
731
|
+
<template x-if="channelCards.length === 0">
|
|
732
|
+
<div class="card">
|
|
733
|
+
<div class="card-body text-center text-secondary py-5">
|
|
734
|
+
<i class="ti ti-message-off" style="font-size:48px; opacity:0.3"></i>
|
|
735
|
+
<p class="mt-2">No channels registered</p>
|
|
736
|
+
</div>
|
|
737
|
+
</div>
|
|
738
|
+
</template>
|
|
739
|
+
</div>
|
|
740
|
+
|
|
741
|
+
<!-- ==================== INTEGRATIONS ==================== -->
|
|
742
|
+
<div x-show="view === 'integrations'" x-cloak class="fade-in">
|
|
743
|
+
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
|
744
|
+
<h2 class="page-title mb-0">Integrations</h2>
|
|
745
|
+
<span class="badge bg-warning-lt" x-show="integrationCards.some((item) => (item.taskStats?.waitingTasks || 0) > 0)" x-text="integrationCards.reduce((total, item) => total + (item.taskStats?.waitingTasks || 0), 0) + ' waiting'"></span>
|
|
746
|
+
<div class="ms-auto d-flex gap-2 align-items-center">
|
|
747
|
+
<button class="btn btn-sm btn-ghost-secondary" @click="fetchIntegrations()">
|
|
748
|
+
<i class="ti ti-refresh"></i>
|
|
749
|
+
</button>
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
|
|
753
|
+
<div class="row g-3">
|
|
754
|
+
<template x-for="integration in integrationCards" :key="integration.name">
|
|
755
|
+
<div class="col-md-6 col-xl-4">
|
|
756
|
+
<div class="card">
|
|
757
|
+
<div class="card-body">
|
|
758
|
+
<div class="d-flex align-items-start gap-2">
|
|
759
|
+
<span class="agent-type-icon agent mt-0">
|
|
760
|
+
<i class="ti" :class="integrationPresence(integration).icon"></i>
|
|
761
|
+
</span>
|
|
762
|
+
<div class="flex-grow-1 min-width-0">
|
|
763
|
+
<div class="d-flex align-items-center gap-2">
|
|
764
|
+
<span class="fw-bold text-truncate" x-text="integration.name"></span>
|
|
765
|
+
<span class="badge" :class="'bg-' + integrationPresence(integration).tone + '-lt'">
|
|
766
|
+
<i class="ti me-1" :class="integrationPresence(integration).icon"></i><span x-text="integrationPresence(integration).label"></span>
|
|
767
|
+
</span>
|
|
768
|
+
</div>
|
|
769
|
+
<div class="d-flex gap-1 mt-2 flex-wrap">
|
|
770
|
+
<span class="badge" :class="integration.configured ? 'bg-success-lt' : 'bg-secondary-lt'" x-text="integration.configured ? 'configured' : 'not configured'"></span>
|
|
771
|
+
<span class="badge" :class="integration.observed ? 'bg-info-lt' : 'bg-secondary-lt'" x-text="integration.observed ? 'observed' : 'no tasks'"></span>
|
|
772
|
+
<span class="badge" :class="integration.callbackConfigured ? 'bg-purple-lt' : 'bg-secondary-lt'" x-text="integration.callbackConfigured ? 'callback' : 'no callback'"></span>
|
|
773
|
+
</div>
|
|
774
|
+
<div class="row g-2 mt-2 text-center">
|
|
775
|
+
<div class="col-3">
|
|
776
|
+
<div class="text-secondary small">Tasks</div>
|
|
777
|
+
<div class="h3 mb-0" x-text="integration.taskStats?.tasks || 0"></div>
|
|
778
|
+
</div>
|
|
779
|
+
<div class="col-3">
|
|
780
|
+
<div class="text-secondary small">Open</div>
|
|
781
|
+
<div class="h3 mb-0" x-text="integration.taskStats?.openTasks || 0"></div>
|
|
782
|
+
</div>
|
|
783
|
+
<div class="col-3">
|
|
784
|
+
<div class="text-secondary small">Waiting</div>
|
|
785
|
+
<div class="h3 mb-0" :class="(integration.taskStats?.waitingTasks || 0) ? 'text-warning' : ''" x-text="integration.taskStats?.waitingTasks || 0"></div>
|
|
786
|
+
</div>
|
|
787
|
+
<div class="col-3">
|
|
788
|
+
<div class="text-secondary small">Failed</div>
|
|
789
|
+
<div class="h3 mb-0" :class="(integration.taskStats?.failedTasks || 0) ? 'text-danger' : ''" x-text="integration.taskStats?.failedTasks || 0"></div>
|
|
790
|
+
</div>
|
|
791
|
+
</div>
|
|
792
|
+
<div class="d-flex gap-1 mt-2 flex-wrap" x-show="integration.scopes?.length">
|
|
793
|
+
<template x-for="scope in (integration.scopes || [])" :key="scope">
|
|
794
|
+
<span class="badge bg-secondary-lt" x-text="scope"></span>
|
|
795
|
+
</template>
|
|
796
|
+
</div>
|
|
797
|
+
<div class="d-flex gap-1 mt-2 flex-wrap" x-show="integration.targets?.length || integration.channels?.length">
|
|
798
|
+
<template x-for="target in (integration.targets || [])" :key="target">
|
|
799
|
+
<span class="badge bg-cyan-lt" x-text="displayTarget(target)"></span>
|
|
800
|
+
</template>
|
|
801
|
+
<template x-for="channel in (integration.channels || [])" :key="channel">
|
|
802
|
+
<span class="badge bg-warning-lt" x-text="'#' + channel"></span>
|
|
803
|
+
</template>
|
|
804
|
+
</div>
|
|
805
|
+
<div class="text-secondary small mt-2 d-flex gap-2 flex-wrap">
|
|
806
|
+
<span x-show="integration.taskStats?.lastSeenAt" x-text="'Last task: ' + timeAgo(integration.taskStats?.lastSeenAt)"></span>
|
|
807
|
+
<span x-show="integration.callbackHost" x-text="integration.callbackHost"></span>
|
|
808
|
+
<span x-text="'Rate: ' + (integration.rateLimit?.currentWindowCount || 0) + '/' + (integration.rateLimit?.limitPerMinute || 0) + ' min'"></span>
|
|
809
|
+
</div>
|
|
810
|
+
</div>
|
|
811
|
+
<button class="btn btn-sm btn-ghost-secondary p-1" title="Show tasks" @click="taskSourceFilter = integration.name; taskStatusFilter = ''; switchView('tasks')">
|
|
812
|
+
<i class="ti ti-list-details"></i>
|
|
813
|
+
</button>
|
|
814
|
+
</div>
|
|
815
|
+
</div>
|
|
816
|
+
</div>
|
|
817
|
+
</div>
|
|
818
|
+
</template>
|
|
819
|
+
</div>
|
|
820
|
+
|
|
821
|
+
<template x-if="integrationCards.length === 0">
|
|
822
|
+
<div class="card">
|
|
823
|
+
<div class="card-body text-center text-secondary py-5">
|
|
824
|
+
<i class="ti ti-plug-off" style="font-size:48px; opacity:0.3"></i>
|
|
825
|
+
<p class="mt-2">No integrations configured or observed</p>
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
828
|
+
</template>
|
|
829
|
+
</div>
|
|
830
|
+
|
|
663
831
|
<!-- ==================== INBOX ==================== -->
|
|
664
832
|
<div x-show="view === 'inbox'" x-cloak class="fade-in">
|
|
665
833
|
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
package/src/db.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
TaskSeverity,
|
|
24
24
|
TaskStatus,
|
|
25
25
|
IntegrationEventInput,
|
|
26
|
+
IntegrationTaskStats,
|
|
26
27
|
InboxDraft,
|
|
27
28
|
InboxState,
|
|
28
29
|
InboxThreadState,
|
|
@@ -856,6 +857,32 @@ export function listTasks(filter?: { status?: string; source?: string; target?:
|
|
|
856
857
|
return (db.prepare(`${TASK_SELECT} ${where} ORDER BY updated_at DESC LIMIT ?`).all(...params) as any[]).map(rowToTask);
|
|
857
858
|
}
|
|
858
859
|
|
|
860
|
+
export function listIntegrationTaskStats(): IntegrationTaskStats[] {
|
|
861
|
+
const rows = db.prepare(`
|
|
862
|
+
SELECT
|
|
863
|
+
source,
|
|
864
|
+
COUNT(*) AS tasks,
|
|
865
|
+
SUM(CASE WHEN status NOT IN ('done', 'failed', 'canceled') THEN 1 ELSE 0 END) AS open_tasks,
|
|
866
|
+
SUM(CASE WHEN status IN ('open', 'blocked') AND claimed_by IS NULL THEN 1 ELSE 0 END) AS waiting_tasks,
|
|
867
|
+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_tasks,
|
|
868
|
+
MAX(last_seen_at) AS last_seen_at,
|
|
869
|
+
MAX(updated_at) AS last_updated_at
|
|
870
|
+
FROM tasks
|
|
871
|
+
GROUP BY source
|
|
872
|
+
ORDER BY last_seen_at DESC, source ASC
|
|
873
|
+
`).all() as any[];
|
|
874
|
+
|
|
875
|
+
return rows.map((row) => ({
|
|
876
|
+
source: row.source,
|
|
877
|
+
tasks: Number(row.tasks ?? 0),
|
|
878
|
+
openTasks: Number(row.open_tasks ?? 0),
|
|
879
|
+
waitingTasks: Number(row.waiting_tasks ?? 0),
|
|
880
|
+
failedTasks: Number(row.failed_tasks ?? 0),
|
|
881
|
+
lastSeenAt: row.last_seen_at ?? undefined,
|
|
882
|
+
lastUpdatedAt: row.last_updated_at ?? undefined,
|
|
883
|
+
}));
|
|
884
|
+
}
|
|
885
|
+
|
|
859
886
|
export function listTaskEvents(taskId: number): TaskEvent[] {
|
|
860
887
|
return (db.prepare("SELECT * FROM task_events WHERE task_id = ? ORDER BY id ASC").all(taskId) as any[]).map(rowToTaskEvent);
|
|
861
888
|
}
|
package/src/routes.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
ingestIntegrationEvent,
|
|
25
25
|
listTasks,
|
|
26
26
|
getTask,
|
|
27
|
+
listIntegrationTaskStats,
|
|
27
28
|
listTaskEvents,
|
|
28
29
|
claimTask,
|
|
29
30
|
renewTaskClaim,
|
|
@@ -49,7 +50,7 @@ import {
|
|
|
49
50
|
validateAgentSession,
|
|
50
51
|
ValidationError,
|
|
51
52
|
} from "./db";
|
|
52
|
-
import type { ActivityEventInput, ActivityKind, AgentSessionGuard, CreatePairInput, IntegrationEventInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
|
|
53
|
+
import type { ActivityEventInput, ActivityKind, AgentCard, AgentSessionGuard, ChannelDirection, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
|
|
53
54
|
import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
|
|
54
55
|
import {
|
|
55
56
|
getIntegrationAuth,
|
|
@@ -481,6 +482,61 @@ function checkIntegrationRateLimit(name: string): boolean {
|
|
|
481
482
|
return bucket.count <= INTEGRATION_RATE_LIMIT_PER_MINUTE;
|
|
482
483
|
}
|
|
483
484
|
|
|
485
|
+
function safeCallbackHost(url: string | undefined): string | undefined {
|
|
486
|
+
if (!url) return undefined;
|
|
487
|
+
try {
|
|
488
|
+
return new URL(url).host;
|
|
489
|
+
} catch {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function metaString(meta: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
495
|
+
const value = meta?.[key];
|
|
496
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function metaStringArray(meta: Record<string, unknown> | undefined, key: string): string[] {
|
|
500
|
+
const value = meta?.[key];
|
|
501
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function metaDirection(meta: Record<string, unknown> | undefined): ChannelDirection {
|
|
505
|
+
const value = metaString(meta, "direction");
|
|
506
|
+
return value === "inbound" || value === "outbound" || value === "bidirectional" ? value : "bidirectional";
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function isChannelAgent(agent: AgentCard): boolean {
|
|
510
|
+
const kind = metaString(agent.meta, "kind");
|
|
511
|
+
return kind === "channel" ||
|
|
512
|
+
kind === "communication-channel" ||
|
|
513
|
+
agent.tags.includes("channel") ||
|
|
514
|
+
agent.capabilities.includes("channel");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function agentToChannel(agent: AgentCard): ChannelSummary {
|
|
518
|
+
const type = metaString(agent.meta, "channelType") ?? metaString(agent.meta, "type") ?? agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ?? "custom";
|
|
519
|
+
const transport = metaString(agent.meta, "transport") ?? type;
|
|
520
|
+
const topicChannels = metaStringArray(agent.meta, "topicChannels");
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
id: metaString(agent.meta, "channelId") ?? agent.id,
|
|
524
|
+
name: metaString(agent.meta, "displayName") ?? agent.name,
|
|
525
|
+
type,
|
|
526
|
+
transport,
|
|
527
|
+
agentId: agent.id,
|
|
528
|
+
status: agent.status,
|
|
529
|
+
ready: agent.ready,
|
|
530
|
+
direction: metaDirection(agent.meta),
|
|
531
|
+
target: metaString(agent.meta, "target"),
|
|
532
|
+
topicChannels,
|
|
533
|
+
capabilities: agent.capabilities,
|
|
534
|
+
tags: agent.tags,
|
|
535
|
+
lastSeen: agent.lastSeen,
|
|
536
|
+
meta: agent.meta,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
484
540
|
async function dispatchTaskCallbacks(taskId: number, eventType: string): Promise<void> {
|
|
485
541
|
const task = getTask(taskId);
|
|
486
542
|
if (!task) return;
|
|
@@ -956,6 +1012,58 @@ const postActivityEvent: Handler = async (req) => {
|
|
|
956
1012
|
|
|
957
1013
|
// --- Tasks and integrations ---
|
|
958
1014
|
|
|
1015
|
+
const getIntegrations: Handler = () => {
|
|
1016
|
+
const configured = new Map(getIntegrationTokens().map((integration) => [integration.name, integration]));
|
|
1017
|
+
const observedStats = new Map(listIntegrationTaskStats().map((stats) => [stats.source, stats]));
|
|
1018
|
+
const names = [...new Set([...configured.keys(), ...observedStats.keys()])].sort((a, b) => a.localeCompare(b));
|
|
1019
|
+
const now = Date.now();
|
|
1020
|
+
|
|
1021
|
+
const integrations: IntegrationSummary[] = names.map((name) => {
|
|
1022
|
+
const config = configured.get(name);
|
|
1023
|
+
const taskStats = observedStats.get(name) ?? {
|
|
1024
|
+
source: name,
|
|
1025
|
+
tasks: 0,
|
|
1026
|
+
openTasks: 0,
|
|
1027
|
+
waitingTasks: 0,
|
|
1028
|
+
failedTasks: 0,
|
|
1029
|
+
};
|
|
1030
|
+
const bucket = integrationRateBuckets.get(name);
|
|
1031
|
+
const bucketCurrent = Boolean(bucket && now - bucket.windowStart < 60_000);
|
|
1032
|
+
|
|
1033
|
+
return {
|
|
1034
|
+
name,
|
|
1035
|
+
configured: Boolean(config),
|
|
1036
|
+
observed: observedStats.has(name),
|
|
1037
|
+
scopes: config?.scopes ?? [],
|
|
1038
|
+
targets: config?.targets ?? [],
|
|
1039
|
+
channels: config?.channels ?? [],
|
|
1040
|
+
callbackHost: safeCallbackHost(config?.callbackUrl),
|
|
1041
|
+
callbackConfigured: Boolean(config?.callbackUrl),
|
|
1042
|
+
rateLimit: {
|
|
1043
|
+
limitPerMinute: INTEGRATION_RATE_LIMIT_PER_MINUTE,
|
|
1044
|
+
currentWindowCount: bucketCurrent ? bucket!.count : 0,
|
|
1045
|
+
windowStartedAt: bucketCurrent ? bucket!.windowStart : undefined,
|
|
1046
|
+
},
|
|
1047
|
+
taskStats,
|
|
1048
|
+
};
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
return json(integrations);
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
const getChannels: Handler = () => {
|
|
1055
|
+
const channels = listAgents()
|
|
1056
|
+
.filter(isChannelAgent)
|
|
1057
|
+
.map(agentToChannel)
|
|
1058
|
+
.sort((a, b) => {
|
|
1059
|
+
const readyDiff = Number(b.ready) - Number(a.ready);
|
|
1060
|
+
if (readyDiff !== 0) return readyDiff;
|
|
1061
|
+
return a.name.localeCompare(b.name);
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
return json(channels);
|
|
1065
|
+
};
|
|
1066
|
+
|
|
959
1067
|
const postIntegrationEvent: Handler = async (req) => {
|
|
960
1068
|
const auth = getIntegrationAuth(req);
|
|
961
1069
|
if (!auth) return error("integration token required", 401);
|
|
@@ -1333,6 +1441,8 @@ const routes: Route[] = [
|
|
|
1333
1441
|
route("GET", "/api/activity", getActivityEvents),
|
|
1334
1442
|
route("POST", "/api/activity", postActivityEvent),
|
|
1335
1443
|
|
|
1444
|
+
route("GET", "/api/channels", getChannels),
|
|
1445
|
+
route("GET", "/api/integrations", getIntegrations),
|
|
1336
1446
|
route("POST", "/api/integrations/events", postIntegrationEvent),
|
|
1337
1447
|
route("GET", "/api/tasks", getTasks),
|
|
1338
1448
|
route("GET", "/api/tasks/:id", getTaskById),
|
package/src/security.ts
CHANGED
|
@@ -95,7 +95,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
95
95
|
if (pathname === "/api/stats") return "stats:read";
|
|
96
96
|
if (pathname === "/api/health") return "health:read";
|
|
97
97
|
if (pathname === "/api/events") return "events:read";
|
|
98
|
-
if (pathname
|
|
98
|
+
if (pathname === "/api/channels") return "channels:read";
|
|
99
|
+
if (pathname === "/api/integrations" || pathname.startsWith("/api/integrations/")) return method === "GET" ? "integrations:read" : "integrations:write";
|
|
99
100
|
if (pathname.startsWith("/api/agents")) return method === "GET" ? "agents:read" : "agents:write";
|
|
100
101
|
if (pathname.startsWith("/api/activity")) return method === "GET" ? "activity:read" : "activity:write";
|
|
101
102
|
if (pathname.startsWith("/api/inbox")) return method === "GET" ? "messages:read" : "messages:write";
|
|
@@ -110,7 +111,7 @@ export function isScopedRequestAuthorized(req: Request): boolean {
|
|
|
110
111
|
const auth = getIntegrationAuth(req);
|
|
111
112
|
if (!auth) return false;
|
|
112
113
|
const pathname = new URL(req.url).pathname;
|
|
113
|
-
if (pathname.startsWith("/api/integrations/") && req.method !== "GET") {
|
|
114
|
+
if ((pathname === "/api/integrations" || pathname.startsWith("/api/integrations/")) && req.method !== "GET") {
|
|
114
115
|
return hasIntegrationScope(auth, "integrations:write") ||
|
|
115
116
|
hasIntegrationScope(auth, "tasks:create") ||
|
|
116
117
|
hasIntegrationScope(auth, "events:create");
|
package/src/types.ts
CHANGED
|
@@ -174,6 +174,52 @@ export interface IntegrationEventInput {
|
|
|
174
174
|
metadata?: Record<string, unknown>;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
export interface IntegrationTaskStats {
|
|
178
|
+
source: string;
|
|
179
|
+
tasks: number;
|
|
180
|
+
openTasks: number;
|
|
181
|
+
waitingTasks: number;
|
|
182
|
+
failedTasks: number;
|
|
183
|
+
lastSeenAt?: number;
|
|
184
|
+
lastUpdatedAt?: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface IntegrationSummary {
|
|
188
|
+
name: string;
|
|
189
|
+
configured: boolean;
|
|
190
|
+
observed: boolean;
|
|
191
|
+
scopes: string[];
|
|
192
|
+
targets: string[];
|
|
193
|
+
channels: string[];
|
|
194
|
+
callbackHost?: string;
|
|
195
|
+
callbackConfigured: boolean;
|
|
196
|
+
rateLimit: {
|
|
197
|
+
limitPerMinute: number;
|
|
198
|
+
currentWindowCount: number;
|
|
199
|
+
windowStartedAt?: number;
|
|
200
|
+
};
|
|
201
|
+
taskStats: IntegrationTaskStats;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export type ChannelDirection = "inbound" | "outbound" | "bidirectional";
|
|
205
|
+
|
|
206
|
+
export interface ChannelSummary {
|
|
207
|
+
id: string;
|
|
208
|
+
name: string;
|
|
209
|
+
type: string;
|
|
210
|
+
transport: string;
|
|
211
|
+
agentId: string;
|
|
212
|
+
status: AgentCard["status"];
|
|
213
|
+
ready: boolean;
|
|
214
|
+
direction: ChannelDirection;
|
|
215
|
+
target?: string;
|
|
216
|
+
topicChannels: string[];
|
|
217
|
+
capabilities: string[];
|
|
218
|
+
tags: string[];
|
|
219
|
+
lastSeen: number;
|
|
220
|
+
meta?: Record<string, unknown>;
|
|
221
|
+
}
|
|
222
|
+
|
|
177
223
|
export interface TaskStatusInput {
|
|
178
224
|
status: TaskStatus;
|
|
179
225
|
agentId?: string;
|