@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.
- package/openapi.json +199 -1
- package/package.json +1 -1
- package/src/be/db.ts +278 -0
- package/src/be/migrations/049_wait_states.sql +30 -0
- package/src/be/migrations/050_wait_states_scope.sql +19 -0
- package/src/http/index.ts +2 -0
- package/src/http/trackers/jira.ts +84 -27
- package/src/http/trackers/linear.ts +67 -11
- package/src/http/utils.ts +15 -0
- package/src/http/workflow-events.ts +107 -0
- package/src/http/workflows.ts +55 -6
- package/src/jira/sync.ts +20 -7
- package/src/linear/gate.ts +122 -0
- package/src/linear/sync.ts +128 -0
- package/src/oauth/keepalive.ts +34 -13
- package/src/tests/ensure-token.test.ts +33 -0
- package/src/tests/linear-webhook.test.ts +383 -0
- package/src/tests/workflow-executors.test.ts +4 -2
- package/src/tests/workflow-mcp-trigger-schema.test.ts +617 -0
- package/src/tests/workflow-patch.test.ts +14 -14
- package/src/tests/workflow-wait-builtin-events.test.ts +279 -0
- package/src/tests/workflow-wait-event.test.ts +384 -0
- package/src/tests/workflow-wait-filter.test.ts +200 -0
- package/src/tests/workflow-wait-http.test.ts +177 -0
- package/src/tests/workflow-wait-recovery.test.ts +178 -0
- package/src/tests/workflow-wait-state-queries.test.ts +419 -0
- package/src/tests/workflow-wait-time.test.ts +255 -0
- package/src/tools/tracker/tracker-status.ts +7 -1
- package/src/tools/workflows/create-workflow.ts +16 -2
- package/src/tools/workflows/patch-workflow.ts +26 -6
- package/src/tools/workflows/trigger-workflow.ts +26 -1
- package/src/tools/workflows/update-workflow.ts +28 -2
- package/src/types.ts +48 -3
- package/src/workflows/definition.ts +2 -5
- package/src/workflows/executors/index.ts +1 -0
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/wait.ts +170 -0
- package/src/workflows/index.ts +18 -2
- package/src/workflows/json-schema-validator.ts +8 -1
- package/src/workflows/recovery.ts +55 -1
- package/src/workflows/resume.ts +272 -0
- package/src/workflows/wait-filter.ts +311 -0
- 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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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(
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/http/workflows.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
}
|