@desplega.ai/agent-swarm 1.91.0 → 1.92.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/openapi.json +1005 -152
- package/package.json +6 -6
- package/plugin/skills/pages/SKILL.md +5 -2
- package/src/be/db.ts +662 -19
- package/src/be/memory/constants.ts +2 -1
- package/src/be/memory/providers/openai-embedding.ts +2 -5
- package/src/be/memory/providers/sqlite-store.ts +293 -76
- package/src/be/memory/types.ts +35 -0
- package/src/be/migrations/083_script_workflows.sql +51 -0
- package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
- package/src/be/migrations/085_script_runs_kind.sql +9 -0
- package/src/be/migrations/086_pages_default_authed.sql +64 -0
- package/src/be/migrations/087_skill_files.sql +19 -0
- package/src/be/modelsdev-cache.json +42310 -38617
- package/src/be/scripts/typecheck.ts +49 -0
- package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.ts +310 -6
- package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
- package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +506 -0
- package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
- package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
- package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
- package/src/be/seed-scripts/catalog/tool-usage.ts +6 -3
- package/src/be/seed-scripts/index.ts +51 -5
- package/src/be/seed-skills/index.ts +3 -3
- package/src/be/skill-sync.ts +91 -7
- package/src/be/swarm-config-guard.ts +17 -0
- package/src/commands/runner.ts +49 -4
- package/src/heartbeat/templates.ts +20 -16
- package/src/http/db-query.ts +20 -5
- package/src/http/index.ts +51 -7
- package/src/http/mcp-user.ts +23 -0
- package/src/http/mcp.ts +58 -0
- package/src/http/memory.ts +58 -0
- package/src/http/pages.ts +1 -1
- package/src/http/script-runs.ts +557 -0
- package/src/http/scripts.ts +39 -2
- package/src/http/skills.ts +225 -0
- package/src/prompts/session-templates.ts +24 -4
- package/src/providers/claude-adapter.ts +107 -28
- package/src/script-workflows/executor.ts +110 -0
- package/src/script-workflows/harness.ts +73 -0
- package/src/script-workflows/label-lint.ts +51 -0
- package/src/script-workflows/limits.ts +22 -0
- package/src/script-workflows/supervisor.ts +139 -0
- package/src/script-workflows/workflow-ctx.ts +209 -0
- package/src/scripts-runtime/sdk-allowlist.ts +4 -0
- package/src/scripts-runtime/swarm-sdk.ts +13 -0
- package/src/scripts-runtime/types/stdlib.d.ts +61 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +61 -0
- package/src/server.ts +4 -0
- package/src/slack/handlers.ts +11 -4
- package/src/slack/message-text.ts +98 -0
- package/src/slack/thread-buffer.ts +5 -3
- package/src/tests/claude-adapter-binary.test.ts +271 -74
- package/src/tests/create-page-tool.test.ts +19 -2
- package/src/tests/db-query.test.ts +28 -0
- package/src/tests/error-tracker.test.ts +121 -0
- package/src/tests/harness-provider-resolution.test.ts +33 -0
- package/src/tests/heartbeat-checklist.test.ts +36 -0
- package/src/tests/mcp-tools.test.ts +6 -0
- package/src/tests/mcp-transport-gc.test.ts +58 -0
- package/src/tests/memory-health-endpoint.test.ts +78 -0
- package/src/tests/memory-store.test.ts +221 -1
- package/src/tests/pages-http.test.ts +20 -2
- package/src/tests/pages-storage.test.ts +26 -0
- package/src/tests/prompt-template-session.test.ts +34 -5
- package/src/tests/script-runs-http.test.ts +278 -0
- package/src/tests/script-workflows-label-lint.test.ts +43 -0
- package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
- package/src/tests/scripts-mcp-e2e.test.ts +102 -2
- package/src/tests/seed-scripts.test.ts +468 -3
- package/src/tests/skill-files-http.test.ts +171 -0
- package/src/tests/skill-files.test.ts +162 -0
- package/src/tests/skill-get-file-tool.test.ts +110 -0
- package/src/tests/skill-sync.test.ts +125 -6
- package/src/tests/slack-message-text.test.ts +250 -0
- package/src/tests/system-default-skills.test.ts +40 -0
- package/src/tools/create-page.ts +2 -2
- package/src/tools/db-query.ts +16 -6
- package/src/tools/script-runs.ts +123 -0
- package/src/tools/skills/index.ts +1 -0
- package/src/tools/skills/skill-get-file.ts +80 -0
- package/src/tools/slack-read.ts +12 -3
- package/src/tools/tool-config.ts +6 -2
- package/src/types.ts +72 -0
- package/src/utils/error-tracker.ts +40 -1
- package/src/utils/internal-ai/complete-structured.ts +10 -4
- package/src/workflows/executors/raw-llm.ts +76 -59
- package/templates/schedules/daily-blocker-digest/content.md +68 -54
- package/templates/schedules/daily-compounding-reflection/content.md +4 -4
- package/templates/schedules/daily-hn-briefing/content.md +5 -5
- package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
- package/templates/schedules/gtm-weekly-review/content.md +9 -9
- package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
- package/templates/skills/agentmail-sending/content.md +6 -7
- package/templates/skills/desloppify/content.md +8 -9
- package/templates/skills/jira-interaction/content.md +25 -33
- package/templates/skills/kapso-whatsapp/content.md +29 -30
- package/templates/skills/linear-interaction/content.md +8 -9
- package/templates/skills/pages/content.md +205 -55
- package/templates/skills/profile-corruption-escalation/content.md +44 -85
- package/templates/skills/script-workflows/config.json +14 -0
- package/templates/skills/script-workflows/content.md +68 -0
- package/templates/skills/sprite-cli/content.md +4 -5
- package/templates/skills/swarm-scripts/content.md +2 -3
- package/templates/skills/turso-interaction/content.md +14 -17
- package/templates/skills/workflow-iterate/content.md +38 -391
- package/templates/skills/x-api-interactions/content.md +4 -6
- package/templates/skills/scheduled-task-resilience/config.json +0 -14
- package/templates/skills/scheduled-task-resilience/content.md +0 -95
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { closeDb, createAgent, getDb, initDb, updateScriptRun } from "../be/db";
|
|
6
|
+
import { handleCore } from "../http/core";
|
|
7
|
+
import { handleScriptRuns } from "../http/script-runs";
|
|
8
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
9
|
+
import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
|
|
10
|
+
|
|
11
|
+
const TEST_DB_PATH = "./test-script-runs-http.sqlite";
|
|
12
|
+
const API_KEY = "test-script-runs-http-key-1234567890";
|
|
13
|
+
|
|
14
|
+
let agentId: string;
|
|
15
|
+
let savedEnv: NodeJS.ProcessEnv;
|
|
16
|
+
|
|
17
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
18
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
19
|
+
try {
|
|
20
|
+
await unlink(path + suffix);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
savedEnv = { ...process.env };
|
|
29
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
30
|
+
initDb(TEST_DB_PATH);
|
|
31
|
+
process.env.AGENT_SWARM_API_KEY = API_KEY;
|
|
32
|
+
process.env.APP_URL = "https://app.example.test";
|
|
33
|
+
process.env.SCRIPT_RUN_SUPERVISOR_DISABLE = "true";
|
|
34
|
+
delete process.env.API_KEY;
|
|
35
|
+
refreshSecretScrubberCache();
|
|
36
|
+
|
|
37
|
+
const agent = createAgent({ name: "script-runs-worker", isLead: false, status: "idle" });
|
|
38
|
+
agentId = agent.id;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
closeDb();
|
|
43
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
44
|
+
for (const key of Object.keys(process.env)) {
|
|
45
|
+
if (!(key in savedEnv)) delete process.env[key];
|
|
46
|
+
}
|
|
47
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
48
|
+
if (value === undefined) delete process.env[key];
|
|
49
|
+
else process.env[key] = value;
|
|
50
|
+
}
|
|
51
|
+
refreshSecretScrubberCache();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
getDb().run("DELETE FROM script_run_journal");
|
|
56
|
+
getDb().run("DELETE FROM script_runs");
|
|
57
|
+
delete process.env.SCRIPT_RUN_CONCURRENCY_CAP;
|
|
58
|
+
delete process.env.SCRIPT_RUN_MAX_STEPS;
|
|
59
|
+
delete process.env.SCRIPT_RUN_MAX_AGENT_TASKS;
|
|
60
|
+
delete process.env.SCRIPT_RUN_MAX_WALL_MS;
|
|
61
|
+
process.env.SCRIPT_RUN_SUPERVISOR_DISABLE = "true";
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
type TestResponse = {
|
|
65
|
+
status: number;
|
|
66
|
+
text: string;
|
|
67
|
+
json: () => Promise<unknown>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
async function dispatch(
|
|
71
|
+
path: string,
|
|
72
|
+
init: RequestInit & { agentId?: string } = {},
|
|
73
|
+
): Promise<TestResponse> {
|
|
74
|
+
const headers: Record<string, string> = {
|
|
75
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
...((init.headers as Record<string, string>) ?? {}),
|
|
78
|
+
};
|
|
79
|
+
if (init.agentId !== undefined) headers["X-Agent-ID"] = init.agentId;
|
|
80
|
+
const req = Readable.from(init.body ? [Buffer.from(String(init.body))] : []) as IncomingMessage;
|
|
81
|
+
req.method = init.method ?? "GET";
|
|
82
|
+
req.url = path;
|
|
83
|
+
req.headers = Object.fromEntries(
|
|
84
|
+
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
let status = 200;
|
|
88
|
+
let text = "";
|
|
89
|
+
const res = {
|
|
90
|
+
headersSent: false,
|
|
91
|
+
writableEnded: false,
|
|
92
|
+
setHeader() {},
|
|
93
|
+
writeHead(code: number) {
|
|
94
|
+
status = code;
|
|
95
|
+
this.headersSent = true;
|
|
96
|
+
return this;
|
|
97
|
+
},
|
|
98
|
+
end(chunk?: unknown) {
|
|
99
|
+
if (chunk !== undefined) text += String(chunk);
|
|
100
|
+
this.writableEnded = true;
|
|
101
|
+
return this;
|
|
102
|
+
},
|
|
103
|
+
} as unknown as ServerResponse;
|
|
104
|
+
|
|
105
|
+
const requestAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
106
|
+
if (!(await handleCore(req, res, requestAgentId, API_KEY))) {
|
|
107
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
108
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
109
|
+
if (!(await handleScriptRuns(req, res, pathSegments, queryParams, requestAgentId))) {
|
|
110
|
+
res.writeHead(404);
|
|
111
|
+
res.end("Not Found");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
status,
|
|
117
|
+
text,
|
|
118
|
+
json: async () => JSON.parse(text),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createBody(extra: Record<string, unknown> = {}): string {
|
|
123
|
+
return JSON.stringify({
|
|
124
|
+
source: "export default async function main() { return { ok: true }; }",
|
|
125
|
+
args: { ok: true },
|
|
126
|
+
...extra,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("/api/script-runs HTTP", () => {
|
|
131
|
+
test("creates and lists a script run", async () => {
|
|
132
|
+
const created = await dispatch("/api/script-runs", {
|
|
133
|
+
method: "POST",
|
|
134
|
+
agentId,
|
|
135
|
+
body: createBody({ scriptName: "daily-report" }),
|
|
136
|
+
});
|
|
137
|
+
expect(created.status).toBe(201);
|
|
138
|
+
const body = (await created.json()) as { id: string; status: string; url: string };
|
|
139
|
+
expect(body.status).toBe("running");
|
|
140
|
+
expect(body.url).toBe(`https://app.example.test/script-runs/${body.id}`);
|
|
141
|
+
|
|
142
|
+
const listed = await dispatch("/api/script-runs", { agentId });
|
|
143
|
+
expect(listed.status).toBe(200);
|
|
144
|
+
const listBody = (await listed.json()) as { runs: Array<{ id: string }>; total: number };
|
|
145
|
+
expect(listBody.total).toBe(1);
|
|
146
|
+
expect(listBody.runs[0]?.id).toBe(body.id);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("returns the existing run for an idempotency key", async () => {
|
|
150
|
+
const first = await dispatch("/api/script-runs", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
agentId,
|
|
153
|
+
body: createBody({ idempotencyKey: "stable-key" }),
|
|
154
|
+
});
|
|
155
|
+
const firstBody = (await first.json()) as { id: string };
|
|
156
|
+
|
|
157
|
+
const second = await dispatch("/api/script-runs", {
|
|
158
|
+
method: "POST",
|
|
159
|
+
agentId,
|
|
160
|
+
body: createBody({ idempotencyKey: "stable-key" }),
|
|
161
|
+
});
|
|
162
|
+
expect(second.status).toBe(409);
|
|
163
|
+
const secondBody = (await second.json()) as { id: string };
|
|
164
|
+
expect(secondBody.id).toBe(firstBody.id);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("rejects obvious literal labels inside loops before launch", async () => {
|
|
168
|
+
const rejected = await dispatch("/api/script-runs", {
|
|
169
|
+
method: "POST",
|
|
170
|
+
agentId,
|
|
171
|
+
body: createBody({
|
|
172
|
+
source:
|
|
173
|
+
'export default async function main(args, ctx) { for (const item of args.items) { await ctx.step.agentTask("process", { task: item.task }); } }',
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
expect(rejected.status).toBe(400);
|
|
177
|
+
const body = (await rejected.json()) as { error: string; violations: Array<{ label: string }> };
|
|
178
|
+
expect(body.error).toBe("label_lint_violation");
|
|
179
|
+
expect(body.violations[0]?.label).toBe("process");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("records and replays journal steps through internal routes", async () => {
|
|
183
|
+
const created = await dispatch("/api/script-runs", {
|
|
184
|
+
method: "POST",
|
|
185
|
+
agentId,
|
|
186
|
+
body: createBody(),
|
|
187
|
+
});
|
|
188
|
+
const { id } = (await created.json()) as { id: string };
|
|
189
|
+
|
|
190
|
+
const recorded = await dispatch(`/api/internal/script-runs/${id}/steps`, {
|
|
191
|
+
method: "POST",
|
|
192
|
+
agentId,
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
stepKey: "summarize",
|
|
195
|
+
stepType: "raw-llm",
|
|
196
|
+
config: { prompt: "hello" },
|
|
197
|
+
status: "completed",
|
|
198
|
+
result: { text: "hi" },
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
expect(recorded.status).toBe(201);
|
|
202
|
+
|
|
203
|
+
const replayed = await dispatch(`/api/internal/script-runs/${id}/steps/summarize`, {
|
|
204
|
+
agentId,
|
|
205
|
+
});
|
|
206
|
+
expect(replayed.status).toBe(200);
|
|
207
|
+
expect(await replayed.json()).toEqual({
|
|
208
|
+
stepKey: "summarize",
|
|
209
|
+
stepType: "raw-llm",
|
|
210
|
+
result: { text: "hi" },
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("aborts the run when the journal step cap is exceeded", async () => {
|
|
215
|
+
process.env.SCRIPT_RUN_MAX_STEPS = "1";
|
|
216
|
+
const created = await dispatch("/api/script-runs", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
agentId,
|
|
219
|
+
body: createBody(),
|
|
220
|
+
});
|
|
221
|
+
const { id } = (await created.json()) as { id: string };
|
|
222
|
+
|
|
223
|
+
const first = await dispatch(`/api/internal/script-runs/${id}/steps`, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
agentId,
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
stepKey: "one",
|
|
228
|
+
stepType: "swarm-script",
|
|
229
|
+
status: "completed",
|
|
230
|
+
result: 1,
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
expect(first.status).toBe(201);
|
|
234
|
+
|
|
235
|
+
const second = await dispatch(`/api/internal/script-runs/${id}/steps`, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
agentId,
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
stepKey: "two",
|
|
240
|
+
stepType: "swarm-script",
|
|
241
|
+
status: "completed",
|
|
242
|
+
result: 2,
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
expect(second.status).toBe(429);
|
|
246
|
+
|
|
247
|
+
const detail = await dispatch(`/api/script-runs/${id}`, { agentId });
|
|
248
|
+
const body = (await detail.json()) as { run: { status: string; error?: string } };
|
|
249
|
+
expect(body.run.status).toBe("aborted_limit");
|
|
250
|
+
expect(body.run.error).toContain("SCRIPT_RUN_MAX_STEPS");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("cancel leaves terminal script runs unchanged", async () => {
|
|
254
|
+
const created = await dispatch("/api/script-runs", {
|
|
255
|
+
method: "POST",
|
|
256
|
+
agentId,
|
|
257
|
+
body: createBody(),
|
|
258
|
+
});
|
|
259
|
+
const { id } = (await created.json()) as { id: string };
|
|
260
|
+
updateScriptRun(id, {
|
|
261
|
+
status: "failed",
|
|
262
|
+
pid: null,
|
|
263
|
+
finishedAt: new Date().toISOString(),
|
|
264
|
+
error: "original failure",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const cancelled = await dispatch(`/api/script-runs/${id}`, {
|
|
268
|
+
method: "DELETE",
|
|
269
|
+
agentId,
|
|
270
|
+
});
|
|
271
|
+
expect(cancelled.status).toBe(204);
|
|
272
|
+
|
|
273
|
+
const detail = await dispatch(`/api/script-runs/${id}`, { agentId });
|
|
274
|
+
const body = (await detail.json()) as { run: { status: string; error?: string } };
|
|
275
|
+
expect(body.run.status).toBe("failed");
|
|
276
|
+
expect(body.run.error).toBe("original failure");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { lintWorkflowLabels } from "../script-workflows/label-lint";
|
|
3
|
+
|
|
4
|
+
describe("lintWorkflowLabels", () => {
|
|
5
|
+
test("rejects a literal step label inside a loop", () => {
|
|
6
|
+
const result = lintWorkflowLabels(`
|
|
7
|
+
export default async function main(args, ctx) {
|
|
8
|
+
for (const item of args.items) {
|
|
9
|
+
await ctx.step.agentTask("process", { task: item.task });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
`);
|
|
13
|
+
|
|
14
|
+
expect(result.ok).toBe(false);
|
|
15
|
+
if (!result.ok) {
|
|
16
|
+
expect(result.errors).toHaveLength(1);
|
|
17
|
+
expect(result.errors[0]?.label).toBe("process");
|
|
18
|
+
expect(result.errors[0]?.detail).toContain("inside a loop");
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("allows template literal step labels inside a loop", () => {
|
|
23
|
+
const result = lintWorkflowLabels(`
|
|
24
|
+
export default async function main(args, ctx) {
|
|
25
|
+
for (const item of args.items) {
|
|
26
|
+
await ctx.step.agentTask(\`process:\${item.id}\`, { task: item.task });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
`);
|
|
30
|
+
|
|
31
|
+
expect(result).toEqual({ ok: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("allows literal step labels outside loops", () => {
|
|
35
|
+
const result = lintWorkflowLabels(`
|
|
36
|
+
export default async function main(_args, ctx) {
|
|
37
|
+
await ctx.step.rawLlm("summarize", { prompt: "hello" });
|
|
38
|
+
}
|
|
39
|
+
`);
|
|
40
|
+
|
|
41
|
+
expect(result).toEqual({ ok: true });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { rm, unlink } from "node:fs/promises";
|
|
3
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
4
|
+
import { closeDb, createAgent, getDb, initDb, listScriptRunJournalSteps } from "../be/db";
|
|
5
|
+
import { handleCore } from "../http/core";
|
|
6
|
+
import { handleScriptRuns } from "../http/script-runs";
|
|
7
|
+
import { handleScripts } from "../http/scripts";
|
|
8
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
9
|
+
import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
|
|
10
|
+
|
|
11
|
+
const TEST_DB_PATH = "./test-script-workflows-runtime-e2e.sqlite";
|
|
12
|
+
const WORKFLOW_RUNTIME_DIR = "./test-script-workflows-runtime";
|
|
13
|
+
const API_KEY = "test-script-workflows-runtime-key-1234567890";
|
|
14
|
+
|
|
15
|
+
let agentId: string;
|
|
16
|
+
let server: Server;
|
|
17
|
+
let baseUrl: string;
|
|
18
|
+
let savedEnv: NodeJS.ProcessEnv;
|
|
19
|
+
|
|
20
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
21
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
22
|
+
try {
|
|
23
|
+
await unlink(path + suffix);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function listen(server: Server): Promise<number> {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
server.once("error", reject);
|
|
33
|
+
server.listen(0, "127.0.0.1", () => {
|
|
34
|
+
const address = server.address();
|
|
35
|
+
if (!address || typeof address === "string") reject(new Error("No server address"));
|
|
36
|
+
else resolve(address.port);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function closeServer(server: Server): Promise<void> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
48
|
+
const agentId = req.headers["x-agent-id"] as string | undefined;
|
|
49
|
+
if (await handleCore(req, res, agentId, API_KEY)) return;
|
|
50
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
51
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
52
|
+
if (await handleScriptRuns(req, res, pathSegments, queryParams, agentId)) return;
|
|
53
|
+
if (await handleScripts(req, res, pathSegments, queryParams, agentId)) return;
|
|
54
|
+
res.writeHead(404);
|
|
55
|
+
res.end("Not Found");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function api(path: string, init: RequestInit = {}): Promise<Response> {
|
|
59
|
+
return fetch(`${baseUrl}${path}`, {
|
|
60
|
+
...init,
|
|
61
|
+
headers: {
|
|
62
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
63
|
+
"X-Agent-ID": agentId,
|
|
64
|
+
"Content-Type": "application/json",
|
|
65
|
+
...((init.headers as Record<string, string>) ?? {}),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function waitForRun(
|
|
71
|
+
id: string,
|
|
72
|
+
): Promise<{ status: string; output?: unknown; error?: string }> {
|
|
73
|
+
const deadline = Date.now() + 10_000;
|
|
74
|
+
while (Date.now() < deadline) {
|
|
75
|
+
const res = await api(`/api/script-runs/${id}`);
|
|
76
|
+
const body = (await res.json()) as {
|
|
77
|
+
run: { status: string; output?: unknown; error?: string };
|
|
78
|
+
};
|
|
79
|
+
if (["completed", "failed", "cancelled", "aborted_limit"].includes(body.run.status)) {
|
|
80
|
+
return body.run;
|
|
81
|
+
}
|
|
82
|
+
await Bun.sleep(250);
|
|
83
|
+
}
|
|
84
|
+
throw new Error("Timed out waiting for script run");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
beforeAll(async () => {
|
|
88
|
+
savedEnv = { ...process.env };
|
|
89
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
90
|
+
initDb(TEST_DB_PATH);
|
|
91
|
+
process.env.AGENT_SWARM_API_KEY = API_KEY;
|
|
92
|
+
process.env.API_KEY = API_KEY;
|
|
93
|
+
process.env.APP_URL = "https://app.example.test";
|
|
94
|
+
delete process.env.SCRIPT_RUN_SUPERVISOR_DISABLE;
|
|
95
|
+
await rm(WORKFLOW_RUNTIME_DIR, { recursive: true, force: true });
|
|
96
|
+
await Bun.$`bun build ./src/script-workflows/harness.ts --target bun --no-splitting --outfile ${WORKFLOW_RUNTIME_DIR}/harness.bundle.js`.quiet();
|
|
97
|
+
process.env.SCRIPT_WORKFLOW_RUNTIME_DIR = WORKFLOW_RUNTIME_DIR;
|
|
98
|
+
refreshSecretScrubberCache();
|
|
99
|
+
|
|
100
|
+
const agent = createAgent({ name: "script-workflow-e2e-worker", isLead: false, status: "idle" });
|
|
101
|
+
agentId = agent.id;
|
|
102
|
+
server = createServer((req, res) => {
|
|
103
|
+
route(req, res).catch((err) => {
|
|
104
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
105
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
const port = await listen(server);
|
|
109
|
+
baseUrl = `http://127.0.0.1:${port}`;
|
|
110
|
+
process.env.MCP_BASE_URL = baseUrl;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterAll(async () => {
|
|
114
|
+
await closeServer(server);
|
|
115
|
+
closeDb();
|
|
116
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
117
|
+
await rm(WORKFLOW_RUNTIME_DIR, { recursive: true, force: true });
|
|
118
|
+
for (const key of Object.keys(process.env)) {
|
|
119
|
+
if (!(key in savedEnv)) delete process.env[key];
|
|
120
|
+
}
|
|
121
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
122
|
+
if (value === undefined) delete process.env[key];
|
|
123
|
+
else process.env[key] = value;
|
|
124
|
+
}
|
|
125
|
+
refreshSecretScrubberCache();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
getDb().run("DELETE FROM script_run_journal");
|
|
130
|
+
getDb().run("DELETE FROM script_runs");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("script workflow runtime", () => {
|
|
134
|
+
test("runs a durable one-off script and replays a completed step", async () => {
|
|
135
|
+
const source = `
|
|
136
|
+
export default async function main(args, ctx) {
|
|
137
|
+
const first = await ctx.step.swarmScript("double", {
|
|
138
|
+
source: "export default async (args) => args.value * 2;",
|
|
139
|
+
args: { value: args.value },
|
|
140
|
+
intent: "script-workflow-e2e"
|
|
141
|
+
});
|
|
142
|
+
const second = await ctx.step.swarmScript("double", {
|
|
143
|
+
source: "export default async () => 999;",
|
|
144
|
+
intent: "script-workflow-e2e-should-replay"
|
|
145
|
+
});
|
|
146
|
+
return { runId: ctx.run.id, first, second };
|
|
147
|
+
}
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
const created = await api("/api/script-runs", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
body: JSON.stringify({ source, args: { value: 7 }, background: true }),
|
|
153
|
+
});
|
|
154
|
+
expect(created.status).toBe(201);
|
|
155
|
+
const { id } = (await created.json()) as { id: string };
|
|
156
|
+
|
|
157
|
+
const run = await waitForRun(id);
|
|
158
|
+
expect(run.status).toBe("completed");
|
|
159
|
+
expect(run.output).toMatchObject({
|
|
160
|
+
runId: id,
|
|
161
|
+
first: { result: 14, exitCode: 0 },
|
|
162
|
+
second: { result: 14, exitCode: 0 },
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const journal = listScriptRunJournalSteps(id);
|
|
166
|
+
expect(journal).toHaveLength(1);
|
|
167
|
+
expect(journal[0]?.stepKey).toBe("double");
|
|
168
|
+
expect(journal[0]?.stepType).toBe("swarm-script");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -6,10 +6,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
6
6
|
import { closeDb, createAgent, getDb, initDb } from "../be/db";
|
|
7
7
|
import { setScriptEmbeddingProviderForTests } from "../be/scripts/embeddings";
|
|
8
8
|
import { handleCore } from "../http/core";
|
|
9
|
+
import { handleScriptRuns } from "../http/script-runs";
|
|
9
10
|
import { handleScripts } from "../http/scripts";
|
|
10
11
|
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
11
12
|
import { registerScriptDeleteTool } from "../tools/script-delete";
|
|
12
13
|
import { registerScriptRunTool } from "../tools/script-run";
|
|
14
|
+
import { registerScriptRunsTools } from "../tools/script-runs";
|
|
13
15
|
import { registerScriptSearchTool } from "../tools/script-search";
|
|
14
16
|
import { registerScriptUpsertTool } from "../tools/script-upsert";
|
|
15
17
|
import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
|
|
@@ -65,6 +67,7 @@ function buildToolServer() {
|
|
|
65
67
|
const server = new McpServer({ name: "scripts-mcp-e2e", version: "1.0.0" });
|
|
66
68
|
registerScriptSearchTool(server);
|
|
67
69
|
registerScriptRunTool(server);
|
|
70
|
+
registerScriptRunsTools(server);
|
|
68
71
|
registerScriptUpsertTool(server);
|
|
69
72
|
registerScriptDeleteTool(server);
|
|
70
73
|
const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
|
|
@@ -74,6 +77,9 @@ function buildToolServer() {
|
|
|
74
77
|
run: registered["script-run"]!,
|
|
75
78
|
upsert: registered["script-upsert"]!,
|
|
76
79
|
del: registered["script-delete"]!,
|
|
80
|
+
launchScriptRun: registered["launch-script-run"]!,
|
|
81
|
+
getScriptRun: registered["get-script-run"]!,
|
|
82
|
+
listScriptRuns: registered["list-script-runs"]!,
|
|
77
83
|
};
|
|
78
84
|
}
|
|
79
85
|
|
|
@@ -126,7 +132,10 @@ async function dispatchScriptsApi(url: string, init: RequestInit = {}): Promise<
|
|
|
126
132
|
if (!(await handleCore(req, res, agentId, API_KEY))) {
|
|
127
133
|
const pathSegments = getPathSegments(req.url || "");
|
|
128
134
|
const queryParams = parseQueryParams(req.url || "");
|
|
129
|
-
if (
|
|
135
|
+
if (
|
|
136
|
+
!(await handleScripts(req, res, pathSegments, queryParams, agentId)) &&
|
|
137
|
+
!(await handleScriptRuns(req, res, pathSegments, queryParams, agentId))
|
|
138
|
+
) {
|
|
130
139
|
res.writeHead(404);
|
|
131
140
|
res.end("Not Found");
|
|
132
141
|
}
|
|
@@ -148,6 +157,7 @@ beforeAll(async () => {
|
|
|
148
157
|
await removeDbFiles(TEST_DB_PATH);
|
|
149
158
|
initDb(TEST_DB_PATH);
|
|
150
159
|
process.env.AGENT_SWARM_API_KEY = API_KEY;
|
|
160
|
+
process.env.SCRIPT_RUN_SUPERVISOR_DISABLE = "true";
|
|
151
161
|
delete process.env.API_KEY;
|
|
152
162
|
refreshSecretScrubberCache();
|
|
153
163
|
setScriptEmbeddingProviderForTests(fakeEmbeddingProvider);
|
|
@@ -155,7 +165,10 @@ beforeAll(async () => {
|
|
|
155
165
|
process.env.MCP_BASE_URL = "http://scripts-mcp-e2e.test";
|
|
156
166
|
globalThis.fetch = (async (input, init) => {
|
|
157
167
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
158
|
-
if (
|
|
168
|
+
if (
|
|
169
|
+
url.startsWith("http://scripts-mcp-e2e.test/api/scripts/") ||
|
|
170
|
+
url.startsWith("http://scripts-mcp-e2e.test/api/script-runs")
|
|
171
|
+
) {
|
|
159
172
|
return dispatchScriptsApi(url, init);
|
|
160
173
|
}
|
|
161
174
|
return savedFetch(input, init);
|
|
@@ -179,6 +192,8 @@ afterAll(async () => {
|
|
|
179
192
|
|
|
180
193
|
beforeEach(() => {
|
|
181
194
|
getDb().run("DELETE FROM scripts");
|
|
195
|
+
getDb().run("DELETE FROM script_run_journal");
|
|
196
|
+
getDb().run("DELETE FROM script_runs");
|
|
182
197
|
});
|
|
183
198
|
|
|
184
199
|
describe("script_ MCP HTTP proxy tools", () => {
|
|
@@ -217,6 +232,59 @@ describe("script_ MCP HTTP proxy tools", () => {
|
|
|
217
232
|
expect(del.structuredContent.data?.deleted).toBe(true);
|
|
218
233
|
});
|
|
219
234
|
|
|
235
|
+
test("persists a successful inline run with kind 'inline' and no journal", async () => {
|
|
236
|
+
const tools = buildToolServer();
|
|
237
|
+
const source = `export default async (args: { value: number }) => ({ doubled: args.value * 2 });`;
|
|
238
|
+
|
|
239
|
+
const run = (await tools.run.handler(
|
|
240
|
+
{ source, args: { value: 21 }, intent: "inline persist e2e" },
|
|
241
|
+
meta(workerId),
|
|
242
|
+
)) as StructuredResult<{ result: { doubled: number } }>;
|
|
243
|
+
expect(run.structuredContent.success).toBe(true);
|
|
244
|
+
expect(run.structuredContent.data?.result).toEqual({ doubled: 42 });
|
|
245
|
+
|
|
246
|
+
const listed = (await tools.listScriptRuns.handler(
|
|
247
|
+
{ limit: 10, offset: 0 },
|
|
248
|
+
meta(workerId),
|
|
249
|
+
)) as StructuredResult<{
|
|
250
|
+
runs: Array<{ id: string; kind: string; status: string }>;
|
|
251
|
+
total: number;
|
|
252
|
+
}>;
|
|
253
|
+
expect(listed.structuredContent.data?.total).toBe(1);
|
|
254
|
+
const inlineRun = listed.structuredContent.data?.runs[0];
|
|
255
|
+
expect(inlineRun?.kind).toBe("inline");
|
|
256
|
+
expect(inlineRun?.status).toBe("completed");
|
|
257
|
+
|
|
258
|
+
const detail = (await tools.getScriptRun.handler(
|
|
259
|
+
{ id: inlineRun?.id },
|
|
260
|
+
meta(workerId),
|
|
261
|
+
)) as StructuredResult<{ run: { kind: string }; journal: unknown[] }>;
|
|
262
|
+
expect(detail.structuredContent.data?.run.kind).toBe("inline");
|
|
263
|
+
expect(detail.structuredContent.data?.journal).toEqual([]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("persists a failed inline run with kind 'inline' and an error", async () => {
|
|
267
|
+
const tools = buildToolServer();
|
|
268
|
+
const source = `export default async () => { throw new Error("boom"); };`;
|
|
269
|
+
|
|
270
|
+
const run = (await tools.run.handler(
|
|
271
|
+
{ source, intent: "inline failure e2e" },
|
|
272
|
+
meta(workerId),
|
|
273
|
+
)) as StructuredResult<unknown>;
|
|
274
|
+
expect(run.structuredContent.success).toBe(true);
|
|
275
|
+
|
|
276
|
+
const listed = (await tools.listScriptRuns.handler(
|
|
277
|
+
{ limit: 10, offset: 0 },
|
|
278
|
+
meta(workerId),
|
|
279
|
+
)) as StructuredResult<{
|
|
280
|
+
runs: Array<{ kind: string; status: string; error?: string }>;
|
|
281
|
+
}>;
|
|
282
|
+
const failed = listed.structuredContent.data?.runs[0];
|
|
283
|
+
expect(failed?.kind).toBe("inline");
|
|
284
|
+
expect(failed?.status).toBe("failed");
|
|
285
|
+
expect(failed?.error).toBeTruthy();
|
|
286
|
+
});
|
|
287
|
+
|
|
220
288
|
test("stdio-style missing agent identity short-circuits clearly", async () => {
|
|
221
289
|
const tools = buildToolServer();
|
|
222
290
|
const result = (await tools.search.handler({ query: "anything" }, meta())) as StructuredResult<{
|
|
@@ -226,6 +294,38 @@ describe("script_ MCP HTTP proxy tools", () => {
|
|
|
226
294
|
expect(result.structuredContent.error).toContain("HTTP MCP transport");
|
|
227
295
|
});
|
|
228
296
|
|
|
297
|
+
test("launches, lists, and inspects durable script workflow runs", async () => {
|
|
298
|
+
const tools = buildToolServer();
|
|
299
|
+
const source = `export default async function main() { return { ok: true }; }`;
|
|
300
|
+
|
|
301
|
+
const launched = (await tools.launchScriptRun.handler(
|
|
302
|
+
{ source, args: { input: true }, scriptName: "mcp-script-workflow" },
|
|
303
|
+
meta(workerId),
|
|
304
|
+
)) as StructuredResult<{ id: string; status: string; url: string }>;
|
|
305
|
+
expect(launched.structuredContent.success).toBe(true);
|
|
306
|
+
expect(launched.structuredContent.status).toBe(201);
|
|
307
|
+
expect(launched.structuredContent.data?.status).toBe("running");
|
|
308
|
+
const runId = launched.structuredContent.data?.id;
|
|
309
|
+
expect(runId).toBeTruthy();
|
|
310
|
+
|
|
311
|
+
const listed = (await tools.listScriptRuns.handler(
|
|
312
|
+
{ status: "running", limit: 10, offset: 0 },
|
|
313
|
+
meta(workerId),
|
|
314
|
+
)) as StructuredResult<{ runs: Array<{ id: string }>; total: number }>;
|
|
315
|
+
expect(listed.structuredContent.success).toBe(true);
|
|
316
|
+
expect(listed.structuredContent.data?.total).toBe(1);
|
|
317
|
+
expect(listed.structuredContent.data?.runs[0]?.id).toBe(runId);
|
|
318
|
+
|
|
319
|
+
const detail = (await tools.getScriptRun.handler(
|
|
320
|
+
{ id: runId },
|
|
321
|
+
meta(workerId),
|
|
322
|
+
)) as StructuredResult<{ run: { id: string; status: string }; journal: unknown[] }>;
|
|
323
|
+
expect(detail.structuredContent.success).toBe(true);
|
|
324
|
+
expect(detail.structuredContent.data?.run.id).toBe(runId);
|
|
325
|
+
expect(detail.structuredContent.data?.run.status).toBe("running");
|
|
326
|
+
expect(detail.structuredContent.data?.journal).toEqual([]);
|
|
327
|
+
});
|
|
328
|
+
|
|
229
329
|
test("typed SDK fixture passes upsert typecheck and wrong arg type fails", async () => {
|
|
230
330
|
const tools = buildToolServer();
|
|
231
331
|
const source = `
|