@desplega.ai/agent-swarm 1.74.4 → 1.76.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.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +1264 -46
  3. package/package.json +2 -2
  4. package/src/be/db.ts +563 -9
  5. package/src/be/memory/edges-store.ts +69 -0
  6. package/src/be/memory/providers/sqlite-store.ts +4 -0
  7. package/src/be/memory/raters/explicit-self.ts +22 -0
  8. package/src/be/memory/raters/implicit-citation.ts +44 -0
  9. package/src/be/memory/raters/llm-client.ts +172 -0
  10. package/src/be/memory/raters/llm-summarizer.ts +218 -0
  11. package/src/be/memory/raters/llm.ts +375 -0
  12. package/src/be/memory/raters/noop.ts +14 -0
  13. package/src/be/memory/raters/registry.ts +86 -0
  14. package/src/be/memory/raters/retrieval.ts +88 -0
  15. package/src/be/memory/raters/run-server-raters.ts +97 -0
  16. package/src/be/memory/raters/store.ts +228 -0
  17. package/src/be/memory/raters/types.ts +101 -0
  18. package/src/be/memory/reranker.ts +32 -2
  19. package/src/be/memory/retrieval-store.ts +116 -0
  20. package/src/be/memory/types.ts +3 -0
  21. package/src/be/migrations/051_memory_posteriors_and_retrieval.sql +67 -0
  22. package/src/be/migrations/052_memory_edges.sql +36 -0
  23. package/src/be/migrations/053_agent_waiting_for_credentials_status.sql +61 -0
  24. package/src/be/migrations/054_agent_harness_provider.sql +21 -0
  25. package/src/be/migrations/055_agent_cred_status.sql +15 -0
  26. package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
  27. package/src/be/migrations/057_inbox_item_state.sql +27 -0
  28. package/src/be/migrations/058_task_templates.sql +31 -0
  29. package/src/be/swarm-config-guard.ts +24 -0
  30. package/src/commands/credential-wait.ts +186 -0
  31. package/src/commands/provider-credentials.ts +434 -0
  32. package/src/commands/runner.ts +253 -21
  33. package/src/hooks/hook.ts +143 -66
  34. package/src/http/agents.ts +191 -1
  35. package/src/http/config.ts +11 -1
  36. package/src/http/core.ts +5 -0
  37. package/src/http/inbox-state.ts +89 -0
  38. package/src/http/index.ts +10 -0
  39. package/src/http/memory.ts +230 -1
  40. package/src/http/sessions.ts +86 -0
  41. package/src/http/status.ts +665 -0
  42. package/src/http/task-templates.ts +51 -0
  43. package/src/http/tasks.ts +85 -5
  44. package/src/http/users.ts +134 -0
  45. package/src/prompts/memories.ts +62 -0
  46. package/src/providers/claude-adapter.ts +22 -0
  47. package/src/providers/claude-managed-adapter.ts +24 -0
  48. package/src/providers/codex-adapter.ts +43 -1
  49. package/src/providers/devin-adapter.ts +18 -0
  50. package/src/providers/index.ts +7 -0
  51. package/src/providers/opencode-adapter.ts +60 -0
  52. package/src/providers/pi-mono-adapter.ts +71 -0
  53. package/src/providers/types.ts +34 -0
  54. package/src/server.ts +2 -0
  55. package/src/slack/handlers.ts +0 -1
  56. package/src/tests/agents-harness-provider.test.ts +333 -0
  57. package/src/tests/credential-check.test.ts +367 -0
  58. package/src/tests/credential-status-api.test.ts +223 -0
  59. package/src/tests/credential-status-routing.test.ts +150 -0
  60. package/src/tests/credential-wait.test.ts +282 -0
  61. package/src/tests/harness-provider-resolution.test.ts +242 -0
  62. package/src/tests/jira-sync.test.ts +1 -1
  63. package/src/tests/memory-edges.test.ts +722 -0
  64. package/src/tests/memory-rate-endpoint.test.ts +330 -0
  65. package/src/tests/memory-rate-tool.test.ts +252 -0
  66. package/src/tests/memory-rater-e2e.test.ts +578 -0
  67. package/src/tests/memory-rater-implicit-citation.test.ts +304 -0
  68. package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
  69. package/src/tests/memory-rater-llm.test.ts +964 -0
  70. package/src/tests/memory-rater-store.test.ts +249 -0
  71. package/src/tests/memory-reranker.test.ts +161 -2
  72. package/src/tests/migration-runner-regressions.test.ts +17 -2
  73. package/src/tests/mocks/mock-llm-rater-client.ts +35 -0
  74. package/src/tests/run-server-raters.test.ts +291 -0
  75. package/src/tests/sessions.test.ts +141 -0
  76. package/src/tests/status.test.ts +843 -0
  77. package/src/tests/stop-hook-task-resolution.test.ts +98 -0
  78. package/src/tests/template-recommendations.test.ts +148 -0
  79. package/src/tests/tool-annotations.test.ts +2 -2
  80. package/src/tests/use-dismissible-card.test.ts +140 -0
  81. package/src/tools/memory-rate.ts +166 -0
  82. package/src/tools/memory-search.ts +18 -0
  83. package/src/tools/store-progress.ts +37 -0
  84. package/src/tools/swarm-config/set-config.ts +17 -1
  85. package/src/tools/tool-config.ts +1 -0
  86. package/src/types.ts +122 -1
  87. package/src/utils/harness-provider.ts +32 -0
  88. package/tsconfig.json +0 -2
@@ -0,0 +1,843 @@
1
+ /**
2
+ * Unit tests for the Phase 1 `/status` endpoint and its DB helpers.
3
+ *
4
+ * Coverage:
5
+ * - Identity: defaults vs. all-envs-set.
6
+ * - Setup state machine: per-milestone permutations (env presence,
7
+ * OAuth row presence, Jira cloudId, agent activity, completed task).
8
+ * - DB helpers: `getLiveAgentCounts`, `getInstanceActivity`,
9
+ * `hasFirstCompletedTask`.
10
+ * - `validateProviderCredentials` error sanitization (mocked fetch).
11
+ * - Test-connection cache flips harness.state to `verified`.
12
+ */
13
+
14
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
16
+ import { unlink } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import {
20
+ closeDb,
21
+ createAgent,
22
+ getDb,
23
+ getInstanceActivity,
24
+ getLiveAgentCounts,
25
+ hasFirstCompletedTask,
26
+ initDb,
27
+ setAgentHarnessProvider,
28
+ updateAgentActivity,
29
+ updateAgentCredStatus,
30
+ } from "../be/db";
31
+ import { storeOAuthTokens, upsertOAuthApp } from "../be/db-queries/oauth";
32
+ import { validateProviderCredentials } from "../commands/provider-credentials";
33
+ import {
34
+ _resetTestConnectionCache,
35
+ buildStatusPayload,
36
+ computeHealth,
37
+ type SetupMilestone,
38
+ } from "../http/status";
39
+ import type { AgentCredStatus } from "../types";
40
+
41
+ // Helper for tests: stamp an agent row with a cred_status snapshot so the
42
+ // `/status` endpoint sees it. Mirrors what the worker boot loop does via
43
+ // `PUT /api/agents/:id/credential-status` after migration 055.
44
+ function seedCredStatus(
45
+ agentId: string,
46
+ harnessProvider: "claude" | "codex" | "pi" | "devin" | "claude-managed" | "opencode",
47
+ partial: Partial<AgentCredStatus> = {},
48
+ ): void {
49
+ setAgentHarnessProvider(agentId, harnessProvider);
50
+ const now = Date.now();
51
+ updateAgentCredStatus(agentId, {
52
+ ready: true,
53
+ missing: [],
54
+ satisfiedBy: "env",
55
+ hint: null,
56
+ liveTest: null,
57
+ reportedAt: now,
58
+ reportKind: "boot",
59
+ ...partial,
60
+ });
61
+ }
62
+
63
+ const TEST_DB_PATH = "./test-status.sqlite";
64
+
65
+ async function removeDbFiles(path: string): Promise<void> {
66
+ for (const suffix of ["", "-wal", "-shm"]) {
67
+ try {
68
+ await unlink(path + suffix);
69
+ } catch (error) {
70
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
71
+ }
72
+ }
73
+ }
74
+
75
+ // All tests share a fresh DB. Per-test isolation comes from `clearTables` +
76
+ // process.env reset in `beforeEach`.
77
+ const ENV_KEYS_TO_RESET = [
78
+ "SWARM_CLOUD",
79
+ "SWARM_ORG_NAME",
80
+ "SWARM_ORG_LOGO_URL",
81
+ "SWARM_BRAND_COLOR",
82
+ "SWARM_MARKETING_URL",
83
+ "SWARM_HIDE_CLOUD_PROMO",
84
+ "HARNESS_PROVIDER",
85
+ "ANTHROPIC_API_KEY",
86
+ "CLAUDE_CODE_OAUTH_TOKEN",
87
+ "OPENAI_API_KEY",
88
+ "OPENROUTER_API_KEY",
89
+ "CODEX_OAUTH",
90
+ "DEVIN_API_KEY",
91
+ "DEVIN_ORG_ID",
92
+ "SLACK_BOT_TOKEN",
93
+ "SLACK_APP_TOKEN",
94
+ "SLACK_DISABLE",
95
+ "GITHUB_WEBHOOK_SECRET",
96
+ "GITHUB_APP_ID",
97
+ "GITHUB_APP_PRIVATE_KEY",
98
+ "AGENT_FS_API_URL",
99
+ "SWARM_VERIFY_TTL_MS",
100
+ ];
101
+
102
+ const savedEnv = new Map<string, string | undefined>();
103
+
104
+ function snapshotEnv() {
105
+ for (const k of ENV_KEYS_TO_RESET) savedEnv.set(k, process.env[k]);
106
+ }
107
+
108
+ function clearEnv() {
109
+ for (const k of ENV_KEYS_TO_RESET) {
110
+ delete process.env[k];
111
+ }
112
+ }
113
+
114
+ function restoreEnv() {
115
+ for (const k of ENV_KEYS_TO_RESET) {
116
+ const v = savedEnv.get(k);
117
+ if (v === undefined) delete process.env[k];
118
+ else process.env[k] = v;
119
+ }
120
+ }
121
+
122
+ function clearTables() {
123
+ const db = getDb();
124
+ db.prepare("DELETE FROM agent_tasks").run();
125
+ db.prepare("DELETE FROM agents").run();
126
+ db.prepare("DELETE FROM oauth_tokens").run();
127
+ db.prepare("DELETE FROM oauth_apps").run();
128
+ }
129
+
130
+ beforeAll(async () => {
131
+ snapshotEnv();
132
+ await removeDbFiles(TEST_DB_PATH);
133
+ initDb(TEST_DB_PATH);
134
+ });
135
+
136
+ afterAll(async () => {
137
+ closeDb();
138
+ await removeDbFiles(TEST_DB_PATH);
139
+ restoreEnv();
140
+ });
141
+
142
+ beforeEach(() => {
143
+ clearEnv();
144
+ clearTables();
145
+ _resetTestConnectionCache();
146
+ });
147
+
148
+ afterEach(() => {
149
+ clearEnv();
150
+ });
151
+
152
+ // ─── Identity ────────────────────────────────────────────────────────────────
153
+
154
+ describe("buildStatusPayload — identity", () => {
155
+ test("defaults when no SWARM_* envs set", () => {
156
+ const payload = buildStatusPayload();
157
+ expect(payload.identity).toEqual({
158
+ name: "Swarm",
159
+ logo_url: null,
160
+ brand_color: null,
161
+ is_cloud: false,
162
+ marketing_url: null,
163
+ hide_cloud_promo: false,
164
+ });
165
+ });
166
+
167
+ test("reflects SWARM_* envs when all set", () => {
168
+ process.env.SWARM_CLOUD = "true";
169
+ process.env.SWARM_ORG_NAME = "Acme";
170
+ process.env.SWARM_ORG_LOGO_URL = "https://acme.example/logo.png";
171
+ process.env.SWARM_BRAND_COLOR = "#ff5500";
172
+ process.env.SWARM_MARKETING_URL = "https://swarm.acme.example";
173
+ process.env.SWARM_HIDE_CLOUD_PROMO = "true";
174
+
175
+ const payload = buildStatusPayload();
176
+ expect(payload.identity).toEqual({
177
+ name: "Acme",
178
+ logo_url: "https://acme.example/logo.png",
179
+ brand_color: "#ff5500",
180
+ is_cloud: true,
181
+ marketing_url: "https://swarm.acme.example",
182
+ hide_cloud_promo: true,
183
+ });
184
+ });
185
+
186
+ test("treats SWARM_CLOUD=1 the same as 'true'", () => {
187
+ process.env.SWARM_CLOUD = "1";
188
+ const payload = buildStatusPayload();
189
+ expect(payload.identity.is_cloud).toBe(true);
190
+ });
191
+ });
192
+
193
+ // ─── Setup state machine ─────────────────────────────────────────────────────
194
+
195
+ function getMilestone(payload: ReturnType<typeof buildStatusPayload>, id: string) {
196
+ const m = payload.setup.find((row) => row.id === id);
197
+ if (!m) throw new Error(`Milestone "${id}" missing from payload`);
198
+ return m;
199
+ }
200
+
201
+ describe("setup milestones", () => {
202
+ test("all unverified on a clean swarm", () => {
203
+ const payload = buildStatusPayload();
204
+ expect(payload.setup).toHaveLength(7);
205
+ for (const m of payload.setup) {
206
+ expect(m.state).toBe("unverified");
207
+ }
208
+ });
209
+
210
+ test("harness becomes `configured` when a worker reports ready creds (no live test yet)", () => {
211
+ const a = createAgent({ name: "w-cfg", isLead: false, status: "idle", capabilities: [] });
212
+ seedCredStatus(a.id, "claude", { ready: true, satisfiedBy: "env", liveTest: null });
213
+
214
+ const payload = buildStatusPayload();
215
+ expect(getMilestone(payload, "harness").state).toBe("configured");
216
+ });
217
+
218
+ test("harness flips to `verified` when a worker's recent live test passed", () => {
219
+ const a = createAgent({ name: "w-vfd", isLead: false, status: "idle", capabilities: [] });
220
+ seedCredStatus(a.id, "claude", {
221
+ ready: true,
222
+ satisfiedBy: "env",
223
+ liveTest: { ok: true, error: null, latency_ms: 42, testedAt: Date.now() },
224
+ });
225
+
226
+ const payload = buildStatusPayload();
227
+ expect(getMilestone(payload, "harness").state).toBe("verified");
228
+ });
229
+
230
+ test("harness stays `unverified` on an empty fleet (no agents registered)", () => {
231
+ const m = getMilestone(buildStatusPayload(), "harness");
232
+ expect(m.state).toBe("unverified");
233
+ expect(m.hint).toContain("No worker agents registered");
234
+ });
235
+
236
+ test("harness stays `unverified` when worker reports missing credentials", () => {
237
+ const a = createAgent({ name: "w-miss", isLead: false, status: "idle", capabilities: [] });
238
+ seedCredStatus(a.id, "claude", {
239
+ ready: false,
240
+ missing: ["ANTHROPIC_API_KEY"],
241
+ satisfiedBy: null,
242
+ });
243
+
244
+ const payload = buildStatusPayload();
245
+ const m = getMilestone(payload, "harness");
246
+ expect(m.state).toBe("unverified");
247
+ expect(m.hint).toContain("ANTHROPIC_API_KEY");
248
+ });
249
+
250
+ // ─── Multi-provider fleet rollup ─────────────────────────────────────────
251
+ describe("harness — multi-provider fleet aggregate", () => {
252
+ test("`verified` when every provider in the fleet has a fresh passing live test", () => {
253
+ const a = createAgent({ name: "claude-w", isLead: false, status: "idle", capabilities: [] });
254
+ const b = createAgent({ name: "codex-w", isLead: false, status: "idle", capabilities: [] });
255
+ const fresh = { ok: true, error: null, latency_ms: 12, testedAt: Date.now() };
256
+ seedCredStatus(a.id, "claude", { ready: true, satisfiedBy: "env", liveTest: fresh });
257
+ seedCredStatus(b.id, "codex", { ready: true, satisfiedBy: "file", liveTest: fresh });
258
+
259
+ const m = getMilestone(buildStatusPayload(), "harness");
260
+ expect(m.state).toBe("verified");
261
+ // Multi-provider fleet → `provider` is undefined; `providers[]` lists both.
262
+ expect(m.provider).toBeUndefined();
263
+ const providerNames = (m.providers ?? []).map((p) => p.provider).sort();
264
+ expect(providerNames).toEqual(["claude", "codex"]);
265
+ });
266
+
267
+ test("`configured` when one provider is verified and another is presence-only", () => {
268
+ const a = createAgent({ name: "claude-w", isLead: false, status: "idle", capabilities: [] });
269
+ const b = createAgent({ name: "codex-w", isLead: false, status: "idle", capabilities: [] });
270
+ seedCredStatus(a.id, "claude", {
271
+ ready: true,
272
+ satisfiedBy: "env",
273
+ liveTest: { ok: true, error: null, latency_ms: 11, testedAt: Date.now() },
274
+ });
275
+ seedCredStatus(b.id, "codex", { ready: true, satisfiedBy: "file", liveTest: null });
276
+
277
+ const m = getMilestone(buildStatusPayload(), "harness");
278
+ expect(m.state).toBe("configured");
279
+ expect(m.hint).toContain("claude");
280
+ expect(m.hint).toContain("codex");
281
+ });
282
+
283
+ test("`unverified` when any provider in the fleet reports blocked credentials", () => {
284
+ const a = createAgent({ name: "claude-w", isLead: false, status: "idle", capabilities: [] });
285
+ const b = createAgent({ name: "pi-w", isLead: false, status: "idle", capabilities: [] });
286
+ seedCredStatus(a.id, "claude", {
287
+ ready: true,
288
+ satisfiedBy: "env",
289
+ liveTest: { ok: true, error: null, latency_ms: 11, testedAt: Date.now() },
290
+ });
291
+ seedCredStatus(b.id, "pi", {
292
+ ready: false,
293
+ missing: ["OPENROUTER_API_KEY"],
294
+ satisfiedBy: null,
295
+ });
296
+
297
+ const m = getMilestone(buildStatusPayload(), "harness");
298
+ expect(m.state).toBe("unverified");
299
+ expect(m.hint).toContain("pi");
300
+ expect(m.hint).toContain("OPENROUTER_API_KEY");
301
+ });
302
+
303
+ test("`provider` populated only on single-provider fleets", () => {
304
+ const a = createAgent({ name: "lone", isLead: false, status: "idle", capabilities: [] });
305
+ seedCredStatus(a.id, "claude", { ready: true, satisfiedBy: "env", liveTest: null });
306
+ expect(getMilestone(buildStatusPayload(), "harness").provider).toBe("claude");
307
+ });
308
+
309
+ test("API process.env.HARNESS_PROVIDER is ignored — fleet wins", () => {
310
+ // Set a misleading env var on the API process. The milestone should
311
+ // still be derived from the (empty) agent fleet.
312
+ process.env.HARNESS_PROVIDER = "claude";
313
+ const m = getMilestone(buildStatusPayload(), "harness");
314
+ expect(m.state).toBe("unverified");
315
+ expect(m.hint).toContain("No worker agents registered");
316
+ });
317
+ });
318
+
319
+ test("slack: needs both bot+app tokens AND not disabled", () => {
320
+ process.env.SLACK_BOT_TOKEN = "xoxb-test";
321
+ const a = buildStatusPayload();
322
+ expect(getMilestone(a, "slack").state).toBe("unverified");
323
+
324
+ process.env.SLACK_APP_TOKEN = "xapp-test";
325
+ const b = buildStatusPayload();
326
+ expect(getMilestone(b, "slack").state).toBe("verified");
327
+
328
+ process.env.SLACK_DISABLE = "true";
329
+ const c = buildStatusPayload();
330
+ expect(getMilestone(c, "slack").state).toBe("unverified");
331
+ });
332
+
333
+ test("github: needs webhook secret + app id + private key", () => {
334
+ process.env.GITHUB_WEBHOOK_SECRET = "secret";
335
+ process.env.GITHUB_APP_ID = "12345";
336
+ const a = buildStatusPayload();
337
+ expect(getMilestone(a, "github").state).toBe("unverified");
338
+
339
+ process.env.GITHUB_APP_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n...";
340
+ const b = buildStatusPayload();
341
+ expect(getMilestone(b, "github").state).toBe("verified");
342
+ });
343
+
344
+ test("linear: row in oauth_tokens flips to verified", () => {
345
+ expect(getMilestone(buildStatusPayload(), "linear").state).toBe("unverified");
346
+
347
+ upsertOAuthApp("linear", {
348
+ clientId: "cid",
349
+ clientSecret: "csec",
350
+ authorizeUrl: "https://linear.app/oauth/authorize",
351
+ tokenUrl: "https://api.linear.app/oauth/token",
352
+ redirectUri: "https://app.example/callback",
353
+ scopes: "read",
354
+ });
355
+ storeOAuthTokens("linear", {
356
+ accessToken: "lin-tok-xyz",
357
+ refreshToken: "ref",
358
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
359
+ scope: "read",
360
+ });
361
+ expect(getMilestone(buildStatusPayload(), "linear").state).toBe("verified");
362
+ });
363
+
364
+ test("jira: requires both oauth_tokens row AND oauth_apps.metadata.cloudId", () => {
365
+ // Seed app row first (FK on oauth_tokens.provider → oauth_apps.provider).
366
+ upsertOAuthApp("jira", {
367
+ clientId: "cid",
368
+ clientSecret: "csec",
369
+ authorizeUrl: "https://auth.atlassian.com/authorize",
370
+ tokenUrl: "https://auth.atlassian.com/oauth/token",
371
+ redirectUri: "https://app.example/callback",
372
+ scopes: "read:jira-work",
373
+ // metadata intentionally omitted on first upsert
374
+ });
375
+ storeOAuthTokens("jira", {
376
+ accessToken: "jira-tok",
377
+ refreshToken: null,
378
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
379
+ scope: null,
380
+ });
381
+ // Without cloudId yet — still unverified.
382
+ expect(getMilestone(buildStatusPayload(), "jira").state).toBe("unverified");
383
+
384
+ upsertOAuthApp("jira", {
385
+ clientId: "cid",
386
+ clientSecret: "csec",
387
+ authorizeUrl: "https://auth.atlassian.com/authorize",
388
+ tokenUrl: "https://auth.atlassian.com/oauth/token",
389
+ redirectUri: "https://app.example/callback",
390
+ scopes: "read:jira-work",
391
+ metadata: JSON.stringify({ cloudId: "abc-123" }),
392
+ });
393
+ expect(getMilestone(buildStatusPayload(), "jira").state).toBe("verified");
394
+ });
395
+
396
+ test("workers: configured when agents exist; verified when lead+worker recently active", () => {
397
+ expect(getMilestone(buildStatusPayload(), "workers").state).toBe("unverified");
398
+
399
+ const lead = createAgent({
400
+ name: "lead-1",
401
+ isLead: true,
402
+ status: "idle",
403
+ capabilities: [],
404
+ });
405
+ expect(getMilestone(buildStatusPayload(), "workers").state).toBe("configured");
406
+
407
+ const worker = createAgent({
408
+ name: "worker-1",
409
+ isLead: false,
410
+ status: "idle",
411
+ capabilities: [],
412
+ });
413
+ // Still configured — neither has lastActivityAt yet.
414
+ expect(getMilestone(buildStatusPayload(), "workers").state).toBe("configured");
415
+
416
+ updateAgentActivity(lead.id);
417
+ updateAgentActivity(worker.id);
418
+ expect(getMilestone(buildStatusPayload(), "workers").state).toBe("verified");
419
+ });
420
+
421
+ test("first_task: unverified by default; verified after a completed task", () => {
422
+ expect(getMilestone(buildStatusPayload(), "first_task").state).toBe("unverified");
423
+
424
+ getDb()
425
+ .prepare(
426
+ `INSERT INTO agent_tasks (id, task, status, source, swarmVersion, createdAt, lastUpdatedAt)
427
+ VALUES (?, ?, 'completed', 'mcp', '1.0.0',
428
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
429
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
430
+ )
431
+ .run("task-completed-1", "first task");
432
+ expect(getMilestone(buildStatusPayload(), "first_task").state).toBe("verified");
433
+ });
434
+ });
435
+
436
+ // ─── DB helpers ──────────────────────────────────────────────────────────────
437
+
438
+ describe("getLiveAgentCounts", () => {
439
+ test("0/0 on empty DB", () => {
440
+ expect(getLiveAgentCounts(5)).toEqual({ leads_alive: 0, workers_alive: 0 });
441
+ });
442
+
443
+ test("counts agents with recent activity, excludes offline", () => {
444
+ const lead = createAgent({ name: "lead-a", isLead: true, status: "idle", capabilities: [] });
445
+ const w1 = createAgent({ name: "worker-a", isLead: false, status: "busy", capabilities: [] });
446
+ const w2 = createAgent({
447
+ name: "worker-b",
448
+ isLead: false,
449
+ status: "offline",
450
+ capabilities: [],
451
+ });
452
+ updateAgentActivity(lead.id);
453
+ updateAgentActivity(w1.id);
454
+ updateAgentActivity(w2.id);
455
+ expect(getLiveAgentCounts(5)).toEqual({ leads_alive: 1, workers_alive: 1 });
456
+ });
457
+
458
+ test("excludes agents with stale lastActivityAt", () => {
459
+ const w1 = createAgent({ name: "stale-w", isLead: false, status: "idle", capabilities: [] });
460
+ // Backdate to 1h ago (well outside the 5min window).
461
+ const past = new Date(Date.now() - 60 * 60 * 1000).toISOString();
462
+ getDb().prepare(`UPDATE agents SET lastActivityAt = ? WHERE id = ?`).run(past, w1.id);
463
+ expect(getLiveAgentCounts(5).workers_alive).toBe(0);
464
+ });
465
+ });
466
+
467
+ describe("getInstanceActivity", () => {
468
+ test("empty DB returns zeroes", () => {
469
+ expect(getInstanceActivity()).toEqual({
470
+ agents_online: 0,
471
+ leads_online: 0,
472
+ recent_tasks_count: 0,
473
+ });
474
+ });
475
+
476
+ test("counts agents online + tasks created in 24h", () => {
477
+ const lead = createAgent({ name: "lead-c", isLead: true, status: "idle", capabilities: [] });
478
+ const worker = createAgent({
479
+ name: "worker-c",
480
+ isLead: false,
481
+ status: "idle",
482
+ capabilities: [],
483
+ });
484
+ updateAgentActivity(lead.id);
485
+ updateAgentActivity(worker.id);
486
+
487
+ getDb()
488
+ .prepare(
489
+ `INSERT INTO agent_tasks (id, task, status, source, swarmVersion, createdAt, lastUpdatedAt)
490
+ VALUES (?, ?, 'pending', 'mcp', '1.0.0',
491
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
492
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
493
+ )
494
+ .run("task-recent-1", "fresh task");
495
+
496
+ const a = getInstanceActivity();
497
+ expect(a.agents_online).toBe(2);
498
+ expect(a.leads_online).toBe(1);
499
+ expect(a.recent_tasks_count).toBe(1);
500
+ });
501
+ });
502
+
503
+ describe("hasFirstCompletedTask", () => {
504
+ test("false on empty DB", () => {
505
+ expect(hasFirstCompletedTask()).toBe(false);
506
+ });
507
+
508
+ test("flips on first completed task", () => {
509
+ getDb()
510
+ .prepare(
511
+ `INSERT INTO agent_tasks (id, task, status, source, swarmVersion, createdAt, lastUpdatedAt)
512
+ VALUES (?, ?, 'pending', 'mcp', '1.0.0',
513
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
514
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
515
+ )
516
+ .run("task-pend-1", "pending task");
517
+ expect(hasFirstCompletedTask()).toBe(false);
518
+
519
+ getDb()
520
+ .prepare(
521
+ `INSERT INTO agent_tasks (id, task, status, source, swarmVersion, createdAt, lastUpdatedAt)
522
+ VALUES (?, ?, 'completed', 'mcp', '1.0.0',
523
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
524
+ strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
525
+ )
526
+ .run("task-done-1", "done task");
527
+ expect(hasFirstCompletedTask()).toBe(true);
528
+ });
529
+ });
530
+
531
+ // ─── Live test dispatcher (mocked fetch) ─────────────────────────────────────
532
+
533
+ describe("validateProviderCredentials — error scrubbing", () => {
534
+ const realFetch = globalThis.fetch;
535
+ const realHome = process.env.HOME;
536
+ // Isolate HOME for the whole suite so a dev's real `~/.codex/auth.json`
537
+ // doesn't accidentally satisfy the codex presence check during tests that
538
+ // expect to exercise the env-credential path.
539
+ let homeSandbox = "";
540
+
541
+ beforeEach(() => {
542
+ homeSandbox = mkdtempSync(join(tmpdir(), "swarm-cred-test-home-"));
543
+ process.env.HOME = homeSandbox;
544
+ });
545
+
546
+ afterEach(() => {
547
+ globalThis.fetch = realFetch;
548
+ if (realHome === undefined) delete process.env.HOME;
549
+ else process.env.HOME = realHome;
550
+ if (homeSandbox) rmSync(homeSandbox, { recursive: true, force: true });
551
+ });
552
+
553
+ test("returns ok:false when neither OAuth nor API key is set for claude", async () => {
554
+ delete process.env.ANTHROPIC_API_KEY;
555
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
556
+ const result = await validateProviderCredentials("claude");
557
+ expect(result.ok).toBe(false);
558
+ // Error names BOTH accepted credentials so OAuth users know they have a path.
559
+ expect(result.error).toContain("CLAUDE_CODE_OAUTH_TOKEN");
560
+ expect(result.error).toContain("ANTHROPIC_API_KEY");
561
+ });
562
+
563
+ test("claude with CLAUDE_CODE_OAUTH_TOKEN passes via presence check (no upstream call)", async () => {
564
+ delete process.env.ANTHROPIC_API_KEY;
565
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = "sk-ant-oat01-fake-oauth-token";
566
+ let fetchCalled = false;
567
+ globalThis.fetch = (async () => {
568
+ fetchCalled = true;
569
+ return new Response("{}", { status: 200 });
570
+ }) as typeof fetch;
571
+ const result = await validateProviderCredentials("claude");
572
+ expect(result.ok).toBe(true);
573
+ expect(fetchCalled).toBe(false);
574
+ });
575
+
576
+ test("claude prefers OAuth presence check over API-key live call when both are set", async () => {
577
+ process.env.ANTHROPIC_API_KEY = "sk-ant-api-fake-1234";
578
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = "sk-ant-oat01-fake-oauth";
579
+ let fetchCalled = false;
580
+ globalThis.fetch = (async () => {
581
+ fetchCalled = true;
582
+ return new Response("{}", { status: 200 });
583
+ }) as typeof fetch;
584
+ const result = await validateProviderCredentials("claude");
585
+ expect(result.ok).toBe(true);
586
+ expect(fetchCalled).toBe(false);
587
+ });
588
+
589
+ test("codex with valid CODEX_OAUTH JSON passes via presence check (no upstream call)", async () => {
590
+ delete process.env.OPENAI_API_KEY;
591
+ process.env.CODEX_OAUTH = JSON.stringify({
592
+ access: "oai-access-token-from-oauth",
593
+ refresh: "oai-refresh",
594
+ expires: Date.now() + 3600_000,
595
+ accountId: "acct_123",
596
+ });
597
+ let fetchCalled = false;
598
+ globalThis.fetch = (async () => {
599
+ fetchCalled = true;
600
+ return new Response("{}", { status: 200 });
601
+ }) as typeof fetch;
602
+ const result = await validateProviderCredentials("codex");
603
+ expect(result.ok).toBe(true);
604
+ expect(fetchCalled).toBe(false);
605
+ });
606
+
607
+ test("codex with ~/.codex/auth.json on disk passes via presence check (no env creds)", async () => {
608
+ // Reproduces the prod scenario: agent boots from a credential pool that
609
+ // pre-materialised auth.json (or ran `codex login` in a prior boot), so
610
+ // CODEX_OAUTH and OPENAI_API_KEY are NOT in env at live-test time. Before
611
+ // this fix the check returned `ok:false` with "Set either CODEX_OAUTH or
612
+ // OPENAI_API_KEY" even though the agent was happily running tasks.
613
+ mkdirSync(join(homeSandbox, ".codex"), { recursive: true });
614
+ writeFileSync(
615
+ join(homeSandbox, ".codex/auth.json"),
616
+ JSON.stringify({ tokens: { id_token: "x" } }),
617
+ );
618
+ delete process.env.CODEX_OAUTH;
619
+ delete process.env.OPENAI_API_KEY;
620
+ let fetchCalled = false;
621
+ globalThis.fetch = (async () => {
622
+ fetchCalled = true;
623
+ return new Response("{}", { status: 200 });
624
+ }) as typeof fetch;
625
+ const result = await validateProviderCredentials("codex");
626
+ expect(result.ok).toBe(true);
627
+ expect(fetchCalled).toBe(false);
628
+ });
629
+
630
+ test("codex with no auth.json and no env creds reports the new error", async () => {
631
+ delete process.env.CODEX_OAUTH;
632
+ delete process.env.OPENAI_API_KEY;
633
+ const result = await validateProviderCredentials("codex");
634
+ expect(result.ok).toBe(false);
635
+ expect(result.error).toContain("auth.json");
636
+ expect(result.error).toContain("CODEX_OAUTH");
637
+ expect(result.error).toContain("OPENAI_API_KEY");
638
+ });
639
+
640
+ test("codex with malformed CODEX_OAUTH falls back to OPENAI_API_KEY", async () => {
641
+ process.env.CODEX_OAUTH = "not-json";
642
+ process.env.OPENAI_API_KEY = "sk-fallback-1234";
643
+ let capturedAuth: string | null = null;
644
+ globalThis.fetch = (async (_url, init) => {
645
+ const headers = new Headers((init as RequestInit | undefined)?.headers);
646
+ capturedAuth = headers.get("authorization");
647
+ return new Response("{}", { status: 200 });
648
+ }) as typeof fetch;
649
+ const result = await validateProviderCredentials("codex");
650
+ expect(result.ok).toBe(true);
651
+ expect(capturedAuth).toBe("Bearer sk-fallback-1234");
652
+ });
653
+
654
+ test("opencode resolves OPENROUTER → ANTHROPIC → OPENAI (matching pi)", async () => {
655
+ delete process.env.OPENAI_API_KEY;
656
+ delete process.env.ANTHROPIC_API_KEY;
657
+ process.env.OPENROUTER_API_KEY = "sk-or-1234";
658
+ let capturedUrl = "";
659
+ globalThis.fetch = (async (url) => {
660
+ capturedUrl = String(url);
661
+ return new Response("{}", { status: 200 });
662
+ }) as typeof fetch;
663
+ const result = await validateProviderCredentials("opencode");
664
+ expect(result.ok).toBe(true);
665
+ // Should hit OpenRouter, NOT OpenAI.
666
+ expect(capturedUrl).toContain("openrouter.ai");
667
+ });
668
+
669
+ test("scrubs api key from error message on 401 response", async () => {
670
+ const fakeKey = "sk-ant-fakekey-DO-NOT-LEAK-1234567890abcdef";
671
+ process.env.ANTHROPIC_API_KEY = fakeKey;
672
+ globalThis.fetch = (async () =>
673
+ new Response(`Unauthorized: invalid key ${fakeKey}`, {
674
+ status: 401,
675
+ })) as typeof fetch;
676
+ const result = await validateProviderCredentials("claude");
677
+ expect(result.ok).toBe(false);
678
+ expect(result.error ?? "").not.toContain(fakeKey);
679
+ // The structural anthropic_key regex *or* env-value substitution should
680
+ // catch it. Either way the literal key must not survive.
681
+ expect(result.error).toMatch(/REDACTED|HTTP 401/);
682
+ });
683
+
684
+ test("returns ok:true on 2xx response", async () => {
685
+ process.env.OPENAI_API_KEY = "sk-test-1234567890";
686
+ globalThis.fetch = (async () =>
687
+ new Response(JSON.stringify({ data: [] }), { status: 200 })) as typeof fetch;
688
+ const result = await validateProviderCredentials("codex");
689
+ expect(result.ok).toBe(true);
690
+ expect(typeof result.latency_ms).toBe("number");
691
+ });
692
+
693
+ test("rejects unknown provider", async () => {
694
+ const result = await validateProviderCredentials("unknown-provider");
695
+ expect(result.ok).toBe(false);
696
+ expect(result.error).toContain("Unknown provider");
697
+ });
698
+ });
699
+
700
+ // ─── Phase 2: aggregate health rollup ────────────────────────────────────────
701
+
702
+ describe("computeHealth (Phase 2)", () => {
703
+ test("`broken` on a clean swarm — harness + workers both unverified", () => {
704
+ const payload = buildStatusPayload();
705
+ expect(payload.health).toBe("broken");
706
+ });
707
+
708
+ test("`broken` when no agents ever joined (harness fleet is empty)", () => {
709
+ const payload = buildStatusPayload();
710
+ // Both harness and workers are `unverified` on a clean swarm → broken.
711
+ expect(payload.health).toBe("broken");
712
+ expect(getMilestone(payload, "harness").state).toBe("unverified");
713
+ });
714
+
715
+ test("`degraded` when harness is `configured` (worker reported ready, no live test) and workers verified", () => {
716
+ const lead = createAgent({ name: "lead-h", isLead: true, status: "idle", capabilities: [] });
717
+ const worker = createAgent({
718
+ name: "worker-h",
719
+ isLead: false,
720
+ status: "idle",
721
+ capabilities: [],
722
+ });
723
+ updateAgentActivity(lead.id);
724
+ updateAgentActivity(worker.id);
725
+ // Both report presence-ok with no live test → harness rollup is `configured`.
726
+ seedCredStatus(lead.id, "claude", { ready: true, satisfiedBy: "env", liveTest: null });
727
+ seedCredStatus(worker.id, "claude", { ready: true, satisfiedBy: "env", liveTest: null });
728
+
729
+ const payload = buildStatusPayload();
730
+ expect(getMilestone(payload, "workers").state).toBe("verified");
731
+ expect(getMilestone(payload, "harness").state).toBe("configured");
732
+ expect(payload.health).toBe("degraded");
733
+ });
734
+
735
+ test("`ok` when workers are `configured` (heartbeat drift is a runtime concern, not setup health)", () => {
736
+ // Workers in `configured` state means agents exist but haven't posted a
737
+ // heartbeat in the last 5 minutes. This is surfaced on /agents and the
738
+ // dashboard canvas — it should NOT degrade the header health dot.
739
+ const lead = createAgent({ name: "lead-d", isLead: true, status: "idle", capabilities: [] });
740
+ seedCredStatus(lead.id, "claude", {
741
+ ready: true,
742
+ satisfiedBy: "env",
743
+ liveTest: { ok: true, error: null, latency_ms: 12, testedAt: Date.now() },
744
+ });
745
+ const payload = buildStatusPayload();
746
+ expect(getMilestone(payload, "workers").state).toBe("configured");
747
+ expect(payload.health).toBe("ok");
748
+ });
749
+
750
+ test("`ok` when harness verified and workers verified (no other integration is `configured`)", () => {
751
+ // We can't reach the in-memory cache from here, so simulate by directly
752
+ // checking the helper: build a synthetic milestone array.
753
+ // (computeHealth is exported.)
754
+ const synthetic: SetupMilestone[] = [
755
+ { id: "harness", label: "Harness", state: "verified" },
756
+ { id: "slack", label: "Slack", state: "unverified" },
757
+ { id: "github", label: "GitHub", state: "unverified" },
758
+ { id: "linear", label: "Linear", state: "unverified" },
759
+ { id: "jira", label: "Jira", state: "unverified" },
760
+ { id: "workers", label: "Workers", state: "verified" },
761
+ { id: "first_task", label: "First task", state: "verified" },
762
+ ];
763
+ expect(computeHealth(synthetic)).toBe("ok");
764
+ });
765
+
766
+ test("`degraded` when an integration is `configured`", () => {
767
+ const synthetic: SetupMilestone[] = [
768
+ { id: "harness", label: "Harness", state: "verified" },
769
+ { id: "slack", label: "Slack", state: "configured" },
770
+ { id: "github", label: "GitHub", state: "unverified" },
771
+ { id: "linear", label: "Linear", state: "unverified" },
772
+ { id: "jira", label: "Jira", state: "unverified" },
773
+ { id: "workers", label: "Workers", state: "verified" },
774
+ { id: "first_task", label: "First task", state: "verified" },
775
+ ];
776
+ expect(computeHealth(synthetic)).toBe("degraded");
777
+ });
778
+
779
+ test("integrations in `unverified` alone do NOT degrade health", () => {
780
+ // No integrations are connected — that's the common shape, not a
781
+ // problem. Health should be `ok` as long as harness + workers verified.
782
+ const synthetic: SetupMilestone[] = [
783
+ { id: "harness", label: "Harness", state: "verified" },
784
+ { id: "slack", label: "Slack", state: "unverified" },
785
+ { id: "github", label: "GitHub", state: "unverified" },
786
+ { id: "linear", label: "Linear", state: "unverified" },
787
+ { id: "jira", label: "Jira", state: "unverified" },
788
+ { id: "workers", label: "Workers", state: "verified" },
789
+ { id: "first_task", label: "First task", state: "unverified" },
790
+ ];
791
+ expect(computeHealth(synthetic)).toBe("ok");
792
+ });
793
+ });
794
+
795
+ // ─── Worker-reported live test drives harness.state ──────────────────────────
796
+ //
797
+ // The pre-refactor in-memory cache is gone — `harness.state` now derives from
798
+ // `agents.cred_status.liveTest` (migration 055) read across all agents whose
799
+ // `harness_provider` matches. These tests cover the new rollup paths.
800
+
801
+ describe("worker-reported live test drives harness.state", () => {
802
+ test("a passing recent live test flips harness to `verified`", () => {
803
+ process.env.HARNESS_PROVIDER = "claude";
804
+ const a = createAgent({ name: "w-lt", isLead: false, status: "idle", capabilities: [] });
805
+ seedCredStatus(a.id, "claude", {
806
+ ready: true,
807
+ liveTest: { ok: true, error: null, latency_ms: 80, testedAt: Date.now() },
808
+ });
809
+ expect(getMilestone(buildStatusPayload(), "harness").state).toBe("verified");
810
+ });
811
+
812
+ test("a stale live test (older than SWARM_VERIFY_TTL_MS) drops to `configured`", () => {
813
+ process.env.HARNESS_PROVIDER = "claude";
814
+ process.env.SWARM_VERIFY_TTL_MS = "1000"; // 1s — anything older is stale
815
+ const a = createAgent({ name: "w-stl", isLead: false, status: "idle", capabilities: [] });
816
+ seedCredStatus(a.id, "claude", {
817
+ ready: true,
818
+ liveTest: {
819
+ ok: true,
820
+ error: null,
821
+ latency_ms: 80,
822
+ testedAt: Date.now() - 60_000, // 60s ago, well beyond TTL
823
+ },
824
+ });
825
+ expect(getMilestone(buildStatusPayload(), "harness").state).toBe("configured");
826
+ });
827
+
828
+ test("a failed live test still leaves harness `configured` if presence is ready", () => {
829
+ process.env.HARNESS_PROVIDER = "claude";
830
+ const a = createAgent({ name: "w-fail", isLead: false, status: "idle", capabilities: [] });
831
+ seedCredStatus(a.id, "claude", {
832
+ ready: true,
833
+ liveTest: {
834
+ ok: false,
835
+ error: "HTTP 401: invalid_api_key",
836
+ latency_ms: 30,
837
+ testedAt: Date.now(),
838
+ },
839
+ });
840
+ // Presence is OK; live test failed → not verified, but still configured.
841
+ expect(getMilestone(buildStatusPayload(), "harness").state).toBe("configured");
842
+ });
843
+ });