@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
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import {
5
5
  closeDb,
6
6
  completeTask,
7
+ createUser,
7
8
  createWorkflow,
8
9
  deleteWorkflow,
9
10
  getTaskByWorkflowRunStepId,
@@ -151,6 +152,28 @@ describe("Workflow Async v2 (Phase 4)", () => {
151
152
  expect(task!.task).toBe("Do the thing");
152
153
  });
153
154
 
155
+ test("inherits requestedByUserId from workflow execution options", async () => {
156
+ const user = createUser({ name: "Workflow Requester" });
157
+ const workflow = makeWorkflow({
158
+ nodes: [
159
+ {
160
+ id: "task1",
161
+ type: "agent-task",
162
+ config: { template: "Do attributed workflow work" },
163
+ },
164
+ ],
165
+ });
166
+
167
+ const runId = await startWorkflowExecution(workflow, { test: true }, registry, {
168
+ requestedByUserId: user.id,
169
+ });
170
+ const steps = getWorkflowRunStepsByRunId(runId);
171
+ const task = getTaskByWorkflowRunStepId(steps[0]!.id);
172
+
173
+ expect(task).toBeTruthy();
174
+ expect(task!.requestedByUserId).toBe(user.id);
175
+ });
176
+
154
177
  test("workflow pauses at waiting when hitting async executor", async () => {
155
178
  const workflow = makeWorkflow({
156
179
  nodes: [
@@ -0,0 +1,122 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ import { parseComposioArgs, runComposioCommand, runXCommand } from "../commands/x";
4
+
5
+ function jsonResponse(body: unknown, status = 200): Response {
6
+ return new Response(JSON.stringify(body), {
7
+ status,
8
+ headers: { "Content-Type": "application/json" },
9
+ });
10
+ }
11
+
12
+ describe("parseComposioArgs", () => {
13
+ test("parses method, path, body, query, and base url", () => {
14
+ const parsed = parseComposioArgs(
15
+ [
16
+ "POST",
17
+ "/tools/execute/GITHUB_CREATE_AN_ISSUE",
18
+ "--body",
19
+ '{"arguments":{"title":"demo"}}',
20
+ "--query",
21
+ "preview=true",
22
+ "--base-url",
23
+ "https://example.test/api",
24
+ ],
25
+ {},
26
+ );
27
+
28
+ expect(parsed.method).toBe("POST");
29
+ expect(parsed.endpoint).toBe("/tools/execute/GITHUB_CREATE_AN_ISSUE");
30
+ expect(parsed.body).toEqual({ arguments: { title: "demo" } });
31
+ expect(parsed.query).toEqual([["preview", "true"]]);
32
+ expect(parsed.baseUrl).toBe("https://example.test/api");
33
+ });
34
+
35
+ test("rejects absolute endpoint URLs so API keys are not routed elsewhere", () => {
36
+ expect(() => parseComposioArgs(["GET", "https://evil.example/tools"], {})).toThrow(
37
+ "endpoint must be a Composio API path",
38
+ );
39
+ });
40
+ });
41
+
42
+ describe("runComposioCommand", () => {
43
+ test("routes request to Composio with x-api-key auth and pretty JSON output", async () => {
44
+ const out: string[] = [];
45
+ const fetchMock = mock(async (url: string | URL | Request, init?: RequestInit) => {
46
+ expect(String(url)).toBe("https://backend.composio.dev/api/v3.1/tools?limit=10");
47
+ expect(init?.method).toBe("GET");
48
+ expect((init?.headers as Record<string, string>)["x-api-key"]).toBe("ck_test_secret");
49
+ return jsonResponse({ ok: true, token: "ck_test_secret" });
50
+ });
51
+
52
+ await runComposioCommand(["GET", "/tools", "--query", "limit=10"], {
53
+ env: { COMPOSIO_API_KEY: "ck_test_secret" },
54
+ fetch: fetchMock,
55
+ log: (message) => out.push(message),
56
+ error: (message) => out.push(message),
57
+ exit: () => {
58
+ throw new Error("should not exit");
59
+ },
60
+ });
61
+
62
+ expect(fetchMock).toHaveBeenCalledTimes(1);
63
+ expect(out.join("\n")).toContain('"ok": true');
64
+ expect(out.join("\n")).toContain("[REDACTED:COMPOSIO_API_KEY]");
65
+ expect(out.join("\n")).not.toContain("ck_test_secret");
66
+ });
67
+
68
+ test("uses org key header when --org is passed", async () => {
69
+ const fetchMock = mock(async (_url: string | URL | Request, init?: RequestInit) => {
70
+ expect((init?.headers as Record<string, string>)["x-org-api-key"]).toBe("org_secret_value");
71
+ expect((init?.headers as Record<string, string>)["x-api-key"]).toBeUndefined();
72
+ return jsonResponse({ ok: true });
73
+ });
74
+
75
+ await runComposioCommand(["GET", "/org/projects", "--org"], {
76
+ env: { COMPOSIO_ORG_API_KEY: "org_secret_value" },
77
+ fetch: fetchMock,
78
+ log: () => {},
79
+ error: () => {},
80
+ exit: () => {
81
+ throw new Error("should not exit");
82
+ },
83
+ });
84
+
85
+ expect(fetchMock).toHaveBeenCalledTimes(1);
86
+ });
87
+
88
+ test("exits before fetch when COMPOSIO_API_KEY is missing", async () => {
89
+ const err: string[] = [];
90
+ const exit = mock(() => undefined);
91
+ const fetchMock = mock(async () => jsonResponse({ ok: true }));
92
+
93
+ await runComposioCommand(["GET", "/tools"], {
94
+ env: {},
95
+ fetch: fetchMock,
96
+ log: () => {},
97
+ error: (message) => err.push(message),
98
+ exit,
99
+ });
100
+
101
+ expect(fetchMock).not.toHaveBeenCalled();
102
+ expect(exit).toHaveBeenCalledWith(1);
103
+ expect(err.join("\n")).toContain("COMPOSIO_API_KEY is required");
104
+ });
105
+ });
106
+
107
+ describe("runXCommand", () => {
108
+ test("dispatches to composio target", async () => {
109
+ const fetchMock = mock(async () => jsonResponse({ ok: true }));
110
+ await runXCommand(["composio", "GET", "/tools"], {
111
+ env: { COMPOSIO_API_KEY: "ck_test_secret" },
112
+ fetch: fetchMock,
113
+ log: () => {},
114
+ error: () => {},
115
+ exit: () => {
116
+ throw new Error("should not exit");
117
+ },
118
+ });
119
+
120
+ expect(fetchMock).toHaveBeenCalledTimes(1);
121
+ });
122
+ });
@@ -0,0 +1,191 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createMetric, getMetric, getMetricBySlug, getMetricVersions, updateMetric } from "@/be/db";
4
+ import { assertSelectOnlyQuery } from "@/http/db-query";
5
+ import { snapshotMetric } from "@/metrics/version";
6
+ import { createToolRegistrar } from "@/tools/utils";
7
+ import { MetricDefinitionSchema } from "@/types";
8
+
9
+ function slugify(input: string): string {
10
+ const slug = input
11
+ .toLowerCase()
12
+ .normalize("NFKD")
13
+ .replace(/[^a-z0-9]+/g, "-")
14
+ .replace(/^-+|-+$/g, "");
15
+ return slug || "metric";
16
+ }
17
+
18
+ function getAppBaseUrl(): string {
19
+ const env = process.env.APP_URL?.trim();
20
+ if (env) return env.replace(/\/+$/, "");
21
+ return "http://localhost:5274";
22
+ }
23
+
24
+ function metricEditCounter(metricId: string): number {
25
+ const versions = getMetricVersions(metricId);
26
+ return versions.length > 0 ? versions[0]!.version + 1 : 1;
27
+ }
28
+
29
+ export const registerCreateMetricTool = (server: McpServer) => {
30
+ createToolRegistrar(server)(
31
+ "create_metric",
32
+ {
33
+ title: "Create or update a metric",
34
+ description:
35
+ "Stores a config-driven dashboard backed by read-only SQL widget queries. " +
36
+ "Calls are upsert-by-(agent, slug), mirroring create_page: same slug updates " +
37
+ "the existing dashboard and snapshots the prior JSON definition.",
38
+ annotations: { destructiveHint: false },
39
+ inputSchema: z.object({
40
+ title: z.string().min(1).describe("Human-readable dashboard title."),
41
+ slug: z
42
+ .string()
43
+ .min(1)
44
+ .optional()
45
+ .describe("URL-safe slug. Defaults to the kebab-cased title."),
46
+ description: z.string().optional().describe("Short description shown in the dashboard."),
47
+ definition: MetricDefinitionSchema.describe(
48
+ "Dashboard JSON definition: a list of widgets, each with SELECT/WITH SQL and viz config.",
49
+ ),
50
+ }),
51
+ outputSchema: z.object({
52
+ yourAgentId: z.string(),
53
+ id: z.string(),
54
+ version: z.number(),
55
+ app_url: z.string(),
56
+ success: z.boolean().optional(),
57
+ message: z.string().optional(),
58
+ }),
59
+ },
60
+ async (input, requestInfo, _meta) => {
61
+ if (!requestInfo.agentId) {
62
+ const msg = "Agent ID required. Set the X-Agent-ID header on the MCP request.";
63
+ return {
64
+ content: [{ type: "text", text: msg }],
65
+ structuredContent: {
66
+ yourAgentId: "",
67
+ id: "",
68
+ version: 0,
69
+ app_url: "",
70
+ success: false,
71
+ message: msg,
72
+ },
73
+ isError: true,
74
+ };
75
+ }
76
+
77
+ try {
78
+ for (const widget of input.definition.widgets) {
79
+ assertSelectOnlyQuery(widget.query.sql);
80
+ }
81
+ } catch (err) {
82
+ const msg = err instanceof Error ? err.message : String(err);
83
+ return {
84
+ content: [{ type: "text", text: `Metric query rejected: ${msg}` }],
85
+ structuredContent: {
86
+ yourAgentId: requestInfo.agentId,
87
+ id: "",
88
+ version: 0,
89
+ app_url: "",
90
+ success: false,
91
+ message: msg,
92
+ },
93
+ isError: true,
94
+ };
95
+ }
96
+
97
+ const slug = input.slug ?? slugify(input.title);
98
+ const existing = getMetricBySlug(requestInfo.agentId, slug);
99
+ let id: string;
100
+
101
+ if (existing) {
102
+ try {
103
+ snapshotMetric(existing.id, requestInfo.agentId);
104
+ } catch {
105
+ // Snapshot failure should not block updates.
106
+ }
107
+ const updated = updateMetric(existing.id, {
108
+ title: input.title,
109
+ description: input.description,
110
+ definition: input.definition,
111
+ });
112
+ if (!updated) {
113
+ const msg = `Failed to update existing metric ${existing.id}.`;
114
+ return {
115
+ content: [{ type: "text", text: msg }],
116
+ structuredContent: {
117
+ yourAgentId: requestInfo.agentId,
118
+ id: existing.id,
119
+ version: 0,
120
+ app_url: "",
121
+ success: false,
122
+ message: msg,
123
+ },
124
+ isError: true,
125
+ };
126
+ }
127
+ id = updated.id;
128
+ } else {
129
+ try {
130
+ const created = createMetric({
131
+ agentId: requestInfo.agentId,
132
+ slug,
133
+ title: input.title,
134
+ description: input.description,
135
+ definition: input.definition,
136
+ });
137
+ id = created.id;
138
+ } catch (err) {
139
+ const detail = err instanceof Error ? err.message : String(err);
140
+ const msg = `Failed to create metric: ${detail}`;
141
+ return {
142
+ content: [{ type: "text", text: msg }],
143
+ structuredContent: {
144
+ yourAgentId: requestInfo.agentId,
145
+ id: "",
146
+ version: 0,
147
+ app_url: "",
148
+ success: false,
149
+ message: msg,
150
+ },
151
+ isError: true,
152
+ };
153
+ }
154
+ }
155
+
156
+ const fresh = getMetric(id);
157
+ if (!fresh) {
158
+ const msg = `Metric ${id} disappeared between write and read.`;
159
+ return {
160
+ content: [{ type: "text", text: msg }],
161
+ structuredContent: {
162
+ yourAgentId: requestInfo.agentId,
163
+ id,
164
+ version: 0,
165
+ app_url: "",
166
+ success: false,
167
+ message: msg,
168
+ },
169
+ isError: true,
170
+ };
171
+ }
172
+
173
+ const version = metricEditCounter(id);
174
+ const appUrl = `${getAppBaseUrl()}/usage/metrics`;
175
+ return {
176
+ content: [
177
+ {
178
+ type: "text",
179
+ text: `Metric "${input.title}" saved (slug=${slug}, version=${version}).\n App: ${appUrl}`,
180
+ },
181
+ ],
182
+ structuredContent: {
183
+ yourAgentId: requestInfo.agentId,
184
+ id,
185
+ version,
186
+ app_url: appUrl,
187
+ },
188
+ };
189
+ },
190
+ );
191
+ };
@@ -0,0 +1,116 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { COMPOSIO_HTTP_METHODS, composioArgsFromParts, executeComposioRequest } from "@/x/composio";
4
+ import { createToolRegistrar } from "./utils";
5
+
6
+ const primitiveQueryValueSchema = z.union([z.string(), z.number(), z.boolean()]);
7
+
8
+ export const registerSwarmXTool = (server: McpServer) => {
9
+ createToolRegistrar(server)(
10
+ "swarm_x",
11
+ {
12
+ title: "Swarm X",
13
+ description:
14
+ "Execute an Agent Swarm external command route. v1 supports target='composio' and mirrors `agent-swarm x composio <method> <path>` with the Composio API key injected server-side.",
15
+ annotations: { openWorldHint: true },
16
+ inputSchema: z.object({
17
+ target: z
18
+ .literal("composio")
19
+ .default("composio")
20
+ .describe("External route target. Only 'composio' is supported in v1."),
21
+ method: z.enum(COMPOSIO_HTTP_METHODS).describe("HTTP method to route to Composio."),
22
+ path: z
23
+ .string()
24
+ .min(1)
25
+ .describe(
26
+ "Composio API path relative to the configured base URL, e.g. /tool_router/session.",
27
+ ),
28
+ body: z.unknown().optional().describe("Optional JSON request body."),
29
+ query: z
30
+ .record(z.string(), primitiveQueryValueSchema)
31
+ .optional()
32
+ .describe("Optional query parameters appended to the Composio path."),
33
+ headers: z
34
+ .record(z.string(), z.string())
35
+ .optional()
36
+ .describe("Optional extra headers. Auth headers are injected by the server."),
37
+ baseUrl: z.string().url().optional().describe("Optional Composio API base URL override."),
38
+ useOrgKey: z
39
+ .boolean()
40
+ .default(false)
41
+ .describe(
42
+ "Use COMPOSIO_ORG_API_KEY/x-org-api-key instead of COMPOSIO_API_KEY/x-api-key.",
43
+ ),
44
+ raw: z
45
+ .boolean()
46
+ .default(false)
47
+ .describe("Return raw text instead of JSON-pretty output text."),
48
+ }),
49
+ outputSchema: z.object({
50
+ target: z.literal("composio"),
51
+ ok: z.boolean(),
52
+ status: z.number(),
53
+ statusText: z.string(),
54
+ method: z.string(),
55
+ url: z.string(),
56
+ response: z.unknown(),
57
+ responseText: z.string(),
58
+ message: z.string(),
59
+ }),
60
+ },
61
+ async (input) => {
62
+ let result: Awaited<ReturnType<typeof executeComposioRequest>>;
63
+ try {
64
+ result = await executeComposioRequest(
65
+ composioArgsFromParts({
66
+ baseUrl: input.baseUrl,
67
+ body: input.body,
68
+ endpoint: input.path,
69
+ headers: input.headers,
70
+ method: input.method,
71
+ query: input.query,
72
+ raw: input.raw,
73
+ useOrgKey: input.useOrgKey,
74
+ }),
75
+ );
76
+ } catch (err) {
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ return {
79
+ content: [{ type: "text", text: `swarm_x composio: ${message}` }],
80
+ structuredContent: {
81
+ target: "composio",
82
+ ok: false,
83
+ status: 0,
84
+ statusText: "Invalid request",
85
+ method: input.method,
86
+ url: "",
87
+ response: null,
88
+ responseText: "",
89
+ message,
90
+ },
91
+ isError: true,
92
+ };
93
+ }
94
+
95
+ const message =
96
+ result.error || result.formattedBody || `HTTP ${result.status} ${result.statusText}`.trim();
97
+ const structuredContent = {
98
+ target: "composio" as const,
99
+ ok: result.ok,
100
+ status: result.status,
101
+ statusText: result.statusText,
102
+ method: result.method,
103
+ url: result.url,
104
+ response: result.body,
105
+ responseText: result.text,
106
+ message,
107
+ };
108
+
109
+ return {
110
+ content: [{ type: "text", text: message }],
111
+ structuredContent,
112
+ isError: !result.ok,
113
+ };
114
+ },
115
+ );
116
+ };
@@ -122,6 +122,9 @@ export const DEFERRED_TOOLS = new Set([
122
122
  // Debug (1)
123
123
  "db-query",
124
124
 
125
+ // Metrics (1)
126
+ "create_metric",
127
+
125
128
  // Approval Requests (1)
126
129
  "request-human-input",
127
130
 
@@ -168,6 +171,9 @@ export const DEFERRED_TOOLS = new Set([
168
171
  "script-delete",
169
172
  "script-query-types",
170
173
 
174
+ // External command routes (1)
175
+ "swarm_x",
176
+
171
177
  // Other (4)
172
178
  "cancel-task",
173
179
  "inject-learning",
package/src/types.ts CHANGED
@@ -1017,6 +1017,7 @@ export const ExecutorMetaSchema = z.object({
1017
1017
  nodeId: z.string(),
1018
1018
  workflowId: z.string().uuid(),
1019
1019
  dryRun: z.boolean().default(false),
1020
+ requestedByUserId: z.string().optional(),
1020
1021
  });
1021
1022
  export type ExecutorMeta = z.infer<typeof ExecutorMetaSchema>;
1022
1023
 
@@ -1390,6 +1391,125 @@ export const PageVersionSchema = z.object({
1390
1391
  });
1391
1392
  export type PageVersion = z.infer<typeof PageVersionSchema>;
1392
1393
 
1394
+ // ---------------------------------------------------------------------------
1395
+ // Metrics
1396
+ // ---------------------------------------------------------------------------
1397
+
1398
+ const MetricParamSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
1399
+ export type MetricParam = z.infer<typeof MetricParamSchema>;
1400
+ const MetricVariableTypeSchema = z.enum(["text", "number", "select"]);
1401
+
1402
+ export const MetricFormatSchema = z.enum(["number", "integer", "currency", "percent", "duration"]);
1403
+ export type MetricFormat = z.infer<typeof MetricFormatSchema>;
1404
+
1405
+ export const MetricVisualizationSchema = z.enum([
1406
+ "stat",
1407
+ "table",
1408
+ "bar",
1409
+ "line",
1410
+ "multi-bar",
1411
+ "multi-line",
1412
+ ]);
1413
+ export type MetricVisualization = z.infer<typeof MetricVisualizationSchema>;
1414
+
1415
+ const MetricQuerySchema = z.object({
1416
+ sql: z.string().min(1).max(10_000),
1417
+ params: z.array(MetricParamSchema).optional(),
1418
+ maxRows: z.number().int().min(1).max(500).optional(),
1419
+ });
1420
+
1421
+ export const MetricVariableSchema = z.object({
1422
+ key: z
1423
+ .string()
1424
+ .min(1)
1425
+ .regex(/^[a-zA-Z][a-zA-Z0-9_]*$/),
1426
+ label: z.string().min(1).optional(),
1427
+ type: MetricVariableTypeSchema.default("text"),
1428
+ defaultValue: MetricParamSchema.optional(),
1429
+ options: z
1430
+ .array(
1431
+ z.object({
1432
+ label: z.string().min(1),
1433
+ value: MetricParamSchema,
1434
+ }),
1435
+ )
1436
+ .optional(),
1437
+ });
1438
+ export type MetricVariable = z.infer<typeof MetricVariableSchema>;
1439
+
1440
+ export const MetricVizConfigSchema = z.object({
1441
+ type: MetricVisualizationSchema,
1442
+ x: z.string().optional(),
1443
+ y: z.string().optional(),
1444
+ series: z.array(z.string()).optional(),
1445
+ label: z.string().optional(),
1446
+ value: z.string().optional(),
1447
+ columns: z
1448
+ .array(
1449
+ z.object({
1450
+ key: z.string(),
1451
+ label: z.string().optional(),
1452
+ format: MetricFormatSchema.optional(),
1453
+ }),
1454
+ )
1455
+ .optional(),
1456
+ format: MetricFormatSchema.optional(),
1457
+ });
1458
+ export type MetricVizConfig = z.infer<typeof MetricVizConfigSchema>;
1459
+
1460
+ export const MetricWidgetSchema = z.object({
1461
+ id: z.string().min(1),
1462
+ title: z.string().min(1),
1463
+ description: z.string().optional(),
1464
+ query: MetricQuerySchema,
1465
+ viz: MetricVizConfigSchema,
1466
+ });
1467
+ export type MetricWidget = z.infer<typeof MetricWidgetSchema>;
1468
+
1469
+ export const MetricDefinitionSchema = z.object({
1470
+ version: z.literal(1),
1471
+ widgets: z.array(MetricWidgetSchema).min(1).max(24),
1472
+ variables: z.array(MetricVariableSchema).max(12).optional(),
1473
+ layout: z
1474
+ .object({
1475
+ columns: z.number().int().min(1).max(4).optional(),
1476
+ })
1477
+ .optional(),
1478
+ refreshSeconds: z.number().int().min(5).max(3600).optional(),
1479
+ });
1480
+ export type MetricDefinition = z.infer<typeof MetricDefinitionSchema>;
1481
+
1482
+ export const MetricSnapshotSchema = z.object({
1483
+ title: z.string(),
1484
+ description: z.string().optional(),
1485
+ definition: MetricDefinitionSchema,
1486
+ });
1487
+ export type MetricSnapshot = z.infer<typeof MetricSnapshotSchema>;
1488
+
1489
+ export const MetricSchema = z.object({
1490
+ id: z.string(),
1491
+ agentId: z.string(),
1492
+ slug: z.string(),
1493
+ title: z.string(),
1494
+ description: z.string().optional(),
1495
+ definition: MetricDefinitionSchema,
1496
+ createdAt: z.string(),
1497
+ updatedAt: z.string(),
1498
+ });
1499
+ export type Metric = z.infer<typeof MetricSchema>;
1500
+
1501
+ export type MetricSummary = Omit<Metric, "definition">;
1502
+
1503
+ export const MetricVersionSchema = z.object({
1504
+ id: z.string(),
1505
+ metricId: z.string(),
1506
+ version: z.number().int().min(1),
1507
+ snapshot: MetricSnapshotSchema,
1508
+ changedByAgentId: z.string().optional(),
1509
+ createdAt: z.string(),
1510
+ });
1511
+ export type MetricVersion = z.infer<typeof MetricVersionSchema>;
1512
+
1393
1513
  // --- Workflow Run ---
1394
1514
 
1395
1515
  export const WorkflowRunStatusSchema = z.enum([
@@ -0,0 +1,28 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { IncomingMessage } from "node:http";
3
+ import type { User } from "../types";
4
+
5
+ export type HttpRequestAuth =
6
+ | { kind: "operator"; fingerprint: string }
7
+ | { kind: "user"; userId: string; user: User };
8
+
9
+ const requestAuth = new WeakMap<IncomingMessage, HttpRequestAuth | null>();
10
+ const authStorage = new AsyncLocalStorage<HttpRequestAuth | null>();
11
+
12
+ export function setRequestAuth(req: IncomingMessage, auth: HttpRequestAuth | null): void {
13
+ requestAuth.set(req, auth);
14
+ authStorage.enterWith(auth);
15
+ }
16
+
17
+ export function getRequestAuth(req: IncomingMessage): HttpRequestAuth | null {
18
+ return requestAuth.get(req) ?? null;
19
+ }
20
+
21
+ export function getCurrentRequestAuth(): HttpRequestAuth | null {
22
+ return authStorage.getStore() ?? null;
23
+ }
24
+
25
+ export function getCurrentRequestUserId(): string | undefined {
26
+ const auth = getCurrentRequestAuth();
27
+ return auth?.kind === "user" ? auth.userId : undefined;
28
+ }
@@ -28,7 +28,7 @@ export async function refreshSkillsIfChanged(
28
28
  ctx: SkillsRefreshContext,
29
29
  lastHashRef: { current: string | null },
30
30
  ): Promise<SkillsRefreshResult> {
31
- const { apiUrl, swarmUrl, apiKey, agentId, role } = ctx;
31
+ const { apiUrl, apiKey, agentId, role } = ctx;
32
32
  const authHeaders: Record<string, string> = { "X-Agent-ID": agentId };
33
33
  if (apiKey) authHeaders.Authorization = `Bearer ${apiKey}`;
34
34
 
@@ -83,7 +83,7 @@ export async function refreshSkillsIfChanged(
83
83
  "X-Agent-ID": agentId,
84
84
  };
85
85
  if (apiKey) syncHeaders.Authorization = `Bearer ${apiKey}`;
86
- const syncRes = await fetch(`${swarmUrl}/api/skills/sync-filesystem`, {
86
+ const syncRes = await fetch(`${apiUrl}/api/skills/sync-filesystem`, {
87
87
  method: "POST",
88
88
  headers: syncHeaders,
89
89
  });