@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.
Files changed (114) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +1005 -152
  3. package/package.json +6 -6
  4. package/plugin/skills/pages/SKILL.md +5 -2
  5. package/src/be/db.ts +662 -19
  6. package/src/be/memory/constants.ts +2 -1
  7. package/src/be/memory/providers/openai-embedding.ts +2 -5
  8. package/src/be/memory/providers/sqlite-store.ts +293 -76
  9. package/src/be/memory/types.ts +35 -0
  10. package/src/be/migrations/083_script_workflows.sql +51 -0
  11. package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
  12. package/src/be/migrations/085_script_runs_kind.sql +9 -0
  13. package/src/be/migrations/086_pages_default_authed.sql +64 -0
  14. package/src/be/migrations/087_skill_files.sql +19 -0
  15. package/src/be/modelsdev-cache.json +42310 -38617
  16. package/src/be/scripts/typecheck.ts +49 -0
  17. package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
  18. package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
  19. package/src/be/seed-scripts/catalog/compound-insights.ts +310 -6
  20. package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
  21. package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
  22. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +506 -0
  23. package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
  24. package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
  25. package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
  26. package/src/be/seed-scripts/catalog/tool-usage.ts +6 -3
  27. package/src/be/seed-scripts/index.ts +51 -5
  28. package/src/be/seed-skills/index.ts +3 -3
  29. package/src/be/skill-sync.ts +91 -7
  30. package/src/be/swarm-config-guard.ts +17 -0
  31. package/src/commands/runner.ts +49 -4
  32. package/src/heartbeat/templates.ts +20 -16
  33. package/src/http/db-query.ts +20 -5
  34. package/src/http/index.ts +51 -7
  35. package/src/http/mcp-user.ts +23 -0
  36. package/src/http/mcp.ts +58 -0
  37. package/src/http/memory.ts +58 -0
  38. package/src/http/pages.ts +1 -1
  39. package/src/http/script-runs.ts +557 -0
  40. package/src/http/scripts.ts +39 -2
  41. package/src/http/skills.ts +225 -0
  42. package/src/prompts/session-templates.ts +24 -4
  43. package/src/providers/claude-adapter.ts +107 -28
  44. package/src/script-workflows/executor.ts +110 -0
  45. package/src/script-workflows/harness.ts +73 -0
  46. package/src/script-workflows/label-lint.ts +51 -0
  47. package/src/script-workflows/limits.ts +22 -0
  48. package/src/script-workflows/supervisor.ts +139 -0
  49. package/src/script-workflows/workflow-ctx.ts +209 -0
  50. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  51. package/src/scripts-runtime/swarm-sdk.ts +13 -0
  52. package/src/scripts-runtime/types/stdlib.d.ts +61 -0
  53. package/src/scripts-runtime/types/swarm-sdk.d.ts +61 -0
  54. package/src/server.ts +4 -0
  55. package/src/slack/handlers.ts +11 -4
  56. package/src/slack/message-text.ts +98 -0
  57. package/src/slack/thread-buffer.ts +5 -3
  58. package/src/tests/claude-adapter-binary.test.ts +271 -74
  59. package/src/tests/create-page-tool.test.ts +19 -2
  60. package/src/tests/db-query.test.ts +28 -0
  61. package/src/tests/error-tracker.test.ts +121 -0
  62. package/src/tests/harness-provider-resolution.test.ts +33 -0
  63. package/src/tests/heartbeat-checklist.test.ts +36 -0
  64. package/src/tests/mcp-tools.test.ts +6 -0
  65. package/src/tests/mcp-transport-gc.test.ts +58 -0
  66. package/src/tests/memory-health-endpoint.test.ts +78 -0
  67. package/src/tests/memory-store.test.ts +221 -1
  68. package/src/tests/pages-http.test.ts +20 -2
  69. package/src/tests/pages-storage.test.ts +26 -0
  70. package/src/tests/prompt-template-session.test.ts +34 -5
  71. package/src/tests/script-runs-http.test.ts +278 -0
  72. package/src/tests/script-workflows-label-lint.test.ts +43 -0
  73. package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
  74. package/src/tests/scripts-mcp-e2e.test.ts +102 -2
  75. package/src/tests/seed-scripts.test.ts +468 -3
  76. package/src/tests/skill-files-http.test.ts +171 -0
  77. package/src/tests/skill-files.test.ts +162 -0
  78. package/src/tests/skill-get-file-tool.test.ts +110 -0
  79. package/src/tests/skill-sync.test.ts +125 -6
  80. package/src/tests/slack-message-text.test.ts +250 -0
  81. package/src/tests/system-default-skills.test.ts +40 -0
  82. package/src/tools/create-page.ts +2 -2
  83. package/src/tools/db-query.ts +16 -6
  84. package/src/tools/script-runs.ts +123 -0
  85. package/src/tools/skills/index.ts +1 -0
  86. package/src/tools/skills/skill-get-file.ts +80 -0
  87. package/src/tools/slack-read.ts +12 -3
  88. package/src/tools/tool-config.ts +6 -2
  89. package/src/types.ts +72 -0
  90. package/src/utils/error-tracker.ts +40 -1
  91. package/src/utils/internal-ai/complete-structured.ts +10 -4
  92. package/src/workflows/executors/raw-llm.ts +76 -59
  93. package/templates/schedules/daily-blocker-digest/content.md +68 -54
  94. package/templates/schedules/daily-compounding-reflection/content.md +4 -4
  95. package/templates/schedules/daily-hn-briefing/content.md +5 -5
  96. package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
  97. package/templates/schedules/gtm-weekly-review/content.md +9 -9
  98. package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
  99. package/templates/skills/agentmail-sending/content.md +6 -7
  100. package/templates/skills/desloppify/content.md +8 -9
  101. package/templates/skills/jira-interaction/content.md +25 -33
  102. package/templates/skills/kapso-whatsapp/content.md +29 -30
  103. package/templates/skills/linear-interaction/content.md +8 -9
  104. package/templates/skills/pages/content.md +205 -55
  105. package/templates/skills/profile-corruption-escalation/content.md +44 -85
  106. package/templates/skills/script-workflows/config.json +14 -0
  107. package/templates/skills/script-workflows/content.md +68 -0
  108. package/templates/skills/sprite-cli/content.md +4 -5
  109. package/templates/skills/swarm-scripts/content.md +2 -3
  110. package/templates/skills/turso-interaction/content.md +14 -17
  111. package/templates/skills/workflow-iterate/content.md +38 -391
  112. package/templates/skills/x-api-interactions/content.md +4 -6
  113. package/templates/skills/scheduled-task-resilience/config.json +0 -14
  114. 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 (!(await handleScripts(req, res, pathSegments, queryParams, agentId))) {
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 (url.startsWith("http://scripts-mcp-e2e.test/api/scripts/")) {
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 = `