@desplega.ai/agent-swarm 1.88.0 → 1.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +7 -0
  2. package/openapi.json +41 -1
  3. package/package.json +3 -2
  4. package/plugin/skills/composio/SKILL.md +173 -0
  5. package/plugin/skills/composio-gmail/SKILL.md +83 -0
  6. package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
  7. package/plugin/skills/composio-google-docs/SKILL.md +71 -0
  8. package/src/be/db.ts +353 -2
  9. package/src/be/migrations/081_metrics.sql +39 -0
  10. package/src/be/migrations/082_user_audit_fields.sql +120 -0
  11. package/src/be/modelsdev-cache.json +3413 -1423
  12. package/src/be/seed-skills/index.ts +7 -0
  13. package/src/cli.tsx +18 -0
  14. package/src/commands/runner.ts +153 -22
  15. package/src/commands/x.ts +118 -0
  16. package/src/github/handlers.ts +40 -1
  17. package/src/heartbeat/heartbeat.ts +80 -12
  18. package/src/http/active-sessions.ts +32 -1
  19. package/src/http/auth.ts +36 -0
  20. package/src/http/core.ts +20 -16
  21. package/src/http/db-query.ts +20 -0
  22. package/src/http/index.ts +2 -0
  23. package/src/http/metrics.ts +447 -0
  24. package/src/http/operator-actor.ts +9 -0
  25. package/src/http/poll.ts +11 -1
  26. package/src/http/tasks.ts +6 -1
  27. package/src/http/workflows.ts +5 -1
  28. package/src/metrics/version.ts +26 -0
  29. package/src/prompts/base-prompt.ts +8 -0
  30. package/src/prompts/session-templates.ts +23 -0
  31. package/src/providers/opencode-adapter.ts +22 -6
  32. package/src/server.ts +10 -1
  33. package/src/tasks/worker-follow-up.ts +19 -1
  34. package/src/tests/base-prompt.test.ts +35 -0
  35. package/src/tests/budget-claim-gate.test.ts +26 -0
  36. package/src/tests/core-auth.test.ts +8 -1
  37. package/src/tests/events-http.test.ts +6 -2
  38. package/src/tests/github-handlers-cancel-config.test.ts +262 -0
  39. package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
  40. package/src/tests/heartbeat.test.ts +84 -3
  41. package/src/tests/http-api-integration.test.ts +3 -1
  42. package/src/tests/metrics-http.test.ts +247 -0
  43. package/src/tests/opencode-adapter.test.ts +90 -30
  44. package/src/tests/runner-repo-autostash.test.ts +117 -0
  45. package/src/tests/runner-requester-profile.test.ts +25 -0
  46. package/src/tests/runner-skills-refresh.test.ts +1 -1
  47. package/src/tests/swarm-x-tool.test.ts +90 -0
  48. package/src/tests/system-default-skills.test.ts +3 -0
  49. package/src/tests/ui-logs-parser.test.ts +271 -0
  50. package/src/tests/user-token-rest-auth.test.ts +129 -0
  51. package/src/tests/workflow-async-v2.test.ts +23 -0
  52. package/src/tests/x-composio.test.ts +122 -0
  53. package/src/tools/create-metric.ts +191 -0
  54. package/src/tools/swarm-x.ts +116 -0
  55. package/src/tools/tool-config.ts +6 -0
  56. package/src/types.ts +120 -0
  57. package/src/utils/request-auth-context.ts +28 -0
  58. package/src/utils/skills-refresh.ts +2 -2
  59. package/src/workflows/engine.ts +24 -2
  60. package/src/workflows/executors/agent-task.ts +2 -0
  61. package/src/x/composio.ts +295 -0
  62. package/templates/skills/attio-interaction/SKILL.md +279 -0
  63. package/templates/skills/attio-interaction/config.json +14 -0
  64. package/templates/skills/attio-interaction/content.md +272 -0
@@ -8,6 +8,7 @@ import {
8
8
  getActiveSessions,
9
9
  heartbeatActiveSession,
10
10
  insertActiveSession,
11
+ resetOrphanedInProgressTasksForAgent,
11
12
  updateActiveSessionProviderSessionId,
12
13
  } from "../be/db";
13
14
  import { route } from "./route-def";
@@ -115,6 +116,21 @@ const cleanupSessions = route({
115
116
  },
116
117
  });
117
118
 
119
+ const recoverOrphanedTasks = route({
120
+ method: "post",
121
+ path: "/api/active-sessions/recover-orphaned-tasks",
122
+ pattern: ["api", "active-sessions", "recover-orphaned-tasks"],
123
+ summary: "Recover orphaned in-progress tasks for an agent",
124
+ tags: ["Active Sessions"],
125
+ body: z.object({
126
+ agentId: z.string().min(1),
127
+ minAgeSeconds: z.number().int().positive().optional(),
128
+ }),
129
+ responses: {
130
+ 200: { description: "Recovery result" },
131
+ },
132
+ });
133
+
118
134
  // ─── Handler ─────────────────────────────────────────────────────────────────
119
135
 
120
136
  export async function handleActiveSessions(
@@ -122,7 +138,7 @@ export async function handleActiveSessions(
122
138
  res: ServerResponse,
123
139
  pathSegments: string[],
124
140
  queryParams: URLSearchParams,
125
- _myAgentId: string | undefined,
141
+ myAgentId: string | undefined,
126
142
  ): Promise<boolean> {
127
143
  if (listActiveSessions.match(req.method, pathSegments)) {
128
144
  const parsed = await listActiveSessions.parse(req, res, pathSegments, queryParams);
@@ -195,5 +211,20 @@ export async function handleActiveSessions(
195
211
  return true;
196
212
  }
197
213
 
214
+ if (recoverOrphanedTasks.match(req.method, pathSegments)) {
215
+ const parsed = await recoverOrphanedTasks.parse(req, res, pathSegments, queryParams);
216
+ if (!parsed) return true;
217
+ if (!myAgentId || parsed.body.agentId !== myAgentId) {
218
+ json(res, { error: "Can only recover orphaned tasks for the calling agent" }, 403);
219
+ return true;
220
+ }
221
+ const tasks = resetOrphanedInProgressTasksForAgent(
222
+ parsed.body.agentId,
223
+ parsed.body.minAgeSeconds ?? 60,
224
+ );
225
+ json(res, { recovered: tasks.length, tasks });
226
+ return true;
227
+ }
228
+
198
229
  return false;
199
230
  }
@@ -0,0 +1,36 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import { fingerprintApiKey, resolveUserByToken } from "../be/users";
3
+ import type { User } from "../types";
4
+ import type { HttpRequestAuth } from "../utils/request-auth-context";
5
+
6
+ function extractBearer(req: IncomingMessage): string | null {
7
+ const raw = req.headers.authorization;
8
+ const header = Array.isArray(raw) ? raw[0] : raw;
9
+ if (!header?.startsWith("Bearer ")) return null;
10
+ return header.slice("Bearer ".length).trim();
11
+ }
12
+
13
+ export function resolveHttpRequestAuth(
14
+ req: IncomingMessage,
15
+ apiKey: string | undefined,
16
+ ): HttpRequestAuth | null {
17
+ const bearer = extractBearer(req);
18
+ if (!bearer) return null;
19
+
20
+ if (apiKey && bearer === apiKey) {
21
+ return { kind: "operator", fingerprint: fingerprintApiKey(bearer) };
22
+ }
23
+
24
+ if (bearer.startsWith("aswt_")) {
25
+ const user = resolveUserByToken(bearer);
26
+ if (isActiveUser(user)) {
27
+ return { kind: "user", userId: user.id, user };
28
+ }
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ function isActiveUser(user: User | null): user is User {
35
+ return !!user && user.status === "active";
36
+ }
package/src/http/core.ts CHANGED
@@ -15,7 +15,9 @@ import { initJira, resetJira } from "../jira";
15
15
  import { initLinear, resetLinear } from "../linear";
16
16
  import { startSlackApp, stopSlackApp } from "../slack";
17
17
  import type { AgentStatus } from "../types";
18
+ import { setRequestAuth } from "../utils/request-auth-context";
18
19
  import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
20
+ import { resolveHttpRequestAuth } from "./auth";
19
21
  import { generateOpenApiSpec, SCALAR_HTML } from "./openapi";
20
22
  import { isPublicRoute } from "./route-def";
21
23
  import { agentWithCapacity, getPathSegments, parseQueryParams } from "./utils";
@@ -234,25 +236,27 @@ export async function handleCore(
234
236
  return true;
235
237
  }
236
238
 
237
- // API-key authentication (if API_KEY is configured). Routes that opt out via
239
+ // API-key authentication. Routes that opt out via
238
240
  // `route({ auth: { apiKey: false } })` — webhooks, OAuth provider callbacks,
239
241
  // etc. — are skipped based on the central `routeRegistry`. Unknown paths
240
- // fall through to the bearer check (fail-closed).
241
- if (apiKey) {
242
- const pathSegments = getPathSegments(req.url || "");
243
- const isUserMcpRoute = req.url === "/mcp-user";
244
- // `/mcp-user` runs its own `aswt_`-token auth in `handleMcpUser`; the swarm
245
- // API key must not gate it.
246
- if (!isUserMcpRoute && !isPublicRoute(req.method, pathSegments)) {
247
- const authHeader = req.headers.authorization;
248
- const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
249
-
250
- if (providedKey !== apiKey) {
251
- res.writeHead(401, { "Content-Type": "application/json" });
252
- res.end(JSON.stringify({ error: "Unauthorized" }));
253
- return true;
254
- }
242
+ // fall through to the bearer check (fail-closed). Normal API calls may use
243
+ // either the global swarm key or an active user-bound `aswt_` token.
244
+ const pathSegments = getPathSegments(req.url || "");
245
+ const isUserMcpRoute = req.url === "/mcp-user";
246
+ // `/mcp-user` runs its own `aswt_`-token auth in `handleMcpUser`; the swarm
247
+ // API key must not gate it.
248
+ if (isUserMcpRoute || isPublicRoute(req.method, pathSegments)) {
249
+ setRequestAuth(req, null);
250
+ } else {
251
+ const auth = resolveHttpRequestAuth(req, apiKey);
252
+
253
+ if (!auth) {
254
+ setRequestAuth(req, null);
255
+ res.writeHead(401, { "Content-Type": "application/json" });
256
+ res.end(JSON.stringify({ error: "Unauthorized" }));
257
+ return true;
255
258
  }
259
+ setRequestAuth(req, auth);
256
260
  }
257
261
 
258
262
  // POST /internal/reload-config — re-read swarm_config into process.env and re-init integrations
@@ -11,6 +11,25 @@ export interface DbQueryResult {
11
11
  total: number;
12
12
  }
13
13
 
14
+ function stripTrailingSemicolon(sql: string): string {
15
+ return sql.trim().replace(/;\s*$/, "").trim();
16
+ }
17
+
18
+ function assertSingleStatement(sql: string): void {
19
+ const stripped = stripTrailingSemicolon(sql);
20
+ if (stripped.includes(";")) {
21
+ throw new Error("Only one SQL statement is allowed");
22
+ }
23
+ }
24
+
25
+ export function assertSelectOnlyQuery(sql: string): void {
26
+ assertSingleStatement(sql);
27
+ const normalized = stripTrailingSemicolon(sql).toLowerCase();
28
+ if (!normalized.startsWith("select ") && !normalized.startsWith("with ")) {
29
+ throw new Error("Metric queries must start with SELECT or WITH");
30
+ }
31
+ }
32
+
14
33
  /**
15
34
  * Execute a read-only SQL query against the swarm database.
16
35
  * Detects write statements via bun:sqlite's columnNames (empty for INSERT/UPDATE/DELETE/DROP).
@@ -20,6 +39,7 @@ export function executeReadOnlyQuery(
20
39
  params: unknown[] = [],
21
40
  maxRows?: number,
22
41
  ): DbQueryResult {
42
+ assertSingleStatement(sql);
23
43
  const stmt = getDb().prepare(sql);
24
44
 
25
45
  // bun:sqlite: columnNames is empty for write statements, populated for SELECT/PRAGMA/EXPLAIN
package/src/http/index.ts CHANGED
@@ -46,6 +46,7 @@ import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from ".
46
46
  import { handleMcpServers } from "./mcp-servers";
47
47
  import { handleMcpUser } from "./mcp-user";
48
48
  import { handleMemory } from "./memory";
49
+ import { handleMetrics } from "./metrics";
49
50
  import { handlePageProxy } from "./page-proxy";
50
51
  import { handlePages } from "./pages";
51
52
  import { handlePagesPublic } from "./pages-public";
@@ -229,6 +230,7 @@ const httpServer = createHttpServer(async (req, res) => {
229
230
  () => handleIntegrations(req, res, pathSegments),
230
231
  () => handlePromptTemplates(req, res, pathSegments, queryParams),
231
232
  () => handleDbQuery(req, res, pathSegments, queryParams),
233
+ () => handleMetrics(req, res, pathSegments, queryParams, myAgentId),
232
234
  () => handleRepos(req, res, pathSegments, queryParams),
233
235
  () => handleSkills(req, res, pathSegments, queryParams, myAgentId),
234
236
  () => handleScripts(req, res, pathSegments, queryParams, myAgentId),
@@ -0,0 +1,447 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import {
4
+ countAllMetrics,
5
+ countMetricsByAgent,
6
+ createMetric,
7
+ deleteMetric,
8
+ getMetric,
9
+ getMetricVersion,
10
+ getMetricVersions,
11
+ listAllMetrics,
12
+ listMetricsByAgent,
13
+ updateMetric,
14
+ } from "../be/db";
15
+ import { snapshotMetric } from "../metrics/version";
16
+ import {
17
+ type Metric,
18
+ MetricDefinitionSchema,
19
+ type MetricParam,
20
+ type MetricSummary,
21
+ type MetricVariable,
22
+ MetricVersionSchema,
23
+ type MetricWidget,
24
+ } from "../types";
25
+ import { assertSelectOnlyQuery, executeReadOnlyQuery } from "./db-query";
26
+ import { route } from "./route-def";
27
+ import { json, jsonError } from "./utils";
28
+
29
+ const DEFAULT_METRIC_MAX_ROWS = 100;
30
+ const HARD_METRIC_MAX_ROWS = 500;
31
+ const VARIABLE_TOKEN_RE = /^\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}$/;
32
+ const MetricRunBodySchema = z
33
+ .object({
34
+ variables: z
35
+ .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
36
+ .optional(),
37
+ })
38
+ .optional();
39
+
40
+ function slugify(input: string): string {
41
+ const slug = input
42
+ .toLowerCase()
43
+ .normalize("NFKD")
44
+ .replace(/[^a-z0-9]+/g, "-")
45
+ .replace(/^-+|-+$/g, "");
46
+ return slug || "metric";
47
+ }
48
+
49
+ function validateMetricDefinition(definition: unknown) {
50
+ const parsed = MetricDefinitionSchema.parse(definition);
51
+ for (const widget of parsed.widgets) {
52
+ assertSelectOnlyQuery(widget.query.sql);
53
+ }
54
+ return parsed;
55
+ }
56
+
57
+ function coerceVariableValue(variable: MetricVariable, raw: unknown): MetricParam {
58
+ if (raw == null || raw === "") {
59
+ return variable.defaultValue ?? null;
60
+ }
61
+ if (variable.type === "number") {
62
+ const numeric = typeof raw === "number" ? raw : Number(raw);
63
+ if (!Number.isFinite(numeric)) {
64
+ throw new Error(`Metric variable "${variable.key}" must be a number`);
65
+ }
66
+ return numeric;
67
+ }
68
+ if (typeof raw === "boolean" || typeof raw === "number" || typeof raw === "string") {
69
+ return raw;
70
+ }
71
+ return String(raw);
72
+ }
73
+
74
+ function resolveMetricVariables(metric: Metric, provided: Record<string, unknown>) {
75
+ const values: Record<string, MetricParam> = {};
76
+ for (const variable of metric.definition.variables ?? []) {
77
+ const value = coerceVariableValue(variable, provided[variable.key]);
78
+ if (variable.options?.length) {
79
+ const allowed = variable.options.some((option) => option.value === value);
80
+ if (!allowed) {
81
+ throw new Error(`Metric variable "${variable.key}" must match one of its options`);
82
+ }
83
+ }
84
+ values[variable.key] = value;
85
+ }
86
+ return values;
87
+ }
88
+
89
+ function resolveWidgetParams(
90
+ widget: MetricWidget,
91
+ variables: Record<string, MetricParam>,
92
+ ): MetricParam[] {
93
+ return (widget.query.params ?? []).map((param) => {
94
+ if (typeof param !== "string") return param;
95
+ const match = VARIABLE_TOKEN_RE.exec(param);
96
+ if (!match) return param;
97
+ const key = match[1]!;
98
+ if (!(key in variables)) {
99
+ throw new Error(`Metric variable "${key}" is not defined`);
100
+ }
101
+ return variables[key] ?? null;
102
+ });
103
+ }
104
+
105
+ function runMetricWidget(widget: MetricWidget, variables: Record<string, MetricParam>) {
106
+ assertSelectOnlyQuery(widget.query.sql);
107
+ const requestedRows = widget.query.maxRows ?? DEFAULT_METRIC_MAX_ROWS;
108
+ const maxRows = Math.min(requestedRows, HARD_METRIC_MAX_ROWS);
109
+ const result = executeReadOnlyQuery(
110
+ widget.query.sql,
111
+ resolveWidgetParams(widget, variables),
112
+ maxRows,
113
+ );
114
+ return {
115
+ widget,
116
+ result: {
117
+ ...result,
118
+ rows: result.rows.map((row) =>
119
+ Object.fromEntries(result.columns.map((column, index) => [column, row[index]])),
120
+ ),
121
+ truncated: result.total > result.rows.length,
122
+ maxRows,
123
+ },
124
+ };
125
+ }
126
+
127
+ function runMetric(metric: Metric, providedVariables: Record<string, unknown> = {}) {
128
+ const variables = resolveMetricVariables(metric, providedVariables);
129
+ const widgets = metric.definition.widgets.map((widget) => runMetricWidget(widget, variables));
130
+ return {
131
+ metric,
132
+ variables,
133
+ widgets,
134
+ // Kept as the first widget result for older callers during the PR cycle.
135
+ result: widgets[0]?.result,
136
+ };
137
+ }
138
+
139
+ const metricDefinitionBody = z.object({
140
+ slug: z.string().min(1).optional(),
141
+ title: z.string().min(1),
142
+ description: z.string().nullable().optional(),
143
+ definition: MetricDefinitionSchema,
144
+ });
145
+
146
+ const createMetricRoute = route({
147
+ method: "post",
148
+ path: "/api/metrics/definitions",
149
+ pattern: ["api", "metrics", "definitions"],
150
+ summary: "Create a metric definition",
151
+ tags: ["Metrics"],
152
+ body: metricDefinitionBody,
153
+ responses: {
154
+ 201: { description: "Metric created" },
155
+ 400: { description: "Invalid metric definition" },
156
+ 409: { description: "Slug already exists for this agent" },
157
+ },
158
+ });
159
+
160
+ const listMetricsRoute = route({
161
+ method: "get",
162
+ path: "/api/metrics/definitions",
163
+ pattern: ["api", "metrics", "definitions"],
164
+ summary: "List metric definitions",
165
+ tags: ["Metrics"],
166
+ query: z.object({
167
+ agentId: z.string().min(1).optional(),
168
+ limit: z.coerce.number().int().min(1).max(500).optional(),
169
+ offset: z.coerce.number().int().min(0).optional(),
170
+ fields: z.enum(["full", "slim"]).optional(),
171
+ }),
172
+ responses: {
173
+ 200: { description: "Metric definitions" },
174
+ },
175
+ });
176
+
177
+ const getMetricRoute = route({
178
+ method: "get",
179
+ path: "/api/metrics/definitions/{id}",
180
+ pattern: ["api", "metrics", "definitions", null],
181
+ summary: "Get a metric definition",
182
+ tags: ["Metrics"],
183
+ params: z.object({ id: z.string() }),
184
+ responses: {
185
+ 200: { description: "Metric definition" },
186
+ 404: { description: "Metric not found" },
187
+ },
188
+ });
189
+
190
+ const updateMetricRoute = route({
191
+ method: "put",
192
+ path: "/api/metrics/definitions/{id}",
193
+ pattern: ["api", "metrics", "definitions", null],
194
+ summary: "Update a metric definition",
195
+ tags: ["Metrics"],
196
+ params: z.object({ id: z.string() }),
197
+ body: metricDefinitionBody.partial(),
198
+ responses: {
199
+ 200: { description: "Metric updated" },
200
+ 404: { description: "Metric not found" },
201
+ },
202
+ });
203
+
204
+ const deleteMetricRoute = route({
205
+ method: "delete",
206
+ path: "/api/metrics/definitions/{id}",
207
+ pattern: ["api", "metrics", "definitions", null],
208
+ summary: "Delete a metric definition",
209
+ tags: ["Metrics"],
210
+ params: z.object({ id: z.string() }),
211
+ responses: {
212
+ 204: { description: "Metric deleted" },
213
+ 404: { description: "Metric not found" },
214
+ },
215
+ });
216
+
217
+ const runMetricRoute = route({
218
+ method: "post",
219
+ path: "/api/metrics/definitions/{id}/run",
220
+ pattern: ["api", "metrics", "definitions", null, "run"],
221
+ summary: "Run a metric definition",
222
+ tags: ["Metrics"],
223
+ params: z.object({ id: z.string() }),
224
+ body: MetricRunBodySchema,
225
+ responses: {
226
+ 200: { description: "Metric result" },
227
+ 400: { description: "Invalid or disallowed query" },
228
+ 404: { description: "Metric not found" },
229
+ },
230
+ });
231
+
232
+ const listMetricVersionsRoute = route({
233
+ method: "get",
234
+ path: "/api/metrics/definitions/{id}/versions",
235
+ pattern: ["api", "metrics", "definitions", null, "versions"],
236
+ summary: "List metric definition versions",
237
+ tags: ["Metrics"],
238
+ params: z.object({ id: z.string() }),
239
+ responses: {
240
+ 200: { description: "Metric version list" },
241
+ 404: { description: "Metric not found" },
242
+ },
243
+ });
244
+
245
+ const getMetricVersionRoute = route({
246
+ method: "get",
247
+ path: "/api/metrics/definitions/{id}/versions/{version}",
248
+ pattern: ["api", "metrics", "definitions", null, "versions", null],
249
+ summary: "Get a metric definition version",
250
+ tags: ["Metrics"],
251
+ params: z.object({ id: z.string(), version: z.coerce.number().int().min(1) }),
252
+ responses: {
253
+ 200: { description: "Metric version" },
254
+ 404: { description: "Metric or version not found" },
255
+ },
256
+ });
257
+
258
+ const metricSchemaRoute = route({
259
+ method: "get",
260
+ path: "/api/metrics/schema",
261
+ pattern: ["api", "metrics", "schema"],
262
+ summary: "Get the metric definition JSON Schema",
263
+ tags: ["Metrics"],
264
+ responses: {
265
+ 200: { description: "Metric definition JSON Schema" },
266
+ },
267
+ });
268
+
269
+ function metricEditCounter(metricId: string): number {
270
+ const versions = getMetricVersions(metricId);
271
+ return versions.length > 0 ? versions[0]!.version + 1 : 1;
272
+ }
273
+
274
+ export async function handleMetrics(
275
+ req: IncomingMessage,
276
+ res: ServerResponse,
277
+ pathSegments: string[],
278
+ queryParams: URLSearchParams,
279
+ myAgentId: string | undefined,
280
+ ): Promise<boolean> {
281
+ if (metricSchemaRoute.match(req.method, pathSegments)) {
282
+ json(res, { schema: z.toJSONSchema(MetricDefinitionSchema, { target: "draft-7" }) });
283
+ return true;
284
+ }
285
+
286
+ if (createMetricRoute.match(req.method, pathSegments)) {
287
+ const parsed = await createMetricRoute.parse(req, res, pathSegments, queryParams);
288
+ if (!parsed) return true;
289
+ const ownerAgentId = myAgentId ?? "ui";
290
+
291
+ try {
292
+ const definition = validateMetricDefinition(parsed.body.definition);
293
+ const slug = parsed.body.slug ?? slugify(parsed.body.title);
294
+ const metric = createMetric({
295
+ agentId: ownerAgentId,
296
+ slug,
297
+ title: parsed.body.title,
298
+ description: parsed.body.description ?? undefined,
299
+ definition,
300
+ });
301
+ json(res, { id: metric.id, version: 1 }, 201);
302
+ } catch (err) {
303
+ const msg = err instanceof Error ? err.message : String(err);
304
+ if (msg.includes("UNIQUE")) {
305
+ const slug = parsed.body.slug ?? slugify(parsed.body.title);
306
+ jsonError(res, `Metric with slug "${slug}" already exists for this agent`, 409);
307
+ return true;
308
+ }
309
+ jsonError(res, msg);
310
+ }
311
+ return true;
312
+ }
313
+
314
+ if (listMetricsRoute.match(req.method, pathSegments)) {
315
+ const parsed = await listMetricsRoute.parse(req, res, pathSegments, queryParams);
316
+ if (!parsed) return true;
317
+ const limit = parsed.query.limit ?? 100;
318
+ const offset = parsed.query.offset ?? 0;
319
+ const full = parsed.query.fields === "full";
320
+ let metrics: Array<Metric | MetricSummary>;
321
+ let total: number;
322
+ if (parsed.query.agentId) {
323
+ metrics = full
324
+ ? listMetricsByAgent(parsed.query.agentId, limit, offset)
325
+ : listMetricsByAgent(parsed.query.agentId, limit, offset, { slim: true });
326
+ total = countMetricsByAgent(parsed.query.agentId);
327
+ } else {
328
+ metrics = full
329
+ ? listAllMetrics(limit, offset)
330
+ : listAllMetrics(limit, offset, { slim: true });
331
+ total = countAllMetrics();
332
+ }
333
+ json(res, { metrics, total, limit, offset });
334
+ return true;
335
+ }
336
+
337
+ if (runMetricRoute.match(req.method, pathSegments)) {
338
+ const parsed = await runMetricRoute.parse(req, res, pathSegments, queryParams);
339
+ if (!parsed) return true;
340
+ const metric = getMetric(parsed.params.id);
341
+ if (!metric) {
342
+ res.writeHead(404);
343
+ res.end();
344
+ return true;
345
+ }
346
+ try {
347
+ json(res, runMetric(metric, parsed.body?.variables ?? {}));
348
+ } catch (err) {
349
+ jsonError(res, err instanceof Error ? err.message : String(err));
350
+ }
351
+ return true;
352
+ }
353
+
354
+ if (getMetricVersionRoute.match(req.method, pathSegments)) {
355
+ const parsed = await getMetricVersionRoute.parse(req, res, pathSegments, queryParams);
356
+ if (!parsed) return true;
357
+ if (!getMetric(parsed.params.id)) {
358
+ res.writeHead(404);
359
+ res.end();
360
+ return true;
361
+ }
362
+ const version = getMetricVersion(parsed.params.id, parsed.params.version);
363
+ if (!version) {
364
+ res.writeHead(404);
365
+ res.end();
366
+ return true;
367
+ }
368
+ json(res, MetricVersionSchema.parse(version));
369
+ return true;
370
+ }
371
+
372
+ if (listMetricVersionsRoute.match(req.method, pathSegments)) {
373
+ const parsed = await listMetricVersionsRoute.parse(req, res, pathSegments, queryParams);
374
+ if (!parsed) return true;
375
+ if (!getMetric(parsed.params.id)) {
376
+ res.writeHead(404);
377
+ res.end();
378
+ return true;
379
+ }
380
+ json(res, { versions: getMetricVersions(parsed.params.id) });
381
+ return true;
382
+ }
383
+
384
+ if (getMetricRoute.match(req.method, pathSegments)) {
385
+ const parsed = await getMetricRoute.parse(req, res, pathSegments, queryParams);
386
+ if (!parsed) return true;
387
+ const metric = getMetric(parsed.params.id);
388
+ if (!metric) {
389
+ res.writeHead(404);
390
+ res.end();
391
+ return true;
392
+ }
393
+ json(res, metric);
394
+ return true;
395
+ }
396
+
397
+ if (updateMetricRoute.match(req.method, pathSegments)) {
398
+ const parsed = await updateMetricRoute.parse(req, res, pathSegments, queryParams);
399
+ if (!parsed) return true;
400
+ if (!getMetric(parsed.params.id)) {
401
+ res.writeHead(404);
402
+ res.end();
403
+ return true;
404
+ }
405
+ try {
406
+ const definition =
407
+ parsed.body.definition !== undefined
408
+ ? validateMetricDefinition(parsed.body.definition)
409
+ : undefined;
410
+ try {
411
+ snapshotMetric(parsed.params.id, myAgentId);
412
+ } catch {
413
+ // Snapshot failures should not block edits, matching Pages.
414
+ }
415
+ const updated = updateMetric(parsed.params.id, {
416
+ title: parsed.body.title,
417
+ description: parsed.body.description ?? undefined,
418
+ definition,
419
+ slug: parsed.body.slug,
420
+ });
421
+ if (!updated) {
422
+ res.writeHead(404);
423
+ res.end();
424
+ return true;
425
+ }
426
+ json(res, { id: updated.id, version: metricEditCounter(updated.id) });
427
+ } catch (err) {
428
+ jsonError(res, err instanceof Error ? err.message : String(err));
429
+ }
430
+ return true;
431
+ }
432
+
433
+ if (deleteMetricRoute.match(req.method, pathSegments)) {
434
+ const parsed = await deleteMetricRoute.parse(req, res, pathSegments, queryParams);
435
+ if (!parsed) return true;
436
+ if (!deleteMetric(parsed.params.id)) {
437
+ res.writeHead(404);
438
+ res.end();
439
+ return true;
440
+ }
441
+ res.writeHead(204);
442
+ res.end();
443
+ return true;
444
+ }
445
+
446
+ return false;
447
+ }
@@ -17,6 +17,7 @@
17
17
  import type { IncomingMessage, ServerResponse } from "node:http";
18
18
  import { fingerprintApiKey, type IdentityActor } from "../be/users";
19
19
  import { getApiKey } from "../utils/api-key";
20
+ import { getRequestAuth } from "../utils/request-auth-context";
20
21
  import { jsonError } from "./utils";
21
22
 
22
23
  /**
@@ -40,6 +41,14 @@ function extractBearer(req: IncomingMessage): string | null {
40
41
  * the request when this returns null.
41
42
  */
42
43
  export function getOperatorActor(req: IncomingMessage, res: ServerResponse): IdentityActor | null {
44
+ const auth = getRequestAuth(req);
45
+ if (auth?.kind === "user") {
46
+ return { kind: "user", id: auth.userId };
47
+ }
48
+ if (auth?.kind === "operator") {
49
+ return { kind: "operator", id: auth.fingerprint };
50
+ }
51
+
43
52
  const rawKey = extractBearer(req);
44
53
  const swarmKey = getApiKey();
45
54