@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
package/src/hooks/hook.ts
CHANGED
|
@@ -4,13 +4,13 @@ import pkg from "../../package.json";
|
|
|
4
4
|
import {
|
|
5
5
|
buildRatingsFromLlm,
|
|
6
6
|
buildSummaryWithRatingsPrompt,
|
|
7
|
-
|
|
7
|
+
dedupeRetrievalsForRater,
|
|
8
8
|
fetchRetrievalsForTask,
|
|
9
9
|
isLlmRaterEnabled,
|
|
10
|
-
parseSummaryWithRatings,
|
|
11
10
|
postRatings,
|
|
12
11
|
type RetrievalRow,
|
|
13
12
|
} from "../be/memory/raters/llm";
|
|
13
|
+
import { runMemoryRater } from "../be/memory/raters/llm-summarizer";
|
|
14
14
|
import type { Agent } from "../types";
|
|
15
15
|
import { checkToolLoop, clearToolHistory } from "./tool-loop-detection";
|
|
16
16
|
|
|
@@ -205,6 +205,32 @@ async function restoreClaudeMdBackup(): Promise<void> {
|
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Resolve task context for the Stop-hook session-summary / memory-rater
|
|
210
|
+
* piggyback. Prefers the AGENT_SWARM_TASK_ID env var (set by the harness in
|
|
211
|
+
* `claude-adapter.ts`) so the rater still works when the on-disk TASK_FILE
|
|
212
|
+
* was already cleaned up — the silent-drop bug PR #444 traced.
|
|
213
|
+
*/
|
|
214
|
+
export async function resolveStopHookTaskContext(
|
|
215
|
+
env: Record<string, string | undefined> = process.env,
|
|
216
|
+
): Promise<{ taskContext: string; taskId: string | undefined }> {
|
|
217
|
+
let taskContext = "";
|
|
218
|
+
let taskId: string | undefined = env.AGENT_SWARM_TASK_ID || undefined;
|
|
219
|
+
const taskFile = env.TASK_FILE;
|
|
220
|
+
if (taskFile) {
|
|
221
|
+
try {
|
|
222
|
+
const taskData = JSON.parse(await Bun.file(taskFile).text());
|
|
223
|
+
taskContext = `Task: ${taskData.task || "Unknown"}`;
|
|
224
|
+
if (!taskId) taskId = taskData.id;
|
|
225
|
+
} catch (err) {
|
|
226
|
+
// Don't blackhole the read failure — log it once so future regressions
|
|
227
|
+
// are visible. Same one-line debug pattern as PR #444's gate trace.
|
|
228
|
+
console.error("[memory-rater:llm] TASK_FILE read failed:", (err as Error).message);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { taskContext, taskId };
|
|
232
|
+
}
|
|
233
|
+
|
|
208
234
|
/**
|
|
209
235
|
* Main hook handler - processes Claude Code hook events
|
|
210
236
|
*/
|
|
@@ -1053,9 +1079,19 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
|
|
|
1053
1079
|
}
|
|
1054
1080
|
}
|
|
1055
1081
|
|
|
1056
|
-
// Session summarization via
|
|
1057
|
-
//
|
|
1058
|
-
|
|
1082
|
+
// Session summarization + LLM rater piggyback via OpenRouter (Vercel AI SDK).
|
|
1083
|
+
//
|
|
1084
|
+
// No-op when OPENROUTER_API_KEY is unset — self-hosters / OSS users
|
|
1085
|
+
// without OpenRouter skip session summary + LLM ratings entirely. The
|
|
1086
|
+
// previous `claude -p` path silently produced "Not logged in · Please
|
|
1087
|
+
// run /login" rows after the 2026-05-05 CLAUDE_CODE_VERSION bump
|
|
1088
|
+
// stopped propagating CLAUDE_CODE_OAUTH_TOKEN to hook subprocesses.
|
|
1089
|
+
if (
|
|
1090
|
+
agentInfo?.id &&
|
|
1091
|
+
msg.transcript_path &&
|
|
1092
|
+
!process.env.SKIP_SESSION_SUMMARY &&
|
|
1093
|
+
process.env.OPENROUTER_API_KEY
|
|
1094
|
+
) {
|
|
1059
1095
|
try {
|
|
1060
1096
|
let transcript = "";
|
|
1061
1097
|
try {
|
|
@@ -1067,19 +1103,10 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
|
|
|
1067
1103
|
}
|
|
1068
1104
|
|
|
1069
1105
|
if (transcript.length > 100) {
|
|
1070
|
-
//
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
const
|
|
1074
|
-
if (taskFile) {
|
|
1075
|
-
try {
|
|
1076
|
-
const taskData = JSON.parse(await Bun.file(taskFile).text());
|
|
1077
|
-
taskContext = `Task: ${taskData.task || "Unknown"}`;
|
|
1078
|
-
taskId = taskData.id;
|
|
1079
|
-
} catch {
|
|
1080
|
-
/* no task file */
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1106
|
+
// Prefer AGENT_SWARM_TASK_ID env var; fall back to TASK_FILE on
|
|
1107
|
+
// disk. PR #444 gate-trace showed the file disappears mid-session
|
|
1108
|
+
// and the silent catch dropped every LLM rater piggyback.
|
|
1109
|
+
const { taskContext, taskId } = await resolveStopHookTaskContext();
|
|
1083
1110
|
|
|
1084
1111
|
const apiUrl =
|
|
1085
1112
|
process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
|
|
@@ -1091,15 +1118,17 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
|
|
|
1091
1118
|
const llmRaterEnabled = isLlmRaterEnabled();
|
|
1092
1119
|
let retrievals: RetrievalRow[] = [];
|
|
1093
1120
|
if (llmRaterEnabled && taskId) {
|
|
1094
|
-
|
|
1121
|
+
const rawRetrievals = await fetchRetrievalsForTask({
|
|
1095
1122
|
apiUrl,
|
|
1096
1123
|
apiKey,
|
|
1097
1124
|
agentId: agentInfo.id,
|
|
1098
1125
|
taskId,
|
|
1099
1126
|
});
|
|
1127
|
+
// Dedup self-similar cron-task memories before sending to the
|
|
1128
|
+
// rater — see `dedupeRetrievalsForRater` doc for the why.
|
|
1129
|
+
retrievals = dedupeRetrievalsForRater(rawRetrievals);
|
|
1100
1130
|
}
|
|
1101
1131
|
|
|
1102
|
-
// Summarize with Claude Haiku — extract only high-value learnings
|
|
1103
1132
|
const baseSummarizePrompt = `You are summarizing an AI agent's work session. Extract ONLY high-value learnings.
|
|
1104
1133
|
|
|
1105
1134
|
DO NOT include:
|
|
@@ -1119,85 +1148,76 @@ ${taskContext ? `\nTask context: ${taskContext}` : ""}
|
|
|
1119
1148
|
Transcript:
|
|
1120
1149
|
${transcript}`;
|
|
1121
1150
|
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
const tmpFile = `/tmp/session-summary-${Date.now()}.txt`;
|
|
1128
|
-
await Bun.write(tmpFile, summarizePrompt);
|
|
1129
|
-
const proc = Bun.spawn(
|
|
1130
|
-
["bash", "-c", `cat "${tmpFile}" | claude -p --model haiku --output-format json`],
|
|
1131
|
-
{
|
|
1132
|
-
stdout: "pipe",
|
|
1133
|
-
stderr: "pipe",
|
|
1134
|
-
env: { ...process.env, SKIP_SESSION_SUMMARY: "1" },
|
|
1135
|
-
},
|
|
1136
|
-
);
|
|
1137
|
-
const timeoutId = setTimeout(() => proc.kill(), 30000);
|
|
1138
|
-
const result = { stdout: await new Response(proc.stdout).text() };
|
|
1139
|
-
clearTimeout(timeoutId);
|
|
1140
|
-
await Bun.$`rm -f ${tmpFile}`.quiet();
|
|
1141
|
-
|
|
1142
|
-
let summary: string;
|
|
1143
|
-
let parsedRatings: ReturnType<typeof parseSummaryWithRatings> = null;
|
|
1144
|
-
if (wantsRatings) {
|
|
1145
|
-
parsedRatings = parseSummaryWithRatings(result.stdout);
|
|
1146
|
-
}
|
|
1147
|
-
if (parsedRatings) {
|
|
1148
|
-
summary = parsedRatings.summary;
|
|
1149
|
-
} else {
|
|
1150
|
-
// Fallback: never index raw JSON. extractSummaryFromClaudeStdout
|
|
1151
|
-
// pulls the `summary` field out of a structured-output payload
|
|
1152
|
-
// whose ratings failed schema validation; otherwise behaves
|
|
1153
|
-
// like the previous unstructured envelope.result extraction.
|
|
1154
|
-
summary = extractSummaryFromClaudeStdout(result.stdout);
|
|
1155
|
-
}
|
|
1151
|
+
// Always ask for the structured (summary + ratings) payload — same
|
|
1152
|
+
// cost as the unstructured path. Empty retrievals → empty memory
|
|
1153
|
+
// block → ratings: []; the postRatings gate below still skips the
|
|
1154
|
+
// POST when there's nothing to send.
|
|
1155
|
+
const summarizePrompt = buildSummaryWithRatingsPrompt(baseSummarizePrompt, retrievals);
|
|
1156
1156
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
headers: {
|
|
1166
|
-
"Content-Type": "application/json",
|
|
1167
|
-
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
1168
|
-
"X-Agent-ID": agentInfo.id,
|
|
1169
|
-
},
|
|
1170
|
-
body: JSON.stringify({
|
|
1171
|
-
agentId: agentInfo.id,
|
|
1172
|
-
content: summary,
|
|
1173
|
-
name: taskContext
|
|
1174
|
-
? `Session: ${taskContext.slice(0, 80)}`
|
|
1175
|
-
: `Session: ${new Date().toISOString().slice(0, 16)}`,
|
|
1176
|
-
scope: "agent",
|
|
1177
|
-
source: "session_summary",
|
|
1178
|
-
...(taskId ? { sourceTaskId: taskId } : {}),
|
|
1179
|
-
}),
|
|
1157
|
+
const raterResult = await runMemoryRater({
|
|
1158
|
+
prompt: summarizePrompt,
|
|
1159
|
+
apiKey: process.env.OPENROUTER_API_KEY,
|
|
1160
|
+
});
|
|
1161
|
+
if (!raterResult.ok) {
|
|
1162
|
+
console.error("[memory-rater:llm] runMemoryRater returned non-ok", {
|
|
1163
|
+
reason: raterResult.reason,
|
|
1164
|
+
...(raterResult.status !== undefined ? { status: raterResult.status } : {}),
|
|
1180
1165
|
});
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1166
|
+
} else {
|
|
1167
|
+
const summary = raterResult.data.summary;
|
|
1168
|
+
const ratings = raterResult.data.ratings;
|
|
1169
|
+
|
|
1170
|
+
// Skip indexing if the session had no significant learnings
|
|
1171
|
+
if (
|
|
1172
|
+
summary &&
|
|
1173
|
+
summary.length > 20 &&
|
|
1174
|
+
!summary.trim().toLowerCase().includes("no significant learnings")
|
|
1175
|
+
) {
|
|
1176
|
+
await fetch(`${apiUrl}/api/memory/index`, {
|
|
1177
|
+
method: "POST",
|
|
1178
|
+
headers: {
|
|
1179
|
+
"Content-Type": "application/json",
|
|
1180
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
1181
|
+
"X-Agent-ID": agentInfo.id,
|
|
1182
|
+
},
|
|
1183
|
+
body: JSON.stringify({
|
|
1191
1184
|
agentId: agentInfo.id,
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1185
|
+
content: summary,
|
|
1186
|
+
name: taskContext
|
|
1187
|
+
? `Session: ${taskContext.slice(0, 80)}`
|
|
1188
|
+
: `Session: ${new Date().toISOString().slice(0, 16)}`,
|
|
1189
|
+
scope: "agent",
|
|
1190
|
+
source: "session_summary",
|
|
1191
|
+
...(taskId ? { sourceTaskId: taskId } : {}),
|
|
1192
|
+
}),
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Best-effort: post LLM ratings. Never blocks summary indexing.
|
|
1197
|
+
if (llmRaterEnabled && taskId && retrievals.length > 0 && ratings.length === 0) {
|
|
1198
|
+
console.error("[memory-rater:llm] piggyback produced no ratings", {
|
|
1199
|
+
retrievalsLen: retrievals.length,
|
|
1200
|
+
ratingsLen: 0,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
if (llmRaterEnabled && taskId && ratings.length > 0) {
|
|
1204
|
+
try {
|
|
1205
|
+
const events = buildRatingsFromLlm(ratings, retrievals);
|
|
1206
|
+
if (events.length > 0) {
|
|
1207
|
+
await postRatings({
|
|
1208
|
+
apiUrl,
|
|
1209
|
+
apiKey,
|
|
1210
|
+
agentId: agentInfo.id,
|
|
1211
|
+
taskId,
|
|
1212
|
+
events,
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
} catch (err) {
|
|
1216
|
+
console.error(
|
|
1217
|
+
"[memory-rater:llm] piggyback rating emission failed:",
|
|
1218
|
+
(err as Error).message,
|
|
1219
|
+
);
|
|
1195
1220
|
}
|
|
1196
|
-
} catch (err) {
|
|
1197
|
-
console.error(
|
|
1198
|
-
"[memory-rater:llm] piggyback rating emission failed:",
|
|
1199
|
-
(err as Error).message,
|
|
1200
|
-
);
|
|
1201
1221
|
}
|
|
1202
1222
|
}
|
|
1203
1223
|
}
|
package/src/http/agents.ts
CHANGED
|
@@ -10,15 +10,18 @@ import {
|
|
|
10
10
|
getDb,
|
|
11
11
|
getSwarmConfigs,
|
|
12
12
|
resetEmptyPollCount,
|
|
13
|
+
setAgentHarnessProvider,
|
|
13
14
|
updateAgentActivity,
|
|
14
15
|
updateAgentCredentialState,
|
|
16
|
+
updateAgentCredStatus,
|
|
15
17
|
updateAgentMaxTasks,
|
|
16
18
|
updateAgentName,
|
|
17
19
|
updateAgentProfile,
|
|
18
20
|
updateAgentProvider,
|
|
19
21
|
updateAgentStatus,
|
|
22
|
+
upsertSwarmConfig,
|
|
20
23
|
} from "../be/db";
|
|
21
|
-
import { ProviderNameSchema } from "../types";
|
|
24
|
+
import { AgentCredStatusSchema, ProviderNameSchema } from "../types";
|
|
22
25
|
import { route } from "./route-def";
|
|
23
26
|
import { agentWithCapacity, json, jsonError } from "./utils";
|
|
24
27
|
|
|
@@ -38,6 +41,12 @@ const registerAgent = route({
|
|
|
38
41
|
capabilities: z.array(z.string()).optional(),
|
|
39
42
|
maxTasks: z.number().int().optional(),
|
|
40
43
|
provider: ProviderNameSchema.optional(),
|
|
44
|
+
/**
|
|
45
|
+
* Phase 1.5 (cloud-personalization): worker-pushed canonical harness
|
|
46
|
+
* provider. Persists to `agents.harness_provider`. Validated against
|
|
47
|
+
* the canonical list — unknown values reject the request with 400.
|
|
48
|
+
*/
|
|
49
|
+
harness_provider: ProviderNameSchema.optional(),
|
|
41
50
|
}),
|
|
42
51
|
responses: {
|
|
43
52
|
200: { description: "Agent re-registered (already existed)" },
|
|
@@ -46,6 +55,25 @@ const registerAgent = route({
|
|
|
46
55
|
},
|
|
47
56
|
});
|
|
48
57
|
|
|
58
|
+
const setAgentHarnessProviderRoute = route({
|
|
59
|
+
method: "patch",
|
|
60
|
+
path: "/api/agents/{id}/harness-provider",
|
|
61
|
+
pattern: ["api", "agents", null, "harness-provider"],
|
|
62
|
+
summary: "Re-assign an agent's harness_provider (live)",
|
|
63
|
+
description:
|
|
64
|
+
"Updates `agents.harness_provider` and upserts `swarm_config` (scope=agent, key=HARNESS_PROVIDER) so the worker's poll-loop reconciliation picks up the new provider within ~10s. No restart required. The swarm_config row is what actually drives the worker; the column mirrors the latest set value for dashboards.",
|
|
65
|
+
tags: ["Agents"],
|
|
66
|
+
params: z.object({ id: z.string() }),
|
|
67
|
+
body: z.object({
|
|
68
|
+
harness_provider: ProviderNameSchema,
|
|
69
|
+
}),
|
|
70
|
+
responses: {
|
|
71
|
+
200: { description: "Updated agent row" },
|
|
72
|
+
400: { description: "Validation error (unknown provider)" },
|
|
73
|
+
404: { description: "Agent not found" },
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
49
77
|
const listAgents = route({
|
|
50
78
|
method: "get",
|
|
51
79
|
path: "/api/agents",
|
|
@@ -150,6 +178,13 @@ const credentialStatusBody = z.object({
|
|
|
150
178
|
ready: z.boolean(),
|
|
151
179
|
/** Env-var names (or absolute file paths) the worker is blocked on. Empty/null when ready. */
|
|
152
180
|
missing: z.array(z.string()).optional().nullable(),
|
|
181
|
+
/**
|
|
182
|
+
* Migration 055: full credential snapshot (presence + live test). Optional
|
|
183
|
+
* for backward compat — older workers may only POST `{ready, missing}`.
|
|
184
|
+
* When present, written to `agents.cred_status` as JSON; the dashboard
|
|
185
|
+
* reads the row instead of running its own check.
|
|
186
|
+
*/
|
|
187
|
+
cred_status: AgentCredStatusSchema.optional().nullable(),
|
|
153
188
|
});
|
|
154
189
|
|
|
155
190
|
const updateAgentCredentialStatusRoute = route({
|
|
@@ -219,6 +254,17 @@ export async function handleAgentRegister(
|
|
|
219
254
|
if (parsed.body.provider && parsed.body.provider !== existingAgent.provider) {
|
|
220
255
|
updateAgentProvider(existingAgent.id, parsed.body.provider);
|
|
221
256
|
}
|
|
257
|
+
// Phase 1.5: worker-pushed harness_provider always wins on
|
|
258
|
+
// re-registration. Env-driven, by design (per-agent live override
|
|
259
|
+
// belongs to DES-359). NULL => leave existing column untouched
|
|
260
|
+
// so PATCH /harness-provider doesn't get clobbered by re-register
|
|
261
|
+
// payloads from older workers.
|
|
262
|
+
if (
|
|
263
|
+
parsed.body.harness_provider &&
|
|
264
|
+
parsed.body.harness_provider !== existingAgent.harnessProvider
|
|
265
|
+
) {
|
|
266
|
+
setAgentHarnessProvider(existingAgent.id, parsed.body.harness_provider);
|
|
267
|
+
}
|
|
222
268
|
resetEmptyPollCount(existingAgent.id);
|
|
223
269
|
return { agent: getAgentById(agentId), created: false };
|
|
224
270
|
}
|
|
@@ -233,6 +279,7 @@ export async function handleAgentRegister(
|
|
|
233
279
|
capabilities: parsed.body.capabilities ?? [],
|
|
234
280
|
maxTasks: parsed.body.maxTasks ?? 1,
|
|
235
281
|
provider: parsed.body.provider,
|
|
282
|
+
harnessProvider: parsed.body.harness_provider ?? null,
|
|
236
283
|
});
|
|
237
284
|
|
|
238
285
|
return { agent, created: true };
|
|
@@ -399,6 +446,28 @@ export async function handleAgentsRest(
|
|
|
399
446
|
return true;
|
|
400
447
|
}
|
|
401
448
|
|
|
449
|
+
if (setAgentHarnessProviderRoute.match(req.method, pathSegments)) {
|
|
450
|
+
const parsed = await setAgentHarnessProviderRoute.parse(req, res, pathSegments, queryParams);
|
|
451
|
+
if (!parsed) return true;
|
|
452
|
+
const agent = setAgentHarnessProvider(parsed.params.id, parsed.body.harness_provider);
|
|
453
|
+
if (!agent) {
|
|
454
|
+
jsonError(res, "Agent not found", 404);
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
// Mirror to swarm_config (scope=agent) so the worker's reconciliation
|
|
458
|
+
// loop actually reads the new value. The column above is for dashboard
|
|
459
|
+
// visibility; this row is the live override.
|
|
460
|
+
upsertSwarmConfig({
|
|
461
|
+
scope: "agent",
|
|
462
|
+
scopeId: parsed.params.id,
|
|
463
|
+
key: "HARNESS_PROVIDER",
|
|
464
|
+
value: parsed.body.harness_provider,
|
|
465
|
+
description: "Set via PATCH /api/agents/{id}/harness-provider",
|
|
466
|
+
});
|
|
467
|
+
json(res, agentWithCapacity(agent));
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
|
|
402
471
|
// Bulk credential-status MUST be matched BEFORE single-agent routes — the
|
|
403
472
|
// path "api/agents/credential-status" otherwise looks like an agent id.
|
|
404
473
|
if (listCredentialStatusRoute.match(req.method, pathSegments)) {
|
|
@@ -413,6 +482,8 @@ export async function handleAgentsRest(
|
|
|
413
482
|
status: a.status,
|
|
414
483
|
missing: a.credentialMissing ?? [],
|
|
415
484
|
provider: a.provider ?? null,
|
|
485
|
+
harnessProvider: a.harnessProvider ?? null,
|
|
486
|
+
credStatus: a.credStatus ?? null,
|
|
416
487
|
lastCheckedAt: a.lastUpdatedAt,
|
|
417
488
|
}));
|
|
418
489
|
json(res, { agents });
|
|
@@ -436,7 +507,14 @@ export async function handleAgentsRest(
|
|
|
436
507
|
jsonError(res, "Agent not found", 404);
|
|
437
508
|
return true;
|
|
438
509
|
}
|
|
439
|
-
|
|
510
|
+
// Phase 055: persist the richer worker-reported snapshot when sent.
|
|
511
|
+
// We accept `null` to explicitly clear (e.g. on harness change), and
|
|
512
|
+
// `undefined` to leave the existing row value untouched.
|
|
513
|
+
const finalAgent =
|
|
514
|
+
parsed.body.cred_status !== undefined
|
|
515
|
+
? (updateAgentCredStatus(parsed.params.id, parsed.body.cred_status ?? null) ?? agent)
|
|
516
|
+
: agent;
|
|
517
|
+
json(res, agentWithCapacity(finalAgent));
|
|
440
518
|
return true;
|
|
441
519
|
}
|
|
442
520
|
|
|
@@ -454,6 +532,8 @@ export async function handleAgentsRest(
|
|
|
454
532
|
status: agent.status,
|
|
455
533
|
missing: agent.credentialMissing ?? [],
|
|
456
534
|
provider: agent.provider ?? null,
|
|
535
|
+
harnessProvider: agent.harnessProvider ?? null,
|
|
536
|
+
credStatus: agent.credStatus ?? null,
|
|
457
537
|
lastCheckedAt: agent.lastUpdatedAt,
|
|
458
538
|
});
|
|
459
539
|
return true;
|
package/src/http/config.ts
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
maskSecrets,
|
|
10
10
|
upsertSwarmConfig,
|
|
11
11
|
} from "../be/db";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
isReservedConfigKey,
|
|
14
|
+
reservedKeyError,
|
|
15
|
+
validateConfigValue,
|
|
16
|
+
} from "../be/swarm-config-guard";
|
|
13
17
|
import { reloadGlobalConfigsAndIntegrations } from "./core";
|
|
14
18
|
import { route } from "./route-def";
|
|
15
19
|
import { json, jsonError } from "./utils";
|
|
@@ -230,6 +234,12 @@ export async function handleConfig(
|
|
|
230
234
|
return true;
|
|
231
235
|
}
|
|
232
236
|
|
|
237
|
+
const validationError = validateConfigValue(key, value);
|
|
238
|
+
if (validationError) {
|
|
239
|
+
jsonError(res, validationError, 400);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
233
243
|
try {
|
|
234
244
|
const includeSecrets = queryParams.get("includeSecrets") === "true";
|
|
235
245
|
const config = upsertSwarmConfig({
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { listInboxState, upsertInboxState } from "../be/db";
|
|
4
|
+
import { InboxItemStatusSchema, InboxItemTypeSchema } from "../types";
|
|
5
|
+
import { route } from "./route-def";
|
|
6
|
+
import { json, jsonError } from "./utils";
|
|
7
|
+
|
|
8
|
+
// ─── Route Definitions ───────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const listState = route({
|
|
11
|
+
method: "get",
|
|
12
|
+
path: "/api/inbox-state",
|
|
13
|
+
pattern: ["api", "inbox-state"],
|
|
14
|
+
summary: "List inbox-item state rows for a user",
|
|
15
|
+
tags: ["Inbox State"],
|
|
16
|
+
query: z.object({
|
|
17
|
+
userId: z.string(),
|
|
18
|
+
status: InboxItemStatusSchema.optional(),
|
|
19
|
+
itemType: InboxItemTypeSchema.optional(),
|
|
20
|
+
}),
|
|
21
|
+
responses: {
|
|
22
|
+
200: { description: "Inbox state rows" },
|
|
23
|
+
400: { description: "Validation error" },
|
|
24
|
+
401: { description: "Unauthorized" },
|
|
25
|
+
},
|
|
26
|
+
auth: { apiKey: true },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const upsertState = route({
|
|
30
|
+
method: "patch",
|
|
31
|
+
path: "/api/inbox-state",
|
|
32
|
+
pattern: ["api", "inbox-state"],
|
|
33
|
+
summary: "Upsert per-user dismiss/snooze/done state for an inbox item",
|
|
34
|
+
tags: ["Inbox State"],
|
|
35
|
+
body: z.object({
|
|
36
|
+
userId: z.string(),
|
|
37
|
+
itemType: InboxItemTypeSchema,
|
|
38
|
+
itemId: z.string().min(1),
|
|
39
|
+
status: InboxItemStatusSchema,
|
|
40
|
+
snoozeUntil: z.string().datetime().optional(),
|
|
41
|
+
}),
|
|
42
|
+
responses: {
|
|
43
|
+
200: { description: "Upserted inbox state row" },
|
|
44
|
+
400: { description: "Validation error" },
|
|
45
|
+
401: { description: "Unauthorized" },
|
|
46
|
+
},
|
|
47
|
+
auth: { apiKey: true },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export async function handleInboxState(
|
|
53
|
+
req: IncomingMessage,
|
|
54
|
+
res: ServerResponse,
|
|
55
|
+
pathSegments: string[],
|
|
56
|
+
queryParams: URLSearchParams,
|
|
57
|
+
): Promise<boolean> {
|
|
58
|
+
if (listState.match(req.method, pathSegments)) {
|
|
59
|
+
const parsed = await listState.parse(req, res, pathSegments, queryParams);
|
|
60
|
+
if (!parsed) return true;
|
|
61
|
+
const items = listInboxState({
|
|
62
|
+
userId: parsed.query.userId,
|
|
63
|
+
status: parsed.query.status,
|
|
64
|
+
itemType: parsed.query.itemType,
|
|
65
|
+
});
|
|
66
|
+
json(res, { items });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (upsertState.match(req.method, pathSegments)) {
|
|
71
|
+
const parsed = await upsertState.parse(req, res, pathSegments, queryParams);
|
|
72
|
+
if (!parsed) return true;
|
|
73
|
+
try {
|
|
74
|
+
const item = upsertInboxState({
|
|
75
|
+
userId: parsed.body.userId,
|
|
76
|
+
itemType: parsed.body.itemType,
|
|
77
|
+
itemId: parsed.body.itemId,
|
|
78
|
+
status: parsed.body.status,
|
|
79
|
+
snoozeUntil: parsed.body.snoozeUntil,
|
|
80
|
+
});
|
|
81
|
+
json(res, { item });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
jsonError(res, err instanceof Error ? err.message : "Failed to upsert inbox state", 500);
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return false;
|
|
89
|
+
}
|
package/src/http/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { handleDbQuery } from "./db-query";
|
|
|
29
29
|
import { handleEcosystem } from "./ecosystem";
|
|
30
30
|
import { handleEvents } from "./events";
|
|
31
31
|
import { handleHeartbeat } from "./heartbeat";
|
|
32
|
+
import { handleInboxState } from "./inbox-state";
|
|
32
33
|
import { handleIntegrations } from "./integrations";
|
|
33
34
|
import { handleMcp } from "./mcp";
|
|
34
35
|
import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from "./mcp-oauth";
|
|
@@ -40,10 +41,14 @@ import { handlePromptTemplates } from "./prompt-templates";
|
|
|
40
41
|
import { handleRepos } from "./repos";
|
|
41
42
|
import { handleSchedules } from "./schedules";
|
|
42
43
|
import { handleSessionData } from "./session-data";
|
|
44
|
+
import { handleSessions } from "./sessions";
|
|
43
45
|
import { handleSkills } from "./skills";
|
|
44
46
|
import { handleStats } from "./stats";
|
|
47
|
+
import { handleStatus } from "./status";
|
|
48
|
+
import { handleTaskTemplates } from "./task-templates";
|
|
45
49
|
import { handleTasks } from "./tasks";
|
|
46
50
|
import { handleTrackers } from "./trackers";
|
|
51
|
+
import { handleUsers } from "./users";
|
|
47
52
|
import { getPathSegments, parseQueryParams, setCorsHeaders } from "./utils";
|
|
48
53
|
import { handleWebhooks } from "./webhooks";
|
|
49
54
|
import { handleWorkflowEvents } from "./workflow-events";
|
|
@@ -126,6 +131,7 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
126
131
|
() => handleContext(req, res, pathSegments, queryParams, myAgentId),
|
|
127
132
|
() => handleTasks(req, res, pathSegments, queryParams, myAgentId),
|
|
128
133
|
() => handleStats(req, res, pathSegments, queryParams),
|
|
134
|
+
() => handleStatus(req, res, pathSegments, queryParams),
|
|
129
135
|
() => handleActiveSessions(req, res, pathSegments, queryParams, myAgentId),
|
|
130
136
|
() => handlePricing(req, res, pathSegments, queryParams, myAgentId),
|
|
131
137
|
() => handleSchedules(req, res, pathSegments, queryParams, myAgentId),
|
|
@@ -144,6 +150,10 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
144
150
|
() => handleApiKeys(req, res, pathSegments, queryParams),
|
|
145
151
|
() => handleHeartbeat(req, res, pathSegments),
|
|
146
152
|
() => handleEvents(req, res, pathSegments, queryParams, myAgentId),
|
|
153
|
+
() => handleUsers(req, res, pathSegments, queryParams),
|
|
154
|
+
() => handleSessions(req, res, pathSegments, queryParams),
|
|
155
|
+
() => handleInboxState(req, res, pathSegments, queryParams),
|
|
156
|
+
() => handleTaskTemplates(req, res, pathSegments, queryParams),
|
|
147
157
|
() => handleMcp(req, res, transports),
|
|
148
158
|
];
|
|
149
159
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getRootTaskChain, getTaskById, listRecentSessions } from "../be/db";
|
|
4
|
+
import { route } from "./route-def";
|
|
5
|
+
import { json, jsonError } from "./utils";
|
|
6
|
+
|
|
7
|
+
// ─── Route Definitions ───────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const listSessions = route({
|
|
10
|
+
method: "get",
|
|
11
|
+
path: "/api/sessions",
|
|
12
|
+
pattern: ["api", "sessions"],
|
|
13
|
+
summary: "List recent task sessions (root tasks + chain summary)",
|
|
14
|
+
tags: ["Sessions"],
|
|
15
|
+
query: z.object({
|
|
16
|
+
limit: z.coerce.number().int().optional(),
|
|
17
|
+
offset: z.coerce.number().int().optional(),
|
|
18
|
+
/** Comma-separated source filter (e.g. `ui,slack`). Omit to include all. */
|
|
19
|
+
source: z.string().optional(),
|
|
20
|
+
/** Case-insensitive substring match against the root task's text. */
|
|
21
|
+
q: z.string().optional(),
|
|
22
|
+
}),
|
|
23
|
+
responses: {
|
|
24
|
+
200: { description: "Recent sessions ordered by chain-wide last activity" },
|
|
25
|
+
401: { description: "Unauthorized" },
|
|
26
|
+
},
|
|
27
|
+
auth: { apiKey: true },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const getSession = route({
|
|
31
|
+
method: "get",
|
|
32
|
+
path: "/api/sessions/{rootTaskId}",
|
|
33
|
+
pattern: ["api", "sessions", null],
|
|
34
|
+
summary: "Get a session — root task + the entire descendant chain",
|
|
35
|
+
tags: ["Sessions"],
|
|
36
|
+
params: z.object({ rootTaskId: z.string() }),
|
|
37
|
+
responses: {
|
|
38
|
+
200: { description: "Root task + chain (ordered by createdAt)" },
|
|
39
|
+
401: { description: "Unauthorized" },
|
|
40
|
+
404: { description: "Root task not found" },
|
|
41
|
+
},
|
|
42
|
+
auth: { apiKey: true },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export async function handleSessions(
|
|
48
|
+
req: IncomingMessage,
|
|
49
|
+
res: ServerResponse,
|
|
50
|
+
pathSegments: string[],
|
|
51
|
+
queryParams: URLSearchParams,
|
|
52
|
+
): Promise<boolean> {
|
|
53
|
+
if (listSessions.match(req.method, pathSegments)) {
|
|
54
|
+
const parsed = await listSessions.parse(req, res, pathSegments, queryParams);
|
|
55
|
+
if (!parsed) return true;
|
|
56
|
+
const sources = parsed.query.source
|
|
57
|
+
? parsed.query.source
|
|
58
|
+
.split(",")
|
|
59
|
+
.map((s) => s.trim())
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
: undefined;
|
|
62
|
+
const sessions = listRecentSessions({
|
|
63
|
+
limit: parsed.query.limit,
|
|
64
|
+
offset: parsed.query.offset,
|
|
65
|
+
source: sources,
|
|
66
|
+
q: parsed.query.q,
|
|
67
|
+
});
|
|
68
|
+
json(res, { sessions });
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (getSession.match(req.method, pathSegments)) {
|
|
73
|
+
const parsed = await getSession.parse(req, res, pathSegments, queryParams);
|
|
74
|
+
if (!parsed) return true;
|
|
75
|
+
const root = getTaskById(parsed.params.rootTaskId);
|
|
76
|
+
if (!root) {
|
|
77
|
+
jsonError(res, "Root task not found", 404);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
const chain = getRootTaskChain(parsed.params.rootTaskId);
|
|
81
|
+
json(res, { root, chain });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return false;
|
|
86
|
+
}
|