agent-relay-server 0.6.1 → 0.7.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 +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 +48 -36
- package/scripts/orchestrator-spawn-smoke.ts +140 -0
- package/src/cli.ts +5 -4
- package/src/config.ts +1 -0
- package/src/db.ts +52 -4
- package/src/routes.ts +74 -48
- package/src/types.ts +16 -0
- package/src/upgrade.ts +80 -7
- package/public/dashboard.js +0 -3032
|
@@ -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">
|
|
@@ -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>
|
|
@@ -1817,7 +1829,7 @@ agent-relay-orchestrator</pre>
|
|
|
1817
1829
|
</div>
|
|
1818
1830
|
|
|
1819
1831
|
</div>
|
|
1820
|
-
|
|
1832
|
+
</main>
|
|
1821
1833
|
|
|
1822
1834
|
<!-- ==================== COMMAND PALETTE ==================== -->
|
|
1823
1835
|
<div class="command-palette" x-show="commandPaletteOpen" x-cloak @click.self="closeCommandPalette()">
|
|
@@ -2567,8 +2579,8 @@ agent-relay-orchestrator</pre>
|
|
|
2567
2579
|
|
|
2568
2580
|
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script>
|
|
2569
2581
|
<script src="https://cdn.jsdelivr.net/npm/apexcharts@latest/dist/apexcharts.min.js"></script>
|
|
2582
|
+
<script type="module" src="dashboard/app.js"></script>
|
|
2570
2583
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
|
2571
|
-
<script src="dashboard.js"></script>
|
|
2572
2584
|
<script>
|
|
2573
2585
|
if ("serviceWorker" in navigator) {
|
|
2574
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 {
|
package/src/db.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { VERSION } from "./config.ts";
|
|
3
|
+
import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "./config.ts";
|
|
4
4
|
import type {
|
|
5
5
|
AgentCard,
|
|
6
6
|
ActivityEvent,
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
ManagedAgent,
|
|
19
19
|
Message,
|
|
20
20
|
Orchestrator,
|
|
21
|
+
OrchestratorHealth,
|
|
21
22
|
OrchestratorStatus,
|
|
22
23
|
PairActionInput,
|
|
23
24
|
PairMessageInput,
|
|
@@ -2412,6 +2413,15 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
2412
2413
|
// --- Orchestrators ---
|
|
2413
2414
|
|
|
2414
2415
|
function rowToOrchestrator(row: any): Orchestrator {
|
|
2416
|
+
const meta = parseJson<Record<string, unknown>>(row.meta, {});
|
|
2417
|
+
const version = stringValue(meta.version);
|
|
2418
|
+
const gitSha = stringValue(meta.gitSha);
|
|
2419
|
+
const protocolRaw = meta.protocolVersion;
|
|
2420
|
+
const protocolVersion = typeof protocolRaw === "number"
|
|
2421
|
+
? protocolRaw
|
|
2422
|
+
: typeof protocolRaw === "string" && protocolRaw.trim() !== ""
|
|
2423
|
+
? Number(protocolRaw)
|
|
2424
|
+
: undefined;
|
|
2415
2425
|
return {
|
|
2416
2426
|
id: row.id,
|
|
2417
2427
|
hostname: row.hostname,
|
|
@@ -2420,13 +2430,40 @@ function rowToOrchestrator(row: any): Orchestrator {
|
|
|
2420
2430
|
providers: parseJson<SpawnProvider[]>(row.providers, []),
|
|
2421
2431
|
baseDir: row.base_dir,
|
|
2422
2432
|
envKeys: parseJson<string[]>(row.env_keys, []),
|
|
2423
|
-
|
|
2433
|
+
...(version ? { version } : {}),
|
|
2434
|
+
...(Number.isFinite(protocolVersion) ? { protocolVersion } : {}),
|
|
2435
|
+
...(gitSha ? { gitSha } : {}),
|
|
2436
|
+
health: orchestratorHealth(version, Number.isFinite(protocolVersion) ? protocolVersion : undefined),
|
|
2437
|
+
meta,
|
|
2424
2438
|
managedAgents: parseJson<ManagedAgent[]>(row.managed_agents, []),
|
|
2425
2439
|
lastSeen: row.last_seen,
|
|
2426
2440
|
createdAt: row.created_at,
|
|
2427
2441
|
};
|
|
2428
2442
|
}
|
|
2429
2443
|
|
|
2444
|
+
function orchestratorHealth(version: string | undefined, protocolVersion: number | undefined): OrchestratorHealth {
|
|
2445
|
+
const issues: OrchestratorHealth["issues"] = [];
|
|
2446
|
+
if (!version) {
|
|
2447
|
+
issues.push({ code: "missing-version", detail: "Orchestrator did not report a version; restart or upgrade it." });
|
|
2448
|
+
} else if (version !== VERSION) {
|
|
2449
|
+
issues.push({ code: "outdated", detail: `Orchestrator ${version} does not match server ${VERSION}.` });
|
|
2450
|
+
}
|
|
2451
|
+
if (protocolVersion !== ORCHESTRATOR_PROTOCOL_VERSION) {
|
|
2452
|
+
issues.push({
|
|
2453
|
+
code: "protocol-mismatch",
|
|
2454
|
+
detail: `Orchestrator protocol ${protocolVersion ?? "unknown"} does not match server protocol ${ORCHESTRATOR_PROTOCOL_VERSION}.`,
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
if (issues.length > 0) {
|
|
2458
|
+
issues.push({ code: "restart-required", detail: "Restart the orchestrator after upgrading Agent Relay." });
|
|
2459
|
+
}
|
|
2460
|
+
return {
|
|
2461
|
+
status: issues.some((issue) => issue.code === "protocol-mismatch") ? "error" : issues.length > 0 ? "warn" : "ok",
|
|
2462
|
+
restartRequired: issues.length > 0,
|
|
2463
|
+
issues,
|
|
2464
|
+
};
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2430
2467
|
export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrator {
|
|
2431
2468
|
const now = Date.now();
|
|
2432
2469
|
const agentId = `orchestrator-${input.id}`;
|
|
@@ -2449,7 +2486,12 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
|
|
|
2449
2486
|
$providers: JSON.stringify(input.providers),
|
|
2450
2487
|
$baseDir: input.baseDir,
|
|
2451
2488
|
$envKeys: JSON.stringify(input.envKeys ?? []),
|
|
2452
|
-
$meta: JSON.stringify(
|
|
2489
|
+
$meta: JSON.stringify({
|
|
2490
|
+
...(input.meta ?? {}),
|
|
2491
|
+
...(input.version ? { version: input.version } : {}),
|
|
2492
|
+
...(input.protocolVersion !== undefined ? { protocolVersion: input.protocolVersion } : {}),
|
|
2493
|
+
...(input.gitSha ? { gitSha: input.gitSha } : {}),
|
|
2494
|
+
}),
|
|
2453
2495
|
$now: now,
|
|
2454
2496
|
});
|
|
2455
2497
|
|
|
@@ -2461,7 +2503,13 @@ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrat
|
|
|
2461
2503
|
machine: input.hostname,
|
|
2462
2504
|
capabilities: ["orchestrator", "spawn"],
|
|
2463
2505
|
status: "online",
|
|
2464
|
-
meta: {
|
|
2506
|
+
meta: {
|
|
2507
|
+
orchestratorId: input.id,
|
|
2508
|
+
builtin: true,
|
|
2509
|
+
...(input.version ? { version: input.version } : {}),
|
|
2510
|
+
...(input.protocolVersion !== undefined ? { protocolVersion: input.protocolVersion } : {}),
|
|
2511
|
+
...(input.gitSha ? { gitSha: input.gitSha } : {}),
|
|
2512
|
+
},
|
|
2465
2513
|
});
|
|
2466
2514
|
|
|
2467
2515
|
return getOrchestrator(input.id)!;
|