@desplega.ai/agent-swarm 1.70.0 → 1.71.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 (41) hide show
  1. package/openapi.json +184 -1
  2. package/package.json +1 -1
  3. package/src/be/db-queries/oauth.ts +45 -15
  4. package/src/be/db-queries/tracker.ts +109 -0
  5. package/src/be/migrations/043_jira_source.sql +128 -0
  6. package/src/commands/runner.ts +7 -2
  7. package/src/http/core.ts +6 -21
  8. package/src/http/index.ts +9 -1
  9. package/src/http/route-def.ts +19 -0
  10. package/src/http/trackers/index.ts +13 -0
  11. package/src/http/trackers/jira.ts +331 -0
  12. package/src/jira/adf.ts +132 -0
  13. package/src/jira/app.ts +65 -0
  14. package/src/jira/client.ts +82 -0
  15. package/src/jira/index.ts +24 -0
  16. package/src/jira/metadata.ts +104 -0
  17. package/src/jira/oauth.ts +98 -0
  18. package/src/jira/outbound.ts +155 -0
  19. package/src/jira/sync.ts +534 -0
  20. package/src/jira/templates.ts +84 -0
  21. package/src/jira/types.ts +35 -0
  22. package/src/jira/webhook-lifecycle.ts +363 -0
  23. package/src/jira/webhook.ts +159 -0
  24. package/src/oauth/wrapper.ts +11 -1
  25. package/src/tasks/context-key.ts +29 -1
  26. package/src/telemetry.ts +38 -3
  27. package/src/tests/context-key.test.ts +19 -0
  28. package/src/tests/jira-adf.test.ts +239 -0
  29. package/src/tests/jira-metadata.test.ts +147 -0
  30. package/src/tests/jira-oauth.test.ts +167 -0
  31. package/src/tests/jira-outbound-sync.test.ts +334 -0
  32. package/src/tests/jira-sync.test.ts +327 -0
  33. package/src/tests/jira-webhook-lifecycle.test.ts +234 -0
  34. package/src/tests/jira-webhook.test.ts +274 -0
  35. package/src/tests/telemetry-init.test.ts +108 -0
  36. package/src/tools/tracker/tracker-link-task.ts +1 -1
  37. package/src/tools/tracker/tracker-map-agent.ts +1 -1
  38. package/src/tools/tracker/tracker-status.ts +1 -1
  39. package/src/tools/tracker/tracker-sync-status.ts +1 -1
  40. package/src/tracker/types.ts +1 -1
  41. package/src/types.ts +1 -0
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.70.0",
5
+ "version": "1.71.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": [
@@ -6209,6 +6209,189 @@
6209
6209
  }
6210
6210
  }
6211
6211
  },
6212
+ "/api/trackers/jira/authorize": {
6213
+ "get": {
6214
+ "summary": "Redirect to Atlassian OAuth consent screen",
6215
+ "tags": [
6216
+ "Trackers"
6217
+ ],
6218
+ "responses": {
6219
+ "302": {
6220
+ "description": "Redirect to Atlassian OAuth"
6221
+ },
6222
+ "500": {
6223
+ "description": "Failed to generate authorization URL"
6224
+ },
6225
+ "503": {
6226
+ "description": "Jira integration not configured"
6227
+ }
6228
+ }
6229
+ }
6230
+ },
6231
+ "/api/trackers/jira/callback": {
6232
+ "get": {
6233
+ "summary": "Handle Jira OAuth callback (resolves cloudId via accessible-resources)",
6234
+ "tags": [
6235
+ "Trackers"
6236
+ ],
6237
+ "parameters": [
6238
+ {
6239
+ "schema": {
6240
+ "type": "string"
6241
+ },
6242
+ "required": true,
6243
+ "name": "code",
6244
+ "in": "query"
6245
+ },
6246
+ {
6247
+ "schema": {
6248
+ "type": "string"
6249
+ },
6250
+ "required": true,
6251
+ "name": "state",
6252
+ "in": "query"
6253
+ }
6254
+ ],
6255
+ "responses": {
6256
+ "200": {
6257
+ "description": "OAuth complete"
6258
+ },
6259
+ "400": {
6260
+ "description": "Invalid state or code"
6261
+ },
6262
+ "500": {
6263
+ "description": "Token exchange or accessible-resources fetch failed"
6264
+ }
6265
+ }
6266
+ }
6267
+ },
6268
+ "/api/trackers/jira/status": {
6269
+ "get": {
6270
+ "summary": "Jira connection status, cloudId/siteUrl, token expiry, expected webhook URL, scope/token-config flags",
6271
+ "tags": [
6272
+ "Trackers"
6273
+ ],
6274
+ "security": [
6275
+ {
6276
+ "bearerAuth": []
6277
+ }
6278
+ ],
6279
+ "responses": {
6280
+ "200": {
6281
+ "description": "Connection status"
6282
+ },
6283
+ "503": {
6284
+ "description": "Jira integration not configured"
6285
+ }
6286
+ }
6287
+ }
6288
+ },
6289
+ "/api/trackers/jira/webhook/{token}": {
6290
+ "post": {
6291
+ "summary": "Receive Jira webhook events (URL-token authenticated). Phase 2 stub — Phase 3 fills in dispatch.",
6292
+ "tags": [
6293
+ "Trackers"
6294
+ ],
6295
+ "parameters": [
6296
+ {
6297
+ "schema": {
6298
+ "type": "string"
6299
+ },
6300
+ "required": true,
6301
+ "name": "token",
6302
+ "in": "path"
6303
+ }
6304
+ ],
6305
+ "responses": {
6306
+ "200": {
6307
+ "description": "Event accepted"
6308
+ },
6309
+ "401": {
6310
+ "description": "Invalid URL token"
6311
+ },
6312
+ "503": {
6313
+ "description": "Jira webhook handler not configured"
6314
+ }
6315
+ }
6316
+ }
6317
+ },
6318
+ "/api/trackers/jira/webhook-register": {
6319
+ "post": {
6320
+ "summary": "Register a Jira dynamic webhook (admin only)",
6321
+ "tags": [
6322
+ "Trackers"
6323
+ ],
6324
+ "security": [
6325
+ {
6326
+ "bearerAuth": []
6327
+ }
6328
+ ],
6329
+ "requestBody": {
6330
+ "content": {
6331
+ "application/json": {
6332
+ "schema": {
6333
+ "type": "object",
6334
+ "properties": {
6335
+ "jqlFilter": {
6336
+ "type": "string",
6337
+ "minLength": 1
6338
+ }
6339
+ },
6340
+ "required": [
6341
+ "jqlFilter"
6342
+ ]
6343
+ }
6344
+ }
6345
+ }
6346
+ },
6347
+ "responses": {
6348
+ "200": {
6349
+ "description": "Webhook registered"
6350
+ },
6351
+ "400": {
6352
+ "description": "Invalid jqlFilter"
6353
+ },
6354
+ "503": {
6355
+ "description": "Jira not connected or JIRA_WEBHOOK_TOKEN missing"
6356
+ }
6357
+ }
6358
+ }
6359
+ },
6360
+ "/api/trackers/jira/webhook/{id}": {
6361
+ "delete": {
6362
+ "summary": "Delete a Jira dynamic webhook (admin only)",
6363
+ "tags": [
6364
+ "Trackers"
6365
+ ],
6366
+ "security": [
6367
+ {
6368
+ "bearerAuth": []
6369
+ }
6370
+ ],
6371
+ "parameters": [
6372
+ {
6373
+ "schema": {
6374
+ "type": "integer",
6375
+ "exclusiveMinimum": 0
6376
+ },
6377
+ "required": true,
6378
+ "name": "id",
6379
+ "in": "path"
6380
+ }
6381
+ ],
6382
+ "responses": {
6383
+ "200": {
6384
+ "description": "Webhook deleted"
6385
+ },
6386
+ "400": {
6387
+ "description": "Invalid webhook id"
6388
+ },
6389
+ "503": {
6390
+ "description": "Jira not connected"
6391
+ }
6392
+ }
6393
+ }
6394
+ },
6212
6395
  "/api/trackers/linear/authorize": {
6213
6396
  "get": {
6214
6397
  "summary": "Redirect to Linear OAuth consent screen",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.70.0",
3
+ "version": "1.71.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>",
@@ -39,9 +39,14 @@ export function upsertOAuthApp(
39
39
  metadata?: string;
40
40
  },
41
41
  ): void {
42
- getDb()
43
- .query(
44
- `INSERT INTO oauth_apps (provider, clientId, clientSecret, authorizeUrl, tokenUrl, redirectUri, scopes, metadata)
42
+ // metadata is treated as a runtime-owned column (cloudId, webhookIds, etc.
43
+ // are written by OAuth callback + webhook-register flows). On INSERT we
44
+ // seed it with whatever the caller passed (or "{}"); on UPDATE we ONLY
45
+ // overwrite when the caller explicitly provided one — otherwise the
46
+ // existing value is preserved across server restarts.
47
+ const metadataProvided = data.metadata !== undefined;
48
+ const sql = metadataProvided
49
+ ? `INSERT INTO oauth_apps (provider, clientId, clientSecret, authorizeUrl, tokenUrl, redirectUri, scopes, metadata)
45
50
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
46
51
  ON CONFLICT(provider) DO UPDATE SET
47
52
  clientId = excluded.clientId,
@@ -51,18 +56,43 @@ export function upsertOAuthApp(
51
56
  redirectUri = excluded.redirectUri,
52
57
  scopes = excluded.scopes,
53
58
  metadata = excluded.metadata,
54
- updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
55
- )
56
- .run(
57
- provider,
58
- data.clientId,
59
- data.clientSecret,
60
- data.authorizeUrl,
61
- data.tokenUrl,
62
- data.redirectUri,
63
- data.scopes,
64
- data.metadata ?? "{}",
65
- );
59
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
60
+ : `INSERT INTO oauth_apps (provider, clientId, clientSecret, authorizeUrl, tokenUrl, redirectUri, scopes, metadata)
61
+ VALUES (?, ?, ?, ?, ?, ?, ?, '{}')
62
+ ON CONFLICT(provider) DO UPDATE SET
63
+ clientId = excluded.clientId,
64
+ clientSecret = excluded.clientSecret,
65
+ authorizeUrl = excluded.authorizeUrl,
66
+ tokenUrl = excluded.tokenUrl,
67
+ redirectUri = excluded.redirectUri,
68
+ scopes = excluded.scopes,
69
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`;
70
+ if (metadataProvided) {
71
+ getDb()
72
+ .query(sql)
73
+ .run(
74
+ provider,
75
+ data.clientId,
76
+ data.clientSecret,
77
+ data.authorizeUrl,
78
+ data.tokenUrl,
79
+ data.redirectUri,
80
+ data.scopes,
81
+ data.metadata as string,
82
+ );
83
+ } else {
84
+ getDb()
85
+ .query(sql)
86
+ .run(
87
+ provider,
88
+ data.clientId,
89
+ data.clientSecret,
90
+ data.authorizeUrl,
91
+ data.tokenUrl,
92
+ data.redirectUri,
93
+ data.scopes,
94
+ );
95
+ }
66
96
  }
67
97
 
68
98
  // ── OAuth Tokens ──
@@ -41,6 +41,75 @@ export function getTrackerSyncByExternalId(
41
41
  return row ? normalizeTrackerSync(row) : null;
42
42
  }
43
43
 
44
+ /**
45
+ * Idempotent UNIQUE-gated insert into `tracker_sync`. Returns
46
+ * `{ inserted: true, sync }` when a fresh row was created, or
47
+ * `{ inserted: false, sync }` when the `(provider, entityType, externalId)`
48
+ * tuple already had a row. Used by inbound webhook handlers (currently Jira)
49
+ * to gate task creation atomically: insert sync row first, only call
50
+ * `createTaskExtended` if `inserted === true`.
51
+ *
52
+ * Note: this is a "claim" insert — `swarmId` is initially the sentinel value
53
+ * passed in (callers typically pass a placeholder like `""` or a known UUID),
54
+ * then update it with `updateTrackerSyncSwarmId` once the task is created.
55
+ */
56
+ export function createTrackerSyncIfAbsent(data: {
57
+ provider: string;
58
+ entityType: "task";
59
+ providerEntityType?: string | null;
60
+ swarmId: string;
61
+ externalId: string;
62
+ externalIdentifier?: string | null;
63
+ externalUrl?: string | null;
64
+ lastSyncOrigin?: "swarm" | "external" | null;
65
+ lastDeliveryId?: string | null;
66
+ syncDirection?: "inbound" | "outbound" | "bidirectional";
67
+ }): { inserted: boolean; sync: TrackerSync } {
68
+ const insertResult = getDb()
69
+ .query(
70
+ `INSERT INTO tracker_sync (provider, entityType, providerEntityType, swarmId, externalId, externalIdentifier, externalUrl, lastSyncOrigin, lastDeliveryId, syncDirection)
71
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
72
+ ON CONFLICT (provider, entityType, externalId) DO NOTHING
73
+ RETURNING *`,
74
+ )
75
+ .get(
76
+ data.provider,
77
+ data.entityType,
78
+ data.providerEntityType ?? null,
79
+ data.swarmId,
80
+ data.externalId,
81
+ data.externalIdentifier ?? null,
82
+ data.externalUrl ?? null,
83
+ data.lastSyncOrigin ?? null,
84
+ data.lastDeliveryId ?? null,
85
+ data.syncDirection ?? "inbound",
86
+ ) as TrackerSync | null;
87
+
88
+ if (insertResult) {
89
+ return { inserted: true, sync: normalizeTrackerSync(insertResult) };
90
+ }
91
+
92
+ // Row already existed — fetch it for the caller.
93
+ const existing = getTrackerSyncByExternalId(data.provider, data.entityType, data.externalId);
94
+ if (!existing) {
95
+ // Should be unreachable: ON CONFLICT means a row exists, but this guard
96
+ // satisfies the type system and surfaces unexpected races loudly.
97
+ throw new Error(
98
+ `[tracker] createTrackerSyncIfAbsent: ON CONFLICT fired but no existing row found for (${data.provider}, ${data.entityType}, ${data.externalId})`,
99
+ );
100
+ }
101
+ return { inserted: false, sync: existing };
102
+ }
103
+
104
+ /**
105
+ * Update the `swarmId` on an existing `tracker_sync` row. Used after the
106
+ * idempotent `createTrackerSyncIfAbsent` returned `{ inserted: true }` and
107
+ * we've now created the swarm task that should own this row.
108
+ */
109
+ export function updateTrackerSyncSwarmId(id: string, swarmId: string): void {
110
+ getDb().query("UPDATE tracker_sync SET swarmId = ? WHERE id = ?").run(swarmId, id);
111
+ }
112
+
44
113
  export function createTrackerSync(data: {
45
114
  provider: string;
46
115
  entityType: "task";
@@ -128,6 +197,46 @@ export function deleteTrackerSync(id: string): void {
128
197
  getDb().query("DELETE FROM tracker_sync WHERE id = ?").run(id);
129
198
  }
130
199
 
200
+ /**
201
+ * Check whether a `tracker_sync` row exists for `provider` with the given
202
+ * `lastDeliveryId`. Used by inbound webhook handlers (Jira) to dedupe
203
+ * deliveries via DB-persisted state instead of a process-local Map.
204
+ *
205
+ * Returns `false` when `deliveryId` is falsy/empty so callers don't have to
206
+ * branch.
207
+ */
208
+ export function hasTrackerDelivery(
209
+ provider: string,
210
+ deliveryId: string | null | undefined,
211
+ ): boolean {
212
+ if (!deliveryId) return false;
213
+ const row = getDb()
214
+ .query("SELECT 1 AS hit FROM tracker_sync WHERE provider = ? AND lastDeliveryId = ? LIMIT 1")
215
+ .get(provider, deliveryId) as { hit: number } | null;
216
+ return !!row;
217
+ }
218
+
219
+ /**
220
+ * Mark a delivery as processed by writing `deliveryId` into the relevant
221
+ * `tracker_sync` row identified by `(provider, entityType, externalId)`.
222
+ *
223
+ * No-op when the row doesn't exist yet (the very first inbound event creates
224
+ * the row via `createTrackerSyncIfAbsent` — recording delivery happens after
225
+ * that). Caller is responsible for ordering.
226
+ */
227
+ export function markTrackerDelivery(
228
+ provider: string,
229
+ entityType: "task",
230
+ externalId: string,
231
+ deliveryId: string,
232
+ ): void {
233
+ getDb()
234
+ .query(
235
+ "UPDATE tracker_sync SET lastDeliveryId = ? WHERE provider = ? AND entityType = ? AND externalId = ?",
236
+ )
237
+ .run(deliveryId, provider, entityType, externalId);
238
+ }
239
+
131
240
  export function getAllTrackerSyncs(provider?: string, entityType?: "task"): TrackerSync[] {
132
241
  const conditions: string[] = [];
133
242
  const values: string[] = [];
@@ -0,0 +1,128 @@
1
+ -- Add 'jira' to the agent_tasks.source CHECK constraint.
2
+ -- SQLite cannot ALTER an existing CHECK; we follow the table-rebuild pattern
3
+ -- (per migration 026). Column list mirrors the live post-042 schema verbatim
4
+ -- — including all post-026 additions (credentialKeySuffix/Type, requestedByUserId,
5
+ -- vcsInstallationId, vcsNodeId, slackReplySent, swarmVersion, contextKey).
6
+ -- INSERT uses an explicit column list (no `SELECT *`) to be robust against
7
+ -- column-order drift between SQLite versions.
8
+ PRAGMA foreign_keys=off;
9
+
10
+ CREATE TABLE agent_tasks_new (
11
+ id TEXT PRIMARY KEY,
12
+ agentId TEXT,
13
+ creatorAgentId TEXT,
14
+ task TEXT NOT NULL,
15
+ status TEXT NOT NULL DEFAULT 'pending',
16
+ source TEXT NOT NULL DEFAULT 'mcp' CHECK(source IN ('mcp', 'slack', 'api', 'github', 'gitlab', 'agentmail', 'system', 'schedule', 'workflow', 'linear', 'jira')),
17
+ taskType TEXT,
18
+ tags TEXT DEFAULT '[]',
19
+ priority INTEGER DEFAULT 50,
20
+ dependsOn TEXT DEFAULT '[]',
21
+ offeredTo TEXT,
22
+ offeredAt TEXT,
23
+ acceptedAt TEXT,
24
+ rejectionReason TEXT,
25
+ slackChannelId TEXT,
26
+ slackThreadTs TEXT,
27
+ slackUserId TEXT,
28
+ mentionMessageId TEXT,
29
+ mentionChannelId TEXT,
30
+ vcsProvider TEXT,
31
+ vcsRepo TEXT,
32
+ vcsEventType TEXT,
33
+ vcsNumber INTEGER,
34
+ vcsCommentId INTEGER,
35
+ vcsAuthor TEXT,
36
+ vcsUrl TEXT,
37
+ parentTaskId TEXT,
38
+ claudeSessionId TEXT,
39
+ agentmailInboxId TEXT,
40
+ agentmailMessageId TEXT,
41
+ agentmailThreadId TEXT,
42
+ model TEXT,
43
+ scheduleId TEXT,
44
+ workflowRunId TEXT REFERENCES workflow_runs(id),
45
+ workflowRunStepId TEXT REFERENCES workflow_run_steps(id),
46
+ createdAt TEXT NOT NULL,
47
+ lastUpdatedAt TEXT NOT NULL,
48
+ finishedAt TEXT,
49
+ failureReason TEXT,
50
+ output TEXT,
51
+ progress TEXT,
52
+ notifiedAt TEXT,
53
+ dir TEXT,
54
+ outputSchema TEXT,
55
+ compactionCount INTEGER DEFAULT 0,
56
+ peakContextPercent REAL,
57
+ totalContextTokensUsed INTEGER,
58
+ contextWindowSize INTEGER,
59
+ was_paused INTEGER NOT NULL DEFAULT 0,
60
+ credentialKeySuffix TEXT,
61
+ credentialKeyType TEXT,
62
+ requestedByUserId TEXT REFERENCES users(id),
63
+ vcsInstallationId INTEGER,
64
+ vcsNodeId TEXT,
65
+ slackReplySent INTEGER DEFAULT 0,
66
+ swarmVersion TEXT,
67
+ contextKey TEXT
68
+ );
69
+
70
+ INSERT INTO agent_tasks_new (
71
+ id, agentId, creatorAgentId, task, status, source, taskType, tags,
72
+ priority, dependsOn, offeredTo, offeredAt, acceptedAt, rejectionReason,
73
+ slackChannelId, slackThreadTs, slackUserId,
74
+ mentionMessageId, mentionChannelId,
75
+ vcsProvider, vcsRepo, vcsEventType, vcsNumber, vcsCommentId, vcsAuthor, vcsUrl,
76
+ parentTaskId, claudeSessionId,
77
+ agentmailInboxId, agentmailMessageId, agentmailThreadId,
78
+ model, scheduleId, workflowRunId, workflowRunStepId,
79
+ createdAt, lastUpdatedAt, finishedAt, failureReason, output, progress, notifiedAt,
80
+ dir, outputSchema, compactionCount, peakContextPercent,
81
+ totalContextTokensUsed, contextWindowSize, was_paused,
82
+ credentialKeySuffix, credentialKeyType, requestedByUserId,
83
+ vcsInstallationId, vcsNodeId, slackReplySent, swarmVersion, contextKey
84
+ )
85
+ SELECT
86
+ id, agentId, creatorAgentId, task, status, source, taskType, tags,
87
+ priority, dependsOn, offeredTo, offeredAt, acceptedAt, rejectionReason,
88
+ slackChannelId, slackThreadTs, slackUserId,
89
+ mentionMessageId, mentionChannelId,
90
+ vcsProvider, vcsRepo, vcsEventType, vcsNumber, vcsCommentId, vcsAuthor, vcsUrl,
91
+ parentTaskId, claudeSessionId,
92
+ agentmailInboxId, agentmailMessageId, agentmailThreadId,
93
+ model, scheduleId, workflowRunId, workflowRunStepId,
94
+ createdAt, lastUpdatedAt, finishedAt, failureReason, output, progress, notifiedAt,
95
+ dir, outputSchema, compactionCount, peakContextPercent,
96
+ totalContextTokensUsed, contextWindowSize, was_paused,
97
+ credentialKeySuffix, credentialKeyType, requestedByUserId,
98
+ vcsInstallationId, vcsNodeId, slackReplySent, swarmVersion, contextKey
99
+ FROM agent_tasks;
100
+
101
+ DROP TABLE agent_tasks;
102
+ ALTER TABLE agent_tasks_new RENAME TO agent_tasks;
103
+
104
+ -- Recreate every index that existed on agent_tasks (per `grep -rn "ON agent_tasks(" src/be/migrations/`):
105
+ -- 001/004/006/009/026: agentId, status, offeredTo, taskType, agentmailThreadId, scheduleId, workflowRunId
106
+ -- 031: requestedByUserId (partial)
107
+ -- 034: parentTaskId
108
+ -- 037: swarmVersion
109
+ -- 040: composite (slackChannelId, slackThreadTs, status)
110
+ -- 042: contextKey + (contextKey, status) composite
111
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_agentId ON agent_tasks(agentId);
112
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_status ON agent_tasks(status);
113
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_offeredTo ON agent_tasks(offeredTo);
114
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_taskType ON agent_tasks(taskType);
115
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_agentmailThreadId ON agent_tasks(agentmailThreadId);
116
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_schedule_id ON agent_tasks(scheduleId);
117
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_workflow_run ON agent_tasks(workflowRunId);
118
+ CREATE INDEX IF NOT EXISTS idx_tasks_requested_by ON agent_tasks(requestedByUserId) WHERE requestedByUserId IS NOT NULL;
119
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_parentTaskId ON agent_tasks(parentTaskId);
120
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_swarmVersion ON agent_tasks(swarmVersion);
121
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_slack_thread
122
+ ON agent_tasks(slackChannelId, slackThreadTs, status);
123
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_context_key
124
+ ON agent_tasks(contextKey);
125
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_context_key_status
126
+ ON agent_tasks(contextKey, status);
127
+
128
+ PRAGMA foreign_keys=on;
@@ -2207,8 +2207,13 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2207
2207
  configureHttpResolver(apiUrl, process.env.API_KEY);
2208
2208
  }
2209
2209
 
2210
- // Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false)
2211
- // Workers use HTTP-based config access (cannot import DB directly)
2210
+ // Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false).
2211
+ // Workers use HTTP-based config access (cannot import DB directly).
2212
+ // IMPORTANT: workers must NOT pass `generateIfMissing` — the api-server is
2213
+ // the sole authority for `telemetry_installation_id`. If the API hasn't
2214
+ // persisted one yet (network blip, fresh boot, API down), the worker simply
2215
+ // skips telemetry instead of minting a fresh `install_<hex>` ID per
2216
+ // restart, which floods prod metrics with phantom installs.
2212
2217
  {
2213
2218
  const telemetryApiKey = process.env.API_KEY;
2214
2219
  await initTelemetry(
package/src/http/core.ts CHANGED
@@ -11,32 +11,14 @@ import {
11
11
  updateAgentStatus,
12
12
  } from "../be/db";
13
13
  import { initGitHub, resetGitHub } from "../github";
14
+ import { initJira, resetJira } from "../jira";
14
15
  import { initLinear, resetLinear } from "../linear";
15
16
  import { startSlackApp, stopSlackApp } from "../slack";
16
17
  import type { AgentStatus } from "../types";
17
18
  import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
18
19
  import { generateOpenApiSpec, SCALAR_HTML } from "./openapi";
19
- import { routeRegistry } from "./route-def";
20
- import { agentWithCapacity, getPathSegments, matchRoute, parseQueryParams } from "./utils";
21
-
22
- /**
23
- * Check whether a request targets a route declared (via the `route()` factory)
24
- * with `auth: { apiKey: false }` — i.e. one that opts out of the API-key
25
- * bearer check. Handler files must use the `route()` factory for this to take
26
- * effect; unknown paths fail closed (auth required).
27
- */
28
- function isPublicRoute(method: string | undefined, pathSegments: string[]): boolean {
29
- for (const def of routeRegistry) {
30
- if (def.auth?.apiKey === false) {
31
- if (
32
- matchRoute(method, pathSegments, def.method.toUpperCase(), def.pattern, def.exact ?? true)
33
- ) {
34
- return true;
35
- }
36
- }
37
- }
38
- return false;
39
- }
20
+ import { isPublicRoute } from "./route-def";
21
+ import { agentWithCapacity, getPathSegments, parseQueryParams } from "./utils";
40
22
 
41
23
  /**
42
24
  * Load global swarm_config entries into process.env.
@@ -88,6 +70,9 @@ export async function reloadGlobalConfigsAndIntegrations(): Promise<ReloadConfig
88
70
  resetLinear();
89
71
  if (initLinear()) integrations.push("linear");
90
72
 
73
+ resetJira();
74
+ if (initJira()) integrations.push("jira");
75
+
91
76
  await stopSlackApp();
92
77
  await startSlackApp();
93
78
  integrations.push("slack");
package/src/http/index.ts CHANGED
@@ -12,6 +12,7 @@ import { closeDb, getSwarmConfigs, upsertSwarmConfig } from "../be/db";
12
12
  import { initGitHub } from "../github";
13
13
  import { initGitLab } from "../gitlab";
14
14
  import { stopHeartbeat } from "../heartbeat";
15
+ import { initJira } from "../jira";
15
16
  import { initLinear } from "../linear";
16
17
  import { startSlackApp, stopSlackApp } from "../slack";
17
18
  import { initTelemetry, telemetry } from "../telemetry";
@@ -245,13 +246,17 @@ httpServer
245
246
  );
246
247
  }
247
248
 
248
- // Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false)
249
+ // Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false).
250
+ // The api-server is the sole authority for the install identity — pass
251
+ // generateIfMissing so it mints a new install ID on first boot. Workers
252
+ // must NOT mint (see src/commands/runner.ts).
249
253
  await initTelemetry(
250
254
  "api-server",
251
255
  (key) => getSwarmConfigs({ scope: "global", key })?.[0]?.value,
252
256
  (key, value) => {
253
257
  upsertSwarmConfig({ scope: "global", key, value });
254
258
  },
259
+ { generateIfMissing: true },
255
260
  );
256
261
  telemetry.server("started", { port });
257
262
 
@@ -270,6 +275,9 @@ httpServer
270
275
  // Initialize Linear tracker integration (if configured)
271
276
  initLinear();
272
277
 
278
+ // Initialize Jira tracker integration (if configured)
279
+ initJira();
280
+
273
281
  // Initialize workflow engine (trigger subscriptions + resume listener)
274
282
  initWorkflows();
275
283
 
@@ -60,6 +60,25 @@ interface RouteHandle<TParams, TQuery, TBody> {
60
60
  /** Global registry — populated at import time, read by OpenAPI generator */
61
61
  export const routeRegistry: RouteDef[] = [];
62
62
 
63
+ /**
64
+ * Check whether a request targets a route declared (via the `route()` factory)
65
+ * with `auth: { apiKey: false }` — i.e. one that opts out of the API-key
66
+ * bearer check. Handler files must use the `route()` factory for this to take
67
+ * effect; unknown paths fail closed (auth required).
68
+ */
69
+ export function isPublicRoute(method: string | undefined, pathSegments: string[]): boolean {
70
+ for (const def of routeRegistry) {
71
+ if (def.auth?.apiKey === false) {
72
+ if (
73
+ matchRoute(method, pathSegments, def.method.toUpperCase(), def.pattern, def.exact ?? true)
74
+ ) {
75
+ return true;
76
+ }
77
+ }
78
+ }
79
+ return false;
80
+ }
81
+
63
82
  // ─── Factory ─────────────────────────────────────────────────────────────────
64
83
 
65
84
  export function route<
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { handleJiraTracker } from "./jira";
2
3
  import { handleLinearTracker } from "./linear";
3
4
 
4
5
  export async function handleTrackers(
@@ -6,5 +7,17 @@ export async function handleTrackers(
6
7
  res: ServerResponse,
7
8
  pathSegments: string[],
8
9
  ): Promise<boolean> {
10
+ // Provider-specific dispatch based on the third path segment
11
+ // (e.g. "api", "trackers", "<provider>", ...).
12
+ if (pathSegments[0] === "api" && pathSegments[1] === "trackers") {
13
+ if (pathSegments[2] === "jira") {
14
+ return await handleJiraTracker(req, res, pathSegments);
15
+ }
16
+ if (pathSegments[2] === "linear") {
17
+ return await handleLinearTracker(req, res, pathSegments);
18
+ }
19
+ }
20
+ // Fallback: try Linear (preserves existing behavior for any path that
21
+ // somehow falls through without an explicit provider segment).
9
22
  return await handleLinearTracker(req, res, pathSegments);
10
23
  }