@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/README.md CHANGED
@@ -9,6 +9,9 @@
9
9
  <sub>Built by <a href="https://desplega.sh">desplega.sh</a> — by builders, for builders.</sub>
10
10
  </p>
11
11
 
12
+ > [!TIP]
13
+ > **This repo evolves every single day.** [Watch now →](https://github.com/desplega-ai/agent-swarm/subscription)
14
+
12
15
  <p align="center">
13
16
  <video src="https://github.com/user-attachments/assets/e220712e-c54d-4f46-b059-bac04639d229" controls muted playsinline width="720"></video>
14
17
  </p>
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.88.0",
5
+ "version": "1.89.0",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -277,6 +277,46 @@
277
277
  }
278
278
  }
279
279
  },
280
+ "/api/active-sessions/recover-orphaned-tasks": {
281
+ "post": {
282
+ "summary": "Recover orphaned in-progress tasks for an agent",
283
+ "tags": [
284
+ "Active Sessions"
285
+ ],
286
+ "security": [
287
+ {
288
+ "bearerAuth": []
289
+ }
290
+ ],
291
+ "requestBody": {
292
+ "content": {
293
+ "application/json": {
294
+ "schema": {
295
+ "type": "object",
296
+ "properties": {
297
+ "agentId": {
298
+ "type": "string",
299
+ "minLength": 1
300
+ },
301
+ "minAgeSeconds": {
302
+ "type": "integer",
303
+ "exclusiveMinimum": 0
304
+ }
305
+ },
306
+ "required": [
307
+ "agentId"
308
+ ]
309
+ }
310
+ }
311
+ }
312
+ },
313
+ "responses": {
314
+ "200": {
315
+ "description": "Recovery result"
316
+ }
317
+ }
318
+ }
319
+ },
280
320
  "/api/agents": {
281
321
  "post": {
282
322
  "summary": "Register or re-register an agent",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.88.0",
3
+ "version": "1.89.0",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -45,6 +45,7 @@
45
45
  "tsc:check": "bun tsc --noEmit",
46
46
  "check:db-boundary": "bash scripts/check-db-boundary.sh",
47
47
  "check:api-key-boundary": "bash scripts/check-api-key-boundary.sh",
48
+ "check:audit-columns": "bash scripts/check-audit-columns.sh",
48
49
  "prepare-release": "bun scripts/prepare-release.ts",
49
50
  "sync-chart-version": "bun scripts/sync-chart-version.ts",
50
51
  "check-chart-version": "bun scripts/sync-chart-version.ts --check-if-package-version-changed",
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: composio
3
+ description: Use Composio from Agent Swarm via the `agent-swarm x composio` CLI route or the `swarm_x` MCP tool. Trigger when a task needs connected third-party app tools such as Gmail, GitHub, Slack, Notion, or HubSpot through Composio Tool Router sessions, Connect Links, or connected accounts.
4
+ ---
5
+
6
+ # Composio
7
+
8
+ Use this skill when a task needs Composio-managed third-party app access.
9
+ The current supported surface is the Agent Swarm `x` route:
10
+
11
+ - CLI: `agent-swarm x composio <method> <path> [options]`
12
+ - MCP: `swarm_x` with `target: "composio"`
13
+
14
+ ## Core Model
15
+
16
+ - `COMPOSIO_API_KEY` is deployment-scoped and injected by the CLI/API process.
17
+ - `user_id` is the app user whose connected accounts should be used.
18
+ - Connected accounts persist under that `user_id` across sessions.
19
+ - Tool Router sessions are task/conversation runtime contexts. Store and reuse
20
+ the `session_id` for follow-up turns; create a new session if the user,
21
+ toolkit set, auth config, or pinned connected account changes.
22
+ - Sessions do not expire, but Connect Links and incomplete connection attempts
23
+ expire quickly. If a connection is missing or expired, initiate a new link.
24
+
25
+ ## Workflow
26
+
27
+ 1. Create or reuse a Tool Router session for the target user and toolkit set.
28
+ 2. Search before executing. Use `/search` to get the current tool slug, schema,
29
+ plan, pitfalls, and connection status.
30
+ 3. If Composio reports no active connection, execute
31
+ `COMPOSIO_MANAGE_CONNECTIONS` for the exact toolkit names it returned.
32
+ 4. Share the returned Connect Link with the user and pause until they complete
33
+ auth.
34
+ 5. Retry the app tool only after the toolkit shows an active connected account.
35
+ 6. Prefer metadata-first reads (`include_payload:false`, `verbose:false`) unless
36
+ the user explicitly needs bodies, attachments, or full records.
37
+ 7. Paginate when Composio returns `nextPageToken`, cursors, or continuation
38
+ fields.
39
+
40
+ ## CLI Examples
41
+
42
+ Create a Gmail-scoped session:
43
+
44
+ ```bash
45
+ agent-swarm x composio POST /tool_router/session \
46
+ --body '{"user_id":"swarm-user-id","toolkits":{"enable":["gmail"]},"workbench":{"enable":false}}'
47
+ ```
48
+
49
+ Search for the right Gmail tool:
50
+
51
+ ```bash
52
+ agent-swarm x composio POST /tool_router/session/$SESSION_ID/search \
53
+ --body '{"queries":[{"use_case":"Check recent emails in Gmail and return metadata only."}]}'
54
+ ```
55
+
56
+ Connect Gmail if needed:
57
+
58
+ ```bash
59
+ agent-swarm x composio POST /tool_router/session/$SESSION_ID/execute \
60
+ --body '{"tool_slug":"COMPOSIO_MANAGE_CONNECTIONS","arguments":{"toolkits":["gmail"]}}'
61
+ ```
62
+
63
+ Fetch lightweight email metadata after connection:
64
+
65
+ ```bash
66
+ agent-swarm x composio POST /tool_router/session/$SESSION_ID/execute \
67
+ --body '{"tool_slug":"GMAIL_FETCH_EMAILS","arguments":{"user_id":"me","max_results":5,"include_payload":false,"verbose":false}}'
68
+ ```
69
+
70
+ ## MCP Example
71
+
72
+ ```jsonc
73
+ // Tool call: swarm_x
74
+ {
75
+ "target": "composio",
76
+ "method": "POST",
77
+ "path": "/tool_router/session/$SESSION_ID/execute",
78
+ "body": {
79
+ "tool_slug": "GMAIL_FETCH_EMAILS",
80
+ "arguments": {
81
+ "user_id": "me",
82
+ "max_results": 5,
83
+ "include_payload": false,
84
+ "verbose": false
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ ## Guardrails
91
+
92
+ - Never invent tool slugs or argument shapes. Use `/search` and returned schemas.
93
+ - Never pass absolute URLs as Composio paths. Use relative API paths only.
94
+ - Do not expose `COMPOSIO_API_KEY`; server-side code injects it.
95
+ - Do not fetch email bodies, attachments, or destructive tool actions unless the
96
+ task explicitly requires them.
97
+ - If multiple accounts exist for a toolkit, ask which account to use or pin the
98
+ specific connected account/session account according to the task.
package/src/be/db.ts CHANGED
@@ -42,6 +42,11 @@ import type {
42
42
  McpServerScope,
43
43
  McpServerTransport,
44
44
  McpServerWithInstallInfo,
45
+ Metric,
46
+ MetricDefinition,
47
+ MetricSnapshot,
48
+ MetricSummary,
49
+ MetricVersion,
45
50
  Page,
46
51
  PageAuthMode,
47
52
  PageContentType,
@@ -90,6 +95,7 @@ import type {
90
95
  } from "../types";
91
96
  import { FollowUpConfigSchema, isTerminalTaskStatus } from "../types";
92
97
  import { deriveProviderFromKeyType } from "../utils/credentials";
98
+ import { getCurrentRequestUserId } from "../utils/request-auth-context";
93
99
  import { scrubSecrets } from "../utils/secret-scrubber";
94
100
  import { decryptSecret, encryptSecret, getEncryptionKey, resolveEncryptionKey } from "./crypto";
95
101
  import { normalizeDate, normalizeDateRequired } from "./date-utils";
@@ -1272,6 +1278,31 @@ export function getPendingTaskForAgent(agentId: string): AgentTask | null {
1272
1278
  return null;
1273
1279
  }
1274
1280
 
1281
+ export function assignUnassignedTaskPending(taskId: string, agentId: string): AgentTask | null {
1282
+ const now = new Date().toISOString();
1283
+ const row = getDb()
1284
+ .prepare<AgentTaskRow, [string, string, string]>(
1285
+ `UPDATE agent_tasks SET agentId = ?, status = 'pending', lastUpdatedAt = ?
1286
+ WHERE id = ? AND status = 'unassigned' RETURNING *`,
1287
+ )
1288
+ .get(agentId, now, taskId);
1289
+
1290
+ if (row) {
1291
+ try {
1292
+ createLogEntry({
1293
+ eventType: "task_status_change",
1294
+ agentId,
1295
+ taskId,
1296
+ oldValue: "unassigned",
1297
+ newValue: "pending",
1298
+ metadata: { pendingDispatch: true },
1299
+ });
1300
+ } catch {}
1301
+ }
1302
+
1303
+ return row ? rowToAgentTask(row) : null;
1304
+ }
1305
+
1275
1306
  export function startTask(taskId: string): AgentTask | null {
1276
1307
  const oldTask = getTaskById(taskId);
1277
1308
  if (!oldTask) return null;
@@ -2259,6 +2290,67 @@ export function getPausedTasksForAgent(agentId: string): AgentTask[] {
2259
2290
  return rows.map(rowToAgentTask);
2260
2291
  }
2261
2292
 
2293
+ export function getOrphanedInProgressTasksForAgent(
2294
+ agentId: string,
2295
+ minAgeSeconds = 60,
2296
+ ): AgentTask[] {
2297
+ const cutoff = new Date(Date.now() - minAgeSeconds * 1000).toISOString();
2298
+ const rows = getDb()
2299
+ .prepare<AgentTaskRow, [string, string]>(
2300
+ `SELECT t.* FROM agent_tasks t
2301
+ LEFT JOIN active_sessions s ON s.taskId = t.id
2302
+ WHERE t.agentId = ?
2303
+ AND t.status = 'in_progress'
2304
+ AND t.claudeSessionId IS NULL
2305
+ AND t.lastUpdatedAt < ?
2306
+ AND s.id IS NULL
2307
+ AND t.finishedAt IS NULL
2308
+ ORDER BY t.createdAt ASC, t.rowid ASC`,
2309
+ )
2310
+ .all(agentId, cutoff);
2311
+ return rows.map(rowToAgentTask);
2312
+ }
2313
+
2314
+ export function resetOrphanedInProgressTasksForAgent(
2315
+ agentId: string,
2316
+ minAgeSeconds = 60,
2317
+ ): AgentTask[] {
2318
+ const cutoff = new Date(Date.now() - minAgeSeconds * 1000).toISOString();
2319
+ const rows = getDb()
2320
+ .prepare<AgentTaskRow, [string, string]>(
2321
+ `UPDATE agent_tasks
2322
+ SET status = 'pending',
2323
+ lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
2324
+ WHERE id IN (
2325
+ SELECT t.id FROM agent_tasks t
2326
+ LEFT JOIN active_sessions s ON s.taskId = t.id
2327
+ WHERE t.agentId = ?
2328
+ AND t.status = 'in_progress'
2329
+ AND t.claudeSessionId IS NULL
2330
+ AND t.lastUpdatedAt < ?
2331
+ AND s.id IS NULL
2332
+ AND t.finishedAt IS NULL
2333
+ )
2334
+ RETURNING *`,
2335
+ )
2336
+ .all(agentId, cutoff);
2337
+
2338
+ for (const row of rows) {
2339
+ try {
2340
+ createLogEntry({
2341
+ eventType: "task_status_change",
2342
+ taskId: row.id,
2343
+ agentId,
2344
+ oldValue: "in_progress",
2345
+ newValue: "pending",
2346
+ metadata: { orphanedInProgressRecovery: true },
2347
+ });
2348
+ } catch {}
2349
+ }
2350
+
2351
+ return rows.map(rowToAgentTask);
2352
+ }
2353
+
2262
2354
  /**
2263
2355
  * Get recently cancelled tasks for an agent.
2264
2356
  * Used by hooks to detect task cancellation and stop the worker loop.
@@ -2866,6 +2958,7 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
2866
2958
  }
2867
2959
  }
2868
2960
 
2961
+ const auditUserId = getCurrentRequestUserId() ?? null;
2869
2962
  const row = getDb()
2870
2963
  .prepare<AgentTaskRow, (string | number | null)[]>(
2871
2964
  `INSERT INTO agent_tasks (
@@ -2876,8 +2969,8 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
2876
2969
  vcsInstallationId, vcsNodeId,
2877
2970
  agentmailInboxId, agentmailMessageId, agentmailThreadId,
2878
2971
  mentionMessageId, mentionChannelId, dir, parentTaskId, model, scheduleId,
2879
- workflowRunId, workflowRunStepId, outputSchema, followUpConfig, requestedByUserId, contextKey, swarmVersion, createdAt, lastUpdatedAt
2880
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
2972
+ workflowRunId, workflowRunStepId, outputSchema, followUpConfig, requestedByUserId, contextKey, swarmVersion, createdAt, lastUpdatedAt, created_by, updated_by
2973
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
2881
2974
  )
2882
2975
  .get(
2883
2976
  id,
@@ -2922,6 +3015,8 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
2922
3015
  pkg.version,
2923
3016
  now,
2924
3017
  now,
3018
+ auditUserId,
3019
+ auditUserId,
2925
3020
  );
2926
3021
 
2927
3022
  if (!row) throw new Error("Failed to create task");
@@ -7212,6 +7307,234 @@ export function getPageVersion(pageId: string, version: number): PageVersion | n
7212
7307
  return row ? rowToPageVersion(row) : null;
7213
7308
  }
7214
7309
 
7310
+ // ============================================================================
7311
+ // Metrics CRUD + version history
7312
+ // ----------------------------------------------------------------------------
7313
+ // Config-driven metrics mirror Pages: parent table `metrics` holds the current
7314
+ // JSON definition, and `metric_versions` holds pre-update snapshots.
7315
+ // ============================================================================
7316
+
7317
+ type MetricRow = {
7318
+ id: string;
7319
+ agentId: string;
7320
+ slug: string;
7321
+ title: string;
7322
+ description: string | null;
7323
+ definition: string;
7324
+ createdAt: string;
7325
+ updatedAt: string;
7326
+ };
7327
+
7328
+ function rowToMetric(row: MetricRow): Metric {
7329
+ return {
7330
+ id: row.id,
7331
+ agentId: row.agentId,
7332
+ slug: row.slug,
7333
+ title: row.title,
7334
+ description: row.description ?? undefined,
7335
+ definition: JSON.parse(row.definition) as MetricDefinition,
7336
+ createdAt: normalizeDateRequired(row.createdAt),
7337
+ updatedAt: normalizeDateRequired(row.updatedAt),
7338
+ };
7339
+ }
7340
+
7341
+ function rowToMetricSummary(row: MetricRow): MetricSummary {
7342
+ return {
7343
+ id: row.id,
7344
+ agentId: row.agentId,
7345
+ slug: row.slug,
7346
+ title: row.title,
7347
+ description: row.description ?? undefined,
7348
+ createdAt: normalizeDateRequired(row.createdAt),
7349
+ updatedAt: normalizeDateRequired(row.updatedAt),
7350
+ };
7351
+ }
7352
+
7353
+ export function createMetric(data: {
7354
+ agentId: string;
7355
+ slug: string;
7356
+ title: string;
7357
+ description?: string;
7358
+ definition: MetricDefinition;
7359
+ }): Metric {
7360
+ const row = getDb()
7361
+ .prepare<MetricRow, [string, string, string, string | null, string]>(
7362
+ `INSERT INTO metrics (agentId, slug, title, description, definition)
7363
+ VALUES (?, ?, ?, ?, ?) RETURNING *`,
7364
+ )
7365
+ .get(
7366
+ data.agentId,
7367
+ data.slug,
7368
+ data.title,
7369
+ data.description ?? null,
7370
+ JSON.stringify(data.definition),
7371
+ );
7372
+ if (!row) throw new Error("Failed to create metric");
7373
+ return rowToMetric(row);
7374
+ }
7375
+
7376
+ export function getMetric(id: string): Metric | null {
7377
+ const row = getDb().prepare<MetricRow, [string]>("SELECT * FROM metrics WHERE id = ?").get(id);
7378
+ return row ? rowToMetric(row) : null;
7379
+ }
7380
+
7381
+ export function getMetricBySlug(agentId: string, slug: string): Metric | null {
7382
+ const row = getDb()
7383
+ .prepare<MetricRow, [string, string]>("SELECT * FROM metrics WHERE agentId = ? AND slug = ?")
7384
+ .get(agentId, slug);
7385
+ return row ? rowToMetric(row) : null;
7386
+ }
7387
+
7388
+ export function listMetricsByAgent(agentId: string, limit?: number, offset?: number): Metric[];
7389
+ export function listMetricsByAgent(
7390
+ agentId: string,
7391
+ limit: number | undefined,
7392
+ offset: number | undefined,
7393
+ opts: { slim: true },
7394
+ ): MetricSummary[];
7395
+ export function listMetricsByAgent(
7396
+ agentId: string,
7397
+ limit = 100,
7398
+ offset = 0,
7399
+ opts?: { slim?: boolean },
7400
+ ): Metric[] | MetricSummary[] {
7401
+ const rows = getDb()
7402
+ .prepare<MetricRow, [string, number, number]>(
7403
+ "SELECT * FROM metrics WHERE agentId = ? ORDER BY updatedAt DESC LIMIT ? OFFSET ?",
7404
+ )
7405
+ .all(agentId, limit, offset);
7406
+ return opts?.slim ? rows.map(rowToMetricSummary) : rows.map(rowToMetric);
7407
+ }
7408
+
7409
+ export function listAllMetrics(limit?: number, offset?: number): Metric[];
7410
+ export function listAllMetrics(
7411
+ limit: number | undefined,
7412
+ offset: number | undefined,
7413
+ opts: { slim: true },
7414
+ ): MetricSummary[];
7415
+ export function listAllMetrics(
7416
+ limit = 100,
7417
+ offset = 0,
7418
+ opts?: { slim?: boolean },
7419
+ ): Metric[] | MetricSummary[] {
7420
+ const rows = getDb()
7421
+ .prepare<MetricRow, [number, number]>(
7422
+ "SELECT * FROM metrics ORDER BY updatedAt DESC LIMIT ? OFFSET ?",
7423
+ )
7424
+ .all(limit, offset);
7425
+ return opts?.slim ? rows.map(rowToMetricSummary) : rows.map(rowToMetric);
7426
+ }
7427
+
7428
+ export function countAllMetrics(): number {
7429
+ const row = getDb().prepare<{ count: number }, []>("SELECT COUNT(*) AS count FROM metrics").get();
7430
+ return row?.count ?? 0;
7431
+ }
7432
+
7433
+ export function countMetricsByAgent(agentId: string): number {
7434
+ const row = getDb()
7435
+ .prepare<{ count: number }, [string]>("SELECT COUNT(*) AS count FROM metrics WHERE agentId = ?")
7436
+ .get(agentId);
7437
+ return row?.count ?? 0;
7438
+ }
7439
+
7440
+ export function updateMetric(
7441
+ id: string,
7442
+ data: {
7443
+ title?: string;
7444
+ description?: string | null;
7445
+ definition?: MetricDefinition;
7446
+ slug?: string;
7447
+ },
7448
+ ): Metric | null {
7449
+ const updates: string[] = [];
7450
+ const params: (string | null)[] = [];
7451
+ if (data.title !== undefined) {
7452
+ updates.push("title = ?");
7453
+ params.push(data.title);
7454
+ }
7455
+ if (data.description !== undefined) {
7456
+ updates.push("description = ?");
7457
+ params.push(data.description ?? null);
7458
+ }
7459
+ if (data.definition !== undefined) {
7460
+ updates.push("definition = ?");
7461
+ params.push(JSON.stringify(data.definition));
7462
+ }
7463
+ if (data.slug !== undefined) {
7464
+ updates.push("slug = ?");
7465
+ params.push(data.slug);
7466
+ }
7467
+ if (updates.length === 0) return getMetric(id);
7468
+ updates.push("updatedAt = ?");
7469
+ params.push(new Date().toISOString());
7470
+ params.push(id);
7471
+ const row = getDb()
7472
+ .prepare<MetricRow, (string | null)[]>(
7473
+ `UPDATE metrics SET ${updates.join(", ")} WHERE id = ? RETURNING *`,
7474
+ )
7475
+ .get(...params);
7476
+ return row ? rowToMetric(row) : null;
7477
+ }
7478
+
7479
+ export function deleteMetric(id: string): boolean {
7480
+ const result = getDb().run("DELETE FROM metrics WHERE id = ?", [id]);
7481
+ return result.changes > 0;
7482
+ }
7483
+
7484
+ type MetricVersionRow = {
7485
+ id: string;
7486
+ metricId: string;
7487
+ version: number;
7488
+ snapshot: string;
7489
+ changedByAgentId: string | null;
7490
+ createdAt: string;
7491
+ };
7492
+
7493
+ function rowToMetricVersion(row: MetricVersionRow): MetricVersion {
7494
+ return {
7495
+ id: row.id,
7496
+ metricId: row.metricId,
7497
+ version: row.version,
7498
+ snapshot: JSON.parse(row.snapshot) as MetricSnapshot,
7499
+ changedByAgentId: row.changedByAgentId ?? undefined,
7500
+ createdAt: normalizeDateRequired(row.createdAt),
7501
+ };
7502
+ }
7503
+
7504
+ export function createMetricVersion(data: {
7505
+ metricId: string;
7506
+ version: number;
7507
+ snapshot: MetricSnapshot;
7508
+ changedByAgentId?: string;
7509
+ }): MetricVersion {
7510
+ const row = getDb()
7511
+ .prepare<MetricVersionRow, [string, number, string, string | null]>(
7512
+ `INSERT INTO metric_versions (metricId, version, snapshot, changedByAgentId)
7513
+ VALUES (?, ?, ?, ?) RETURNING *`,
7514
+ )
7515
+ .get(data.metricId, data.version, JSON.stringify(data.snapshot), data.changedByAgentId ?? null);
7516
+ if (!row) throw new Error("Failed to create metric version");
7517
+ return rowToMetricVersion(row);
7518
+ }
7519
+
7520
+ export function getMetricVersions(metricId: string): MetricVersion[] {
7521
+ return getDb()
7522
+ .prepare<MetricVersionRow, [string]>(
7523
+ "SELECT * FROM metric_versions WHERE metricId = ? ORDER BY version DESC",
7524
+ )
7525
+ .all(metricId)
7526
+ .map(rowToMetricVersion);
7527
+ }
7528
+
7529
+ export function getMetricVersion(metricId: string, version: number): MetricVersion | null {
7530
+ const row = getDb()
7531
+ .prepare<MetricVersionRow, [string, number]>(
7532
+ "SELECT * FROM metric_versions WHERE metricId = ? AND version = ?",
7533
+ )
7534
+ .get(metricId, version);
7535
+ return row ? rowToMetricVersion(row) : null;
7536
+ }
7537
+
7215
7538
  // ============================================================================
7216
7539
  // Prompt Template Operations
7217
7540
  // ============================================================================
@@ -0,0 +1,39 @@
1
+ -- Config-driven metrics. Mirrors Pages: parent table holds the current
2
+ -- definition, metric_versions stores pre-update snapshots.
3
+
4
+ CREATE TABLE IF NOT EXISTS metrics (
5
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
6
+ agentId TEXT NOT NULL,
7
+ slug TEXT NOT NULL,
8
+ title TEXT NOT NULL,
9
+ description TEXT,
10
+ definition TEXT NOT NULL,
11
+ createdAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
12
+ updatedAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
13
+ UNIQUE (agentId, slug)
14
+ );
15
+
16
+ CREATE INDEX IF NOT EXISTS idx_metrics_agentId ON metrics(agentId);
17
+ CREATE INDEX IF NOT EXISTS idx_metrics_updatedAt ON metrics(updatedAt DESC);
18
+
19
+ CREATE TABLE IF NOT EXISTS metric_versions (
20
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
21
+ metricId TEXT NOT NULL REFERENCES metrics(id) ON DELETE CASCADE,
22
+ version INTEGER NOT NULL,
23
+ snapshot TEXT NOT NULL,
24
+ changedByAgentId TEXT,
25
+ createdAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
26
+ UNIQUE (metricId, version)
27
+ );
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_metric_versions_metricId ON metric_versions(metricId);
30
+
31
+ INSERT OR IGNORE INTO metrics (agentId, slug, title, description, definition)
32
+ VALUES
33
+ (
34
+ 'system',
35
+ 'swarm-operations-overview',
36
+ 'Swarm operations overview',
37
+ 'A starter dashboard mixing raw SQL widgets with chart and table visualizations.',
38
+ '{"version":1,"refreshSeconds":60,"layout":{"columns":2},"variables":[{"key":"rangeModifier","label":"Time range","type":"select","defaultValue":"-30 days","options":[{"label":"Last 7 days","value":"-7 days"},{"label":"Last 30 days","value":"-30 days"},{"label":"Last 90 days","value":"-90 days"}]},{"key":"userFilter","label":"Requester user ID or name","type":"text","defaultValue":""},{"key":"agentFilter","label":"Agent ID","type":"text","defaultValue":""}],"widgets":[{"id":"open-tasks","title":"Open tasks","description":"Tasks that are not terminal.","query":{"sql":"SELECT COUNT(*) AS open_tasks FROM agent_tasks WHERE status NOT IN (''completed'', ''failed'', ''cancelled'', ''superseded'')","maxRows":10},"viz":{"type":"stat","value":"open_tasks","format":"integer"}},{"id":"tasks-created-per-day","title":"Tasks created per day","description":"Daily task volume for the selected time range.","query":{"sql":"SELECT date(createdAt) AS day, COUNT(*) AS tasks FROM agent_tasks WHERE createdAt >= datetime(''now'', ?) GROUP BY day ORDER BY day","params":["{{rangeModifier}}"],"maxRows":100},"viz":{"type":"line","x":"day","y":"tasks","format":"integer","columns":[{"key":"day","label":"Day"},{"key":"tasks","label":"Tasks","format":"integer"}]}},{"id":"usage-by-user","title":"Usage by user","description":"Tasks requested and session cost by user for the selected time range, requester filter, and agent filter.","query":{"sql":"SELECT COALESCE(u.name, ''Unassigned'') AS user, COUNT(DISTINCT t.id) AS tasks, ROUND(COALESCE(SUM(sc.totalCostUsd), 0), 4) AS cost_usd FROM agent_tasks t LEFT JOIN users u ON u.id = t.requestedByUserId LEFT JOIN session_costs sc ON sc.taskId = t.id WHERE t.createdAt >= datetime(''now'', ?) AND (? = '''' OR COALESCE(u.id, '''') = ? OR COALESCE(u.name, '''') LIKE ''%'' || ? || ''%'') AND (? = '''' OR COALESCE(t.agentId, '''') = ?) GROUP BY COALESCE(u.name, ''Unassigned'') ORDER BY cost_usd DESC, tasks DESC","params":["{{rangeModifier}}","{{userFilter}}","{{userFilter}}","{{userFilter}}","{{agentFilter}}","{{agentFilter}}"],"maxRows":100},"viz":{"type":"bar","x":"user","y":"tasks","format":"integer","columns":[{"key":"user","label":"User"},{"key":"tasks","label":"Tasks","format":"integer"},{"key":"cost_usd","label":"Cost","format":"currency"}]}},{"id":"task-outcomes-by-day","title":"Task outcomes by day","description":"Completed and failed tasks for the selected time range.","query":{"sql":"SELECT date(finishedAt) AS day, SUM(CASE WHEN status = ''completed'' THEN 1 ELSE 0 END) AS completed, SUM(CASE WHEN status = ''failed'' THEN 1 ELSE 0 END) AS failed FROM agent_tasks WHERE finishedAt IS NOT NULL AND finishedAt >= datetime(''now'', ?) GROUP BY day ORDER BY day","params":["{{rangeModifier}}"],"maxRows":100},"viz":{"type":"multi-line","x":"day","series":["completed","failed"],"format":"integer","columns":[{"key":"day","label":"Day"},{"key":"completed","label":"Completed","format":"integer"},{"key":"failed","label":"Failed","format":"integer"}]}},{"id":"recent-task-outcomes","title":"Recent task outcomes","description":"Task status breakdown for tasks created in the selected time range.","query":{"sql":"SELECT status, COUNT(*) AS tasks FROM agent_tasks WHERE createdAt >= datetime(''now'', ?) GROUP BY status ORDER BY tasks DESC","params":["{{rangeModifier}}"],"maxRows":100},"viz":{"type":"table","columns":[{"key":"status","label":"Status"},{"key":"tasks","label":"Tasks","format":"integer"}]}}]}'
39
+ );