@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.
- package/README.md +1 -1
- package/openapi.json +973 -36
- package/package.json +2 -2
- package/src/be/db.ts +527 -9
- package/src/be/memory/raters/llm-summarizer.ts +218 -0
- package/src/be/memory/raters/llm.ts +56 -75
- package/src/be/memory/retrieval-store.ts +21 -0
- package/src/be/migrations/054_agent_harness_provider.sql +21 -0
- package/src/be/migrations/055_agent_cred_status.sql +15 -0
- package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
- package/src/be/migrations/057_inbox_item_state.sql +27 -0
- package/src/be/migrations/058_task_templates.sql +31 -0
- package/src/be/swarm-config-guard.ts +24 -0
- package/src/commands/credential-wait.ts +1 -1
- package/src/commands/provider-credentials.ts +434 -0
- package/src/commands/runner.ts +229 -42
- package/src/hooks/hook.ts +115 -95
- package/src/http/agents.ts +82 -2
- package/src/http/config.ts +11 -1
- package/src/http/inbox-state.ts +89 -0
- package/src/http/index.ts +10 -0
- package/src/http/sessions.ts +86 -0
- package/src/http/status.ts +665 -0
- package/src/http/task-templates.ts +51 -0
- package/src/http/tasks.ts +85 -5
- package/src/http/users.ts +134 -0
- package/src/providers/claude-adapter.ts +5 -0
- package/src/providers/codex-adapter.ts +1 -1
- package/src/providers/index.ts +1 -1
- package/src/slack/handlers.ts +0 -1
- package/src/tests/agents-harness-provider.test.ts +333 -0
- package/src/tests/credential-check.test.ts +32 -1
- package/src/tests/credential-status-api.test.ts +42 -0
- package/src/tests/harness-provider-resolution.test.ts +242 -0
- package/src/tests/jira-sync.test.ts +1 -1
- package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
- package/src/tests/memory-rater-llm.test.ts +265 -107
- package/src/tests/migration-runner-regressions.test.ts +17 -2
- package/src/tests/sessions.test.ts +141 -0
- package/src/tests/status.test.ts +843 -0
- package/src/tests/stop-hook-task-resolution.test.ts +98 -0
- package/src/tests/template-recommendations.test.ts +148 -0
- package/src/tests/use-dismissible-card.test.ts +140 -0
- package/src/tools/swarm-config/set-config.ts +17 -1
- package/src/types.ts +117 -0
- package/src/utils/harness-provider.ts +32 -0
- package/tsconfig.json +0 -2
- 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
|
+
}
|