agent-relay-server 0.4.38 → 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 +338 -14
- package/public/index.html +446 -23
- package/src/agent-spawn.ts +137 -0
- package/src/db.ts +171 -1
- package/src/routes.ts +380 -2
- package/src/sse.ts +15 -1
- package/src/types.ts +60 -0
package/src/routes.ts
CHANGED
|
@@ -46,12 +46,20 @@ 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";
|
|
62
|
+
import { listHostDirectories, spawnCodexAgent, type CodexSpawnApprovalMode } from "./agent-spawn";
|
|
55
63
|
import {
|
|
56
64
|
getIntegrationAuth,
|
|
57
65
|
hasIntegrationScope,
|
|
@@ -66,6 +74,8 @@ import {
|
|
|
66
74
|
emitMessageClaimReleased,
|
|
67
75
|
emitMessageDeleted,
|
|
68
76
|
emitTaskChanged,
|
|
77
|
+
emitOrchestratorStatus,
|
|
78
|
+
emitOrchestratorRemoved,
|
|
69
79
|
} from "./sse";
|
|
70
80
|
|
|
71
81
|
type Handler = (
|
|
@@ -150,6 +160,9 @@ function parseQueryInt(
|
|
|
150
160
|
}
|
|
151
161
|
|
|
152
162
|
const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
|
|
163
|
+
const VALID_AGENT_ACTIONS = ["restart", "shutdown"] as const;
|
|
164
|
+
const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
|
|
165
|
+
const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
|
|
153
166
|
const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
|
|
154
167
|
const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
|
|
155
168
|
const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
|
|
@@ -738,6 +751,356 @@ const deleteAgentById: Handler = (_req, params) => {
|
|
|
738
751
|
return json({ ok: true });
|
|
739
752
|
};
|
|
740
753
|
|
|
754
|
+
function agentCanReceiveControlAction(agent: AgentCard): boolean {
|
|
755
|
+
return agent.id !== "user" &&
|
|
756
|
+
agent.id !== "system" &&
|
|
757
|
+
agent.meta?.kind !== "channel" &&
|
|
758
|
+
!agent.tags.includes("channel");
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const postAgentAction: Handler = async (req, params) => {
|
|
762
|
+
const parsed = await parseBody<unknown>(req);
|
|
763
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
764
|
+
try {
|
|
765
|
+
if (!isRecord(parsed.body)) return error("action required");
|
|
766
|
+
const action = cleanEnum(parsed.body.action, "action", VALID_AGENT_ACTIONS);
|
|
767
|
+
if (!action) return error("action required");
|
|
768
|
+
const agent = getAgent(params.id!);
|
|
769
|
+
if (!agent) return error("agent not found", 404);
|
|
770
|
+
if (!agentCanReceiveControlAction(agent)) return error("agent does not support dashboard control actions", 400);
|
|
771
|
+
|
|
772
|
+
const title = action === "restart" ? "Agent restart requested" : "Agent shutdown requested";
|
|
773
|
+
const msg = sendMessage({
|
|
774
|
+
from: "system",
|
|
775
|
+
to: agent.id,
|
|
776
|
+
type: "system",
|
|
777
|
+
subject: title,
|
|
778
|
+
body: action === "restart"
|
|
779
|
+
? "Dashboard requested that this agent restart its relay-managed session now."
|
|
780
|
+
: "Dashboard requested that this agent shut down its relay-managed session now.",
|
|
781
|
+
meta: {
|
|
782
|
+
agentControl: {
|
|
783
|
+
action,
|
|
784
|
+
requestedBy: "dashboard",
|
|
785
|
+
requestedAt: Date.now(),
|
|
786
|
+
},
|
|
787
|
+
delivery: "interrupt",
|
|
788
|
+
priority: "urgent",
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
emitNewMessage(msg);
|
|
792
|
+
auditEvent({
|
|
793
|
+
clientId: "server-agent-" + agent.id + "-action-" + action + "-" + msg.id,
|
|
794
|
+
kind: "state",
|
|
795
|
+
title,
|
|
796
|
+
body: action,
|
|
797
|
+
meta: agent.id,
|
|
798
|
+
icon: action === "restart" ? "ti-refresh" : "ti-power",
|
|
799
|
+
view: "agents",
|
|
800
|
+
messageId: msg.id,
|
|
801
|
+
agentId: agent.id,
|
|
802
|
+
metadata: { action },
|
|
803
|
+
});
|
|
804
|
+
return json({ ok: true, action, message: msg }, 202);
|
|
805
|
+
} catch (e) {
|
|
806
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
807
|
+
throw e;
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
const postAgentSpawn: Handler = async (req) => {
|
|
812
|
+
const parsed = await parseBody<unknown>(req);
|
|
813
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
814
|
+
try {
|
|
815
|
+
if (!isRecord(parsed.body)) return error("provider required");
|
|
816
|
+
const provider = cleanEnum(parsed.body.provider, "provider", [...VALID_AGENT_SPAWN_PROVIDERS, "claude"] as const);
|
|
817
|
+
if (!provider) return error("provider required");
|
|
818
|
+
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_CODEX_SPAWN_APPROVALS, "guarded") as CodexSpawnApprovalMode;
|
|
819
|
+
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
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);
|
|
857
|
+
const relayUrl = process.env.AGENT_RELAY_SPAWN_RELAY_URL || process.env.AGENT_RELAY_URL || `http://127.0.0.1:${process.env.PORT || "4850"}`;
|
|
858
|
+
const token = req.headers.get("X-Agent-Relay-Token") ?? req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
|
|
859
|
+
const result = spawnCodexAgent({
|
|
860
|
+
cwd,
|
|
861
|
+
approvalMode,
|
|
862
|
+
label,
|
|
863
|
+
relayUrl,
|
|
864
|
+
token: token || undefined,
|
|
865
|
+
dryRun: process.env.AGENT_RELAY_SPAWN_DRY_RUN === "1",
|
|
866
|
+
});
|
|
867
|
+
auditEvent({
|
|
868
|
+
clientId: "server-agent-spawn-codex-" + Date.now(),
|
|
869
|
+
kind: "state",
|
|
870
|
+
title: "Codex agent spawn requested (direct)",
|
|
871
|
+
body: result.cwd,
|
|
872
|
+
meta: result.pid ? `pid ${result.pid}` : "dry run",
|
|
873
|
+
icon: "ti-plus",
|
|
874
|
+
view: "agents",
|
|
875
|
+
metadata: {
|
|
876
|
+
provider: result.provider,
|
|
877
|
+
approvalMode: result.approvalMode,
|
|
878
|
+
pid: result.pid ?? null,
|
|
879
|
+
logPath: result.logPath,
|
|
880
|
+
dryRun: result.dryRun === true,
|
|
881
|
+
},
|
|
882
|
+
});
|
|
883
|
+
return json(result, 202);
|
|
884
|
+
} catch (e) {
|
|
885
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
886
|
+
if (e instanceof Error) return error(e.message, 400);
|
|
887
|
+
throw e;
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
const getHostDirectories: Handler = (req) => {
|
|
892
|
+
try {
|
|
893
|
+
const url = new URL(req.url);
|
|
894
|
+
const path = cleanString(url.searchParams.get("path") ?? undefined, "path", { max: 500 });
|
|
895
|
+
return json(listHostDirectories(path));
|
|
896
|
+
} catch (e) {
|
|
897
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
898
|
+
if (e instanceof Error) return error(e.message, 400);
|
|
899
|
+
throw e;
|
|
900
|
+
}
|
|
901
|
+
};
|
|
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
|
+
|
|
741
1104
|
// --- Message routes ---
|
|
742
1105
|
|
|
743
1106
|
const VALID_MSG_TYPES = ["message", "system"];
|
|
@@ -1369,20 +1732,23 @@ const getHealthRoute: Handler = () => json(getHealth());
|
|
|
1369
1732
|
const postSystemReap: Handler = () => {
|
|
1370
1733
|
const released = releaseExpiredClaims();
|
|
1371
1734
|
const reapedAgentIds = reapStaleAgents();
|
|
1735
|
+
const reapedOrchestratorIds = reapStaleOrchestrators();
|
|
1372
1736
|
for (const id of released.messageIds) emitMessageClaimReleased(id);
|
|
1373
1737
|
for (const task of released.tasks) emitTaskChanged(task, "task.updated");
|
|
1374
1738
|
for (const id of reapedAgentIds) emitAgentStatus(id);
|
|
1739
|
+
for (const id of reapedOrchestratorIds) emitOrchestratorStatus(id);
|
|
1375
1740
|
auditEvent({
|
|
1376
1741
|
clientId: "server-system-reap-" + Date.now(),
|
|
1377
1742
|
kind: "state",
|
|
1378
1743
|
title: "Maintenance reaper run",
|
|
1379
|
-
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)`,
|
|
1380
1745
|
icon: "ti-broom",
|
|
1381
1746
|
view: "activity",
|
|
1382
1747
|
});
|
|
1383
1748
|
return json({
|
|
1384
1749
|
ok: true,
|
|
1385
1750
|
reapedAgentIds,
|
|
1751
|
+
reapedOrchestratorIds,
|
|
1386
1752
|
releasedMessageIds: released.messageIds,
|
|
1387
1753
|
releasedTaskIds: released.tasks.map((task) => task.id),
|
|
1388
1754
|
});
|
|
@@ -1412,16 +1778,28 @@ function route(method: string, path: string, handler: Handler): Route {
|
|
|
1412
1778
|
|
|
1413
1779
|
const routes: Route[] = [
|
|
1414
1780
|
route("POST", "/api/agents", postAgent),
|
|
1781
|
+
route("POST", "/api/agents/spawn", postAgentSpawn),
|
|
1415
1782
|
route("GET", "/api/agents", getAgents),
|
|
1416
1783
|
route("GET", "/api/agents/find", findAgents),
|
|
1784
|
+
route("GET", "/api/agents/spawn/directories", getHostDirectories),
|
|
1417
1785
|
route("GET", "/api/agents/:id", getAgentById),
|
|
1418
1786
|
route("PATCH", "/api/agents/:id/status", patchAgentStatus),
|
|
1419
1787
|
route("PATCH", "/api/agents/:id/ready", patchAgentReady),
|
|
1420
1788
|
route("PATCH", "/api/agents/:id/label", patchAgentLabel),
|
|
1421
1789
|
route("PATCH", "/api/agents/:id/tags", patchAgentTags),
|
|
1422
1790
|
route("POST", "/api/agents/:id/heartbeat", postHeartbeat),
|
|
1791
|
+
route("POST", "/api/agents/:id/actions", postAgentAction),
|
|
1423
1792
|
route("DELETE", "/api/agents/:id", deleteAgentById),
|
|
1424
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
|
+
|
|
1425
1803
|
route("POST", "/api/system/broadcast", postSystemBroadcast),
|
|
1426
1804
|
route("POST", "/api/messages", postMessage),
|
|
1427
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";
|