agent-relay-server 0.6.0 → 0.7.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/package.json +3 -1
- package/public/dashboard/actions.js +819 -0
- package/public/dashboard/api.js +336 -0
- package/public/dashboard/app.js +34 -0
- package/public/dashboard/charts.js +128 -0
- package/public/dashboard/computed.js +693 -0
- package/public/dashboard/constants.js +28 -0
- package/public/dashboard/display.js +345 -0
- package/public/dashboard/state.js +129 -0
- package/public/dashboard/utils.js +207 -0
- package/public/index.html +61 -41
- package/scripts/orchestrator-spawn-smoke.ts +140 -0
- package/src/cli.ts +5 -4
- package/src/config.ts +1 -0
- package/src/db.ts +205 -23
- package/src/routes.ts +74 -48
- package/src/types.ts +33 -0
- package/src/upgrade.ts +80 -7
- package/public/dashboard.js +0 -3019
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { HUMAN_AGENT_ID, AGENT_TYPE_ICONS, AGENT_TYPE_TITLES, WAITING_TASK_STATUSES } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
export function indexAgents(agents) {
|
|
4
|
+
const byId = {};
|
|
5
|
+
for (const agent of agents) byId[agent.id] = agent;
|
|
6
|
+
return byId;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function upsertById(list, item) {
|
|
10
|
+
const idx = list.findIndex((existing) => existing.id === item.id);
|
|
11
|
+
if (idx >= 0) list.splice(idx, 1, item);
|
|
12
|
+
else list.push(item);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function toTimestamp(value) {
|
|
16
|
+
const ts = typeof value === "number" ? value : new Date(value || 0).getTime();
|
|
17
|
+
return Number.isFinite(ts) ? ts : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isBuiltInAgent(agent) {
|
|
21
|
+
return agent?.meta?.builtin === true || agent?.id === HUMAN_AGENT_ID || agent?.id === "system";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isChannelAgent(agent) {
|
|
25
|
+
return agent?.kind === "channel" || agent?.meta?.kind === "channel" || agent?.tags?.includes("channel");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function agentType(agent) {
|
|
29
|
+
if (agent?.id === HUMAN_AGENT_ID) return "user";
|
|
30
|
+
if (agent?.id === "system") return "system";
|
|
31
|
+
if (isChannelAgent(agent)) return "channel";
|
|
32
|
+
|
|
33
|
+
const values = [
|
|
34
|
+
...(agent?.tags || []),
|
|
35
|
+
agent?.meta?.provider,
|
|
36
|
+
agent?.meta?.client,
|
|
37
|
+
agent?.meta?.runtime,
|
|
38
|
+
agent?.meta?.agentType,
|
|
39
|
+
agent?.id,
|
|
40
|
+
agent?.name,
|
|
41
|
+
]
|
|
42
|
+
.filter((value) => typeof value === "string")
|
|
43
|
+
.map((value) => value.toLowerCase());
|
|
44
|
+
|
|
45
|
+
if (values.some((value) => value.includes("codex"))) return "codex";
|
|
46
|
+
if (values.some((value) => value.includes("claude"))) return "claude";
|
|
47
|
+
return "agent";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function agentTypeIcon(agent) {
|
|
51
|
+
return AGENT_TYPE_ICONS[agentType(agent)] || AGENT_TYPE_ICONS.agent;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function agentTypeTitle(agent) {
|
|
55
|
+
return AGENT_TYPE_TITLES[agentType(agent)] || AGENT_TYPE_TITLES.agent;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isAgentStale(vm, agent) {
|
|
59
|
+
if (!agent?.lastSeen || agent.status === "offline") return false;
|
|
60
|
+
if (agent.id === "user" || agent.id === "system") return false;
|
|
61
|
+
const lastSeenMs = new Date(agent.lastSeen).getTime();
|
|
62
|
+
if (!Number.isFinite(lastSeenMs)) return false;
|
|
63
|
+
return (vm.now || Date.now()) - lastSeenMs > 60_000;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function agentSupportsControlActions(agent) {
|
|
67
|
+
return Boolean(agent && !isBuiltInAgent(agent) && !isChannelAgent(agent));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function visibleAgents(vm) {
|
|
71
|
+
const nonChannelAgents = vm.agents.filter((agent) => !isChannelAgent(agent));
|
|
72
|
+
return vm.showBuiltIns ? nonChannelAgents : nonChannelAgents.filter((agent) => !isBuiltInAgent(agent));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function messageBody(msg) {
|
|
76
|
+
if (!msg) return "";
|
|
77
|
+
const payload = msg.payload || {};
|
|
78
|
+
const channelMessage = payload.message;
|
|
79
|
+
if (channelMessage && typeof channelMessage === "object" && typeof channelMessage.text === "string" && channelMessage.text.trim()) {
|
|
80
|
+
return channelMessage.text;
|
|
81
|
+
}
|
|
82
|
+
const interaction = payload.interaction;
|
|
83
|
+
if (interaction && typeof interaction === "object") {
|
|
84
|
+
const title = typeof interaction.title === "string" ? interaction.title.trim() : "";
|
|
85
|
+
const description = typeof interaction.description === "string" ? interaction.description.trim() : "";
|
|
86
|
+
if (title && description) return title + "\n" + description;
|
|
87
|
+
if (title) return title;
|
|
88
|
+
if (description) return description;
|
|
89
|
+
}
|
|
90
|
+
const reaction = payload.reaction;
|
|
91
|
+
if (reaction && typeof reaction === "object") {
|
|
92
|
+
const name = typeof reaction.name === "string" ? reaction.name : "";
|
|
93
|
+
const emoji = typeof reaction.emoji === "string" ? reaction.emoji : "";
|
|
94
|
+
const value = typeof reaction.value === "string" ? reaction.value : "";
|
|
95
|
+
return ["Reaction", emoji || name || value].filter(Boolean).join(": ");
|
|
96
|
+
}
|
|
97
|
+
const activity = payload.activity;
|
|
98
|
+
if (activity && typeof activity === "object") {
|
|
99
|
+
const kind = typeof activity.kind === "string" ? activity.kind : "activity";
|
|
100
|
+
const state = typeof activity.state === "string" ? activity.state : "";
|
|
101
|
+
return [kind, state].filter(Boolean).join(" ");
|
|
102
|
+
}
|
|
103
|
+
if (typeof payload.text === "string" && payload.text.trim()) return payload.text;
|
|
104
|
+
if (typeof payload.message === "string" && payload.message.trim()) return payload.message;
|
|
105
|
+
return msg.body || "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function messageLooksLikeQuestion(msg) {
|
|
109
|
+
return /\?/.test(`${msg.subject || ""}\n${messageBody(msg)}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function inboxPeer(msg) {
|
|
113
|
+
if (msg.from === HUMAN_AGENT_ID && msg.to) return msg.to;
|
|
114
|
+
if (msg.to === HUMAN_AGENT_ID && msg.from) return msg.from;
|
|
115
|
+
return "";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isHumanInboundMessage(msg) {
|
|
119
|
+
return msg.to === HUMAN_AGENT_ID && msg.from !== HUMAN_AGENT_ID;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isUnreadHumanMessage(vm, peer, msg) {
|
|
123
|
+
if (!isHumanInboundMessage(msg)) return false;
|
|
124
|
+
if ((msg.readBy || []).includes(HUMAN_AGENT_ID)) return false;
|
|
125
|
+
return msg.id > readCursorForPeer(vm, peer);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function maxMessageId(messages, predicate) {
|
|
129
|
+
let max = 0;
|
|
130
|
+
for (const msg of messages) {
|
|
131
|
+
if (predicate(msg) && msg.id > max) max = msg.id;
|
|
132
|
+
}
|
|
133
|
+
return max;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function readCursorForPeer(vm, peer) {
|
|
137
|
+
const value = Number(vm.inboxReadCursors?.[peer] || 0);
|
|
138
|
+
return Number.isFinite(value) ? value : 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function draftForPeer(vm, peer) {
|
|
142
|
+
return typeof vm.inboxDrafts?.[peer] === "string" ? vm.inboxDrafts[peer] : "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function isClaimableTaskWaiting(task) {
|
|
146
|
+
return WAITING_TASK_STATUSES.has(task.status) && !task.claimedBy;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function isClaimableMessageWaiting(msg) {
|
|
150
|
+
return Boolean(msg.claimable && !msg.claimedBy && !(msg.kind === "task" && Number.isSafeInteger(msg.payload?.taskId)));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function targetMatchesAgent(target, agent) {
|
|
154
|
+
if (!target || !agent) return false;
|
|
155
|
+
if (target === "broadcast" || target === agent.id) return true;
|
|
156
|
+
if (target.startsWith("tag:")) return (agent.tags || []).includes(target.slice(4));
|
|
157
|
+
if (target.startsWith("cap:")) return (agent.capabilities || []).includes(target.slice(4));
|
|
158
|
+
if (target.startsWith("label:")) return agent.label === target.slice(6);
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function messageMatchesChannel(message, channel) {
|
|
163
|
+
if (!message || !channel) return false;
|
|
164
|
+
const channelKeys = [channel.id, channel.type, ...(channel.topicChannels || [])].filter(Boolean);
|
|
165
|
+
if (message.channel && channelKeys.includes(message.channel)) return true;
|
|
166
|
+
const payloadChannel = message.payload?.channel;
|
|
167
|
+
if (payloadChannel && typeof payloadChannel === "object") {
|
|
168
|
+
const payloadKeys = [payloadChannel.agentId, payloadChannel.provider, payloadChannel.accountId].filter(Boolean);
|
|
169
|
+
if (payloadKeys.includes(channel.id) || payloadKeys.includes(channel.type) || payloadKeys.includes(channel.accountId)) return true;
|
|
170
|
+
}
|
|
171
|
+
if (channel.agentId && (message.from === channel.agentId || message.to === channel.agentId)) return true;
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function activityItem(input) {
|
|
176
|
+
return {
|
|
177
|
+
id: input.id,
|
|
178
|
+
kind: input.kind || "state",
|
|
179
|
+
ts: Number(input.ts) || 0,
|
|
180
|
+
icon: input.icon || "ti-activity",
|
|
181
|
+
title: input.title || "Activity",
|
|
182
|
+
body: input.body || "",
|
|
183
|
+
meta: input.meta || "",
|
|
184
|
+
view: input.view || "",
|
|
185
|
+
peer: input.peer || "",
|
|
186
|
+
clientId: input.clientId || "",
|
|
187
|
+
messageId: input.messageId,
|
|
188
|
+
pairId: input.pairId,
|
|
189
|
+
taskId: input.taskId,
|
|
190
|
+
agentId: input.agentId,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function emptyAttention() {
|
|
195
|
+
return {
|
|
196
|
+
unread: 0,
|
|
197
|
+
agentQuestion: false,
|
|
198
|
+
pendingPairInvite: false,
|
|
199
|
+
claimableTasks: 0,
|
|
200
|
+
total: 0,
|
|
201
|
+
score: 0,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function safeFilename(value) {
|
|
206
|
+
return String(value || "export").replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "export";
|
|
207
|
+
}
|
package/public/index.html
CHANGED
|
@@ -34,8 +34,6 @@
|
|
|
34
34
|
|
|
35
35
|
.ar-main { height: 100vh; }
|
|
36
36
|
|
|
37
|
-
.view-fit { display: flex; flex-direction: column; height: calc(100vh - 2rem); }
|
|
38
|
-
|
|
39
37
|
.status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
|
40
38
|
.status-dot.online { background: var(--tblr-success); box-shadow: 0 0 6px var(--tblr-success); }
|
|
41
39
|
.status-dot.online.not-ready { animation: pulse-dot 1.5s ease-in-out infinite; }
|
|
@@ -183,7 +181,7 @@
|
|
|
183
181
|
.fade-in { animation: fadeIn 0.2s ease-in; }
|
|
184
182
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
185
183
|
|
|
186
|
-
.stat-card
|
|
184
|
+
i.stat-card { font-size: 32px; opacity: 0.3; }
|
|
187
185
|
|
|
188
186
|
@media (max-width: 768px) {
|
|
189
187
|
.ar-sidebar { display: none; }
|
|
@@ -219,13 +217,11 @@
|
|
|
219
217
|
</a>
|
|
220
218
|
<a href="#" class="nav-link" :class="{ active: view === 'orchestrators' }" @click.prevent="switchView('orchestrators')">
|
|
221
219
|
<i class="ti ti-server-2"></i>Orchestrators
|
|
222
|
-
<span class="
|
|
223
|
-
<span class="badge bg-success text-white" x-show="orchestrators.filter(o => o.status === 'online').length > 0" x-text="orchestrators.filter(o => o.status === 'online').length"></span>
|
|
224
|
-
</span>
|
|
220
|
+
<span class="badge bg-success text-white ms-auto" x-show="onlineOrchestratorCount > 0" x-text="onlineOrchestratorCount"></span>
|
|
225
221
|
</a>
|
|
226
222
|
<a href="#" class="nav-link" :class="{ active: view === 'channels' }" @click.prevent="switchView('channels')">
|
|
227
223
|
<i class="ti ti-messages"></i>Channels
|
|
228
|
-
<span class="badge bg-success text-white ms-auto" x-show="
|
|
224
|
+
<span class="badge bg-success text-white ms-auto" x-show="readyChannelCount > 0" x-text="readyChannelCount"></span>
|
|
229
225
|
</a>
|
|
230
226
|
<a href="#" class="nav-link" :class="{ active: view === 'connectors' }" @click.prevent="switchView('connectors')">
|
|
231
227
|
<i class="ti ti-plug"></i>Connectors
|
|
@@ -297,34 +293,34 @@
|
|
|
297
293
|
<span class="form-check-label small">Auto refresh</span>
|
|
298
294
|
</label>
|
|
299
295
|
</div>
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
296
|
+
<div class="text-muted small" x-show="stats.version" x-text="'v' + stats.version"></div>
|
|
297
|
+
</div>
|
|
298
|
+
</aside>
|
|
303
299
|
|
|
304
300
|
<!-- Mobile nav -->
|
|
305
301
|
<div class="mobile-nav d-none border-bottom p-2 gap-1 position-fixed top-0 w-100 bg-dark" style="z-index:50">
|
|
306
|
-
<template x-for="v in ['overview','agents','channels','connectors','integrations','inbox','activity','pairs','messages','work','tasks','analytics']">
|
|
302
|
+
<template x-for="v in ['overview','agents','orchestrators','channels','connectors','integrations','inbox','activity','pairs','messages','work','tasks','analytics']">
|
|
307
303
|
<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>
|
|
308
304
|
</template>
|
|
309
305
|
</div>
|
|
310
306
|
|
|
311
307
|
<!-- Main content -->
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
308
|
+
<main class="ar-main flex-grow-1 overflow-auto">
|
|
309
|
+
<div class="container-xl py-3">
|
|
310
|
+
<template x-if="authNeeded">
|
|
311
|
+
<div class="alert alert-warning d-flex align-items-center gap-2 mb-3">
|
|
312
|
+
<i class="ti ti-lock"></i>
|
|
313
|
+
<input
|
|
314
|
+
type="password"
|
|
315
|
+
class="form-control form-control-sm"
|
|
316
|
+
style="max-width: 320px"
|
|
317
|
+
placeholder="Agent Relay token"
|
|
318
|
+
x-model="authToken"
|
|
319
|
+
@keydown.enter="saveTokenAndRefresh()"
|
|
320
|
+
>
|
|
321
|
+
<button class="btn btn-sm btn-warning" @click="saveTokenAndRefresh()">Unlock</button>
|
|
322
|
+
</div>
|
|
323
|
+
</template>
|
|
328
324
|
|
|
329
325
|
<!-- ==================== OVERVIEW ==================== -->
|
|
330
326
|
<div x-show="view === 'overview'" x-cloak class="fade-in">
|
|
@@ -339,8 +335,8 @@
|
|
|
339
335
|
<div class="card-body">
|
|
340
336
|
<div class="d-flex align-items-center">
|
|
341
337
|
<div>
|
|
342
|
-
<div class="text-secondary small">
|
|
343
|
-
<div class="h1 mb-0" x-text="
|
|
338
|
+
<div class="text-secondary small">Agents</div>
|
|
339
|
+
<div class="h1 mb-0" x-text="onlineCount"></div>
|
|
344
340
|
</div>
|
|
345
341
|
<i class="ti ti-robot ms-auto stat-card"></i>
|
|
346
342
|
</div>
|
|
@@ -352,10 +348,10 @@
|
|
|
352
348
|
<div class="card-body">
|
|
353
349
|
<div class="d-flex align-items-center">
|
|
354
350
|
<div>
|
|
355
|
-
<div class="text-secondary small">
|
|
356
|
-
<div class="h1 mb-0 text-
|
|
351
|
+
<div class="text-secondary small">Busy</div>
|
|
352
|
+
<div class="h1 mb-0 text-warning" x-text="busyAgentCount"></div>
|
|
357
353
|
</div>
|
|
358
|
-
<i class="ti ti-
|
|
354
|
+
<i class="ti ti-activity ms-auto stat-card"></i>
|
|
359
355
|
</div>
|
|
360
356
|
</div>
|
|
361
357
|
</div>
|
|
@@ -366,7 +362,7 @@
|
|
|
366
362
|
<div class="d-flex align-items-center">
|
|
367
363
|
<div>
|
|
368
364
|
<div class="text-secondary small">Messages (24h)</div>
|
|
369
|
-
|
|
365
|
+
<div class="h1 mb-0 text-info" x-text="stats.messagesLast24h ?? 0"></div>
|
|
370
366
|
</div>
|
|
371
367
|
<i class="ti ti-mail ms-auto stat-card"></i>
|
|
372
368
|
</div>
|
|
@@ -679,10 +675,10 @@
|
|
|
679
675
|
<div x-show="view === 'orchestrators'" x-cloak class="fade-in">
|
|
680
676
|
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
|
681
677
|
<h2 class="page-title mb-0">Orchestrators</h2>
|
|
682
|
-
<span class="badge bg-success-lt" x-show="
|
|
683
|
-
x-text="
|
|
678
|
+
<span class="badge bg-success-lt" x-show="onlineOrchestratorCount > 0"
|
|
679
|
+
x-text="onlineOrchestratorCount + ' online'"></span>
|
|
684
680
|
<div class="ms-auto">
|
|
685
|
-
<button class="btn btn-sm btn-primary" @click="openOrchestratorSpawn()" :disabled="
|
|
681
|
+
<button class="btn btn-sm btn-primary" @click="openOrchestratorSpawn()" :disabled="onlineOrchestratorCount === 0">
|
|
686
682
|
<i class="ti ti-plus me-1"></i>Spawn Agent
|
|
687
683
|
</button>
|
|
688
684
|
</div>
|
|
@@ -700,14 +696,30 @@
|
|
|
700
696
|
<h3 class="mb-0" x-text="orch.hostname"></h3>
|
|
701
697
|
<small class="text-secondary" x-text="orch.id"></small>
|
|
702
698
|
</div>
|
|
703
|
-
<
|
|
699
|
+
<div class="ms-auto d-flex align-items-center gap-1">
|
|
700
|
+
<span class="badge" :class="orchestratorHealthClass(orch)" x-text="orchestratorHealthLabel(orch)"></span>
|
|
701
|
+
<span class="badge" :class="orch.status === 'online' ? 'bg-success' : 'bg-secondary'" x-text="orch.status"></span>
|
|
702
|
+
</div>
|
|
704
703
|
</div>
|
|
705
704
|
|
|
706
|
-
<div class="d-flex gap-3 mb-3 text-secondary" style="font-size: 13px">
|
|
707
|
-
<span><i class="ti ti-folder me-1"></i><span x-text="orch.baseDir"></span></span>
|
|
705
|
+
<div class="d-flex gap-3 mb-3 text-secondary flex-wrap min-width-0" style="font-size: 13px">
|
|
706
|
+
<span class="text-truncate"><i class="ti ti-folder me-1"></i><span x-text="orch.baseDir"></span></span>
|
|
708
707
|
<span><i class="ti ti-key me-1"></i><span x-text="(orch.envKeys?.length || 0) + ' env vars'"></span></span>
|
|
708
|
+
<span><i class="ti ti-package me-1"></i><span x-text="orch.version || 'unknown'"></span></span>
|
|
709
709
|
</div>
|
|
710
710
|
|
|
711
|
+
<template x-if="orch.health?.issues?.length">
|
|
712
|
+
<div class="alert alert-warning py-2 px-3 mb-3" :class="orch.health.status === 'error' ? 'alert-danger' : 'alert-warning'">
|
|
713
|
+
<div class="d-flex align-items-start gap-2">
|
|
714
|
+
<i class="ti ti-alert-triangle mt-1"></i>
|
|
715
|
+
<div>
|
|
716
|
+
<div x-text="orch.health.issues[0].detail"></div>
|
|
717
|
+
<small x-show="orch.health.restartRequired">Restart required after upgrade.</small>
|
|
718
|
+
</div>
|
|
719
|
+
</div>
|
|
720
|
+
</div>
|
|
721
|
+
</template>
|
|
722
|
+
|
|
711
723
|
<div class="d-flex gap-1 mb-3">
|
|
712
724
|
<template x-for="provider in orch.providers" :key="provider">
|
|
713
725
|
<span class="badge" :class="provider === 'claude' ? 'bg-orange-lt' : 'bg-blue-lt'" x-text="provider"></span>
|
|
@@ -859,6 +871,8 @@ agent-relay-orchestrator</pre>
|
|
|
859
871
|
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
|
860
872
|
<h2 class="page-title mb-0">Channels</h2>
|
|
861
873
|
<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>
|
|
874
|
+
<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>
|
|
875
|
+
<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
876
|
<div class="ms-auto d-flex gap-2 align-items-center">
|
|
863
877
|
<button class="btn btn-sm btn-ghost-secondary" @click="fetchChannels()">
|
|
864
878
|
<i class="ti ti-refresh"></i>
|
|
@@ -887,6 +901,12 @@ agent-relay-orchestrator</pre>
|
|
|
887
901
|
<span class="badge bg-cyan-lt" x-text="channel.direction"></span>
|
|
888
902
|
<span class="badge bg-secondary-lt" x-text="displayTarget(channel.target || channel.agentId)"></span>
|
|
889
903
|
</div>
|
|
904
|
+
<div class="alert py-2 px-2 mt-2 mb-0"
|
|
905
|
+
:class="channel.targetHealth?.status === 'error' ? 'alert-danger' : 'alert-warning'"
|
|
906
|
+
x-show="channel.targetHealth && channel.targetHealth.status !== 'ok'">
|
|
907
|
+
<i class="ti me-1" :class="channel.targetHealth?.status === 'error' ? 'ti-alert-triangle' : 'ti-alert-circle'"></i>
|
|
908
|
+
<span x-text="channel.targetHealth?.detail"></span>
|
|
909
|
+
</div>
|
|
890
910
|
<div class="d-flex gap-1 mt-2 flex-wrap" x-show="channel.capabilities?.length">
|
|
891
911
|
<template x-for="capability in (channel.capabilities || [])" :key="capability">
|
|
892
912
|
<span class="badge bg-secondary-lt" x-text="capability"></span>
|
|
@@ -1809,7 +1829,7 @@ agent-relay-orchestrator</pre>
|
|
|
1809
1829
|
</div>
|
|
1810
1830
|
|
|
1811
1831
|
</div>
|
|
1812
|
-
|
|
1832
|
+
</main>
|
|
1813
1833
|
|
|
1814
1834
|
<!-- ==================== COMMAND PALETTE ==================== -->
|
|
1815
1835
|
<div class="command-palette" x-show="commandPaletteOpen" x-cloak @click.self="closeCommandPalette()">
|
|
@@ -2559,8 +2579,8 @@ agent-relay-orchestrator</pre>
|
|
|
2559
2579
|
|
|
2560
2580
|
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script>
|
|
2561
2581
|
<script src="https://cdn.jsdelivr.net/npm/apexcharts@latest/dist/apexcharts.min.js"></script>
|
|
2582
|
+
<script type="module" src="dashboard/app.js"></script>
|
|
2562
2583
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
|
2563
|
-
<script src="dashboard.js"></script>
|
|
2564
2584
|
<script>
|
|
2565
2585
|
if ("serviceWorker" in navigator) {
|
|
2566
2586
|
window.addEventListener("load", () => {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
type Orchestrator = {
|
|
3
|
+
id: string;
|
|
4
|
+
status: string;
|
|
5
|
+
providers: string[];
|
|
6
|
+
baseDir: string;
|
|
7
|
+
managedAgents?: Array<{ label?: string; tmuxSession: string }>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type Agent = {
|
|
11
|
+
id: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
status: string;
|
|
14
|
+
ready: boolean;
|
|
15
|
+
tags?: string[];
|
|
16
|
+
meta?: { cwd?: string; provider?: string };
|
|
17
|
+
createdAt?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
let relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
22
|
+
let orchestratorId = process.env.AGENT_RELAY_SMOKE_ORCHESTRATOR || "";
|
|
23
|
+
let cwd = process.env.AGENT_RELAY_SMOKE_CWD || process.cwd();
|
|
24
|
+
let timeoutMs = Number(process.env.AGENT_RELAY_SMOKE_TIMEOUT_MS || 90_000);
|
|
25
|
+
let providers = (process.env.AGENT_RELAY_SMOKE_PROVIDERS || "codex,claude").split(",").filter(Boolean);
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < args.length; i++) {
|
|
28
|
+
const arg = args[i]!;
|
|
29
|
+
if (arg === "--relay-url" && args[i + 1]) relayUrl = args[++i]!;
|
|
30
|
+
else if (arg === "--orchestrator" && args[i + 1]) orchestratorId = args[++i]!;
|
|
31
|
+
else if (arg === "--cwd" && args[i + 1]) cwd = args[++i]!;
|
|
32
|
+
else if (arg === "--timeout" && args[i + 1]) timeoutMs = Number(args[++i]);
|
|
33
|
+
else if (arg === "--providers" && args[i + 1]) providers = args[++i]!.split(",").map((p) => p.trim()).filter(Boolean);
|
|
34
|
+
else throw new Error(`Unknown option: ${arg}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
relayUrl = relayUrl.replace(/\/+$/, "");
|
|
38
|
+
|
|
39
|
+
function headers(): Record<string, string> {
|
|
40
|
+
const h: Record<string, string> = { "Content-Type": "application/json" };
|
|
41
|
+
if (process.env.AGENT_RELAY_TOKEN) h["X-Agent-Relay-Token"] = process.env.AGENT_RELAY_TOKEN;
|
|
42
|
+
return h;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
46
|
+
const res = await fetch(`${relayUrl}/api${path}`, {
|
|
47
|
+
method,
|
|
48
|
+
headers: headers(),
|
|
49
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) throw new Error(`${method} ${path} failed: ${res.status} ${await res.text()}`);
|
|
52
|
+
return await res.json() as T;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function apiOptional<T>(method: string, path: string, body?: unknown): Promise<T | null> {
|
|
56
|
+
const res = await fetch(`${relayUrl}/api${path}`, {
|
|
57
|
+
method,
|
|
58
|
+
headers: headers(),
|
|
59
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
60
|
+
});
|
|
61
|
+
if (res.status === 404) return null;
|
|
62
|
+
if (!res.ok) throw new Error(`${method} ${path} failed: ${res.status} ${await res.text()}`);
|
|
63
|
+
if (res.status === 204) return null;
|
|
64
|
+
return await res.json() as T;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function waitFor<T>(label: string, fn: () => Promise<T | null>): Promise<T> {
|
|
68
|
+
const deadline = Date.now() + timeoutMs;
|
|
69
|
+
while (Date.now() < deadline) {
|
|
70
|
+
const result = await fn();
|
|
71
|
+
if (result) return result;
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`${label} timed out after ${timeoutMs}ms`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findSpawnedAgent(agents: Agent[], provider: string, label: string, startedAt: number): Agent | null {
|
|
78
|
+
return agents
|
|
79
|
+
.filter((agent) => agent.ready && agent.status !== "offline")
|
|
80
|
+
.filter((agent) => agent.label === label)
|
|
81
|
+
.filter((agent) => agent.meta?.cwd === cwd)
|
|
82
|
+
.filter((agent) => (agent.tags || []).includes(provider))
|
|
83
|
+
.filter((agent) => (agent.tags || []).includes("dashboard-spawned"))
|
|
84
|
+
.filter((agent) => !agent.createdAt || agent.createdAt >= startedAt - 5_000)
|
|
85
|
+
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))[0] || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const orchestrators = await api<Orchestrator[]>("GET", "/orchestrators");
|
|
89
|
+
const orchestrator = orchestratorId
|
|
90
|
+
? orchestrators.find((orch) => orch.id === orchestratorId)
|
|
91
|
+
: orchestrators.find((orch) => orch.status === "online");
|
|
92
|
+
if (!orchestrator) throw new Error(orchestratorId ? `orchestrator not found: ${orchestratorId}` : "no online orchestrator found");
|
|
93
|
+
if (orchestrator.status !== "online") throw new Error(`orchestrator ${orchestrator.id} is ${orchestrator.status}`);
|
|
94
|
+
|
|
95
|
+
for (const provider of providers) {
|
|
96
|
+
if (!orchestrator.providers.includes(provider)) {
|
|
97
|
+
console.log(`skip ${provider}: orchestrator ${orchestrator.id} does not support it`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const label = `smoke-${provider}-${Date.now()}`;
|
|
102
|
+
const startedAt = Date.now();
|
|
103
|
+
console.log(`spawn ${provider} via ${orchestrator.id}`);
|
|
104
|
+
await api("POST", `/orchestrators/${encodeURIComponent(orchestrator.id)}/spawn`, {
|
|
105
|
+
provider,
|
|
106
|
+
cwd,
|
|
107
|
+
label,
|
|
108
|
+
approvalMode: "guarded",
|
|
109
|
+
...(provider === "claude" ? { prompt: "Agent Relay spawn smoke. Register with relay and wait for shutdown." } : {}),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const agent = await waitFor(`waiting for ${provider} agent registration`, async () => {
|
|
113
|
+
const agents = await api<Agent[]>("GET", "/agents");
|
|
114
|
+
return findSpawnedAgent(agents, provider, label, startedAt);
|
|
115
|
+
});
|
|
116
|
+
console.log(`registered ${provider}: ${agent.id}`);
|
|
117
|
+
|
|
118
|
+
const managed = await waitFor(`waiting for ${provider} managed session`, async () => {
|
|
119
|
+
const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
|
|
120
|
+
return latest?.managedAgents?.find((entry) => entry.label === label || entry.tmuxSession.includes(label)) || null;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await api("POST", `/orchestrators/${encodeURIComponent(orchestrator.id)}/actions`, {
|
|
124
|
+
action: "shutdown",
|
|
125
|
+
agentId: managed.tmuxSession,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await waitFor(`waiting for ${provider} managed session shutdown`, async () => {
|
|
129
|
+
const latest = (await api<Orchestrator[]>("GET", "/orchestrators")).find((orch) => orch.id === orchestrator.id);
|
|
130
|
+
const stillManaged = latest?.managedAgents?.some((managed) => managed.label === label || managed.tmuxSession.includes(label));
|
|
131
|
+
return stillManaged ? null : true;
|
|
132
|
+
});
|
|
133
|
+
await waitFor(`waiting for ${provider} agent cleanup`, async () => {
|
|
134
|
+
const current = await apiOptional<Agent>("GET", `/agents/${encodeURIComponent(agent.id)}`);
|
|
135
|
+
return current ? null : true;
|
|
136
|
+
});
|
|
137
|
+
console.log(`shutdown ${provider}: ${label}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log("spawn smoke passed");
|
package/src/cli.ts
CHANGED
|
@@ -31,7 +31,7 @@ agent-relay ${VERSION}
|
|
|
31
31
|
Usage:
|
|
32
32
|
agent-relay [start]
|
|
33
33
|
agent-relay setup [--yes] [--dry-run] [--force] [--env-file PATH] [--host HOST] [--port PORT] [--db-path PATH] [--token TOKEN|--no-token]
|
|
34
|
-
agent-relay upgrade [--dry-run] [--version VERSION] [--providers auto|all|codex|claude] [--no-restart] [--yes]
|
|
34
|
+
agent-relay upgrade [--dry-run] [--version VERSION] [--providers auto|all|codex|claude|orchestrator] [--no-restart] [--yes]
|
|
35
35
|
agent-relay setup upgrade [same options as upgrade]
|
|
36
36
|
agent-relay daemon <install|uninstall|start|stop|restart|enable|disable|status|logs> [options]
|
|
37
37
|
agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
|
|
@@ -74,7 +74,7 @@ Daemon options:
|
|
|
74
74
|
|
|
75
75
|
Upgrade options:
|
|
76
76
|
--version VERSION Target version (default: latest published server version)
|
|
77
|
-
--providers LIST Provider integrations to upgrade: auto, all, codex, claude
|
|
77
|
+
--providers LIST Provider integrations to upgrade: auto, all, codex, claude, orchestrator
|
|
78
78
|
--no-restart Do not restart agent-relay.service
|
|
79
79
|
--dry-run Print detected state and planned commands
|
|
80
80
|
--yes Skip confirmation prompts
|
|
@@ -158,6 +158,7 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
|
|
|
158
158
|
} else if (arg === "--provider" && i + 1 < args.length) providers.push(parseUpgradeProvider(args[++i]!));
|
|
159
159
|
else if (arg === "--codex") providers.push("codex");
|
|
160
160
|
else if (arg === "--claude") providers.push("claude");
|
|
161
|
+
else if (arg === "--orchestrator") providers.push("orchestrator");
|
|
161
162
|
else if (arg === "--all") providers.push("all");
|
|
162
163
|
else if (arg === "--dry-run") dryRun = true;
|
|
163
164
|
else if (arg === "--no-restart") noRestart = true;
|
|
@@ -200,8 +201,8 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
|
|
|
200
201
|
}
|
|
201
202
|
|
|
202
203
|
function parseUpgradeProvider(value: string): UpgradeProvider {
|
|
203
|
-
if (value === "auto" || value === "all" || value === "codex" || value === "claude") return value;
|
|
204
|
-
throw new Error(`Unknown upgrade provider "${value}". Expected auto, all, codex, or
|
|
204
|
+
if (value === "auto" || value === "all" || value === "codex" || value === "claude" || value === "orchestrator") return value;
|
|
205
|
+
throw new Error(`Unknown upgrade provider "${value}". Expected auto, all, codex, claude, or orchestrator.`);
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
async function handleSlashOrPairCommand(command: string, args: string[]): Promise<void> {
|
package/src/config.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8"));
|
|
9
9
|
export const VERSION: string = pkg.version;
|
|
10
|
+
export const ORCHESTRATOR_PROTOCOL_VERSION = 2;
|
|
10
11
|
|
|
11
12
|
export const DAY_MS = 86_400_000;
|
|
12
13
|
function envPositiveInt(name: string, fallback: number): number {
|