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,197 @@
1
+ // Auto-split from routes.ts (#299). Domain: pairs.
2
+ import { MAX_BODY_BYTES } from "../config";
3
+ import { ValidationError, acceptPair, createPair, endPair, getPair, listPairs, rejectPair, sendPairMessage } from "../db";
4
+ import { auditEvent, error, json, parseBody, type Handler } from "./_shared";
5
+ import { cleanMeta, cleanString, cleanTtlMs } from "../validation";
6
+ import { emitNewMessage } from "../sse";
7
+ import { isRecord } from "agent-relay-sdk";
8
+ import { type CreatePairInput, type PairActionInput, type PairMessageInput, type PairStatus } from "../types";
9
+
10
+ const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
11
+
12
+ function normalizeCreatePairInput(body: unknown): CreatePairInput {
13
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
14
+ return {
15
+ from: cleanString(body.from, "from", { required: true, max: 200 })!,
16
+ target: cleanString(body.target, "target", { required: true, max: 200 })!,
17
+ objective: cleanString(body.objective, "objective", { max: 2000 }),
18
+ ttlMs: cleanTtlMs(body.ttlMs),
19
+ meta: cleanMeta(body.meta),
20
+ };
21
+ }
22
+
23
+ function normalizePairActionInput(body: unknown): PairActionInput {
24
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
25
+ return {
26
+ agentId: cleanString(body.agentId, "agentId", { required: true, max: 200 })!,
27
+ reason: cleanString(body.reason, "reason", { max: 1000 }),
28
+ };
29
+ }
30
+
31
+ function normalizePairMessageInput(body: unknown): PairMessageInput {
32
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
33
+ return {
34
+ from: cleanString(body.from, "from", { required: true, max: 200 })!,
35
+ body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
36
+ subject: cleanString(body.subject, "subject", { max: 200 }),
37
+ };
38
+ }
39
+
40
+ function pairErrorStatus(code: string): number {
41
+ if (code === "not_found") return 404;
42
+ if (code === "busy") return 409;
43
+ if (code === "ambiguous") return 409;
44
+ if (code === "forbidden") return 403;
45
+ return 400;
46
+ }
47
+
48
+ function pairError(result: { error: string; code: string; matches?: unknown[]; busy?: unknown[] }): Response {
49
+ return json({
50
+ error: result.error,
51
+ ...(result.matches ? { matches: result.matches } : {}),
52
+ ...(result.busy ? { busy: result.busy } : {}),
53
+ }, pairErrorStatus(result.code));
54
+ }
55
+
56
+ export const postPair: Handler = async (req) => {
57
+ const parsed = await parseBody<unknown>(req);
58
+ if (!parsed.ok) return error(parsed.error, parsed.status);
59
+ try {
60
+ const result = createPair(normalizeCreatePairInput(parsed.body));
61
+ if (!result.ok) return pairError(result);
62
+ emitNewMessage(result.invite);
63
+ auditEvent({
64
+ clientId: "server-pair-" + result.pair.id + "-invited",
65
+ kind: "pair",
66
+ title: "Pair invited",
67
+ body: result.pair.objective,
68
+ meta: `${result.pair.requesterId} <-> ${result.pair.targetId}`,
69
+ icon: "ti-link-plus",
70
+ view: "pairs",
71
+ pairId: result.pair.id,
72
+ agentId: result.pair.requesterId,
73
+ });
74
+ return json({ pair: result.pair, invite: result.invite }, 201);
75
+ } catch (e) {
76
+ if (e instanceof ValidationError) return error(e.message, 400);
77
+ throw e;
78
+ }
79
+ };
80
+
81
+ export const getPairs: Handler = (req) => {
82
+ const url = new URL(req.url);
83
+ const status = url.searchParams.get("status") ?? undefined;
84
+ if (status && !VALID_PAIR_STATUSES.includes(status as any)) {
85
+ return error(`status must be one of: ${VALID_PAIR_STATUSES.join(", ")}`);
86
+ }
87
+ return json(listPairs({
88
+ agentId: url.searchParams.get("agent") ?? undefined,
89
+ status: status as PairStatus | undefined,
90
+ }));
91
+ };
92
+
93
+ export const getPairById: Handler = (_req, params) => {
94
+ const pair = getPair(params.id!);
95
+ return pair ? json(pair) : error("pair not found", 404);
96
+ };
97
+
98
+ export const postAcceptPair: Handler = async (req, params) => {
99
+ const parsed = await parseBody<unknown>(req);
100
+ if (!parsed.ok) return error(parsed.error, parsed.status);
101
+ try {
102
+ const result = acceptPair(params.id!, normalizePairActionInput(parsed.body));
103
+ if (!result.ok) return pairError(result);
104
+ for (const notice of result.notices) emitNewMessage(notice);
105
+ auditEvent({
106
+ clientId: "server-pair-" + result.pair.id + "-accepted",
107
+ kind: "pair",
108
+ title: "Pair accepted",
109
+ body: result.pair.objective,
110
+ meta: `${result.pair.requesterId} <-> ${result.pair.targetId}`,
111
+ icon: "ti-link",
112
+ view: "pairs",
113
+ pairId: result.pair.id,
114
+ agentId: result.pair.targetId,
115
+ });
116
+ return json(result.pair);
117
+ } catch (e) {
118
+ if (e instanceof ValidationError) return error(e.message, 400);
119
+ throw e;
120
+ }
121
+ };
122
+
123
+ export const postRejectPair: Handler = async (req, params) => {
124
+ const parsed = await parseBody<unknown>(req);
125
+ if (!parsed.ok) return error(parsed.error, parsed.status);
126
+ try {
127
+ const result = rejectPair(params.id!, normalizePairActionInput(parsed.body));
128
+ if (!result.ok) return pairError(result);
129
+ emitNewMessage(result.notice);
130
+ auditEvent({
131
+ clientId: "server-pair-" + result.pair.id + "-rejected",
132
+ kind: "pair",
133
+ title: "Pair rejected",
134
+ body: result.pair.objective,
135
+ meta: `${result.pair.requesterId} <-> ${result.pair.targetId}`,
136
+ icon: "ti-x",
137
+ view: "pairs",
138
+ pairId: result.pair.id,
139
+ agentId: result.pair.targetId,
140
+ });
141
+ return json(result.pair);
142
+ } catch (e) {
143
+ if (e instanceof ValidationError) return error(e.message, 400);
144
+ throw e;
145
+ }
146
+ };
147
+
148
+ export const postHangupPair: Handler = async (req, params) => {
149
+ const parsed = await parseBody<unknown>(req);
150
+ if (!parsed.ok) return error(parsed.error, parsed.status);
151
+ try {
152
+ const result = endPair(params.id!, normalizePairActionInput(parsed.body));
153
+ if (!result.ok) return pairError(result);
154
+ if (result.notice) emitNewMessage(result.notice);
155
+ auditEvent({
156
+ clientId: "server-pair-" + result.pair.id + "-ended-" + Date.now(),
157
+ kind: "pair",
158
+ title: "Pair ended",
159
+ body: result.pair.objective,
160
+ meta: result.pair.endedBy ? "by " + result.pair.endedBy : `${result.pair.requesterId} <-> ${result.pair.targetId}`,
161
+ icon: "ti-phone-off",
162
+ view: "pairs",
163
+ pairId: result.pair.id,
164
+ agentId: result.pair.endedBy,
165
+ });
166
+ return json(result.pair);
167
+ } catch (e) {
168
+ if (e instanceof ValidationError) return error(e.message, 400);
169
+ throw e;
170
+ }
171
+ };
172
+
173
+ export const postPairMessage: Handler = async (req, params) => {
174
+ const parsed = await parseBody<unknown>(req);
175
+ if (!parsed.ok) return error(parsed.error, parsed.status);
176
+ try {
177
+ const result = sendPairMessage(params.id!, normalizePairMessageInput(parsed.body));
178
+ if (!result.ok) return pairError(result);
179
+ emitNewMessage(result.message);
180
+ auditEvent({
181
+ clientId: "server-pair-message-" + result.message.id,
182
+ kind: "pair",
183
+ title: "Pair message sent",
184
+ body: result.message.subject || result.message.body,
185
+ meta: "from " + result.message.from,
186
+ icon: "ti-messages",
187
+ view: "pairs",
188
+ messageId: result.message.id,
189
+ pairId: result.pair.id,
190
+ agentId: result.message.from,
191
+ });
192
+ return json({ pair: result.pair, message: result.message }, 201);
193
+ } catch (e) {
194
+ if (e instanceof ValidationError) return error(e.message, 400);
195
+ throw e;
196
+ }
197
+ };
@@ -0,0 +1,112 @@
1
+ // Auto-split from routes.ts (#299). Domain: provider-config.
2
+ import { ValidationError, listOrchestrators } from "../db";
3
+ import { applyCommandToRecipe } from "../recipe-runner";
4
+ import { cleanString, cleanStringArray, optionalEnum } from "../validation";
5
+ import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../../runner/src/config";
6
+ import { deleteCommand, getCommand } from "../commands-db";
7
+ import { effectiveProviderCatalogList } from "../provider-catalog-store";
8
+ import { emitCommand, error, json, parseBody, type Handler } from "./_shared";
9
+ import { isRecord } from "agent-relay-sdk";
10
+ import { type ProviderConfig } from "../../runner/src/adapter";
11
+
12
+ const VALID_PROVIDER_CONFIGS = ["claude", "codex"] as const;
13
+
14
+ export const getProvidersRoute: Handler = () => {
15
+ return json({
16
+ catalog: effectiveProviderCatalogList(),
17
+ orchestrators: listOrchestrators().map((orchestrator) => ({
18
+ id: orchestrator.id,
19
+ hostname: orchestrator.hostname,
20
+ status: orchestrator.status,
21
+ providers: orchestrator.providers,
22
+ providerStatus: orchestrator.providerStatus ?? [],
23
+ checkedAt: orchestrator.providerStatus?.reduce((latest, status) => Math.max(latest, status.checkedAt), 0) ?? 0,
24
+ })),
25
+ });
26
+ };
27
+
28
+ export const getProviderConfigsRoute: Handler = () => {
29
+ return json(Object.fromEntries(VALID_PROVIDER_CONFIGS.map((provider) => {
30
+ const config = loadProviderConfig(provider);
31
+ return [provider, providerConfigPublic(config)];
32
+ })));
33
+ };
34
+
35
+ export const getProviderConfigRoute: Handler = (_req, params) => {
36
+ const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
37
+ if (!provider) return error("provider required");
38
+ return json(providerConfigPublic(loadProviderConfig(provider)));
39
+ };
40
+
41
+ export const putProviderConfigRoute: Handler = async (req, params) => {
42
+ const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
43
+ if (!provider) return error("provider required");
44
+ const parsed = await parseBody<unknown>(req);
45
+ if (!parsed.ok) return error(parsed.error, parsed.status);
46
+ try {
47
+ if (!isRecord(parsed.body)) return error("provider config body must be an object");
48
+ const defaults = defaultProviderConfig(provider);
49
+ const headless = isRecord(parsed.body.headless) ? parsed.body.headless : {};
50
+ const config: ProviderConfig = {
51
+ command: cleanString(parsed.body.command, "command", { required: true, max: 500 })!,
52
+ defaultArgs: cleanStringArray(parsed.body.defaultArgs, "defaultArgs", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultArgs,
53
+ env: cleanEnvRecord(parsed.body.env),
54
+ pluginDirs: cleanStringArray(parsed.body.pluginDirs, "pluginDirs", { itemMax: 80, maxItems: 50 }) ?? defaults.pluginDirs,
55
+ defaultCapabilities: cleanStringArray(parsed.body.defaultCapabilities, "defaultCapabilities", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultCapabilities,
56
+ defaultApprovalMode: cleanString(parsed.body.defaultApprovalMode, "defaultApprovalMode", { max: 80 }) ?? defaults.defaultApprovalMode,
57
+ defaultTags: cleanStringArray(parsed.body.defaultTags, "defaultTags", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultTags,
58
+ chatCaptureMode: (optionalEnum(parsed.body.chatCaptureMode, "chatCaptureMode", ["final", "full"]) ?? defaults.chatCaptureMode) as "final" | "full",
59
+ headless: {
60
+ tmuxPrefix: cleanString(headless.tmuxPrefix, "headless.tmuxPrefix", { max: 120 }) ?? defaults.headless.tmuxPrefix,
61
+ shutdownTimeoutMs: cleanPositiveInt(headless.shutdownTimeoutMs, "headless.shutdownTimeoutMs") ?? defaults.headless.shutdownTimeoutMs,
62
+ },
63
+ };
64
+ return json(providerConfigPublic(writeProviderConfig(provider, config)));
65
+ } catch (e) {
66
+ if (e instanceof ValidationError) return error(e.message, 400);
67
+ throw e;
68
+ }
69
+ };
70
+
71
+ export const postProviderConfigTestRoute: Handler = async (_req, params) => {
72
+ const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
73
+ if (!provider) return error("provider required");
74
+ const config = loadProviderConfig(provider);
75
+ const proc = Bun.spawn(["bash", "-lc", `command -v "$1"`, "bash", config.command], {
76
+ stdout: "pipe",
77
+ stderr: "pipe",
78
+ });
79
+ const [exitCode, stdout, stderr] = await Promise.all([
80
+ proc.exited,
81
+ new Response(proc.stdout).text(),
82
+ new Response(proc.stderr).text(),
83
+ ]);
84
+ return json({ ok: exitCode === 0, command: config.command, path: stdout.trim(), error: stderr.trim() }, exitCode === 0 ? 200 : 422);
85
+ };
86
+
87
+ function cleanEnvRecord(value: unknown): Record<string, string> {
88
+ if (value === undefined) return {};
89
+ if (!isRecord(value)) throw new ValidationError("env must be an object");
90
+ const env: Record<string, string> = {};
91
+ for (const [key, item] of Object.entries(value)) {
92
+ if (typeof item !== "string") throw new ValidationError(`env.${key} must be a string`);
93
+ env[key] = item;
94
+ }
95
+ return env;
96
+ }
97
+
98
+ function cleanPositiveInt(value: unknown, field: string): number | undefined {
99
+ if (value === undefined) return undefined;
100
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new ValidationError(`${field} must be a positive integer`);
101
+ return value;
102
+ }
103
+
104
+ export const deleteCommandById: Handler = (_req, params) => {
105
+ if (!deleteCommand(params.id!)) return error("command not found or cannot be canceled", 404);
106
+ const command = getCommand(params.id!);
107
+ if (command) {
108
+ applyCommandToRecipe(command);
109
+ emitCommand(command);
110
+ }
111
+ return json({ ok: true });
112
+ };
@@ -0,0 +1,113 @@
1
+ // Auto-split from routes.ts (#299). Domain: recipes.
2
+ import { ValidationError, listArtifactsForEntity } from "../db";
3
+ import { auditEvent, cleanAttachmentRefs, emitCommand, error, json, parseBody, type Handler } from "./_shared";
4
+ import { cleanString } from "../validation";
5
+ import { getRecipe, listRecipes } from "../recipe-loader";
6
+ import { getRecipeInstance, listRecipeInstances, startRecipe, stopRecipe } from "../recipe-runner";
7
+ import { isRecord } from "agent-relay-sdk";
8
+ import { linkRecipeArtifacts } from "../recipe-db";
9
+
10
+ export const getRecipes: Handler = () => {
11
+ return json(listRecipes().map((loaded) => ({
12
+ name: loaded.name,
13
+ source: loaded.source,
14
+ path: loaded.path,
15
+ recipe: loaded.recipe,
16
+ })));
17
+ };
18
+
19
+ export const getRecipeByName: Handler = (_req, params) => {
20
+ const recipe = getRecipe(params.name!);
21
+ return recipe ? json(recipe) : error("recipe not found", 404);
22
+ };
23
+
24
+ export const getRecipeInstances: Handler = (req) => {
25
+ const url = new URL(req.url);
26
+ const status = url.searchParams.get("status") ?? undefined;
27
+ return json(listRecipeInstances(status as any));
28
+ };
29
+
30
+ export const getRecipeInstanceById: Handler = (_req, params) => {
31
+ const instance = getRecipeInstance(params.id!);
32
+ return instance ? json(instance) : error("recipe instance not found", 404);
33
+ };
34
+
35
+ export const postRecipeStart: Handler = async (req) => {
36
+ const parsed = await parseBody<unknown>(req);
37
+ if (!parsed.ok) return error(parsed.error, parsed.status);
38
+ try {
39
+ if (!isRecord(parsed.body)) return error("recipe name required");
40
+ const name = cleanString(parsed.body.name ?? parsed.body.recipe, "name", { required: true, max: 120 })!;
41
+ const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
42
+ const orchestratorId = cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 120 });
43
+ const startedBy = cleanString(parsed.body.startedBy, "startedBy", { max: 120 }) ?? "api";
44
+ const attachments = cleanAttachmentRefs(parsed.body.attachments ?? parsed.body.artifacts, "attachments") ?? [];
45
+ const result = startRecipe({ name, cwd, orchestratorId, startedBy, attachments });
46
+ for (const command of result.commands) emitCommand(command);
47
+ auditEvent({
48
+ clientId: "server-recipe-start-" + result.instance.id,
49
+ kind: "state",
50
+ title: "Recipe started",
51
+ body: result.instance.recipeName,
52
+ meta: result.instance.id,
53
+ icon: "ti-play",
54
+ view: "activity",
55
+ metadata: { recipeInstanceId: result.instance.id, commands: result.commands.map((command) => command.id) },
56
+ });
57
+ return json(result, 202);
58
+ } catch (e) {
59
+ if (e instanceof ValidationError) return error(e.message, 400);
60
+ if (e instanceof Error) return error(e.message, 400);
61
+ throw e;
62
+ }
63
+ };
64
+
65
+ export const getRecipeInstanceArtifacts: Handler = (_req, params) => {
66
+ const instance = getRecipeInstance(params.id!);
67
+ if (!instance) return error("recipe instance not found", 404);
68
+ return json(listArtifactsForEntity("recipeRun", instance.id));
69
+ };
70
+
71
+ export const postRecipeInstanceArtifacts: Handler = async (req, params) => {
72
+ const parsed = await parseBody<unknown>(req);
73
+ if (!parsed.ok) return error(parsed.error, parsed.status);
74
+ try {
75
+ if (!isRecord(parsed.body)) return error("attachments required");
76
+ const createdBy = cleanString(parsed.body.createdBy, "createdBy", { max: 120 }) ?? "api";
77
+ const attachments = cleanAttachmentRefs(parsed.body.attachments ?? parsed.body.artifacts, "attachments") ?? [];
78
+ const instance = linkRecipeArtifacts(params.id!, attachments, createdBy);
79
+ if (!instance) return error("recipe instance not found", 404);
80
+ return json({ instance, artifacts: listArtifactsForEntity("recipeRun", instance.id) }, 201);
81
+ } catch (e) {
82
+ if (e instanceof ValidationError) return error(e.message, 400);
83
+ if (e instanceof Error) return error(e.message, e.message.includes("not found") ? 404 : 400);
84
+ throw e;
85
+ }
86
+ };
87
+
88
+ export const postRecipeStop: Handler = async (req, params) => {
89
+ const parsed = await parseBody<unknown>(req);
90
+ if (!parsed.ok) return error(parsed.error, parsed.status);
91
+ try {
92
+ const stoppedBy = isRecord(parsed.body)
93
+ ? cleanString(parsed.body.stoppedBy, "stoppedBy", { max: 120 }) ?? "api"
94
+ : "api";
95
+ const result = stopRecipe(params.id!, stoppedBy);
96
+ for (const command of result.commands) emitCommand(command);
97
+ auditEvent({
98
+ clientId: "server-recipe-stop-" + result.instance.id + "-" + Date.now(),
99
+ kind: "state",
100
+ title: "Recipe stopped",
101
+ body: result.instance.recipeName,
102
+ meta: result.instance.id,
103
+ icon: "ti-player-stop",
104
+ view: "activity",
105
+ metadata: { recipeInstanceId: result.instance.id, commands: result.commands.map((command) => command.id) },
106
+ });
107
+ return json(result, 202);
108
+ } catch (e) {
109
+ if (e instanceof ValidationError) return error(e.message, 400);
110
+ if (e instanceof Error) return error(e.message, e.message.includes("not found") ? 404 : 400);
111
+ throw e;
112
+ }
113
+ };
@@ -0,0 +1,231 @@
1
+ // Auto-split from routes.ts (#299). Domain: spawn-policy.
2
+ import { ValidationError, getOrchestrator } from "../db";
3
+ import { auditEvent, authAuditMetadata, dashboardAttribution, emitCommand, error, json, parseBody, spawnRequestId, type Handler } from "./_shared";
4
+ import { buildManagedSpawnParams } from "../managed-policy";
5
+ import { cleanString } from "../validation";
6
+ import { createCommand } from "../commands-db";
7
+ import { deleteConfig, getManagedAgentState, getSpawnPolicy, listSpawnPolicies, setConfig, upsertManagedAgentState } from "../config-store";
8
+ import { emitConfigChanged, emitManagedAgentStateChanged } from "../sse";
9
+ import { getLifecycleManager } from "../lifecycle-manager";
10
+ import { isRecord } from "agent-relay-sdk";
11
+ import { type Command, type SpawnPolicy } from "../types";
12
+
13
+ function policyStatusPayload(policy: SpawnPolicy) {
14
+ return {
15
+ policy,
16
+ state: getManagedAgentState(policy.name) ?? {
17
+ policyName: policy.name,
18
+ status: "stopped",
19
+ orchestratorId: policy.orchestratorId,
20
+ provider: policy.provider,
21
+ restartCount: 0,
22
+ consecutiveFailures: 0,
23
+ updatedAt: 0,
24
+ },
25
+ };
26
+ }
27
+
28
+ function requirePolicyAndOrchestrator(name: string): { policy: SpawnPolicy; orch: NonNullable<ReturnType<typeof getOrchestrator>> } | Response {
29
+ const entry = getSpawnPolicy(name);
30
+ if (!entry) return error("spawn policy not found", 404);
31
+ const policy = entry.value;
32
+ const orch = getOrchestrator(policy.orchestratorId);
33
+ if (!orch) return error("orchestrator not found", 404);
34
+ return { policy, orch };
35
+ }
36
+
37
+ function setPolicyEnabled(policy: SpawnPolicy, enabled: boolean): SpawnPolicy {
38
+ if (policy.enabled === enabled || (enabled && policy.enabled === undefined)) return policy;
39
+ const entry = setConfig("spawn-policy", policy.name, { ...policy, enabled }, "lifecycle-control");
40
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
41
+ return entry.value as SpawnPolicy;
42
+ }
43
+
44
+ function enqueuePolicyStart(policy: SpawnPolicy, reason: string): Command | Response {
45
+ const orch = getOrchestrator(policy.orchestratorId);
46
+ if (!orch) return error("orchestrator not found", 404);
47
+ if (orch.status !== "online") return error("orchestrator is offline", 409);
48
+ if (!orch.providers.includes(policy.provider)) return error(`orchestrator does not have provider available: ${policy.provider}`, 409);
49
+ const requestId = spawnRequestId();
50
+ const existing = getManagedAgentState(policy.name);
51
+ const state = upsertManagedAgentState({
52
+ policyName: policy.name,
53
+ status: "starting",
54
+ orchestratorId: policy.orchestratorId,
55
+ provider: policy.provider,
56
+ spawnRequestId: requestId,
57
+ lastSpawnAt: Date.now(),
58
+ // Preserve failure history so a manual start doesn't silently re-arm a
59
+ // crashlooping agent for immediate retry.
60
+ restartCount: existing?.restartCount ?? 0,
61
+ consecutiveFailures: existing?.consecutiveFailures ?? 0,
62
+ });
63
+ emitManagedAgentStateChanged(policy.name, state as unknown as Record<string, unknown>);
64
+ const command = createCommand({
65
+ type: "agent.spawn",
66
+ source: "system",
67
+ target: orch.agentId,
68
+ correlationId: requestId,
69
+ params: {
70
+ ...buildManagedSpawnParams(policy, requestId, { createdBy: "managed-agent" }),
71
+ reason,
72
+ orchestratorId: orch.id,
73
+ },
74
+ });
75
+ emitCommand(command);
76
+ return command;
77
+ }
78
+
79
+ function enqueuePolicyStop(policy: SpawnPolicy, action: "shutdown" | "restart", reason: string): Command | Response {
80
+ const orch = getOrchestrator(policy.orchestratorId);
81
+ if (!orch) return error("orchestrator not found", 404);
82
+ const state = getManagedAgentState(policy.name);
83
+ const restartRequestId = action === "restart" ? spawnRequestId() : undefined;
84
+ const restartSpawn = restartRequestId ? buildManagedSpawnParams(policy, restartRequestId, { createdBy: "managed-agent" }) : undefined;
85
+ const nextState = upsertManagedAgentState({
86
+ policyName: policy.name,
87
+ status: "stopping",
88
+ agentId: state?.agentId,
89
+ orchestratorId: policy.orchestratorId,
90
+ provider: policy.provider,
91
+ tmuxSession: state?.tmuxSession,
92
+ spawnRequestId: restartRequestId ?? state?.spawnRequestId,
93
+ lastSpawnAt: restartSpawn ? Date.now() : state?.lastSpawnAt,
94
+ lastStopAt: Date.now(),
95
+ restartCount: state?.restartCount ?? 0,
96
+ consecutiveFailures: state?.consecutiveFailures ?? 0,
97
+ });
98
+ emitManagedAgentStateChanged(policy.name, nextState as unknown as Record<string, unknown>);
99
+ const command = createCommand({
100
+ type: action === "restart" ? "agent.restart" : "agent.shutdown",
101
+ source: "system",
102
+ target: orch.agentId,
103
+ correlationId: state?.spawnRequestId,
104
+ params: {
105
+ action,
106
+ policyName: policy.name,
107
+ spawnRequestId: state?.spawnRequestId,
108
+ agentId: state?.agentId,
109
+ tmuxSession: state?.tmuxSession,
110
+ graceful: true,
111
+ timeoutMs: 10_000,
112
+ reason,
113
+ orchestratorId: orch.id,
114
+ ...(restartSpawn ? { restartSpawn } : {}),
115
+ requestedBy: "managed-agent",
116
+ requestedAt: Date.now(),
117
+ },
118
+ });
119
+ emitCommand(command);
120
+ return command;
121
+ }
122
+
123
+ const POLICY_LIFECYCLE_ICON: Record<"start" | "stop" | "restart", string> = {
124
+ start: "ti-player-play",
125
+ stop: "ti-power",
126
+ restart: "ti-refresh",
127
+ };
128
+
129
+ function auditPolicyLifecycle(req: Request, policyName: string, action: "start" | "stop" | "restart", command: Command, surface: unknown): void {
130
+ const agentId = typeof command.params?.agentId === "string" ? command.params.agentId : undefined;
131
+ auditEvent({
132
+ clientId: `server-policy-${policyName}-${action}-${command.id}`,
133
+ kind: "state",
134
+ title: `Managed agent ${action} requested`,
135
+ body: policyName,
136
+ meta: agentId ?? policyName,
137
+ icon: POLICY_LIFECYCLE_ICON[action],
138
+ view: "managed",
139
+ ...(agentId ? { agentId } : {}),
140
+ metadata: { action, policyName, commandId: command.id, ...authAuditMetadata(req), ...dashboardAttribution(req, surface) },
141
+ });
142
+ }
143
+
144
+ export const getSpawnPoliciesRoute: Handler = () => {
145
+ return json(listSpawnPolicies().map((entry) => policyStatusPayload(entry.value)));
146
+ };
147
+
148
+ export const getSpawnPolicyRoute: Handler = (_req, params) => {
149
+ const entry = getSpawnPolicy(params.name!);
150
+ return entry ? json(entry) : error("spawn policy not found", 404);
151
+ };
152
+
153
+ export const putSpawnPolicyRoute: 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 value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
158
+ ? parsed.body.value
159
+ : parsed.body;
160
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
161
+ const entry = setConfig("spawn-policy", params.name!, value, updatedBy);
162
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
163
+ getLifecycleManager().onConfigChanged("spawn-policy", entry.key);
164
+ return json(entry, entry.version === 1 ? 201 : 200);
165
+ } catch (e) {
166
+ if (e instanceof ValidationError) return error(e.message, 400);
167
+ throw e;
168
+ }
169
+ };
170
+
171
+ export const deleteSpawnPolicyRoute: Handler = (req, params) => {
172
+ const entry = getSpawnPolicy(params.name!);
173
+ if (!entry) return error("spawn policy not found", 404);
174
+ const stop = enqueuePolicyStop(entry.value, "shutdown", "policy-deleted");
175
+ if (stop instanceof Response) return stop;
176
+ deleteConfig("spawn-policy", params.name!, new URL(req.url).searchParams.get("updatedBy") ?? undefined);
177
+ emitConfigChanged("spawn-policy", params.name!, entry.version + 1);
178
+ return json({ ok: true, command: stop }, 202);
179
+ };
180
+
181
+ async function policyLifecycleSurface(req: Request): Promise<unknown> {
182
+ const parsed = await parseBody<unknown>(req);
183
+ return parsed.ok && isRecord(parsed.body) ? parsed.body.surface : undefined;
184
+ }
185
+
186
+ export const postSpawnPolicyStart: Handler = async (req, params) => {
187
+ const resolved = requirePolicyAndOrchestrator(params.name!);
188
+ if (resolved instanceof Response) return resolved;
189
+ const surface = await policyLifecycleSurface(req);
190
+ const policy = setPolicyEnabled(resolved.policy, true);
191
+ const command = enqueuePolicyStart(policy, "manual-start");
192
+ if (command instanceof Response) return command;
193
+ auditPolicyLifecycle(req, policy.name, "start", command, surface);
194
+ return json({ ok: true, command }, 202);
195
+ };
196
+
197
+ export const postSpawnPolicyStop: Handler = async (req, params) => {
198
+ const resolved = requirePolicyAndOrchestrator(params.name!);
199
+ if (resolved instanceof Response) return resolved;
200
+ const surface = await policyLifecycleSurface(req);
201
+ const policy = setPolicyEnabled(resolved.policy, false);
202
+ const command = enqueuePolicyStop(policy, "shutdown", "manual-stop");
203
+ if (command instanceof Response) return command;
204
+ auditPolicyLifecycle(req, policy.name, "stop", command, surface);
205
+ return json({ ok: true, command }, 202);
206
+ };
207
+
208
+ export const postSpawnPolicyRestart: Handler = async (req, params) => {
209
+ const resolved = requirePolicyAndOrchestrator(params.name!);
210
+ if (resolved instanceof Response) return resolved;
211
+ const surface = await policyLifecycleSurface(req);
212
+ const policy = setPolicyEnabled(resolved.policy, true);
213
+ const command = enqueuePolicyStop(policy, "restart", "manual-restart");
214
+ if (command instanceof Response) return command;
215
+ auditPolicyLifecycle(req, policy.name, "restart", command, surface);
216
+ return json({ ok: true, command }, 202);
217
+ };
218
+
219
+ export const getSpawnPolicyStatus: Handler = (_req, params) => {
220
+ const entry = getSpawnPolicy(params.name!);
221
+ return entry ? json(policyStatusPayload(entry.value)) : error("spawn policy not found", 404);
222
+ };
223
+
224
+ export const getSpawnPoliciesHealth: Handler = () => {
225
+ return json(listSpawnPolicies().map((entry) => policyStatusPayload(entry.value)));
226
+ };
227
+
228
+ export const getSpawnPolicyHealth: Handler = (_req, params) => {
229
+ const entry = getSpawnPolicy(params.name!);
230
+ return entry ? json(policyStatusPayload(entry.value)) : error("spawn policy not found", 404);
231
+ };