agent-relay-server 0.10.7 → 0.10.8
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/index.html +49 -47
- package/src/config-store.ts +380 -0
- package/src/db.ts +223 -5
- package/src/index.ts +2 -0
- package/src/lifecycle-manager.ts +369 -0
- package/src/routes.ts +407 -8
- package/src/sse.ts +26 -0
package/src/routes.ts
CHANGED
|
@@ -11,9 +11,11 @@ import {
|
|
|
11
11
|
deleteAgent,
|
|
12
12
|
sendMessage,
|
|
13
13
|
sendMessageWithResult,
|
|
14
|
+
getMessageDeliveryStatus,
|
|
14
15
|
getMessage,
|
|
15
16
|
getThread,
|
|
16
17
|
claimMessage,
|
|
18
|
+
listQueuedMessages,
|
|
17
19
|
listRecentMessages,
|
|
18
20
|
pollMessages,
|
|
19
21
|
markRead,
|
|
@@ -53,6 +55,8 @@ import {
|
|
|
53
55
|
reapStaleAgents,
|
|
54
56
|
reapStaleOrchestrators,
|
|
55
57
|
releaseExpiredClaims,
|
|
58
|
+
expireQueuedMessages,
|
|
59
|
+
resolveQueuedPolicyMessages,
|
|
56
60
|
releaseOrphanedTasks,
|
|
57
61
|
validateAgentSession,
|
|
58
62
|
upsertOrchestrator,
|
|
@@ -64,6 +68,18 @@ import {
|
|
|
64
68
|
evaluatePoolBindings,
|
|
65
69
|
ValidationError,
|
|
66
70
|
} from "./db";
|
|
71
|
+
import {
|
|
72
|
+
deleteConfig,
|
|
73
|
+
getConfig,
|
|
74
|
+
getConfigHistory,
|
|
75
|
+
getManagedAgentState,
|
|
76
|
+
getSpawnPolicy,
|
|
77
|
+
listSpawnPolicies,
|
|
78
|
+
listConfig,
|
|
79
|
+
setConfig,
|
|
80
|
+
upsertManagedAgentState,
|
|
81
|
+
updateManagedAgentState,
|
|
82
|
+
} from "./config-store";
|
|
67
83
|
import { createCommand, deleteCommand, expireCommands, getCommand, listCommands, updateCommand } from "./commands-db";
|
|
68
84
|
import { getRecipe, listRecipes } from "./recipe-loader";
|
|
69
85
|
import { applyCommandToRecipe, getRecipeInstance, listRecipeInstances, startRecipe, stopRecipe } from "./recipe-runner";
|
|
@@ -86,7 +102,7 @@ import {
|
|
|
86
102
|
updateAutomation,
|
|
87
103
|
type AutomationDispatchResult,
|
|
88
104
|
} from "./automations";
|
|
89
|
-
import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnProvider, TaskStatus, TaskStatusInput } from "./types";
|
|
105
|
+
import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnPolicy, SpawnProvider, TaskStatus, TaskStatusInput } from "./types";
|
|
90
106
|
import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
|
|
91
107
|
import { listHostDirectories } from "./agent-spawn";
|
|
92
108
|
import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
|
|
@@ -104,13 +120,19 @@ import {
|
|
|
104
120
|
emitMessageClaimed,
|
|
105
121
|
emitMessageClaimReleased,
|
|
106
122
|
emitMessageDeleted,
|
|
123
|
+
emitMessageQueued,
|
|
124
|
+
emitMessageAvailable,
|
|
125
|
+
emitMessageExpired,
|
|
107
126
|
emitTaskChanged,
|
|
108
127
|
emitChannelActivity,
|
|
109
128
|
emitOrchestratorStatus,
|
|
110
129
|
emitOrchestratorRemoved,
|
|
111
130
|
emitPoolBindingChanged,
|
|
131
|
+
emitConfigChanged,
|
|
132
|
+
emitManagedAgentStateChanged,
|
|
112
133
|
} from "./sse";
|
|
113
134
|
import { emitRelayEvent } from "./events";
|
|
135
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
114
136
|
|
|
115
137
|
type Handler = (
|
|
116
138
|
req: Request,
|
|
@@ -195,7 +217,7 @@ function parseQueryInt(
|
|
|
195
217
|
|
|
196
218
|
const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as const;
|
|
197
219
|
const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
|
|
198
|
-
const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool"] as const;
|
|
220
|
+
const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
|
|
199
221
|
const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
|
|
200
222
|
const VALID_AGENT_ACTIONS = ["restart", "shutdown"] as const;
|
|
201
223
|
const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
|
|
@@ -212,6 +234,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
212
234
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
213
235
|
}
|
|
214
236
|
|
|
237
|
+
function pathWithinBase(path: string, baseDir: string): boolean {
|
|
238
|
+
const base = resolve(baseDir);
|
|
239
|
+
const target = resolve(path);
|
|
240
|
+
const rel = relative(base, target);
|
|
241
|
+
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
242
|
+
}
|
|
243
|
+
|
|
215
244
|
function cleanString(
|
|
216
245
|
value: unknown,
|
|
217
246
|
field: string,
|
|
@@ -370,6 +399,12 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
|
|
|
370
399
|
claimable: body.claimable as boolean | undefined,
|
|
371
400
|
idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }),
|
|
372
401
|
};
|
|
402
|
+
if (body.maxAgeSeconds !== undefined) {
|
|
403
|
+
if (typeof body.maxAgeSeconds !== "number" || !Number.isSafeInteger(body.maxAgeSeconds) || body.maxAgeSeconds < 0 || body.maxAgeSeconds > 2_592_000) {
|
|
404
|
+
throw new ValidationError("maxAgeSeconds must be an integer between 0 and 2592000");
|
|
405
|
+
}
|
|
406
|
+
input.maxAgeSeconds = body.maxAgeSeconds;
|
|
407
|
+
}
|
|
373
408
|
|
|
374
409
|
const channel = cleanString(body.channel, "channel", { max: 120 });
|
|
375
410
|
if (channel) input.channel = channel;
|
|
@@ -438,6 +473,7 @@ function normalizeChannelBindingInput(body: unknown): {
|
|
|
438
473
|
|
|
439
474
|
function routeTargetFromAddress(target: string): ChannelRouteTarget {
|
|
440
475
|
if (target.startsWith("pool:")) return { type: "pool", id: target.slice("pool:".length) };
|
|
476
|
+
if (target.startsWith("policy:")) return { type: "policy", id: target.slice("policy:".length) };
|
|
441
477
|
if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
|
|
442
478
|
if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
|
|
443
479
|
if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
|
|
@@ -456,6 +492,7 @@ function messageTargetForChannelTarget(target: ChannelRouteTarget, binding?: Cha
|
|
|
456
492
|
if (target.type === "tag") return `tag:${target.id}`;
|
|
457
493
|
if (target.type === "capability") return `cap:${target.id}`;
|
|
458
494
|
if (target.type === "broadcast") return "broadcast";
|
|
495
|
+
if (target.type === "policy") return `policy:${target.id}`;
|
|
459
496
|
return target.id;
|
|
460
497
|
}
|
|
461
498
|
|
|
@@ -623,7 +660,7 @@ function resolveIntegrationEventTarget(input: IntegrationEventInput): Integratio
|
|
|
623
660
|
|
|
624
661
|
const binding = bindings[0]!;
|
|
625
662
|
const resolvedTarget = messageTargetForChannelTarget(binding.target, binding);
|
|
626
|
-
if (!getAgent(resolvedTarget)) {
|
|
663
|
+
if (!resolvedTarget.startsWith("policy:") && !getAgent(resolvedTarget)) {
|
|
627
664
|
throw new ValidationError(`channel ${channelId} binding resolves to ${resolvedTarget}; integration channel targets must resolve to one backend agent`);
|
|
628
665
|
}
|
|
629
666
|
|
|
@@ -920,6 +957,25 @@ const postAgent: Handler = async (req) => {
|
|
|
920
957
|
const input = normalizeAgentInput(parsed.body);
|
|
921
958
|
const existing = getAgent(input.id);
|
|
922
959
|
const agent = upsertAgent(input);
|
|
960
|
+
const policyName = metaString(agent.meta, "policyName");
|
|
961
|
+
const spawnRequestId = metaString(agent.meta, "spawnRequestId");
|
|
962
|
+
if (policyName && spawnRequestId) {
|
|
963
|
+
const state = getManagedAgentState(policyName);
|
|
964
|
+
if (state?.spawnRequestId === spawnRequestId) {
|
|
965
|
+
const updatedState = updateManagedAgentState(policyName, {
|
|
966
|
+
status: "running",
|
|
967
|
+
agentId: agent.id,
|
|
968
|
+
healthySince: Date.now(),
|
|
969
|
+
lastError: undefined,
|
|
970
|
+
});
|
|
971
|
+
if (updatedState) emitManagedAgentStateChanged(policyName, updatedState as unknown as Record<string, unknown>);
|
|
972
|
+
const available = resolveQueuedPolicyMessages(policyName, agent.id);
|
|
973
|
+
if (available.length) {
|
|
974
|
+
emitMessageAvailable(policyName, agent.id, available);
|
|
975
|
+
for (const message of available) emitNewMessage(message);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
923
979
|
emitAgentStatus(agent.id);
|
|
924
980
|
if (!existing) {
|
|
925
981
|
auditEvent({
|
|
@@ -1424,24 +1480,36 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
|
|
|
1424
1480
|
return error(`orchestrator does not support provider: ${provider}`);
|
|
1425
1481
|
}
|
|
1426
1482
|
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
1427
|
-
if (cwd && !cwd
|
|
1483
|
+
if (cwd && !pathWithinBase(cwd, orch.baseDir)) {
|
|
1428
1484
|
return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
|
|
1429
1485
|
}
|
|
1430
1486
|
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
1431
1487
|
const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
|
|
1432
1488
|
const prompt = cleanString(parsed.body.prompt, "prompt", { max: 4000 });
|
|
1489
|
+
const tags = cleanStringArray(parsed.body.tags, "tags") ?? [];
|
|
1490
|
+
const capabilities = cleanStringArray(parsed.body.capabilities, "capabilities") ?? [];
|
|
1491
|
+
const providerArgs = cleanStringArray(parsed.body.providerArgs, "providerArgs") ?? [];
|
|
1492
|
+
const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
|
|
1493
|
+
const spawnRequestId = cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 });
|
|
1433
1494
|
|
|
1434
1495
|
const command = createCommand({
|
|
1435
1496
|
type: "agent.spawn",
|
|
1436
1497
|
source: "system",
|
|
1437
1498
|
target: orch.agentId,
|
|
1499
|
+
correlationId: spawnRequestId,
|
|
1438
1500
|
params: {
|
|
1439
1501
|
action: "spawn",
|
|
1440
1502
|
provider,
|
|
1441
1503
|
cwd: cwd || orch.baseDir,
|
|
1442
1504
|
label,
|
|
1505
|
+
tags,
|
|
1506
|
+
capabilities,
|
|
1443
1507
|
approvalMode,
|
|
1508
|
+
permissionMode: approvalMode,
|
|
1509
|
+
providerArgs,
|
|
1444
1510
|
prompt,
|
|
1511
|
+
policyName,
|
|
1512
|
+
spawnRequestId,
|
|
1445
1513
|
requestedBy: "dashboard",
|
|
1446
1514
|
requestedAt: Date.now(),
|
|
1447
1515
|
},
|
|
@@ -1475,12 +1543,17 @@ const postOrchestratorAction: Handler = async (req, params) => {
|
|
|
1475
1543
|
const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown"] as const);
|
|
1476
1544
|
if (!action) return error("action required");
|
|
1477
1545
|
const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
|
|
1546
|
+
const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
|
|
1547
|
+
const spawnRequestId = cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 });
|
|
1548
|
+
const tmuxSession = cleanString(parsed.body.tmuxSession, "tmuxSession", { max: 200 });
|
|
1549
|
+
const reason = cleanString(parsed.body.reason, "reason", { max: 200 }) ?? "manual";
|
|
1478
1550
|
|
|
1479
1551
|
const command = createCommand({
|
|
1480
1552
|
type: action === "restart" ? "agent.restart" : "agent.shutdown",
|
|
1481
1553
|
source: "system",
|
|
1482
1554
|
target: agentId || orch.agentId,
|
|
1483
|
-
|
|
1555
|
+
correlationId: spawnRequestId,
|
|
1556
|
+
params: { action, agentId, policyName, spawnRequestId, tmuxSession, reason, requestedBy: "dashboard", requestedAt: Date.now(), orchestratorId: orch.id },
|
|
1484
1557
|
});
|
|
1485
1558
|
emitCommand(command);
|
|
1486
1559
|
auditEvent({
|
|
@@ -1517,6 +1590,34 @@ const getOrchestratorDirectories: Handler = async (req, params) => {
|
|
|
1517
1590
|
}
|
|
1518
1591
|
};
|
|
1519
1592
|
|
|
1593
|
+
async function proxyOrchestratorGet(req: Request, orchestratorId: string, path: string): Promise<Response> {
|
|
1594
|
+
const orch = getOrchestrator(orchestratorId);
|
|
1595
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
1596
|
+
if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
|
|
1597
|
+
if (orch.status !== "online") return error("orchestrator is offline", 422);
|
|
1598
|
+
const incoming = new URL(req.url);
|
|
1599
|
+
const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
|
|
1600
|
+
const headers: Record<string, string> = {};
|
|
1601
|
+
const relayToken = process.env.AGENT_RELAY_TOKEN;
|
|
1602
|
+
if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
|
|
1603
|
+
try {
|
|
1604
|
+
const res = await fetch(proxyUrl, { headers, signal: AbortSignal.timeout(10_000) });
|
|
1605
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1606
|
+
if (contentType.includes("text/event-stream")) {
|
|
1607
|
+
return new Response(res.body, { status: res.status, headers: { "Content-Type": contentType } });
|
|
1608
|
+
}
|
|
1609
|
+
const body = await res.text();
|
|
1610
|
+
return new Response(body, { status: res.status, headers: { "Content-Type": contentType || "application/json" } });
|
|
1611
|
+
} catch (e) {
|
|
1612
|
+
return error(`Failed to reach orchestrator API: ${(e as Error).message}`, 502);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
const getOrchestratorProviders: Handler = (req, params) => proxyOrchestratorGet(req, params.id!, "/api/providers");
|
|
1617
|
+
const getOrchestratorSessions: Handler = (req, params) => proxyOrchestratorGet(req, params.id!, "/api/sessions");
|
|
1618
|
+
const getOrchestratorVersion: Handler = (req, params) => proxyOrchestratorGet(req, params.id!, "/api/version");
|
|
1619
|
+
const getOrchestratorLogs: Handler = (req, params) => proxyOrchestratorGet(req, params.id!, `/api/logs/${encodeURIComponent(params.session!)}`);
|
|
1620
|
+
|
|
1520
1621
|
const deleteOrchestratorById: Handler = (_req, params) => {
|
|
1521
1622
|
const deleted = deleteOrchestrator(params.id!);
|
|
1522
1623
|
if (!deleted) return error("orchestrator not found", 404);
|
|
@@ -1612,6 +1713,262 @@ const patchCommand: Handler = async (req, params) => {
|
|
|
1612
1713
|
}
|
|
1613
1714
|
};
|
|
1614
1715
|
|
|
1716
|
+
// --- Config routes ---
|
|
1717
|
+
|
|
1718
|
+
function normalizeConfigPathParam(raw: string | undefined, field: string): string {
|
|
1719
|
+
return cleanString(raw, field, { required: true, max: 240 })!;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
const getConfigNamespace: Handler = (_req, params) => {
|
|
1723
|
+
const namespace = normalizeConfigPathParam(params.namespace, "namespace");
|
|
1724
|
+
return json(listConfig(namespace));
|
|
1725
|
+
};
|
|
1726
|
+
|
|
1727
|
+
const getConfigKey: Handler = (_req, params) => {
|
|
1728
|
+
const namespace = normalizeConfigPathParam(params.namespace, "namespace");
|
|
1729
|
+
const key = normalizeConfigPathParam(params.key, "key");
|
|
1730
|
+
const entry = getConfig(namespace, key);
|
|
1731
|
+
return entry ? json(entry) : error("config not found", 404);
|
|
1732
|
+
};
|
|
1733
|
+
|
|
1734
|
+
const putConfigKey: Handler = async (req, params) => {
|
|
1735
|
+
const parsed = await parseBody<unknown>(req);
|
|
1736
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1737
|
+
try {
|
|
1738
|
+
if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
|
|
1739
|
+
if (!Object.prototype.hasOwnProperty.call(parsed.body, "value")) throw new ValidationError("value required");
|
|
1740
|
+
const namespace = normalizeConfigPathParam(params.namespace, "namespace");
|
|
1741
|
+
const key = normalizeConfigPathParam(params.key, "key");
|
|
1742
|
+
const updatedBy = cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 });
|
|
1743
|
+
const entry = setConfig(namespace, key, parsed.body.value, updatedBy);
|
|
1744
|
+
emitConfigChanged(entry.namespace, entry.key, entry.version);
|
|
1745
|
+
return json(entry, entry.version === 1 ? 201 : 200);
|
|
1746
|
+
} catch (e) {
|
|
1747
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1748
|
+
throw e;
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
const deleteConfigKey: Handler = (req, params) => {
|
|
1753
|
+
try {
|
|
1754
|
+
const namespace = normalizeConfigPathParam(params.namespace, "namespace");
|
|
1755
|
+
const key = normalizeConfigPathParam(params.key, "key");
|
|
1756
|
+
const updatedBy = cleanString(new URL(req.url).searchParams.get("updatedBy") ?? undefined, "updatedBy", { max: 200 });
|
|
1757
|
+
const existing = getConfig(namespace, key);
|
|
1758
|
+
if (!existing) return error("config not found", 404);
|
|
1759
|
+
deleteConfig(namespace, key, updatedBy);
|
|
1760
|
+
emitConfigChanged(namespace, key, existing.version + 1);
|
|
1761
|
+
return json({ ok: true });
|
|
1762
|
+
} catch (e) {
|
|
1763
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1764
|
+
throw e;
|
|
1765
|
+
}
|
|
1766
|
+
};
|
|
1767
|
+
|
|
1768
|
+
const getConfigKeyHistory: Handler = (req, params) => {
|
|
1769
|
+
try {
|
|
1770
|
+
const namespace = normalizeConfigPathParam(params.namespace, "namespace");
|
|
1771
|
+
const key = normalizeConfigPathParam(params.key, "key");
|
|
1772
|
+
const limitRaw = parseQueryInt(new URL(req.url).searchParams.get("limit"), { min: 1, max: 500 });
|
|
1773
|
+
if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
|
|
1774
|
+
return json(getConfigHistory(namespace, key, limitRaw ?? undefined));
|
|
1775
|
+
} catch (e) {
|
|
1776
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1777
|
+
throw e;
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
|
|
1781
|
+
// --- Spawn policy routes ---
|
|
1782
|
+
|
|
1783
|
+
function spawnRequestId(): string {
|
|
1784
|
+
return `sp_${crypto.randomUUID()}`;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function policyStatusPayload(policy: SpawnPolicy) {
|
|
1788
|
+
return {
|
|
1789
|
+
policy,
|
|
1790
|
+
state: getManagedAgentState(policy.name) ?? {
|
|
1791
|
+
policyName: policy.name,
|
|
1792
|
+
status: "stopped",
|
|
1793
|
+
orchestratorId: policy.orchestratorId,
|
|
1794
|
+
provider: policy.provider,
|
|
1795
|
+
restartCount: 0,
|
|
1796
|
+
consecutiveFailures: 0,
|
|
1797
|
+
updatedAt: 0,
|
|
1798
|
+
},
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
function managedSpawnParams(policy: SpawnPolicy, requestId: string): Record<string, unknown> {
|
|
1803
|
+
return {
|
|
1804
|
+
action: "spawn",
|
|
1805
|
+
provider: policy.provider,
|
|
1806
|
+
cwd: policy.cwd,
|
|
1807
|
+
label: policy.label,
|
|
1808
|
+
tags: policy.tags,
|
|
1809
|
+
capabilities: policy.capabilities,
|
|
1810
|
+
approvalMode: policy.permissionMode,
|
|
1811
|
+
permissionMode: policy.permissionMode,
|
|
1812
|
+
providerArgs: policy.providerArgs,
|
|
1813
|
+
prompt: policy.prompt,
|
|
1814
|
+
headless: true,
|
|
1815
|
+
policyName: policy.name,
|
|
1816
|
+
spawnRequestId: requestId,
|
|
1817
|
+
requestedBy: "managed-agent",
|
|
1818
|
+
requestedAt: Date.now(),
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function requirePolicyAndOrchestrator(name: string): { policy: SpawnPolicy; orch: NonNullable<ReturnType<typeof getOrchestrator>> } | Response {
|
|
1823
|
+
const entry = getSpawnPolicy(name);
|
|
1824
|
+
if (!entry) return error("spawn policy not found", 404);
|
|
1825
|
+
const policy = entry.value;
|
|
1826
|
+
const orch = getOrchestrator(policy.orchestratorId);
|
|
1827
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
1828
|
+
return { policy, orch };
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
function enqueuePolicyStart(policy: SpawnPolicy, reason: string): Command | Response {
|
|
1832
|
+
const orch = getOrchestrator(policy.orchestratorId);
|
|
1833
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
1834
|
+
if (orch.status !== "online") return error("orchestrator is offline", 409);
|
|
1835
|
+
const requestId = spawnRequestId();
|
|
1836
|
+
const state = upsertManagedAgentState({
|
|
1837
|
+
policyName: policy.name,
|
|
1838
|
+
status: "starting",
|
|
1839
|
+
orchestratorId: policy.orchestratorId,
|
|
1840
|
+
provider: policy.provider,
|
|
1841
|
+
spawnRequestId: requestId,
|
|
1842
|
+
lastSpawnAt: Date.now(),
|
|
1843
|
+
});
|
|
1844
|
+
emitManagedAgentStateChanged(policy.name, state as unknown as Record<string, unknown>);
|
|
1845
|
+
const command = createCommand({
|
|
1846
|
+
type: "agent.spawn",
|
|
1847
|
+
source: "system",
|
|
1848
|
+
target: orch.agentId,
|
|
1849
|
+
correlationId: requestId,
|
|
1850
|
+
params: {
|
|
1851
|
+
...managedSpawnParams(policy, requestId),
|
|
1852
|
+
reason,
|
|
1853
|
+
orchestratorId: orch.id,
|
|
1854
|
+
},
|
|
1855
|
+
});
|
|
1856
|
+
emitCommand(command);
|
|
1857
|
+
return command;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
function enqueuePolicyStop(policy: SpawnPolicy, action: "shutdown" | "restart", reason: string): Command | Response {
|
|
1861
|
+
const orch = getOrchestrator(policy.orchestratorId);
|
|
1862
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
1863
|
+
const state = getManagedAgentState(policy.name);
|
|
1864
|
+
const nextState = upsertManagedAgentState({
|
|
1865
|
+
policyName: policy.name,
|
|
1866
|
+
status: action === "restart" ? "starting" : "stopping",
|
|
1867
|
+
agentId: state?.agentId,
|
|
1868
|
+
orchestratorId: policy.orchestratorId,
|
|
1869
|
+
provider: policy.provider,
|
|
1870
|
+
tmuxSession: state?.tmuxSession,
|
|
1871
|
+
spawnRequestId: state?.spawnRequestId,
|
|
1872
|
+
lastSpawnAt: state?.lastSpawnAt,
|
|
1873
|
+
lastStopAt: Date.now(),
|
|
1874
|
+
restartCount: state?.restartCount ?? 0,
|
|
1875
|
+
consecutiveFailures: state?.consecutiveFailures ?? 0,
|
|
1876
|
+
});
|
|
1877
|
+
emitManagedAgentStateChanged(policy.name, nextState as unknown as Record<string, unknown>);
|
|
1878
|
+
const command = createCommand({
|
|
1879
|
+
type: action === "restart" ? "agent.restart" : "agent.shutdown",
|
|
1880
|
+
source: "system",
|
|
1881
|
+
target: orch.agentId,
|
|
1882
|
+
correlationId: state?.spawnRequestId,
|
|
1883
|
+
params: {
|
|
1884
|
+
action,
|
|
1885
|
+
policyName: policy.name,
|
|
1886
|
+
spawnRequestId: state?.spawnRequestId,
|
|
1887
|
+
agentId: state?.agentId,
|
|
1888
|
+
tmuxSession: state?.tmuxSession,
|
|
1889
|
+
graceful: true,
|
|
1890
|
+
timeoutMs: 10_000,
|
|
1891
|
+
reason,
|
|
1892
|
+
orchestratorId: orch.id,
|
|
1893
|
+
requestedBy: "managed-agent",
|
|
1894
|
+
requestedAt: Date.now(),
|
|
1895
|
+
},
|
|
1896
|
+
});
|
|
1897
|
+
emitCommand(command);
|
|
1898
|
+
return command;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
const getSpawnPoliciesRoute: Handler = () => {
|
|
1902
|
+
return json(listSpawnPolicies().map((entry) => policyStatusPayload(entry.value)));
|
|
1903
|
+
};
|
|
1904
|
+
|
|
1905
|
+
const getSpawnPolicyRoute: Handler = (_req, params) => {
|
|
1906
|
+
const entry = getSpawnPolicy(params.name!);
|
|
1907
|
+
return entry ? json(entry) : error("spawn policy not found", 404);
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1910
|
+
const putSpawnPolicyRoute: Handler = async (req, params) => {
|
|
1911
|
+
const parsed = await parseBody<unknown>(req);
|
|
1912
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1913
|
+
try {
|
|
1914
|
+
const value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
|
|
1915
|
+
? parsed.body.value
|
|
1916
|
+
: parsed.body;
|
|
1917
|
+
const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
|
|
1918
|
+
const entry = setConfig("spawn-policy", params.name!, value, updatedBy);
|
|
1919
|
+
emitConfigChanged(entry.namespace, entry.key, entry.version);
|
|
1920
|
+
return json(entry, entry.version === 1 ? 201 : 200);
|
|
1921
|
+
} catch (e) {
|
|
1922
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
1923
|
+
throw e;
|
|
1924
|
+
}
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1927
|
+
const deleteSpawnPolicyRoute: Handler = (req, params) => {
|
|
1928
|
+
const entry = getSpawnPolicy(params.name!);
|
|
1929
|
+
if (!entry) return error("spawn policy not found", 404);
|
|
1930
|
+
const stop = enqueuePolicyStop(entry.value, "shutdown", "policy-deleted");
|
|
1931
|
+
if (stop instanceof Response) return stop;
|
|
1932
|
+
deleteConfig("spawn-policy", params.name!, new URL(req.url).searchParams.get("updatedBy") ?? undefined);
|
|
1933
|
+
emitConfigChanged("spawn-policy", params.name!, entry.version + 1);
|
|
1934
|
+
return json({ ok: true, command: stop }, 202);
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
const postSpawnPolicyStart: Handler = (_req, params) => {
|
|
1938
|
+
const resolved = requirePolicyAndOrchestrator(params.name!);
|
|
1939
|
+
if (resolved instanceof Response) return resolved;
|
|
1940
|
+
const command = enqueuePolicyStart(resolved.policy, "manual-start");
|
|
1941
|
+
return command instanceof Response ? command : json({ ok: true, command }, 202);
|
|
1942
|
+
};
|
|
1943
|
+
|
|
1944
|
+
const postSpawnPolicyStop: Handler = (_req, params) => {
|
|
1945
|
+
const resolved = requirePolicyAndOrchestrator(params.name!);
|
|
1946
|
+
if (resolved instanceof Response) return resolved;
|
|
1947
|
+
const command = enqueuePolicyStop(resolved.policy, "shutdown", "manual-stop");
|
|
1948
|
+
return command instanceof Response ? command : json({ ok: true, command }, 202);
|
|
1949
|
+
};
|
|
1950
|
+
|
|
1951
|
+
const postSpawnPolicyRestart: Handler = (_req, params) => {
|
|
1952
|
+
const resolved = requirePolicyAndOrchestrator(params.name!);
|
|
1953
|
+
if (resolved instanceof Response) return resolved;
|
|
1954
|
+
const command = enqueuePolicyStop(resolved.policy, "restart", "manual-restart");
|
|
1955
|
+
return command instanceof Response ? command : json({ ok: true, command }, 202);
|
|
1956
|
+
};
|
|
1957
|
+
|
|
1958
|
+
const getSpawnPolicyStatus: Handler = (_req, params) => {
|
|
1959
|
+
const entry = getSpawnPolicy(params.name!);
|
|
1960
|
+
return entry ? json(policyStatusPayload(entry.value)) : error("spawn policy not found", 404);
|
|
1961
|
+
};
|
|
1962
|
+
|
|
1963
|
+
const getSpawnPoliciesHealth: Handler = () => {
|
|
1964
|
+
return json(listSpawnPolicies().map((entry) => policyStatusPayload(entry.value)));
|
|
1965
|
+
};
|
|
1966
|
+
|
|
1967
|
+
const getSpawnPolicyHealth: Handler = (_req, params) => {
|
|
1968
|
+
const entry = getSpawnPolicy(params.name!);
|
|
1969
|
+
return entry ? json(policyStatusPayload(entry.value)) : error("spawn policy not found", 404);
|
|
1970
|
+
};
|
|
1971
|
+
|
|
1615
1972
|
// --- Provider config routes ---
|
|
1616
1973
|
|
|
1617
1974
|
const VALID_PROVIDER_CONFIGS = ["claude", "codex"] as const;
|
|
@@ -1705,6 +2062,21 @@ const deleteCommandById: Handler = (_req, params) => {
|
|
|
1705
2062
|
|
|
1706
2063
|
const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system"];
|
|
1707
2064
|
|
|
2065
|
+
const getQueuedMessagesRoute: Handler = (req) => {
|
|
2066
|
+
const url = new URL(req.url);
|
|
2067
|
+
const target = cleanString(url.searchParams.get("for") ?? undefined, "for", { required: true, max: 240 })!;
|
|
2068
|
+
const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
|
|
2069
|
+
if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
|
|
2070
|
+
return json(listQueuedMessages(target, limitRaw ?? undefined));
|
|
2071
|
+
};
|
|
2072
|
+
|
|
2073
|
+
const getMessageStatusRoute: Handler = (_req, params) => {
|
|
2074
|
+
const id = parseId(params.id);
|
|
2075
|
+
if (id === null) return error("invalid message id");
|
|
2076
|
+
const status = getMessageDeliveryStatus(id);
|
|
2077
|
+
return status ? json(status) : error("message not found", 404);
|
|
2078
|
+
};
|
|
2079
|
+
|
|
1708
2080
|
const postMessage: Handler = async (req) => {
|
|
1709
2081
|
const parsed = await parseBody<unknown>(req);
|
|
1710
2082
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -1724,7 +2096,8 @@ const postMessage: Handler = async (req) => {
|
|
|
1724
2096
|
}
|
|
1725
2097
|
const result = sendMessageWithResult(input);
|
|
1726
2098
|
if (result.created) {
|
|
1727
|
-
|
|
2099
|
+
if (result.message.deliveryStatus === "queued") emitMessageQueued(result.message);
|
|
2100
|
+
else emitNewMessage(result.message);
|
|
1728
2101
|
const isWork = result.message.kind === "task";
|
|
1729
2102
|
auditEvent({
|
|
1730
2103
|
clientId: "server-message-" + result.message.id,
|
|
@@ -2221,7 +2594,9 @@ const postChannelEvent: Handler = async (req, params) => {
|
|
|
2221
2594
|
claimable: false,
|
|
2222
2595
|
}));
|
|
2223
2596
|
for (const result of results) {
|
|
2224
|
-
if (result.created)
|
|
2597
|
+
if (!result.created) continue;
|
|
2598
|
+
if (result.message.deliveryStatus === "queued") emitMessageQueued(result.message);
|
|
2599
|
+
else emitNewMessage(result.message);
|
|
2225
2600
|
}
|
|
2226
2601
|
return json({
|
|
2227
2602
|
messages: results.map((result) => result.message),
|
|
@@ -2570,12 +2945,14 @@ const getHealthRoute: Handler = () => json(getHealth());
|
|
|
2570
2945
|
const postSystemReap: Handler = () => {
|
|
2571
2946
|
const released = releaseExpiredClaims();
|
|
2572
2947
|
const releasedOrphans = releaseOrphanedTasks();
|
|
2948
|
+
const expiredQueuedMessages = expireQueuedMessages();
|
|
2573
2949
|
const expiredCommands = expireCommands();
|
|
2574
2950
|
const reapedAgentIds = reapStaleAgents();
|
|
2575
2951
|
const reapedOrchestratorIds = reapStaleOrchestrators();
|
|
2576
2952
|
for (const id of released.messageIds) emitMessageClaimReleased(id);
|
|
2577
2953
|
for (const task of released.tasks) emitTaskChanged(task, "task.updated");
|
|
2578
2954
|
for (const task of releasedOrphans) emitTaskChanged(task, "task.updated");
|
|
2955
|
+
for (const message of expiredQueuedMessages) emitMessageExpired(message);
|
|
2579
2956
|
for (const command of expiredCommands) {
|
|
2580
2957
|
applyCommandToRecipe(command);
|
|
2581
2958
|
emitCommand(command);
|
|
@@ -2600,7 +2977,7 @@ const postSystemReap: Handler = () => {
|
|
|
2600
2977
|
clientId: "server-system-reap-" + Date.now(),
|
|
2601
2978
|
kind: "state",
|
|
2602
2979
|
title: "Maintenance reaper run",
|
|
2603
|
-
body: `${reapedAgentIds.length} stale agent(s), ${reapedOrchestratorIds.length} stale orchestrator(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s), ${expiredCommands.length} command(s)`,
|
|
2980
|
+
body: `${reapedAgentIds.length} stale agent(s), ${reapedOrchestratorIds.length} stale orchestrator(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s), ${expiredQueuedMessages.length} queued message(s), ${expiredCommands.length} command(s)`,
|
|
2604
2981
|
icon: "ti-broom",
|
|
2605
2982
|
view: "activity",
|
|
2606
2983
|
});
|
|
@@ -2611,6 +2988,7 @@ const postSystemReap: Handler = () => {
|
|
|
2611
2988
|
releasedMessageIds: released.messageIds,
|
|
2612
2989
|
releasedTaskIds: released.tasks.map((task) => task.id),
|
|
2613
2990
|
releasedOrphanedTaskIds: releasedOrphans.map((task) => task.id),
|
|
2991
|
+
expiredQueuedMessageIds: expiredQueuedMessages.map((message) => message.id),
|
|
2614
2992
|
expiredCommandIds: expiredCommands.map((command) => command.id),
|
|
2615
2993
|
});
|
|
2616
2994
|
};
|
|
@@ -2660,6 +3038,10 @@ const routes: Route[] = [
|
|
|
2660
3038
|
route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
|
|
2661
3039
|
route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
|
|
2662
3040
|
route("GET", "/api/orchestrators/:id/directories", getOrchestratorDirectories),
|
|
3041
|
+
route("GET", "/api/orchestrators/:id/providers", getOrchestratorProviders),
|
|
3042
|
+
route("GET", "/api/orchestrators/:id/sessions", getOrchestratorSessions),
|
|
3043
|
+
route("GET", "/api/orchestrators/:id/version", getOrchestratorVersion),
|
|
3044
|
+
route("GET", "/api/orchestrators/:id/logs/:session", getOrchestratorLogs),
|
|
2663
3045
|
route("DELETE", "/api/orchestrators/:id", deleteOrchestratorById),
|
|
2664
3046
|
|
|
2665
3047
|
route("POST", "/api/system/broadcast", postSystemBroadcast),
|
|
@@ -2678,6 +3060,21 @@ const routes: Route[] = [
|
|
|
2678
3060
|
route("GET", "/api/commands/:id", getCommandById),
|
|
2679
3061
|
route("PATCH", "/api/commands/:id", patchCommand),
|
|
2680
3062
|
route("DELETE", "/api/commands/:id", deleteCommandById),
|
|
3063
|
+
route("GET", "/api/config/:namespace", getConfigNamespace),
|
|
3064
|
+
route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
|
|
3065
|
+
route("GET", "/api/config/:namespace/:key", getConfigKey),
|
|
3066
|
+
route("PUT", "/api/config/:namespace/:key", putConfigKey),
|
|
3067
|
+
route("DELETE", "/api/config/:namespace/:key", deleteConfigKey),
|
|
3068
|
+
route("GET", "/api/spawn-policies", getSpawnPoliciesRoute),
|
|
3069
|
+
route("GET", "/api/spawn-policy/:name", getSpawnPolicyRoute),
|
|
3070
|
+
route("PUT", "/api/spawn-policy/:name", putSpawnPolicyRoute),
|
|
3071
|
+
route("DELETE", "/api/spawn-policy/:name", deleteSpawnPolicyRoute),
|
|
3072
|
+
route("POST", "/api/spawn-policy/:name/start", postSpawnPolicyStart),
|
|
3073
|
+
route("POST", "/api/spawn-policy/:name/stop", postSpawnPolicyStop),
|
|
3074
|
+
route("POST", "/api/spawn-policy/:name/restart", postSpawnPolicyRestart),
|
|
3075
|
+
route("GET", "/api/spawn-policies/health", getSpawnPoliciesHealth),
|
|
3076
|
+
route("GET", "/api/spawn-policy/:name/status", getSpawnPolicyStatus),
|
|
3077
|
+
route("GET", "/api/spawn-policy/:name/health", getSpawnPolicyHealth),
|
|
2681
3078
|
route("GET", "/api/providers/config", getProviderConfigsRoute),
|
|
2682
3079
|
route("GET", "/api/providers/:provider/config", getProviderConfigRoute),
|
|
2683
3080
|
route("PUT", "/api/providers/:provider/config", putProviderConfigRoute),
|
|
@@ -2685,7 +3082,9 @@ const routes: Route[] = [
|
|
|
2685
3082
|
|
|
2686
3083
|
route("POST", "/api/messages", postMessage),
|
|
2687
3084
|
route("GET", "/api/messages", getMessages),
|
|
3085
|
+
route("GET", "/api/messages/queued", getQueuedMessagesRoute),
|
|
2688
3086
|
route("GET", "/api/messages/cursor", getCursorRoute),
|
|
3087
|
+
route("GET", "/api/messages/:id/status", getMessageStatusRoute),
|
|
2689
3088
|
route("GET", "/api/messages/:id", getMessageById),
|
|
2690
3089
|
route("GET", "/api/messages/:id/thread", getMessageThread),
|
|
2691
3090
|
route("POST", "/api/messages/:id/claim", postClaimMessage),
|
package/src/sse.ts
CHANGED
|
@@ -70,6 +70,7 @@ function send(conn: Connection, event: string, data: unknown) {
|
|
|
70
70
|
function messageMatchesAgent(msg: Message, agentId: string): boolean {
|
|
71
71
|
const agent = getAgent(agentId);
|
|
72
72
|
if (!agent) return false;
|
|
73
|
+
if (msg.resolvedToAgent === agentId) return true;
|
|
73
74
|
if (msg.to === agentId || msg.from === agentId) return true;
|
|
74
75
|
if (msg.to === "broadcast") return true;
|
|
75
76
|
if (msg.to.startsWith("tag:") && agent.tags.includes(msg.to.slice(4))) return true;
|
|
@@ -130,6 +131,23 @@ export function emitMessageDeleted(messageId: number) {
|
|
|
130
131
|
emitRelayEvent({ type: "message.deleted", source: "server", subject: String(messageId), data: { messageId } });
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
export function emitMessageQueued(msg: Message) {
|
|
135
|
+
emitRelayEvent({ type: "message.queued", source: msg.from, subject: String(msg.id), data: msg as unknown as Record<string, unknown> });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function emitMessageAvailable(policyName: string, agentId: string, messages: Message[]) {
|
|
139
|
+
emitRelayEvent({
|
|
140
|
+
type: "message.available",
|
|
141
|
+
source: "server",
|
|
142
|
+
subject: `policy:${policyName}`,
|
|
143
|
+
data: { policyName, agentId, messageIds: messages.map((message) => message.id), count: messages.length },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function emitMessageExpired(msg: Message) {
|
|
148
|
+
emitRelayEvent({ type: "message.expired", source: "server", subject: String(msg.id), data: msg as unknown as Record<string, unknown> });
|
|
149
|
+
}
|
|
150
|
+
|
|
133
151
|
function sendTaskChanged(task: Task, eventType = "task.updated"): void {
|
|
134
152
|
for (const conn of connections.values()) {
|
|
135
153
|
if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId)) continue;
|
|
@@ -145,6 +163,14 @@ export function emitChannelActivity(activity: Record<string, unknown>) {
|
|
|
145
163
|
emitRelayEvent({ type: "channel.activity", source: "server", data: activity });
|
|
146
164
|
}
|
|
147
165
|
|
|
166
|
+
export function emitConfigChanged(namespace: string, key: string, version: number) {
|
|
167
|
+
emitRelayEvent({ type: "config.changed", source: "server", subject: `${namespace}:${key}`, data: { namespace, key, version } });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function emitManagedAgentStateChanged(policyName: string, data: Record<string, unknown>) {
|
|
171
|
+
emitRelayEvent({ type: "policy.state.changed", source: "server", subject: policyName, data });
|
|
172
|
+
}
|
|
173
|
+
|
|
148
174
|
export function getConnectionCount(): number {
|
|
149
175
|
return connections.size;
|
|
150
176
|
}
|