agent-relay-server 0.4.39 → 0.5.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 +1 -1
- package/public/dashboard.js +128 -1
- package/public/index.html +185 -0
- package/src/db.ts +131 -0
- package/src/routes.ts +263 -5
- package/src/sse.ts +15 -1
- package/src/types.ts +60 -0
package/package.json
CHANGED
package/public/dashboard.js
CHANGED
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
|
|
55
55
|
agents: [],
|
|
56
56
|
agentsById: {},
|
|
57
|
+
orchestrators: [],
|
|
57
58
|
pairs: [],
|
|
58
59
|
messages: [],
|
|
59
60
|
tasks: [],
|
|
@@ -85,6 +86,14 @@
|
|
|
85
86
|
replyTo: null,
|
|
86
87
|
composeOpen: false,
|
|
87
88
|
agentSpawnOpen: false,
|
|
89
|
+
orchestratorSpawnOpen: false,
|
|
90
|
+
spawnOrchId: "",
|
|
91
|
+
spawnProvider: "claude",
|
|
92
|
+
spawnCwd: "",
|
|
93
|
+
spawnLabel: "",
|
|
94
|
+
spawnApproval: "guarded",
|
|
95
|
+
spawnPrompt: "",
|
|
96
|
+
spawnDirListing: null,
|
|
88
97
|
agentDirectoryBrowser: { open: false, loading: false, path: "", parent: "", home: "", cwd: "", entries: [], error: "" },
|
|
89
98
|
pairInviteOpen: false,
|
|
90
99
|
pairMessageOpen: false,
|
|
@@ -281,6 +290,8 @@
|
|
|
281
290
|
es.addEventListener("message.claimed", (event) => handleMessageClaimed(this, parseEventData(event)));
|
|
282
291
|
es.addEventListener("message.claim_released", (event) => handleMessageClaimReleased(this, parseEventData(event)));
|
|
283
292
|
es.addEventListener("message.deleted", (event) => handleMessageDeleted(this, parseEventData(event)));
|
|
293
|
+
es.addEventListener("orchestrator.status", (event) => handleOrchestratorStatus(this, parseEventData(event)));
|
|
294
|
+
es.addEventListener("orchestrator.removed", (event) => handleOrchestratorRemoved(this, parseEventData(event)));
|
|
284
295
|
registerTaskEvents(this, es);
|
|
285
296
|
}
|
|
286
297
|
|
|
@@ -320,6 +331,14 @@
|
|
|
320
331
|
refreshChartsIfVisible(vm);
|
|
321
332
|
}
|
|
322
333
|
|
|
334
|
+
function handleOrchestratorStatus(vm, orch) {
|
|
335
|
+
upsertById(vm.orchestrators, orch);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function handleOrchestratorRemoved(vm, data) {
|
|
339
|
+
vm.orchestrators = vm.orchestrators.filter((o) => o.id !== data.id);
|
|
340
|
+
}
|
|
341
|
+
|
|
323
342
|
function handleMessageClaimed(vm, data) {
|
|
324
343
|
const msg = vm.messages.find((item) => item.id === data.messageId);
|
|
325
344
|
if (!msg) return;
|
|
@@ -374,7 +393,7 @@
|
|
|
374
393
|
},
|
|
375
394
|
|
|
376
395
|
async refresh() {
|
|
377
|
-
await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchChannels(), this.fetchIntegrations(), this.fetchInboxState(), this.fetchActivityEvents()]);
|
|
396
|
+
await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchOrchestrators(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchChannels(), this.fetchIntegrations(), this.fetchInboxState(), this.fetchActivityEvents()]);
|
|
378
397
|
},
|
|
379
398
|
|
|
380
399
|
async refreshLiveData() {
|
|
@@ -407,6 +426,12 @@
|
|
|
407
426
|
} catch {}
|
|
408
427
|
},
|
|
409
428
|
|
|
429
|
+
async fetchOrchestrators() {
|
|
430
|
+
try {
|
|
431
|
+
this.orchestrators = await this.api("GET", "/orchestrators");
|
|
432
|
+
} catch {}
|
|
433
|
+
},
|
|
434
|
+
|
|
410
435
|
async fetchPairs() {
|
|
411
436
|
try {
|
|
412
437
|
let pairs;
|
|
@@ -535,9 +560,15 @@
|
|
|
535
560
|
healthIssues: { get: getHealthIssues },
|
|
536
561
|
healthDiagnostics: { get: getHealthDiagnostics },
|
|
537
562
|
commandPaletteItems: { get: getCommandPaletteItems },
|
|
563
|
+
spawnAvailableProviders: { get: getSpawnAvailableProviders },
|
|
538
564
|
};
|
|
539
565
|
}
|
|
540
566
|
|
|
567
|
+
function getSpawnAvailableProviders() {
|
|
568
|
+
const orch = this.orchestrators.find((o) => o.id === this.spawnOrchId);
|
|
569
|
+
return orch ? orch.providers : ["claude", "codex"];
|
|
570
|
+
}
|
|
571
|
+
|
|
541
572
|
function getOnlineCount() {
|
|
542
573
|
return visibleAgents(this).filter((agent) => agent.status !== "offline").length;
|
|
543
574
|
}
|
|
@@ -1667,6 +1698,12 @@
|
|
|
1667
1698
|
openAgentDirectoryBrowser,
|
|
1668
1699
|
browseAgentDirectory,
|
|
1669
1700
|
selectAgentDirectory,
|
|
1701
|
+
openOrchestratorSpawn,
|
|
1702
|
+
openOrchestratorSpawnFor,
|
|
1703
|
+
browseOrchestratorDirs,
|
|
1704
|
+
submitOrchestratorSpawn,
|
|
1705
|
+
orchestratorAction,
|
|
1706
|
+
deleteOrchestrator,
|
|
1670
1707
|
doSendPairMessage,
|
|
1671
1708
|
doAcceptPair,
|
|
1672
1709
|
doRejectPair,
|
|
@@ -2478,6 +2515,96 @@
|
|
|
2478
2515
|
}
|
|
2479
2516
|
}
|
|
2480
2517
|
|
|
2518
|
+
// --- Orchestrator methods ---
|
|
2519
|
+
|
|
2520
|
+
function openOrchestratorSpawn() {
|
|
2521
|
+
const online = this.orchestrators.filter((o) => o.status === "online");
|
|
2522
|
+
if (online.length === 0) return alert("No orchestrators online");
|
|
2523
|
+
this.spawnOrchId = online[0].id;
|
|
2524
|
+
this.spawnProvider = "claude";
|
|
2525
|
+
this.spawnCwd = "";
|
|
2526
|
+
this.spawnLabel = "";
|
|
2527
|
+
this.spawnApproval = "guarded";
|
|
2528
|
+
this.spawnPrompt = "";
|
|
2529
|
+
this.spawnDirListing = null;
|
|
2530
|
+
this.orchestratorSpawnOpen = true;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
function openOrchestratorSpawnFor(orchId) {
|
|
2534
|
+
this.spawnOrchId = orchId;
|
|
2535
|
+
this.spawnProvider = "claude";
|
|
2536
|
+
this.spawnCwd = "";
|
|
2537
|
+
this.spawnLabel = "";
|
|
2538
|
+
this.spawnApproval = "guarded";
|
|
2539
|
+
this.spawnPrompt = "";
|
|
2540
|
+
this.spawnDirListing = null;
|
|
2541
|
+
this.orchestratorSpawnOpen = true;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
async function browseOrchestratorDirs() {
|
|
2545
|
+
try {
|
|
2546
|
+
const query = this.spawnCwd ? "?path=" + encodeURIComponent(this.spawnCwd) : "";
|
|
2547
|
+
this.spawnDirListing = await this.api("GET", "/agents/spawn/directories" + query);
|
|
2548
|
+
} catch (e) {
|
|
2549
|
+
alert("Directory browse failed: " + e.message);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
async function submitOrchestratorSpawn() {
|
|
2554
|
+
if (!this.spawnOrchId) return alert("Select an orchestrator");
|
|
2555
|
+
try {
|
|
2556
|
+
const payload = {
|
|
2557
|
+
provider: this.spawnProvider,
|
|
2558
|
+
approvalMode: this.spawnApproval,
|
|
2559
|
+
};
|
|
2560
|
+
if (this.spawnCwd) payload.cwd = this.spawnCwd;
|
|
2561
|
+
if (this.spawnLabel) payload.label = this.spawnLabel;
|
|
2562
|
+
if (this.spawnPrompt) payload.prompt = this.spawnPrompt;
|
|
2563
|
+
await this.api("POST", "/orchestrators/" + encodeURIComponent(this.spawnOrchId) + "/spawn", payload);
|
|
2564
|
+
this.orchestratorSpawnOpen = false;
|
|
2565
|
+
this.recordOperatorActivity({
|
|
2566
|
+
title: `${this.spawnProvider} agent spawn requested`,
|
|
2567
|
+
body: this.spawnCwd || "",
|
|
2568
|
+
meta: this.spawnOrchId,
|
|
2569
|
+
icon: "ti-plus",
|
|
2570
|
+
kind: "state",
|
|
2571
|
+
view: "orchestrators",
|
|
2572
|
+
});
|
|
2573
|
+
await Promise.all([this.fetchOrchestrators(), this.fetchActivityEvents()]);
|
|
2574
|
+
} catch (e) {
|
|
2575
|
+
alert("Spawn failed: " + e.message);
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
async function orchestratorAction(orchId, action, agentId) {
|
|
2580
|
+
const label = action === "restart" ? "Restart" : "Shutdown";
|
|
2581
|
+
if (!confirm(`${label} agent "${agentId || "all"}" on orchestrator "${orchId}"?`)) return;
|
|
2582
|
+
try {
|
|
2583
|
+
await this.api("POST", "/orchestrators/" + encodeURIComponent(orchId) + "/actions", { action, agentId });
|
|
2584
|
+
this.recordOperatorActivity({
|
|
2585
|
+
title: `Agent ${action} requested`,
|
|
2586
|
+
body: agentId || "all agents",
|
|
2587
|
+
meta: orchId,
|
|
2588
|
+
icon: action === "restart" ? "ti-refresh" : "ti-power",
|
|
2589
|
+
kind: "state",
|
|
2590
|
+
view: "orchestrators",
|
|
2591
|
+
});
|
|
2592
|
+
await Promise.all([this.fetchOrchestrators(), this.fetchActivityEvents()]);
|
|
2593
|
+
} catch (e) {
|
|
2594
|
+
alert(`${label} failed: ` + e.message);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
async function deleteOrchestrator(orchId) {
|
|
2599
|
+
if (!confirm(`Remove orchestrator "${orchId}"? This will NOT stop its managed agents.`)) return;
|
|
2600
|
+
try {
|
|
2601
|
+
await this.api("DELETE", "/orchestrators/" + encodeURIComponent(orchId));
|
|
2602
|
+
await this.fetchOrchestrators();
|
|
2603
|
+
} catch (e) {
|
|
2604
|
+
alert("Delete failed: " + e.message);
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2481
2608
|
function closePairMessage() {
|
|
2482
2609
|
this.pairMessageOpen = false;
|
|
2483
2610
|
this.pairMessage = { ...DEFAULT_PAIR_MESSAGE };
|
package/public/index.html
CHANGED
|
@@ -210,6 +210,12 @@
|
|
|
210
210
|
<span class="badge bg-success text-white" x-show="onlineCount > 0" x-text="onlineCount"></span>
|
|
211
211
|
</span>
|
|
212
212
|
</a>
|
|
213
|
+
<a href="#" class="nav-link" :class="{ active: view === 'orchestrators' }" @click.prevent="switchView('orchestrators')">
|
|
214
|
+
<i class="ti ti-server-2"></i>Orchestrators
|
|
215
|
+
<span class="ms-auto d-inline-flex gap-1 align-items-center">
|
|
216
|
+
<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>
|
|
217
|
+
</span>
|
|
218
|
+
</a>
|
|
213
219
|
<a href="#" class="nav-link" :class="{ active: view === 'channels' }" @click.prevent="switchView('channels')">
|
|
214
220
|
<i class="ti ti-messages"></i>Channels
|
|
215
221
|
<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>
|
|
@@ -658,6 +664,185 @@
|
|
|
658
664
|
</template>
|
|
659
665
|
</div>
|
|
660
666
|
|
|
667
|
+
<!-- ==================== ORCHESTRATORS ==================== -->
|
|
668
|
+
<div x-show="view === 'orchestrators'" x-cloak class="fade-in">
|
|
669
|
+
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
|
670
|
+
<h2 class="page-title mb-0">Orchestrators</h2>
|
|
671
|
+
<span class="badge bg-success-lt" x-show="orchestrators.filter(o => o.status === 'online').length > 0"
|
|
672
|
+
x-text="orchestrators.filter(o => o.status === 'online').length + ' online'"></span>
|
|
673
|
+
<div class="ms-auto">
|
|
674
|
+
<button class="btn btn-sm btn-primary" @click="openOrchestratorSpawn()" :disabled="orchestrators.filter(o => o.status === 'online').length === 0">
|
|
675
|
+
<i class="ti ti-plus me-1"></i>Spawn Agent
|
|
676
|
+
</button>
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
|
|
680
|
+
<template x-if="orchestrators.length > 0">
|
|
681
|
+
<div class="row g-3">
|
|
682
|
+
<template x-for="orch in orchestrators" :key="orch.id">
|
|
683
|
+
<div class="col-12 col-lg-6">
|
|
684
|
+
<div class="card">
|
|
685
|
+
<div class="card-body">
|
|
686
|
+
<div class="d-flex align-items-center mb-3">
|
|
687
|
+
<i class="ti ti-server-2 me-2" style="font-size: 24px"></i>
|
|
688
|
+
<div>
|
|
689
|
+
<h3 class="mb-0" x-text="orch.hostname"></h3>
|
|
690
|
+
<small class="text-secondary" x-text="orch.id"></small>
|
|
691
|
+
</div>
|
|
692
|
+
<span class="ms-auto badge" :class="orch.status === 'online' ? 'bg-success' : 'bg-secondary'" x-text="orch.status"></span>
|
|
693
|
+
</div>
|
|
694
|
+
|
|
695
|
+
<div class="d-flex gap-3 mb-3 text-secondary" style="font-size: 13px">
|
|
696
|
+
<span><i class="ti ti-folder me-1"></i><span x-text="orch.baseDir"></span></span>
|
|
697
|
+
<span><i class="ti ti-key me-1"></i><span x-text="(orch.envKeys?.length || 0) + ' env vars'"></span></span>
|
|
698
|
+
</div>
|
|
699
|
+
|
|
700
|
+
<div class="d-flex gap-1 mb-3">
|
|
701
|
+
<template x-for="provider in orch.providers" :key="provider">
|
|
702
|
+
<span class="badge" :class="provider === 'claude' ? 'bg-orange-lt' : 'bg-blue-lt'" x-text="provider"></span>
|
|
703
|
+
</template>
|
|
704
|
+
</div>
|
|
705
|
+
|
|
706
|
+
<template x-if="orch.managedAgents?.length > 0">
|
|
707
|
+
<div>
|
|
708
|
+
<h4 class="mb-2" style="font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--tblr-secondary)">
|
|
709
|
+
Managed Agents (<span x-text="orch.managedAgents.length"></span>)
|
|
710
|
+
</h4>
|
|
711
|
+
<div class="list-group list-group-flush">
|
|
712
|
+
<template x-for="agent in orch.managedAgents" :key="agent.tmuxSession">
|
|
713
|
+
<div class="list-group-item px-0 py-2 d-flex align-items-center gap-2">
|
|
714
|
+
<span class="badge" :class="agent.provider === 'claude' ? 'bg-orange-lt' : 'bg-blue-lt'" x-text="agent.provider" style="font-size: 10px"></span>
|
|
715
|
+
<span x-text="agent.label || agent.tmuxSession" style="font-size: 13px"></span>
|
|
716
|
+
<small class="text-secondary" x-text="agent.cwd" style="font-size: 11px"></small>
|
|
717
|
+
<div class="ms-auto d-flex gap-1">
|
|
718
|
+
<button class="btn btn-sm btn-ghost-warning p-1" title="Restart"
|
|
719
|
+
@click="orchestratorAction(orch.id, 'restart', agent.agentId || agent.tmuxSession)">
|
|
720
|
+
<i class="ti ti-refresh" style="font-size: 14px"></i>
|
|
721
|
+
</button>
|
|
722
|
+
<button class="btn btn-sm btn-ghost-danger p-1" title="Shutdown"
|
|
723
|
+
@click="orchestratorAction(orch.id, 'shutdown', agent.agentId || agent.tmuxSession)">
|
|
724
|
+
<i class="ti ti-power" style="font-size: 14px"></i>
|
|
725
|
+
</button>
|
|
726
|
+
</div>
|
|
727
|
+
</div>
|
|
728
|
+
</template>
|
|
729
|
+
</div>
|
|
730
|
+
</div>
|
|
731
|
+
</template>
|
|
732
|
+
<template x-if="!orch.managedAgents?.length">
|
|
733
|
+
<p class="text-secondary mb-0" style="font-size: 13px">No managed agents</p>
|
|
734
|
+
</template>
|
|
735
|
+
|
|
736
|
+
<div class="d-flex gap-1 mt-3">
|
|
737
|
+
<button class="btn btn-sm btn-primary" @click="openOrchestratorSpawnFor(orch.id)" :disabled="orch.status !== 'online'">
|
|
738
|
+
<i class="ti ti-plus me-1"></i>Spawn
|
|
739
|
+
</button>
|
|
740
|
+
<button class="btn btn-sm btn-ghost-danger" @click="deleteOrchestrator(orch.id)">
|
|
741
|
+
<i class="ti ti-trash me-1"></i>Remove
|
|
742
|
+
</button>
|
|
743
|
+
</div>
|
|
744
|
+
</div>
|
|
745
|
+
<div class="card-footer text-secondary" style="font-size: 12px">
|
|
746
|
+
Last seen: <span x-text="timeAgo(orch.lastSeen)"></span>
|
|
747
|
+
</div>
|
|
748
|
+
</div>
|
|
749
|
+
</div>
|
|
750
|
+
</template>
|
|
751
|
+
</div>
|
|
752
|
+
</template>
|
|
753
|
+
|
|
754
|
+
<template x-if="orchestrators.length === 0">
|
|
755
|
+
<div class="card">
|
|
756
|
+
<div class="card-body text-center py-5">
|
|
757
|
+
<i class="ti ti-server-2 mb-3" style="font-size: 48px; color: var(--tblr-secondary)"></i>
|
|
758
|
+
<h3>No orchestrators registered</h3>
|
|
759
|
+
<p class="text-secondary">Install and start <code>agent-relay-orchestrator</code> on each host to enable remote agent spawning.</p>
|
|
760
|
+
<pre class="text-start mx-auto" style="max-width: 500px; font-size: 12px">npm install -g agent-relay-orchestrator
|
|
761
|
+
agent-relay-orchestrator init
|
|
762
|
+
agent-relay-orchestrator</pre>
|
|
763
|
+
</div>
|
|
764
|
+
</div>
|
|
765
|
+
</template>
|
|
766
|
+
</div>
|
|
767
|
+
|
|
768
|
+
<!-- Orchestrator Spawn Modal -->
|
|
769
|
+
<div class="modal" :class="{ show: orchestratorSpawnOpen }" tabindex="-1" :style="orchestratorSpawnOpen ? 'display:block' : 'display:none'" @click.self="orchestratorSpawnOpen = false">
|
|
770
|
+
<div class="modal-dialog modal-dialog-centered">
|
|
771
|
+
<div class="modal-content">
|
|
772
|
+
<div class="modal-header">
|
|
773
|
+
<h5 class="modal-title">Spawn Agent</h5>
|
|
774
|
+
<button type="button" class="btn-close" @click="orchestratorSpawnOpen = false"></button>
|
|
775
|
+
</div>
|
|
776
|
+
<div class="modal-body">
|
|
777
|
+
<div class="mb-3">
|
|
778
|
+
<label class="form-label">Orchestrator</label>
|
|
779
|
+
<select class="form-select" x-model="spawnOrchId">
|
|
780
|
+
<template x-for="orch in orchestrators.filter(o => o.status === 'online')" :key="orch.id">
|
|
781
|
+
<option :value="orch.id" x-text="orch.hostname + ' (' + orch.providers.join(', ') + ')'"></option>
|
|
782
|
+
</template>
|
|
783
|
+
</select>
|
|
784
|
+
</div>
|
|
785
|
+
<div class="mb-3">
|
|
786
|
+
<label class="form-label">Provider</label>
|
|
787
|
+
<select class="form-select" x-model="spawnProvider">
|
|
788
|
+
<template x-for="p in spawnAvailableProviders" :key="p">
|
|
789
|
+
<option :value="p" x-text="p"></option>
|
|
790
|
+
</template>
|
|
791
|
+
</select>
|
|
792
|
+
</div>
|
|
793
|
+
<div class="mb-3">
|
|
794
|
+
<label class="form-label">Working Directory</label>
|
|
795
|
+
<div class="input-group">
|
|
796
|
+
<input type="text" class="form-control" x-model="spawnCwd" placeholder="Leave empty for base directory">
|
|
797
|
+
<button class="btn btn-outline-secondary" @click="browseOrchestratorDirs()" type="button">
|
|
798
|
+
<i class="ti ti-folder"></i>
|
|
799
|
+
</button>
|
|
800
|
+
</div>
|
|
801
|
+
<template x-if="spawnDirListing">
|
|
802
|
+
<div class="border rounded mt-2 p-2" style="max-height: 200px; overflow-y: auto; font-size: 13px">
|
|
803
|
+
<template x-if="spawnDirListing.parent">
|
|
804
|
+
<a href="#" class="d-block py-1 text-secondary" @click.prevent="spawnCwd = spawnDirListing.parent; browseOrchestratorDirs()">
|
|
805
|
+
<i class="ti ti-arrow-up me-1"></i>..
|
|
806
|
+
</a>
|
|
807
|
+
</template>
|
|
808
|
+
<template x-for="entry in spawnDirListing.entries" :key="entry.path">
|
|
809
|
+
<a href="#" class="d-block py-1" @click.prevent="spawnCwd = entry.path; browseOrchestratorDirs()">
|
|
810
|
+
<i class="ti ti-folder me-1"></i><span x-text="entry.name"></span>
|
|
811
|
+
</a>
|
|
812
|
+
</template>
|
|
813
|
+
</div>
|
|
814
|
+
</template>
|
|
815
|
+
</div>
|
|
816
|
+
<div class="mb-3">
|
|
817
|
+
<label class="form-label">Label</label>
|
|
818
|
+
<input type="text" class="form-control" x-model="spawnLabel" placeholder="e.g. backend, reviewer">
|
|
819
|
+
</div>
|
|
820
|
+
<div class="mb-3">
|
|
821
|
+
<label class="form-label">Approval Mode</label>
|
|
822
|
+
<select class="form-select" x-model="spawnApproval">
|
|
823
|
+
<option value="guarded">Guarded (block destructive ops)</option>
|
|
824
|
+
<option value="open">Open (no restrictions)</option>
|
|
825
|
+
<option value="read-only">Read-only (observe only)</option>
|
|
826
|
+
</select>
|
|
827
|
+
</div>
|
|
828
|
+
<template x-if="spawnProvider === 'claude'">
|
|
829
|
+
<div class="mb-3">
|
|
830
|
+
<label class="form-label">Initial Prompt</label>
|
|
831
|
+
<textarea class="form-control" x-model="spawnPrompt" rows="3" placeholder="You are a headless relay agent..."></textarea>
|
|
832
|
+
</div>
|
|
833
|
+
</template>
|
|
834
|
+
</div>
|
|
835
|
+
<div class="modal-footer">
|
|
836
|
+
<button class="btn btn-secondary" @click="orchestratorSpawnOpen = false">Cancel</button>
|
|
837
|
+
<button class="btn btn-primary" @click="submitOrchestratorSpawn()">
|
|
838
|
+
<i class="ti ti-rocket me-1"></i>Spawn
|
|
839
|
+
</button>
|
|
840
|
+
</div>
|
|
841
|
+
</div>
|
|
842
|
+
</div>
|
|
843
|
+
</div>
|
|
844
|
+
<div class="modal-backdrop fade show" x-show="orchestratorSpawnOpen" x-cloak></div>
|
|
845
|
+
|
|
661
846
|
<!-- ==================== CHANNELS ==================== -->
|
|
662
847
|
<div x-show="view === 'channels'" x-cloak class="fade-in">
|
|
663
848
|
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
|
package/src/db.ts
CHANGED
|
@@ -9,15 +9,21 @@ import type {
|
|
|
9
9
|
CreatePairInput,
|
|
10
10
|
HealthCheck,
|
|
11
11
|
HealthReport,
|
|
12
|
+
ManagedAgent,
|
|
12
13
|
Message,
|
|
13
14
|
MessageType,
|
|
15
|
+
Orchestrator,
|
|
16
|
+
OrchestratorStatus,
|
|
14
17
|
PairActionInput,
|
|
15
18
|
PairMessageInput,
|
|
16
19
|
PairSession,
|
|
17
20
|
PairStatus,
|
|
18
21
|
RegisterAgentInput,
|
|
22
|
+
RegisterOrchestratorInput,
|
|
19
23
|
SendMessageInput,
|
|
20
24
|
PollQuery,
|
|
25
|
+
SpawnApprovalMode,
|
|
26
|
+
SpawnProvider,
|
|
21
27
|
Task,
|
|
22
28
|
TaskEvent,
|
|
23
29
|
TaskSeverity,
|
|
@@ -192,6 +198,20 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
192
198
|
);
|
|
193
199
|
CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
|
|
194
200
|
CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
|
|
201
|
+
|
|
202
|
+
CREATE TABLE IF NOT EXISTS orchestrators (
|
|
203
|
+
id TEXT PRIMARY KEY,
|
|
204
|
+
hostname TEXT NOT NULL,
|
|
205
|
+
status TEXT NOT NULL DEFAULT 'online',
|
|
206
|
+
agent_id TEXT NOT NULL,
|
|
207
|
+
providers TEXT NOT NULL DEFAULT '[]',
|
|
208
|
+
base_dir TEXT NOT NULL,
|
|
209
|
+
env_keys TEXT NOT NULL DEFAULT '[]',
|
|
210
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
211
|
+
managed_agents TEXT NOT NULL DEFAULT '[]',
|
|
212
|
+
last_seen INTEGER NOT NULL,
|
|
213
|
+
created_at INTEGER NOT NULL
|
|
214
|
+
);
|
|
195
215
|
`);
|
|
196
216
|
|
|
197
217
|
// Migrations
|
|
@@ -1866,3 +1886,114 @@ export function getHealth(now: number = Date.now()): HealthReport {
|
|
|
1866
1886
|
: "ok";
|
|
1867
1887
|
return { status, version: VERSION, generatedAt: now, checks };
|
|
1868
1888
|
}
|
|
1889
|
+
|
|
1890
|
+
// --- Orchestrators ---
|
|
1891
|
+
|
|
1892
|
+
function rowToOrchestrator(row: any): Orchestrator {
|
|
1893
|
+
return {
|
|
1894
|
+
id: row.id,
|
|
1895
|
+
hostname: row.hostname,
|
|
1896
|
+
status: row.status as OrchestratorStatus,
|
|
1897
|
+
agentId: row.agent_id,
|
|
1898
|
+
providers: parseJson<SpawnProvider[]>(row.providers, []),
|
|
1899
|
+
baseDir: row.base_dir,
|
|
1900
|
+
envKeys: parseJson<string[]>(row.env_keys, []),
|
|
1901
|
+
meta: parseJson(row.meta, {}),
|
|
1902
|
+
managedAgents: parseJson<ManagedAgent[]>(row.managed_agents, []),
|
|
1903
|
+
lastSeen: row.last_seen,
|
|
1904
|
+
createdAt: row.created_at,
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrator {
|
|
1909
|
+
const now = Date.now();
|
|
1910
|
+
const agentId = `orchestrator-${input.id}`;
|
|
1911
|
+
const stmt = db.prepare(`
|
|
1912
|
+
INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, env_keys, meta, last_seen, created_at)
|
|
1913
|
+
VALUES ($id, $hostname, 'online', $agentId, $providers, $baseDir, $envKeys, $meta, $now, $now)
|
|
1914
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1915
|
+
hostname = $hostname,
|
|
1916
|
+
status = 'online',
|
|
1917
|
+
providers = $providers,
|
|
1918
|
+
base_dir = $baseDir,
|
|
1919
|
+
env_keys = $envKeys,
|
|
1920
|
+
meta = $meta,
|
|
1921
|
+
last_seen = $now
|
|
1922
|
+
`);
|
|
1923
|
+
stmt.run({
|
|
1924
|
+
$id: input.id,
|
|
1925
|
+
$hostname: input.hostname,
|
|
1926
|
+
$agentId: agentId,
|
|
1927
|
+
$providers: JSON.stringify(input.providers),
|
|
1928
|
+
$baseDir: input.baseDir,
|
|
1929
|
+
$envKeys: JSON.stringify(input.envKeys ?? []),
|
|
1930
|
+
$meta: JSON.stringify(input.meta ?? {}),
|
|
1931
|
+
$now: now,
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
// Also register as an agent so the orchestrator can receive messages
|
|
1935
|
+
upsertAgent({
|
|
1936
|
+
id: agentId,
|
|
1937
|
+
name: `Orchestrator (${input.hostname})`,
|
|
1938
|
+
tags: ["orchestrator", input.hostname],
|
|
1939
|
+
machine: input.hostname,
|
|
1940
|
+
capabilities: ["orchestrator", "spawn"],
|
|
1941
|
+
status: "online",
|
|
1942
|
+
meta: { orchestratorId: input.id, builtin: true },
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
return getOrchestrator(input.id)!;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
export function getOrchestrator(id: string): Orchestrator | null {
|
|
1949
|
+
const row = db.prepare("SELECT * FROM orchestrators WHERE id = ?").get(id) as any;
|
|
1950
|
+
return row ? rowToOrchestrator(row) : null;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
export function listOrchestrators(): Orchestrator[] {
|
|
1954
|
+
return (db.prepare("SELECT * FROM orchestrators ORDER BY hostname").all() as any[]).map(rowToOrchestrator);
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
export function orchestratorHeartbeat(id: string): Orchestrator | null {
|
|
1958
|
+
const now = Date.now();
|
|
1959
|
+
db.prepare("UPDATE orchestrators SET last_seen = ?, status = 'online' WHERE id = ?").run(now, id);
|
|
1960
|
+
// Also heartbeat the agent
|
|
1961
|
+
const orch = getOrchestrator(id);
|
|
1962
|
+
if (orch) {
|
|
1963
|
+
heartbeat(orch.agentId);
|
|
1964
|
+
}
|
|
1965
|
+
return orch;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
export function setOrchestratorStatus(id: string, status: OrchestratorStatus): Orchestrator | null {
|
|
1969
|
+
db.prepare("UPDATE orchestrators SET status = ?, last_seen = ? WHERE id = ?").run(status, Date.now(), id);
|
|
1970
|
+
const orch = getOrchestrator(id);
|
|
1971
|
+
if (orch) {
|
|
1972
|
+
setStatus(orch.agentId, status === "online" ? "online" : "offline");
|
|
1973
|
+
}
|
|
1974
|
+
return orch;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
export function updateManagedAgents(id: string, agents: ManagedAgent[]): Orchestrator | null {
|
|
1978
|
+
db.prepare("UPDATE orchestrators SET managed_agents = ?, last_seen = ? WHERE id = ?")
|
|
1979
|
+
.run(JSON.stringify(agents), Date.now(), id);
|
|
1980
|
+
return getOrchestrator(id);
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
export function deleteOrchestrator(id: string): boolean {
|
|
1984
|
+
const orch = getOrchestrator(id);
|
|
1985
|
+
if (!orch) return false;
|
|
1986
|
+
db.prepare("DELETE FROM orchestrators WHERE id = ?").run(id);
|
|
1987
|
+
deleteAgent(orch.agentId);
|
|
1988
|
+
return true;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
export function reapStaleOrchestrators(): string[] {
|
|
1992
|
+
const cutoff = Date.now() - STALE_TTL_MS;
|
|
1993
|
+
const stale = db.prepare("SELECT id, agent_id FROM orchestrators WHERE last_seen < ? AND status = 'online'").all(cutoff) as any[];
|
|
1994
|
+
for (const row of stale) {
|
|
1995
|
+
db.prepare("UPDATE orchestrators SET status = 'offline' WHERE id = ?").run(row.id);
|
|
1996
|
+
setStatus(row.agent_id, "offline");
|
|
1997
|
+
}
|
|
1998
|
+
return stale.map((row: any) => row.id);
|
|
1999
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -46,11 +46,18 @@ import {
|
|
|
46
46
|
createCallbackDelivery,
|
|
47
47
|
finishCallbackDelivery,
|
|
48
48
|
reapStaleAgents,
|
|
49
|
+
reapStaleOrchestrators,
|
|
49
50
|
releaseExpiredClaims,
|
|
50
51
|
validateAgentSession,
|
|
52
|
+
upsertOrchestrator,
|
|
53
|
+
getOrchestrator,
|
|
54
|
+
listOrchestrators,
|
|
55
|
+
orchestratorHeartbeat,
|
|
56
|
+
updateManagedAgents,
|
|
57
|
+
deleteOrchestrator,
|
|
51
58
|
ValidationError,
|
|
52
59
|
} from "./db";
|
|
53
|
-
import type { ActivityEventInput, ActivityKind, AgentCard, AgentSessionGuard, ChannelDirection, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
|
|
60
|
+
import type { ActivityEventInput, ActivityKind, AgentCard, AgentSessionGuard, ChannelDirection, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnProvider, TaskStatus, TaskStatusInput } from "./types";
|
|
54
61
|
import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
|
|
55
62
|
import { listHostDirectories, spawnCodexAgent, type CodexSpawnApprovalMode } from "./agent-spawn";
|
|
56
63
|
import {
|
|
@@ -67,6 +74,8 @@ import {
|
|
|
67
74
|
emitMessageClaimReleased,
|
|
68
75
|
emitMessageDeleted,
|
|
69
76
|
emitTaskChanged,
|
|
77
|
+
emitOrchestratorStatus,
|
|
78
|
+
emitOrchestratorRemoved,
|
|
70
79
|
} from "./sse";
|
|
71
80
|
|
|
72
81
|
type Handler = (
|
|
@@ -804,11 +813,47 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
804
813
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
805
814
|
try {
|
|
806
815
|
if (!isRecord(parsed.body)) return error("provider required");
|
|
807
|
-
const provider = cleanEnum(parsed.body.provider, "provider", VALID_AGENT_SPAWN_PROVIDERS);
|
|
808
|
-
if (provider
|
|
816
|
+
const provider = cleanEnum(parsed.body.provider, "provider", [...VALID_AGENT_SPAWN_PROVIDERS, "claude"] as const);
|
|
817
|
+
if (!provider) return error("provider required");
|
|
809
818
|
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_CODEX_SPAWN_APPROVALS, "guarded") as CodexSpawnApprovalMode;
|
|
810
819
|
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
811
820
|
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
821
|
+
|
|
822
|
+
// Check for an online orchestrator that supports this provider
|
|
823
|
+
const orchestrators = listOrchestrators().filter(
|
|
824
|
+
(o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
|
|
825
|
+
);
|
|
826
|
+
if (orchestrators.length > 0) {
|
|
827
|
+
// Route through the first available orchestrator
|
|
828
|
+
const orch = orchestrators[0]!;
|
|
829
|
+
const msg = sendMessage({
|
|
830
|
+
from: "system",
|
|
831
|
+
to: orch.agentId,
|
|
832
|
+
type: "system",
|
|
833
|
+
subject: "Spawn agent",
|
|
834
|
+
body: JSON.stringify({ action: "spawn", provider, cwd, label, approvalMode }),
|
|
835
|
+
meta: {
|
|
836
|
+
orchestratorControl: { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() },
|
|
837
|
+
delivery: "interrupt",
|
|
838
|
+
priority: "urgent",
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
emitNewMessage(msg);
|
|
842
|
+
auditEvent({
|
|
843
|
+
clientId: "server-agent-spawn-" + provider + "-" + Date.now(),
|
|
844
|
+
kind: "state",
|
|
845
|
+
title: `${provider} agent spawn requested (via ${orch.id})`,
|
|
846
|
+
body: cwd || orch.baseDir,
|
|
847
|
+
meta: orch.id,
|
|
848
|
+
icon: "ti-plus",
|
|
849
|
+
view: "agents",
|
|
850
|
+
metadata: { provider, orchestratorId: orch.id, approvalMode },
|
|
851
|
+
});
|
|
852
|
+
return json({ ok: true, orchestratorId: orch.id, provider, message: msg }, 202);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Fallback: direct spawn for codex only (no orchestrator)
|
|
856
|
+
if (provider !== "codex") return error("no orchestrator available for provider: " + provider);
|
|
812
857
|
const relayUrl = process.env.AGENT_RELAY_SPAWN_RELAY_URL || process.env.AGENT_RELAY_URL || `http://127.0.0.1:${process.env.PORT || "4850"}`;
|
|
813
858
|
const token = req.headers.get("X-Agent-Relay-Token") ?? req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
|
|
814
859
|
const result = spawnCodexAgent({
|
|
@@ -822,7 +867,7 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
822
867
|
auditEvent({
|
|
823
868
|
clientId: "server-agent-spawn-codex-" + Date.now(),
|
|
824
869
|
kind: "state",
|
|
825
|
-
title: "Codex agent spawn requested",
|
|
870
|
+
title: "Codex agent spawn requested (direct)",
|
|
826
871
|
body: result.cwd,
|
|
827
872
|
meta: result.pid ? `pid ${result.pid}` : "dry run",
|
|
828
873
|
icon: "ti-plus",
|
|
@@ -855,6 +900,207 @@ const getHostDirectories: Handler = (req) => {
|
|
|
855
900
|
}
|
|
856
901
|
};
|
|
857
902
|
|
|
903
|
+
// --- Orchestrator routes ---
|
|
904
|
+
|
|
905
|
+
const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
|
|
906
|
+
const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
|
|
907
|
+
|
|
908
|
+
const postOrchestrator: Handler = async (req) => {
|
|
909
|
+
const parsed = await parseBody<unknown>(req);
|
|
910
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
911
|
+
try {
|
|
912
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
913
|
+
const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
|
|
914
|
+
const hostname = cleanString(parsed.body.hostname, "hostname", { required: true, max: 120 })!;
|
|
915
|
+
const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
|
|
916
|
+
const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
|
|
917
|
+
if (providers) {
|
|
918
|
+
for (const p of providers) {
|
|
919
|
+
if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
|
|
920
|
+
return error(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys");
|
|
925
|
+
const meta = cleanMeta(parsed.body.meta);
|
|
926
|
+
const orch = upsertOrchestrator({ id, hostname, providers: providers ?? ["claude", "codex"], baseDir, envKeys, meta });
|
|
927
|
+
auditEvent({
|
|
928
|
+
clientId: "server-orchestrator-register-" + id + "-" + Date.now(),
|
|
929
|
+
kind: "state",
|
|
930
|
+
title: "Orchestrator registered",
|
|
931
|
+
body: hostname,
|
|
932
|
+
meta: id,
|
|
933
|
+
icon: "ti-server-2",
|
|
934
|
+
view: "orchestrators",
|
|
935
|
+
metadata: { orchestratorId: id, providers: orch.providers },
|
|
936
|
+
});
|
|
937
|
+
return json(orch, 201);
|
|
938
|
+
} catch (e) {
|
|
939
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
940
|
+
throw e;
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
const getOrchestrators: Handler = () => {
|
|
945
|
+
return json(listOrchestrators());
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
const getOrchestratorById: Handler = (_req, params) => {
|
|
949
|
+
const orch = getOrchestrator(params.id!);
|
|
950
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
951
|
+
return json(orch);
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
const postOrchestratorHeartbeat: Handler = (_req, params) => {
|
|
955
|
+
const orch = orchestratorHeartbeat(params.id!);
|
|
956
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
957
|
+
return json({ ok: true });
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
const patchOrchestratorAgents: Handler = async (req, params) => {
|
|
961
|
+
const parsed = await parseBody<unknown>(req);
|
|
962
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
963
|
+
try {
|
|
964
|
+
const orch = getOrchestrator(params.id!);
|
|
965
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
966
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
967
|
+
const agents = parsed.body.agents;
|
|
968
|
+
if (!Array.isArray(agents)) return error("agents must be an array");
|
|
969
|
+
const cleaned: ManagedAgent[] = agents.map((a: any) => {
|
|
970
|
+
if (!isRecord(a)) throw new ValidationError("each agent must be an object");
|
|
971
|
+
return {
|
|
972
|
+
agentId: cleanString(a.agentId, "agentId", { required: true, max: 240 })!,
|
|
973
|
+
provider: cleanEnum(a.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider,
|
|
974
|
+
tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!,
|
|
975
|
+
cwd: cleanString(a.cwd, "cwd", { required: true, max: 500 })!,
|
|
976
|
+
label: cleanString(a.label, "label", { max: 120 }),
|
|
977
|
+
approvalMode: (cleanEnum(a.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") ?? "guarded") as SpawnApprovalMode,
|
|
978
|
+
pid: typeof a.pid === "number" && Number.isSafeInteger(a.pid) ? a.pid : undefined,
|
|
979
|
+
startedAt: typeof a.startedAt === "number" ? a.startedAt : Date.now(),
|
|
980
|
+
};
|
|
981
|
+
});
|
|
982
|
+
const updated = updateManagedAgents(params.id!, cleaned);
|
|
983
|
+
return json(updated);
|
|
984
|
+
} catch (e) {
|
|
985
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
986
|
+
throw e;
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
991
|
+
const parsed = await parseBody<unknown>(req);
|
|
992
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
993
|
+
try {
|
|
994
|
+
const orch = getOrchestrator(params.id!);
|
|
995
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
996
|
+
if (orch.status !== "online") return error("orchestrator is offline", 409);
|
|
997
|
+
|
|
998
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
999
|
+
const provider = cleanEnum(parsed.body.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider;
|
|
1000
|
+
if (!orch.providers.includes(provider)) {
|
|
1001
|
+
return error(`orchestrator does not support provider: ${provider}`);
|
|
1002
|
+
}
|
|
1003
|
+
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
1004
|
+
if (cwd && !cwd.startsWith(orch.baseDir)) {
|
|
1005
|
+
return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
|
|
1006
|
+
}
|
|
1007
|
+
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
1008
|
+
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
|
|
1009
|
+
const prompt = cleanString(parsed.body.prompt, "prompt", { max: 4000 });
|
|
1010
|
+
|
|
1011
|
+
// Send control message to orchestrator's agent inbox
|
|
1012
|
+
const msg = sendMessage({
|
|
1013
|
+
from: "system",
|
|
1014
|
+
to: orch.agentId,
|
|
1015
|
+
type: "system",
|
|
1016
|
+
subject: "Spawn agent",
|
|
1017
|
+
body: JSON.stringify({ action: "spawn", provider, cwd: cwd || orch.baseDir, label, approvalMode, prompt }),
|
|
1018
|
+
meta: {
|
|
1019
|
+
orchestratorControl: {
|
|
1020
|
+
action: "spawn",
|
|
1021
|
+
provider,
|
|
1022
|
+
cwd: cwd || orch.baseDir,
|
|
1023
|
+
label,
|
|
1024
|
+
approvalMode,
|
|
1025
|
+
prompt,
|
|
1026
|
+
requestedBy: "dashboard",
|
|
1027
|
+
requestedAt: Date.now(),
|
|
1028
|
+
},
|
|
1029
|
+
delivery: "interrupt",
|
|
1030
|
+
priority: "urgent",
|
|
1031
|
+
},
|
|
1032
|
+
});
|
|
1033
|
+
emitNewMessage(msg);
|
|
1034
|
+
auditEvent({
|
|
1035
|
+
clientId: "server-orchestrator-spawn-" + orch.id + "-" + Date.now(),
|
|
1036
|
+
kind: "state",
|
|
1037
|
+
title: `Spawn ${provider} agent requested`,
|
|
1038
|
+
body: cwd || orch.baseDir,
|
|
1039
|
+
meta: orch.id,
|
|
1040
|
+
icon: "ti-plus",
|
|
1041
|
+
view: "orchestrators",
|
|
1042
|
+
metadata: { orchestratorId: orch.id, provider, approvalMode, label },
|
|
1043
|
+
});
|
|
1044
|
+
return json({ ok: true, orchestratorId: orch.id, message: msg }, 202);
|
|
1045
|
+
} catch (e) {
|
|
1046
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1047
|
+
throw e;
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
const postOrchestratorAction: Handler = async (req, params) => {
|
|
1052
|
+
const parsed = await parseBody<unknown>(req);
|
|
1053
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1054
|
+
try {
|
|
1055
|
+
const orch = getOrchestrator(params.id!);
|
|
1056
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
1057
|
+
|
|
1058
|
+
if (!isRecord(parsed.body)) return error("body required");
|
|
1059
|
+
const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown"] as const);
|
|
1060
|
+
if (!action) return error("action required");
|
|
1061
|
+
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
1062
|
+
|
|
1063
|
+
const msg = sendMessage({
|
|
1064
|
+
from: "system",
|
|
1065
|
+
to: orch.agentId,
|
|
1066
|
+
type: "system",
|
|
1067
|
+
subject: action === "restart" ? "Restart agent" : "Shutdown agent",
|
|
1068
|
+
body: JSON.stringify({ action, agentId }),
|
|
1069
|
+
meta: {
|
|
1070
|
+
orchestratorControl: {
|
|
1071
|
+
action,
|
|
1072
|
+
agentId,
|
|
1073
|
+
requestedBy: "dashboard",
|
|
1074
|
+
requestedAt: Date.now(),
|
|
1075
|
+
},
|
|
1076
|
+
delivery: "interrupt",
|
|
1077
|
+
priority: "urgent",
|
|
1078
|
+
},
|
|
1079
|
+
});
|
|
1080
|
+
emitNewMessage(msg);
|
|
1081
|
+
auditEvent({
|
|
1082
|
+
clientId: "server-orchestrator-action-" + orch.id + "-" + action + "-" + Date.now(),
|
|
1083
|
+
kind: "state",
|
|
1084
|
+
title: `Agent ${action} requested`,
|
|
1085
|
+
body: agentId || "all",
|
|
1086
|
+
meta: orch.id,
|
|
1087
|
+
icon: action === "restart" ? "ti-refresh" : "ti-power",
|
|
1088
|
+
view: "orchestrators",
|
|
1089
|
+
metadata: { orchestratorId: orch.id, action, agentId },
|
|
1090
|
+
});
|
|
1091
|
+
return json({ ok: true, action, message: msg }, 202);
|
|
1092
|
+
} catch (e) {
|
|
1093
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1094
|
+
throw e;
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
const deleteOrchestratorById: Handler = (_req, params) => {
|
|
1099
|
+
const deleted = deleteOrchestrator(params.id!);
|
|
1100
|
+
if (!deleted) return error("orchestrator not found", 404);
|
|
1101
|
+
return json({ ok: true });
|
|
1102
|
+
};
|
|
1103
|
+
|
|
858
1104
|
// --- Message routes ---
|
|
859
1105
|
|
|
860
1106
|
const VALID_MSG_TYPES = ["message", "system"];
|
|
@@ -1486,20 +1732,23 @@ const getHealthRoute: Handler = () => json(getHealth());
|
|
|
1486
1732
|
const postSystemReap: Handler = () => {
|
|
1487
1733
|
const released = releaseExpiredClaims();
|
|
1488
1734
|
const reapedAgentIds = reapStaleAgents();
|
|
1735
|
+
const reapedOrchestratorIds = reapStaleOrchestrators();
|
|
1489
1736
|
for (const id of released.messageIds) emitMessageClaimReleased(id);
|
|
1490
1737
|
for (const task of released.tasks) emitTaskChanged(task, "task.updated");
|
|
1491
1738
|
for (const id of reapedAgentIds) emitAgentStatus(id);
|
|
1739
|
+
for (const id of reapedOrchestratorIds) emitOrchestratorStatus(id);
|
|
1492
1740
|
auditEvent({
|
|
1493
1741
|
clientId: "server-system-reap-" + Date.now(),
|
|
1494
1742
|
kind: "state",
|
|
1495
1743
|
title: "Maintenance reaper run",
|
|
1496
|
-
body: `${reapedAgentIds.length} stale agent(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s)`,
|
|
1744
|
+
body: `${reapedAgentIds.length} stale agent(s), ${reapedOrchestratorIds.length} stale orchestrator(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s)`,
|
|
1497
1745
|
icon: "ti-broom",
|
|
1498
1746
|
view: "activity",
|
|
1499
1747
|
});
|
|
1500
1748
|
return json({
|
|
1501
1749
|
ok: true,
|
|
1502
1750
|
reapedAgentIds,
|
|
1751
|
+
reapedOrchestratorIds,
|
|
1503
1752
|
releasedMessageIds: released.messageIds,
|
|
1504
1753
|
releasedTaskIds: released.tasks.map((task) => task.id),
|
|
1505
1754
|
});
|
|
@@ -1542,6 +1791,15 @@ const routes: Route[] = [
|
|
|
1542
1791
|
route("POST", "/api/agents/:id/actions", postAgentAction),
|
|
1543
1792
|
route("DELETE", "/api/agents/:id", deleteAgentById),
|
|
1544
1793
|
|
|
1794
|
+
route("POST", "/api/orchestrators", postOrchestrator),
|
|
1795
|
+
route("GET", "/api/orchestrators", getOrchestrators),
|
|
1796
|
+
route("GET", "/api/orchestrators/:id", getOrchestratorById),
|
|
1797
|
+
route("POST", "/api/orchestrators/:id/heartbeat", postOrchestratorHeartbeat),
|
|
1798
|
+
route("PATCH", "/api/orchestrators/:id/agents", patchOrchestratorAgents),
|
|
1799
|
+
route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
|
|
1800
|
+
route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
|
|
1801
|
+
route("DELETE", "/api/orchestrators/:id", deleteOrchestratorById),
|
|
1802
|
+
|
|
1545
1803
|
route("POST", "/api/system/broadcast", postSystemBroadcast),
|
|
1546
1804
|
route("POST", "/api/messages", postMessage),
|
|
1547
1805
|
route("GET", "/api/messages", getMessages),
|
package/src/sse.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getAgent } from "./db";
|
|
1
|
+
import { getAgent, getOrchestrator } from "./db";
|
|
2
2
|
import type { Message, Task } from "./types";
|
|
3
3
|
|
|
4
4
|
interface Connection {
|
|
@@ -135,3 +135,17 @@ function targetMatchesAgent(target: string, agentId: string): boolean {
|
|
|
135
135
|
if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
|
|
136
136
|
return false;
|
|
137
137
|
}
|
|
138
|
+
|
|
139
|
+
export function emitOrchestratorStatus(orchestratorId: string) {
|
|
140
|
+
const orch = getOrchestrator(orchestratorId);
|
|
141
|
+
const data = orch ?? { id: orchestratorId, status: "offline" };
|
|
142
|
+
for (const conn of connections.values()) {
|
|
143
|
+
send(conn, "orchestrator.status", data);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function emitOrchestratorRemoved(orchestratorId: string) {
|
|
148
|
+
for (const conn of connections.values()) {
|
|
149
|
+
send(conn, "orchestrator.removed", { id: orchestratorId });
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -291,6 +291,66 @@ export interface ActivityEventInput {
|
|
|
291
291
|
metadata?: Record<string, unknown>;
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
// --- Orchestrators ---
|
|
295
|
+
|
|
296
|
+
export type OrchestratorStatus = "online" | "offline";
|
|
297
|
+
export type SpawnProvider = "claude" | "codex";
|
|
298
|
+
export type SpawnApprovalMode = "open" | "guarded" | "read-only";
|
|
299
|
+
|
|
300
|
+
export interface Orchestrator {
|
|
301
|
+
id: string;
|
|
302
|
+
hostname: string;
|
|
303
|
+
status: OrchestratorStatus;
|
|
304
|
+
agentId: string; // relay agent id for messaging
|
|
305
|
+
providers: SpawnProvider[];
|
|
306
|
+
baseDir: string;
|
|
307
|
+
envKeys: string[]; // names only, never values
|
|
308
|
+
meta: Record<string, unknown>;
|
|
309
|
+
managedAgents: ManagedAgent[];
|
|
310
|
+
lastSeen: number;
|
|
311
|
+
createdAt: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export interface ManagedAgent {
|
|
315
|
+
agentId: string;
|
|
316
|
+
provider: SpawnProvider;
|
|
317
|
+
tmuxSession: string;
|
|
318
|
+
cwd: string;
|
|
319
|
+
label?: string;
|
|
320
|
+
approvalMode: SpawnApprovalMode;
|
|
321
|
+
pid?: number;
|
|
322
|
+
startedAt: number;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface RegisterOrchestratorInput {
|
|
326
|
+
id: string;
|
|
327
|
+
hostname: string;
|
|
328
|
+
providers: SpawnProvider[];
|
|
329
|
+
baseDir: string;
|
|
330
|
+
envKeys?: string[];
|
|
331
|
+
meta?: Record<string, unknown>;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export interface OrchestratorSpawnInput {
|
|
335
|
+
provider: SpawnProvider;
|
|
336
|
+
cwd?: string;
|
|
337
|
+
label?: string;
|
|
338
|
+
approvalMode?: SpawnApprovalMode;
|
|
339
|
+
prompt?: string;
|
|
340
|
+
env?: Record<string, string>;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export interface OrchestratorSpawnResult {
|
|
344
|
+
orchestratorId: string;
|
|
345
|
+
provider: SpawnProvider;
|
|
346
|
+
tmuxSession: string;
|
|
347
|
+
cwd: string;
|
|
348
|
+
label?: string;
|
|
349
|
+
approvalMode: SpawnApprovalMode;
|
|
350
|
+
pid?: number;
|
|
351
|
+
startedAt: number;
|
|
352
|
+
}
|
|
353
|
+
|
|
294
354
|
export interface HealthCheck {
|
|
295
355
|
name: string;
|
|
296
356
|
status: "ok" | "warn" | "error";
|