@desplega.ai/agent-swarm 1.76.1 → 1.76.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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.76.1",
5
+ "version": "1.76.2",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -1781,7 +1781,7 @@
1781
1781
  }
1782
1782
  },
1783
1783
  "delete": {
1784
- "summary": "Delete a config entry by ID (including legacy reserved rows for cleanup)",
1784
+ "summary": "Delete a config entry by ID (including legacy reserved rows for cleanup). Global-scope deletes auto-trigger an integrations reload.",
1785
1785
  "tags": [
1786
1786
  "Config"
1787
1787
  ],
@@ -1858,7 +1858,7 @@
1858
1858
  }
1859
1859
  },
1860
1860
  "put": {
1861
- "summary": "Create or update a config entry (reserved env-only keys are rejected)",
1861
+ "summary": "Create or update a config entry (reserved env-only keys are rejected). Global-scope writes auto-trigger an integrations reload (debounced ~250ms) so Slack/GitHub/Linear/Jira/AgentMail pick up new credentials without an explicit /api/config/reload call.",
1862
1862
  "tags": [
1863
1863
  "Config"
1864
1864
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.76.1",
3
+ "version": "1.76.2",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -14,7 +14,7 @@ import {
14
14
  reservedKeyError,
15
15
  validateConfigValue,
16
16
  } from "../be/swarm-config-guard";
17
- import { reloadGlobalConfigsAndIntegrations } from "./core";
17
+ import { reloadGlobalConfigsAndIntegrations, scheduleIntegrationsReload } from "./core";
18
18
  import { route } from "./route-def";
19
19
  import { json, jsonError } from "./utils";
20
20
 
@@ -104,7 +104,8 @@ const upsertConfig = route({
104
104
  method: "put",
105
105
  path: "/api/config",
106
106
  pattern: ["api", "config"],
107
- summary: "Create or update a config entry (reserved env-only keys are rejected)",
107
+ summary:
108
+ "Create or update a config entry (reserved env-only keys are rejected). Global-scope writes auto-trigger an integrations reload (debounced ~250ms) so Slack/GitHub/Linear/Jira/AgentMail pick up new credentials without an explicit /api/config/reload call.",
108
109
  tags: ["Config"],
109
110
  body: z.object({
110
111
  scope: z.enum(["global", "agent", "repo"]),
@@ -125,7 +126,8 @@ const deleteConfig = route({
125
126
  method: "delete",
126
127
  path: "/api/config/{id}",
127
128
  pattern: ["api", "config", null],
128
- summary: "Delete a config entry by ID (including legacy reserved rows for cleanup)",
129
+ summary:
130
+ "Delete a config entry by ID (including legacy reserved rows for cleanup). Global-scope deletes auto-trigger an integrations reload.",
129
131
  tags: ["Config"],
130
132
  params: z.object({ id: z.string() }),
131
133
  responses: {
@@ -251,6 +253,13 @@ export async function handleConfig(
251
253
  envPath: envPath || null,
252
254
  description: description || null,
253
255
  });
256
+ // Auto-reload integrations when a global-scoped config changes so callers
257
+ // (CLI, automation, dashboard) don't have to remember to hit /reload.
258
+ // Debounced to coalesce sequential single-key upserts from the dashboard
259
+ // batch save (no bulk endpoint exists).
260
+ if (scope === "global") {
261
+ scheduleIntegrationsReload();
262
+ }
254
263
  const result = includeSecrets || !config.isSecret ? config : maskSecrets([config])[0];
255
264
  json(res, result);
256
265
  } catch (_error) {
@@ -272,6 +281,9 @@ export async function handleConfig(
272
281
  jsonError(res, "Config not found", 404);
273
282
  return true;
274
283
  }
284
+ if (existing.scope === "global") {
285
+ scheduleIntegrationsReload();
286
+ }
275
287
  json(res, { success: true });
276
288
  return true;
277
289
  }
package/src/http/core.ts CHANGED
@@ -84,6 +84,114 @@ export async function reloadGlobalConfigsAndIntegrations(): Promise<ReloadConfig
84
84
  };
85
85
  }
86
86
 
87
+ // ─── Auto-reload debouncer ────────────────────────────────────────────────────
88
+ // Why this exists: the integrations dashboard saves a row at a time (no bulk
89
+ // endpoint — see ui/src/api/hooks/use-config-api.ts useUpsertConfigsBatch),
90
+ // so a "save" of N keys produces N upsert calls in tight succession. Reloading
91
+ // after each one would tear Slack's socket down N times. Coalesce instead.
92
+ let pendingReloadTimer: ReturnType<typeof setTimeout> | null = null;
93
+ let inFlightReload: Promise<ReloadConfigResult> | null = null;
94
+ let reloadRerunRequested = false;
95
+ let autoReloadInvocations = 0;
96
+ const AUTO_RELOAD_DEBOUNCE_MS = 250;
97
+
98
+ /**
99
+ * Schedule a coalesced integrations reload. Repeated calls within the debounce
100
+ * window collapse into a single reload. If a reload is currently running, the
101
+ * scheduler defers the next one until it finishes (so a save during a reload
102
+ * still re-runs once afterwards).
103
+ *
104
+ * Fire-and-forget — failures are logged and swallowed so callers (HTTP handlers)
105
+ * don't have to await the reload before responding.
106
+ */
107
+ export function scheduleIntegrationsReload(delayMs = AUTO_RELOAD_DEBOUNCE_MS): void {
108
+ if (inFlightReload) {
109
+ reloadRerunRequested = true;
110
+ return;
111
+ }
112
+ if (pendingReloadTimer) {
113
+ clearTimeout(pendingReloadTimer);
114
+ }
115
+ pendingReloadTimer = setTimeout(() => {
116
+ pendingReloadTimer = null;
117
+ autoReloadInvocations += 1;
118
+ inFlightReload = reloadGlobalConfigsAndIntegrations()
119
+ .then((r) => {
120
+ console.log(
121
+ `[auto-reload] Loaded ${r.configsLoaded} config(s), re-initialized: ${r.integrationsReinitialized.join(", ") || "none"}`,
122
+ );
123
+ return r;
124
+ })
125
+ .catch((err) => {
126
+ const message = err instanceof Error ? err.message : String(err);
127
+ console.error("[auto-reload] Failed:", message);
128
+ throw err;
129
+ })
130
+ .finally(() => {
131
+ inFlightReload = null;
132
+ if (reloadRerunRequested) {
133
+ reloadRerunRequested = false;
134
+ scheduleIntegrationsReload(delayMs);
135
+ }
136
+ });
137
+ }, delayMs);
138
+ }
139
+
140
+ /**
141
+ * For tests + shutdown: cancel any pending timer and await any in-flight
142
+ * reload. Returns once the queue is fully drained.
143
+ */
144
+ export async function flushPendingIntegrationsReload(): Promise<void> {
145
+ if (pendingReloadTimer) {
146
+ clearTimeout(pendingReloadTimer);
147
+ pendingReloadTimer = null;
148
+ autoReloadInvocations += 1;
149
+ inFlightReload = reloadGlobalConfigsAndIntegrations()
150
+ .catch((err) => {
151
+ const message = err instanceof Error ? err.message : String(err);
152
+ console.error("[auto-reload] flush failed:", message);
153
+ throw err;
154
+ })
155
+ .finally(() => {
156
+ inFlightReload = null;
157
+ }) as Promise<ReloadConfigResult>;
158
+ }
159
+ if (inFlightReload) {
160
+ try {
161
+ await inFlightReload;
162
+ } catch {
163
+ // Already logged; flush should not throw on caller's path.
164
+ }
165
+ }
166
+ // Drain any reruns queued while we were awaiting.
167
+ while (reloadRerunRequested) {
168
+ reloadRerunRequested = false;
169
+ autoReloadInvocations += 1;
170
+ inFlightReload = reloadGlobalConfigsAndIntegrations()
171
+ .catch(() => null)
172
+ .finally(() => {
173
+ inFlightReload = null;
174
+ }) as Promise<ReloadConfigResult>;
175
+ await inFlightReload;
176
+ }
177
+ }
178
+
179
+ // ─── Test helpers (stable surface for src/tests/) ─────────────────────────────
180
+ // Module state is intentionally process-global; tests need to reset it between
181
+ // cases to avoid cross-contamination. Not part of the public HTTP API.
182
+ export function _autoReloadStatsForTests(): { invocations: number; pending: boolean } {
183
+ return { invocations: autoReloadInvocations, pending: pendingReloadTimer !== null };
184
+ }
185
+ export function _resetAutoReloadForTests(): void {
186
+ if (pendingReloadTimer) {
187
+ clearTimeout(pendingReloadTimer);
188
+ pendingReloadTimer = null;
189
+ }
190
+ inFlightReload = null;
191
+ reloadRerunRequested = false;
192
+ autoReloadInvocations = 0;
193
+ }
194
+
87
195
  export async function handleCore(
88
196
  req: IncomingMessage,
89
197
  res: ServerResponse,
@@ -1,10 +1,16 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { createServer as createHttpServer, type Server } from "node:http";
4
4
  import { initAgentMail, resetAgentMail } from "../agentmail";
5
- import { closeDb, getDb, initDb, upsertSwarmConfig } from "../be/db";
5
+ import { closeDb, deleteSwarmConfig, getDb, initDb, upsertSwarmConfig } from "../be/db";
6
6
  import { initGitHub, resetGitHub } from "../github";
7
- import { loadGlobalConfigsIntoEnv } from "../http/core";
7
+ import {
8
+ _autoReloadStatsForTests,
9
+ _resetAutoReloadForTests,
10
+ flushPendingIntegrationsReload,
11
+ loadGlobalConfigsIntoEnv,
12
+ scheduleIntegrationsReload,
13
+ } from "../http/core";
8
14
 
9
15
  const TEST_DB_PATH = "./test-reload-config.sqlite";
10
16
  const TEST_PORT = 13023;
@@ -199,3 +205,137 @@ describe("reload-config", () => {
199
205
  expect(res.status).toBe(404);
200
206
  });
201
207
  });
208
+
209
+ describe("auto-reload debouncer", () => {
210
+ // The reload calls into stopSlackApp/startSlackApp + GH/Linear/Jira/AgentMail
211
+ // init. They are no-ops without credentials, so we explicitly disable Slack
212
+ // (it has its own DISABLE switch) and rely on the others being unconfigured.
213
+ let originalSlackDisable: string | undefined;
214
+
215
+ beforeAll(() => {
216
+ originalSlackDisable = process.env.SLACK_DISABLE;
217
+ process.env.SLACK_DISABLE = "true";
218
+ });
219
+
220
+ afterAll(() => {
221
+ if (originalSlackDisable === undefined) {
222
+ delete process.env.SLACK_DISABLE;
223
+ } else {
224
+ process.env.SLACK_DISABLE = originalSlackDisable;
225
+ }
226
+ });
227
+
228
+ beforeEach(() => {
229
+ _resetAutoReloadForTests();
230
+ });
231
+
232
+ test("scheduleIntegrationsReload runs reload after the debounce window", async () => {
233
+ const testKey = `__TEST_AUTO_RELOAD_RUNS_${Date.now()}`;
234
+ upsertSwarmConfig({ scope: "global", key: testKey, value: "fresh" });
235
+ delete process.env[testKey];
236
+
237
+ scheduleIntegrationsReload(50);
238
+ expect(_autoReloadStatsForTests().pending).toBe(true);
239
+
240
+ await flushPendingIntegrationsReload();
241
+
242
+ expect(_autoReloadStatsForTests().invocations).toBe(1);
243
+ expect(process.env[testKey]).toBe("fresh");
244
+
245
+ delete process.env[testKey];
246
+ });
247
+
248
+ test("rapid scheduleIntegrationsReload calls coalesce into one reload", async () => {
249
+ const testKey = `__TEST_COALESCE_${Date.now()}`;
250
+ upsertSwarmConfig({ scope: "global", key: testKey, value: "v1" });
251
+
252
+ scheduleIntegrationsReload(100);
253
+ scheduleIntegrationsReload(100);
254
+ scheduleIntegrationsReload(100);
255
+ scheduleIntegrationsReload(100);
256
+
257
+ expect(_autoReloadStatsForTests().invocations).toBe(0);
258
+
259
+ await flushPendingIntegrationsReload();
260
+
261
+ expect(_autoReloadStatsForTests().invocations).toBe(1);
262
+
263
+ delete process.env[testKey];
264
+ });
265
+
266
+ test("schedule during in-flight reload triggers exactly one rerun", async () => {
267
+ const testKey = `__TEST_RERUN_${Date.now()}`;
268
+ upsertSwarmConfig({ scope: "global", key: testKey, value: "first" });
269
+
270
+ scheduleIntegrationsReload(20);
271
+ // Wait just past the debounce so the first reload is in-flight, then
272
+ // schedule again. The second call should defer to a rerun, not a parallel
273
+ // reload.
274
+ await new Promise((r) => setTimeout(r, 25));
275
+ scheduleIntegrationsReload(20);
276
+ scheduleIntegrationsReload(20); // collapses with the rerun-pending flag
277
+
278
+ await flushPendingIntegrationsReload();
279
+
280
+ // First run + one rerun = 2 invocations total.
281
+ expect(_autoReloadStatsForTests().invocations).toBe(2);
282
+
283
+ delete process.env[testKey];
284
+ });
285
+
286
+ test("flushPendingIntegrationsReload is a no-op when nothing is queued", async () => {
287
+ expect(_autoReloadStatsForTests().pending).toBe(false);
288
+ await flushPendingIntegrationsReload();
289
+ expect(_autoReloadStatsForTests().invocations).toBe(0);
290
+ });
291
+
292
+ test("auto-reload picks up a brand-new config row at runtime", async () => {
293
+ const testKey = `__TEST_NEW_ROW_${Date.now()}`;
294
+ delete process.env[testKey];
295
+
296
+ // Simulate the upsert path's behavior: write the row, then schedule.
297
+ upsertSwarmConfig({ scope: "global", key: testKey, value: "live-update" });
298
+ scheduleIntegrationsReload(20);
299
+
300
+ await flushPendingIntegrationsReload();
301
+
302
+ expect(process.env[testKey]).toBe("live-update");
303
+ delete process.env[testKey];
304
+ });
305
+
306
+ test("auto-reload reflects an updated value (override semantics)", async () => {
307
+ const testKey = `__TEST_OVERRIDE_LIVE_${Date.now()}`;
308
+ process.env[testKey] = "shipped-by-deploy";
309
+
310
+ // Pre-existing env should win at startup, but reload uses override=true.
311
+ upsertSwarmConfig({ scope: "global", key: testKey, value: "from-config" });
312
+ scheduleIntegrationsReload(20);
313
+
314
+ await flushPendingIntegrationsReload();
315
+
316
+ expect(process.env[testKey]).toBe("from-config");
317
+ delete process.env[testKey];
318
+ });
319
+
320
+ test("delete + reload removes value from active env (well, doesn't re-inject it)", async () => {
321
+ const testKey = `__TEST_DELETE_${Date.now()}`;
322
+ delete process.env[testKey];
323
+
324
+ const config = upsertSwarmConfig({ scope: "global", key: testKey, value: "to-be-deleted" });
325
+ scheduleIntegrationsReload(20);
326
+ await flushPendingIntegrationsReload();
327
+ expect(process.env[testKey]).toBe("to-be-deleted");
328
+
329
+ deleteSwarmConfig(config.id);
330
+ // Mimic the delete handler in src/http/config.ts.
331
+ scheduleIntegrationsReload(20);
332
+ await flushPendingIntegrationsReload();
333
+
334
+ // Caveat: process.env keeps the previously-injected value. Reload only
335
+ // overwrites keys that still exist in DB. This test pins that behavior so
336
+ // anyone changing the loader has to make a deliberate decision about
337
+ // whether to also unset removed keys.
338
+ expect(process.env[testKey]).toBe("to-be-deleted");
339
+ delete process.env[testKey];
340
+ });
341
+ });