@desplega.ai/agent-swarm 1.74.0 → 1.74.2

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 (43) hide show
  1. package/openapi.json +199 -1
  2. package/package.json +1 -1
  3. package/src/be/db.ts +278 -0
  4. package/src/be/migrations/049_wait_states.sql +30 -0
  5. package/src/be/migrations/050_wait_states_scope.sql +19 -0
  6. package/src/http/index.ts +2 -0
  7. package/src/http/trackers/jira.ts +84 -27
  8. package/src/http/trackers/linear.ts +67 -11
  9. package/src/http/utils.ts +15 -0
  10. package/src/http/workflow-events.ts +107 -0
  11. package/src/http/workflows.ts +55 -6
  12. package/src/jira/sync.ts +20 -7
  13. package/src/linear/gate.ts +122 -0
  14. package/src/linear/sync.ts +128 -0
  15. package/src/oauth/keepalive.ts +34 -13
  16. package/src/tests/ensure-token.test.ts +33 -0
  17. package/src/tests/linear-webhook.test.ts +383 -0
  18. package/src/tests/workflow-executors.test.ts +4 -2
  19. package/src/tests/workflow-mcp-trigger-schema.test.ts +617 -0
  20. package/src/tests/workflow-patch.test.ts +14 -14
  21. package/src/tests/workflow-wait-builtin-events.test.ts +279 -0
  22. package/src/tests/workflow-wait-event.test.ts +384 -0
  23. package/src/tests/workflow-wait-filter.test.ts +200 -0
  24. package/src/tests/workflow-wait-http.test.ts +177 -0
  25. package/src/tests/workflow-wait-recovery.test.ts +178 -0
  26. package/src/tests/workflow-wait-state-queries.test.ts +419 -0
  27. package/src/tests/workflow-wait-time.test.ts +255 -0
  28. package/src/tools/tracker/tracker-status.ts +7 -1
  29. package/src/tools/workflows/create-workflow.ts +16 -2
  30. package/src/tools/workflows/patch-workflow.ts +26 -6
  31. package/src/tools/workflows/trigger-workflow.ts +26 -1
  32. package/src/tools/workflows/update-workflow.ts +28 -2
  33. package/src/types.ts +48 -3
  34. package/src/workflows/definition.ts +2 -5
  35. package/src/workflows/executors/index.ts +1 -0
  36. package/src/workflows/executors/registry.ts +2 -0
  37. package/src/workflows/executors/wait.ts +170 -0
  38. package/src/workflows/index.ts +18 -2
  39. package/src/workflows/json-schema-validator.ts +8 -1
  40. package/src/workflows/recovery.ts +55 -1
  41. package/src/workflows/resume.ts +272 -0
  42. package/src/workflows/wait-filter.ts +311 -0
  43. package/src/workflows/wait-poller.ts +63 -0
@@ -6,6 +6,7 @@ import { clearJiraMetadata, getJiraMetadata } from "../../jira/metadata";
6
6
  import { getJiraAuthorizationUrl, handleJiraCallback } from "../../jira/oauth";
7
7
  import { handleJiraWebhook } from "../../jira/webhook";
8
8
  import { deleteJiraWebhook, registerJiraWebhook } from "../../jira/webhook-lifecycle";
9
+ import { ensureTokenOrThrow } from "../../oauth/ensure-token";
9
10
  import { route } from "../route-def";
10
11
  import { deriveApiBaseUrl, parseQueryParams } from "../utils";
11
12
 
@@ -59,6 +60,21 @@ const jiraStatus = route({
59
60
  },
60
61
  });
61
62
 
63
+ const jiraRefresh = route({
64
+ method: "post",
65
+ path: "/api/trackers/jira/refresh",
66
+ pattern: ["api", "trackers", "jira", "refresh"],
67
+ summary:
68
+ "Force a Jira OAuth token refresh and return the updated status payload. Useful when an agent observes an expired token via tracker-status / db-query and wants to recover without restarting the server or re-running 3LO.",
69
+ tags: ["Trackers"],
70
+ responses: {
71
+ 200: { description: "Token refreshed; returns same shape as /status" },
72
+ 409: { description: "Jira not connected (no refresh token stored)" },
73
+ 500: { description: "Refresh failed (e.g. revoked grant, network error)" },
74
+ 503: { description: "Jira integration not configured" },
75
+ },
76
+ });
77
+
62
78
  const jiraWebhook = route({
63
79
  method: "post",
64
80
  path: "/api/trackers/jira/webhook/{token}",
@@ -144,6 +160,38 @@ function getRedirectUri(req: IncomingMessage): string {
144
160
  return `${deriveApiBaseUrl(req)}/api/trackers/jira/callback`;
145
161
  }
146
162
 
163
+ function buildJiraStatusPayload(req: IncomingMessage): Record<string, unknown> {
164
+ const tokens = getOAuthTokens("jira");
165
+ const meta = getJiraMetadata();
166
+ const scope = tokens?.scope ?? null;
167
+ // Atlassian returns scopes space-separated in the token response.
168
+ const scopeList = scope ? scope.split(/[\s,]+/).filter(Boolean) : [];
169
+ const hasManageWebhookScope = scopeList.includes("manage:jira-webhook");
170
+
171
+ const status: Record<string, unknown> = {
172
+ provider: "jira",
173
+ connected: !!tokens,
174
+ cloudId: meta.cloudId ?? null,
175
+ siteUrl: meta.siteUrl ?? null,
176
+ tokenExpiresAt: tokens?.expiresAt ?? null,
177
+ scope,
178
+ hasManageWebhookScope,
179
+ webhookTokenConfigured: Boolean(process.env.JIRA_WEBHOOK_TOKEN),
180
+ webhookUrl: getWebhookUrl(req),
181
+ redirectUri: getRedirectUri(req),
182
+ webhookIds: meta.webhookIds ?? [],
183
+ };
184
+
185
+ // Phase 5: surface manual-webhook instructions when the OAuth grant
186
+ // doesn't include `manage:jira-webhook` (admin must register webhooks
187
+ // manually in the Atlassian UI).
188
+ if (!hasManageWebhookScope) {
189
+ status.manualWebhookInstructions = MANUAL_WEBHOOK_INSTRUCTIONS;
190
+ }
191
+
192
+ return status;
193
+ }
194
+
147
195
  // ─── Handler ─────────────────────────────────────────────────────────────────
148
196
 
149
197
  export async function handleJiraTracker(
@@ -222,37 +270,46 @@ export async function handleJiraTracker(
222
270
  return true;
223
271
  }
224
272
 
273
+ res.writeHead(200, { "Content-Type": "application/json" });
274
+ res.end(JSON.stringify(buildJiraStatusPayload(req)));
275
+ return true;
276
+ }
277
+
278
+ // POST /api/trackers/jira/refresh — force-refresh the access token and
279
+ // return the same shape as /status. Useful when an agent observes a stale
280
+ // token (via tracker-status / db-query MCP) and needs to recover in-band.
281
+ if (jiraRefresh.match(req.method, pathSegments)) {
282
+ if (!isJiraEnabled()) {
283
+ res.writeHead(503, { "Content-Type": "application/json" });
284
+ res.end(JSON.stringify({ error: "Jira integration not configured" }));
285
+ return true;
286
+ }
287
+
225
288
  const tokens = getOAuthTokens("jira");
226
- const meta = getJiraMetadata();
227
- const scope = tokens?.scope ?? null;
228
- // Atlassian returns scopes space-separated in the token response.
229
- const scopeList = scope ? scope.split(/[\s,]+/).filter(Boolean) : [];
230
-
231
- const hasManageWebhookScope = scopeList.includes("manage:jira-webhook");
232
-
233
- const status: Record<string, unknown> = {
234
- provider: "jira",
235
- connected: !!tokens,
236
- cloudId: meta.cloudId ?? null,
237
- siteUrl: meta.siteUrl ?? null,
238
- tokenExpiresAt: tokens?.expiresAt ?? null,
239
- scope,
240
- hasManageWebhookScope,
241
- webhookTokenConfigured: Boolean(process.env.JIRA_WEBHOOK_TOKEN),
242
- webhookUrl: getWebhookUrl(req),
243
- redirectUri: getRedirectUri(req),
244
- webhookIds: meta.webhookIds ?? [],
245
- };
246
-
247
- // Phase 5: surface manual-webhook instructions when the OAuth grant
248
- // doesn't include `manage:jira-webhook` (admin must register webhooks
249
- // manually in the Atlassian UI).
250
- if (!hasManageWebhookScope) {
251
- status.manualWebhookInstructions = MANUAL_WEBHOOK_INSTRUCTIONS;
289
+ if (!tokens?.refreshToken) {
290
+ res.writeHead(409, { "Content-Type": "application/json" });
291
+ res.end(
292
+ JSON.stringify({
293
+ error: "Jira not connected — no refresh token stored. Run 3LO via /authorize.",
294
+ }),
295
+ );
296
+ return true;
297
+ }
298
+
299
+ try {
300
+ // Pass a buffer larger than any plausible token lifetime so
301
+ // isTokenExpiringSoon() always returns true and a refresh always fires.
302
+ await ensureTokenOrThrow("jira", Number.MAX_SAFE_INTEGER);
303
+ } catch (err) {
304
+ const message = err instanceof Error ? err.message : String(err);
305
+ console.error("[Jira] Forced token refresh failed:", message);
306
+ res.writeHead(500, { "Content-Type": "application/json" });
307
+ res.end(JSON.stringify({ error: "Token refresh failed", details: message }));
308
+ return true;
252
309
  }
253
310
 
254
311
  res.writeHead(200, { "Content-Type": "application/json" });
255
- res.end(JSON.stringify(status));
312
+ res.end(JSON.stringify(buildJiraStatusPayload(req)));
256
313
  return true;
257
314
  }
258
315
 
@@ -8,6 +8,7 @@ import {
8
8
  revokeLinearToken,
9
9
  } from "../../linear/oauth";
10
10
  import { handleLinearWebhook } from "../../linear/webhook";
11
+ import { ensureTokenOrThrow } from "../../oauth/ensure-token";
11
12
  import { route } from "../route-def";
12
13
  import { deriveApiBaseUrl, parseQueryParams } from "../utils";
13
14
 
@@ -57,6 +58,21 @@ const linearStatus = route({
57
58
  },
58
59
  });
59
60
 
61
+ const linearRefresh = route({
62
+ method: "post",
63
+ path: "/api/trackers/linear/refresh",
64
+ pattern: ["api", "trackers", "linear", "refresh"],
65
+ summary:
66
+ "Force a Linear OAuth token refresh and return the updated status payload. Useful when an agent observes an expired token and wants to recover without restarting the server or re-running OAuth.",
67
+ tags: ["Trackers"],
68
+ responses: {
69
+ 200: { description: "Token refreshed; returns same shape as /status" },
70
+ 409: { description: "Linear not connected (no refresh token stored)" },
71
+ 500: { description: "Refresh failed" },
72
+ 503: { description: "Linear integration not configured" },
73
+ },
74
+ });
75
+
60
76
  const linearWebhook = route({
61
77
  method: "post",
62
78
  path: "/api/trackers/linear/webhook",
@@ -86,6 +102,22 @@ const linearDisconnect = route({
86
102
  },
87
103
  });
88
104
 
105
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
106
+
107
+ function buildLinearStatusPayload(req: IncomingMessage): Record<string, unknown> {
108
+ const tokens = getOAuthTokens("linear");
109
+ const baseUrl = deriveApiBaseUrl(req);
110
+
111
+ return {
112
+ provider: "linear",
113
+ connected: !!tokens,
114
+ tokenExpiry: tokens?.expiresAt ?? null,
115
+ scope: tokens?.scope ?? null,
116
+ webhookUrl: `${baseUrl}/api/trackers/linear/webhook`,
117
+ redirectUri: `${baseUrl}/api/trackers/linear/callback`,
118
+ };
119
+ }
120
+
89
121
  // ─── Handler ─────────────────────────────────────────────────────────────────
90
122
 
91
123
  export async function handleLinearTracker(
@@ -164,20 +196,44 @@ export async function handleLinearTracker(
164
196
  return true;
165
197
  }
166
198
 
199
+ res.writeHead(200, { "Content-Type": "application/json" });
200
+ res.end(JSON.stringify(buildLinearStatusPayload(req)));
201
+ return true;
202
+ }
203
+
204
+ // POST /api/trackers/linear/refresh — force-refresh the Linear access token.
205
+ // Mirrors the Jira refresh route; recovery path for agents that observe a
206
+ // stale token in oauth_tokens.
207
+ if (linearRefresh.match(req.method, pathSegments)) {
208
+ if (!isLinearEnabled()) {
209
+ res.writeHead(503, { "Content-Type": "application/json" });
210
+ res.end(JSON.stringify({ error: "Linear integration not configured" }));
211
+ return true;
212
+ }
213
+
167
214
  const tokens = getOAuthTokens("linear");
168
- const baseUrl = deriveApiBaseUrl(req);
169
-
170
- const status = {
171
- provider: "linear",
172
- connected: !!tokens,
173
- tokenExpiry: tokens?.expiresAt ?? null,
174
- scope: tokens?.scope ?? null,
175
- webhookUrl: `${baseUrl}/api/trackers/linear/webhook`,
176
- redirectUri: `${baseUrl}/api/trackers/linear/callback`,
177
- };
215
+ if (!tokens?.refreshToken) {
216
+ res.writeHead(409, { "Content-Type": "application/json" });
217
+ res.end(
218
+ JSON.stringify({
219
+ error: "Linear not connected — no refresh token stored. Run OAuth via /authorize.",
220
+ }),
221
+ );
222
+ return true;
223
+ }
224
+
225
+ try {
226
+ await ensureTokenOrThrow("linear", Number.MAX_SAFE_INTEGER);
227
+ } catch (err) {
228
+ const message = err instanceof Error ? err.message : String(err);
229
+ console.error("[Linear] Forced token refresh failed:", message);
230
+ res.writeHead(500, { "Content-Type": "application/json" });
231
+ res.end(JSON.stringify({ error: "Token refresh failed", details: message }));
232
+ return true;
233
+ }
178
234
 
179
235
  res.writeHead(200, { "Content-Type": "application/json" });
180
- res.end(JSON.stringify(status));
236
+ res.end(JSON.stringify(buildLinearStatusPayload(req)));
181
237
  return true;
182
238
  }
183
239
 
package/src/http/utils.ts CHANGED
@@ -57,6 +57,21 @@ export function jsonError(res: ServerResponse, error: string, status = 400) {
57
57
  res.end(JSON.stringify({ error }));
58
58
  }
59
59
 
60
+ /**
61
+ * Send a 400 response for a workflow `triggerSchema` validation failure.
62
+ * Frozen wire shape: `{ error: "TriggerSchemaError", message, details: string[] }`.
63
+ * `details` carries the per-field validator output so callers can render
64
+ * field-level diagnostics (FE tester, MCP, etc.).
65
+ */
66
+ export function triggerSchemaErrorResponse(
67
+ res: ServerResponse,
68
+ message: string,
69
+ details: string[],
70
+ ) {
71
+ res.writeHead(400, { "Content-Type": "application/json" });
72
+ res.end(JSON.stringify({ error: "TriggerSchemaError", message, details }));
73
+ }
74
+
60
75
  /**
61
76
  * Derive the API base URL for outbound-facing values (webhook URLs, OAuth
62
77
  * redirect URIs). Returns a URL with no trailing slash.
@@ -0,0 +1,107 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { getWorkflowRun } from "../be/db";
4
+ import { workflowEventBus } from "../workflows/event-bus";
5
+ import { route } from "./route-def";
6
+ import { json, jsonError } from "./utils";
7
+
8
+ // ─── Route Definitions ───────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Run-scoped event signal: emits `name` on `workflowEventBus` with the payload
12
+ * augmented by `_runId` (so wait-states with `scope: 'run'` can correlate).
13
+ *
14
+ * Existing built-in bus events from `src/be/db.ts` carry `workflowRunId`; the
15
+ * matcher in `src/workflows/resume.ts` accepts either field name.
16
+ */
17
+ const runScopedSignalRoute = route({
18
+ method: "post",
19
+ path: "/api/workflow-runs/{runId}/events",
20
+ pattern: ["api", "workflow-runs", null, "events"],
21
+ summary: "Fire a run-scoped event signal",
22
+ description:
23
+ "Emits an event onto the workflow event bus with `_runId` injected. " +
24
+ "Used by wait nodes in `event` mode with `scope: 'run'`. The body's `name` " +
25
+ "is the bus event name; `payload` is forwarded as-is plus `_runId`.",
26
+ tags: ["WorkflowEvents"],
27
+ params: z.object({ runId: z.string().uuid() }),
28
+ body: z.object({
29
+ name: z.string().min(1),
30
+ payload: z.record(z.string(), z.unknown()).optional(),
31
+ }),
32
+ responses: {
33
+ 200: { description: "Event emitted" },
34
+ 404: { description: "Workflow run not found" },
35
+ 400: { description: "Validation error" },
36
+ },
37
+ auth: { apiKey: true },
38
+ });
39
+
40
+ /**
41
+ * Global broadcast: emits `name` on `workflowEventBus` with the raw payload.
42
+ * Wait-states with `scope: 'global'` may resolve from these signals.
43
+ */
44
+ const globalSignalRoute = route({
45
+ method: "post",
46
+ path: "/api/workflow-events",
47
+ pattern: ["api", "workflow-events"],
48
+ summary: "Fire a global workflow event signal",
49
+ description:
50
+ "Emits an event onto the workflow event bus. Wait-states with " +
51
+ "`scope: 'global'` may match. Run-scoped waits will NOT match this " +
52
+ "broadcast unless the payload carries a matching `workflowRunId`.",
53
+ tags: ["WorkflowEvents"],
54
+ body: z.object({
55
+ name: z.string().min(1),
56
+ payload: z.record(z.string(), z.unknown()).optional(),
57
+ }),
58
+ responses: {
59
+ 200: { description: "Event emitted" },
60
+ 400: { description: "Validation error" },
61
+ },
62
+ auth: { apiKey: true },
63
+ });
64
+
65
+ // ─── Handler ─────────────────────────────────────────────────────────────────
66
+
67
+ export async function handleWorkflowEvents(
68
+ req: IncomingMessage,
69
+ res: ServerResponse,
70
+ pathSegments: string[],
71
+ queryParams: URLSearchParams,
72
+ ): Promise<boolean> {
73
+ // POST /api/workflow-runs/{runId}/events
74
+ if (runScopedSignalRoute.match(req.method, pathSegments)) {
75
+ const parsed = await runScopedSignalRoute.parse(req, res, pathSegments, queryParams);
76
+ if (!parsed) return true;
77
+
78
+ const run = getWorkflowRun(parsed.params.runId);
79
+ if (!run) {
80
+ jsonError(res, "Workflow run not found", 404);
81
+ return true;
82
+ }
83
+
84
+ const payload = { ...(parsed.body.payload ?? {}), _runId: parsed.params.runId };
85
+ workflowEventBus.emit(parsed.body.name, payload);
86
+
87
+ json(res, {
88
+ ok: true,
89
+ name: parsed.body.name,
90
+ runId: parsed.params.runId,
91
+ });
92
+ return true;
93
+ }
94
+
95
+ // POST /api/workflow-events
96
+ if (globalSignalRoute.match(req.method, pathSegments)) {
97
+ const parsed = await globalSignalRoute.parse(req, res, pathSegments, queryParams);
98
+ if (!parsed) return true;
99
+
100
+ workflowEventBus.emit(parsed.body.name, parsed.body.payload ?? {});
101
+
102
+ json(res, { ok: true, name: parsed.body.name });
103
+ return true;
104
+ }
105
+
106
+ return false;
107
+ }
@@ -16,19 +16,20 @@ import {
16
16
  CooldownConfigSchema,
17
17
  InputValueSchema,
18
18
  TriggerConfigSchema,
19
- WorkflowDefinitionPatchSchema,
20
19
  WorkflowDefinitionSchema,
21
20
  WorkflowNodePatchSchema,
21
+ WorkflowPatchSchema,
22
22
  WorkflowRunStatusSchema,
23
23
  } from "../types";
24
24
  import { getExecutorRegistry, startWorkflowExecution } from "../workflows";
25
25
  import { applyDefinitionPatch, generateEdges, validateDefinition } from "../workflows/definition";
26
26
  import { TriggerSchemaError } from "../workflows/engine";
27
+ import { validateJsonSchema } from "../workflows/json-schema-validator";
27
28
  import { cancelWorkflowRun, retryFailedRun } from "../workflows/resume";
28
29
  import { handleWebhookTrigger, WebhookError } from "../workflows/triggers";
29
30
  import { snapshotWorkflow } from "../workflows/version";
30
31
  import { route } from "./route-def";
31
- import { json, jsonError, parseBody } from "./utils";
32
+ import { json, jsonError, parseBody, triggerSchemaErrorResponse } from "./utils";
32
33
 
33
34
  // ─── Route Definitions ───────────────────────────────────────────────────────
34
35
 
@@ -112,7 +113,7 @@ const patchWorkflowRoute = route({
112
113
  summary: "Patch a workflow definition (create/update/delete nodes)",
113
114
  tags: ["Workflows"],
114
115
  params: z.object({ id: z.string() }),
115
- body: WorkflowDefinitionPatchSchema,
116
+ body: WorkflowPatchSchema,
116
117
  responses: {
117
118
  200: { description: "Workflow patched (version snapshot created)" },
118
119
  400: { description: "Invalid patch or resulting definition" },
@@ -163,6 +164,20 @@ const triggerWorkflowRoute = route({
163
164
  },
164
165
  });
165
166
 
167
+ const validateTriggerRoute = route({
168
+ method: "post",
169
+ path: "/api/workflows/{id}/trigger/validate",
170
+ pattern: ["api", "workflows", null, "trigger", "validate"],
171
+ summary: "Validate a payload against the workflow's triggerSchema (no run)",
172
+ tags: ["Workflows"],
173
+ params: z.object({ id: z.string() }),
174
+ responses: {
175
+ 200: { description: "Payload matches the workflow's triggerSchema (or workflow has none)" },
176
+ 400: { description: "Payload failed validation; body matches the TriggerSchemaError contract" },
177
+ 404: { description: "Workflow not found" },
178
+ },
179
+ });
180
+
166
181
  const listWorkflowRunsRoute = route({
167
182
  method: "get",
168
183
  path: "/api/workflows/{id}/runs",
@@ -349,7 +364,7 @@ export async function handleWorkflows(
349
364
  json(res, result, 201);
350
365
  } catch (err) {
351
366
  if (err instanceof TriggerSchemaError) {
352
- jsonError(res, err.message, 400);
367
+ triggerSchemaErrorResponse(res, err.message, err.validationErrors);
353
368
  } else if (err instanceof WebhookError) {
354
369
  jsonError(res, err.message, err.statusCode);
355
370
  } else {
@@ -510,7 +525,13 @@ export async function handleWorkflows(
510
525
  // Snapshot failure should not block the update
511
526
  }
512
527
 
513
- const workflow = updateWorkflow(id, { definition: patchResult.definition });
528
+ const updateArgs: Parameters<typeof updateWorkflow>[1] = {
529
+ definition: patchResult.definition,
530
+ };
531
+ if (parsed.body.triggerSchema !== undefined) {
532
+ updateArgs.triggerSchema = parsed.body.triggerSchema;
533
+ }
534
+ const workflow = updateWorkflow(id, updateArgs);
514
535
  if (!workflow) {
515
536
  res.writeHead(404);
516
537
  res.end();
@@ -585,6 +606,34 @@ export async function handleWorkflows(
585
606
  return true;
586
607
  }
587
608
 
609
+ if (validateTriggerRoute.match(req.method, pathSegments)) {
610
+ const parsed = await validateTriggerRoute.parse(req, res, pathSegments, queryParams);
611
+ if (!parsed) return true;
612
+ const workflow = getWorkflow(parsed.params.id);
613
+ if (!workflow) {
614
+ res.writeHead(404);
615
+ res.end();
616
+ return true;
617
+ }
618
+ const body = await parseBody<Record<string, unknown>>(req);
619
+ const triggerData = (body?.triggerData ?? body) as unknown;
620
+ if (!workflow.triggerSchema) {
621
+ json(res, { valid: true, schema: null });
622
+ return true;
623
+ }
624
+ const errors = validateJsonSchema(workflow.triggerSchema, triggerData);
625
+ if (errors.length > 0) {
626
+ triggerSchemaErrorResponse(
627
+ res,
628
+ `Trigger schema validation failed: ${errors.join("; ")}`,
629
+ errors,
630
+ );
631
+ return true;
632
+ }
633
+ json(res, { valid: true });
634
+ return true;
635
+ }
636
+
588
637
  if (triggerWorkflowRoute.match(req.method, pathSegments)) {
589
638
  const parsed = await triggerWorkflowRoute.parse(req, res, pathSegments, queryParams);
590
639
  if (!parsed) return true;
@@ -605,7 +654,7 @@ export async function handleWorkflows(
605
654
  runId = await startWorkflowExecution(workflow, body, getExecutorRegistry());
606
655
  } catch (err) {
607
656
  if (err instanceof TriggerSchemaError) {
608
- jsonError(res, err.message, 400);
657
+ triggerSchemaErrorResponse(res, err.message, err.validationErrors);
609
658
  return true;
610
659
  }
611
660
  throw err;
package/src/jira/sync.ts CHANGED
@@ -75,17 +75,30 @@ export async function resolveBotAccountId(): Promise<string | null> {
75
75
 
76
76
  try {
77
77
  await ensureToken("jira");
78
- const tokens = getOAuthTokens("jira");
78
+ let tokens = getOAuthTokens("jira");
79
79
  if (!tokens?.accessToken) {
80
80
  console.warn("[Jira Sync] No Jira access token; cannot resolve bot accountId");
81
81
  return null;
82
82
  }
83
- const res = await fetch("https://api.atlassian.com/me", {
84
- headers: {
85
- Authorization: `Bearer ${tokens.accessToken}`,
86
- Accept: "application/json",
87
- },
88
- });
83
+ const callMe = async (accessToken: string) =>
84
+ fetch("https://api.atlassian.com/me", {
85
+ headers: {
86
+ Authorization: `Bearer ${accessToken}`,
87
+ Accept: "application/json",
88
+ },
89
+ });
90
+ let res = await callMe(tokens.accessToken);
91
+ // Mirror jiraFetch's 401-retry pattern: a token may go stale between the
92
+ // proactive ensureToken call and the request reaching Atlassian.
93
+ if (res.status === 401) {
94
+ await ensureToken("jira", 0);
95
+ tokens = getOAuthTokens("jira");
96
+ if (!tokens?.accessToken) {
97
+ console.warn("[Jira Sync] /me returned 401 and refresh produced no token");
98
+ return null;
99
+ }
100
+ res = await callMe(tokens.accessToken);
101
+ }
89
102
  if (!res.ok) {
90
103
  console.warn(`[Jira Sync] /me returned ${res.status}; cannot resolve bot accountId`);
91
104
  return null;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Linear assignment-time gating: decides whether an incoming AgentSessionEvent
3
+ * should trigger swarm task creation based on the issue's workflow state and
4
+ * labels.
5
+ *
6
+ * Rules:
7
+ * - The set of allowed `WorkflowState.type` values is configurable via
8
+ * `LINEAR_ALLOWED_STATES` (CSV). Default: `unstarted,started,completed,canceled`,
9
+ * which matches Linear's enum minus `triage` and `backlog` ("tracked but not
10
+ * ready").
11
+ * - The override label name is configurable via `LINEAR_SWARM_READY_LABEL`.
12
+ * Default: `swarm-ready`. Matching is case-insensitive.
13
+ * - If the state cannot be resolved (null), default to allow (fail-open) so
14
+ * assignments aren't silently swallowed by missing data.
15
+ */
16
+
17
+ export const DEFAULT_SWARM_READY_LABEL = "swarm-ready";
18
+ export const DEFAULT_ALLOWED_STATE_TYPES = [
19
+ "unstarted",
20
+ "started",
21
+ "completed",
22
+ "canceled",
23
+ ] as const;
24
+
25
+ /** Backwards-compat alias kept so tests can reference the default override label. */
26
+ export const SWARM_READY_LABEL = DEFAULT_SWARM_READY_LABEL;
27
+
28
+ export interface LinearGateConfig {
29
+ /** Lowercased `WorkflowState.type` values that should trigger task creation. */
30
+ allowedStateTypes: Set<string>;
31
+ /** Lowercased label name that bypasses the state gate. */
32
+ swarmReadyLabel: string;
33
+ }
34
+
35
+ export interface LinearGateInput {
36
+ /** Linear `WorkflowState.type` value. Null if unknown. */
37
+ stateType: string | null;
38
+ /** Names of labels attached to the issue. Case-insensitive matching. */
39
+ labelNames: string[];
40
+ }
41
+
42
+ export type LinearGateDecision =
43
+ | { create: true; reason: "ready" | "label-override" }
44
+ | { create: false; reason: string };
45
+
46
+ /**
47
+ * Resolve the gate config from environment variables, with sensible defaults.
48
+ * Read on each call so tests / runtime overrides are picked up without restart.
49
+ */
50
+ export function getLinearGateConfig(): LinearGateConfig {
51
+ const statesEnv = process.env.LINEAR_ALLOWED_STATES;
52
+ const allowedStateTypes = parseStateList(statesEnv);
53
+
54
+ const labelEnv = process.env.LINEAR_SWARM_READY_LABEL?.trim();
55
+ const swarmReadyLabel = (
56
+ labelEnv && labelEnv.length > 0 ? labelEnv : DEFAULT_SWARM_READY_LABEL
57
+ ).toLowerCase();
58
+
59
+ return { allowedStateTypes, swarmReadyLabel };
60
+ }
61
+
62
+ function parseStateList(csv: string | undefined): Set<string> {
63
+ if (csv === undefined) {
64
+ return new Set(DEFAULT_ALLOWED_STATE_TYPES);
65
+ }
66
+ // An explicit empty value locks down all states (only label override works).
67
+ return new Set(
68
+ csv
69
+ .split(",")
70
+ .map((s) => s.trim().toLowerCase())
71
+ .filter((s) => s.length > 0),
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Pure decision function: should this Linear assignment create a swarm task?
77
+ *
78
+ * Exported separately from the side-effecting webhook handler so it can be
79
+ * unit-tested without spinning up the DB or Linear API. Config is injectable
80
+ * for tests; production callers can rely on the env-driven default.
81
+ */
82
+ export function shouldCreateTaskFromLinearEvent(
83
+ input: LinearGateInput,
84
+ config: LinearGateConfig = getLinearGateConfig(),
85
+ ): LinearGateDecision {
86
+ const labelMatch = config.swarmReadyLabel;
87
+ const hasReadyLabel = input.labelNames.some((name) => name.trim().toLowerCase() === labelMatch);
88
+ if (hasReadyLabel) {
89
+ return { create: true, reason: "label-override" };
90
+ }
91
+
92
+ const stateType = input.stateType?.toLowerCase() ?? null;
93
+ // Fail-open: if we couldn't resolve the state, default to today's behavior
94
+ // rather than silently swallowing assignments.
95
+ if (stateType === null) {
96
+ return { create: true, reason: "ready" };
97
+ }
98
+ if (!config.allowedStateTypes.has(stateType)) {
99
+ return { create: false, reason: stateType };
100
+ }
101
+ return { create: true, reason: "ready" };
102
+ }
103
+
104
+ /**
105
+ * Build the user-facing message posted on a skipped Linear assignment.
106
+ */
107
+ export function buildSkipMessage(
108
+ reason: string,
109
+ swarmReadyLabel: string = getLinearGateConfig().swarmReadyLabel,
110
+ ): string {
111
+ const stateLabel = titleCase(reason);
112
+ return [
113
+ `Agent Swarm received the assignment but skipped — this issue is in ${stateLabel}.`,
114
+ "",
115
+ `To trigger work, move it to an allowed workflow state (e.g. **Todo** or **In Progress**), or add the \`${swarmReadyLabel}\` label and re-assign the agent.`,
116
+ ].join("\n");
117
+ }
118
+
119
+ function titleCase(s: string): string {
120
+ if (s.length === 0) return s;
121
+ return (s[0] ?? "").toUpperCase() + s.slice(1);
122
+ }