agent-relay-server 0.32.1 → 0.32.3

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 (97) hide show
  1. package/docs/openapi.json +57 -127
  2. package/package.json +1 -1
  3. package/public/assets/{activity-C6nbfryG.js → activity-DT1JGHnp.js} +2 -2
  4. package/public/assets/{activity-C6nbfryG.js.map → activity-DT1JGHnp.js.map} +1 -1
  5. package/public/assets/{agent-profiles-FEITAgHs.js → agent-profiles-CrMemMkZ.js} +2 -2
  6. package/public/assets/{agent-profiles-FEITAgHs.js.map → agent-profiles-CrMemMkZ.js.map} +1 -1
  7. package/public/assets/{agents-D4S0yIbe.js → agents-Bl-rrgOy.js} +2 -2
  8. package/public/assets/{agents-D4S0yIbe.js.map → agents-Bl-rrgOy.js.map} +1 -1
  9. package/public/assets/{analytics-DM2g62T_.js → analytics-a663ak56.js} +2 -2
  10. package/public/assets/{analytics-DM2g62T_.js.map → analytics-a663ak56.js.map} +1 -1
  11. package/public/assets/{automation-3D2pQa1C.js → automation-CiaLThdO.js} +2 -2
  12. package/public/assets/{automation-3D2pQa1C.js.map → automation-CiaLThdO.js.map} +1 -1
  13. package/public/assets/{branch-state-badge-Bi4IbkOZ.js → branch-state-badge-D4ur3m3_.js} +2 -2
  14. package/public/assets/{branch-state-badge-Bi4IbkOZ.js.map → branch-state-badge-D4ur3m3_.js.map} +1 -1
  15. package/public/assets/{channels-QNp7zmA_.js → channels-o9KLTHoK.js} +2 -2
  16. package/public/assets/{channels-QNp7zmA_.js.map → channels-o9KLTHoK.js.map} +1 -1
  17. package/public/assets/{chat-jeXt_SFs.js → chat-5hvHZcAe.js} +2 -2
  18. package/public/assets/{chat-jeXt_SFs.js.map → chat-5hvHZcAe.js.map} +1 -1
  19. package/public/assets/{connectors-BGJARDui.js → connectors-CdC806mA.js} +2 -2
  20. package/public/assets/{connectors-BGJARDui.js.map → connectors-CdC806mA.js.map} +1 -1
  21. package/public/assets/{formatted-body-impl-B7FgqkYL.js → formatted-body-impl-Ca74OAEH.js} +2 -2
  22. package/public/assets/{formatted-body-impl-B7FgqkYL.js.map → formatted-body-impl-Ca74OAEH.js.map} +1 -1
  23. package/public/assets/{index-2m9mT8kV.js → index-C_33ymaw.js} +6 -6
  24. package/public/assets/{index-2m9mT8kV.js.map → index-C_33ymaw.js.map} +1 -1
  25. package/public/assets/{integrations-CJm8-FcG.js → integrations-1nxMizDY.js} +2 -2
  26. package/public/assets/{integrations-CJm8-FcG.js.map → integrations-1nxMizDY.js.map} +1 -1
  27. package/public/assets/{maintenance-CBvZrVAG.js → maintenance-DiFNzNPN.js} +2 -2
  28. package/public/assets/{maintenance-CBvZrVAG.js.map → maintenance-DiFNzNPN.js.map} +1 -1
  29. package/public/assets/{managed-agents-Dcmm8YKt.js → managed-agents-Do3dKvfj.js} +2 -2
  30. package/public/assets/{managed-agents-Dcmm8YKt.js.map → managed-agents-Do3dKvfj.js.map} +1 -1
  31. package/public/assets/{markdown-preview-impl-7xjqdiEu.js → markdown-preview-impl-CLA0J255.js} +2 -2
  32. package/public/assets/{markdown-preview-impl-7xjqdiEu.js.map → markdown-preview-impl-CLA0J255.js.map} +1 -1
  33. package/public/assets/{memory-BmGNW61h.js → memory-IjwqFzBd.js} +2 -2
  34. package/public/assets/{memory-BmGNW61h.js.map → memory-IjwqFzBd.js.map} +1 -1
  35. package/public/assets/{messages-BvMMhoy-.js → messages-DjvWqHyn.js} +2 -2
  36. package/public/assets/{messages-BvMMhoy-.js.map → messages-DjvWqHyn.js.map} +1 -1
  37. package/public/assets/{orchestrators-DsstaupT.js → orchestrators-D2IqDxDT.js} +2 -2
  38. package/public/assets/{orchestrators-DsstaupT.js.map → orchestrators-D2IqDxDT.js.map} +1 -1
  39. package/public/assets/{overview-kK6PTce3.js → overview-DKC3TbAh.js} +2 -2
  40. package/public/assets/{overview-kK6PTce3.js.map → overview-DKC3TbAh.js.map} +1 -1
  41. package/public/assets/{pairs-BEFvTW6X.js → pairs-WpKCPE1n.js} +2 -2
  42. package/public/assets/{pairs-BEFvTW6X.js.map → pairs-WpKCPE1n.js.map} +1 -1
  43. package/public/assets/{security-Dc5wZwv0.js → security-BF7ZtPQe.js} +2 -2
  44. package/public/assets/{security-Dc5wZwv0.js.map → security-BF7ZtPQe.js.map} +1 -1
  45. package/public/assets/{settings-CEtJrORa.js → settings-CQnjrTa-.js} +2 -2
  46. package/public/assets/{settings-CEtJrORa.js.map → settings-CQnjrTa-.js.map} +1 -1
  47. package/public/assets/{store-DkmReBlH.js → store-C9VcSo05.js} +2 -2
  48. package/public/assets/{store-DkmReBlH.js.map → store-C9VcSo05.js.map} +1 -1
  49. package/public/assets/{tasks-pQKtxqeV.js → tasks-CbN_GSSb.js} +2 -2
  50. package/public/assets/{tasks-pQKtxqeV.js.map → tasks-CbN_GSSb.js.map} +1 -1
  51. package/public/assets/{terminal-viewer-impl-Cc769mYy.js → terminal-viewer-impl-BJRohThT.js} +2 -2
  52. package/public/assets/{terminal-viewer-impl-Cc769mYy.js.map → terminal-viewer-impl-BJRohThT.js.map} +1 -1
  53. package/public/assets/{work-queue-DjAanr02.js → work-queue-C5xLBLmm.js} +2 -2
  54. package/public/assets/{work-queue-DjAanr02.js.map → work-queue-C5xLBLmm.js.map} +1 -1
  55. package/public/assets/{workspaces-DLBNyR4k.js → workspaces-D91H3wDX.js} +2 -2
  56. package/public/assets/{workspaces-DLBNyR4k.js.map → workspaces-D91H3wDX.js.map} +1 -1
  57. package/public/index.html +2 -2
  58. package/scripts/orchestrator-spawn-smoke.ts +2 -1
  59. package/src/automations.ts +2 -4
  60. package/src/managed-policy.ts +2 -4
  61. package/src/mcp.ts +3 -3
  62. package/src/ratchet-files.ts +37 -0
  63. package/src/routes/_shared.ts +376 -0
  64. package/src/routes/activity.ts +61 -0
  65. package/src/routes/agent-profiles.ts +47 -0
  66. package/src/routes/agent-sessions.ts +488 -0
  67. package/src/routes/agents-spawn.ts +274 -0
  68. package/src/routes/agents.ts +251 -0
  69. package/src/routes/artifacts.ts +226 -0
  70. package/src/routes/automations.ts +83 -0
  71. package/src/routes/commands.ts +317 -0
  72. package/src/routes/config.ts +66 -0
  73. package/src/routes/connectors.ts +108 -0
  74. package/src/routes/inbox.ts +142 -0
  75. package/src/routes/index.ts +293 -0
  76. package/src/routes/insights.ts +81 -0
  77. package/src/routes/integrations.ts +592 -0
  78. package/src/routes/memory.ts +337 -0
  79. package/src/routes/messages.ts +529 -0
  80. package/src/routes/orchestrator-bootstrap.ts +100 -0
  81. package/src/routes/orchestrator-proxy.ts +160 -0
  82. package/src/routes/orchestrator.ts +490 -0
  83. package/src/routes/pairs.ts +197 -0
  84. package/src/routes/provider-config.ts +112 -0
  85. package/src/routes/recipes.ts +113 -0
  86. package/src/routes/spawn-policy.ts +231 -0
  87. package/src/routes/spec.ts +54 -0
  88. package/src/routes/sse.ts +9 -0
  89. package/src/routes/stats.ts +32 -0
  90. package/src/routes/steward.ts +45 -0
  91. package/src/routes/tasks.ts +174 -0
  92. package/src/routes/tokens.ts +311 -0
  93. package/src/routes/workspaces.ts +355 -0
  94. package/src/routes.ts +3 -6892
  95. package/src/runtime-tokens.ts +17 -8
  96. package/src/security.ts +0 -2
  97. package/src/validation.ts +134 -0
@@ -0,0 +1,274 @@
1
+ // Auto-split from routes.ts (#299). Domain: agents-spawn.
2
+ import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
+ import { McpNotFoundError, selectSpawnOrchestrator } from "../mcp";
4
+ import { VALID_AGENT_STATUSES, auditEvent, authAuditMetadata, authorizeRoute, emitCommand, error, json, metaString, parseBody, spawnRequestId, type Handler } from "./_shared";
5
+ import { ValidationError, getAgent, getOrchestrator, listAgents, resolveQueuedPolicyMessages, upsertAgent } from "../db";
6
+ import { buildSpawnCommand, resolveSpawnModelParams, type SpawnModelParams } from "../spawn-command";
7
+ import { cleanMeta, cleanNullableString, cleanString, cleanStringArray, optionalEnum } from "../validation";
8
+ import { createCommand } from "../commands-db";
9
+ import { emitAgentStatus, emitManagedAgentStateChanged, emitMessageAvailable, emitMessageDeliveryUpdated, emitNewMessage } from "../sse";
10
+ import { getAgentProfile, getManagedAgentState, updateManagedAgentState } from "../config-store";
11
+ import { getCompactionWatch } from "../compaction-watch";
12
+ import { getComponentAuth } from "../security";
13
+ import { isPathWithinBase } from "../utils";
14
+ import { listHostDirectories } from "../agent-spawn";
15
+ import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
16
+ import { runnerRuntimeTokenEnv } from "../runtime-tokens";
17
+ import { type AgentKind, type RegisterAgentInput, type SpawnApprovalMode, type SpawnProvider, type WorkspaceMode } from "../types";
18
+ import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
19
+
20
+ const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
21
+
22
+ function normalizeAgentInput(body: unknown): RegisterAgentInput {
23
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
24
+ const status = cleanString(body.status, "status", { max: 20 });
25
+ if (status && !VALID_AGENT_STATUSES.includes(status as any)) {
26
+ throw new ValidationError(`status must be one of: ${VALID_AGENT_STATUSES.join(", ")}`);
27
+ }
28
+ if (body.ready !== undefined && typeof body.ready !== "boolean") {
29
+ throw new ValidationError("ready must be a boolean");
30
+ }
31
+
32
+ const input: RegisterAgentInput = {
33
+ id: cleanString(body.id, "id", { required: true, max: 200 })!,
34
+ name: cleanString(body.name, "name", { required: true, max: 200 })!,
35
+ kind: optionalEnum(body.kind, "kind", VALID_AGENT_KINDS) as AgentKind | undefined,
36
+ status: status as RegisterAgentInput["status"] | undefined,
37
+ ready: body.ready as boolean | undefined,
38
+ };
39
+
40
+ const label = cleanNullableString(body.label, "label", 120);
41
+ if (label !== undefined) input.label = label;
42
+ const tags = cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 });
43
+ if (tags) input.tags = tags;
44
+ const capabilities = cleanStringArray(body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 });
45
+ if (capabilities) input.capabilities = capabilities;
46
+ const machine = cleanString(body.machine, "machine", { max: 120 });
47
+ if (machine) input.machine = machine;
48
+ const rig = cleanString(body.rig, "rig", { max: 120 });
49
+ if (rig) input.rig = rig;
50
+ const instanceId = cleanString(body.instanceId, "instanceId", { max: 200 });
51
+ if (instanceId) input.instanceId = instanceId;
52
+ if (isRecord(body.providerCapabilities)) input.providerCapabilities = body.providerCapabilities as unknown as RegisterAgentInput["providerCapabilities"];
53
+ if (isRecord(body.context)) input.context = body.context as unknown as RegisterAgentInput["context"];
54
+ const meta = cleanMeta(body.meta);
55
+ if (meta) input.meta = meta;
56
+
57
+ return input;
58
+ }
59
+
60
+ export const postAgent: Handler = async (req) => {
61
+ const parsed = await parseBody<unknown>(req);
62
+ if (!parsed.ok) return error(parsed.error, parsed.status);
63
+ try {
64
+ const input = normalizeAgentInput(parsed.body);
65
+ // Lineage is authoritative from the registering token's signed constraints — never the
66
+ // client-sent body (a child can't forge its own parent). Set by relay at spawn for
67
+ // agent-initiated spawns (`spawnedBy`) and the delegating-component path (`parentAgents`).
68
+ const constraints = getComponentAuth(req)?.constraints;
69
+ const lineage = constraints?.spawnedBy ?? constraints?.parentAgents?.[0];
70
+ if (lineage) input.spawnedBy = lineage;
71
+ const existing = getAgent(input.id);
72
+ const agent = upsertAgent(input);
73
+ const policyName = metaString(agent.meta, "policyName");
74
+ const spawnRequestId = metaString(agent.meta, "spawnRequestId");
75
+ const tmuxSession = metaString(agent.meta, "tmuxSession");
76
+ if (policyName && spawnRequestId) {
77
+ const state = getManagedAgentState(policyName);
78
+ if (state?.spawnRequestId === spawnRequestId) {
79
+ const updatedState = updateManagedAgentState(policyName, {
80
+ status: "running",
81
+ agentId: agent.id,
82
+ tmuxSession: tmuxSession ?? state.tmuxSession,
83
+ healthySince: Date.now(),
84
+ lastError: undefined,
85
+ });
86
+ if (updatedState) emitManagedAgentStateChanged(policyName, updatedState as unknown as Record<string, unknown>);
87
+ const available = resolveQueuedPolicyMessages(policyName, agent.id);
88
+ if (available.length) {
89
+ emitMessageAvailable(policyName, agent.id, available);
90
+ for (const message of available) {
91
+ emitNewMessage(message);
92
+ // queued → pending flips delivery_status; the dashboard dedups message.new
93
+ // by id (the message already shows as "queued"), so without an explicit
94
+ // delivery_updated the badge stays stale until the next poll (#265).
95
+ emitMessageDeliveryUpdated(message);
96
+ }
97
+ }
98
+ }
99
+ }
100
+ emitAgentStatus(agent.id);
101
+ // A real PreCompact / SessionStart(clear) hook reports progress via the
102
+ // agent's timelineEvent — clears any pending stall watch for this agent.
103
+ // timelineEvent is latched, so pass its timestamp: only a fresh event
104
+ // (stamped after the watch armed) counts as this command's real start.
105
+ const timelineEvent = isRecord(agent.meta?.timelineEvent) ? agent.meta.timelineEvent : undefined;
106
+ const timelineStatus = metaString(timelineEvent, "status");
107
+ if (timelineStatus) {
108
+ const timelineTs = typeof timelineEvent?.timestamp === "number" ? timelineEvent.timestamp : undefined;
109
+ getCompactionWatch().noteTimelineStatus(agent.id, timelineStatus, timelineTs);
110
+ }
111
+ if (!existing) {
112
+ auditEvent({
113
+ clientId: "server-agent-" + agent.id + "-registered",
114
+ kind: "state",
115
+ title: "Agent registered",
116
+ body: agent.name,
117
+ meta: agent.id,
118
+ icon: "ti-robot",
119
+ view: "agents",
120
+ agentId: agent.id,
121
+ });
122
+ }
123
+ return json(agent, 201);
124
+ } catch (e) {
125
+ if (e instanceof ValidationError) return error(e.message, 400);
126
+ throw e;
127
+ }
128
+ };
129
+
130
+ export const postRouteAdvice: Handler = async (req) => {
131
+ const parsed = await parseBody<RouteAdvisorInput>(req);
132
+ if (!parsed.ok) return error(parsed.error, parsed.status);
133
+ const body = isRecord(parsed.body) ? parsed.body : {};
134
+ const limit = typeof body.limit === "number" ? body.limit : undefined;
135
+ const maxUtilization = typeof body.maxUtilization === "number" ? body.maxUtilization : undefined;
136
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) return error("limit must be a positive integer");
137
+ if (maxUtilization !== undefined && (maxUtilization < 0 || maxUtilization > 1)) return error("maxUtilization must be between 0 and 1");
138
+ return json({
139
+ candidates: rankRouteCandidates(listAgents(), {
140
+ text: typeof body.text === "string" ? body.text : undefined,
141
+ target: typeof body.target === "string" ? body.target : undefined,
142
+ capabilities: Array.isArray(body.capabilities) ? body.capabilities.filter((item): item is string => typeof item === "string") : undefined,
143
+ tags: Array.isArray(body.tags) ? body.tags.filter((item): item is string => typeof item === "string") : undefined,
144
+ maxUtilization,
145
+ preferWarmContext: typeof body.preferWarmContext === "boolean" ? body.preferWarmContext : undefined,
146
+ limit,
147
+ }),
148
+ });
149
+ };
150
+
151
+ export const postAgentSpawn: Handler = async (req) => {
152
+ const parsed = await parseBody<unknown>(req);
153
+ if (!parsed.ok) return error(parsed.error, parsed.status);
154
+ try {
155
+ if (!isRecord(parsed.body)) return error("provider required");
156
+ const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider | undefined;
157
+ if (!provider) return error("provider required");
158
+ const orchestratorId = cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 200 });
159
+ const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
160
+ // Resolves an explicit host, else the host whose baseDir contains cwd, else the lone candidate
161
+ // (the same resolver the MCP spawn tool uses — one home for host selection).
162
+ const orch = selectSpawnOrchestrator(provider, orchestratorId, cwd);
163
+ if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
164
+ return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
165
+ }
166
+ const selection = validateSpawnSelectionForOrchestrator(orch, provider, parsed.body);
167
+ if (selection instanceof Response) return selection;
168
+ const approvalMode = optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
169
+ const label = cleanString(parsed.body.label, "label", { max: 120 });
170
+ const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
171
+ const profile = cleanString(parsed.body.profile, "profile", { max: 120 });
172
+ const prompt = cleanString(parsed.body.prompt, "prompt", { max: 16000 });
173
+ const systemPromptAppend = cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 });
174
+ const tags = cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }) ?? [];
175
+ const capabilities = cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }) ?? [];
176
+ const providerArgs = cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }) ?? [];
177
+ const agentProfile = profile ? getAgentProfile(profile)?.value : undefined;
178
+ if (profile && !agentProfile) return error("agent profile not found", 404);
179
+ const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
180
+ const requestId = cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 }) ?? spawnRequestId();
181
+ const denied = authorizeRoute(req, {
182
+ scope: "agent:write",
183
+ resource: { orchestratorId: orch.id, cwd: cwd || orch.baseDir, policyName, spawnRequestId: requestId },
184
+ });
185
+ if (denied) return denied;
186
+ const command = createCommand({
187
+ type: "agent.spawn",
188
+ source: "system",
189
+ target: orch.agentId,
190
+ correlationId: requestId,
191
+ params: buildSpawnCommand({
192
+ provider,
193
+ modelParams: selection,
194
+ cwd: cwd || orch.baseDir,
195
+ workspaceMode,
196
+ label,
197
+ tags,
198
+ capabilities,
199
+ approvalMode,
200
+ permissionMode: approvalMode,
201
+ profile: profile || undefined,
202
+ agentProfile,
203
+ providerArgs,
204
+ prompt,
205
+ systemPromptAppend,
206
+ policyName,
207
+ spawnRequestId: requestId,
208
+ env: runnerRuntimeTokenEnv({
209
+ orchestratorId: orch.id,
210
+ cwd: cwd || orch.baseDir,
211
+ provider,
212
+ label,
213
+ policyName,
214
+ spawnRequestId: requestId,
215
+ createdBy: "dashboard",
216
+ profile: profile || undefined,
217
+ }),
218
+ requestedBy: "dashboard",
219
+ requestedAt: Date.now(),
220
+ }),
221
+ });
222
+ emitCommand(command);
223
+ auditEvent({
224
+ clientId: "server-agent-spawn-" + provider + "-" + command.id,
225
+ kind: "state",
226
+ title: `${provider} agent spawn requested (via ${orch.id})`,
227
+ body: cwd || orch.baseDir,
228
+ meta: orch.id,
229
+ icon: "ti-plus",
230
+ view: "agents",
231
+ metadata: { provider, orchestratorId: orch.id, approvalMode, workspaceMode, label, commandId: command.id, ...authAuditMetadata(req) },
232
+ });
233
+ return json({ ok: true, orchestratorId: orch.id, provider, command }, 202);
234
+ } catch (e) {
235
+ if (e instanceof McpNotFoundError) return error(e.message, 404);
236
+ if (e instanceof ValidationError) return error(e.message, 400);
237
+ if (e instanceof Error) return error(e.message, 400);
238
+ throw e;
239
+ }
240
+ };
241
+
242
+ export const getHostDirectories: Handler = (req) => {
243
+ try {
244
+ const url = new URL(req.url);
245
+ const path = cleanString(url.searchParams.get("path") ?? undefined, "path", { max: 500 });
246
+ return json(listHostDirectories(path));
247
+ } catch (e) {
248
+ if (e instanceof ValidationError) return error(e.message, 400);
249
+ if (e instanceof Error) return error(e.message, 400);
250
+ throw e;
251
+ }
252
+ };
253
+
254
+ function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): SpawnModelParams {
255
+ const model = cleanString(body.model, "model", { max: 120 });
256
+ const effort = optionalEnum(body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
257
+ return resolveSpawnModelParams(provider, model, effort);
258
+ }
259
+
260
+ function validateSpawnSelectionForOrchestrator(
261
+ orch: NonNullable<ReturnType<typeof getOrchestrator>>,
262
+ provider: SpawnProvider,
263
+ body: Record<string, unknown>,
264
+ ): SpawnModelParams | Response {
265
+ if (!orch.providers.includes(provider)) {
266
+ return error(`orchestrator does not have provider available: ${provider}`, 409);
267
+ }
268
+ try {
269
+ return cleanSpawnSelection(body, provider);
270
+ } catch (e) {
271
+ if (e instanceof Error) return error(e.message, 400);
272
+ throw e;
273
+ }
274
+ }
@@ -0,0 +1,251 @@
1
+ // Auto-split from routes.ts (#299). Domain: agents.
2
+ import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, type Handler } from "./_shared";
3
+ import { ValidationError, deleteAgent, getAgent, getAgentTimeline, heartbeat, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
4
+ import { attachBranchState, attachBranchStates } from "../agent-branch-state";
5
+ import { cleanStringArray } from "../validation";
6
+ import { clearActiveMemories, memoryBroker } from "../memory-service";
7
+ import { emitAgentRemoved, emitAgentStatus } from "../sse";
8
+ import { getCompactionWatch } from "../compaction-watch";
9
+ import { isRecord } from "agent-relay-sdk";
10
+ import { type AgentCard } from "../types";
11
+
12
+ type AgentActivityState = {
13
+ key: "available" | "busy" | "not-ready" | "offline";
14
+ title: string;
15
+ icon: string;
16
+ };
17
+
18
+ function agentActivityState(agent?: Pick<AgentCard, "ready" | "status"> | null): AgentActivityState | null {
19
+ if (!agent) return null;
20
+ if (agent.status === "offline") return { key: "offline", title: "Agent offline", icon: "ti-plug-off" };
21
+ if (!agent.ready) return { key: "not-ready", title: "Agent not ready", icon: "ti-loader" };
22
+ if (agent.status === "busy") return { key: "busy", title: "Agent busy", icon: "ti-activity" };
23
+ return { key: "available", title: "Agent available", icon: "ti-circle-check" };
24
+ }
25
+
26
+ function auditAgentStateTransition(agentId: string, before: AgentCard | null | undefined, after: AgentCard | null | undefined): void {
27
+ const previous = agentActivityState(before);
28
+ const next = agentActivityState(after);
29
+ if (!next || previous?.key === next.key) return;
30
+ auditEvent({
31
+ clientId: "server-agent-" + agentId + "-state-" + next.key + "-" + Date.now(),
32
+ kind: "state",
33
+ title: next.title,
34
+ meta: agentId,
35
+ icon: next.icon,
36
+ view: "agents",
37
+ agentId,
38
+ });
39
+ }
40
+
41
+ export const getAgents: Handler = (req) => {
42
+ const url = new URL(req.url);
43
+ const tag = url.searchParams.get("tag") ?? undefined;
44
+ const machine = url.searchParams.get("machine") ?? undefined;
45
+ const status = url.searchParams.get("status") ?? undefined;
46
+ return json(attachBranchStates(listAgents({ tag, machine, status })));
47
+ };
48
+
49
+ export const findAgents: Handler = (req) => {
50
+ const url = new URL(req.url);
51
+ const capability = url.searchParams.get("capability");
52
+ if (!capability) return error("capability query param required");
53
+ const includeAll = url.searchParams.get("all") === "true";
54
+ return json(searchAgents({ capability, status: includeAll ? "all" : "running" }));
55
+ };
56
+
57
+ export const getAgentById: Handler = (_req, params) => {
58
+ const agent = getAgent(params.id!);
59
+ return agent ? json(attachBranchState(agent)) : error("agent not found", 404);
60
+ };
61
+
62
+ export const getAgentReplyObligations: Handler = (_req, params) => {
63
+ const agent = getAgent(params.id!);
64
+ return agent ? json(listPendingReplyObligations(agent.id)) : error("agent not found", 404);
65
+ };
66
+
67
+ export const getAgentContextSnapshots: Handler = (req, params) => {
68
+ const agent = getAgent(params.id!);
69
+ if (!agent) return error("agent not found", 404);
70
+ const url = new URL(req.url);
71
+ const sinceRaw = url.searchParams.get("since");
72
+ const limitRaw = url.searchParams.get("limit");
73
+ const since = sinceRaw ? Number(sinceRaw) : undefined;
74
+ const limit = limitRaw ? Number(limitRaw) : undefined;
75
+ if (sinceRaw && (!Number.isFinite(since) || since! < 0)) return error("since must be a positive timestamp");
76
+ if (limitRaw && (!Number.isSafeInteger(limit) || limit! <= 0)) return error("limit must be a positive integer");
77
+ return json(listContextSnapshots(agent.id, { since, limit }));
78
+ };
79
+
80
+ export const getAgentTimelineRoute: Handler = (req, params) => {
81
+ // Deliberately no agent-existence check: the timeline's main value is debugging
82
+ // an agent that already died or was removed.
83
+ const url = new URL(req.url);
84
+ const sinceRaw = url.searchParams.get("since");
85
+ const limitRaw = url.searchParams.get("limit");
86
+ const since = sinceRaw ? Number(sinceRaw) : undefined;
87
+ const limit = limitRaw ? Number(limitRaw) : undefined;
88
+ if (sinceRaw && (!Number.isFinite(since) || since! < 0)) return error("since must be a positive timestamp");
89
+ if (limitRaw && (!Number.isSafeInteger(limit) || limit! <= 0)) return error("limit must be a positive integer");
90
+ return json(getAgentTimeline(params.id!, { since, limit }));
91
+ };
92
+
93
+ export const getAgentActiveMemories: Handler = async (req, params) => {
94
+ const agent = getAgent(params.id!);
95
+ if (!agent) return error("agent not found", 404);
96
+ try {
97
+ const capabilities = await memoryBroker.capabilities();
98
+ if (!capabilities.activeList || !memoryBroker.listActive) {
99
+ return json({ agentId: agent.id, supported: false, memories: [] });
100
+ }
101
+ const memories = await memoryBroker.listActive(agent.id, memoryContext(req));
102
+ return json({ agentId: agent.id, supported: true, memories, total: memories.length });
103
+ } catch (e) {
104
+ return memoryErrorResponse(e);
105
+ }
106
+ };
107
+
108
+ export const deleteAgentActiveMemories: Handler = async (req, params) => {
109
+ const agent = getAgent(params.id!);
110
+ if (!agent) return error("agent not found", 404);
111
+ try {
112
+ const cleared = await clearActiveMemories(agent.id, "manual", memoryContext(req));
113
+ return json({ agentId: agent.id, supported: cleared, cleared });
114
+ } catch (e) {
115
+ return memoryErrorResponse(e);
116
+ }
117
+ };
118
+
119
+ export const getMessageArtifactsRoute: Handler = (_req, params) => {
120
+ const id = parseId(params.id);
121
+ if (id === null) return error("invalid message id");
122
+ return json(listArtifactsForEntity("message", id));
123
+ };
124
+
125
+ export const getTaskArtifactsRoute: Handler = (_req, params) => {
126
+ const id = parseId(params.id);
127
+ if (id === null) return error("invalid task id");
128
+ return json(listArtifactsForEntity("task", id));
129
+ };
130
+
131
+ export const patchAgentStatus: Handler = async (req, params) => {
132
+ const parsed = await parseBody<{ status: string }>(req);
133
+ if (!parsed.ok) return error(parsed.error, parsed.status);
134
+ const body = parsed.body;
135
+ if (!body?.status) return error("status required");
136
+ const valid = VALID_AGENT_STATUSES;
137
+ if (!valid.includes(body.status as any)) return error(`status must be one of: ${valid.join(", ")}`);
138
+ try {
139
+ const guard = normalizeAgentSessionGuard(req, body);
140
+ const session = validateAgentSession(params.id!, guard);
141
+ if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
142
+ const before = getAgent(params.id!);
143
+ if (!setStatus(params.id!, body.status as (typeof VALID_AGENT_STATUSES)[number], guard)) return error("agent not found", 404);
144
+ auditAgentStateTransition(params.id!, before, getAgent(params.id!));
145
+ } catch (e) {
146
+ if (e instanceof ValidationError) return error(e.message, 400);
147
+ throw e;
148
+ }
149
+ emitAgentStatus(params.id!);
150
+ return json({ ok: true });
151
+ };
152
+
153
+ export const postHeartbeat: Handler = async (req, params) => {
154
+ const parsed = await parseBody<unknown>(req);
155
+ if (!parsed.ok) return error(parsed.error, parsed.status);
156
+ try {
157
+ const guard = normalizeAgentSessionGuard(req, parsed.body);
158
+ const session = validateAgentSession(params.id!, guard);
159
+ if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
160
+ return heartbeat(params.id!, guard) ? json({ ok: true }) : error("agent not found", 404);
161
+ } catch (e) {
162
+ if (e instanceof ValidationError) return error(e.message, 400);
163
+ throw e;
164
+ }
165
+ };
166
+
167
+ export const patchAgentLabel: Handler = async (req, params) => {
168
+ const parsed = await parseBody<{ label: string | null }>(req);
169
+ if (!parsed.ok) return error(parsed.error, parsed.status);
170
+ const body = parsed.body;
171
+ if (body === null || !("label" in body)) return error("label field required (string or null)");
172
+ const before = getAgent(params.id!);
173
+ if (!setLabel(params.id!, body.label)) return error("agent not found", 404);
174
+ emitAgentStatus(params.id!);
175
+ const after = getAgent(params.id!);
176
+ if ((before?.label ?? null) !== (after?.label ?? null)) {
177
+ auditEvent({
178
+ clientId: "server-agent-" + params.id! + "-label-" + Date.now(),
179
+ kind: "state",
180
+ title: after?.label ? "Agent label set" : "Agent label cleared",
181
+ body: after?.label ?? before?.label ?? "",
182
+ meta: params.id!,
183
+ icon: "ti-tag",
184
+ view: "chat",
185
+ agentId: params.id!,
186
+ metadata: { field: "label", previous: before?.label ?? null, next: after?.label ?? null },
187
+ });
188
+ }
189
+ return json({ ok: true });
190
+ };
191
+
192
+ export const patchAgentTags: Handler = async (req, params) => {
193
+ const parsed = await parseBody<unknown>(req);
194
+ if (!parsed.ok) return error(parsed.error, parsed.status);
195
+ try {
196
+ const tags = isRecord(parsed.body) ? cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }) : undefined;
197
+ if (!tags) return error("tags field required");
198
+ const before = getAgent(params.id!);
199
+ const agent = setTags(params.id!, tags);
200
+ if (!agent) return error("agent not found", 404);
201
+ emitAgentStatus(params.id!);
202
+ if (JSON.stringify(before?.tags ?? []) !== JSON.stringify(agent.tags ?? [])) {
203
+ auditEvent({
204
+ clientId: "server-agent-" + params.id! + "-tags-" + Date.now(),
205
+ kind: "state",
206
+ title: "Agent tags updated",
207
+ body: (agent.tags || []).join(", "),
208
+ meta: params.id!,
209
+ icon: "ti-tags",
210
+ view: "chat",
211
+ agentId: params.id!,
212
+ metadata: { field: "tags", previous: before?.tags ?? [], next: agent.tags ?? [] },
213
+ });
214
+ }
215
+ return json(agent);
216
+ } catch (e) {
217
+ if (e instanceof ValidationError) return error(e.message, 400);
218
+ throw e;
219
+ }
220
+ };
221
+
222
+ export const patchAgentReady: Handler = async (req, params) => {
223
+ const parsed = await parseBody<{ ready: boolean }>(req);
224
+ if (!parsed.ok) return error(parsed.error, parsed.status);
225
+ const body = parsed.body;
226
+ if (body === null || typeof body.ready !== "boolean") return error("ready field required (boolean)");
227
+ try {
228
+ const guard = normalizeAgentSessionGuard(req, body);
229
+ const session = validateAgentSession(params.id!, guard);
230
+ if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
231
+ const before = getAgent(params.id!);
232
+ if (!markReady(params.id!, body.ready, guard)) return error("agent not found", 404);
233
+ auditAgentStateTransition(params.id!, before, getAgent(params.id!));
234
+ } catch (e) {
235
+ if (e instanceof ValidationError) return error(e.message, 400);
236
+ throw e;
237
+ }
238
+ emitAgentStatus(params.id!);
239
+ return json({ ok: true });
240
+ };
241
+
242
+ export const deleteAgentById: Handler = (_req, params) => {
243
+ const result = deleteAgent(params.id!);
244
+ if (!result.ok) {
245
+ const status = result.error === "agent not found" ? 404 : 400;
246
+ return error(result.error!, status);
247
+ }
248
+ emitAgentRemoved(params.id!);
249
+ getCompactionWatch().forget(params.id!);
250
+ return json({ ok: true });
251
+ };