@desplega.ai/agent-swarm 1.88.0 → 1.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/openapi.json +41 -1
- package/package.json +2 -1
- package/plugin/skills/composio/SKILL.md +98 -0
- package/src/be/db.ts +325 -2
- package/src/be/migrations/081_metrics.sql +39 -0
- package/src/be/migrations/082_user_audit_fields.sql +120 -0
- package/src/be/modelsdev-cache.json +2750 -1431
- package/src/be/seed-skills/index.ts +7 -0
- package/src/cli.tsx +18 -0
- package/src/commands/runner.ts +153 -22
- package/src/commands/x.ts +118 -0
- package/src/github/handlers.ts +40 -1
- package/src/heartbeat/heartbeat.ts +26 -5
- package/src/http/active-sessions.ts +32 -1
- package/src/http/auth.ts +36 -0
- package/src/http/core.ts +20 -16
- package/src/http/db-query.ts +20 -0
- package/src/http/index.ts +2 -0
- package/src/http/metrics.ts +447 -0
- package/src/http/operator-actor.ts +9 -0
- package/src/http/poll.ts +11 -1
- package/src/http/tasks.ts +4 -1
- package/src/http/workflows.ts +5 -1
- package/src/metrics/version.ts +26 -0
- package/src/prompts/base-prompt.ts +8 -0
- package/src/prompts/session-templates.ts +23 -0
- package/src/providers/opencode-adapter.ts +22 -6
- package/src/server.ts +10 -1
- package/src/tests/base-prompt.test.ts +35 -0
- package/src/tests/budget-claim-gate.test.ts +26 -0
- package/src/tests/core-auth.test.ts +8 -1
- package/src/tests/events-http.test.ts +6 -2
- package/src/tests/github-handlers-cancel-config.test.ts +262 -0
- package/src/tests/heartbeat.test.ts +84 -3
- package/src/tests/http-api-integration.test.ts +3 -1
- package/src/tests/metrics-http.test.ts +247 -0
- package/src/tests/opencode-adapter.test.ts +90 -30
- package/src/tests/runner-repo-autostash.test.ts +117 -0
- package/src/tests/runner-requester-profile.test.ts +25 -0
- package/src/tests/runner-skills-refresh.test.ts +1 -1
- package/src/tests/swarm-x-tool.test.ts +90 -0
- package/src/tests/system-default-skills.test.ts +3 -0
- package/src/tests/ui-logs-parser.test.ts +271 -0
- package/src/tests/user-token-rest-auth.test.ts +129 -0
- package/src/tests/workflow-async-v2.test.ts +23 -0
- package/src/tests/x-composio.test.ts +122 -0
- package/src/tools/create-metric.ts +191 -0
- package/src/tools/swarm-x.ts +116 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/types.ts +120 -0
- package/src/utils/request-auth-context.ts +28 -0
- package/src/utils/skills-refresh.ts +2 -2
- package/src/workflows/engine.ts +24 -2
- package/src/workflows/executors/agent-task.ts +2 -0
- package/src/x/composio.ts +295 -0
- package/templates/skills/attio-interaction/SKILL.md +279 -0
- package/templates/skills/attio-interaction/config.json +14 -0
- package/templates/skills/attio-interaction/content.md +272 -0
|
@@ -72,6 +72,7 @@ export type BasePromptArgs = {
|
|
|
72
72
|
claudeMd?: string | null;
|
|
73
73
|
clonePath: string;
|
|
74
74
|
warning?: string | null;
|
|
75
|
+
autoStashes?: { ref: string; message: string }[];
|
|
75
76
|
guidelines?: {
|
|
76
77
|
prChecks: string[];
|
|
77
78
|
mergeChecks: string[];
|
|
@@ -197,6 +198,13 @@ export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
|
|
|
197
198
|
} else if (!args.repoContext.warning) {
|
|
198
199
|
prompt += `Repository is cloned at \`${args.repoContext.clonePath}\` but has no CLAUDE.md file.\n`;
|
|
199
200
|
}
|
|
201
|
+
|
|
202
|
+
if (args.repoContext.autoStashes && args.repoContext.autoStashes.length > 0) {
|
|
203
|
+
const stashes = args.repoContext.autoStashes
|
|
204
|
+
.map((stash) => `- ${stash.ref}: ${stash.message}`)
|
|
205
|
+
.join("\n");
|
|
206
|
+
prompt += `\nPending auto-stashed work exists in this repo:\n${stashes}\nRestore if relevant with \`git stash apply <ref>\` or \`git stash pop <ref>\`.\n`;
|
|
207
|
+
}
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
// Inject repo guidelines
|
|
@@ -565,6 +565,29 @@ When working in a repository, your system prompt may include a **Repository Guid
|
|
|
565
565
|
category: "system",
|
|
566
566
|
});
|
|
567
567
|
|
|
568
|
+
// ============================================================================
|
|
569
|
+
// Per-task prompt templates (category: "task_lifecycle")
|
|
570
|
+
// ============================================================================
|
|
571
|
+
|
|
572
|
+
registerTemplate({
|
|
573
|
+
eventType: "task.requester.profile",
|
|
574
|
+
header: "",
|
|
575
|
+
defaultBody: `
|
|
576
|
+
## Requester Profile
|
|
577
|
+
This task was requested by {{requester_name}}{{requester_role_suffix}}.{{requester_notes_section}}
|
|
578
|
+
Honor this requester profile in tone, depth, and format where it doesn't conflict with correctness or your operating rules.
|
|
579
|
+
`,
|
|
580
|
+
variables: [
|
|
581
|
+
{ name: "requester_name", description: "The requesting user's display name" },
|
|
582
|
+
{ name: "requester_role_suffix", description: "Formatted role suffix, including parentheses" },
|
|
583
|
+
{
|
|
584
|
+
name: "requester_notes_section",
|
|
585
|
+
description: "Formatted notes section sourced from users.notes, or empty string",
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
category: "task_lifecycle",
|
|
589
|
+
});
|
|
590
|
+
|
|
568
591
|
// ============================================================================
|
|
569
592
|
// Composite session templates (category: "session")
|
|
570
593
|
// ============================================================================
|
|
@@ -219,6 +219,7 @@ export class OpencodeSession implements ProviderSession {
|
|
|
219
219
|
private completionPromise: Promise<ProviderResult>;
|
|
220
220
|
private server: { url: string; close(): void };
|
|
221
221
|
private aborted = false;
|
|
222
|
+
private completed = false;
|
|
222
223
|
|
|
223
224
|
// Running cost accumulators
|
|
224
225
|
private totalCostUsd = 0;
|
|
@@ -273,6 +274,10 @@ export class OpencodeSession implements ProviderSession {
|
|
|
273
274
|
return this._sessionId;
|
|
274
275
|
}
|
|
275
276
|
|
|
277
|
+
get isFinished(): boolean {
|
|
278
|
+
return this.completed;
|
|
279
|
+
}
|
|
280
|
+
|
|
276
281
|
/** Emit the synthetic session_init event. Called by the adapter immediately
|
|
277
282
|
* after construction; buffers if no listener is attached yet. */
|
|
278
283
|
emitSessionInit(provider: "opencode"): void {
|
|
@@ -358,7 +363,7 @@ export class OpencodeSession implements ProviderSession {
|
|
|
358
363
|
|
|
359
364
|
/** Process a single opencode SSE event */
|
|
360
365
|
handleOpencodeEvent(ev: OpencodeEvent): void {
|
|
361
|
-
if (this.aborted) return;
|
|
366
|
+
if (this.aborted || this.completed) return;
|
|
362
367
|
|
|
363
368
|
// Always emit the raw event as a scrubbed raw_log
|
|
364
369
|
const rawContent = scrubSecrets(JSON.stringify(ev));
|
|
@@ -471,7 +476,7 @@ export class OpencodeSession implements ProviderSession {
|
|
|
471
476
|
for (const l of this.listeners) l(resultEvent);
|
|
472
477
|
const raw = scrubSecrets(JSON.stringify(resultEvent));
|
|
473
478
|
this.emitDirect({ type: "raw_log", content: raw });
|
|
474
|
-
this.
|
|
479
|
+
void this.finish({
|
|
475
480
|
exitCode: 0,
|
|
476
481
|
sessionId: this._sessionId,
|
|
477
482
|
cost,
|
|
@@ -515,7 +520,7 @@ export class OpencodeSession implements ProviderSession {
|
|
|
515
520
|
const raw = scrubSecrets(JSON.stringify(errorEvent));
|
|
516
521
|
this.emitDirect({ type: "raw_log", content: raw });
|
|
517
522
|
const cost = this.buildCostData(true);
|
|
518
|
-
this.
|
|
523
|
+
void this.finish({
|
|
519
524
|
exitCode: 1,
|
|
520
525
|
sessionId: this._sessionId,
|
|
521
526
|
cost,
|
|
@@ -546,12 +551,22 @@ export class OpencodeSession implements ProviderSession {
|
|
|
546
551
|
return this.completionPromise;
|
|
547
552
|
}
|
|
548
553
|
|
|
554
|
+
private async finish(result: ProviderResult): Promise<void> {
|
|
555
|
+
if (this.completed) return;
|
|
556
|
+
this.completed = true;
|
|
557
|
+
try {
|
|
558
|
+
this.server.close();
|
|
559
|
+
} catch {
|
|
560
|
+
// best-effort
|
|
561
|
+
}
|
|
562
|
+
await this.cleanupFiles();
|
|
563
|
+
this.completionResolve(result);
|
|
564
|
+
}
|
|
565
|
+
|
|
549
566
|
async abort(): Promise<void> {
|
|
550
567
|
if (this.aborted) return;
|
|
551
568
|
this.aborted = true;
|
|
552
|
-
this.
|
|
553
|
-
await this.cleanupFiles();
|
|
554
|
-
this.completionResolve({
|
|
569
|
+
await this.finish({
|
|
555
570
|
exitCode: 1,
|
|
556
571
|
sessionId: this._sessionId,
|
|
557
572
|
isError: true,
|
|
@@ -760,6 +775,7 @@ export class OpencodeAdapter implements ProviderAdapter {
|
|
|
760
775
|
.then(async ({ stream }) => {
|
|
761
776
|
for await (const event of stream) {
|
|
762
777
|
session.handleOpencodeEvent(event as OpencodeEvent);
|
|
778
|
+
if (session.isFinished) break;
|
|
763
779
|
}
|
|
764
780
|
// Stream ended without session.idle — treat as completion
|
|
765
781
|
})
|
package/src/server.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { registerCancelTaskTool } from "./tools/cancel-task";
|
|
|
6
6
|
import { registerContextDiffTool } from "./tools/context-diff";
|
|
7
7
|
import { registerContextHistoryTool } from "./tools/context-history";
|
|
8
8
|
import { registerCreateChannelTool } from "./tools/create-channel";
|
|
9
|
+
import { registerCreateMetricTool } from "./tools/create-metric";
|
|
9
10
|
import { registerCreatePageTool } from "./tools/create-page";
|
|
10
11
|
import { registerDbQueryTool } from "./tools/db-query";
|
|
11
12
|
import { registerDeleteChannelTool } from "./tools/delete-channel";
|
|
@@ -109,6 +110,7 @@ import {
|
|
|
109
110
|
registerListConfigTool,
|
|
110
111
|
registerSetConfigTool,
|
|
111
112
|
} from "./tools/swarm-config";
|
|
113
|
+
import { registerSwarmXTool } from "./tools/swarm-x";
|
|
112
114
|
// Task pool capability
|
|
113
115
|
import { registerTaskActionTool } from "./tools/task-action";
|
|
114
116
|
// Tracker capability
|
|
@@ -146,7 +148,7 @@ import {
|
|
|
146
148
|
// Capability-based feature flags
|
|
147
149
|
// Default: all capabilities enabled
|
|
148
150
|
const DEFAULT_CAPABILITIES =
|
|
149
|
-
"core,task-pool,profiles,services,scheduling,memory,workflows,pages,kv";
|
|
151
|
+
"core,task-pool,profiles,services,scheduling,memory,workflows,pages,metrics,kv";
|
|
150
152
|
const CAPABILITIES = new Set(
|
|
151
153
|
(process.env.CAPABILITIES || DEFAULT_CAPABILITIES).split(",").map((s) => s.trim()),
|
|
152
154
|
);
|
|
@@ -226,6 +228,9 @@ export function createServer() {
|
|
|
226
228
|
registerScriptDeleteTool(server);
|
|
227
229
|
registerScriptQueryTypesTool(server);
|
|
228
230
|
|
|
231
|
+
// External command routes - mirrors the `agent-swarm x ...` CLI surface.
|
|
232
|
+
registerSwarmXTool(server);
|
|
233
|
+
|
|
229
234
|
// Slack integration tools (always registered, will no-op if Slack not configured)
|
|
230
235
|
registerSlackReplyTool(server);
|
|
231
236
|
registerSlackReadTool(server);
|
|
@@ -338,6 +343,10 @@ export function createServer() {
|
|
|
338
343
|
registerCreatePageTool(server);
|
|
339
344
|
}
|
|
340
345
|
|
|
346
|
+
if (hasCapability("metrics")) {
|
|
347
|
+
registerCreateMetricTool(server);
|
|
348
|
+
}
|
|
349
|
+
|
|
341
350
|
// KV capability — namespaced Redis-like key/value (see src/be/migrations/061_kv_store.sql).
|
|
342
351
|
// Enabled by default; opt out via `CAPABILITIES=...` without `kv`.
|
|
343
352
|
if (hasCapability("kv")) {
|
|
@@ -272,6 +272,41 @@ describe("getBasePrompt — repoContext", () => {
|
|
|
272
272
|
expect(result).toContain("Repository Guidelines (MANDATORY)");
|
|
273
273
|
expect(result).toContain("Auto-merge: Allowed");
|
|
274
274
|
});
|
|
275
|
+
|
|
276
|
+
test("surfaces swarm-autostash entries when present", async () => {
|
|
277
|
+
const result = await getBasePrompt({
|
|
278
|
+
...minimalArgs,
|
|
279
|
+
repoContext: {
|
|
280
|
+
claudeMd: "Rules",
|
|
281
|
+
clonePath: "/workspace/my-repo",
|
|
282
|
+
autoStashes: [
|
|
283
|
+
{
|
|
284
|
+
ref: "stash@{0}",
|
|
285
|
+
message: "On main: swarm-autostash main 2026-06-01T13:00:00.000Z",
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(result).toContain("Pending auto-stashed work exists in this repo");
|
|
292
|
+
expect(result).toContain("stash@{0}: On main: swarm-autostash main");
|
|
293
|
+
expect(result).toContain("git stash apply <ref>");
|
|
294
|
+
expect(result).toContain("git stash pop <ref>");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("does not mention auto-stashed work when no swarm-autostash entries exist", async () => {
|
|
298
|
+
const result = await getBasePrompt({
|
|
299
|
+
...minimalArgs,
|
|
300
|
+
repoContext: {
|
|
301
|
+
claudeMd: "Rules",
|
|
302
|
+
clonePath: "/workspace/my-repo",
|
|
303
|
+
autoStashes: [],
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(result).not.toContain("Pending auto-stashed work exists in this repo");
|
|
308
|
+
expect(result).not.toContain("git stash apply <ref>");
|
|
309
|
+
});
|
|
275
310
|
});
|
|
276
311
|
|
|
277
312
|
// ---------------------------------------------------------------------------
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
createAgent,
|
|
12
12
|
createSessionCost,
|
|
13
13
|
createTaskExtended,
|
|
14
|
+
createUser,
|
|
14
15
|
getAgentById,
|
|
15
16
|
getDb,
|
|
16
17
|
incrementEmptyPollCount,
|
|
@@ -129,6 +130,31 @@ describe("Phase 3 — /api/poll budget admission gate", () => {
|
|
|
129
130
|
expect((body.trigger as { taskId: string }).taskId).toBe(task.id);
|
|
130
131
|
});
|
|
131
132
|
|
|
133
|
+
test("pending task trigger includes requester role and notes", async () => {
|
|
134
|
+
const worker = createAgent({ name: "w-requester", isLead: false, status: "idle", maxTasks: 1 });
|
|
135
|
+
const requester = createUser({
|
|
136
|
+
name: "Requester One",
|
|
137
|
+
email: "requester@example.com",
|
|
138
|
+
role: "engineering manager",
|
|
139
|
+
notes: "Include implementation detail and test coverage.",
|
|
140
|
+
});
|
|
141
|
+
createTaskExtended("profile-aware task", {
|
|
142
|
+
agentId: worker.id,
|
|
143
|
+
requestedByUserId: requester.id,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const { status, body } = await callPoll(worker.id);
|
|
147
|
+
expect(status).toBe(200);
|
|
148
|
+
if ("error" in body) throw new Error("unexpected error response");
|
|
149
|
+
expect(body.trigger?.type).toBe("task_assigned");
|
|
150
|
+
expect(body.trigger?.requestedBy).toEqual({
|
|
151
|
+
name: "Requester One",
|
|
152
|
+
email: "requester@example.com",
|
|
153
|
+
role: "engineering manager",
|
|
154
|
+
notes: "Include implementation detail and test coverage.",
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
132
158
|
test("no budgets configured + no work → trigger=null", async () => {
|
|
133
159
|
const worker = createAgent({ name: "w-empty", isLead: false, status: "idle", maxTasks: 1 });
|
|
134
160
|
const { status, body } = await callPoll(worker.id);
|
|
@@ -135,8 +135,15 @@ describe("handleCore auth middleware (no API_KEY configured)", () => {
|
|
|
135
135
|
server.close();
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
-
test("authed routes
|
|
138
|
+
test("authed routes fail closed without Bearer when API_KEY is empty", async () => {
|
|
139
139
|
const res = await fetch(`http://localhost:${port}/api/mcp-oauth/some-id/status`);
|
|
140
|
+
expect(res.status).toBe(401);
|
|
141
|
+
const body = await res.json();
|
|
142
|
+
expect(body.error).toBe("Unauthorized");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("public routes still pass when API_KEY is empty", async () => {
|
|
146
|
+
const res = await fetch(`http://localhost:${port}/api/mcp-oauth/callback`);
|
|
140
147
|
expect(res.status).not.toBe(401);
|
|
141
148
|
});
|
|
142
149
|
});
|
|
@@ -5,6 +5,7 @@ import type { Subprocess } from "bun";
|
|
|
5
5
|
const TEST_PORT = 13033;
|
|
6
6
|
const TEST_DB_PATH = `/tmp/test-events-http-${Date.now()}.sqlite`;
|
|
7
7
|
const BASE = `http://localhost:${TEST_PORT}`;
|
|
8
|
+
const TEST_API_KEY = "test-events-http-key";
|
|
8
9
|
|
|
9
10
|
let serverProc: Subprocess;
|
|
10
11
|
|
|
@@ -15,7 +16,10 @@ async function api(
|
|
|
15
16
|
): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
16
17
|
const res = await fetch(`${BASE}${path}`, {
|
|
17
18
|
method,
|
|
18
|
-
headers: {
|
|
19
|
+
headers: {
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
22
|
+
},
|
|
19
23
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
20
24
|
});
|
|
21
25
|
const text = await res.text();
|
|
@@ -54,7 +58,7 @@ beforeAll(async () => {
|
|
|
54
58
|
...process.env,
|
|
55
59
|
PORT: String(TEST_PORT),
|
|
56
60
|
DATABASE_PATH: TEST_DB_PATH,
|
|
57
|
-
API_KEY:
|
|
61
|
+
API_KEY: TEST_API_KEY,
|
|
58
62
|
CAPABILITIES: "core",
|
|
59
63
|
SLACK_BOT_TOKEN: "",
|
|
60
64
|
GITHUB_WEBHOOK_SECRET: "",
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the runtime-config flags that control whether GitHub unassign and
|
|
3
|
+
* review-request-removed events terminate the linked swarm task.
|
|
4
|
+
*
|
|
5
|
+
* Flags (scope "global"):
|
|
6
|
+
* github.cancelOnUnassign — PR unassigned + issue unassigned
|
|
7
|
+
* github.cancelOnReviewRequestRemoved — PR review_request_removed
|
|
8
|
+
*
|
|
9
|
+
* Absent / any value ≠ "false" → cancel (current behavior, default).
|
|
10
|
+
* Value "false" → leave task untouched, return { created: false }.
|
|
11
|
+
*/
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { unlink } from "node:fs/promises";
|
|
14
|
+
import {
|
|
15
|
+
closeDb,
|
|
16
|
+
createAgent,
|
|
17
|
+
createTaskExtended,
|
|
18
|
+
deleteSwarmConfig,
|
|
19
|
+
getDb,
|
|
20
|
+
getSwarmConfigs,
|
|
21
|
+
getTaskById,
|
|
22
|
+
initDb,
|
|
23
|
+
upsertSwarmConfig,
|
|
24
|
+
} from "../be/db";
|
|
25
|
+
import { handleIssue, handlePullRequest } from "../github/handlers";
|
|
26
|
+
import { GITHUB_BOT_NAME } from "../github/mentions";
|
|
27
|
+
import type { IssueEvent, PullRequestEvent } from "../github/types";
|
|
28
|
+
|
|
29
|
+
const TEST_DB_PATH = "./test-github-handlers-cancel-config.sqlite";
|
|
30
|
+
|
|
31
|
+
const BASE_REPO = { full_name: "test/repo", html_url: "https://github.com/test/repo" };
|
|
32
|
+
const BASE_PR = {
|
|
33
|
+
number: 1,
|
|
34
|
+
title: "Test PR",
|
|
35
|
+
body: null as string | null,
|
|
36
|
+
html_url: "https://github.com/test/repo/pull/1",
|
|
37
|
+
user: { login: "sender" },
|
|
38
|
+
head: { ref: "feature", sha: "abc1234567890" },
|
|
39
|
+
base: { ref: "main" },
|
|
40
|
+
merged: false,
|
|
41
|
+
merged_by: undefined,
|
|
42
|
+
};
|
|
43
|
+
const BASE_ISSUE = {
|
|
44
|
+
number: 10,
|
|
45
|
+
title: "Test Issue",
|
|
46
|
+
body: null as string | null,
|
|
47
|
+
html_url: "https://github.com/test/repo/issues/10",
|
|
48
|
+
user: { login: "sender" },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ── Setup ──
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
55
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
56
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
57
|
+
initDb(TEST_DB_PATH);
|
|
58
|
+
createAgent({
|
|
59
|
+
id: "lead-cancel-config-test",
|
|
60
|
+
name: "CancelConfigTestLead",
|
|
61
|
+
status: "idle",
|
|
62
|
+
isLead: true,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterAll(async () => {
|
|
67
|
+
closeDb();
|
|
68
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
69
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
70
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Clear tasks and config rows between tests.
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
const db = getDb();
|
|
76
|
+
db.prepare("DELETE FROM agent_tasks").run();
|
|
77
|
+
// Remove both flag rows so each test starts from a clean slate.
|
|
78
|
+
for (const key of ["github.cancelOnUnassign", "github.cancelOnReviewRequestRemoved"]) {
|
|
79
|
+
const rows = getSwarmConfigs({ scope: "global", key });
|
|
80
|
+
for (const row of rows) deleteSwarmConfig(row.id);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── Helpers ──
|
|
85
|
+
|
|
86
|
+
function seedTask(vcsNumber: number, kind: "pr" | "issue"): string {
|
|
87
|
+
const task = createTaskExtended("test task", {
|
|
88
|
+
agentId: "lead-cancel-config-test",
|
|
89
|
+
source: "github",
|
|
90
|
+
vcsProvider: "github",
|
|
91
|
+
vcsRepo: BASE_REPO.full_name,
|
|
92
|
+
vcsNumber,
|
|
93
|
+
vcsEventType: kind === "pr" ? "pull_request" : "issues",
|
|
94
|
+
});
|
|
95
|
+
return task.id;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function setConfigFlag(key: string, value: string) {
|
|
99
|
+
upsertSwarmConfig({ scope: "global", key, value });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getTaskStatus(taskId: string): string | undefined {
|
|
103
|
+
return getTaskById(taskId)?.status;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── github.cancelOnUnassign — PR unassigned ──
|
|
107
|
+
|
|
108
|
+
describe("PR unassigned — github.cancelOnUnassign", () => {
|
|
109
|
+
test("default (no config row): unassign cancels the task", async () => {
|
|
110
|
+
const taskId = seedTask(BASE_PR.number, "pr");
|
|
111
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
112
|
+
|
|
113
|
+
const event: PullRequestEvent = {
|
|
114
|
+
action: "unassigned",
|
|
115
|
+
pull_request: { ...BASE_PR },
|
|
116
|
+
repository: BASE_REPO,
|
|
117
|
+
sender: { login: "someone" },
|
|
118
|
+
assignee: { login: GITHUB_BOT_NAME, id: 1 },
|
|
119
|
+
};
|
|
120
|
+
const result = await handlePullRequest(event);
|
|
121
|
+
|
|
122
|
+
expect(result.created).toBe(false);
|
|
123
|
+
expect(getTaskStatus(taskId)).toBe("failed");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("config = 'false': unassign leaves task untouched", async () => {
|
|
127
|
+
setConfigFlag("github.cancelOnUnassign", "false");
|
|
128
|
+
const taskId = seedTask(BASE_PR.number, "pr");
|
|
129
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
130
|
+
|
|
131
|
+
const event: PullRequestEvent = {
|
|
132
|
+
action: "unassigned",
|
|
133
|
+
pull_request: { ...BASE_PR },
|
|
134
|
+
repository: BASE_REPO,
|
|
135
|
+
sender: { login: "someone" },
|
|
136
|
+
assignee: { login: GITHUB_BOT_NAME, id: 1 },
|
|
137
|
+
};
|
|
138
|
+
const result = await handlePullRequest(event);
|
|
139
|
+
|
|
140
|
+
expect(result.created).toBe(false);
|
|
141
|
+
// Task must NOT have been failed — still pending.
|
|
142
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── github.cancelOnUnassign — issue unassigned ──
|
|
147
|
+
|
|
148
|
+
describe("issue unassigned — github.cancelOnUnassign", () => {
|
|
149
|
+
test("default (no config row): unassign cancels the task", async () => {
|
|
150
|
+
const taskId = seedTask(BASE_ISSUE.number, "issue");
|
|
151
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
152
|
+
|
|
153
|
+
const event: IssueEvent = {
|
|
154
|
+
action: "unassigned",
|
|
155
|
+
issue: { ...BASE_ISSUE },
|
|
156
|
+
repository: BASE_REPO,
|
|
157
|
+
sender: { login: "someone" },
|
|
158
|
+
assignee: { login: GITHUB_BOT_NAME, id: 1 },
|
|
159
|
+
};
|
|
160
|
+
const result = await handleIssue(event);
|
|
161
|
+
|
|
162
|
+
expect(result.created).toBe(false);
|
|
163
|
+
expect(getTaskStatus(taskId)).toBe("failed");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("config = 'false': unassign leaves task untouched", async () => {
|
|
167
|
+
setConfigFlag("github.cancelOnUnassign", "false");
|
|
168
|
+
const taskId = seedTask(BASE_ISSUE.number, "issue");
|
|
169
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
170
|
+
|
|
171
|
+
const event: IssueEvent = {
|
|
172
|
+
action: "unassigned",
|
|
173
|
+
issue: { ...BASE_ISSUE },
|
|
174
|
+
repository: BASE_REPO,
|
|
175
|
+
sender: { login: "someone" },
|
|
176
|
+
assignee: { login: GITHUB_BOT_NAME, id: 1 },
|
|
177
|
+
};
|
|
178
|
+
const result = await handleIssue(event);
|
|
179
|
+
|
|
180
|
+
expect(result.created).toBe(false);
|
|
181
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ── github.cancelOnReviewRequestRemoved ──
|
|
186
|
+
|
|
187
|
+
describe("PR review_request_removed — github.cancelOnReviewRequestRemoved", () => {
|
|
188
|
+
test("default (no config row): review removal cancels the task", async () => {
|
|
189
|
+
const taskId = seedTask(BASE_PR.number, "pr");
|
|
190
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
191
|
+
|
|
192
|
+
const event: PullRequestEvent = {
|
|
193
|
+
action: "review_request_removed",
|
|
194
|
+
pull_request: { ...BASE_PR },
|
|
195
|
+
repository: BASE_REPO,
|
|
196
|
+
sender: { login: "someone" },
|
|
197
|
+
requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
|
|
198
|
+
};
|
|
199
|
+
const result = await handlePullRequest(event);
|
|
200
|
+
|
|
201
|
+
expect(result.created).toBe(false);
|
|
202
|
+
expect(getTaskStatus(taskId)).toBe("failed");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("config = 'false': review removal leaves task untouched", async () => {
|
|
206
|
+
setConfigFlag("github.cancelOnReviewRequestRemoved", "false");
|
|
207
|
+
const taskId = seedTask(BASE_PR.number, "pr");
|
|
208
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
209
|
+
|
|
210
|
+
const event: PullRequestEvent = {
|
|
211
|
+
action: "review_request_removed",
|
|
212
|
+
pull_request: { ...BASE_PR },
|
|
213
|
+
repository: BASE_REPO,
|
|
214
|
+
sender: { login: "someone" },
|
|
215
|
+
requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
|
|
216
|
+
};
|
|
217
|
+
const result = await handlePullRequest(event);
|
|
218
|
+
|
|
219
|
+
expect(result.created).toBe(false);
|
|
220
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ── Independence: flags do not bleed into each other ──
|
|
225
|
+
|
|
226
|
+
describe("flag independence", () => {
|
|
227
|
+
test("cancelOnUnassign=false does NOT affect review_request_removed (still cancels)", async () => {
|
|
228
|
+
// Only disable the unassign flag; leave review-request flag absent (default = cancel).
|
|
229
|
+
setConfigFlag("github.cancelOnUnassign", "false");
|
|
230
|
+
const taskId = seedTask(BASE_PR.number, "pr");
|
|
231
|
+
|
|
232
|
+
const event: PullRequestEvent = {
|
|
233
|
+
action: "review_request_removed",
|
|
234
|
+
pull_request: { ...BASE_PR },
|
|
235
|
+
repository: BASE_REPO,
|
|
236
|
+
sender: { login: "someone" },
|
|
237
|
+
requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
|
|
238
|
+
};
|
|
239
|
+
await handlePullRequest(event);
|
|
240
|
+
|
|
241
|
+
// review_request_removed STILL cancels because its own flag is absent (default true).
|
|
242
|
+
expect(getTaskStatus(taskId)).toBe("failed");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("cancelOnReviewRequestRemoved=false does NOT affect unassign (still cancels)", async () => {
|
|
246
|
+
// Only disable the review-request flag; leave unassign flag absent (default = cancel).
|
|
247
|
+
setConfigFlag("github.cancelOnReviewRequestRemoved", "false");
|
|
248
|
+
const taskId = seedTask(BASE_PR.number, "pr");
|
|
249
|
+
|
|
250
|
+
const event: PullRequestEvent = {
|
|
251
|
+
action: "unassigned",
|
|
252
|
+
pull_request: { ...BASE_PR },
|
|
253
|
+
repository: BASE_REPO,
|
|
254
|
+
sender: { login: "someone" },
|
|
255
|
+
assignee: { login: GITHUB_BOT_NAME, id: 1 },
|
|
256
|
+
};
|
|
257
|
+
await handlePullRequest(event);
|
|
258
|
+
|
|
259
|
+
// unassigned STILL cancels because its own flag is absent (default true).
|
|
260
|
+
expect(getTaskStatus(taskId)).toBe("failed");
|
|
261
|
+
});
|
|
262
|
+
});
|