@desplega.ai/agent-swarm 1.73.0 → 1.73.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 +1 -1
- package/package.json +1 -1
- package/src/oauth/ensure-token.ts +26 -9
- package/src/oauth/keepalive.ts +21 -11
- package/src/slack/blocks.ts +4 -6
- package/src/tests/ensure-token.test.ts +33 -1
- package/src/tests/slack-blocks.test.ts +14 -14
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.
|
|
5
|
+
"version": "1.73.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": [
|
package/package.json
CHANGED
|
@@ -26,11 +26,35 @@ function getOAuthConfig(provider: string): OAuthProviderConfig | null {
|
|
|
26
26
|
* If the token is expiring soon, attempt to refresh it.
|
|
27
27
|
* Call this before any API interaction with an OAuth-protected service.
|
|
28
28
|
*
|
|
29
|
+
* Reactive variant — never throws. Refresh failures are logged so a single
|
|
30
|
+
* dead-token incident doesn't tear down an unrelated request path. Use
|
|
31
|
+
* {@link ensureTokenOrThrow} from keepalive contexts where you want a dead
|
|
32
|
+
* refresh token to surface as an alert.
|
|
33
|
+
*
|
|
29
34
|
* @param bufferMs - How far ahead to check for expiry. Default 5 min (reactive use).
|
|
30
35
|
* Keepalive callers should pass a larger value (e.g. 13h) to force
|
|
31
36
|
* a proactive refresh well before the token actually expires.
|
|
32
37
|
*/
|
|
33
38
|
export async function ensureToken(provider: string, bufferMs?: number): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
await ensureTokenOrThrow(provider, bufferMs);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error(
|
|
43
|
+
`[OAuth] Failed to refresh ${provider} token:`,
|
|
44
|
+
err instanceof Error ? err.message : err,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Strict variant of {@link ensureToken}: throws on refresh failure of a
|
|
51
|
+
* configured provider so callers (keepalive, alerting) can react.
|
|
52
|
+
*
|
|
53
|
+
* Stays silent (no throw) when the provider isn't configured or no refresh
|
|
54
|
+
* token is stored — those are "not connected" states, not failures, and
|
|
55
|
+
* shouldn't page anyone.
|
|
56
|
+
*/
|
|
57
|
+
export async function ensureTokenOrThrow(provider: string, bufferMs?: number): Promise<void> {
|
|
34
58
|
if (!isTokenExpiringSoon(provider, bufferMs)) return;
|
|
35
59
|
|
|
36
60
|
const config = getOAuthConfig(provider);
|
|
@@ -42,13 +66,6 @@ export async function ensureToken(provider: string, bufferMs?: number): Promise<
|
|
|
42
66
|
return;
|
|
43
67
|
}
|
|
44
68
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
console.log(`[OAuth] ${provider} token refreshed successfully`);
|
|
48
|
-
} catch (err) {
|
|
49
|
-
console.error(
|
|
50
|
-
`[OAuth] Failed to refresh ${provider} token:`,
|
|
51
|
-
err instanceof Error ? err.message : err,
|
|
52
|
-
);
|
|
53
|
-
}
|
|
69
|
+
await refreshAccessToken(config, tokens.refreshToken);
|
|
70
|
+
console.log(`[OAuth] ${provider} token refreshed successfully`);
|
|
54
71
|
}
|
package/src/oauth/keepalive.ts
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ensureTokenOrThrow } from "./ensure-token";
|
|
2
2
|
|
|
3
3
|
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
|
|
4
4
|
const THIRTEEN_HOURS_MS = 13 * 60 * 60 * 1000;
|
|
5
5
|
const SLACK_ALERTS_CHANNEL = process.env.SLACK_ALERTS_CHANNEL || "C08JCRURPBV";
|
|
6
6
|
|
|
7
|
+
const KEEPALIVE_PROVIDERS = ["linear", "jira"] as const;
|
|
8
|
+
|
|
7
9
|
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Proactively refresh OAuth tokens on a schedule to prevent expiry.
|
|
11
13
|
* If refresh fails, posts a Slack notification so someone can re-auth manually.
|
|
14
|
+
*
|
|
15
|
+
* Why both Linear and Jira: Atlassian rotates refresh tokens and expires them
|
|
16
|
+
* after 90 days of inactivity, so a swarm that doesn't touch Jira for a long
|
|
17
|
+
* stretch will silently lose the ability to refresh. Touching the token every
|
|
18
|
+
* 12h keeps the refresh token alive and surfaces a dead one as an alert
|
|
19
|
+
* instead of a runtime 401.
|
|
12
20
|
*/
|
|
13
21
|
async function runKeepalive(): Promise<void> {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
for (const provider of KEEPALIVE_PROVIDERS) {
|
|
23
|
+
console.log(`[OAuth Keepalive] Running scheduled token refresh for ${provider}...`);
|
|
24
|
+
try {
|
|
25
|
+
await ensureTokenOrThrow(provider, THIRTEEN_HOURS_MS);
|
|
26
|
+
console.log(`[OAuth Keepalive] ${provider} token check completed successfully`);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
29
|
+
console.error(`[OAuth Keepalive] Failed to refresh ${provider} token: ${message}`);
|
|
30
|
+
await notifySlack(
|
|
31
|
+
`⚠️ *OAuth Keepalive Failed*\nProvider: \`${provider}\`\nError: ${message}\n\nManual re-authorization may be required.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
24
34
|
}
|
|
25
35
|
}
|
|
26
36
|
|
package/src/slack/blocks.ts
CHANGED
|
@@ -349,12 +349,10 @@ function renderTree(root: TreeNode): string {
|
|
|
349
349
|
const visibleChildren = root.children.slice(0, MAX_VISIBLE_CHILDREN);
|
|
350
350
|
const hiddenCount = root.children.length - visibleChildren.length;
|
|
351
351
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const isLast = i === visibleChildren.length - 1 && hiddenCount === 0;
|
|
355
|
-
const prefix = isLast ? "└ " : "├ ";
|
|
356
|
-
const continuationPrefix = isLast ? " " : "│ ";
|
|
352
|
+
const prefix = "↳ ";
|
|
353
|
+
const continuationPrefix = " ";
|
|
357
354
|
|
|
355
|
+
for (const child of visibleChildren) {
|
|
358
356
|
lines.push(`${prefix}${renderNodeLine(child)}`);
|
|
359
357
|
|
|
360
358
|
for (const detail of renderChildDetail(child, continuationPrefix)) {
|
|
@@ -363,7 +361,7 @@ function renderTree(root: TreeNode): string {
|
|
|
363
361
|
}
|
|
364
362
|
|
|
365
363
|
if (hiddenCount > 0) {
|
|
366
|
-
lines.push(
|
|
364
|
+
lines.push(`↳ _and ${hiddenCount} more..._`);
|
|
367
365
|
}
|
|
368
366
|
|
|
369
367
|
return lines.join("\n");
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
storeOAuthTokens,
|
|
8
8
|
upsertOAuthApp,
|
|
9
9
|
} from "../be/db-queries/oauth";
|
|
10
|
-
import { ensureToken } from "../oauth/ensure-token";
|
|
10
|
+
import { ensureToken, ensureTokenOrThrow } from "../oauth/ensure-token";
|
|
11
11
|
|
|
12
12
|
const TEST_DB_PATH = "./test-ensure-token.sqlite";
|
|
13
13
|
|
|
@@ -202,3 +202,35 @@ describe("ensureToken", () => {
|
|
|
202
202
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
|
+
|
|
206
|
+
describe("ensureTokenOrThrow", () => {
|
|
207
|
+
test("throws when refresh fails for a configured provider (so keepalive can alert)", async () => {
|
|
208
|
+
storeOAuthTokens("test-provider", {
|
|
209
|
+
accessToken: "old-token",
|
|
210
|
+
refreshToken: "refresh-token",
|
|
211
|
+
expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
globalThis.fetch = mock(() =>
|
|
215
|
+
Promise.resolve(
|
|
216
|
+
new Response('{"error":"invalid_grant"}', {
|
|
217
|
+
status: 400,
|
|
218
|
+
headers: { "Content-Type": "application/json" },
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
await expect(ensureTokenOrThrow("test-provider")).rejects.toThrow(/Token refresh failed/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("stays silent (no throw) when no refresh token is stored", async () => {
|
|
227
|
+
deleteOAuthTokens("test-provider");
|
|
228
|
+
|
|
229
|
+
// "Not connected" should not page anyone
|
|
230
|
+
await expect(ensureTokenOrThrow("test-provider")).resolves.toBeUndefined();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("stays silent (no throw) when provider is not configured", async () => {
|
|
234
|
+
await expect(ensureTokenOrThrow("nonexistent-provider")).resolves.toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -616,14 +616,14 @@ describe("buildTreeBlocks", () => {
|
|
|
616
616
|
|
|
617
617
|
// Root line
|
|
618
618
|
expect(lines[0]).toContain("⏳ *Lead*");
|
|
619
|
-
// Worker1 line with
|
|
620
|
-
expect(lines[1]).toMatch(
|
|
621
|
-
// Worker1 progress indented under continuation
|
|
622
|
-
expect(lines[2]).toMatch(
|
|
623
|
-
// Worker2 line with
|
|
624
|
-
expect(lines[3]).toMatch(
|
|
619
|
+
// Worker1 line with ↳ prefix
|
|
620
|
+
expect(lines[1]).toMatch(/^↳ ⏳ \*Worker1\*/);
|
|
621
|
+
// Worker1 progress indented under continuation (3 spaces, aligned under ↳ )
|
|
622
|
+
expect(lines[2]).toMatch(/^ {3}Fetching data\.\.\.$/);
|
|
623
|
+
// Worker2 line with ↳ prefix
|
|
624
|
+
expect(lines[3]).toMatch(/^↳ ⏳ \*Worker2\*/);
|
|
625
625
|
// Worker2 progress indented
|
|
626
|
-
expect(lines[4]).toMatch(/^ {
|
|
626
|
+
expect(lines[4]).toMatch(/^ {3}Compiling\.\.\.$/);
|
|
627
627
|
});
|
|
628
628
|
|
|
629
629
|
test("max children collapse (9+ children -> 8 shown + 'and 1 more...')", () => {
|
|
@@ -654,8 +654,8 @@ describe("buildTreeBlocks", () => {
|
|
|
654
654
|
expect(text).toContain("*Worker8*");
|
|
655
655
|
expect(text).not.toContain("*Worker9*");
|
|
656
656
|
expect(text).toContain("and 1 more...");
|
|
657
|
-
// The "and N more..." line uses
|
|
658
|
-
expect(lines[lines.length - 1]).toContain("
|
|
657
|
+
// The "and N more..." line uses ↳ prefix
|
|
658
|
+
expect(lines[lines.length - 1]).toContain("↳ _and 1 more..._");
|
|
659
659
|
});
|
|
660
660
|
|
|
661
661
|
test("max children collapse with many hidden", () => {
|
|
@@ -837,7 +837,7 @@ describe("buildTreeBlocks", () => {
|
|
|
837
837
|
expect(blocks.length).toBe(1);
|
|
838
838
|
});
|
|
839
839
|
|
|
840
|
-
test("tree
|
|
840
|
+
test("tree indent: all children use ↳ prefix", () => {
|
|
841
841
|
const root: TreeNode = {
|
|
842
842
|
taskId: makeTaskId("nnnn0001"),
|
|
843
843
|
agentName: "Lead",
|
|
@@ -867,10 +867,10 @@ describe("buildTreeBlocks", () => {
|
|
|
867
867
|
const blocks = buildTreeBlocks([root]);
|
|
868
868
|
const lines = blocks[0].text.text.split("\n");
|
|
869
869
|
|
|
870
|
-
//
|
|
871
|
-
expect(lines[1]).toMatch(
|
|
872
|
-
expect(lines[2]).toMatch(
|
|
873
|
-
expect(lines[3]).toMatch(
|
|
870
|
+
// All children use ↳ (no branching distinction in proportional fonts)
|
|
871
|
+
expect(lines[1]).toMatch(/^↳ /);
|
|
872
|
+
expect(lines[2]).toMatch(/^↳ /);
|
|
873
|
+
expect(lines[3]).toMatch(/^↳ /);
|
|
874
874
|
});
|
|
875
875
|
|
|
876
876
|
test("completed root with output (no slackReplySent, no children)", () => {
|