@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
package/src/linear/sync.ts
CHANGED
|
@@ -10,6 +10,12 @@ import { ensureToken } from "../oauth/ensure-token";
|
|
|
10
10
|
import { resolveTemplate } from "../prompts/resolver";
|
|
11
11
|
import { linearContextKey } from "../tasks/context-key";
|
|
12
12
|
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
13
|
+
import {
|
|
14
|
+
buildSkipMessage,
|
|
15
|
+
getLinearGateConfig,
|
|
16
|
+
type LinearGateInput,
|
|
17
|
+
shouldCreateTaskFromLinearEvent,
|
|
18
|
+
} from "./gate";
|
|
13
19
|
// Side-effect import: registers all Linear event templates in the in-memory registry
|
|
14
20
|
import "./templates";
|
|
15
21
|
|
|
@@ -228,6 +234,104 @@ export function mapLinearStatusToSwarm(linearStateName: string): string | null {
|
|
|
228
234
|
return LINEAR_STATUS_MAP[linearStateName] ?? null;
|
|
229
235
|
}
|
|
230
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Try to read state.type and label names directly from the webhook payload.
|
|
239
|
+
*
|
|
240
|
+
* Returns null if neither field is present so the caller can fetch via
|
|
241
|
+
* GraphQL. As of the Linear AgentSessionEvent webhook schema (April 2026),
|
|
242
|
+
* `agentSession.issue` only includes `id`, `identifier`, `title`, `url`,
|
|
243
|
+
* `description`, `team`, and `teamId` — but we still try inline extraction
|
|
244
|
+
* to keep tests hermetic and to be forward-compatible if Linear ever extends
|
|
245
|
+
* the payload.
|
|
246
|
+
*/
|
|
247
|
+
function extractInlineGateInput(issue: Record<string, unknown>): LinearGateInput | null {
|
|
248
|
+
const stateRaw = issue.state as Record<string, unknown> | undefined;
|
|
249
|
+
const stateType =
|
|
250
|
+
stateRaw && typeof stateRaw.type === "string" ? (stateRaw.type as string) : null;
|
|
251
|
+
|
|
252
|
+
const labelsRaw = issue.labels;
|
|
253
|
+
let labelNames: string[] | null = null;
|
|
254
|
+
if (Array.isArray(labelsRaw)) {
|
|
255
|
+
labelNames = labelsRaw
|
|
256
|
+
.map((l) =>
|
|
257
|
+
l && typeof l === "object" ? String((l as Record<string, unknown>).name ?? "") : "",
|
|
258
|
+
)
|
|
259
|
+
.filter((n) => n.length > 0);
|
|
260
|
+
} else if (
|
|
261
|
+
labelsRaw &&
|
|
262
|
+
typeof labelsRaw === "object" &&
|
|
263
|
+
Array.isArray((labelsRaw as { nodes?: unknown }).nodes)
|
|
264
|
+
) {
|
|
265
|
+
labelNames = ((labelsRaw as { nodes: unknown[] }).nodes ?? [])
|
|
266
|
+
.map((l) =>
|
|
267
|
+
l && typeof l === "object" ? String((l as Record<string, unknown>).name ?? "") : "",
|
|
268
|
+
)
|
|
269
|
+
.filter((n) => n.length > 0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (stateType === null && labelNames === null) return null;
|
|
273
|
+
return { stateType, labelNames: labelNames ?? [] };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const ISSUE_GATE_QUERY = `
|
|
277
|
+
query AgentSwarmIssueGate($id: String!) {
|
|
278
|
+
issue(id: $id) {
|
|
279
|
+
state { type }
|
|
280
|
+
labels { nodes { name } }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
`;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Fetch the workflow-state type and label names for an issue via GraphQL.
|
|
287
|
+
* Used as a fallback when the AgentSessionEvent payload doesn't include them
|
|
288
|
+
* inline (today, it never does).
|
|
289
|
+
*
|
|
290
|
+
* Exported for testing — not part of the public API.
|
|
291
|
+
*/
|
|
292
|
+
export async function _fetchIssueGatingInfo(issueId: string): Promise<LinearGateInput> {
|
|
293
|
+
await ensureToken("linear");
|
|
294
|
+
const tokens = getOAuthTokens("linear");
|
|
295
|
+
if (!tokens) {
|
|
296
|
+
console.log(
|
|
297
|
+
`[Linear Sync] No OAuth tokens; cannot fetch issue ${issueId} gating info — defaulting to allow.`,
|
|
298
|
+
);
|
|
299
|
+
return { stateType: null, labelNames: [] };
|
|
300
|
+
}
|
|
301
|
+
const res = await fetch("https://api.linear.app/graphql", {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: {
|
|
304
|
+
"Content-Type": "application/json",
|
|
305
|
+
Authorization: `Bearer ${tokens.accessToken}`,
|
|
306
|
+
},
|
|
307
|
+
body: JSON.stringify({ query: ISSUE_GATE_QUERY, variables: { id: issueId } }),
|
|
308
|
+
});
|
|
309
|
+
if (!res.ok) {
|
|
310
|
+
console.error(
|
|
311
|
+
`[Linear Sync] Failed to fetch issue ${issueId} gating info: ${res.status} ${res.statusText}`,
|
|
312
|
+
);
|
|
313
|
+
return { stateType: null, labelNames: [] };
|
|
314
|
+
}
|
|
315
|
+
const json = (await res.json()) as {
|
|
316
|
+
data?: {
|
|
317
|
+
issue?: {
|
|
318
|
+
state?: { type?: string };
|
|
319
|
+
labels?: { nodes?: Array<{ name?: string }> };
|
|
320
|
+
};
|
|
321
|
+
};
|
|
322
|
+
errors?: unknown[];
|
|
323
|
+
};
|
|
324
|
+
if (json.errors) {
|
|
325
|
+
console.error("[Linear Sync] GraphQL errors fetching issue gating info:", json.errors);
|
|
326
|
+
return { stateType: null, labelNames: [] };
|
|
327
|
+
}
|
|
328
|
+
const stateType = json.data?.issue?.state?.type ?? null;
|
|
329
|
+
const labelNames = (json.data?.issue?.labels?.nodes ?? [])
|
|
330
|
+
.map((n) => n?.name ?? "")
|
|
331
|
+
.filter((n) => n.length > 0);
|
|
332
|
+
return { stateType, labelNames };
|
|
333
|
+
}
|
|
334
|
+
|
|
231
335
|
/**
|
|
232
336
|
* Find the lead agent to receive Linear tasks.
|
|
233
337
|
* Returns null if no lead is available (task will go to pool).
|
|
@@ -320,6 +424,30 @@ export async function handleAgentSessionEvent(event: Record<string, unknown>): P
|
|
|
320
424
|
);
|
|
321
425
|
}
|
|
322
426
|
|
|
427
|
+
// State gate: only trigger task creation when the issue is in an allowed
|
|
428
|
+
// workflow state (Todo / In Progress / etc). States outside the allowlist
|
|
429
|
+
// (Backlog, Triage by default) are skipped unless the swarm-ready label
|
|
430
|
+
// override is attached. Both the allowlist and the override label name are
|
|
431
|
+
// configurable via LINEAR_ALLOWED_STATES and LINEAR_SWARM_READY_LABEL.
|
|
432
|
+
const gateConfig = getLinearGateConfig();
|
|
433
|
+
const inlineGate = extractInlineGateInput(issue);
|
|
434
|
+
const gateInput = inlineGate ?? (await _fetchIssueGatingInfo(issueId));
|
|
435
|
+
const decision = shouldCreateTaskFromLinearEvent(gateInput, gateConfig);
|
|
436
|
+
if (!decision.create) {
|
|
437
|
+
console.log(
|
|
438
|
+
`[Linear Sync] Issue ${issueIdentifier} skipped — workflow state "${decision.reason}" is gated (labels: [${gateInput.labelNames.join(", ")}])`,
|
|
439
|
+
);
|
|
440
|
+
if (sessionId) {
|
|
441
|
+
const skipMsg = buildSkipMessage(decision.reason, gateConfig.swarmReadyLabel);
|
|
442
|
+
// Use response so the AgentSession auto-completes — leaves a visible
|
|
443
|
+
// comment on the issue without orphaning the session in pending state.
|
|
444
|
+
endAgentSession(sessionId, skipMsg, "response").catch((err) => {
|
|
445
|
+
console.error("[Linear Sync] Failed to post skip response on AgentSession:", err);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
323
451
|
const lead = findLeadAgent();
|
|
324
452
|
|
|
325
453
|
const sessionSection = sessionUrl ? `\nSession: ${sessionUrl}` : "";
|
package/src/oauth/keepalive.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import { ensureTokenOrThrow } from "./ensure-token";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
// Tick every 50 minutes with a 65-minute "expiring soon" buffer.
|
|
4
|
+
//
|
|
5
|
+
// Atlassian (and Linear) issue 1h access tokens. With this cadence the DB row
|
|
6
|
+
// is always rotated before its current access token expires, so anything that
|
|
7
|
+
// reads oauth_tokens.accessToken directly without going through jiraFetch /
|
|
8
|
+
// linear-outbound (e.g. agents using the read-only db-query MCP) sees a
|
|
9
|
+
// not-yet-expired token. The 65-min buffer is wider than the access-token
|
|
10
|
+
// lifetime, so isTokenExpiringSoon always returns true and every tick rotates.
|
|
11
|
+
//
|
|
12
|
+
// Touching the row this often also serves the original "keep the refresh
|
|
13
|
+
// token alive" goal — Atlassian expires inactive refresh tokens after 90 days,
|
|
14
|
+
// and Linear's behavior is similar; refreshing every 50 min trivially keeps
|
|
15
|
+
// both providers active.
|
|
16
|
+
const KEEPALIVE_INTERVAL_MS = 50 * 60 * 1000;
|
|
17
|
+
const KEEPALIVE_BUFFER_MS = 65 * 60 * 1000;
|
|
5
18
|
const SLACK_ALERTS_CHANNEL = process.env.SLACK_ALERTS_CHANNEL || "C08JCRURPBV";
|
|
6
19
|
|
|
7
20
|
const KEEPALIVE_PROVIDERS = ["linear", "jira"] as const;
|
|
@@ -9,20 +22,25 @@ const KEEPALIVE_PROVIDERS = ["linear", "jira"] as const;
|
|
|
9
22
|
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
10
23
|
|
|
11
24
|
/**
|
|
12
|
-
* Proactively refresh OAuth tokens on a schedule
|
|
13
|
-
* If refresh fails, posts a Slack notification so someone can re-auth manually.
|
|
25
|
+
* Proactively refresh OAuth tokens on a schedule.
|
|
14
26
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
27
|
+
* Two purposes, both served by the same tick:
|
|
28
|
+
*
|
|
29
|
+
* 1. Access-token freshness in the DB. Anything that reads
|
|
30
|
+
* `oauth_tokens.accessToken` directly (db-query MCP, future MCP servers,
|
|
31
|
+
* `tracker-status`) needs a not-yet-expired value. The 50-min cadence
|
|
32
|
+
* keeps the row ahead of the 1h access-token lifetime.
|
|
33
|
+
* 2. Refresh-token liveness. Atlassian rotates refresh tokens and expires
|
|
34
|
+
* them after ~90 days of inactivity, so silent gaps in usage would kill
|
|
35
|
+
* the integration. Refreshing on every tick keeps the refresh token
|
|
36
|
+
* active and surfaces a dead one as a Slack alert instead of a runtime
|
|
37
|
+
* 401 in the middle of an agent task.
|
|
20
38
|
*/
|
|
21
39
|
async function runKeepalive(): Promise<void> {
|
|
22
40
|
for (const provider of KEEPALIVE_PROVIDERS) {
|
|
23
41
|
console.log(`[OAuth Keepalive] Running scheduled token refresh for ${provider}...`);
|
|
24
42
|
try {
|
|
25
|
-
await ensureTokenOrThrow(provider,
|
|
43
|
+
await ensureTokenOrThrow(provider, KEEPALIVE_BUFFER_MS);
|
|
26
44
|
console.log(`[OAuth Keepalive] ${provider} token check completed successfully`);
|
|
27
45
|
} catch (err) {
|
|
28
46
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -56,7 +74,8 @@ async function notifySlack(text: string): Promise<void> {
|
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
/**
|
|
59
|
-
* Start the OAuth keepalive timer. Runs
|
|
77
|
+
* Start the OAuth keepalive timer. Runs once shortly after startup, then on
|
|
78
|
+
* KEEPALIVE_INTERVAL_MS thereafter.
|
|
60
79
|
*/
|
|
61
80
|
export function startOAuthKeepalive(): void {
|
|
62
81
|
if (keepaliveInterval) {
|
|
@@ -64,14 +83,16 @@ export function startOAuthKeepalive(): void {
|
|
|
64
83
|
return;
|
|
65
84
|
}
|
|
66
85
|
|
|
67
|
-
console.log(
|
|
86
|
+
console.log(
|
|
87
|
+
`[OAuth Keepalive] Starting (interval ${Math.round(KEEPALIVE_INTERVAL_MS / 60_000)}min, buffer ${Math.round(KEEPALIVE_BUFFER_MS / 60_000)}min)`,
|
|
88
|
+
);
|
|
68
89
|
|
|
69
90
|
// Run once after a short delay (let server finish startup)
|
|
70
91
|
setTimeout(() => runKeepalive(), 10_000);
|
|
71
92
|
|
|
72
93
|
keepaliveInterval = setInterval(() => {
|
|
73
94
|
runKeepalive();
|
|
74
|
-
},
|
|
95
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
75
96
|
}
|
|
76
97
|
|
|
77
98
|
/**
|
|
@@ -233,4 +233,37 @@ describe("ensureTokenOrThrow", () => {
|
|
|
233
233
|
test("stays silent (no throw) when provider is not configured", async () => {
|
|
234
234
|
await expect(ensureTokenOrThrow("nonexistent-provider")).resolves.toBeUndefined();
|
|
235
235
|
});
|
|
236
|
+
|
|
237
|
+
test("forces a refresh when bufferMs is wider than any plausible expiry", async () => {
|
|
238
|
+
// Pattern used by the POST /api/trackers/{provider}/refresh route to
|
|
239
|
+
// guarantee a rotation regardless of how far the current token is from
|
|
240
|
+
// expiry.
|
|
241
|
+
storeOAuthTokens("test-provider", {
|
|
242
|
+
accessToken: "old-token",
|
|
243
|
+
refreshToken: "refresh-token",
|
|
244
|
+
expiresAt: new Date(Date.now() + 50 * 60 * 1000).toISOString(), // 50 min ahead
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const fetchSpy = mock(() =>
|
|
248
|
+
Promise.resolve(
|
|
249
|
+
new Response(
|
|
250
|
+
JSON.stringify({
|
|
251
|
+
access_token: "rotated-token",
|
|
252
|
+
token_type: "Bearer",
|
|
253
|
+
expires_in: 3600,
|
|
254
|
+
refresh_token: "rotated-refresh",
|
|
255
|
+
}),
|
|
256
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
257
|
+
),
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
globalThis.fetch = fetchSpy;
|
|
261
|
+
|
|
262
|
+
await ensureTokenOrThrow("test-provider", Number.MAX_SAFE_INTEGER);
|
|
263
|
+
|
|
264
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
265
|
+
const tokens = getOAuthTokens("test-provider");
|
|
266
|
+
expect(tokens?.accessToken).toBe("rotated-token");
|
|
267
|
+
expect(tokens?.refreshToken).toBe("rotated-refresh");
|
|
268
|
+
});
|
|
236
269
|
});
|
|
@@ -3,6 +3,14 @@ import { createHmac } from "node:crypto";
|
|
|
3
3
|
import { unlink } from "node:fs/promises";
|
|
4
4
|
import { closeDb, createTaskExtended, getTaskById, initDb } from "../be/db";
|
|
5
5
|
import { createTrackerSync, getTrackerSyncByExternalId } from "../be/db-queries/tracker";
|
|
6
|
+
import {
|
|
7
|
+
buildSkipMessage,
|
|
8
|
+
DEFAULT_ALLOWED_STATE_TYPES,
|
|
9
|
+
DEFAULT_SWARM_READY_LABEL,
|
|
10
|
+
getLinearGateConfig,
|
|
11
|
+
SWARM_READY_LABEL,
|
|
12
|
+
shouldCreateTaskFromLinearEvent,
|
|
13
|
+
} from "../linear/gate";
|
|
6
14
|
import {
|
|
7
15
|
handleAgentSessionEvent,
|
|
8
16
|
handleIssueDelete,
|
|
@@ -505,3 +513,378 @@ describe("handleIssueDelete", () => {
|
|
|
505
513
|
expect(updated!.status).toBe("completed");
|
|
506
514
|
});
|
|
507
515
|
});
|
|
516
|
+
|
|
517
|
+
// ─── shouldCreateTaskFromLinearEvent (gate) ──────────────────────────────────
|
|
518
|
+
|
|
519
|
+
describe("shouldCreateTaskFromLinearEvent (default config)", () => {
|
|
520
|
+
test("creates task for unstarted (Todo) state", () => {
|
|
521
|
+
const decision = shouldCreateTaskFromLinearEvent({
|
|
522
|
+
stateType: "unstarted",
|
|
523
|
+
labelNames: [],
|
|
524
|
+
});
|
|
525
|
+
expect(decision).toEqual({ create: true, reason: "ready" });
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("creates task for started (In Progress) state", () => {
|
|
529
|
+
const decision = shouldCreateTaskFromLinearEvent({
|
|
530
|
+
stateType: "started",
|
|
531
|
+
labelNames: ["bug"],
|
|
532
|
+
});
|
|
533
|
+
expect(decision.create).toBe(true);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("skips task for backlog state", () => {
|
|
537
|
+
const decision = shouldCreateTaskFromLinearEvent({
|
|
538
|
+
stateType: "backlog",
|
|
539
|
+
labelNames: [],
|
|
540
|
+
});
|
|
541
|
+
expect(decision).toEqual({ create: false, reason: "backlog" });
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("skips task for triage state", () => {
|
|
545
|
+
const decision = shouldCreateTaskFromLinearEvent({
|
|
546
|
+
stateType: "triage",
|
|
547
|
+
labelNames: ["needs-review"],
|
|
548
|
+
});
|
|
549
|
+
expect(decision).toEqual({ create: false, reason: "triage" });
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("swarm-ready label overrides backlog gate", () => {
|
|
553
|
+
const decision = shouldCreateTaskFromLinearEvent({
|
|
554
|
+
stateType: "backlog",
|
|
555
|
+
labelNames: [SWARM_READY_LABEL],
|
|
556
|
+
});
|
|
557
|
+
expect(decision).toEqual({ create: true, reason: "label-override" });
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("swarm-ready label overrides triage gate", () => {
|
|
561
|
+
const decision = shouldCreateTaskFromLinearEvent({
|
|
562
|
+
stateType: "triage",
|
|
563
|
+
labelNames: ["bug", SWARM_READY_LABEL, "p0"],
|
|
564
|
+
});
|
|
565
|
+
expect(decision).toEqual({ create: true, reason: "label-override" });
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("label match is case-insensitive", () => {
|
|
569
|
+
const decision = shouldCreateTaskFromLinearEvent({
|
|
570
|
+
stateType: "backlog",
|
|
571
|
+
labelNames: ["Swarm-Ready"],
|
|
572
|
+
});
|
|
573
|
+
expect(decision.create).toBe(true);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("state matching is case-insensitive (defensive)", () => {
|
|
577
|
+
// Linear's enum is lowercase but be defensive in case payload casing varies.
|
|
578
|
+
const decision = shouldCreateTaskFromLinearEvent({
|
|
579
|
+
stateType: "Backlog",
|
|
580
|
+
labelNames: [],
|
|
581
|
+
});
|
|
582
|
+
expect(decision).toEqual({ create: false, reason: "backlog" });
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("allowed state types pass through", () => {
|
|
586
|
+
// E.g. completed, canceled — included in default allowlist, trigger as today.
|
|
587
|
+
expect(shouldCreateTaskFromLinearEvent({ stateType: "completed", labelNames: [] }).create).toBe(
|
|
588
|
+
true,
|
|
589
|
+
);
|
|
590
|
+
expect(shouldCreateTaskFromLinearEvent({ stateType: "canceled", labelNames: [] }).create).toBe(
|
|
591
|
+
true,
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("null state type allows task creation (fail-open)", () => {
|
|
596
|
+
// If we couldn't resolve the state for any reason, default to today's
|
|
597
|
+
// behavior rather than silently swallowing assignments.
|
|
598
|
+
const decision = shouldCreateTaskFromLinearEvent({
|
|
599
|
+
stateType: null,
|
|
600
|
+
labelNames: [],
|
|
601
|
+
});
|
|
602
|
+
expect(decision.create).toBe(true);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("DEFAULT_SWARM_READY_LABEL matches today's hardcoded value", () => {
|
|
606
|
+
expect(DEFAULT_SWARM_READY_LABEL).toBe("swarm-ready");
|
|
607
|
+
expect(SWARM_READY_LABEL).toBe(DEFAULT_SWARM_READY_LABEL);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("DEFAULT_ALLOWED_STATE_TYPES covers Linear enum minus triage/backlog", () => {
|
|
611
|
+
expect([...DEFAULT_ALLOWED_STATE_TYPES].sort()).toEqual(
|
|
612
|
+
["canceled", "completed", "started", "unstarted"].sort(),
|
|
613
|
+
);
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
describe("shouldCreateTaskFromLinearEvent (env-driven overrides)", () => {
|
|
618
|
+
const envKeys = ["LINEAR_ALLOWED_STATES", "LINEAR_SWARM_READY_LABEL"] as const;
|
|
619
|
+
const saved: Record<string, string | undefined> = {};
|
|
620
|
+
|
|
621
|
+
beforeEach(() => {
|
|
622
|
+
for (const k of envKeys) {
|
|
623
|
+
saved[k] = process.env[k];
|
|
624
|
+
delete process.env[k];
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// Re-snapshot only this scope's env state on cleanup.
|
|
629
|
+
function restore() {
|
|
630
|
+
for (const k of envKeys) {
|
|
631
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
632
|
+
else process.env[k] = saved[k];
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
test("LINEAR_ALLOWED_STATES expands the allowlist (e.g. include backlog)", () => {
|
|
637
|
+
process.env.LINEAR_ALLOWED_STATES = "unstarted,started,backlog";
|
|
638
|
+
const decision = shouldCreateTaskFromLinearEvent({
|
|
639
|
+
stateType: "backlog",
|
|
640
|
+
labelNames: [],
|
|
641
|
+
});
|
|
642
|
+
expect(decision).toEqual({ create: true, reason: "ready" });
|
|
643
|
+
restore();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("LINEAR_ALLOWED_STATES restricts the allowlist (drop completed)", () => {
|
|
647
|
+
process.env.LINEAR_ALLOWED_STATES = "unstarted,started";
|
|
648
|
+
const decision = shouldCreateTaskFromLinearEvent({
|
|
649
|
+
stateType: "completed",
|
|
650
|
+
labelNames: [],
|
|
651
|
+
});
|
|
652
|
+
expect(decision).toEqual({ create: false, reason: "completed" });
|
|
653
|
+
restore();
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("LINEAR_ALLOWED_STATES handles whitespace and case", () => {
|
|
657
|
+
process.env.LINEAR_ALLOWED_STATES = " Unstarted , STARTED ,backlog";
|
|
658
|
+
expect(shouldCreateTaskFromLinearEvent({ stateType: "backlog", labelNames: [] }).create).toBe(
|
|
659
|
+
true,
|
|
660
|
+
);
|
|
661
|
+
expect(shouldCreateTaskFromLinearEvent({ stateType: "completed", labelNames: [] }).create).toBe(
|
|
662
|
+
false,
|
|
663
|
+
);
|
|
664
|
+
restore();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("empty LINEAR_ALLOWED_STATES locks down everything but label override", () => {
|
|
668
|
+
process.env.LINEAR_ALLOWED_STATES = "";
|
|
669
|
+
expect(shouldCreateTaskFromLinearEvent({ stateType: "started", labelNames: [] })).toEqual({
|
|
670
|
+
create: false,
|
|
671
|
+
reason: "started",
|
|
672
|
+
});
|
|
673
|
+
expect(
|
|
674
|
+
shouldCreateTaskFromLinearEvent({
|
|
675
|
+
stateType: "started",
|
|
676
|
+
labelNames: [SWARM_READY_LABEL],
|
|
677
|
+
}).create,
|
|
678
|
+
).toBe(true);
|
|
679
|
+
restore();
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("LINEAR_SWARM_READY_LABEL overrides the bypass label", () => {
|
|
683
|
+
process.env.LINEAR_SWARM_READY_LABEL = "go-now";
|
|
684
|
+
// The default label no longer triggers.
|
|
685
|
+
expect(
|
|
686
|
+
shouldCreateTaskFromLinearEvent({
|
|
687
|
+
stateType: "backlog",
|
|
688
|
+
labelNames: [SWARM_READY_LABEL],
|
|
689
|
+
}),
|
|
690
|
+
).toEqual({ create: false, reason: "backlog" });
|
|
691
|
+
// The configured label does.
|
|
692
|
+
expect(
|
|
693
|
+
shouldCreateTaskFromLinearEvent({
|
|
694
|
+
stateType: "backlog",
|
|
695
|
+
labelNames: ["go-now"],
|
|
696
|
+
}),
|
|
697
|
+
).toEqual({ create: true, reason: "label-override" });
|
|
698
|
+
restore();
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test("LINEAR_SWARM_READY_LABEL match is case-insensitive", () => {
|
|
702
|
+
process.env.LINEAR_SWARM_READY_LABEL = "GO-NOW";
|
|
703
|
+
expect(
|
|
704
|
+
shouldCreateTaskFromLinearEvent({
|
|
705
|
+
stateType: "backlog",
|
|
706
|
+
labelNames: ["Go-Now"],
|
|
707
|
+
}).create,
|
|
708
|
+
).toBe(true);
|
|
709
|
+
restore();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("getLinearGateConfig reflects env at call time", () => {
|
|
713
|
+
process.env.LINEAR_ALLOWED_STATES = "started";
|
|
714
|
+
process.env.LINEAR_SWARM_READY_LABEL = "ship-it";
|
|
715
|
+
const cfg = getLinearGateConfig();
|
|
716
|
+
expect(cfg.allowedStateTypes).toEqual(new Set(["started"]));
|
|
717
|
+
expect(cfg.swarmReadyLabel).toBe("ship-it");
|
|
718
|
+
restore();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("getLinearGateConfig falls back to defaults when env is unset", () => {
|
|
722
|
+
const cfg = getLinearGateConfig();
|
|
723
|
+
expect(cfg.allowedStateTypes).toEqual(new Set(DEFAULT_ALLOWED_STATE_TYPES));
|
|
724
|
+
expect(cfg.swarmReadyLabel).toBe(DEFAULT_SWARM_READY_LABEL);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test("buildSkipMessage uses the configured override label", () => {
|
|
728
|
+
process.env.LINEAR_SWARM_READY_LABEL = "go-now";
|
|
729
|
+
const msg = buildSkipMessage("backlog", getLinearGateConfig().swarmReadyLabel);
|
|
730
|
+
expect(msg).toContain("Backlog");
|
|
731
|
+
expect(msg).toContain("`go-now`");
|
|
732
|
+
expect(msg).not.toContain("`swarm-ready`");
|
|
733
|
+
restore();
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// ─── handleAgentSessionEvent — state-gate skip path ──────────────────────────
|
|
738
|
+
|
|
739
|
+
describe("handleAgentSessionEvent — state gate", () => {
|
|
740
|
+
test("skips task creation when issue.state.type is backlog", async () => {
|
|
741
|
+
const event = {
|
|
742
|
+
type: "AgentSession",
|
|
743
|
+
action: "create",
|
|
744
|
+
agentSession: {
|
|
745
|
+
id: "session-gate-backlog-001",
|
|
746
|
+
url: "https://linear.app/team/issue/ENG-500/agent",
|
|
747
|
+
issue: {
|
|
748
|
+
id: "issue-gate-backlog-001",
|
|
749
|
+
identifier: "ENG-500",
|
|
750
|
+
title: "Backlog ticket",
|
|
751
|
+
url: "https://linear.app/team/issue/ENG-500",
|
|
752
|
+
// Inline state/labels so the test doesn't need to mock GraphQL.
|
|
753
|
+
state: { type: "backlog" },
|
|
754
|
+
labels: [],
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
await handleAgentSessionEvent(event);
|
|
760
|
+
|
|
761
|
+
// No tracker_sync should have been created.
|
|
762
|
+
const sync = getTrackerSyncByExternalId("linear", "task", "issue-gate-backlog-001");
|
|
763
|
+
expect(sync).toBeNull();
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("skips task creation when issue.state.type is triage", async () => {
|
|
767
|
+
const event = {
|
|
768
|
+
type: "AgentSession",
|
|
769
|
+
action: "create",
|
|
770
|
+
agentSession: {
|
|
771
|
+
id: "session-gate-triage-001",
|
|
772
|
+
issue: {
|
|
773
|
+
id: "issue-gate-triage-001",
|
|
774
|
+
identifier: "ENG-501",
|
|
775
|
+
title: "Triage ticket",
|
|
776
|
+
url: "https://linear.app/team/issue/ENG-501",
|
|
777
|
+
state: { type: "triage" },
|
|
778
|
+
labels: { nodes: [] },
|
|
779
|
+
},
|
|
780
|
+
},
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
await handleAgentSessionEvent(event);
|
|
784
|
+
|
|
785
|
+
const sync = getTrackerSyncByExternalId("linear", "task", "issue-gate-triage-001");
|
|
786
|
+
expect(sync).toBeNull();
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("creates task when issue is in backlog but has swarm-ready label", async () => {
|
|
790
|
+
const event = {
|
|
791
|
+
type: "AgentSession",
|
|
792
|
+
action: "create",
|
|
793
|
+
agentSession: {
|
|
794
|
+
id: "session-gate-override-001",
|
|
795
|
+
issue: {
|
|
796
|
+
id: "issue-gate-override-001",
|
|
797
|
+
identifier: "ENG-502",
|
|
798
|
+
title: "Pre-staged backlog ticket",
|
|
799
|
+
url: "https://linear.app/team/issue/ENG-502",
|
|
800
|
+
state: { type: "backlog" },
|
|
801
|
+
labels: { nodes: [{ name: "bug" }, { name: SWARM_READY_LABEL }] },
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
await handleAgentSessionEvent(event);
|
|
807
|
+
|
|
808
|
+
const sync = getTrackerSyncByExternalId("linear", "task", "issue-gate-override-001");
|
|
809
|
+
expect(sync).not.toBeNull();
|
|
810
|
+
expect(sync!.externalIdentifier).toBe("ENG-502");
|
|
811
|
+
const task = getTaskById(sync!.swarmId);
|
|
812
|
+
expect(task).not.toBeNull();
|
|
813
|
+
expect(task!.source).toBe("linear");
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test("LINEAR_ALLOWED_STATES env override widens the allowlist end-to-end", async () => {
|
|
817
|
+
process.env.LINEAR_ALLOWED_STATES = "unstarted,started,backlog";
|
|
818
|
+
try {
|
|
819
|
+
const event = {
|
|
820
|
+
type: "AgentSession",
|
|
821
|
+
action: "create",
|
|
822
|
+
agentSession: {
|
|
823
|
+
id: "session-gate-env-allow-001",
|
|
824
|
+
issue: {
|
|
825
|
+
id: "issue-gate-env-allow-001",
|
|
826
|
+
identifier: "ENG-510",
|
|
827
|
+
title: "Backlog ticket allowed via env",
|
|
828
|
+
url: "https://linear.app/team/issue/ENG-510",
|
|
829
|
+
state: { type: "backlog" },
|
|
830
|
+
labels: [],
|
|
831
|
+
},
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
await handleAgentSessionEvent(event);
|
|
835
|
+
const sync = getTrackerSyncByExternalId("linear", "task", "issue-gate-env-allow-001");
|
|
836
|
+
expect(sync).not.toBeNull();
|
|
837
|
+
} finally {
|
|
838
|
+
delete process.env.LINEAR_ALLOWED_STATES;
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("LINEAR_SWARM_READY_LABEL env override is honored end-to-end", async () => {
|
|
843
|
+
process.env.LINEAR_SWARM_READY_LABEL = "go-now";
|
|
844
|
+
try {
|
|
845
|
+
const event = {
|
|
846
|
+
type: "AgentSession",
|
|
847
|
+
action: "create",
|
|
848
|
+
agentSession: {
|
|
849
|
+
id: "session-gate-env-label-001",
|
|
850
|
+
issue: {
|
|
851
|
+
id: "issue-gate-env-label-001",
|
|
852
|
+
identifier: "ENG-511",
|
|
853
|
+
title: "Backlog ticket with custom override label",
|
|
854
|
+
url: "https://linear.app/team/issue/ENG-511",
|
|
855
|
+
state: { type: "backlog" },
|
|
856
|
+
labels: { nodes: [{ name: "go-now" }] },
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
};
|
|
860
|
+
await handleAgentSessionEvent(event);
|
|
861
|
+
const sync = getTrackerSyncByExternalId("linear", "task", "issue-gate-env-label-001");
|
|
862
|
+
expect(sync).not.toBeNull();
|
|
863
|
+
} finally {
|
|
864
|
+
delete process.env.LINEAR_SWARM_READY_LABEL;
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("creates task when issue is in unstarted (Todo) state", async () => {
|
|
869
|
+
const event = {
|
|
870
|
+
type: "AgentSession",
|
|
871
|
+
action: "create",
|
|
872
|
+
agentSession: {
|
|
873
|
+
id: "session-gate-todo-001",
|
|
874
|
+
issue: {
|
|
875
|
+
id: "issue-gate-todo-001",
|
|
876
|
+
identifier: "ENG-503",
|
|
877
|
+
title: "Ready ticket",
|
|
878
|
+
url: "https://linear.app/team/issue/ENG-503",
|
|
879
|
+
state: { type: "unstarted" },
|
|
880
|
+
labels: [],
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
await handleAgentSessionEvent(event);
|
|
886
|
+
|
|
887
|
+
const sync = getTrackerSyncByExternalId("linear", "task", "issue-gate-todo-001");
|
|
888
|
+
expect(sync).not.toBeNull();
|
|
889
|
+
});
|
|
890
|
+
});
|
|
@@ -697,7 +697,7 @@ describe("ValidateExecutor", () => {
|
|
|
697
697
|
// ─── Registry Wiring ─────────────────────────────────────────
|
|
698
698
|
|
|
699
699
|
describe("createExecutorRegistry", () => {
|
|
700
|
-
test("registers all
|
|
700
|
+
test("registers all 10 executors (7 instant + 3 async)", () => {
|
|
701
701
|
const registry = createExecutorRegistry(mockDeps);
|
|
702
702
|
const types = registry.types();
|
|
703
703
|
|
|
@@ -710,7 +710,8 @@ describe("createExecutorRegistry", () => {
|
|
|
710
710
|
expect(types).toContain("validate");
|
|
711
711
|
expect(types).toContain("agent-task");
|
|
712
712
|
expect(types).toContain("human-in-the-loop");
|
|
713
|
-
expect(types).
|
|
713
|
+
expect(types).toContain("wait");
|
|
714
|
+
expect(types).toHaveLength(10);
|
|
714
715
|
});
|
|
715
716
|
|
|
716
717
|
test("instant executors have mode instant, async executors have mode async", () => {
|
|
@@ -729,6 +730,7 @@ describe("createExecutorRegistry", () => {
|
|
|
729
730
|
}
|
|
730
731
|
expect(registry.get("agent-task").mode).toBe("async");
|
|
731
732
|
expect(registry.get("human-in-the-loop").mode).toBe("async");
|
|
733
|
+
expect(registry.get("wait").mode).toBe("async");
|
|
732
734
|
});
|
|
733
735
|
|
|
734
736
|
test("get() retrieves the correct executor by type", () => {
|