@desplega.ai/agent-swarm 1.90.0 → 1.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +2 -1
  2. package/openapi.json +803 -150
  3. package/package.json +5 -5
  4. package/src/artifact-sdk/server.ts +2 -1
  5. package/src/be/db.ts +337 -1
  6. package/src/be/memory/providers/sqlite-store.ts +6 -1
  7. package/src/be/memory/types.ts +1 -0
  8. package/src/be/migrations/083_script_workflows.sql +51 -0
  9. package/src/be/modelsdev-cache.json +42352 -38595
  10. package/src/be/scripts/typecheck.ts +181 -1
  11. package/src/be/seed-scripts/catalog/compound-insights.ts +398 -0
  12. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +911 -0
  13. package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
  14. package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
  15. package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
  16. package/src/be/seed-scripts/catalog/tool-usage.ts +59 -0
  17. package/src/be/seed-scripts/index.ts +54 -0
  18. package/src/be/seed-skills/index.ts +7 -0
  19. package/src/be/swarm-config-guard.ts +17 -0
  20. package/src/commands/artifact.ts +3 -2
  21. package/src/commands/profile-sync.ts +310 -0
  22. package/src/commands/runner.ts +134 -3
  23. package/src/hooks/hook.ts +32 -9
  24. package/src/http/db-query.ts +20 -5
  25. package/src/http/index.ts +57 -0
  26. package/src/http/integrations.ts +6 -1
  27. package/src/http/mcp-bridge.ts +117 -0
  28. package/src/http/mcp-oauth.ts +97 -39
  29. package/src/http/memory.ts +5 -2
  30. package/src/http/openapi.ts +2 -2
  31. package/src/http/pages-public.ts +10 -11
  32. package/src/http/pages.ts +7 -11
  33. package/src/http/script-runs.ts +555 -0
  34. package/src/http/scripts.ts +24 -1
  35. package/src/http/utils.ts +11 -4
  36. package/src/jira/app.ts +2 -3
  37. package/src/jira/webhook-lifecycle.ts +2 -1
  38. package/src/linear/app.ts +2 -3
  39. package/src/prompts/session-templates.ts +24 -4
  40. package/src/providers/claude-adapter.ts +86 -13
  41. package/src/script-workflows/executor.ts +110 -0
  42. package/src/script-workflows/harness.ts +73 -0
  43. package/src/script-workflows/label-lint.ts +51 -0
  44. package/src/script-workflows/limits.ts +22 -0
  45. package/src/script-workflows/supervisor.ts +139 -0
  46. package/src/script-workflows/workflow-ctx.ts +205 -0
  47. package/src/scripts-runtime/executors/native.ts +1 -0
  48. package/src/scripts-runtime/sdk-allowlist.ts +124 -0
  49. package/src/scripts-runtime/swarm-sdk.ts +198 -3
  50. package/src/scripts-runtime/types/stdlib.d.ts +287 -0
  51. package/src/scripts-runtime/types/swarm-sdk.d.ts +287 -0
  52. package/src/server.ts +2 -0
  53. package/src/slack/handlers.ts +11 -4
  54. package/src/slack/message-text.ts +98 -0
  55. package/src/slack/thread-buffer.ts +5 -3
  56. package/src/tests/claude-adapter-binary.test.ts +147 -4
  57. package/src/tests/claude-adapter-otel.test.ts +85 -1
  58. package/src/tests/db-query.test.ts +28 -0
  59. package/src/tests/error-tracker.test.ts +121 -0
  60. package/src/tests/harness-provider-resolution.test.ts +33 -0
  61. package/src/tests/hook-registration-nudge.test.ts +69 -0
  62. package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
  63. package/src/tests/mcp-tools.test.ts +6 -0
  64. package/src/tests/pages-public-html.test.ts +41 -0
  65. package/src/tests/pages-public-json-redirect.test.ts +37 -2
  66. package/src/tests/profile-sync.test.ts +282 -0
  67. package/src/tests/prompt-template-session.test.ts +34 -5
  68. package/src/tests/script-runs-http.test.ts +278 -0
  69. package/src/tests/script-workflows-label-lint.test.ts +43 -0
  70. package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
  71. package/src/tests/scripts-mcp-e2e.test.ts +49 -2
  72. package/src/tests/scripts-runtime.test.ts +33 -0
  73. package/src/tests/seed-scripts.test.ts +347 -2
  74. package/src/tests/slack-message-text.test.ts +250 -0
  75. package/src/tests/system-default-skills.test.ts +40 -0
  76. package/src/tools/create-metric.ts +2 -3
  77. package/src/tools/create-page.ts +3 -6
  78. package/src/tools/db-query.ts +16 -6
  79. package/src/tools/memory-rate.ts +2 -1
  80. package/src/tools/memory-search.ts +1 -0
  81. package/src/tools/register-kapso-number.ts +2 -4
  82. package/src/tools/request-human-input.ts +2 -1
  83. package/src/tools/script-common.ts +2 -4
  84. package/src/tools/script-run.ts +7 -0
  85. package/src/tools/script-runs.ts +123 -0
  86. package/src/tools/slack-read.ts +12 -3
  87. package/src/tools/tool-config.ts +4 -1
  88. package/src/types.ts +52 -0
  89. package/src/utils/constants.ts +58 -8
  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/skills/pages/content.md +205 -55
  94. package/templates/skills/script-workflows/config.json +14 -0
  95. package/templates/skills/script-workflows/content.md +68 -0
  96. package/templates/skills/swarm-scripts/content.md +45 -7
@@ -0,0 +1,282 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import {
3
+ buildIdentityPayload,
4
+ CLAUDE_MD_PATH,
5
+ collectProfilePayloads,
6
+ extractSetupScriptContent,
7
+ type FileReader,
8
+ IDENTITY_MD_PATH,
9
+ postProfileUpdate,
10
+ resolveClaudeMdPath,
11
+ SETUP_SCRIPT_PATH,
12
+ SOUL_MD_PATH,
13
+ syncProfileFilesToServer,
14
+ TOOLS_MD_PATH,
15
+ WORKSPACE_CLAUDE_MD_PATH,
16
+ } from "../commands/profile-sync";
17
+
18
+ const MARKER_START = "# === Agent-managed setup (from DB) ===";
19
+ const MARKER_END = "# === End agent-managed setup ===";
20
+
21
+ // A SOUL/IDENTITY body long enough to clear the 500-char min-length guard.
22
+ const LONG = "x".repeat(600);
23
+
24
+ describe("extractSetupScriptContent (marker extraction)", () => {
25
+ test("extracts ONLY the content between the agent-managed markers", () => {
26
+ const raw = [
27
+ "#!/bin/bash",
28
+ "echo operator-prelude",
29
+ MARKER_START,
30
+ "export FOO=bar",
31
+ 'echo "agent line"',
32
+ MARKER_END,
33
+ "echo operator-postlude",
34
+ ].join("\n");
35
+
36
+ expect(extractSetupScriptContent(raw)).toBe('export FOO=bar\necho "agent line"');
37
+ });
38
+
39
+ test("strips a leading shebang when no markers are present", () => {
40
+ const raw = '#!/bin/bash\necho "whole file is agent-managed"';
41
+ expect(extractSetupScriptContent(raw)).toBe('echo "whole file is agent-managed"');
42
+ });
43
+
44
+ test("returns null for an empty / whitespace-only file", () => {
45
+ expect(extractSetupScriptContent("")).toBeNull();
46
+ expect(extractSetupScriptContent(" \n\t ")).toBeNull();
47
+ });
48
+
49
+ test("returns null when the marker section is empty", () => {
50
+ const raw = `prelude\n${MARKER_START}\n \n${MARKER_END}\npostlude`;
51
+ expect(extractSetupScriptContent(raw)).toBeNull();
52
+ });
53
+
54
+ test("returns null when content exceeds the max length", () => {
55
+ const raw = `${MARKER_START}\n${"a".repeat(65537)}\n${MARKER_END}`;
56
+ expect(extractSetupScriptContent(raw)).toBeNull();
57
+ });
58
+ });
59
+
60
+ describe("buildIdentityPayload (min-length guard)", () => {
61
+ test("includes SOUL/IDENTITY only when they clear the 500-char minimum", () => {
62
+ const errSpy = spyOn(console, "error").mockImplementation(() => {});
63
+ try {
64
+ const ok = buildIdentityPayload({ soulMd: LONG, identityMd: LONG });
65
+ expect(ok.soulMd).toBe(LONG);
66
+ expect(ok.identityMd).toBe(LONG);
67
+
68
+ const short = buildIdentityPayload({ soulMd: "too short", identityMd: "also short" });
69
+ expect(short.soulMd).toBeUndefined();
70
+ expect(short.identityMd).toBeUndefined();
71
+ // The guard must be VISIBLE — it logs why it skipped.
72
+ expect(errSpy).toHaveBeenCalled();
73
+ } finally {
74
+ errSpy.mockRestore();
75
+ }
76
+ });
77
+
78
+ test("TOOLS.md has no min-length guard (any non-empty content syncs)", () => {
79
+ const payload = buildIdentityPayload({ toolsMd: "short tools" });
80
+ expect(payload.toolsMd).toBe("short tools");
81
+ });
82
+
83
+ test("HEARTBEAT.md syncs even when empty (no trim/min-length guard)", () => {
84
+ const payload = buildIdentityPayload({ heartbeatMd: "" });
85
+ expect(payload.heartbeatMd).toBe("");
86
+ });
87
+
88
+ test("skips files that exceed the max length", () => {
89
+ const huge = "z".repeat(65537);
90
+ const payload = buildIdentityPayload({ soulMd: huge, toolsMd: huge });
91
+ expect(payload.soulMd).toBeUndefined();
92
+ expect(payload.toolsMd).toBeUndefined();
93
+ });
94
+
95
+ test("absent files (undefined) produce no keys", () => {
96
+ expect(buildIdentityPayload({})).toEqual({});
97
+ });
98
+ });
99
+
100
+ describe("collectProfilePayloads (field gate)", () => {
101
+ const reader = (files: Record<string, string>): FileReader => {
102
+ return async (path: string) => files[path];
103
+ };
104
+
105
+ test("only the selected field group is collected", async () => {
106
+ const files = reader({
107
+ [SOUL_MD_PATH]: LONG,
108
+ [IDENTITY_MD_PATH]: LONG,
109
+ [TOOLS_MD_PATH]: "tools",
110
+ [CLAUDE_MD_PATH]: "claude md content",
111
+ [SETUP_SCRIPT_PATH]: `${MARKER_START}\nexport X=1\n${MARKER_END}`,
112
+ });
113
+
114
+ const setupOnly = await collectProfilePayloads(["setup"], "session_sync", files);
115
+ expect(setupOnly.map((p) => p.label)).toEqual(["setup"]);
116
+ expect(setupOnly[0]?.body).toEqual({ setupScript: "export X=1", changeSource: "session_sync" });
117
+
118
+ const claudeOnly = await collectProfilePayloads(["claude"], "session_sync", files);
119
+ expect(claudeOnly.map((p) => p.label)).toEqual(["claude"]);
120
+
121
+ const all = await collectProfilePayloads(
122
+ ["identity", "claude", "setup"],
123
+ "session_sync",
124
+ files,
125
+ );
126
+ expect(all.map((p) => p.label).sort()).toEqual(["claude", "identity", "setup"]);
127
+ });
128
+
129
+ test("a missing file yields no payload for that group (no empty POST)", async () => {
130
+ const files = reader({}); // nothing on disk
131
+ const payloads = await collectProfilePayloads(
132
+ ["identity", "claude", "setup"],
133
+ "session_sync",
134
+ files,
135
+ );
136
+ expect(payloads).toEqual([]);
137
+ });
138
+
139
+ test("propagates the changeSource into every body", async () => {
140
+ const files = reader({ [TOOLS_MD_PATH]: "tools" });
141
+ const payloads = await collectProfilePayloads(["identity"], "self_edit", files);
142
+ expect(payloads[0]?.body.changeSource).toBe("self_edit");
143
+ });
144
+
145
+ test("non-Claude providers sync /workspace/CLAUDE.md, not the personal file", async () => {
146
+ // A codex/pi/opencode session edits the runner-materialized workspace file;
147
+ // the Claude personal file (~/.claude/CLAUDE.md) is absent for them.
148
+ const files = reader({ [WORKSPACE_CLAUDE_MD_PATH]: "workspace claude md edit" });
149
+
150
+ const payloads = await collectProfilePayloads(
151
+ ["claude"],
152
+ "session_sync",
153
+ files,
154
+ WORKSPACE_CLAUDE_MD_PATH,
155
+ );
156
+ expect(payloads.map((p) => p.label)).toEqual(["claude"]);
157
+ expect(payloads[0]?.body).toEqual({
158
+ claudeMd: "workspace claude md edit",
159
+ changeSource: "session_sync",
160
+ });
161
+ });
162
+
163
+ test("Claude's default path never reads the workspace materialization", async () => {
164
+ // Guard against reverting a real Claude personal-file edit: with the default
165
+ // (personal-file) path, content sitting only at /workspace/CLAUDE.md — the
166
+ // stale boot materialization — must NOT be picked up as a claude payload.
167
+ const files = reader({ [WORKSPACE_CLAUDE_MD_PATH]: "stale workspace materialization" });
168
+
169
+ const payloads = await collectProfilePayloads(["claude"], "session_sync", files);
170
+ expect(payloads).toEqual([]);
171
+ });
172
+ });
173
+
174
+ describe("resolveClaudeMdPath (per-batch provider routing)", () => {
175
+ test("an all-Claude batch uses the personal-file path (Stop-hook backstop)", () => {
176
+ expect(resolveClaudeMdPath(["claude"])).toBe(CLAUDE_MD_PATH);
177
+ expect(resolveClaudeMdPath(["claude", "claude"])).toBe(CLAUDE_MD_PATH);
178
+ });
179
+
180
+ test("any non-Claude local session routes to the workspace file", () => {
181
+ expect(resolveClaudeMdPath(["codex"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
182
+ expect(resolveClaudeMdPath(["pi"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
183
+ expect(resolveClaudeMdPath(["opencode"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
184
+ // Mixed batch: a non-Claude edit means the workspace file is authoritative.
185
+ expect(resolveClaudeMdPath(["claude", "codex"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
186
+ });
187
+ });
188
+
189
+ describe("postProfileUpdate (non-2xx is surfaced, not swallowed)", () => {
190
+ const opts = {
191
+ agentId: "agent-1",
192
+ apiUrl: "https://api.example.test",
193
+ apiKey: "secret-key",
194
+ };
195
+ const payload = {
196
+ label: "setup",
197
+ body: { setupScript: "export X=1", changeSource: "session_sync" },
198
+ };
199
+
200
+ test("a successful 2xx response logs no warning", async () => {
201
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
202
+ const fetchImpl = (async () => new Response("{}", { status: 200 })) as typeof fetch;
203
+ try {
204
+ await postProfileUpdate({ ...opts, fetchImpl }, payload);
205
+ expect(warnSpy).not.toHaveBeenCalled();
206
+ } finally {
207
+ warnSpy.mockRestore();
208
+ }
209
+ });
210
+
211
+ test("a non-2xx response surfaces a warning but does NOT throw", async () => {
212
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
213
+ const fetchImpl = (async () =>
214
+ new Response("boom", { status: 500, statusText: "Server Error" })) as typeof fetch;
215
+ try {
216
+ // Must resolve (non-fatal), not reject.
217
+ await expect(postProfileUpdate({ ...opts, fetchImpl }, payload)).resolves.toBeUndefined();
218
+ expect(warnSpy).toHaveBeenCalledTimes(1);
219
+ const msg = String(warnSpy.mock.calls[0]?.[0]);
220
+ expect(msg).toContain("setup sync failed");
221
+ expect(msg).toContain("500");
222
+ } finally {
223
+ warnSpy.mockRestore();
224
+ }
225
+ });
226
+
227
+ test("a thrown fetch error surfaces a warning but does NOT throw", async () => {
228
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
229
+ const fetchImpl = (async () => {
230
+ throw new Error("network down");
231
+ }) as typeof fetch;
232
+ try {
233
+ await expect(postProfileUpdate({ ...opts, fetchImpl }, payload)).resolves.toBeUndefined();
234
+ expect(warnSpy).toHaveBeenCalledTimes(1);
235
+ expect(String(warnSpy.mock.calls[0]?.[0])).toContain("setup sync errored");
236
+ } finally {
237
+ warnSpy.mockRestore();
238
+ }
239
+ });
240
+
241
+ test("sends a PUT to the profile route with auth + agent headers", async () => {
242
+ let capturedUrl = "";
243
+ let capturedInit: RequestInit | undefined;
244
+ const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => {
245
+ capturedUrl = String(url);
246
+ capturedInit = init;
247
+ return new Response("{}", { status: 200 });
248
+ }) as typeof fetch;
249
+
250
+ await postProfileUpdate({ ...opts, fetchImpl }, payload);
251
+
252
+ expect(capturedUrl).toBe("https://api.example.test/api/agents/agent-1/profile");
253
+ expect(capturedInit?.method).toBe("PUT");
254
+ const headers = capturedInit?.headers as Record<string, string>;
255
+ expect(headers.Authorization).toBe("Bearer secret-key");
256
+ expect(headers["X-Agent-ID"]).toBe("agent-1");
257
+ expect(JSON.parse(String(capturedInit?.body))).toEqual(payload.body);
258
+ });
259
+ });
260
+
261
+ describe("syncProfileFilesToServer (orchestration is non-fatal)", () => {
262
+ test("resolves without throwing even when every POST fails", async () => {
263
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
264
+ const errSpy = spyOn(console, "error").mockImplementation(() => {});
265
+ const fetchImpl = (async () => new Response("nope", { status: 503 })) as typeof fetch;
266
+ try {
267
+ await expect(
268
+ syncProfileFilesToServer({
269
+ agentId: "agent-1",
270
+ apiUrl: "https://api.example.test",
271
+ apiKey: "secret-key",
272
+ changeSource: "session_sync",
273
+ // No files on a CI box → typically no payloads; still must never throw.
274
+ fetchImpl,
275
+ }),
276
+ ).resolves.toBeUndefined();
277
+ } finally {
278
+ warnSpy.mockRestore();
279
+ errSpy.mockRestore();
280
+ }
281
+ });
282
+ });
@@ -54,7 +54,7 @@ describe("Session templates — registration", () => {
54
54
  await ensureTemplatesRegistered();
55
55
  });
56
56
 
57
- test("all 14 system templates are registered", () => {
57
+ test("all 15 system templates are registered", () => {
58
58
  const systemTemplates = [
59
59
  "system.agent.role",
60
60
  "system.agent.register",
@@ -66,6 +66,7 @@ describe("Session templates — registration", () => {
66
66
  "system.agent.agent_fs",
67
67
  "system.agent.self_awareness",
68
68
  "system.agent.context_mode",
69
+ "system.agent.seed_scripts",
69
70
 
70
71
  "system.agent.system",
71
72
  "system.agent.services",
@@ -90,12 +91,12 @@ describe("Session templates — registration", () => {
90
91
  }
91
92
  });
92
93
 
93
- test("total of 20 session/system templates registered", () => {
94
+ test("total of 21 session/system templates registered", () => {
94
95
  const all = getAllTemplateDefinitions();
95
96
  const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
96
- // 20 = the original 19 + `system.session.worker.pi` (a pi-specific worker
97
- // composite that omits the context_mode block see session-templates.ts).
98
- expect(sessionSystem.length).toBe(20);
97
+ // 21 = the original 19 + `system.session.worker.pi` + `system.agent.seed_scripts`.
98
+ // The pi composite omits script nudges because pi workers do not have MCP.
99
+ expect(sessionSystem.length).toBe(21);
99
100
  });
100
101
  });
101
102
 
@@ -187,6 +188,15 @@ describe("Session templates — individual resolution", () => {
187
188
  expect(result.text).toContain("batch_execute");
188
189
  });
189
190
 
191
+ test("system.agent.seed_scripts points agents at task-start scripts", () => {
192
+ const result = resolveTemplate("system.agent.seed_scripts", {});
193
+ expect(result.text).toContain("Pre-built Seed Scripts");
194
+ expect(result.text).toContain("task-context-gathering");
195
+ expect(result.text).toContain("smart-recall");
196
+ expect(result.text).toContain("script-search");
197
+ expect(result.text).not.toContain("compound-insights");
198
+ });
199
+
190
200
  // system.agent.guidelines was removed — its content was redundant with worker/lead templates
191
201
 
192
202
  test("system.agent.system contains package info", () => {
@@ -240,6 +250,7 @@ describe("Session templates — composite resolution", () => {
240
250
 
241
251
  // Contains context_mode
242
252
  expect(result.text).toContain("Context Window Management");
253
+ expect(result.text).toContain("Pre-built Seed Scripts");
243
254
 
244
255
  // Guidelines template was removed (redundant with lead/worker templates)
245
256
 
@@ -275,6 +286,7 @@ describe("Session templates — composite resolution", () => {
275
286
 
276
287
  // Contains context_mode
277
288
  expect(result.text).toContain("Context Window Management");
289
+ expect(result.text).toContain("Pre-built Seed Scripts");
278
290
 
279
291
  // Guidelines template was removed (redundant with lead/worker templates)
280
292
 
@@ -309,6 +321,8 @@ describe("Session templates — composite resolution", () => {
309
321
  expect(workerResult.text).toContain("join-swarm");
310
322
  expect(leadResult.text).toContain("How You Are Built");
311
323
  expect(workerResult.text).toContain("How You Are Built");
324
+ expect(leadResult.text).toContain("Pre-built Seed Scripts");
325
+ expect(workerResult.text).toContain("Pre-built Seed Scripts");
312
326
 
313
327
  // Lead has lead content, not worker
314
328
  expect(leadResult.text).toContain("CRITICAL: You are a coordinator");
@@ -343,6 +357,7 @@ describe("Session templates — getBasePrompt integration", () => {
343
357
  expect(result).toContain("join-swarm");
344
358
  expect(result).toContain("store-progress");
345
359
  expect(result).toContain("How You Are Built");
360
+ expect(result).toContain("Pre-built Seed Scripts");
346
361
  expect(result).toContain("System packages available");
347
362
 
348
363
  // Conditional sections (services included by default)
@@ -365,8 +380,22 @@ describe("Session templates — getBasePrompt integration", () => {
365
380
  expect(result).toContain("your role is: lead");
366
381
  expect(result).toContain("integration-test-lead");
367
382
  expect(result).toContain("CRITICAL: You are a coordinator");
383
+ expect(result).toContain("Pre-built Seed Scripts");
368
384
 
369
385
  // Should NOT have worker content
370
386
  expect(result).not.toContain("task-action");
371
387
  });
388
+
389
+ test("getBasePrompt excludes seed_scripts for pi worker", async () => {
390
+ const { getBasePrompt } = await import("../prompts/base-prompt");
391
+ const result = await getBasePrompt({
392
+ role: "worker",
393
+ agentId: "integration-test-pi",
394
+ swarmUrl: "swarm.test.com",
395
+ provider: "pi",
396
+ });
397
+
398
+ expect(result).not.toContain("Pre-built Seed Scripts");
399
+ expect(result).not.toContain("task-context-gathering");
400
+ });
372
401
  });
@@ -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
+ });