agent-relay-server 0.32.2 → 0.32.4

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.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/public/assets/display-JI19Vc7L.js.map +1 -1
  3. package/src/branch-landed.ts +38 -2
  4. package/src/cli.ts +3 -3
  5. package/src/maintenance.ts +21 -21
  6. package/src/mcp.ts +2 -2
  7. package/src/ratchet-files.ts +37 -0
  8. package/src/routes/_shared.ts +376 -0
  9. package/src/routes/activity.ts +61 -0
  10. package/src/routes/agent-profiles.ts +47 -0
  11. package/src/routes/agent-sessions.ts +488 -0
  12. package/src/routes/agents-spawn.ts +274 -0
  13. package/src/routes/agents.ts +251 -0
  14. package/src/routes/artifacts.ts +226 -0
  15. package/src/routes/automations.ts +83 -0
  16. package/src/routes/commands.ts +317 -0
  17. package/src/routes/config.ts +66 -0
  18. package/src/routes/connectors.ts +108 -0
  19. package/src/routes/inbox.ts +142 -0
  20. package/src/routes/index.ts +293 -0
  21. package/src/routes/insights.ts +81 -0
  22. package/src/routes/integrations.ts +592 -0
  23. package/src/routes/memory.ts +337 -0
  24. package/src/routes/messages.ts +529 -0
  25. package/src/routes/orchestrator-bootstrap.ts +100 -0
  26. package/src/routes/orchestrator-proxy.ts +160 -0
  27. package/src/routes/orchestrator.ts +490 -0
  28. package/src/routes/pairs.ts +197 -0
  29. package/src/routes/provider-config.ts +112 -0
  30. package/src/routes/recipes.ts +113 -0
  31. package/src/routes/spawn-policy.ts +231 -0
  32. package/src/routes/spec.ts +54 -0
  33. package/src/routes/sse.ts +9 -0
  34. package/src/routes/stats.ts +32 -0
  35. package/src/routes/steward.ts +45 -0
  36. package/src/routes/tasks.ts +174 -0
  37. package/src/routes/tokens.ts +311 -0
  38. package/src/routes/workspaces.ts +364 -0
  39. package/src/routes.ts +3 -6822
  40. package/src/validation.ts +134 -0
  41. package/src/workspace-actions.ts +7 -1
  42. package/src/workspace-merge.ts +12 -1
@@ -0,0 +1,160 @@
1
+ // Auto-split from routes.ts (#299). Domain: orchestrator-proxy.
2
+ import { RELAY_TOKEN_HEADER, SPAWN_PROVIDERS, isRecord } from "agent-relay-sdk";
3
+ import { ValidationError, getOrchestrator, orchestratorHeartbeat } from "../db";
4
+ import { authorizeRoute, cleanJsonArray, error, json, type Handler } from "./_shared";
5
+ import { cleanStringArray } from "../validation";
6
+ import { emitOrchestratorStatus } from "../sse";
7
+ import { type OrchestratorRuntimeInput, type SpawnProvider } from "../types";
8
+
9
+ export const getOrchestratorDirectories: Handler = async (req, params) => {
10
+ const orch = getOrchestrator(params.id!);
11
+ if (!orch) return error("orchestrator not found", 404);
12
+ if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
13
+ if (orch.status !== "online") return error("orchestrator is offline", 422);
14
+ const url = new URL(req.url);
15
+ const path = url.searchParams.get("path") || "";
16
+ const proxyUrl = `${orch.apiUrl}/api/directories${path ? `?path=${encodeURIComponent(path)}` : ""}`;
17
+ try {
18
+ const res = await fetch(proxyUrl, { signal: AbortSignal.timeout(5_000) });
19
+ const body = await res.json();
20
+ return json(body, res.status);
21
+ } catch (e) {
22
+ return error(`Failed to reach orchestrator API: ${(e as Error).message}`, 502);
23
+ }
24
+ };
25
+
26
+ export const postOrchestratorCreateDirectory: Handler = async (req, params) => {
27
+ const orch = getOrchestrator(params.id!);
28
+ if (!orch) return error("orchestrator not found", 404);
29
+ if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
30
+ if (orch.status !== "online") return error("orchestrator is offline", 422);
31
+ try {
32
+ const body = await req.json();
33
+ const res = await fetch(`${orch.apiUrl}/api/directories`, {
34
+ method: "POST",
35
+ headers: { "Content-Type": "application/json" },
36
+ body: JSON.stringify(body),
37
+ signal: AbortSignal.timeout(5_000),
38
+ });
39
+ const result = await res.json();
40
+ return json(result, res.status);
41
+ } catch (e) {
42
+ return error(`Failed to reach orchestrator API: ${(e as Error).message}`, 502);
43
+ }
44
+ };
45
+
46
+ export const getOrchestratorFilesList: Handler = (req, params) =>
47
+ proxyOrchestratorGet(req, params.id!, "/api/files/list");
48
+
49
+ export const getOrchestratorFileRead: Handler = (req, params) =>
50
+ proxyOrchestratorGet(req, params.id!, "/api/files/read");
51
+
52
+ export const getOrchestratorFileStat: Handler = (req, params) =>
53
+ proxyOrchestratorGet(req, params.id!, "/api/files/stat");
54
+
55
+ export const getOrchestratorWorkspaceProbe: Handler = (req, params) =>
56
+ proxyOrchestratorGet(req, params.id!, "/api/workspace/probe");
57
+
58
+ async function proxyOrchestratorGet(req: Request, orchestratorId: string, path: string): Promise<Response> {
59
+ const orch = getOrchestrator(orchestratorId);
60
+ if (!orch) return error("orchestrator not found", 404);
61
+ if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
62
+ if (orch.status !== "online") return error("orchestrator is offline", 422);
63
+ const incoming = new URL(req.url);
64
+ const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
65
+ const headers: Record<string, string> = {};
66
+ const relayToken = process.env.AGENT_RELAY_TOKEN;
67
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
68
+ try {
69
+ const res = await fetch(proxyUrl, { headers, signal: AbortSignal.timeout(10_000) });
70
+ const contentType = res.headers.get("content-type") ?? "";
71
+ if (contentType.includes("text/event-stream")) {
72
+ return new Response(res.body, { status: res.status, headers: { "Content-Type": contentType } });
73
+ }
74
+ const body = await res.text();
75
+ return new Response(body, { status: res.status, headers: { "Content-Type": contentType || "application/json" } });
76
+ } catch (e) {
77
+ return error(`Failed to reach orchestrator API: ${(e as Error).message}`, 502);
78
+ }
79
+ }
80
+
81
+ async function proxyOrchestratorPost(req: Request, orchestratorId: string, path: string): Promise<Response> {
82
+ const orch = getOrchestrator(orchestratorId);
83
+ if (!orch) return error("orchestrator not found", 404);
84
+ if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
85
+ if (orch.status !== "online") return error("orchestrator is offline", 422);
86
+ const incoming = new URL(req.url);
87
+ const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
88
+ const headers: Record<string, string> = {
89
+ "Content-Type": req.headers.get("content-type") ?? "application/json",
90
+ };
91
+ const relayToken = process.env.AGENT_RELAY_TOKEN;
92
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
93
+ try {
94
+ const body = await req.text();
95
+ const res = await fetch(proxyUrl, {
96
+ method: "POST",
97
+ headers,
98
+ body,
99
+ signal: AbortSignal.timeout(10_000),
100
+ });
101
+ const contentType = res.headers.get("content-type") ?? "";
102
+ return new Response(await res.text(), { status: res.status, headers: { "Content-Type": contentType || "application/json" } });
103
+ } catch (e) {
104
+ return error(`Failed to reach orchestrator API: ${(e as Error).message}`, 502);
105
+ }
106
+ }
107
+
108
+ export const getOrchestratorProviders: Handler = async (req, params) => {
109
+ const res = await proxyOrchestratorGet(req, params.id!, "/api/providers");
110
+ if (!res.ok) return res;
111
+ const incoming = new URL(req.url);
112
+ if (incoming.searchParams.get("refresh") !== "1") return res;
113
+
114
+ const payload = await res.clone().json().catch(() => null) as unknown;
115
+ if (!isRecord(payload)) return res;
116
+ try {
117
+ const providers = cleanStringArray(payload.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined;
118
+ if (providers) {
119
+ for (const p of providers) {
120
+ if (!SPAWN_PROVIDERS.includes(p as any)) {
121
+ throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
122
+ }
123
+ }
124
+ }
125
+ const updated = orchestratorHeartbeat(params.id!, {
126
+ providers,
127
+ providerStatus: cleanJsonArray(payload.providerStatus, "providerStatus") as OrchestratorRuntimeInput["providerStatus"],
128
+ providerCatalog: cleanJsonArray(payload.providerCatalog, "providerCatalog") as OrchestratorRuntimeInput["providerCatalog"],
129
+ });
130
+ if (updated) emitOrchestratorStatus(params.id!);
131
+ } catch (e) {
132
+ if (e instanceof ValidationError) return error(e.message, 400);
133
+ throw e;
134
+ }
135
+ return res;
136
+ };
137
+
138
+ export const getOrchestratorSessions: Handler = (req, params) => proxyOrchestratorGet(req, params.id!, "/api/sessions");
139
+
140
+ export const getOrchestratorVersion: Handler = (req, params) => proxyOrchestratorGet(req, params.id!, "/api/version");
141
+
142
+ export const getOrchestratorLogs: Handler = (req, params) => {
143
+ const denied = authorizeRoute(req, { scope: "logs:read", resource: { orchestratorId: params.id!, logsRead: true } });
144
+ return denied ?? proxyOrchestratorGet(req, params.id!, `/api/logs/${encodeURIComponent(params.session!)}`);
145
+ };
146
+
147
+ export const getOrchestratorTerminal: Handler = (req, params) => {
148
+ const denied = authorizeRoute(req, { scope: "terminal:attach", resource: { orchestratorId: params.id!, terminalAttach: true } });
149
+ return denied ?? proxyOrchestratorGet(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}`);
150
+ };
151
+
152
+ export const postOrchestratorTerminalInput: Handler = (req, params) => {
153
+ const denied = authorizeRoute(req, { scope: "terminal:attach", resource: { orchestratorId: params.id!, terminalAttach: true } });
154
+ return denied ?? proxyOrchestratorPost(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}/input`);
155
+ };
156
+
157
+ export const postOrchestratorTerminalResize: Handler = (req, params) => {
158
+ const denied = authorizeRoute(req, { scope: "terminal:attach", resource: { orchestratorId: params.id!, terminalAttach: true } });
159
+ return denied ?? proxyOrchestratorPost(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}/resize`);
160
+ };
@@ -0,0 +1,490 @@
1
+ // Auto-split from routes.ts (#299). Domain: orchestrator.
2
+ import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
+ import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "../contracts";
4
+ import { VERSION } from "../config";
5
+ import { ValidationError, deleteOrchestrator, getOrchestrator, listOrchestrators, orchestratorHeartbeat, recordAgentExitDiagnostics, setOrchestratorUpgradeState, updateManagedAgents, upsertOrchestrator } from "../db";
6
+ import { auditEvent, authAuditMetadata, authorizeRoute, cleanJsonArray, cleanSafeNumber, dashboardAttribution, emitCommand, error, isRootCredentialRequest, json, parseBody, spawnRequestId, type Handler } from "./_shared";
7
+ import { cleanMeta, cleanString, cleanStringArray, cleanWorkspaceMetadata, optionalEnum } from "../validation";
8
+ import { createCommand, updateCommand } from "../commands-db";
9
+ import { emitAgentStatus, emitOrchestratorStatus } from "../sse";
10
+ import { getLifecycleManager } from "../lifecycle-manager";
11
+ import { issueOrchestratorRuntimeToken } from "../runtime-tokens";
12
+ import { type ManagedAgent, type ManagedSessionExitDiagnostics, type OrchestratorRuntimeInput, type RegisterOrchestratorInput, type SpawnApprovalMode, type SpawnProvider } from "../types";
13
+
14
+ function cleanProtocolVersion(value: unknown): number | undefined {
15
+ if (value === undefined) return undefined;
16
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
17
+ throw new ValidationError("protocolVersion must be a positive integer");
18
+ }
19
+
20
+ function cleanRuntimePackageMetadata(value: unknown): RuntimePackageMetadata | undefined {
21
+ if (value === undefined) return undefined;
22
+ const metadata = parseRuntimePackage(value);
23
+ if (!metadata) throw new ValidationError("package metadata must include name and version");
24
+ return metadata;
25
+ }
26
+
27
+ function cleanRuntimeContracts(value: unknown): RuntimeContracts | undefined {
28
+ if (value === undefined) return undefined;
29
+ if (!isRecord(value)) throw new ValidationError("contracts must be an object");
30
+ const contracts = parseRuntimeContracts(value);
31
+ const allowed = new Set(Object.keys(CONTRACT_VERSIONS));
32
+ for (const key of Object.keys(value)) {
33
+ if (!allowed.has(key)) throw new ValidationError(`unsupported contract: ${key}`);
34
+ if (contracts[key as keyof typeof CONTRACT_VERSIONS] === undefined) {
35
+ throw new ValidationError(`${key} must be a positive integer`);
36
+ }
37
+ }
38
+ return contracts;
39
+ }
40
+
41
+ function cleanRuntimeCapabilities(value: unknown): RuntimeCapabilities | undefined {
42
+ if (value === undefined) return undefined;
43
+ if (!isRecord(value)) throw new ValidationError("capabilities must be an object");
44
+ const capabilities = parseRuntimeCapabilities(value);
45
+ if (Object.keys(capabilities).length !== Object.keys(value).length) {
46
+ throw new ValidationError("capabilities values must be boolean");
47
+ }
48
+ return capabilities;
49
+ }
50
+
51
+ export const postOrchestrator: Handler = async (req) => {
52
+ const parsed = await parseBody<unknown>(req);
53
+ if (!parsed.ok) return error(parsed.error, parsed.status);
54
+ try {
55
+ if (!isRecord(parsed.body)) return error("body required");
56
+ const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
57
+ const hostname = cleanString(parsed.body.hostname, "hostname", { required: true, max: 120 })!;
58
+ const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
59
+ const providers = cleanStringArray(parsed.body.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined;
60
+ if (providers) {
61
+ for (const p of providers) {
62
+ if (!SPAWN_PROVIDERS.includes(p as any)) {
63
+ return error(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
64
+ }
65
+ }
66
+ }
67
+ const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys", { itemMax: 80, maxItems: 50 });
68
+ const apiUrl = cleanString(parsed.body.apiUrl, "apiUrl", { max: 500 });
69
+ const packageMetadata = cleanRuntimePackageMetadata(parsed.body.package);
70
+ const contracts = cleanRuntimeContracts(parsed.body.contracts);
71
+ const runtimeCapabilities = cleanRuntimeCapabilities(parsed.body.capabilities);
72
+ const version = cleanString(parsed.body.version, "version", { max: 80 });
73
+ const gitSha = cleanString(parsed.body.gitSha, "gitSha", { max: 80 });
74
+ const protocolVersion = cleanProtocolVersion(parsed.body.protocolVersion);
75
+ const providerStatus = cleanJsonArray(parsed.body.providerStatus, "providerStatus");
76
+ const providerCatalog = cleanJsonArray(parsed.body.providerCatalog, "providerCatalog");
77
+ const meta = cleanMeta(parsed.body.meta);
78
+ const orch = upsertOrchestrator({
79
+ id,
80
+ hostname,
81
+ providers: providers ?? ["claude", "codex"],
82
+ baseDir,
83
+ apiUrl,
84
+ envKeys,
85
+ package: packageMetadata,
86
+ contracts,
87
+ capabilities: runtimeCapabilities,
88
+ version,
89
+ protocolVersion,
90
+ gitSha,
91
+ providerStatus: providerStatus as RegisterOrchestratorInput["providerStatus"],
92
+ providerCatalog: providerCatalog as RegisterOrchestratorInput["providerCatalog"],
93
+ meta,
94
+ });
95
+ reconcileOrchestratorUpgrade(orch);
96
+ auditEvent({
97
+ clientId: "server-orchestrator-register-" + id + "-" + Date.now(),
98
+ kind: "state",
99
+ title: "Orchestrator registered",
100
+ body: hostname,
101
+ meta: id,
102
+ icon: "ti-server-2",
103
+ view: "orchestrators",
104
+ metadata: { orchestratorId: id, providers: orch.providers, ...authAuditMetadata(req) },
105
+ });
106
+ const runtimeToken = isRootCredentialRequest(req)
107
+ ? issueOrchestratorRuntimeToken({ orchestratorId: id, baseDir, createdBy: `orchestrator:${id}` })
108
+ : undefined;
109
+ return json(runtimeToken ? { ...orch, runtimeToken } : orch, 201);
110
+ } catch (e) {
111
+ if (e instanceof ValidationError) return error(e.message, 400);
112
+ throw e;
113
+ }
114
+ };
115
+
116
+ export const getOrchestrators: Handler = () => {
117
+ return json(listOrchestrators());
118
+ };
119
+
120
+ export const getOrchestratorById: Handler = (_req, params) => {
121
+ const orch = getOrchestrator(params.id!);
122
+ if (!orch) return error("orchestrator not found", 404);
123
+ return json(orch);
124
+ };
125
+
126
+ export const postOrchestratorHeartbeat: Handler = async (req, params) => {
127
+ const parsed = await parseBody<unknown>(req);
128
+ if (!parsed.ok) return error(parsed.error, parsed.status);
129
+ try {
130
+ const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: params.id! } });
131
+ if (denied) return denied;
132
+ const body = isRecord(parsed.body) ? parsed.body : {};
133
+ const runtime: OrchestratorRuntimeInput = {
134
+ package: cleanRuntimePackageMetadata(body.package),
135
+ contracts: cleanRuntimeContracts(body.contracts),
136
+ capabilities: cleanRuntimeCapabilities(body.capabilities),
137
+ version: cleanString(body.version, "version", { max: 80 }),
138
+ protocolVersion: cleanProtocolVersion(body.protocolVersion),
139
+ gitSha: cleanString(body.gitSha, "gitSha", { max: 80 }),
140
+ providers: cleanStringArray(body.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined,
141
+ providerStatus: cleanJsonArray(body.providerStatus, "providerStatus") as OrchestratorRuntimeInput["providerStatus"],
142
+ providerCatalog: cleanJsonArray(body.providerCatalog, "providerCatalog") as OrchestratorRuntimeInput["providerCatalog"],
143
+ };
144
+ if (runtime.providers) {
145
+ for (const p of runtime.providers) {
146
+ if (!SPAWN_PROVIDERS.includes(p as any)) throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
147
+ }
148
+ }
149
+ const orch = orchestratorHeartbeat(params.id!, runtime);
150
+ if (!orch) return error("orchestrator not found", 404);
151
+ reconcileOrchestratorUpgrade(orch);
152
+ return json({ ok: true });
153
+ } catch (e) {
154
+ if (e instanceof ValidationError) return error(e.message, 400);
155
+ throw e;
156
+ }
157
+ };
158
+
159
+ export const patchOrchestratorAgents: Handler = async (req, params) => {
160
+ const parsed = await parseBody<unknown>(req);
161
+ if (!parsed.ok) return error(parsed.error, parsed.status);
162
+ try {
163
+ const orch = getOrchestrator(params.id!);
164
+ if (!orch) return error("orchestrator not found", 404);
165
+ const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
166
+ if (denied) return denied;
167
+ if (!isRecord(parsed.body)) return error("body required");
168
+ const agents = parsed.body.agents;
169
+ if (!Array.isArray(agents)) return error("agents must be an array");
170
+ const cleaned: ManagedAgent[] = agents.map((a: any) => {
171
+ if (!isRecord(a)) throw new ValidationError("each agent must be an object");
172
+ const sessionName = cleanString(a.sessionName, "sessionName", { max: 240 })
173
+ ?? cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!;
174
+ return {
175
+ agentId: cleanString(a.agentId, "agentId", { max: 240 }) || "",
176
+ provider: optionalEnum(a.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider,
177
+ sessionName,
178
+ tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { max: 240 }) ?? sessionName,
179
+ supervisor: optionalEnum(a.supervisor, "supervisor", ["process", "systemd", "launchd", "unknown"] as const),
180
+ systemdUnit: cleanString(a.systemdUnit, "systemdUnit", { max: 240 }),
181
+ terminalSession: cleanString(a.terminalSession, "terminalSession", { max: 240 }),
182
+ terminalAvailable: typeof a.terminalAvailable === "boolean" ? a.terminalAvailable : undefined,
183
+ cwd: cleanString(a.cwd, "cwd", { required: true, max: 500 })!,
184
+ profile: cleanString(a.profile, "profile", { max: 120 }),
185
+ workspaceMode: optionalEnum(a.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
186
+ workspace: cleanWorkspaceMetadata(a.workspace, "workspace"),
187
+ label: cleanString(a.label, "label", { max: 120 }),
188
+ approvalMode: (optionalEnum(a.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") ?? "guarded") as SpawnApprovalMode,
189
+ policyName: cleanString(a.policyName, "policyName", { max: 120 }),
190
+ spawnRequestId: cleanString(a.spawnRequestId, "spawnRequestId", { max: 160 }),
191
+ automationRunId: cleanString(a.automationRunId, "automationRunId", { max: 160 }),
192
+ pid: typeof a.pid === "number" && Number.isSafeInteger(a.pid) ? a.pid : undefined,
193
+ startedAt: typeof a.startedAt === "number" ? a.startedAt : Date.now(),
194
+ };
195
+ });
196
+ const exitedAgents = parsed.body.exitedAgents === undefined
197
+ ? []
198
+ : Array.isArray(parsed.body.exitedAgents)
199
+ ? parsed.body.exitedAgents.map(cleanManagedSessionExitDiagnostics)
200
+ : (() => { throw new ValidationError("exitedAgents must be an array"); })();
201
+ const updated = updateManagedAgents(params.id!, cleaned);
202
+ for (const exited of exitedAgents) {
203
+ const agent = recordAgentExitDiagnostics(exited.agentId, exited);
204
+ if (agent) emitAgentStatus(agent.id);
205
+ auditEvent({
206
+ clientId: [
207
+ "managed-session-exit",
208
+ params.id!,
209
+ exited.spawnRequestId ?? exited.tmuxSession,
210
+ String(exited.detectedAt),
211
+ ].join(":"),
212
+ kind: "state",
213
+ title: "Managed agent exited",
214
+ body: exited.lastError,
215
+ meta: exited.policyName ?? exited.label ?? exited.tmuxSession,
216
+ icon: "ti-alert-triangle",
217
+ view: "agents",
218
+ agentId: exited.agentId,
219
+ metadata: {
220
+ severity: "warning",
221
+ orchestratorId: params.id!,
222
+ provider: exited.provider,
223
+ sessionName: exited.sessionName,
224
+ tmuxSession: exited.tmuxSession,
225
+ policyName: exited.policyName,
226
+ spawnRequestId: exited.spawnRequestId,
227
+ cwd: exited.cwd,
228
+ supervisor: exited.supervisor,
229
+ systemdUnit: exited.systemdUnit,
230
+ terminalSession: exited.terminalSession,
231
+ terminalAvailable: exited.terminalAvailable,
232
+ logFile: exited.logFile,
233
+ logBytes: exited.logBytes,
234
+ logEmpty: exited.logEmpty,
235
+ runnerInfoPresent: exited.runnerInfoPresent,
236
+ systemd: exited.systemd,
237
+ unavailable: exited.unavailable,
238
+ lastExit: exited,
239
+ },
240
+ });
241
+ }
242
+ getLifecycleManager().onOrchestratorManagedAgentsReported(params.id!, cleaned, exitedAgents);
243
+ if (updated) emitOrchestratorStatus(params.id!);
244
+ return json(updated);
245
+ } catch (e) {
246
+ if (e instanceof ValidationError) return error(e.message, 400);
247
+ throw e;
248
+ }
249
+ };
250
+
251
+ function cleanManagedSessionExitDiagnostics(value: unknown, index: number): ManagedSessionExitDiagnostics {
252
+ if (!isRecord(value)) throw new ValidationError(`exitedAgents[${index}] must be an object`);
253
+ const provider = optionalEnum(value.provider, `exitedAgents[${index}].provider`, SPAWN_PROVIDERS);
254
+ if (!provider) throw new ValidationError(`exitedAgents[${index}].provider required`);
255
+ const systemd = isRecord(value.systemd)
256
+ ? {
257
+ unit: cleanString(value.systemd.unit, `exitedAgents[${index}].systemd.unit`, { required: true, max: 240 })!,
258
+ activeState: cleanString(value.systemd.activeState, `exitedAgents[${index}].systemd.activeState`, { max: 80 }),
259
+ subState: cleanString(value.systemd.subState, `exitedAgents[${index}].systemd.subState`, { max: 80 }),
260
+ result: cleanString(value.systemd.result, `exitedAgents[${index}].systemd.result`, { max: 80 }),
261
+ execMainCode: cleanString(value.systemd.execMainCode, `exitedAgents[${index}].systemd.execMainCode`, { max: 80 }),
262
+ execMainStatus: cleanString(value.systemd.execMainStatus, `exitedAgents[${index}].systemd.execMainStatus`, { max: 80 }),
263
+ mainPid: cleanSafeNumber(value.systemd.mainPid),
264
+ unavailable: cleanString(value.systemd.unavailable, `exitedAgents[${index}].systemd.unavailable`, { max: 500 }),
265
+ }
266
+ : undefined;
267
+ const logTail = Array.isArray(value.logTail)
268
+ ? value.logTail
269
+ .map((line, lineIndex) => cleanString(line, `exitedAgents[${index}].logTail[${lineIndex}]`, { max: 1000 }))
270
+ .filter(Boolean)
271
+ .slice(-20) as string[]
272
+ : undefined;
273
+ const unavailable = Array.isArray(value.unavailable)
274
+ ? value.unavailable
275
+ .map((item, itemIndex) => cleanString(item, `exitedAgents[${index}].unavailable[${itemIndex}]`, { max: 500 }))
276
+ .filter(Boolean)
277
+ .slice(0, 20) as string[]
278
+ : undefined;
279
+ const diagnostics: ManagedSessionExitDiagnostics = {
280
+ agentId: cleanString(value.agentId, `exitedAgents[${index}].agentId`, { required: true, max: 240 })!,
281
+ provider: provider as SpawnProvider,
282
+ workspaceMode: optionalEnum(value.workspaceMode, `exitedAgents[${index}].workspaceMode`, VALID_WORKSPACE_MODES),
283
+ workspace: cleanWorkspaceMetadata(value.workspace, `exitedAgents[${index}].workspace`),
284
+ sessionName: cleanString(value.sessionName, `exitedAgents[${index}].sessionName`, { max: 240 }),
285
+ tmuxSession: cleanString(value.tmuxSession, `exitedAgents[${index}].tmuxSession`, { required: true, max: 240 })!,
286
+ cwd: cleanString(value.cwd, `exitedAgents[${index}].cwd`, { required: true, max: 500 })!,
287
+ label: cleanString(value.label, `exitedAgents[${index}].label`, { max: 120 }),
288
+ policyName: cleanString(value.policyName, `exitedAgents[${index}].policyName`, { max: 120 }),
289
+ spawnRequestId: cleanString(value.spawnRequestId, `exitedAgents[${index}].spawnRequestId`, { max: 160 }),
290
+ automationRunId: cleanString(value.automationRunId, `exitedAgents[${index}].automationRunId`, { max: 160 }),
291
+ supervisor: optionalEnum(value.supervisor, `exitedAgents[${index}].supervisor`, ["process", "systemd", "launchd", "unknown"] as const, "unknown")!,
292
+ systemdUnit: cleanString(value.systemdUnit, `exitedAgents[${index}].systemdUnit`, { max: 240 }),
293
+ terminalSession: cleanString(value.terminalSession, `exitedAgents[${index}].terminalSession`, { max: 240 }),
294
+ terminalAvailable: typeof value.terminalAvailable === "boolean" ? value.terminalAvailable : undefined,
295
+ pid: cleanSafeNumber(value.pid),
296
+ currentPid: cleanSafeNumber(value.currentPid),
297
+ startedAt: cleanSafeNumber(value.startedAt) ?? Date.now(),
298
+ detectedAt: cleanSafeNumber(value.detectedAt) ?? Date.now(),
299
+ runtimeMs: cleanSafeNumber(value.runtimeMs) ?? 0,
300
+ logFile: cleanString(value.logFile, `exitedAgents[${index}].logFile`, { max: 500 }),
301
+ logBytes: cleanSafeNumber(value.logBytes),
302
+ logEmpty: typeof value.logEmpty === "boolean" ? value.logEmpty : undefined,
303
+ logTail,
304
+ runnerInfoFile: cleanString(value.runnerInfoFile, `exitedAgents[${index}].runnerInfoFile`, { max: 500 }),
305
+ runnerInfoPresent: typeof value.runnerInfoPresent === "boolean" ? value.runnerInfoPresent : undefined,
306
+ systemd,
307
+ unavailable,
308
+ lastError: cleanString(value.lastError, `exitedAgents[${index}].lastError`, { required: true, max: 1000 })!,
309
+ };
310
+ if (JSON.stringify(diagnostics).length > 16_384) throw new ValidationError(`exitedAgents[${index}] is too large`);
311
+ return diagnostics;
312
+ }
313
+
314
+ const ORCH_UPGRADE_DEADLINE_MS = 5 * 60_000;
315
+
316
+ const ORCH_UPGRADE_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
317
+
318
+ const VALID_ORCH_UPGRADE_PROVIDERS = ["auto", "all", "codex", "claude", "orchestrator"] as const;
319
+
320
+ function semverCore(v: string): [number, number, number] | null {
321
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
322
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
323
+ }
324
+
325
+ function compareSemverCore(a: string, b: string): number {
326
+ const ca = semverCore(a), cb = semverCore(b);
327
+ if (!ca || !cb) return 0;
328
+ for (let i = 0; i < 3; i++) if (ca[i] !== cb[i]) return ca[i]! - cb[i]!;
329
+ return 0;
330
+ }
331
+
332
+ function orchestratorSelfUpgradeError(orch: ReturnType<typeof getOrchestrator>): string | undefined {
333
+ if (!orch) return "orchestrator not found";
334
+ if (orch.status !== "online") return "orchestrator is offline";
335
+ if (!orch.capabilities?.relayCommandBus) {
336
+ return `orchestrator ${orch.id} does not support relay command bus upgrades; upgrade it manually first`;
337
+ }
338
+ if (!orch.capabilities?.selfUpgrade) {
339
+ return `orchestrator ${orch.id}${orch.version ? ` v${orch.version}` : ""} does not advertise self-upgrade support; upgrade it manually once to ${VERSION} or newer`;
340
+ }
341
+ if ((orch.supervisor !== "systemd" && orch.supervisor !== "launchd") || !orch.selfUnit) {
342
+ return `orchestrator ${orch.id} cannot self-upgrade under supervisor ${orch.supervisor ?? "unknown"}; upgrade it manually`;
343
+ }
344
+ return undefined;
345
+ }
346
+
347
+ function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>): void {
348
+ if (!orch) return;
349
+ const up = orch.upgrade;
350
+ if (!up) return;
351
+ const reported = orch.version;
352
+ if (reported && reported === up.desiredVersion && up.status === "succeeded" && up.error) {
353
+ const { error: _previousError, ...successState } = up;
354
+ setOrchestratorUpgradeState(orch.id, successState);
355
+ emitOrchestratorStatus(orch.id);
356
+ return;
357
+ }
358
+ if (reported && reported === up.desiredVersion && (up.status === "pending" || up.status === "failed")) {
359
+ if (up.commandId) updateCommand(up.commandId, { status: "succeeded", result: { version: reported, ...(up.fromVersion ? { fromVersion: up.fromVersion } : {}) } });
360
+ const { error: _previousError, ...successState } = up;
361
+ setOrchestratorUpgradeState(orch.id, { ...successState, status: "succeeded", settledAt: Date.now() });
362
+ auditEvent({
363
+ clientId: `server-orchestrator-upgraded-${orch.id}-${Date.now()}`,
364
+ kind: "state",
365
+ title: "Orchestrator upgraded",
366
+ body: `${up.fromVersion ?? "?"} → ${reported}`,
367
+ meta: orch.id,
368
+ icon: "ti-arrow-up-circle",
369
+ view: "orchestrators",
370
+ metadata: { orchestratorId: orch.id, version: reported, commandId: up.commandId },
371
+ });
372
+ emitOrchestratorStatus(orch.id);
373
+ } else if (up.status === "pending" && Date.now() - up.requestedAt > ORCH_UPGRADE_DEADLINE_MS) {
374
+ const errMsg = `upgrade timed out; orchestrator still reports ${reported ?? "unknown"} (wanted ${up.desiredVersion})`;
375
+ if (up.commandId) updateCommand(up.commandId, { status: "failed", error: errMsg });
376
+ setOrchestratorUpgradeState(orch.id, { ...up, status: "failed", settledAt: Date.now(), error: errMsg });
377
+ auditEvent({
378
+ clientId: `server-orchestrator-upgrade-failed-${orch.id}-${Date.now()}`,
379
+ kind: "state",
380
+ title: "Orchestrator upgrade failed",
381
+ body: errMsg,
382
+ meta: orch.id,
383
+ icon: "ti-alert-triangle",
384
+ view: "orchestrators",
385
+ metadata: { orchestratorId: orch.id, commandId: up.commandId },
386
+ });
387
+ emitOrchestratorStatus(orch.id);
388
+ }
389
+ }
390
+
391
+ export const postOrchestratorAction: Handler = async (req, params) => {
392
+ const parsed = await parseBody<unknown>(req);
393
+ if (!parsed.ok) return error(parsed.error, parsed.status);
394
+ try {
395
+ const orch = getOrchestrator(params.id!);
396
+ if (!orch) return error("orchestrator not found", 404);
397
+
398
+ if (!isRecord(parsed.body)) return error("body required");
399
+ const action = optionalEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade"] as const);
400
+ if (!action) return error("action required");
401
+
402
+ if (action === "upgrade") {
403
+ const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
404
+ if (denied) return denied;
405
+ const targetVersion = cleanString(parsed.body.targetVersion, "targetVersion", { max: 80 }) ?? VERSION;
406
+ if (!ORCH_UPGRADE_SEMVER_RE.test(targetVersion)) return error("targetVersion must be a semver like 0.10.20");
407
+ const force = parsed.body.force === true;
408
+ const providers = (cleanStringArray(parsed.body.providers, "providers", { itemMax: 80, maxItems: 50 }) ?? ["orchestrator"])
409
+ .filter((p) => (VALID_ORCH_UPGRADE_PROVIDERS as readonly string[]).includes(p));
410
+ if (!providers.length) return error(`providers must be any of: ${VALID_ORCH_UPGRADE_PROVIDERS.join(", ")}`);
411
+ const selfUpgradeError = orchestratorSelfUpgradeError(orch);
412
+ if (selfUpgradeError) return error(selfUpgradeError, 409);
413
+ if (!force && orch.version && compareSemverCore(targetVersion, orch.version) < 0) {
414
+ return error(`refusing downgrade ${orch.version} → ${targetVersion}; pass force to override`, 409);
415
+ }
416
+ const requestedAt = Date.now();
417
+ const command = createCommand({
418
+ type: "orchestrator.upgrade",
419
+ source: "system",
420
+ target: orch.agentId,
421
+ ttlMs: ORCH_UPGRADE_DEADLINE_MS + 5 * 60_000,
422
+ params: { targetVersion, providers, force, orchestratorId: orch.id, requestedBy: "dashboard", requestedAt },
423
+ });
424
+ setOrchestratorUpgradeState(orch.id, {
425
+ desiredVersion: targetVersion,
426
+ status: "pending",
427
+ commandId: command.id,
428
+ providers,
429
+ ...(orch.version ? { fromVersion: orch.version } : {}),
430
+ requestedBy: "dashboard",
431
+ requestedAt,
432
+ });
433
+ emitCommand(command);
434
+ emitOrchestratorStatus(orch.id);
435
+ auditEvent({
436
+ clientId: `server-orchestrator-upgrade-${orch.id}-${command.id}`,
437
+ kind: "state",
438
+ title: "Orchestrator upgrade requested",
439
+ body: `${orch.version ?? "?"} → ${targetVersion}`,
440
+ meta: orch.id,
441
+ icon: "ti-arrow-up-circle",
442
+ view: "orchestrators",
443
+ metadata: { orchestratorId: orch.id, targetVersion, providers, commandId: command.id, ...authAuditMetadata(req), ...dashboardAttribution(req, parsed.body.surface) },
444
+ });
445
+ return json({ ok: true, action, command, targetVersion }, 202);
446
+ }
447
+ const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
448
+ const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
449
+ const spawnRequestId = cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 });
450
+ const sessionName = cleanString(parsed.body.sessionName, "sessionName", { max: 200 });
451
+ const tmuxSession = cleanString(parsed.body.tmuxSession, "tmuxSession", { max: 200 });
452
+ const reason = cleanString(parsed.body.reason, "reason", { max: 200 }) ?? "manual";
453
+ const denied = authorizeRoute(req, {
454
+ scope: "command:write",
455
+ resource: { orchestratorId: orch.id, agentId, policyName, spawnRequestId },
456
+ });
457
+ if (denied) return denied;
458
+
459
+ const command = createCommand({
460
+ type: action === "restart" ? "agent.restart" : "agent.shutdown",
461
+ source: "system",
462
+ target: orch.agentId,
463
+ correlationId: spawnRequestId,
464
+ params: { action, agentId, policyName, spawnRequestId, sessionName, tmuxSession, reason, requestedBy: "dashboard", requestedAt: Date.now(), orchestratorId: orch.id },
465
+ });
466
+ emitCommand(command);
467
+ auditEvent({
468
+ clientId: "server-orchestrator-action-" + orch.id + "-" + action + "-" + command.id,
469
+ kind: "state",
470
+ title: `Agent ${action} requested`,
471
+ body: agentId || "all",
472
+ meta: orch.id,
473
+ icon: action === "restart" ? "ti-refresh" : "ti-power",
474
+ view: "orchestrators",
475
+ metadata: { orchestratorId: orch.id, action, agentId, commandId: command.id, ...authAuditMetadata(req), ...dashboardAttribution(req, parsed.body.surface) },
476
+ });
477
+ return json({ ok: true, action, command }, 202);
478
+ } catch (e) {
479
+ if (e instanceof ValidationError) return error(e.message, 400);
480
+ throw e;
481
+ }
482
+ };
483
+
484
+ export const deleteOrchestratorById: Handler = (req, params) => {
485
+ const denied = authorizeRoute(req, { scope: "system:admin", resource: { orchestratorId: params.id! } });
486
+ if (denied) return denied;
487
+ const deleted = deleteOrchestrator(params.id!);
488
+ if (!deleted) return error("orchestrator not found", 404);
489
+ return json({ ok: true });
490
+ };