@desplega.ai/agent-swarm 1.88.0 → 1.89.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 (59) hide show
  1. package/README.md +3 -0
  2. package/openapi.json +41 -1
  3. package/package.json +2 -1
  4. package/plugin/skills/composio/SKILL.md +98 -0
  5. package/src/be/db.ts +325 -2
  6. package/src/be/migrations/081_metrics.sql +39 -0
  7. package/src/be/migrations/082_user_audit_fields.sql +120 -0
  8. package/src/be/modelsdev-cache.json +2750 -1431
  9. package/src/be/seed-skills/index.ts +7 -0
  10. package/src/cli.tsx +18 -0
  11. package/src/commands/runner.ts +153 -22
  12. package/src/commands/x.ts +118 -0
  13. package/src/github/handlers.ts +40 -1
  14. package/src/heartbeat/heartbeat.ts +26 -5
  15. package/src/http/active-sessions.ts +32 -1
  16. package/src/http/auth.ts +36 -0
  17. package/src/http/core.ts +20 -16
  18. package/src/http/db-query.ts +20 -0
  19. package/src/http/index.ts +2 -0
  20. package/src/http/metrics.ts +447 -0
  21. package/src/http/operator-actor.ts +9 -0
  22. package/src/http/poll.ts +11 -1
  23. package/src/http/tasks.ts +4 -1
  24. package/src/http/workflows.ts +5 -1
  25. package/src/metrics/version.ts +26 -0
  26. package/src/prompts/base-prompt.ts +8 -0
  27. package/src/prompts/session-templates.ts +23 -0
  28. package/src/providers/opencode-adapter.ts +22 -6
  29. package/src/server.ts +10 -1
  30. package/src/tests/base-prompt.test.ts +35 -0
  31. package/src/tests/budget-claim-gate.test.ts +26 -0
  32. package/src/tests/core-auth.test.ts +8 -1
  33. package/src/tests/events-http.test.ts +6 -2
  34. package/src/tests/github-handlers-cancel-config.test.ts +262 -0
  35. package/src/tests/heartbeat.test.ts +84 -3
  36. package/src/tests/http-api-integration.test.ts +3 -1
  37. package/src/tests/metrics-http.test.ts +247 -0
  38. package/src/tests/opencode-adapter.test.ts +90 -30
  39. package/src/tests/runner-repo-autostash.test.ts +117 -0
  40. package/src/tests/runner-requester-profile.test.ts +25 -0
  41. package/src/tests/runner-skills-refresh.test.ts +1 -1
  42. package/src/tests/swarm-x-tool.test.ts +90 -0
  43. package/src/tests/system-default-skills.test.ts +3 -0
  44. package/src/tests/ui-logs-parser.test.ts +271 -0
  45. package/src/tests/user-token-rest-auth.test.ts +129 -0
  46. package/src/tests/workflow-async-v2.test.ts +23 -0
  47. package/src/tests/x-composio.test.ts +122 -0
  48. package/src/tools/create-metric.ts +191 -0
  49. package/src/tools/swarm-x.ts +116 -0
  50. package/src/tools/tool-config.ts +6 -0
  51. package/src/types.ts +120 -0
  52. package/src/utils/request-auth-context.ts +28 -0
  53. package/src/utils/skills-refresh.ts +2 -2
  54. package/src/workflows/engine.ts +24 -2
  55. package/src/workflows/executors/agent-task.ts +2 -0
  56. package/src/x/composio.ts +295 -0
  57. package/templates/skills/attio-interaction/SKILL.md +279 -0
  58. package/templates/skills/attio-interaction/config.json +14 -0
  59. package/templates/skills/attio-interaction/content.md +272 -0
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
 
package/src/http/poll.ts CHANGED
@@ -86,6 +86,10 @@ const pollTriggers = route({
86
86
  const CHANNEL_ACTIVITY_INTERVAL_MS = 60_000; // Check at most once per 60s
87
87
  let lastChannelActivityCheckAt = 0;
88
88
 
89
+ function getRequesterNotes(notes: string | undefined): string | undefined {
90
+ return typeof notes === "string" && notes.trim().length > 0 ? notes : undefined;
91
+ }
92
+
89
93
  // ─── Cursor Commit Endpoint ─────────────────────────────────────────────────
90
94
 
91
95
  const commitCursorsRoute = route({
@@ -256,6 +260,7 @@ export async function handlePoll(
256
260
  const requestedByUser = pendingTask.requestedByUserId
257
261
  ? getUserById(pendingTask.requestedByUserId)
258
262
  : undefined;
263
+ const requestedByNotes = getRequesterNotes(requestedByUser?.notes);
259
264
 
260
265
  return {
261
266
  trigger: {
@@ -263,7 +268,12 @@ export async function handlePoll(
263
268
  taskId: pendingTask.id,
264
269
  task: { ...pendingTask, status: "in_progress" },
265
270
  ...(requestedByUser && {
266
- requestedBy: { name: requestedByUser.name, email: requestedByUser.email },
271
+ requestedBy: {
272
+ name: requestedByUser.name,
273
+ email: requestedByUser.email,
274
+ role: requestedByUser.role,
275
+ notes: requestedByNotes,
276
+ },
267
277
  }),
268
278
  },
269
279
  };
package/src/http/tasks.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  ProviderNameSchema,
35
35
  ResumeReasonSchema,
36
36
  } from "../types";
37
+ import { getRequestAuth } from "../utils/request-auth-context";
37
38
  import { route } from "./route-def";
38
39
  import { json, jsonError } from "./utils";
39
40
 
@@ -354,7 +355,9 @@ export async function handleTasks(
354
355
  // Tolerant `requestedByUserId`: prevent the deleted-user race from
355
356
  // becoming a 500 — if the referenced user doesn't exist, log and drop
356
357
  // the field rather than letting the FK fail at INSERT.
357
- let requestedByUserId = parsed.body.requestedByUserId || undefined;
358
+ const auth = getRequestAuth(req);
359
+ let requestedByUserId =
360
+ auth?.kind === "user" ? auth.userId : parsed.body.requestedByUserId || undefined;
358
361
  if (requestedByUserId && !getUserById(requestedByUserId)) {
359
362
  console.warn(
360
363
  `[tasks] requestedByUserId ${requestedByUserId} does not exist — coercing to NULL`,
@@ -21,6 +21,7 @@ import {
21
21
  WorkflowPatchSchema,
22
22
  WorkflowRunStatusSchema,
23
23
  } from "../types";
24
+ import { getRequestAuth } from "../utils/request-auth-context";
24
25
  import { getExecutorRegistry, startWorkflowExecution } from "../workflows";
25
26
  import { applyDefinitionPatch, generateEdges, validateDefinition } from "../workflows/definition";
26
27
  import { TriggerSchemaError } from "../workflows/engine";
@@ -645,10 +646,13 @@ export async function handleWorkflows(
645
646
  return true;
646
647
  }
647
648
  const body = await parseBody<Record<string, unknown>>(req);
649
+ const auth = getRequestAuth(req);
648
650
 
649
651
  let runId: string;
650
652
  try {
651
- runId = await startWorkflowExecution(workflow, body, getExecutorRegistry());
653
+ runId = await startWorkflowExecution(workflow, body, getExecutorRegistry(), {
654
+ requestedByUserId: auth?.kind === "user" ? auth.userId : undefined,
655
+ });
652
656
  } catch (err) {
653
657
  if (err instanceof TriggerSchemaError) {
654
658
  triggerSchemaErrorResponse(res, err.message, err.validationErrors);
@@ -0,0 +1,26 @@
1
+ import { createMetricVersion, getMetric, getMetricVersions } from "../be/db";
2
+ import type { MetricSnapshot, MetricVersion } from "../types";
3
+
4
+ export function snapshotMetric(metricId: string, changedByAgentId?: string): MetricVersion {
5
+ const metric = getMetric(metricId);
6
+ if (!metric) {
7
+ throw new Error(`Metric ${metricId} not found — cannot create snapshot`);
8
+ }
9
+
10
+ const existingVersions = getMetricVersions(metricId);
11
+ const maxVersion = existingVersions.length > 0 ? existingVersions[0]!.version : 0;
12
+ const nextVersion = maxVersion + 1;
13
+
14
+ const snapshot: MetricSnapshot = {
15
+ title: metric.title,
16
+ description: metric.description,
17
+ definition: metric.definition,
18
+ };
19
+
20
+ return createMetricVersion({
21
+ metricId,
22
+ version: nextVersion,
23
+ snapshot,
24
+ changedByAgentId,
25
+ });
26
+ }