@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 +3 -3
- package/package.json +1 -1
- package/src/http/config.ts +15 -3
- package/src/http/core.ts +108 -0
- package/src/tests/reload-config.test.ts +143 -3
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.
|
|
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
package/src/http/config.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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 {
|
|
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
|
+
});
|