@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
|
@@ -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("
|
|
77
|
-
// /health
|
|
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
|
-
|
|
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("
|
|
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
|
-
({
|
|
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
|