@desplega.ai/agent-swarm 1.75.0 → 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 (48) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +973 -36
  3. package/package.json +2 -2
  4. package/src/be/db.ts +527 -9
  5. package/src/be/memory/raters/llm-summarizer.ts +218 -0
  6. package/src/be/memory/raters/llm.ts +56 -75
  7. package/src/be/memory/retrieval-store.ts +21 -0
  8. package/src/be/migrations/054_agent_harness_provider.sql +21 -0
  9. package/src/be/migrations/055_agent_cred_status.sql +15 -0
  10. package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
  11. package/src/be/migrations/057_inbox_item_state.sql +27 -0
  12. package/src/be/migrations/058_task_templates.sql +31 -0
  13. package/src/be/swarm-config-guard.ts +24 -0
  14. package/src/commands/credential-wait.ts +1 -1
  15. package/src/commands/provider-credentials.ts +434 -0
  16. package/src/commands/runner.ts +229 -42
  17. package/src/hooks/hook.ts +115 -95
  18. package/src/http/agents.ts +82 -2
  19. package/src/http/config.ts +11 -1
  20. package/src/http/inbox-state.ts +89 -0
  21. package/src/http/index.ts +10 -0
  22. package/src/http/sessions.ts +86 -0
  23. package/src/http/status.ts +665 -0
  24. package/src/http/task-templates.ts +51 -0
  25. package/src/http/tasks.ts +85 -5
  26. package/src/http/users.ts +134 -0
  27. package/src/providers/claude-adapter.ts +5 -0
  28. package/src/providers/codex-adapter.ts +1 -1
  29. package/src/providers/index.ts +1 -1
  30. package/src/slack/handlers.ts +0 -1
  31. package/src/tests/agents-harness-provider.test.ts +333 -0
  32. package/src/tests/credential-check.test.ts +32 -1
  33. package/src/tests/credential-status-api.test.ts +42 -0
  34. package/src/tests/harness-provider-resolution.test.ts +242 -0
  35. package/src/tests/jira-sync.test.ts +1 -1
  36. package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
  37. package/src/tests/memory-rater-llm.test.ts +265 -107
  38. package/src/tests/migration-runner-regressions.test.ts +17 -2
  39. package/src/tests/sessions.test.ts +141 -0
  40. package/src/tests/status.test.ts +843 -0
  41. package/src/tests/stop-hook-task-resolution.test.ts +98 -0
  42. package/src/tests/template-recommendations.test.ts +148 -0
  43. package/src/tests/use-dismissible-card.test.ts +140 -0
  44. package/src/tools/swarm-config/set-config.ts +17 -1
  45. package/src/types.ts +117 -0
  46. package/src/utils/harness-provider.ts +32 -0
  47. package/tsconfig.json +0 -2
  48. package/src/providers/credentials.ts +0 -74
@@ -0,0 +1,665 @@
1
+ /**
2
+ * `/status` — identity + setup readiness + activity + agent_fs aggregate
3
+ * for the home page.
4
+ *
5
+ * Architecture (post worker-self-report refactor):
6
+ * - Credential checks are NEVER run server-side. Workers run them in their
7
+ * boot loop / post-task hook (`src/commands/credential-wait.ts` and
8
+ * `src/commands/runner.ts`) and POST results to the agent row's
9
+ * `cred_status` column. This file only reads those rows.
10
+ * - This is critical for the bun-compiled API binary: importing any
11
+ * provider-adapter code at module level drags worker-harness SDKs (e.g.
12
+ * `@mariozechner/pi-coding-agent`) into the bundle, which crashes at
13
+ * `/usr/local/bin/` on boot. Keep this file adapter-free.
14
+ * - Setup checks beyond credentials are still env- and DB-only (zero
15
+ * network, zero side effects).
16
+ */
17
+
18
+ import type { IncomingMessage, ServerResponse } from "node:http";
19
+ import { z } from "zod";
20
+ import {
21
+ getAgentHarnessProviders,
22
+ getDb,
23
+ getInstanceActivity,
24
+ getLiveAgentCounts,
25
+ hasFirstCompletedTask,
26
+ listAgentsWithCredStatusByProvider,
27
+ } from "../be/db";
28
+ import { getOAuthApp, getOAuthTokens } from "../be/db-queries/oauth";
29
+ import { type AgentCredStatus, ProviderNameSchema } from "../types";
30
+ import { route } from "./route-def";
31
+ import { json, jsonError } from "./utils";
32
+
33
+ // ─── Schemas ─────────────────────────────────────────────────────────────────
34
+
35
+ export const SetupMilestoneStateSchema = z.enum(["unverified", "configured", "verified"]);
36
+ export type SetupMilestoneState = z.infer<typeof SetupMilestoneStateSchema>;
37
+
38
+ export const SetupMilestoneIdSchema = z.enum([
39
+ "harness",
40
+ "slack",
41
+ "github",
42
+ "linear",
43
+ "jira",
44
+ "workers",
45
+ "first_task",
46
+ ]);
47
+ export type SetupMilestoneId = z.infer<typeof SetupMilestoneIdSchema>;
48
+
49
+ /**
50
+ * Per-provider rollup attached to the `harness` milestone when the swarm has
51
+ * registered workers reporting `cred_status`. The milestone aggregates these
52
+ * into a single state for `health` rollup, but the array lets the UI render
53
+ * a per-provider breakdown (e.g. "claude verified · codex blocked").
54
+ */
55
+ export const HarnessProviderRollupSchema = z.object({
56
+ provider: ProviderNameSchema,
57
+ state: SetupMilestoneStateSchema,
58
+ workers: z.number().int().nonnegative(),
59
+ });
60
+ export type HarnessProviderRollup = z.infer<typeof HarnessProviderRollupSchema>;
61
+
62
+ export const SetupMilestoneSchema = z.object({
63
+ id: SetupMilestoneIdSchema,
64
+ label: z.string(),
65
+ state: SetupMilestoneStateSchema,
66
+ hint: z.string().optional(),
67
+ action_url: z.string().optional(),
68
+ /**
69
+ * Canonical harness provider name. Only populated on the `harness`
70
+ * milestone when the fleet contains exactly one distinct provider —
71
+ * otherwise undefined and the UI falls back to `providers[]`.
72
+ */
73
+ provider: ProviderNameSchema.optional(),
74
+ /**
75
+ * Per-provider rollup. Populated on the `harness` milestone whenever the
76
+ * fleet has ≥1 registered worker. Empty array possible if all rows have
77
+ * `harness_provider = NULL` (legacy agents pre-migration 054).
78
+ */
79
+ providers: z.array(HarnessProviderRollupSchema).optional(),
80
+ });
81
+ export type SetupMilestone = z.infer<typeof SetupMilestoneSchema>;
82
+
83
+ export const StatusIdentitySchema = z.object({
84
+ name: z.string(),
85
+ logo_url: z.string().nullable(),
86
+ brand_color: z.string().nullable(),
87
+ is_cloud: z.boolean(),
88
+ marketing_url: z.string().nullable(),
89
+ hide_cloud_promo: z.boolean(),
90
+ });
91
+ export type StatusIdentity = z.infer<typeof StatusIdentitySchema>;
92
+
93
+ export const StatusActivitySchema = z.object({
94
+ agents_online: z.number().int().nonnegative(),
95
+ leads_online: z.number().int().nonnegative(),
96
+ recent_tasks_count: z.number().int().nonnegative(),
97
+ });
98
+ export type StatusActivity = z.infer<typeof StatusActivitySchema>;
99
+
100
+ export const StatusAgentFsSchema = z.object({
101
+ configured: z.boolean(),
102
+ base_url: z.string().nullable(),
103
+ });
104
+ export type StatusAgentFs = z.infer<typeof StatusAgentFsSchema>;
105
+
106
+ /**
107
+ * Phase 2: Aggregate health derived from setup milestones.
108
+ *
109
+ * - `broken` — harness or workers blocking (creds missing, no live workers ever).
110
+ * - `degraded` — at least one optional integration `unverified`/`configured`
111
+ * while another is configured, OR harness creds present but
112
+ * never tested (`configured`).
113
+ * - `ok` — harness + workers `verified`; no integration left in
114
+ * `configured` state.
115
+ */
116
+ export const StatusHealthSchema = z.enum(["ok", "degraded", "broken"]);
117
+ export type StatusHealth = z.infer<typeof StatusHealthSchema>;
118
+
119
+ export const StatusResponseSchema = z.object({
120
+ identity: StatusIdentitySchema,
121
+ setup: z.array(SetupMilestoneSchema),
122
+ activity: StatusActivitySchema,
123
+ agent_fs: StatusAgentFsSchema,
124
+ /** Phase 2: rolled-up health for the always-on header badge. */
125
+ health: StatusHealthSchema,
126
+ });
127
+ export type StatusResponse = z.infer<typeof StatusResponseSchema>;
128
+
129
+ export const TestConnectionRequestSchema = z.object({
130
+ provider: ProviderNameSchema,
131
+ });
132
+
133
+ export const TestConnectionResponseSchema = z.object({
134
+ ok: z.boolean(),
135
+ error: z.string().optional(),
136
+ latency_ms: z.number().int().nonnegative(),
137
+ });
138
+
139
+ // ─── Worker-reported credential rollup ───────────────────────────────────────
140
+
141
+ /**
142
+ * Read all worker reports for a given harness provider and reduce them to a
143
+ * single milestone-grade rollup. Reports are joined from `agents.cred_status`
144
+ * (migration 055) — no provider-adapter code runs here.
145
+ *
146
+ * `verified` ⇐ at least one worker has `liveTest.ok === true` and the test
147
+ * is fresher than `getCredVerifyTtlMs()`.
148
+ * `configured` ⇐ at least one worker has `ready: true` (presence check
149
+ * passed) but no fresh passing live test.
150
+ * `unverified` ⇐ no worker reported, or all reporting workers have
151
+ * `ready: false`.
152
+ */
153
+ type CredRollupState = "verified" | "configured" | "unverified";
154
+
155
+ interface CredRollup {
156
+ state: CredRollupState;
157
+ workers: number;
158
+ reports: number;
159
+ latestLiveTest: AgentCredStatus["liveTest"];
160
+ latestMissing: string[];
161
+ oldestReportAgeMs: number | null;
162
+ }
163
+
164
+ function getCredVerifyTtlMs(): number {
165
+ const raw = process.env.SWARM_VERIFY_TTL_MS;
166
+ if (!raw) return 3_600_000; // 1h default — matches pre-refactor behavior.
167
+ const parsed = Number.parseInt(raw, 10);
168
+ if (Number.isNaN(parsed) || parsed <= 0) return 3_600_000;
169
+ return parsed;
170
+ }
171
+
172
+ /**
173
+ * Compatibility stub. The pre-refactor in-memory test-connection cache is
174
+ * gone — credential state now lives on agent rows. Tests still call this
175
+ * in `beforeEach` for backward compat; it's a no-op today.
176
+ */
177
+ export function _resetTestConnectionCache(): void {
178
+ // intentionally empty
179
+ }
180
+
181
+ function rollupCredStatusForProvider(provider: string): CredRollup {
182
+ const agents = listAgentsWithCredStatusByProvider(provider);
183
+ const reports = agents.map((a) => a.credStatus).filter((s): s is AgentCredStatus => s != null);
184
+
185
+ if (reports.length === 0) {
186
+ return {
187
+ state: "unverified",
188
+ workers: agents.length,
189
+ reports: 0,
190
+ latestLiveTest: null,
191
+ latestMissing: [],
192
+ oldestReportAgeMs: null,
193
+ };
194
+ }
195
+
196
+ // Pick the most-recent passing live test, if any, then fall back to
197
+ // most-recent live test of any kind.
198
+ const ttl = getCredVerifyTtlMs();
199
+ const now = Date.now();
200
+ const passing = reports
201
+ .filter((r) => r.liveTest?.ok === true && now - (r.liveTest?.testedAt ?? 0) < ttl)
202
+ .sort((a, b) => (b.liveTest?.testedAt ?? 0) - (a.liveTest?.testedAt ?? 0));
203
+ const anyLive = reports
204
+ .filter((r) => r.liveTest != null)
205
+ .sort((a, b) => (b.liveTest?.testedAt ?? 0) - (a.liveTest?.testedAt ?? 0));
206
+ const latestLiveTest = passing[0]?.liveTest ?? anyLive[0]?.liveTest ?? null;
207
+
208
+ const anyReady = reports.some((r) => r.ready);
209
+ const state: CredRollupState =
210
+ passing.length > 0 ? "verified" : anyReady ? "configured" : "unverified";
211
+
212
+ // For UI hints, pick the most-recent missing[] from a not-ready report.
213
+ const latestNotReady = reports
214
+ .filter((r) => !r.ready)
215
+ .sort((a, b) => b.reportedAt - a.reportedAt)[0];
216
+ const latestMissing = latestNotReady?.missing ?? [];
217
+
218
+ const oldestReportAgeMs =
219
+ reports.length > 0 ? Math.max(...reports.map((r) => now - r.reportedAt)) : null;
220
+
221
+ return {
222
+ state,
223
+ workers: agents.length,
224
+ reports: reports.length,
225
+ latestLiveTest,
226
+ latestMissing,
227
+ oldestReportAgeMs,
228
+ };
229
+ }
230
+
231
+ // ─── Identity ────────────────────────────────────────────────────────────────
232
+
233
+ function buildIdentity(): StatusIdentity {
234
+ const cloudRaw = process.env.SWARM_CLOUD;
235
+ const hideRaw = process.env.SWARM_HIDE_CLOUD_PROMO;
236
+ return {
237
+ name: process.env.SWARM_ORG_NAME?.trim() || "Swarm",
238
+ logo_url: process.env.SWARM_ORG_LOGO_URL?.trim() || null,
239
+ brand_color: process.env.SWARM_BRAND_COLOR?.trim() || null,
240
+ is_cloud: cloudRaw === "true" || cloudRaw === "1",
241
+ marketing_url: process.env.SWARM_MARKETING_URL?.trim() || null,
242
+ hide_cloud_promo: hideRaw === "true" || hideRaw === "1",
243
+ };
244
+ }
245
+
246
+ // ─── Setup milestones ────────────────────────────────────────────────────────
247
+
248
+ /**
249
+ * Compose a one-line, per-provider description for the milestone hint.
250
+ * Examples:
251
+ * "1 worker · live test ok"
252
+ * "2 workers · presence ok, awaiting live test"
253
+ * "missing: OPENAI_API_KEY"
254
+ */
255
+ function describeRoll(roll: CredRollup): string {
256
+ if (roll.workers === 0) return "no workers";
257
+ if (roll.reports === 0) {
258
+ return `${roll.workers} ${roll.workers === 1 ? "worker" : "workers"}, none reported`;
259
+ }
260
+ const w = `${roll.workers} ${roll.workers === 1 ? "worker" : "workers"}`;
261
+ if (roll.state === "verified") return `${w} · live test ok`;
262
+ if (roll.state === "configured") return `${w} · presence ok, awaiting live test`;
263
+ return roll.latestMissing.length > 0
264
+ ? `missing: ${roll.latestMissing.join(", ")}`
265
+ : "creds blocked";
266
+ }
267
+
268
+ /**
269
+ * Fleet-aware harness milestone.
270
+ *
271
+ * Reads the agent fleet (distinct `harness_provider` values reported by
272
+ * workers via migration 054/055), runs the cred-status rollup per provider,
273
+ * and aggregates:
274
+ *
275
+ * `verified` — every provider in the fleet has a fresh passing live test.
276
+ * `configured` — every provider has at least `configured`, ≥1 not verified.
277
+ * `unverified` — any provider has `unverified` (no reports OR all-blocked).
278
+ *
279
+ * Crucially: the API never reads `process.env.HARNESS_PROVIDER` here.
280
+ * `HARNESS_PROVIDER` is a worker-side env var; the API hosts a fleet that
281
+ * may run several harnesses simultaneously. Empty fleet → `unverified` with
282
+ * an onboarding hint.
283
+ */
284
+ function harnessMilestone(): SetupMilestone {
285
+ const fleet = getAgentHarnessProviders();
286
+
287
+ if (fleet.length === 0) {
288
+ return {
289
+ id: "harness",
290
+ label: "Harness configured",
291
+ state: "unverified",
292
+ hint: "No worker agents registered yet. Start a worker with HARNESS_PROVIDER set to one of: claude, codex, pi, devin, claude-managed, opencode.",
293
+ action_url: "/agents",
294
+ };
295
+ }
296
+
297
+ const perProvider = fleet
298
+ .map(({ provider }) => {
299
+ const parsed = ProviderNameSchema.safeParse(provider);
300
+ if (!parsed.success) return null;
301
+ return { provider: parsed.data, roll: rollupCredStatusForProvider(parsed.data) };
302
+ })
303
+ .filter(
304
+ (x): x is { provider: z.infer<typeof ProviderNameSchema>; roll: CredRollup } => x !== null,
305
+ );
306
+
307
+ if (perProvider.length === 0) {
308
+ return {
309
+ id: "harness",
310
+ label: "Harness configured",
311
+ state: "unverified",
312
+ hint: "Registered agents have unrecognised harness_provider values; check the agent rows.",
313
+ action_url: "/agents",
314
+ };
315
+ }
316
+
317
+ const states = perProvider.map((p) => p.roll.state);
318
+ const aggregateState: SetupMilestoneState = states.every((s) => s === "verified")
319
+ ? "verified"
320
+ : states.every((s) => s !== "unverified")
321
+ ? "configured"
322
+ : "unverified";
323
+
324
+ const hint = perProvider.map((p) => `${p.provider}: ${describeRoll(p.roll)}`).join(" · ");
325
+ const singleProvider = perProvider.length === 1 ? perProvider[0]?.provider : undefined;
326
+
327
+ return {
328
+ id: "harness",
329
+ label: "Harness configured",
330
+ state: aggregateState,
331
+ hint,
332
+ action_url: aggregateState === "unverified" ? "/agents" : "/integrations",
333
+ provider: singleProvider,
334
+ providers: perProvider.map((p) => ({
335
+ provider: p.provider,
336
+ state: p.roll.state,
337
+ workers: p.roll.workers,
338
+ })),
339
+ };
340
+ }
341
+
342
+ function slackMilestone(): SetupMilestone {
343
+ const bot = process.env.SLACK_BOT_TOKEN;
344
+ const app = process.env.SLACK_APP_TOKEN;
345
+ const disable = process.env.SLACK_DISABLE;
346
+ const disabled = disable === "true" || disable === "1";
347
+
348
+ if (disabled || !bot || !app) {
349
+ return {
350
+ id: "slack",
351
+ label: "Slack connected",
352
+ state: "unverified",
353
+ hint: disabled
354
+ ? "Slack is explicitly disabled (SLACK_DISABLE=true)."
355
+ : "Set SLACK_BOT_TOKEN + SLACK_APP_TOKEN to connect Slack.",
356
+ action_url: "/integrations/slack",
357
+ };
358
+ }
359
+ // Socket Mode connection state isn't surfaced today — Phase 2+ enhancement.
360
+ // For now treat env-present as `verified` so the UX matches the brainstorm.
361
+ return {
362
+ id: "slack",
363
+ label: "Slack connected",
364
+ state: "verified",
365
+ action_url: "/integrations/slack",
366
+ };
367
+ }
368
+
369
+ function githubMilestone(): SetupMilestone {
370
+ const webhook = process.env.GITHUB_WEBHOOK_SECRET;
371
+ const appId = process.env.GITHUB_APP_ID;
372
+ const privateKey = process.env.GITHUB_APP_PRIVATE_KEY;
373
+ if (!webhook || !appId || !privateKey) {
374
+ return {
375
+ id: "github",
376
+ label: "GitHub App connected",
377
+ state: "unverified",
378
+ hint: "Set GITHUB_WEBHOOK_SECRET, GITHUB_APP_ID, and GITHUB_APP_PRIVATE_KEY.",
379
+ action_url: "/integrations/github",
380
+ };
381
+ }
382
+ return {
383
+ id: "github",
384
+ label: "GitHub App connected",
385
+ state: "verified",
386
+ action_url: "/integrations/github",
387
+ };
388
+ }
389
+
390
+ function linearMilestone(): SetupMilestone {
391
+ const tokens = getOAuthTokens("linear");
392
+ if (!tokens) {
393
+ return {
394
+ id: "linear",
395
+ label: "Linear connected",
396
+ state: "unverified",
397
+ hint: "Connect Linear via the integrations page.",
398
+ action_url: "/integrations/linear",
399
+ };
400
+ }
401
+ return {
402
+ id: "linear",
403
+ label: "Linear connected",
404
+ state: "verified",
405
+ hint: "Token row present; refresh-failure tracking will land in a future migration — check #swarm-alerts for keepalive errors.",
406
+ action_url: "/integrations/linear",
407
+ };
408
+ }
409
+
410
+ function jiraMilestone(): SetupMilestone {
411
+ const tokens = getOAuthTokens("jira");
412
+ if (!tokens) {
413
+ return {
414
+ id: "jira",
415
+ label: "Jira connected",
416
+ state: "unverified",
417
+ hint: "Connect Jira via the integrations page.",
418
+ action_url: "/integrations/jira",
419
+ };
420
+ }
421
+ // Verify cloudId is in oauth_apps.metadata.
422
+ const app = getOAuthApp("jira");
423
+ let hasCloudId = false;
424
+ try {
425
+ const meta = app?.metadata ? JSON.parse(app.metadata) : null;
426
+ hasCloudId = !!(meta && typeof meta === "object" && meta.cloudId);
427
+ } catch {
428
+ hasCloudId = false;
429
+ }
430
+ if (!hasCloudId) {
431
+ return {
432
+ id: "jira",
433
+ label: "Jira connected",
434
+ state: "unverified",
435
+ hint: "Token row present, but cloudId is not yet stored — finish the Jira OAuth callback.",
436
+ action_url: "/integrations/jira",
437
+ };
438
+ }
439
+ return {
440
+ id: "jira",
441
+ label: "Jira connected",
442
+ state: "verified",
443
+ hint: "Token row present; refresh-failure tracking will land in a future migration — check #swarm-alerts for keepalive errors.",
444
+ action_url: "/integrations/jira",
445
+ };
446
+ }
447
+
448
+ function workersMilestone(): SetupMilestone {
449
+ // `configured` if ≥1 row in agents; `verified` if both lead+worker alive
450
+ // within the last 5 minutes.
451
+ const totalRow = getDb()
452
+ .prepare<{ count: number }, []>(`SELECT COUNT(*) AS count FROM agents`)
453
+ .get();
454
+ const totalAgents = totalRow?.count ?? 0;
455
+
456
+ const { leads_alive, workers_alive } = getLiveAgentCounts(5);
457
+ if (leads_alive > 0 && workers_alive > 0) {
458
+ return {
459
+ id: "workers",
460
+ label: "Workers running",
461
+ state: "verified",
462
+ action_url: "/agents",
463
+ };
464
+ }
465
+
466
+ if (totalAgents > 0) {
467
+ return {
468
+ id: "workers",
469
+ label: "Workers running",
470
+ state: "configured",
471
+ hint:
472
+ leads_alive === 0
473
+ ? "Lead has no recent heartbeat — start the lead."
474
+ : "No workers heartbeated in the last 5 minutes — start a worker.",
475
+ action_url: "/agents",
476
+ };
477
+ }
478
+
479
+ return {
480
+ id: "workers",
481
+ label: "Workers running",
482
+ state: "unverified",
483
+ hint: "Run a worker container via Docker compose. See docs for setup.",
484
+ action_url: "/agents",
485
+ };
486
+ }
487
+
488
+ function firstTaskMilestone(): SetupMilestone {
489
+ if (hasFirstCompletedTask()) {
490
+ return {
491
+ id: "first_task",
492
+ label: "First task completed",
493
+ state: "verified",
494
+ action_url: "/tasks",
495
+ };
496
+ }
497
+ return {
498
+ id: "first_task",
499
+ label: "First task completed",
500
+ state: "unverified",
501
+ hint: "Send your first task to confirm the swarm runs end-to-end.",
502
+ action_url: "/tasks?new=true",
503
+ };
504
+ }
505
+
506
+ function buildSetup(): SetupMilestone[] {
507
+ return [
508
+ harnessMilestone(),
509
+ slackMilestone(),
510
+ githubMilestone(),
511
+ linearMilestone(),
512
+ jiraMilestone(),
513
+ workersMilestone(),
514
+ firstTaskMilestone(),
515
+ ];
516
+ }
517
+
518
+ // ─── Health aggregate (Phase 2) ──────────────────────────────────────────────
519
+
520
+ /**
521
+ * Roll the setup state into a single tri-state health value used by the
522
+ * header dot.
523
+ *
524
+ * Decision matrix:
525
+ * - Harness `unverified` (no creds at all) → `broken` — the swarm cannot run a task.
526
+ * - Workers `unverified` (no agents ever) → `broken` — same reason.
527
+ * - Harness `configured` (creds present, never live-tested) → `degraded`.
528
+ * - Any of {slack, github, linear, jira} `configured` (i.e. half-set-up) → `degraded`.
529
+ * - Otherwise → `ok`.
530
+ *
531
+ * Notes:
532
+ * - Worker `configured` (fleet exists but missed recent heartbeats) does NOT
533
+ * degrade the rollup. Heartbeat drift is a runtime concern surfaced on the
534
+ * /agents page and the dashboard canvas — not a setup health signal.
535
+ * - Integrations in `unverified` are NOT degrading on their own — most
536
+ * deployments don't connect every integration. They only nudge the rollup if
537
+ * paired with another integration in `configured` (the brainstorm contract).
538
+ */
539
+ export function computeHealth(setup: SetupMilestone[]): StatusHealth {
540
+ const byId = new Map(setup.map((m) => [m.id, m] as const));
541
+ const harness = byId.get("harness");
542
+ const workers = byId.get("workers");
543
+
544
+ // `broken` rules — critical blockers.
545
+ if (!harness || harness.state === "unverified") return "broken";
546
+ if (!workers || workers.state === "unverified") return "broken";
547
+
548
+ // `degraded` rules.
549
+ if (harness.state === "configured") return "degraded";
550
+
551
+ for (const id of ["slack", "github", "linear", "jira"] as const) {
552
+ const m = byId.get(id);
553
+ if (m?.state === "configured") return "degraded";
554
+ }
555
+
556
+ return "ok";
557
+ }
558
+
559
+ // ─── Public payload builder (also exported for tests) ────────────────────────
560
+
561
+ export function buildStatusPayload(): StatusResponse {
562
+ const setup = buildSetup();
563
+ return {
564
+ identity: buildIdentity(),
565
+ setup,
566
+ activity: getInstanceActivity(),
567
+ agent_fs: {
568
+ configured: !!process.env.AGENT_FS_API_URL,
569
+ base_url: process.env.AGENT_FS_API_URL ?? null,
570
+ },
571
+ health: computeHealth(setup),
572
+ };
573
+ }
574
+
575
+ // ─── Routes ──────────────────────────────────────────────────────────────────
576
+
577
+ const getStatus = route({
578
+ method: "get",
579
+ path: "/status",
580
+ pattern: ["status"],
581
+ summary: "Identity + setup readiness + live activity for the swarm dashboard",
582
+ description:
583
+ "Single source of truth consumed by the UI home page. Identity comes from SWARM_* envs; the 7 setup milestones each emit `unverified | configured | verified`; activity counts agents alive in the last 5 min and tasks created in the last 24h; agent_fs reports whether AGENT_FS_API_URL is set.",
584
+ tags: ["Status"],
585
+ responses: {
586
+ 200: { description: "Status payload", schema: StatusResponseSchema },
587
+ 401: { description: "Unauthorized" },
588
+ },
589
+ auth: { apiKey: true },
590
+ });
591
+
592
+ const postTestConnection = route({
593
+ method: "post",
594
+ path: "/status/test-connection",
595
+ pattern: ["status", "test-connection"],
596
+ summary: "Live-test the harness provider's credentials",
597
+ description:
598
+ "Issues a real upstream call (Anthropic /v1/models, OpenAI /v1/models, etc.) for the given provider. Updates an in-memory cache so the next GET /status reports `harness.state = 'verified'` for SWARM_VERIFY_TTL_MS (default 1h).",
599
+ tags: ["Status"],
600
+ body: TestConnectionRequestSchema,
601
+ responses: {
602
+ 200: { description: "Live-test result", schema: TestConnectionResponseSchema },
603
+ 400: { description: "Validation error" },
604
+ 401: { description: "Unauthorized" },
605
+ },
606
+ auth: { apiKey: true },
607
+ });
608
+
609
+ // ─── Handler ─────────────────────────────────────────────────────────────────
610
+
611
+ export async function handleStatus(
612
+ req: IncomingMessage,
613
+ res: ServerResponse,
614
+ pathSegments: string[],
615
+ queryParams: URLSearchParams,
616
+ ): Promise<boolean> {
617
+ if (getStatus.match(req.method, pathSegments)) {
618
+ try {
619
+ const payload = buildStatusPayload();
620
+ json(res, payload);
621
+ } catch (err) {
622
+ jsonError(res, err instanceof Error ? err.message : "Failed to build status", 500);
623
+ }
624
+ return true;
625
+ }
626
+
627
+ if (postTestConnection.match(req.method, pathSegments)) {
628
+ const parsed = await postTestConnection.parse(req, res, pathSegments, queryParams);
629
+ if (!parsed) return true;
630
+
631
+ const { provider } = parsed.body;
632
+ const roll = rollupCredStatusForProvider(provider);
633
+
634
+ // No workers registered for this provider — the operator needs to start
635
+ // a worker before any live test can run. Surface as a soft failure so
636
+ // the UI can render the hint inline.
637
+ if (roll.workers === 0) {
638
+ json(res, {
639
+ ok: false,
640
+ error: `No workers registered with HARNESS_PROVIDER=${provider}. Start a worker — its boot loop will run a live test and report it here.`,
641
+ latency_ms: 0,
642
+ });
643
+ return true;
644
+ }
645
+
646
+ if (!roll.latestLiveTest) {
647
+ json(res, {
648
+ ok: false,
649
+ error:
650
+ "Workers are registered but none have run a live test yet (still booting, or CRED_CHECK_DISABLE=1 was set).",
651
+ latency_ms: 0,
652
+ });
653
+ return true;
654
+ }
655
+
656
+ json(res, {
657
+ ok: roll.latestLiveTest.ok,
658
+ ...(roll.latestLiveTest.error ? { error: roll.latestLiveTest.error } : {}),
659
+ latency_ms: roll.latestLiveTest.latency_ms,
660
+ });
661
+ return true;
662
+ }
663
+
664
+ return false;
665
+ }