@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.
- package/README.md +3 -0
- package/openapi.json +41 -1
- package/package.json +2 -1
- package/plugin/skills/composio/SKILL.md +98 -0
- package/src/be/db.ts +325 -2
- package/src/be/migrations/081_metrics.sql +39 -0
- package/src/be/migrations/082_user_audit_fields.sql +120 -0
- package/src/be/modelsdev-cache.json +2750 -1431
- package/src/be/seed-skills/index.ts +7 -0
- package/src/cli.tsx +18 -0
- package/src/commands/runner.ts +153 -22
- package/src/commands/x.ts +118 -0
- package/src/github/handlers.ts +40 -1
- package/src/heartbeat/heartbeat.ts +26 -5
- package/src/http/active-sessions.ts +32 -1
- package/src/http/auth.ts +36 -0
- package/src/http/core.ts +20 -16
- package/src/http/db-query.ts +20 -0
- package/src/http/index.ts +2 -0
- package/src/http/metrics.ts +447 -0
- package/src/http/operator-actor.ts +9 -0
- package/src/http/poll.ts +11 -1
- package/src/http/tasks.ts +4 -1
- package/src/http/workflows.ts +5 -1
- package/src/metrics/version.ts +26 -0
- package/src/prompts/base-prompt.ts +8 -0
- package/src/prompts/session-templates.ts +23 -0
- package/src/providers/opencode-adapter.ts +22 -6
- package/src/server.ts +10 -1
- package/src/tests/base-prompt.test.ts +35 -0
- package/src/tests/budget-claim-gate.test.ts +26 -0
- package/src/tests/core-auth.test.ts +8 -1
- package/src/tests/events-http.test.ts +6 -2
- package/src/tests/github-handlers-cancel-config.test.ts +262 -0
- package/src/tests/heartbeat.test.ts +84 -3
- package/src/tests/http-api-integration.test.ts +3 -1
- package/src/tests/metrics-http.test.ts +247 -0
- package/src/tests/opencode-adapter.test.ts +90 -30
- package/src/tests/runner-repo-autostash.test.ts +117 -0
- package/src/tests/runner-requester-profile.test.ts +25 -0
- package/src/tests/runner-skills-refresh.test.ts +1 -1
- package/src/tests/swarm-x-tool.test.ts +90 -0
- package/src/tests/system-default-skills.test.ts +3 -0
- package/src/tests/ui-logs-parser.test.ts +271 -0
- package/src/tests/user-token-rest-auth.test.ts +129 -0
- package/src/tests/workflow-async-v2.test.ts +23 -0
- package/src/tests/x-composio.test.ts +122 -0
- package/src/tools/create-metric.ts +191 -0
- package/src/tools/swarm-x.ts +116 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/types.ts +120 -0
- package/src/utils/request-auth-context.ts +28 -0
- package/src/utils/skills-refresh.ts +2 -2
- package/src/workflows/engine.ts +24 -2
- package/src/workflows/executors/agent-task.ts +2 -0
- package/src/x/composio.ts +295 -0
- package/templates/skills/attio-interaction/SKILL.md +279 -0
- package/templates/skills/attio-interaction/config.json +14 -0
- 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
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
package/src/http/db-query.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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`,
|
package/src/http/workflows.ts
CHANGED
|
@@ -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
|
+
}
|