@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.
Files changed (97) hide show
  1. package/dist/{actions-3az2vwwn.js → actions-agcbc8nd.js} +6 -6
  2. package/dist/{app-vygnfvrh.js → app-g2s78xya.js} +3 -3
  3. package/dist/{assistant-2jr2frrm.js → assistant-yd3jkb9d.js} +8 -8
  4. package/dist/{boot-reembed-y7r9cxt0.js → boot-reembed-6spqw4q0.js} +3 -3
  5. package/dist/{boot-reembed-ncbg16rb.js → boot-reembed-b22s9w5x.js} +4 -4
  6. package/dist/{boot-scrub-logs-t82rwa0y.js → boot-scrub-logs-6h3cqkc0.js} +2 -2
  7. package/dist/{claude-adapter-vvx2aw3v.js → claude-adapter-ng5y8zd7.js} +4 -4
  8. package/dist/{cli-mfa2eaef.js → cli-35jknh9e.js} +5 -5
  9. package/dist/{cli-cv2vjg2y.js → cli-3y553gws.js} +1 -1
  10. package/dist/{cli-nhvym0d2.js → cli-4gzv54ss.js} +15 -6
  11. package/dist/{cli-4dakdn30.js → cli-4r4jkqep.js} +6 -6
  12. package/dist/{cli-83razmqg.js → cli-54ht104h.js} +3 -3
  13. package/dist/{cli-6290n4yz.js → cli-5bnz1t86.js} +1 -1
  14. package/dist/{cli-6bz2hzvw.js → cli-68vayryq.js} +2 -2
  15. package/dist/{cli-1z01xdqh.js → cli-6gwgzdgw.js} +2 -2
  16. package/dist/{cli-1dfatkzm.js → cli-84vrhesc.js} +1 -1
  17. package/dist/{cli-jfrh963q.js → cli-8v57p983.js} +10 -10
  18. package/dist/{cli-3f0tnh34.js → cli-ads0hfw2.js} +81 -23
  19. package/dist/{cli-fmha74ga.js → cli-bnq8h7ea.js} +3 -3
  20. package/dist/{cli-j3xnd5b4.js → cli-d5ygfv18.js} +7 -6
  21. package/dist/{cli-8f4h27gm.js → cli-edwrx42s.js} +1 -1
  22. package/dist/{cli-r5vwg1ma.js → cli-fpjmcbhz.js} +4 -4
  23. package/dist/{cli-w0ygcv43.js → cli-gpdtxzar.js} +6 -4
  24. package/dist/{cli-4rdjdddh.js → cli-hz5hp6wn.js} +1 -1
  25. package/dist/{cli-6mmwmy45.js → cli-j7qp8gtf.js} +1 -1
  26. package/dist/{cli-5a5nrq3g.js → cli-jjhnma34.js} +5 -5
  27. package/dist/{cli-ey09krp2.js → cli-m3z53bed.js} +1 -1
  28. package/dist/{cli-cf6vq6qa.js → cli-mgef08pc.js} +1 -1
  29. package/dist/{cli-0jhyxtdj.js → cli-qckgdqmr.js} +2 -2
  30. package/dist/{cli-a1zrc2g6.js → cli-qhfkxrpg.js} +108 -33
  31. package/dist/{cli-yw2z5kj9.js → cli-spf767vc.js} +1 -1
  32. package/dist/{cli-1857msnd.js → cli-tee8w7tq.js} +1 -1
  33. package/dist/{cli-qxn3g3yx.js → cli-tmn19cdz.js} +61 -6
  34. package/dist/{cli-7ccy0h4r.js → cli-y16cesaq.js} +2 -2
  35. package/dist/{cli-cm4z7w7w.js → cli-z6e8eqrm.js} +2 -2
  36. package/dist/cli.js +16 -16
  37. package/dist/{codex-adapter-6p290yq3.js → codex-adapter-kw11ps3j.js} +2 -2
  38. package/dist/{codex-session-runner-7b3dhpmx.js → codex-session-runner-sffb69v7.js} +2 -2
  39. package/dist/{commands-wxg1g4nq.js → commands-0xxp4tz8.js} +2 -2
  40. package/dist/{db-8fqeheve.js → db-0npp39zd.js} +6 -2
  41. package/dist/{handlers-gwtqp17c.js → handlers-5p7a1jx6.js} +15 -9
  42. package/dist/{hook-gd9fwfp0.js → hook-hpefr0a8.js} +2 -2
  43. package/dist/{hook-mrqygs7p.js → hook-jjpxn4se.js} +3 -3
  44. package/dist/{http-x4mc1mqg.js → http-wfp33653.js} +322 -61
  45. package/dist/{index-yd654kac.js → index-17h657rg.js} +12 -8
  46. package/dist/{index-pzgasj16.js → index-m2p7ctyh.js} +10 -10
  47. package/dist/{index-2mdqrd0n.js → index-racb4wfq.js} +9 -9
  48. package/dist/{index-4djjkp9z.js → index-zh66wm65.js} +8 -8
  49. package/dist/{keepalive-3tycspcy.js → keepalive-wxjd633a.js} +4 -4
  50. package/dist/{lead-js1h3z7d.js → lead-359ec2z0.js} +23 -23
  51. package/dist/{maintenance-y91ry8q4.js → maintenance-rr03qjtb.js} +4 -4
  52. package/dist/{mistral-conversations-hdmnkf6h.js → mistral-conversations-qdrnmrb8.js} +1 -1
  53. package/dist/{oauth-refresh-sweep-s33gtxrw.js → oauth-refresh-sweep-jwj22yb8.js} +3 -3
  54. package/dist/{onboard-vndj64mb.js → onboard-asnhchcv.js} +2 -2
  55. package/dist/{openai-completions-cwy2kkzg.js → openai-completions-yzftqrft.js} +33 -4
  56. package/dist/{otel-impl-d2p84k6e.js → otel-impl-54cra6mw.js} +18 -4
  57. package/dist/{pi-mono-adapter-1phspz2y.js → pi-mono-adapter-29g7axgj.js} +669 -335
  58. package/dist/{pricing-refresh-c2bwsyk5.js → pricing-refresh-mkyy7c2y.js} +4 -4
  59. package/dist/{rbac-roles-2pedz5e9.js → rbac-roles-ak56tr75.js} +2 -2
  60. package/dist/{rbac-roles-reef5y2d.js → rbac-roles-kb3afm95.js} +3 -3
  61. package/dist/{render-v2-zd5r5wgf.js → render-v2-434asg49.js} +3 -3
  62. package/dist/{seed-pricing-2kyk1vhg.js → seed-pricing-nbmd7ah8.js} +3 -3
  63. package/dist/{setup-5ys9pwyr.js → setup-wh4qmjs4.js} +2 -2
  64. package/dist/{worker-nb1f7k04.js → worker-j1d27tjv.js} +23 -23
  65. package/openapi.json +167 -2
  66. package/package.json +6 -6
  67. package/src/be/db-retention.ts +275 -0
  68. package/src/be/db.ts +82 -7
  69. package/src/be/swarm-config-guard.ts +30 -0
  70. package/src/heartbeat/heartbeat.ts +21 -0
  71. package/src/http/index.ts +33 -33
  72. package/src/http/route-def.ts +93 -4
  73. package/src/http/stats.ts +37 -1
  74. package/src/http/tasks.ts +93 -0
  75. package/src/http/utils.ts +94 -0
  76. package/src/otel-impl.ts +21 -3
  77. package/src/otel.ts +15 -4
  78. package/src/slack/blocks.ts +1 -0
  79. package/src/slack/handlers.ts +7 -0
  80. package/src/tasks/sibling-awareness.ts +1 -0
  81. package/src/tasks/sibling-block.ts +17 -4
  82. package/src/tests/artifact-sdk.test.ts +20 -15
  83. package/src/tests/db-retention.test.ts +344 -0
  84. package/src/tests/docs-index-footprint.test.js +4 -5
  85. package/src/tests/draft-task-dispatch-race.test.ts +189 -0
  86. package/src/tests/events-http.test.ts +70 -27
  87. package/src/tests/otel-abort-span.test.ts +156 -0
  88. package/src/tests/otel-span-kind.test.ts +59 -0
  89. package/src/tests/promote-draft-task-route.test.ts +175 -0
  90. package/src/tests/route-def-find-route.test.ts +90 -4
  91. package/src/tests/sibling-block.test.ts +29 -1
  92. package/src/tests/slack-assistant-comention-production.test.ts +7 -1
  93. package/src/tests/steering-enabled.test.ts +6 -0
  94. package/src/tests/swarm-config-reserved-keys.test.ts +27 -0
  95. package/src/types.ts +2 -1
  96. package/templates/skills/swarm-scripts/SKILL.md +4 -0
  97. package/templates/skills/swarm-scripts/content.md +4 -0
@@ -0,0 +1,156 @@
1
+ /**
2
+ * A client that disconnects mid-request fires neither `finish` nor `error` —
3
+ * `wireHttpSpanLifecycle` (used by `src/http/index.ts`) handles that via
4
+ * `res.on("close", ...)`. Two invariants on that path:
5
+ *
6
+ * 1. `http.response.status_code` must not report the pre-`writeHead` `200`
7
+ * default when no response was ever sent (self-contradicts
8
+ * `agentswarm.http.aborted: true`).
9
+ * 2. The span status must stay Unset — OTel HTTP semconv reserves ERROR for
10
+ * 5xx, and a client abort is the normal SSE teardown path for /mcp and
11
+ * /mcp-user.
12
+ *
13
+ * (1) is a pure function (`abortedStatusCodeAttribute`), tested directly.
14
+ * (2), plus the end-exactly-once guarantee across `finish`/`error`/`close` in
15
+ * every firing order, is exercised against `wireHttpSpanLifecycle` with a
16
+ * mocked response and span — extracted out of `src/http/index.ts` (which
17
+ * binds a port at module scope, see `shutdown-error-scrubbing.test.ts`)
18
+ * specifically so this lifecycle is testable without reading source text.
19
+ */
20
+
21
+ import { describe, expect, test } from "bun:test";
22
+ import {
23
+ abortedStatusCodeAttribute,
24
+ type SpanLifecycleResponse,
25
+ wireHttpSpanLifecycle,
26
+ } from "../http/utils";
27
+ import type { Attributes, SpanStatus, SwarmSpan } from "../otel";
28
+
29
+ function createMockResponse(headersSent: boolean) {
30
+ const listeners: Record<string, ((err?: Error) => void)[]> = {};
31
+ const res: SpanLifecycleResponse & {
32
+ emit: (event: "finish" | "error" | "close", err?: Error) => void;
33
+ } = {
34
+ headersSent,
35
+ on(event, listener) {
36
+ if (!listeners[event]) listeners[event] = [];
37
+ listeners[event].push(listener);
38
+ return res;
39
+ },
40
+ emit(event, err) {
41
+ for (const listener of listeners[event] ?? []) listener(err);
42
+ },
43
+ };
44
+ return res;
45
+ }
46
+
47
+ function createMockSpan() {
48
+ const attributeCalls: Attributes[] = [];
49
+ const statusCalls: SpanStatus[] = [];
50
+ let endCalls = 0;
51
+ const span: SwarmSpan = {
52
+ setAttribute: () => span,
53
+ setAttributes: (attrs) => {
54
+ attributeCalls.push(attrs);
55
+ return span;
56
+ },
57
+ addEvent: () => span,
58
+ recordException: () => {},
59
+ setStatus: (status) => {
60
+ statusCalls.push(status);
61
+ return span;
62
+ },
63
+ end: () => {
64
+ endCalls += 1;
65
+ },
66
+ };
67
+ return { span, attributeCalls, statusCalls, getEndCalls: () => endCalls };
68
+ }
69
+
70
+ describe("abortedStatusCodeAttribute", () => {
71
+ test("omits the status code when no response was ever sent", () => {
72
+ expect(abortedStatusCodeAttribute(false, 200)).toBeUndefined();
73
+ });
74
+
75
+ test("reports the real status code once headers were sent", () => {
76
+ expect(abortedStatusCodeAttribute(true, 404)).toBe(404);
77
+ });
78
+ });
79
+
80
+ describe("wireHttpSpanLifecycle", () => {
81
+ test("a premature close (no finish, no error) ends the span exactly once, marks aborted, omits the fabricated status", () => {
82
+ const res = createMockResponse(false);
83
+ const { span, attributeCalls, statusCalls, getEndCalls } = createMockSpan();
84
+ wireHttpSpanLifecycle(res, span, () => 200, performance.now());
85
+
86
+ res.emit("close");
87
+
88
+ expect(getEndCalls()).toBe(1);
89
+ expect(attributeCalls.at(-1)).toMatchObject({ "agentswarm.http.aborted": true });
90
+ // headersSent is false — must not claim the pre-writeHead 200 default was sent.
91
+ expect(attributeCalls.at(-1)?.["http.response.status_code"]).toBeUndefined();
92
+ // A client abort is not a 5xx — status must stay Unset, not ERROR.
93
+ expect(statusCalls).toEqual([]);
94
+ });
95
+
96
+ test("close firing before a would-be finish leaves the span ended — finish is a no-op", () => {
97
+ const res = createMockResponse(true);
98
+ const { span, getEndCalls } = createMockSpan();
99
+ wireHttpSpanLifecycle(res, span, () => 200, performance.now());
100
+
101
+ res.emit("close");
102
+ res.emit("finish");
103
+
104
+ // Deleting the guard (or the close handler's `span.end()`) would leave
105
+ // this at 0 or let a second `end()` land — either way the count moves.
106
+ expect(getEndCalls()).toBe(1);
107
+ });
108
+
109
+ test("close after headers were sent reports the real status code, not the 200 default", () => {
110
+ const res = createMockResponse(true);
111
+ const { span, attributeCalls } = createMockSpan();
112
+ wireHttpSpanLifecycle(res, span, () => 404, performance.now());
113
+
114
+ res.emit("close");
115
+
116
+ expect(attributeCalls.at(-1)?.["http.response.status_code"]).toBe(404);
117
+ });
118
+
119
+ test("finish then close (the happy-path order) ends the span exactly once and never marks aborted", () => {
120
+ const res = createMockResponse(true);
121
+ const { span, attributeCalls, getEndCalls } = createMockSpan();
122
+ wireHttpSpanLifecycle(res, span, () => 200, performance.now());
123
+
124
+ res.emit("finish");
125
+ res.emit("close");
126
+
127
+ expect(getEndCalls()).toBe(1);
128
+ for (const attrs of attributeCalls) {
129
+ expect(attrs).not.toHaveProperty("agentswarm.http.aborted");
130
+ }
131
+ });
132
+
133
+ test("a 5xx finish sets ERROR status; close arriving after it is a no-op", () => {
134
+ const res = createMockResponse(true);
135
+ const { span, statusCalls, getEndCalls } = createMockSpan();
136
+ wireHttpSpanLifecycle(res, span, () => 503, performance.now());
137
+
138
+ res.emit("finish");
139
+ res.emit("close");
140
+
141
+ expect(getEndCalls()).toBe(1);
142
+ expect(statusCalls).toEqual([{ code: 2, message: "HTTP 503" }]);
143
+ });
144
+
145
+ test("error ends the span exactly once and records ERROR status; a later close is a no-op", () => {
146
+ const res = createMockResponse(false);
147
+ const { span, statusCalls, getEndCalls } = createMockSpan();
148
+ wireHttpSpanLifecycle(res, span, () => 200, performance.now());
149
+
150
+ res.emit("error", new Error("boom"));
151
+ res.emit("close");
152
+
153
+ expect(getEndCalls()).toBe(1);
154
+ expect(statusCalls).toEqual([{ code: 2, message: "boom" }]);
155
+ });
156
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Tests that `startSpan` maps the backend-agnostic `SwarmSpanKind` onto the
3
+ * real OTel `SpanKind` before handing it to the tracer. Datadog's APM
4
+ * resource-name derivation only appends `http.route` for `SpanKindServer`
5
+ * spans — an inbound HTTP span created without an explicit kind defaults to
6
+ * `INTERNAL` and collapses every endpoint into one resource per method.
7
+ */
8
+
9
+ import { afterEach, describe, expect, test } from "bun:test";
10
+ import { SpanKind, type Tracer } from "@opentelemetry/api";
11
+ import { _injectTracerForTests, startSpan } from "../otel-impl";
12
+
13
+ function fakeTracer() {
14
+ const calls: { name: string; kind: SpanKind | undefined }[] = [];
15
+ const tracer = {
16
+ startSpan(name: string, options?: { kind?: SpanKind }) {
17
+ calls.push({ name, kind: options?.kind });
18
+ return {
19
+ setAttribute: () => {},
20
+ setAttributes: () => {},
21
+ addEvent: () => {},
22
+ recordException: () => {},
23
+ setStatus: () => {},
24
+ end: () => {},
25
+ };
26
+ },
27
+ };
28
+ return { calls, tracer: tracer as unknown as Tracer };
29
+ }
30
+
31
+ describe("startSpan span-kind mapping", () => {
32
+ afterEach(() => {
33
+ _injectTracerForTests(undefined);
34
+ });
35
+
36
+ test("forwards SpanKind.SERVER when kind: 'server' is requested", () => {
37
+ const { calls, tracer } = fakeTracer();
38
+ _injectTracerForTests(tracer);
39
+
40
+ startSpan(
41
+ "GET /api/attribution/by-person",
42
+ { "http.request.method": "GET" },
43
+ { kind: "server" },
44
+ );
45
+
46
+ expect(calls).toHaveLength(1);
47
+ expect(calls[0]?.kind).toBe(SpanKind.SERVER);
48
+ });
49
+
50
+ test("leaves kind undefined when no options are passed", () => {
51
+ const { calls, tracer } = fakeTracer();
52
+ _injectTracerForTests(tracer);
53
+
54
+ startSpan("mcp.tool kv-get", { "mcp.tool.name": "kv-get" });
55
+
56
+ expect(calls).toHaveLength(1);
57
+ expect(calls[0]?.kind).toBeUndefined();
58
+ });
59
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * HTTP-level coverage for the #1240 draft-task surface: `POST /api/tasks`
3
+ * with `draft: true` and `POST /api/tasks/{id}/promote-draft`.
4
+ */
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { unlink } from "node:fs/promises";
7
+ import {
8
+ createServer as createHttpServer,
9
+ type IncomingMessage,
10
+ type Server,
11
+ type ServerResponse,
12
+ } from "node:http";
13
+ import { closeDb, createAgent, createTaskExtended, createUser, initDb } from "../be/db";
14
+ import { findUserById } from "../be/users";
15
+ import { handleTasks } from "../http/tasks";
16
+ import { getPathSegments, parseQueryParams } from "../http/utils";
17
+ import { setRequestAuth } from "../utils/request-auth-context";
18
+
19
+ const TEST_DB_PATH = "./test-promote-draft-task-route.sqlite";
20
+
21
+ let server: Server;
22
+ let baseUrl: string;
23
+ let leadAgentId: string;
24
+
25
+ function createTestServer(): Server {
26
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
27
+ // Tests opt into a "user" principal via `x-test-user-id`; otherwise the
28
+ // request runs as a trusted operator (ownership checks pass through),
29
+ // matching the existing task-title-route.test.ts pattern.
30
+ const testUserId = req.headers["x-test-user-id"] as string | undefined;
31
+ if (testUserId) {
32
+ const user = await findUserById(testUserId);
33
+ setRequestAuth(req, user ? { kind: "user", userId: user.id, user } : null);
34
+ } else {
35
+ setRequestAuth(req, { kind: "operator", fingerprint: "promote-draft-route-test" });
36
+ }
37
+ res.setHeader("Content-Type", "application/json");
38
+ const pathSegments = getPathSegments(req.url ?? "");
39
+ const query = parseQueryParams(req.url ?? "");
40
+ const callerAgentId = req.headers["x-agent-id"] as string | undefined;
41
+ if (await handleTasks(req, res, pathSegments, query, callerAgentId)) return;
42
+ res.writeHead(404);
43
+ res.end(JSON.stringify({ error: "Not found" }));
44
+ });
45
+ }
46
+
47
+ async function api(
48
+ method: string,
49
+ path: string,
50
+ body?: unknown,
51
+ headers?: Record<string, string>,
52
+ ): Promise<{ status: number; body: any }> {
53
+ const response = await fetch(`${baseUrl}${path}`, {
54
+ method,
55
+ headers: { "Content-Type": "application/json", ...headers },
56
+ body: body === undefined ? undefined : JSON.stringify(body),
57
+ });
58
+ const text = await response.text();
59
+ return { status: response.status, body: text ? JSON.parse(text) : undefined };
60
+ }
61
+
62
+ beforeAll(async () => {
63
+ initDb(TEST_DB_PATH);
64
+ leadAgentId = (
65
+ await createAgent({ name: "promote-draft-route-lead", isLead: true, status: "idle" })
66
+ ).id;
67
+
68
+ server = createTestServer();
69
+ await new Promise<void>((resolve) => server.listen(0, resolve));
70
+ const address = server.address();
71
+ if (!address || typeof address === "string") throw new Error("test server did not listen");
72
+ baseUrl = `http://127.0.0.1:${address.port}`;
73
+ });
74
+
75
+ afterAll(async () => {
76
+ await new Promise<void>((resolve) => server.close(() => resolve()));
77
+ closeDb();
78
+ for (const suffix of ["", "-wal", "-shm"]) {
79
+ try {
80
+ await unlink(`${TEST_DB_PATH}${suffix}`);
81
+ } catch {}
82
+ }
83
+ });
84
+
85
+ describe("POST /api/tasks draft:true + /promote-draft (#1240)", () => {
86
+ test("draft:true creates a task in draft status, invisible to the assigned agent's dispatch queue", async () => {
87
+ const created = await api("POST", "/api/tasks", {
88
+ task: "session with attachments uploading",
89
+ agentId: leadAgentId,
90
+ source: "ui",
91
+ draft: true,
92
+ });
93
+ expect(created.status).toBe(201);
94
+ expect(created.body.status).toBe("draft");
95
+ expect(created.body.agentId).toBe(leadAgentId);
96
+
97
+ // Session list surfacing (GET /api/sessions) isn't wired into handleTasks
98
+ // — covered separately by listRecentSessions having no status filter.
99
+ });
100
+
101
+ test("promote-draft transitions draft -> pending for an owned task", async () => {
102
+ const created = await api("POST", "/api/tasks", {
103
+ task: "another draft session",
104
+ agentId: leadAgentId,
105
+ source: "ui",
106
+ draft: true,
107
+ });
108
+ expect(created.body.status).toBe("draft");
109
+
110
+ const promoted = await api("POST", `/api/tasks/${created.body.id}/promote-draft`);
111
+ expect(promoted.status).toBe(200);
112
+ expect(promoted.body.status).toBe("pending");
113
+ expect(promoted.body.agentId).toBe(leadAgentId);
114
+ });
115
+
116
+ test("promote-draft is idempotent — a second call returns the already-promoted task", async () => {
117
+ const created = await api("POST", "/api/tasks", {
118
+ task: "idempotency check",
119
+ agentId: leadAgentId,
120
+ source: "ui",
121
+ draft: true,
122
+ });
123
+
124
+ const first = await api("POST", `/api/tasks/${created.body.id}/promote-draft`);
125
+ expect(first.status).toBe(200);
126
+ expect(first.body.status).toBe("pending");
127
+
128
+ const second = await api("POST", `/api/tasks/${created.body.id}/promote-draft`);
129
+ expect(second.status).toBe(200);
130
+ expect(second.body.status).toBe("pending");
131
+ });
132
+
133
+ test("promote-draft on a task with no owner promotes to unassigned", async () => {
134
+ const task = await createTaskExtended("owner-less draft", { status: "draft" });
135
+ const promoted = await api("POST", `/api/tasks/${task.id}/promote-draft`);
136
+ expect(promoted.status).toBe(200);
137
+ expect(promoted.body.status).toBe("unassigned");
138
+ });
139
+
140
+ test("unknown task id returns 404", async () => {
141
+ const res = await api("POST", "/api/tasks/nonexistent-task-id/promote-draft");
142
+ expect(res.status).toBe(404);
143
+ });
144
+
145
+ test("a user who did not request the task cannot promote it", async () => {
146
+ const owner = await createUser({ name: "Draft Owner" });
147
+ const stranger = await createUser({ name: "Not The Owner" });
148
+
149
+ const created = await api(
150
+ "POST",
151
+ "/api/tasks",
152
+ {
153
+ task: "owned draft session",
154
+ agentId: leadAgentId,
155
+ source: "ui",
156
+ draft: true,
157
+ requestedByUserId: owner.id,
158
+ },
159
+ { "x-test-user-id": owner.id },
160
+ );
161
+ expect(created.body.status).toBe("draft");
162
+ expect(created.body.requestedByUserId).toBe(owner.id);
163
+
164
+ const deniedRes = await api("POST", `/api/tasks/${created.body.id}/promote-draft`, undefined, {
165
+ "x-test-user-id": stranger.id,
166
+ });
167
+ expect(deniedRes.status).toBe(403);
168
+
169
+ const allowedRes = await api("POST", `/api/tasks/${created.body.id}/promote-draft`, undefined, {
170
+ "x-test-user-id": owner.id,
171
+ });
172
+ expect(allowedRes.status).toBe(200);
173
+ expect(allowedRes.body.status).toBe("pending");
174
+ });
175
+ });
@@ -73,21 +73,107 @@ describe("describeRequestRoute", () => {
73
73
  expect(desc.httpRoute).toBe("/api/tasks");
74
74
  });
75
75
 
76
- test("unmatched path falls back to `{METHOD} /{firstSegment}` and omits http.route", () => {
77
- // /health is a core route not declared via route(), so no template match.
76
+ test("a core static route not declared via route() still gets http.route", () => {
77
+ // /health isn't registered through route(), but it's a fixed literal path
78
+ // (not a template), so CORE_ROUTE_TEMPLATES maps it to http.route too.
78
79
  const desc = describeRequestRoute("GET", ["health"]);
79
80
  expect(desc.spanName).toBe("GET /health");
80
- // No fabricated value — http.route is omitted for unmatched paths.
81
+ expect(desc.httpRoute).toBe("/health");
82
+ });
83
+
84
+ test("unmatched path with no core-route entry falls back to `{METHOD} /{firstSegment}` and omits http.route", () => {
85
+ const desc = describeRequestRoute("GET", ["nope", "missing"]);
86
+ expect(desc.spanName).toBe("GET /nope");
87
+ // No fabricated value — http.route is omitted for genuinely unmatched paths.
81
88
  expect(desc.httpRoute).toBeUndefined();
82
89
  });
83
90
 
84
91
  test("unmatched deeper path still only uses the first segment, no http.route", () => {
85
- // Bounded cardinality: never `GET /mcp/<session-id>`.
92
+ // Bounded cardinality: never `GET /mcp/<session-id>`. The core-route
93
+ // template only matches the exact literal `/mcp`, not a deeper path under it.
86
94
  const desc = describeRequestRoute("POST", ["mcp", "session-xyz", "messages"]);
87
95
  expect(desc.spanName).toBe("POST /mcp");
88
96
  expect(desc.httpRoute).toBeUndefined();
89
97
  });
90
98
 
99
+ test("the exact literal /mcp path gets http.route", () => {
100
+ const desc = describeRequestRoute("POST", ["mcp"]);
101
+ expect(desc.spanName).toBe("POST /mcp");
102
+ expect(desc.httpRoute).toBe("/mcp");
103
+ });
104
+
105
+ test("a multi-segment core static route gets its full path as http.route", () => {
106
+ const desc = describeRequestRoute("POST", ["internal", "reload-config"]);
107
+ expect(desc.spanName).toBe("POST /internal/reload-config");
108
+ expect(desc.httpRoute).toBe("/internal/reload-config");
109
+ });
110
+
111
+ test("POST /me is a 404 in handleCore (GET-only) and must not carry http.route", () => {
112
+ const desc = describeRequestRoute("POST", ["me"]);
113
+ expect(desc.spanName).toBe("POST /me");
114
+ expect(desc.httpRoute).toBeUndefined();
115
+ });
116
+
117
+ test("GET /internal/reload-config is a 404 in handleCore (POST-only) and must not carry http.route", () => {
118
+ const desc = describeRequestRoute("GET", ["internal", "reload-config"]);
119
+ expect(desc.spanName).toBe("GET /internal");
120
+ expect(desc.httpRoute).toBeUndefined();
121
+ });
122
+
123
+ test("PUT /mcp is a 405 in handleMcp (GET/POST/DELETE-only) and must not carry http.route", () => {
124
+ const desc = describeRequestRoute("PUT", ["mcp"]);
125
+ expect(desc.spanName).toBe("PUT /mcp");
126
+ expect(desc.httpRoute).toBeUndefined();
127
+ });
128
+
129
+ test("POST /health has no method gate in handleCore, so it still carries http.route", () => {
130
+ const desc = describeRequestRoute("POST", ["health"]);
131
+ expect(desc.spanName).toBe("POST /health");
132
+ expect(desc.httpRoute).toBe("/health");
133
+ });
134
+
135
+ test("GET /health?t=123 is a 404 in handleCore (strict URL equality) and must not carry http.route", () => {
136
+ const desc = describeRequestRoute("GET", ["health"], true);
137
+ expect(desc.spanName).toBe("GET /health");
138
+ expect(desc.httpRoute).toBeUndefined();
139
+ });
140
+
141
+ test("GET /me?x=1 matches handleCore's `?`-suffixed form and keeps http.route", () => {
142
+ const desc = describeRequestRoute("GET", ["me"], true);
143
+ expect(desc.spanName).toBe("GET /me");
144
+ expect(desc.httpRoute).toBe("/me");
145
+ });
146
+
147
+ test("GET /health/ is a 404 in handleCore (strict URL equality) and must not carry http.route", () => {
148
+ const desc = describeRequestRoute("GET", ["health"], false, true);
149
+ expect(desc.spanName).toBe("GET /health");
150
+ expect(desc.httpRoute).toBeUndefined();
151
+ });
152
+
153
+ test("GET /openapi.json/ is a 404 in handleCore and must not carry http.route", () => {
154
+ const desc = describeRequestRoute("GET", ["openapi.json"], false, true);
155
+ expect(desc.spanName).toBe("GET /openapi.json");
156
+ expect(desc.httpRoute).toBeUndefined();
157
+ });
158
+
159
+ test("POST /mcp/ is a 404 in handleMcp (strict URL equality) and must not carry http.route", () => {
160
+ const desc = describeRequestRoute("POST", ["mcp"], false, true);
161
+ expect(desc.spanName).toBe("POST /mcp");
162
+ expect(desc.httpRoute).toBeUndefined();
163
+ });
164
+
165
+ test("GET /mcp-user/ is a 404 in handleCore (strict URL equality) and must not carry http.route", () => {
166
+ const desc = describeRequestRoute("GET", ["mcp-user"], false, true);
167
+ expect(desc.spanName).toBe("GET /mcp-user");
168
+ expect(desc.httpRoute).toBeUndefined();
169
+ });
170
+
171
+ test("GET /docs/ is the one intentional exception and keeps http.route", () => {
172
+ const desc = describeRequestRoute("GET", ["docs"], false, true);
173
+ expect(desc.spanName).toBe("GET /docs");
174
+ expect(desc.httpRoute).toBe("/docs");
175
+ });
176
+
91
177
  test("known path with unknown method omits http.route", () => {
92
178
  // No PATCH handler on /api/tasks — must not fabricate a template.
93
179
  const desc = describeRequestRoute("PATCH", ["api", "tasks"]);
@@ -19,6 +19,7 @@ function sibling(overrides: Partial<SiblingTaskInfo> = {}): SiblingTaskInfo {
19
19
  agentName: "Picateclas",
20
20
  description: "Do the thing",
21
21
  updatedAt: new Date(NOW - 60_000).toISOString(),
22
+ createdAt: new Date(NOW - 60_000).toISOString(),
22
23
  ...overrides,
23
24
  };
24
25
  }
@@ -100,7 +101,34 @@ describe("renderSiblingBlock", () => {
100
101
  );
101
102
  expect(out).toContain("[in_progress] task:id-1");
102
103
  expect(out).toContain("[pending] task:id-2");
103
- expect(out).toContain("started 1h ago");
104
+ expect(out).toContain("last active 1h ago");
105
+ });
106
+
107
+ test("reports start time from createdAt, not from the progress-bumped updatedAt", () => {
108
+ // Regression: a sibling created 60s ago that emitted a progress event 1s
109
+ // ago used to render as "started 1s ago", making two independent user
110
+ // inputs look like a double-submit of the same one.
111
+ const out = renderSiblingBlock(
112
+ "task:slack:C1:1",
113
+ [
114
+ sibling({
115
+ createdAt: new Date(NOW - 60_000).toISOString(),
116
+ updatedAt: new Date(NOW - 1_000).toISOString(),
117
+ }),
118
+ ],
119
+ NOW,
120
+ );
121
+ expect(out).toContain("started 1m ago, last active 1s ago");
122
+ expect(out).not.toContain("started 1s ago");
123
+ });
124
+
125
+ test("omits the started clause rather than guessing when createdAt is absent", () => {
126
+ const { createdAt: _omitted, ...withoutCreatedAt } = sibling({
127
+ updatedAt: new Date(NOW - 1_000).toISOString(),
128
+ });
129
+ const out = renderSiblingBlock("task:slack:C1:1", [withoutCreatedAt], NOW);
130
+ expect(out).toContain("last active 1s ago");
131
+ expect(out).not.toContain("started");
104
132
  });
105
133
 
106
134
  test("falls back to agent:unassigned when no agent info", () => {
@@ -30,6 +30,7 @@ process.env.SLACK_RENDER_V2 = "false";
30
30
 
31
31
  let createAssistantFn: typeof import("../slack/assistant").createAssistant;
32
32
  let registerMessageHandlerFn: typeof import("../slack/handlers").registerMessageHandler;
33
+ let resetSlackHandlerCachesForTestingFn: typeof import("../slack/handlers").resetSlackHandlerCachesForTesting;
33
34
 
34
35
  let createTaskWithSiblingAwarenessSpy: any;
35
36
  let getAllAgentsSpy: any;
@@ -98,7 +99,11 @@ beforeAll(async () => {
98
99
  installSpyImplementations();
99
100
 
100
101
  ({ createAssistant: createAssistantFn } = await import("../slack/assistant"));
101
- ({ registerMessageHandler: registerMessageHandlerFn } = await import("../slack/handlers"));
102
+ ({
103
+ registerMessageHandler: registerMessageHandlerFn,
104
+ resetSlackHandlerCachesForTesting: resetSlackHandlerCachesForTestingFn,
105
+ } = await import("../slack/handlers"));
106
+ resetSlackHandlerCachesForTestingFn();
102
107
  });
103
108
 
104
109
  beforeEach(() => {
@@ -120,6 +125,7 @@ afterAll(() => {
120
125
  restoreEnvValue("SLACK_ALLOWED_EMAIL_DOMAINS");
121
126
  restoreEnvValue("SLACK_ALLOWED_USER_IDS");
122
127
  mock.restore();
128
+ resetSlackHandlerCachesForTestingFn();
123
129
  });
124
130
 
125
131
  // ---------------------------------------------------------------------------
@@ -198,6 +198,12 @@ describe("STEERING_ENABLED opt-in", () => {
198
198
  });
199
199
  });
200
200
 
201
+ test("reports an empty retention object on the authenticated metrics endpoint before a sweep", async () => {
202
+ const metrics = await api("GET", "/api/metrics");
203
+ expect(metrics.status).toBe(200);
204
+ expect(metrics.body.retention).toEqual({});
205
+ });
206
+
201
207
  test("removes steering MCP tools when disabled and restores them when enabled", async () => {
202
208
  await withSteeringDisabled(async () => {
203
209
  const serverTools = registeredTools(await createServer({ fullSurface: true }));
@@ -249,6 +249,17 @@ describe("swarm-config reserved keys guard", () => {
249
249
 
250
250
  // ─── MCP tool: set-config ─────────────────────────────────────────────────
251
251
  describe("MCP set-config tool", () => {
252
+ test("rejects an invalid database retention dry-run flag", async () => {
253
+ const handler = mcpServer.handlers.get("set-config");
254
+ const result = (await handler!(
255
+ { scope: "global", key: "DB_RETENTION_DRY_RUN", value: "treu" },
256
+ makeRequestInfo(),
257
+ )) as { structuredContent: { success: boolean; message: string } };
258
+
259
+ expect(result.structuredContent.success).toBe(false);
260
+ expect(result.structuredContent.message).toContain("Invalid DB_RETENTION_DRY_RUN");
261
+ });
262
+
252
263
  test("rejects reserved key with structured error", async () => {
253
264
  const handler = mcpServer.handlers.get("set-config");
254
265
  expect(handler).toBeDefined();
@@ -385,6 +396,22 @@ describe("swarm-config reserved keys guard", () => {
385
396
 
386
397
  // ─── HTTP: PUT /api/config ────────────────────────────────────────────────
387
398
  describe("HTTP PUT /api/config", () => {
399
+ test("returns 400 for an invalid database retention dry-run flag", async () => {
400
+ const res = await fetch(`${baseUrl}/api/config`, {
401
+ method: "PUT",
402
+ headers: { "Content-Type": "application/json" },
403
+ body: JSON.stringify({
404
+ scope: "global",
405
+ key: "DB_RETENTION_DRY_RUN",
406
+ value: "treu",
407
+ }),
408
+ });
409
+
410
+ expect(res.status).toBe(400);
411
+ const body = (await res.json()) as { error: string };
412
+ expect(body.error).toContain("Invalid DB_RETENTION_DRY_RUN");
413
+ });
414
+
388
415
  test("returns 400 for reserved key API_KEY", async () => {
389
416
  const res = await fetch(`${baseUrl}/api/config`, {
390
417
  method: "PUT",
package/src/types.ts CHANGED
@@ -237,6 +237,7 @@ export function resolveTaskModelSelection(opts: {
237
237
 
238
238
  // Task status - includes new unassigned and offered states
239
239
  export const AgentTaskStatusSchema = z.enum([
240
+ "draft", // Created but not yet dispatch-eligible (UI attachments still uploading, #1240); promoted to pending/offered/unassigned once the upload batch settles or times out
240
241
  "backlog", // Task is in backlog, not yet ready for pool
241
242
  "unassigned", // Task pool - no owner yet
242
243
  "offered", // Offered to agent, awaiting accept/reject
@@ -654,7 +655,7 @@ export const CreateTaskOptionsSchema = z.object({
654
655
  dependsOn: z.array(z.string()).optional(),
655
656
  offeredTo: z.string().optional(),
656
657
  /** Explicitly set initial status. */
657
- status: z.enum(["backlog", "unassigned"]).optional(),
658
+ status: z.enum(["draft", "backlog", "unassigned"]).optional(),
658
659
  slackChannelId: z.string().optional(),
659
660
  slackThreadTs: z.string().optional(),
660
661
  /** Exact Slack message that directly triggered this task; never inherited. */
@@ -137,6 +137,10 @@ function rowsToObjects(res: any): any[] {
137
137
  }
138
138
  ```
139
139
 
140
+ ### Concurrency limit
141
+
142
+ `ctx.swarm.db_query` throttles at about three concurrent calls. A wider `Promise.all` fan-out can return 429 responses with no `rows` key, so `res?.data?.rows ?? []` misreads throttling as a clean zero. Check the response status and `data.error` before reading `rows`. Serialize queries or cap concurrency at three.
143
+
140
144
  Common tables: `agent_tasks`, `session_logs`, `agent_memory`, `scheduled_tasks`, `agents`. `session_logs` has no `tool_name` column. Tool names sit inside the `content` JSON column. Extract them with `instr` and `substr` in SQL, or parse the JSON in the script.
141
145
 
142
146
  ## Progress from a script
@@ -132,6 +132,10 @@ function rowsToObjects(res: any): any[] {
132
132
  }
133
133
  ```
134
134
 
135
+ ### Concurrency limit
136
+
137
+ `ctx.swarm.db_query` throttles at about three concurrent calls. A wider `Promise.all` fan-out can return 429 responses with no `rows` key, so `res?.data?.rows ?? []` misreads throttling as a clean zero. Check the response status and `data.error` before reading `rows`. Serialize queries or cap concurrency at three.
138
+
135
139
  Common tables: `agent_tasks`, `session_logs`, `agent_memory`, `scheduled_tasks`, `agents`. `session_logs` has no `tool_name` column. Tool names sit inside the `content` JSON column. Extract them with `instr` and `substr` in SQL, or parse the JSON in the script.
136
140
 
137
141
  ## Progress from a script