@desplega.ai/agent-swarm 1.135.2 → 1.136.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/dist/{actions-3az2vwwn.js → actions-agcbc8nd.js} +6 -6
- package/dist/{app-vygnfvrh.js → app-g2s78xya.js} +3 -3
- package/dist/{assistant-2jr2frrm.js → assistant-yd3jkb9d.js} +8 -8
- package/dist/{boot-reembed-y7r9cxt0.js → boot-reembed-6spqw4q0.js} +3 -3
- package/dist/{boot-reembed-ncbg16rb.js → boot-reembed-b22s9w5x.js} +4 -4
- package/dist/{boot-scrub-logs-t82rwa0y.js → boot-scrub-logs-6h3cqkc0.js} +2 -2
- package/dist/{claude-adapter-vvx2aw3v.js → claude-adapter-ng5y8zd7.js} +4 -4
- package/dist/{cli-mfa2eaef.js → cli-35jknh9e.js} +5 -5
- package/dist/{cli-cv2vjg2y.js → cli-3y553gws.js} +1 -1
- package/dist/{cli-nhvym0d2.js → cli-4gzv54ss.js} +15 -6
- package/dist/{cli-4dakdn30.js → cli-4r4jkqep.js} +6 -6
- package/dist/{cli-83razmqg.js → cli-54ht104h.js} +3 -3
- package/dist/{cli-6290n4yz.js → cli-5bnz1t86.js} +1 -1
- package/dist/{cli-6bz2hzvw.js → cli-68vayryq.js} +2 -2
- package/dist/{cli-1z01xdqh.js → cli-6gwgzdgw.js} +2 -2
- package/dist/{cli-1dfatkzm.js → cli-84vrhesc.js} +1 -1
- package/dist/{cli-jfrh963q.js → cli-8v57p983.js} +10 -10
- package/dist/{cli-3f0tnh34.js → cli-ads0hfw2.js} +81 -23
- package/dist/{cli-fmha74ga.js → cli-bnq8h7ea.js} +3 -3
- package/dist/{cli-j3xnd5b4.js → cli-d5ygfv18.js} +7 -6
- package/dist/{cli-8f4h27gm.js → cli-edwrx42s.js} +1 -1
- package/dist/{cli-r5vwg1ma.js → cli-fpjmcbhz.js} +4 -4
- package/dist/{cli-w0ygcv43.js → cli-gpdtxzar.js} +6 -4
- package/dist/{cli-4rdjdddh.js → cli-hz5hp6wn.js} +1 -1
- package/dist/{cli-6mmwmy45.js → cli-j7qp8gtf.js} +1 -1
- package/dist/{cli-5a5nrq3g.js → cli-jjhnma34.js} +5 -5
- package/dist/{cli-ey09krp2.js → cli-m3z53bed.js} +1 -1
- package/dist/{cli-cf6vq6qa.js → cli-mgef08pc.js} +1 -1
- package/dist/{cli-0jhyxtdj.js → cli-qckgdqmr.js} +2 -2
- package/dist/{cli-a1zrc2g6.js → cli-qhfkxrpg.js} +108 -33
- package/dist/{cli-yw2z5kj9.js → cli-spf767vc.js} +1 -1
- package/dist/{cli-1857msnd.js → cli-tee8w7tq.js} +1 -1
- package/dist/{cli-qxn3g3yx.js → cli-tmn19cdz.js} +61 -6
- package/dist/{cli-7ccy0h4r.js → cli-y16cesaq.js} +2 -2
- package/dist/{cli-cm4z7w7w.js → cli-z6e8eqrm.js} +2 -2
- package/dist/cli.js +16 -16
- package/dist/{codex-adapter-6p290yq3.js → codex-adapter-kw11ps3j.js} +2 -2
- package/dist/{codex-session-runner-7b3dhpmx.js → codex-session-runner-sffb69v7.js} +2 -2
- package/dist/{commands-wxg1g4nq.js → commands-0xxp4tz8.js} +2 -2
- package/dist/{db-8fqeheve.js → db-0npp39zd.js} +6 -2
- package/dist/{handlers-gwtqp17c.js → handlers-5p7a1jx6.js} +15 -9
- package/dist/{hook-gd9fwfp0.js → hook-hpefr0a8.js} +2 -2
- package/dist/{hook-mrqygs7p.js → hook-jjpxn4se.js} +3 -3
- package/dist/{http-x4mc1mqg.js → http-wfp33653.js} +322 -61
- package/dist/{index-yd654kac.js → index-17h657rg.js} +12 -8
- package/dist/{index-pzgasj16.js → index-m2p7ctyh.js} +10 -10
- package/dist/{index-2mdqrd0n.js → index-racb4wfq.js} +9 -9
- package/dist/{index-4djjkp9z.js → index-zh66wm65.js} +8 -8
- package/dist/{keepalive-3tycspcy.js → keepalive-wxjd633a.js} +4 -4
- package/dist/{lead-js1h3z7d.js → lead-359ec2z0.js} +23 -23
- package/dist/{maintenance-y91ry8q4.js → maintenance-rr03qjtb.js} +4 -4
- package/dist/{mistral-conversations-hdmnkf6h.js → mistral-conversations-qdrnmrb8.js} +1 -1
- package/dist/{oauth-refresh-sweep-s33gtxrw.js → oauth-refresh-sweep-jwj22yb8.js} +3 -3
- package/dist/{onboard-vndj64mb.js → onboard-asnhchcv.js} +2 -2
- package/dist/{openai-completions-cwy2kkzg.js → openai-completions-yzftqrft.js} +33 -4
- package/dist/{otel-impl-d2p84k6e.js → otel-impl-54cra6mw.js} +18 -4
- package/dist/{pi-mono-adapter-1phspz2y.js → pi-mono-adapter-29g7axgj.js} +669 -335
- package/dist/{pricing-refresh-c2bwsyk5.js → pricing-refresh-mkyy7c2y.js} +4 -4
- package/dist/{rbac-roles-2pedz5e9.js → rbac-roles-ak56tr75.js} +2 -2
- package/dist/{rbac-roles-reef5y2d.js → rbac-roles-kb3afm95.js} +3 -3
- package/dist/{render-v2-zd5r5wgf.js → render-v2-434asg49.js} +3 -3
- package/dist/{seed-pricing-2kyk1vhg.js → seed-pricing-nbmd7ah8.js} +3 -3
- package/dist/{setup-5ys9pwyr.js → setup-wh4qmjs4.js} +2 -2
- package/dist/{worker-nb1f7k04.js → worker-j1d27tjv.js} +23 -23
- package/openapi.json +167 -2
- package/package.json +6 -6
- package/src/be/db-retention.ts +275 -0
- package/src/be/db.ts +82 -7
- package/src/be/swarm-config-guard.ts +30 -0
- package/src/heartbeat/heartbeat.ts +21 -0
- package/src/http/index.ts +33 -33
- package/src/http/route-def.ts +93 -4
- package/src/http/stats.ts +37 -1
- package/src/http/tasks.ts +93 -0
- package/src/http/utils.ts +94 -0
- package/src/otel-impl.ts +21 -3
- package/src/otel.ts +15 -4
- package/src/slack/blocks.ts +1 -0
- package/src/slack/handlers.ts +7 -0
- package/src/tasks/sibling-awareness.ts +1 -0
- package/src/tasks/sibling-block.ts +17 -4
- package/src/tests/artifact-sdk.test.ts +20 -15
- package/src/tests/db-retention.test.ts +344 -0
- package/src/tests/docs-index-footprint.test.js +4 -5
- package/src/tests/draft-task-dispatch-race.test.ts +189 -0
- package/src/tests/events-http.test.ts +70 -27
- package/src/tests/otel-abort-span.test.ts +156 -0
- package/src/tests/otel-span-kind.test.ts +59 -0
- package/src/tests/promote-draft-task-route.test.ts +175 -0
- package/src/tests/route-def-find-route.test.ts +90 -4
- package/src/tests/sibling-block.test.ts +29 -1
- package/src/tests/slack-assistant-comention-production.test.ts +7 -1
- package/src/tests/steering-enabled.test.ts +6 -0
- package/src/tests/swarm-config-reserved-keys.test.ts +27 -0
- package/src/types.ts +2 -1
- package/templates/skills/swarm-scripts/SKILL.md +4 -0
- package/templates/skills/swarm-scripts/content.md +4 -0
package/src/http/route-def.ts
CHANGED
|
@@ -179,19 +179,85 @@ export interface RequestRouteDescriptor {
|
|
|
179
179
|
* `{METHOD} {route-template}`.
|
|
180
180
|
*
|
|
181
181
|
* - Matched `route()` handler: `GET /api/tasks/{id}`
|
|
182
|
-
* -
|
|
182
|
+
* - Static core path (see `CORE_ROUTE_TEMPLATES`): `GET /health`, `POST /mcp`
|
|
183
|
+
* - Otherwise unmatched (deeper MCP paths, 404s): `GET /<first-segment>`
|
|
183
184
|
* - Root or empty path: bare `GET`
|
|
184
185
|
*/
|
|
185
186
|
spanName: string;
|
|
186
187
|
/**
|
|
187
188
|
* Value for the `http.route` span attribute — the bounded-cardinality route
|
|
188
|
-
* template (e.g. `/api/tasks/{id}`). Set
|
|
189
|
-
*
|
|
190
|
-
*
|
|
189
|
+
* template (e.g. `/api/tasks/{id}`). Set when a `route()` handler matched,
|
|
190
|
+
* or when the request matches a static core path in `CORE_ROUTE_TEMPLATES`
|
|
191
|
+
* on a method and query-string form that handler actually accepts. Left
|
|
192
|
+
* `undefined` for genuinely unmatched paths so callers omit the attribute
|
|
193
|
+
* rather than fabricating a value.
|
|
191
194
|
*/
|
|
192
195
|
httpRoute?: string;
|
|
193
196
|
}
|
|
194
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Static literal paths handled outside the `route()` factory (core.ts, mcp.ts)
|
|
200
|
+
* that would otherwise fall through to the low-cardinality `{METHOD}
|
|
201
|
+
* /{first-segment}` fallback below. Each of these is a single, fixed path —
|
|
202
|
+
* not a template with params — so mapping it to `http.route` is not
|
|
203
|
+
* fabricating a value, just naming a route that doesn't happen to go through
|
|
204
|
+
* `route()`. `/health` in particular is high-volume liveness-probe traffic;
|
|
205
|
+
* leaving it in the anonymous `GET` bucket makes that bucket useless to
|
|
206
|
+
* aggregate on.
|
|
207
|
+
*
|
|
208
|
+
* `methods` mirrors the method gate `handleCore` actually applies to that
|
|
209
|
+
* path — `undefined` means `handleCore` has no method check there (the
|
|
210
|
+
* handler runs for any verb). A method NOT in this list is a path
|
|
211
|
+
* `handleCore` 404s, so it must not carry `http.route` either — otherwise the
|
|
212
|
+
* attribute lies about a request that never matched a real handler.
|
|
213
|
+
*
|
|
214
|
+
* `toleratesQueryString` mirrors whether `handleCore` matches the path when
|
|
215
|
+
* the raw URL carries a `?`. Most of these entries compare the raw URL with
|
|
216
|
+
* strict equality (`req.url === "/health"` and similarly for
|
|
217
|
+
* `openapi.json`/`docs`/`internal/reload-config`/`mcp`/`mcp-user`), so
|
|
218
|
+
* `GET /health?t=123` 404s in `handleCore` even though `pathSegments` still
|
|
219
|
+
* resolves to `health`. `/me` and `/cancelled-tasks` explicitly accept the
|
|
220
|
+
* `?`-suffixed form (`req.url === "/me" || req.url?.startsWith("/me?")`), so
|
|
221
|
+
* those two tolerate a query string. Default is `false` (no tolerance) —
|
|
222
|
+
* verified against every comparison in `src/http/core.ts`, `src/http/mcp.ts`,
|
|
223
|
+
* and `src/http/mcp-user.ts`.
|
|
224
|
+
*
|
|
225
|
+
* `toleratesTrailingSlash` mirrors the same strict-equality problem for a
|
|
226
|
+
* trailing `/`: `getPathSegments("/health/")` returns `["health"]` (trailing
|
|
227
|
+
* empty segment is filtered), so without this gate a `GET /health/` request —
|
|
228
|
+
* which `handleCore` 404s on `req.url === "/health"` strict equality — would
|
|
229
|
+
* still get `http.route: "/health"` fabricated onto its span. `docs` is the
|
|
230
|
+
* one intentional exception: `handleCore` explicitly accepts
|
|
231
|
+
* `req.url === "/docs" || req.url === "/docs/"`. Default is `false`.
|
|
232
|
+
*
|
|
233
|
+
* Keyed by the joined path segments (no leading slash, no query string) so a
|
|
234
|
+
* deeper unmatched path under the same first segment — e.g.
|
|
235
|
+
* `/mcp/<session>/messages` — does NOT match and correctly falls through to
|
|
236
|
+
* the bounded first-segment fallback instead of fabricating a template.
|
|
237
|
+
*/
|
|
238
|
+
const CORE_ROUTE_TEMPLATES: Record<
|
|
239
|
+
string,
|
|
240
|
+
{
|
|
241
|
+
path: string;
|
|
242
|
+
methods?: string[];
|
|
243
|
+
toleratesQueryString?: boolean;
|
|
244
|
+
toleratesTrailingSlash?: boolean;
|
|
245
|
+
}
|
|
246
|
+
> = {
|
|
247
|
+
health: { path: "/health" },
|
|
248
|
+
"openapi.json": { path: "/openapi.json" },
|
|
249
|
+
docs: { path: "/docs", toleratesTrailingSlash: true },
|
|
250
|
+
me: { path: "/me", methods: ["GET"], toleratesQueryString: true },
|
|
251
|
+
"cancelled-tasks": {
|
|
252
|
+
path: "/cancelled-tasks",
|
|
253
|
+
methods: ["GET"],
|
|
254
|
+
toleratesQueryString: true,
|
|
255
|
+
},
|
|
256
|
+
"internal/reload-config": { path: "/internal/reload-config", methods: ["POST"] },
|
|
257
|
+
mcp: { path: "/mcp", methods: ["GET", "POST", "DELETE"] },
|
|
258
|
+
"mcp-user": { path: "/mcp-user", methods: ["GET", "POST", "DELETE"] },
|
|
259
|
+
};
|
|
260
|
+
|
|
195
261
|
/**
|
|
196
262
|
* Describe an inbound HTTP request for OTel: a low-cardinality span name plus
|
|
197
263
|
* the `http.route` attribute value (per the HTTP server semantic conventions).
|
|
@@ -199,14 +265,37 @@ export interface RequestRouteDescriptor {
|
|
|
199
265
|
* Never embeds raw path params or query strings — the goal is one span name and
|
|
200
266
|
* one `http.route` value per endpoint so SigNoz can group/filter/aggregate by
|
|
201
267
|
* them. The raw path is still preserved on the `url.path` attribute.
|
|
268
|
+
*
|
|
269
|
+
* `hasQueryString` reflects whether the raw request URL carried a `?`. A
|
|
270
|
+
* core-route entry without `toleratesQueryString` is a path `handleCore`
|
|
271
|
+
* 404s once a query string is present (see `CORE_ROUTE_TEMPLATES`), so that
|
|
272
|
+
* combination must not carry `http.route` either — same "omitted, never
|
|
273
|
+
* fabricated" contract as the method-mismatch case above it.
|
|
274
|
+
*
|
|
275
|
+
* `hasTrailingSlash` reflects whether the raw request path (query string
|
|
276
|
+
* excluded) ended in `/` for a path with more than one character — i.e. `/`
|
|
277
|
+
* itself is not "trailing slash". A core-route entry without
|
|
278
|
+
* `toleratesTrailingSlash` is a path `handleCore` 404s once a trailing slash
|
|
279
|
+
* is present, so that combination must not carry `http.route` either.
|
|
202
280
|
*/
|
|
203
281
|
export function describeRequestRoute(
|
|
204
282
|
method: string | undefined,
|
|
205
283
|
pathSegments: string[],
|
|
284
|
+
hasQueryString = false,
|
|
285
|
+
hasTrailingSlash = false,
|
|
206
286
|
): RequestRouteDescriptor {
|
|
207
287
|
const m = (method ?? "").toUpperCase() || "UNKNOWN";
|
|
208
288
|
const matched = findRoute(method, pathSegments);
|
|
209
289
|
if (matched) return { spanName: `${m} ${matched.path}`, httpRoute: matched.path };
|
|
290
|
+
const coreTemplate = CORE_ROUTE_TEMPLATES[pathSegments.join("/")];
|
|
291
|
+
if (
|
|
292
|
+
coreTemplate &&
|
|
293
|
+
(!coreTemplate.methods || coreTemplate.methods.includes(m)) &&
|
|
294
|
+
(!hasQueryString || coreTemplate.toleratesQueryString) &&
|
|
295
|
+
(!hasTrailingSlash || coreTemplate.toleratesTrailingSlash)
|
|
296
|
+
) {
|
|
297
|
+
return { spanName: `${m} ${coreTemplate.path}`, httpRoute: coreTemplate.path };
|
|
298
|
+
}
|
|
210
299
|
const first = pathSegments[0];
|
|
211
300
|
return { spanName: first ? `${m} /${first}` : m };
|
|
212
301
|
}
|
package/src/http/stats.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getTaskStats,
|
|
12
12
|
withFavoriteFlags,
|
|
13
13
|
} from "../be/db";
|
|
14
|
+
import { getDbRetentionStats } from "../be/db-retention";
|
|
14
15
|
import { isSteeringEnabled } from "../be/steering";
|
|
15
16
|
import type { AgentLog } from "../types";
|
|
16
17
|
import { AgentLogSchema, ScheduledTaskSchema, ServiceSchema } from "../types";
|
|
@@ -57,6 +58,38 @@ const SwarmMetricsSchema = z.object({
|
|
|
57
58
|
pages: z.object({ total: z.number() }),
|
|
58
59
|
sessions: z.object({ active: z.number() }),
|
|
59
60
|
skills: z.object({ total: z.number() }),
|
|
61
|
+
retention: z.object({
|
|
62
|
+
sessionLogs: z
|
|
63
|
+
.object({
|
|
64
|
+
at: z.string(),
|
|
65
|
+
rowsDeleted: z.number(),
|
|
66
|
+
batches: z.number(),
|
|
67
|
+
durationMs: z.number(),
|
|
68
|
+
dryRun: z.boolean(),
|
|
69
|
+
cumulativeRowsDeleted: z.number(),
|
|
70
|
+
})
|
|
71
|
+
.optional(),
|
|
72
|
+
agentLog: z
|
|
73
|
+
.object({
|
|
74
|
+
at: z.string(),
|
|
75
|
+
rowsDeleted: z.number(),
|
|
76
|
+
batches: z.number(),
|
|
77
|
+
durationMs: z.number(),
|
|
78
|
+
dryRun: z.boolean(),
|
|
79
|
+
cumulativeRowsDeleted: z.number(),
|
|
80
|
+
})
|
|
81
|
+
.optional(),
|
|
82
|
+
events: z
|
|
83
|
+
.object({
|
|
84
|
+
at: z.string(),
|
|
85
|
+
rowsDeleted: z.number(),
|
|
86
|
+
batches: z.number(),
|
|
87
|
+
durationMs: z.number(),
|
|
88
|
+
dryRun: z.boolean(),
|
|
89
|
+
cumulativeRowsDeleted: z.number(),
|
|
90
|
+
})
|
|
91
|
+
.optional(),
|
|
92
|
+
}),
|
|
60
93
|
});
|
|
61
94
|
|
|
62
95
|
/** Mirrors `ConcurrentContext` (src/be/db.ts). */
|
|
@@ -242,7 +275,10 @@ export async function handleStats(
|
|
|
242
275
|
}
|
|
243
276
|
|
|
244
277
|
if (getMetrics.match(req.method, pathSegments)) {
|
|
245
|
-
getMetrics.respond(res, 200,
|
|
278
|
+
getMetrics.respond(res, 200, {
|
|
279
|
+
...(await getSwarmMetrics()),
|
|
280
|
+
retention: getDbRetentionStats(),
|
|
281
|
+
});
|
|
246
282
|
return true;
|
|
247
283
|
}
|
|
248
284
|
|
package/src/http/tasks.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
markSteeringDelivered,
|
|
25
25
|
markSteeringHandled,
|
|
26
26
|
pauseTask,
|
|
27
|
+
promoteDraftTask,
|
|
27
28
|
resumeTask,
|
|
28
29
|
supersedeTask,
|
|
29
30
|
updateAgentStatusFromCapacity,
|
|
@@ -233,6 +234,14 @@ const createTask = route({
|
|
|
233
234
|
model: z.string().optional(),
|
|
234
235
|
modelTier: ModelTierSchema.optional(),
|
|
235
236
|
effort: ReasoningEffortSchema.optional(),
|
|
237
|
+
/**
|
|
238
|
+
* Create in `draft` status instead of the normal pending/unassigned/offered
|
|
239
|
+
* status (#1240) — the task exists and is visible to its owner, but is not
|
|
240
|
+
* dispatch-eligible. Used by the UI composer while attachments are still
|
|
241
|
+
* uploading; the caller MUST promote it via `POST /api/tasks/{id}/promote-draft`
|
|
242
|
+
* once the upload batch settles (or it self-promotes on a timeout).
|
|
243
|
+
*/
|
|
244
|
+
draft: z.boolean().optional(),
|
|
236
245
|
}),
|
|
237
246
|
responses: {
|
|
238
247
|
201: { description: "Task created", schema: AgentTaskSchema },
|
|
@@ -240,6 +249,23 @@ const createTask = route({
|
|
|
240
249
|
},
|
|
241
250
|
});
|
|
242
251
|
|
|
252
|
+
const promoteDraftTaskRoute = route({
|
|
253
|
+
method: "post",
|
|
254
|
+
path: "/api/tasks/{id}/promote-draft",
|
|
255
|
+
pattern: ["api", "tasks", null, "promote-draft"],
|
|
256
|
+
summary: "Promote a draft task out of the pre-dispatch draft state",
|
|
257
|
+
description:
|
|
258
|
+
"Transitions a `draft` task (#1240 — created with attachments still uploading) to its normal dispatch-eligible status: `offered` if it was offered to an agent, `pending` if it has an owning agent (the common case — UI-composer tasks default to Lead), otherwise `unassigned`. Called by the UI composer once its attachment upload batch settles, whether every file uploaded, some failed, or all failed — a draft must never be stranded by an upload error. Idempotent: calling it on a task that already left `draft` returns the current task unchanged rather than erroring, so a retried request is safe.",
|
|
259
|
+
tags: ["Tasks"],
|
|
260
|
+
params: z.object({ id: z.string() }),
|
|
261
|
+
rbac: { permission: "task.action.own" },
|
|
262
|
+
responses: {
|
|
263
|
+
200: { description: "Task promoted (or already out of draft)", schema: AgentTaskSchema },
|
|
264
|
+
403: { description: "Caller does not own this task" },
|
|
265
|
+
404: { description: "Task not found" },
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
243
269
|
const updateSession = route({
|
|
244
270
|
method: "put",
|
|
245
271
|
path: "/api/tasks/{id}/session",
|
|
@@ -621,6 +647,42 @@ async function canSteerTask(
|
|
|
621
647
|
return can({ principal, verb, resource, source: "http" }).allow;
|
|
622
648
|
}
|
|
623
649
|
|
|
650
|
+
/**
|
|
651
|
+
* Ownership gate for `task.action.own` — single verb, no `.any` counterpart:
|
|
652
|
+
* the underlying `requester-owns-task` policy already lets any non-user
|
|
653
|
+
* principal (agent/operator) through, so agent-side callers never need a
|
|
654
|
+
* separate escalated verb. User principals must match the task's
|
|
655
|
+
* `requestedByUserId`. Used by `POST /api/tasks/{id}/promote-draft`.
|
|
656
|
+
*/
|
|
657
|
+
async function canActOnOwnTask(
|
|
658
|
+
req: IncomingMessage,
|
|
659
|
+
myAgentId: string | undefined,
|
|
660
|
+
task: AgentTask,
|
|
661
|
+
): Promise<boolean> {
|
|
662
|
+
const resource: RbacResource = {
|
|
663
|
+
kind: "task",
|
|
664
|
+
taskId: task.id,
|
|
665
|
+
requestedByUserId: task.requestedByUserId,
|
|
666
|
+
creatorAgentId: task.creatorAgentId,
|
|
667
|
+
agentId: task.agentId,
|
|
668
|
+
};
|
|
669
|
+
let principal: RbacPrincipal;
|
|
670
|
+
|
|
671
|
+
const auth = getRequestAuth(req);
|
|
672
|
+
if (auth?.kind === "operator") {
|
|
673
|
+
principal = { kind: "operator" };
|
|
674
|
+
} else if (auth?.kind === "user") {
|
|
675
|
+
principal = { kind: "user", userId: auth.userId };
|
|
676
|
+
} else {
|
|
677
|
+
if (!myAgentId) return false;
|
|
678
|
+
const agent = await getAgentById(myAgentId);
|
|
679
|
+
if (!agent) return false;
|
|
680
|
+
principal = { kind: "agent", agentId: myAgentId, isLead: agent.isLead };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return can({ principal, verb: "task.action.own", resource, source: "http" }).allow;
|
|
684
|
+
}
|
|
685
|
+
|
|
624
686
|
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
625
687
|
|
|
626
688
|
export async function handleTasks(
|
|
@@ -773,6 +835,7 @@ export async function handleTasks(
|
|
|
773
835
|
outputSchema: parsed.body.outputSchema || undefined,
|
|
774
836
|
contextKey: parsed.body.contextKey || undefined,
|
|
775
837
|
requestedByUserId,
|
|
838
|
+
status: parsed.body.draft ? "draft" : undefined,
|
|
776
839
|
...splitLegacyModelAlias({
|
|
777
840
|
model: parsed.body.model,
|
|
778
841
|
modelTier: parsed.body.modelTier,
|
|
@@ -804,6 +867,36 @@ export async function handleTasks(
|
|
|
804
867
|
return true;
|
|
805
868
|
}
|
|
806
869
|
|
|
870
|
+
if (promoteDraftTaskRoute.match(req.method, pathSegments)) {
|
|
871
|
+
const parsed = await promoteDraftTaskRoute.parse(req, res, pathSegments, queryParams);
|
|
872
|
+
if (!parsed) return true;
|
|
873
|
+
|
|
874
|
+
const existingTask = await getTaskById(parsed.params.id);
|
|
875
|
+
if (!existingTask) {
|
|
876
|
+
jsonError(res, "Task not found", 404);
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const allowed = await canActOnOwnTask(req, myAgentId, existingTask);
|
|
881
|
+
if (!allowed) {
|
|
882
|
+
jsonError(res, "Not authorized to act on this task", 403);
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Idempotent: a task already out of `draft` (already promoted, or a
|
|
887
|
+
// retried request) is returned as-is rather than erroring — the UI calls
|
|
888
|
+
// this exactly once per upload batch but must stay safe under retry.
|
|
889
|
+
const promoted =
|
|
890
|
+
existingTask.status === "draft" ? await promoteDraftTask(parsed.params.id) : existingTask;
|
|
891
|
+
if (!promoted) {
|
|
892
|
+
jsonError(res, "Task not found", 404);
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
promoteDraftTaskRoute.respond(res, 200, promoted);
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
|
|
807
900
|
if (updateSession.match(req.method, pathSegments)) {
|
|
808
901
|
const parsed = await updateSession.parse(req, res, pathSegments, queryParams);
|
|
809
902
|
if (!parsed) return true;
|
package/src/http/utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { getActiveTaskCount } from "../be/db";
|
|
3
|
+
import type { SwarmSpan } from "../otel";
|
|
3
4
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
4
5
|
|
|
5
6
|
export function setCorsHeaders(req: IncomingMessage, res: ServerResponse) {
|
|
@@ -47,6 +48,99 @@ export function getPathSegments(url: string): string[] {
|
|
|
47
48
|
return path.split("/").filter(Boolean);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* `http.response.status_code` for a request span whose response connection
|
|
53
|
+
* closed before completion. `statusCode` initializes to `200` and is only
|
|
54
|
+
* ever updated by `writeHead` — on the premature-close path that never ran,
|
|
55
|
+
* so reporting it verbatim would claim a 200 response was sent when none was.
|
|
56
|
+
* Omitted (not fabricated) when `headersSent` is false.
|
|
57
|
+
*
|
|
58
|
+
* Node emits `close` for BOTH a completed response and a connection
|
|
59
|
+
* terminated early, so this path proves the response did not complete — not
|
|
60
|
+
* that the client was the actor.
|
|
61
|
+
*/
|
|
62
|
+
export function abortedStatusCodeAttribute(
|
|
63
|
+
headersSent: boolean,
|
|
64
|
+
statusCode: number,
|
|
65
|
+
): number | undefined {
|
|
66
|
+
return headersSent ? statusCode : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Minimal `ServerResponse` surface `wireHttpSpanLifecycle` needs — narrowed so
|
|
71
|
+
* tests can pass a plain mock instead of a real Node response.
|
|
72
|
+
*/
|
|
73
|
+
export interface SpanLifecycleResponse {
|
|
74
|
+
readonly headersSent: boolean;
|
|
75
|
+
on(event: "finish" | "error" | "close", listener: (err?: Error) => void): unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Wires a request span to its response's terminal events so the span always
|
|
80
|
+
* ends exactly once, regardless of which event fires first.
|
|
81
|
+
*
|
|
82
|
+
* `finish` and `error` are the two ways Node signals a completed response;
|
|
83
|
+
* `close` additionally fires on a connection that terminates early —
|
|
84
|
+
* including AFTER `finish` on the happy path, which is why every branch
|
|
85
|
+
* guards on `spanEnded` before touching the span. Extracted out of
|
|
86
|
+
* `src/http/index.ts` (which binds a port at module scope and so cannot be
|
|
87
|
+
* imported directly in a test) so the close-before-finish ordering and the
|
|
88
|
+
* end-exactly-once guarantee can be exercised with a mocked response/span.
|
|
89
|
+
*/
|
|
90
|
+
export function wireHttpSpanLifecycle(
|
|
91
|
+
res: SpanLifecycleResponse,
|
|
92
|
+
span: SwarmSpan,
|
|
93
|
+
getStatusCode: () => number,
|
|
94
|
+
startTime: number,
|
|
95
|
+
): void {
|
|
96
|
+
let spanEnded = false;
|
|
97
|
+
|
|
98
|
+
res.on("finish", () => {
|
|
99
|
+
if (spanEnded) return;
|
|
100
|
+
spanEnded = true;
|
|
101
|
+
const statusCode = getStatusCode();
|
|
102
|
+
span.setAttributes({
|
|
103
|
+
"http.response.status_code": statusCode,
|
|
104
|
+
"agentswarm.http.duration_ms": Math.round((performance.now() - startTime) * 10) / 10,
|
|
105
|
+
});
|
|
106
|
+
if (statusCode >= 500) {
|
|
107
|
+
span.setStatus({ code: 2, message: `HTTP ${statusCode}` });
|
|
108
|
+
}
|
|
109
|
+
span.end();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
res.on("error", (err) => {
|
|
113
|
+
if (spanEnded) return;
|
|
114
|
+
spanEnded = true;
|
|
115
|
+
if (err) span.recordException(err);
|
|
116
|
+
span.setStatus({ code: 2, message: err?.message });
|
|
117
|
+
span.end();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// `close` fires after `finish` on the happy path (the `spanEnded` guard
|
|
121
|
+
// already covers that), but a response connection that terminates early
|
|
122
|
+
// fires neither `finish` nor `error` — without this, the span stays open
|
|
123
|
+
// and is never exported. Verified against Bun 1.4.0 (pinned): `close`
|
|
124
|
+
// fires on a premature close even with `headersSent === false`; Bun
|
|
125
|
+
// 1.3.14 never fires it.
|
|
126
|
+
res.on("close", () => {
|
|
127
|
+
if (spanEnded) return;
|
|
128
|
+
spanEnded = true;
|
|
129
|
+
span.setAttributes({
|
|
130
|
+
"http.response.status_code": abortedStatusCodeAttribute(res.headersSent, getStatusCode()),
|
|
131
|
+
"agentswarm.http.duration_ms": Math.round((performance.now() - startTime) * 10) / 10,
|
|
132
|
+
"agentswarm.http.aborted": true,
|
|
133
|
+
});
|
|
134
|
+
// Status left Unset. `close` cannot distinguish an intentional client
|
|
135
|
+
// cancellation from a response-write failure, and the dominant case
|
|
136
|
+
// here is the former: a normal SSE teardown on /mcp or /mcp-user.
|
|
137
|
+
// Marking every premature close ERROR would put that routine traffic
|
|
138
|
+
// in the service error rate. `agentswarm.http.aborted` carries the
|
|
139
|
+
// signal instead. Revisit if a discriminator becomes available.
|
|
140
|
+
span.end();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
50
144
|
export function safeRequestUrlForLog(rawUrl: string | undefined): string {
|
|
51
145
|
if (!rawUrl) return "";
|
|
52
146
|
|
package/src/otel-impl.ts
CHANGED
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
propagation,
|
|
6
6
|
ROOT_CONTEXT,
|
|
7
7
|
type Span,
|
|
8
|
+
SpanKind,
|
|
8
9
|
SpanStatusCode,
|
|
10
|
+
type Tracer,
|
|
9
11
|
trace,
|
|
10
12
|
} from "@opentelemetry/api";
|
|
11
13
|
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
@@ -20,7 +22,7 @@ import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
|
|
20
22
|
import { NodeSDK } from "@opentelemetry/sdk-node";
|
|
21
23
|
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
|
|
22
24
|
import pkg from "../package.json";
|
|
23
|
-
import type { SwarmSpan } from "./otel";
|
|
25
|
+
import type { SpanOptions, SwarmSpan, SwarmSpanKind } from "./otel";
|
|
24
26
|
import { scrubSecrets } from "./utils/secret-scrubber";
|
|
25
27
|
|
|
26
28
|
type AttributeValue = string | number | boolean | string[] | number[] | boolean[];
|
|
@@ -219,9 +221,21 @@ export async function withSpan<T>(
|
|
|
219
221
|
});
|
|
220
222
|
}
|
|
221
223
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
+
const SPAN_KIND_MAP: Record<SwarmSpanKind, SpanKind> = {
|
|
225
|
+
internal: SpanKind.INTERNAL,
|
|
226
|
+
server: SpanKind.SERVER,
|
|
227
|
+
client: SpanKind.CLIENT,
|
|
228
|
+
producer: SpanKind.PRODUCER,
|
|
229
|
+
consumer: SpanKind.CONSUMER,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
let tracerOverrideForTests: Tracer | undefined;
|
|
233
|
+
|
|
234
|
+
export function startSpan(name: string, attributes?: Attributes, options?: SpanOptions): SwarmSpan {
|
|
235
|
+
const tracer = tracerOverrideForTests ?? trace.getTracer(TRACER_NAME);
|
|
236
|
+
const span = tracer.startSpan(name, {
|
|
224
237
|
attributes: cleanAttributes(attributes),
|
|
238
|
+
kind: options?.kind ? SPAN_KIND_MAP[options.kind] : undefined,
|
|
225
239
|
});
|
|
226
240
|
return spanAdapter(span);
|
|
227
241
|
}
|
|
@@ -326,3 +340,7 @@ export function _injectCountersForTests(
|
|
|
326
340
|
tokenCounter = token;
|
|
327
341
|
costDriftCounter = drift;
|
|
328
342
|
}
|
|
343
|
+
|
|
344
|
+
export function _injectTracerForTests(tracer: Tracer | undefined): void {
|
|
345
|
+
tracerOverrideForTests = tracer;
|
|
346
|
+
}
|
package/src/otel.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type AttributeValue = string | number | boolean | string[] | number[] | boolean[];
|
|
2
2
|
export type Attributes = Record<string, AttributeValue | undefined>;
|
|
3
3
|
|
|
4
|
-
type SpanStatus = {
|
|
4
|
+
export type SpanStatus = {
|
|
5
5
|
code: number;
|
|
6
6
|
message?: string;
|
|
7
7
|
};
|
|
@@ -15,6 +15,15 @@ export type SwarmSpan = {
|
|
|
15
15
|
end: () => void;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
// Backend-agnostic mirror of OTel's `SpanKind`. Kept as a string union (not a
|
|
19
|
+
// value import of `@opentelemetry/api`) so this dependency-free module, loaded
|
|
20
|
+
// by every worker process, never eagerly pulls in the OTel API. `otel-impl.ts`
|
|
21
|
+
// maps this onto the real `SpanKind` only when OTLP is configured.
|
|
22
|
+
export type SwarmSpanKind = "internal" | "server" | "client" | "producer" | "consumer";
|
|
23
|
+
export interface SpanOptions {
|
|
24
|
+
kind?: SwarmSpanKind;
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
// eslint-disable-next-line import/no-duplicates -- type-only import, no side-effects
|
|
19
28
|
import type { SessionCostMetric } from "./otel-impl";
|
|
20
29
|
export type { SessionCostMetric };
|
|
@@ -51,7 +60,9 @@ let realWithSpan:
|
|
|
51
60
|
attributes?: Attributes,
|
|
52
61
|
) => Promise<T>)
|
|
53
62
|
| undefined;
|
|
54
|
-
let realStartSpan:
|
|
63
|
+
let realStartSpan:
|
|
64
|
+
| ((name: string, attributes?: Attributes, options?: SpanOptions) => SwarmSpan)
|
|
65
|
+
| undefined;
|
|
55
66
|
let realWithRemoteContext:
|
|
56
67
|
| (<T>(carrier: Record<string, unknown>, fn: () => Promise<T> | T) => Promise<T>)
|
|
57
68
|
| undefined;
|
|
@@ -102,11 +113,11 @@ export async function withSpan<T>(
|
|
|
102
113
|
return realWithSpan(name, fn, attributes);
|
|
103
114
|
}
|
|
104
115
|
|
|
105
|
-
export function startSpan(name: string, attributes?: Attributes): SwarmSpan {
|
|
116
|
+
export function startSpan(name: string, attributes?: Attributes, options?: SpanOptions): SwarmSpan {
|
|
106
117
|
if (!otelConfigured() || !realStartSpan) {
|
|
107
118
|
return NOOP_SPAN;
|
|
108
119
|
}
|
|
109
|
-
return realStartSpan(name, attributes);
|
|
120
|
+
return realStartSpan(name, attributes, options);
|
|
110
121
|
}
|
|
111
122
|
|
|
112
123
|
export function withSpanContext<T>(span: SwarmSpan, fn: () => T): T {
|
package/src/slack/blocks.ts
CHANGED
package/src/slack/handlers.ts
CHANGED
|
@@ -196,6 +196,13 @@ let cachedBotId: string | null = null;
|
|
|
196
196
|
const swarmThreadRootCache = new Map<string, boolean>();
|
|
197
197
|
const SWARM_THREAD_ROOT_CACHE_MAX = 1000;
|
|
198
198
|
|
|
199
|
+
/** Reset Slack handler caches between tests that exercise production handlers. */
|
|
200
|
+
export function resetSlackHandlerCachesForTesting(): void {
|
|
201
|
+
cachedBotUserId = null;
|
|
202
|
+
cachedBotId = null;
|
|
203
|
+
swarmThreadRootCache.clear();
|
|
204
|
+
}
|
|
205
|
+
|
|
199
206
|
/**
|
|
200
207
|
* Pure check: does the given thread-root message belong to our own swarm bot?
|
|
201
208
|
* Exported for testing.
|
|
@@ -18,9 +18,15 @@ export type SiblingTaskInfo = {
|
|
|
18
18
|
agentId: string | null;
|
|
19
19
|
agentName: string | null;
|
|
20
20
|
description: string;
|
|
21
|
-
// Most recent change timestamp (ISO string). Used for
|
|
22
|
-
// and for picking the "best" sibling to wire as parent.
|
|
21
|
+
// Most recent change timestamp (ISO string). Used for the "last active"
|
|
22
|
+
// relative-time render and for picking the "best" sibling to wire as parent.
|
|
23
|
+
// NOTE: this ticks on every progress event, so it is NOT a proxy for when
|
|
24
|
+
// the sibling (or the user input behind it) started.
|
|
23
25
|
updatedAt: string;
|
|
26
|
+
// Creation timestamp (ISO string) — when the user input behind this sibling
|
|
27
|
+
// actually arrived. Optional so existing callers keep compiling; when it is
|
|
28
|
+
// absent we omit the "started" clause rather than guess from `updatedAt`.
|
|
29
|
+
createdAt?: string;
|
|
24
30
|
};
|
|
25
31
|
|
|
26
32
|
const DESCRIPTION_TRUNCATE = 200;
|
|
@@ -100,8 +106,15 @@ export function renderSiblingBlock(
|
|
|
100
106
|
: s.agentId
|
|
101
107
|
? `agent:${s.agentId}`
|
|
102
108
|
: "agent:unassigned";
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
// `updatedAt` advances on every progress event, so rendering it as
|
|
110
|
+
// "started" made a minutes-old sibling look seconds-old and invited the
|
|
111
|
+
// reader to conclude two unrelated user inputs were a double-submit.
|
|
112
|
+
// Report each timestamp under its own name instead.
|
|
113
|
+
const lastActive = `last active ${formatRelativeTime(s.updatedAt, now)} ago`;
|
|
114
|
+
const timing = s.createdAt
|
|
115
|
+
? `started ${formatRelativeTime(s.createdAt, now)} ago, ${lastActive}`
|
|
116
|
+
: lastActive;
|
|
117
|
+
lines.push(`- [${s.status}] task:${s.id} — ${agentLabel} — ${timing}`);
|
|
105
118
|
lines.push(` "${truncateForBlock(s.description)}"`);
|
|
106
119
|
}
|
|
107
120
|
lines.push("</sibling_tasks_in_progress>");
|
|
@@ -9,6 +9,11 @@ import {
|
|
|
9
9
|
} from "../artifact-sdk/server";
|
|
10
10
|
import { getBasePrompt } from "../prompts/base-prompt";
|
|
11
11
|
|
|
12
|
+
function restoreEnvValue(key: string, value: string | undefined): void {
|
|
13
|
+
if (value === undefined) delete process.env[key];
|
|
14
|
+
else process.env[key] = value;
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
// ─── Port allocation tests ──────────────────────────────────────────────
|
|
13
18
|
|
|
14
19
|
describe("getAvailablePort", () => {
|
|
@@ -132,9 +137,9 @@ describe("createArtifactServer", () => {
|
|
|
132
137
|
});
|
|
133
138
|
|
|
134
139
|
afterAll(() => {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
restoreEnvValue("AGENT_ID", originalEnv.AGENT_ID);
|
|
141
|
+
restoreEnvValue("API_KEY", originalEnv.API_KEY);
|
|
142
|
+
restoreEnvValue("MCP_BASE_URL", originalEnv.MCP_BASE_URL);
|
|
138
143
|
});
|
|
139
144
|
|
|
140
145
|
describe("factory return shape", () => {
|
|
@@ -596,9 +601,9 @@ describe("artifact CLI command", () => {
|
|
|
596
601
|
expect(output).toContain("https://test.lt.example.com");
|
|
597
602
|
} finally {
|
|
598
603
|
console.log = origLog;
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
604
|
+
restoreEnvValue("MCP_BASE_URL", origEnv.MCP_BASE_URL);
|
|
605
|
+
restoreEnvValue("API_KEY", origEnv.API_KEY);
|
|
606
|
+
restoreEnvValue("AGENT_ID", origEnv.AGENT_ID);
|
|
602
607
|
mockServer.stop();
|
|
603
608
|
}
|
|
604
609
|
});
|
|
@@ -627,9 +632,9 @@ describe("artifact CLI command", () => {
|
|
|
627
632
|
expect(output).toContain("No active artifacts");
|
|
628
633
|
} finally {
|
|
629
634
|
console.log = origLog;
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
635
|
+
restoreEnvValue("MCP_BASE_URL", origEnv.MCP_BASE_URL);
|
|
636
|
+
restoreEnvValue("API_KEY", origEnv.API_KEY);
|
|
637
|
+
restoreEnvValue("AGENT_ID", origEnv.AGENT_ID);
|
|
633
638
|
mockServer.stop();
|
|
634
639
|
}
|
|
635
640
|
});
|
|
@@ -679,9 +684,9 @@ describe("artifact CLI command", () => {
|
|
|
679
684
|
expect(output).toContain("No active artifacts");
|
|
680
685
|
} finally {
|
|
681
686
|
console.log = origLog;
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
687
|
+
restoreEnvValue("MCP_BASE_URL", origEnv.MCP_BASE_URL);
|
|
688
|
+
restoreEnvValue("API_KEY", origEnv.API_KEY);
|
|
689
|
+
restoreEnvValue("AGENT_ID", origEnv.AGENT_ID);
|
|
685
690
|
mockServer.stop();
|
|
686
691
|
}
|
|
687
692
|
});
|
|
@@ -736,9 +741,9 @@ describe("artifact CLI command", () => {
|
|
|
736
741
|
expect(output).toContain("stopped");
|
|
737
742
|
} finally {
|
|
738
743
|
console.log = origLog;
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
744
|
+
restoreEnvValue("MCP_BASE_URL", origEnv.MCP_BASE_URL);
|
|
745
|
+
restoreEnvValue("API_KEY", origEnv.API_KEY);
|
|
746
|
+
restoreEnvValue("AGENT_ID", origEnv.AGENT_ID);
|
|
742
747
|
mockServer.stop();
|
|
743
748
|
}
|
|
744
749
|
});
|