@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.
- package/README.md +7 -0
- package/openapi.json +41 -1
- package/package.json +3 -2
- package/plugin/skills/composio/SKILL.md +173 -0
- package/plugin/skills/composio-gmail/SKILL.md +83 -0
- package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
- package/plugin/skills/composio-google-docs/SKILL.md +71 -0
- package/src/be/db.ts +353 -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 +3413 -1423
- 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 +80 -12
- 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 +6 -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/tasks/worker-follow-up.ts +19 -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-supersede-resume.test.ts +91 -1
- 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
|
@@ -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
|
-
|
|
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
|
}
|
package/src/http/auth.ts
ADDED
|
@@ -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
|
|
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
|
|