@desplega.ai/agent-swarm 1.80.0 → 1.80.2
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/openapi.json +399 -14
- package/package.json +3 -1
- package/src/artifact-sdk/server.ts +2 -1
- package/src/be/db.ts +1 -1
- package/src/be/migrations/064_scripts.sql +39 -0
- package/src/be/migrations/065_script_embeddings.sql +7 -0
- package/src/be/migrations/066_scripts_args_json_schema.sql +1 -0
- package/src/be/scripts/db.ts +417 -0
- package/src/be/scripts/embeddings.ts +233 -0
- package/src/be/scripts/extract-schema.ts +55 -0
- package/src/be/scripts/maintenance.ts +9 -0
- package/src/be/scripts/typecheck.ts +199 -0
- package/src/cli.tsx +22 -5
- package/src/commands/artifact.ts +3 -2
- package/src/commands/claude-managed-setup.ts +2 -1
- package/src/commands/codex-login.ts +5 -3
- package/src/commands/onboard.tsx +2 -1
- package/src/commands/runner.ts +153 -20
- package/src/commands/setup.tsx +5 -3
- package/src/hooks/hook.ts +4 -3
- package/src/http/index.ts +40 -29
- package/src/http/memory.ts +28 -0
- package/src/http/openapi.ts +1 -0
- package/src/http/page-proxy.ts +2 -1
- package/src/http/route-def.ts +1 -0
- package/src/http/schedules.ts +37 -0
- package/src/http/scripts.ts +388 -0
- package/src/linear/outbound.ts +9 -2
- package/src/otel.ts +5 -0
- package/src/providers/claude-adapter.ts +23 -1
- package/src/providers/types.ts +8 -0
- package/src/scripts-runtime/ctx.ts +23 -0
- package/src/scripts-runtime/eval-harness.ts +63 -0
- package/src/scripts-runtime/executors/native.ts +232 -0
- package/src/scripts-runtime/executors/registry.ts +16 -0
- package/src/scripts-runtime/executors/types.ts +63 -0
- package/src/scripts-runtime/extract-args-schema.ts +69 -0
- package/src/scripts-runtime/extract-signature.ts +81 -0
- package/src/scripts-runtime/import-allowlist.ts +109 -0
- package/src/scripts-runtime/loader.ts +96 -0
- package/src/scripts-runtime/redacted.ts +48 -0
- package/src/scripts-runtime/sdk-allowlist.ts +29 -0
- package/src/scripts-runtime/stdlib/fetch.ts +46 -0
- package/src/scripts-runtime/stdlib/glob.ts +8 -0
- package/src/scripts-runtime/stdlib/grep.ts +34 -0
- package/src/scripts-runtime/stdlib/index.ts +16 -0
- package/src/scripts-runtime/stdlib/table.ts +17 -0
- package/src/scripts-runtime/swarm-config.ts +35 -0
- package/src/scripts-runtime/swarm-sdk.ts +197 -0
- package/src/scripts-runtime/types/stdlib.d.ts +104 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
- package/src/server.ts +12 -0
- package/src/tests/api-key.test.ts +33 -0
- package/src/tests/codex-login.test.ts +1 -1
- package/src/tests/error-tracker.test.ts +44 -0
- package/src/tests/linear-outbound-sync.test.ts +109 -0
- package/src/tests/mcp-tools.test.ts +69 -0
- package/src/tests/rate-limit-event.test.ts +292 -0
- package/src/tests/redacted.test.ts +29 -0
- package/src/tests/runner-tool-spans.test.ts +268 -0
- package/src/tests/script-executor-conformance.test.ts +142 -0
- package/src/tests/script-executor-registry.test.ts +17 -0
- package/src/tests/scripts-db.test.ts +329 -0
- package/src/tests/scripts-embeddings.test.ts +291 -0
- package/src/tests/scripts-extract-signature.test.ts +47 -0
- package/src/tests/scripts-http.test.ts +403 -0
- package/src/tests/scripts-import-allowlist.test.ts +55 -0
- package/src/tests/scripts-mcp-e2e.test.ts +269 -0
- package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
- package/src/tests/scripts-runtime.test.ts +344 -0
- package/src/tests/sdk-allowlist.test.ts +59 -0
- package/src/tests/secret-scrubber.test.ts +35 -1
- package/src/tests/swarm-config.test.ts +38 -0
- package/src/tests/tool-annotations.test.ts +2 -2
- package/src/tests/tool-call-progress.test.ts +30 -0
- package/src/tests/workflow-e2e.test.ts +218 -0
- package/src/tests/workflow-executors.test.ts +32 -2
- package/src/tests/workflow-input-redaction.test.ts +232 -0
- package/src/tests/workflow-swarm-script.test.ts +273 -0
- package/src/tools/memory-rate.ts +2 -1
- package/src/tools/script-common.ts +88 -0
- package/src/tools/script-delete.ts +35 -0
- package/src/tools/script-query-types.ts +37 -0
- package/src/tools/script-run.ts +43 -0
- package/src/tools/script-search.ts +32 -0
- package/src/tools/script-upsert.ts +43 -0
- package/src/tools/tool-config.ts +7 -0
- package/src/types.ts +61 -1
- package/src/utils/api-key.ts +28 -0
- package/src/utils/error-tracker.ts +58 -0
- package/src/utils/page-session.ts +8 -6
- package/src/utils/secret-scrubber.ts +22 -1
- package/src/workflows/engine.ts +12 -4
- package/src/workflows/executors/index.ts +1 -0
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/script.ts +12 -1
- package/src/workflows/executors/swarm-script.ts +170 -0
- package/src/workflows/input.ts +65 -0
- package/src/workflows/recovery.ts +31 -3
- package/src/workflows/resume.ts +43 -5
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, initDb } from "../be/db";
|
|
4
|
+
import { mcpToolNameForSdkMethod, SDK_ALLOWLIST } from "../scripts-runtime/sdk-allowlist";
|
|
5
|
+
import type { SwarmConfig } from "../scripts-runtime/swarm-config";
|
|
6
|
+
import { createSwarmSdk } from "../scripts-runtime/swarm-sdk";
|
|
7
|
+
import { createServer } from "../server";
|
|
8
|
+
|
|
9
|
+
const TEST_DB_PATH = "./test-sdk-allowlist.sqlite";
|
|
10
|
+
|
|
11
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
12
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
13
|
+
try {
|
|
14
|
+
await unlink(path + suffix);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("script SDK allowlist", () => {
|
|
22
|
+
let registeredTools: Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
26
|
+
initDb(TEST_DB_PATH);
|
|
27
|
+
const server = createServer();
|
|
28
|
+
registeredTools = (server as unknown as { _registeredTools: Record<string, unknown> })
|
|
29
|
+
._registeredTools;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
closeDb();
|
|
34
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("every SDK allowlist entry resolves to a live MCP tool", () => {
|
|
38
|
+
const missing = SDK_ALLOWLIST.map((name) => mcpToolNameForSdkMethod(name)).filter(
|
|
39
|
+
(name) => !(name in registeredTools),
|
|
40
|
+
);
|
|
41
|
+
expect(missing).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("runtime proxy rejects non-allowlisted tools before fetch", async () => {
|
|
45
|
+
const sdk = createSwarmSdk({} as SwarmConfig);
|
|
46
|
+
await expect(sdk.join_swarm({})).rejects.toThrow(
|
|
47
|
+
"Tool 'join_swarm' is not exposed to scripts (lifecycle/cred tool)",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("bundled swarm-sdk.d.ts exposes only allowlisted methods", async () => {
|
|
52
|
+
const types = await Bun.file("src/scripts-runtime/types/swarm-sdk.d.ts").text();
|
|
53
|
+
for (const name of SDK_ALLOWLIST) {
|
|
54
|
+
expect(types).toContain(`${name}(args`);
|
|
55
|
+
}
|
|
56
|
+
expect(types).not.toContain("join_swarm(");
|
|
57
|
+
expect(types).not.toContain("start_worker(");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { refreshSecretScrubberCache, scrubSecrets } from "../utils/secret-scrubber";
|
|
2
|
+
import { refreshSecretScrubberCache, scrubObject, scrubSecrets } from "../utils/secret-scrubber";
|
|
3
3
|
|
|
4
4
|
// Snapshot/restore process.env between tests so env-derived cache entries
|
|
5
5
|
// don't leak across cases.
|
|
@@ -266,3 +266,37 @@ describe("scrubSecrets — does not over-scrub", () => {
|
|
|
266
266
|
expect(out).toBe("example: ghp_TOKEN and glpat-xyz (both too short)");
|
|
267
267
|
});
|
|
268
268
|
});
|
|
269
|
+
|
|
270
|
+
describe("scrubObject", () => {
|
|
271
|
+
test("scrubs nested object and array string leaves", () => {
|
|
272
|
+
process.env.NESTED_TOKEN = "nested-secret-value-1234567890";
|
|
273
|
+
refreshSecretScrubberCache();
|
|
274
|
+
|
|
275
|
+
const out = scrubObject({
|
|
276
|
+
keep: 1,
|
|
277
|
+
nested: {
|
|
278
|
+
secret: "nested-secret-value-1234567890",
|
|
279
|
+
list: ["safe", "nested-secret-value-1234567890"],
|
|
280
|
+
},
|
|
281
|
+
nullish: null,
|
|
282
|
+
bool: true,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(out).toEqual({
|
|
286
|
+
keep: 1,
|
|
287
|
+
nested: {
|
|
288
|
+
secret: "[REDACTED:NESTED_TOKEN]",
|
|
289
|
+
list: ["safe", "[REDACTED:NESTED_TOKEN]"],
|
|
290
|
+
},
|
|
291
|
+
nullish: null,
|
|
292
|
+
bool: true,
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("handles circular references without recursing forever", () => {
|
|
297
|
+
const value: Record<string, unknown> = { a: "ok" };
|
|
298
|
+
value.self = value;
|
|
299
|
+
|
|
300
|
+
expect(scrubObject(value)).toEqual({ a: "ok", self: "[Circular]" });
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { SwarmConfigPayload } from "../scripts-runtime/executors/types";
|
|
3
|
+
import { Redacted } from "../scripts-runtime/redacted";
|
|
4
|
+
import { SwarmConfig } from "../scripts-runtime/swarm-config";
|
|
5
|
+
|
|
6
|
+
const payload: SwarmConfigPayload = {
|
|
7
|
+
system: {
|
|
8
|
+
apiKey: { value: "test-api-key", isSecret: true },
|
|
9
|
+
agentId: { value: "agent-1", isSecret: false },
|
|
10
|
+
mcpBaseUrl: { value: "http://localhost:3013", isSecret: false },
|
|
11
|
+
},
|
|
12
|
+
user: {
|
|
13
|
+
"user-key": { value: "user-value", isSecret: true },
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("SwarmConfig", () => {
|
|
18
|
+
test("hydrates system values as Redacted values with metadata", () => {
|
|
19
|
+
const config = new SwarmConfig(payload);
|
|
20
|
+
expect(Redacted.value(config.apiKey)).toBe("test-api-key");
|
|
21
|
+
expect(Redacted.meta(config.apiKey)).toEqual({ type: "system", isSecret: true });
|
|
22
|
+
expect(Redacted.value(config.agentId)).toBe("agent-1");
|
|
23
|
+
expect(Redacted.meta(config.mcpBaseUrl)).toEqual({ type: "system", isSecret: false });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("returns user-set config values", () => {
|
|
27
|
+
const config = new SwarmConfig(payload);
|
|
28
|
+
const value = config.get("user-key");
|
|
29
|
+
expect(value).toBeDefined();
|
|
30
|
+
expect(Redacted.value(value!)).toBe("user-value");
|
|
31
|
+
expect(Redacted.meta(value!)).toEqual({ type: "user", isSecret: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("missing user keys return undefined", () => {
|
|
35
|
+
const config = new SwarmConfig(payload);
|
|
36
|
+
expect(config.get("missing")).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -323,9 +323,9 @@ describe("Tool Annotations & Classification", () => {
|
|
|
323
323
|
test("registered tool count matches expected total", () => {
|
|
324
324
|
const count = Object.keys(tools).length;
|
|
325
325
|
// We expect all tools to be registered when all capabilities are enabled (default)
|
|
326
|
-
// Includes 11 skill tools
|
|
326
|
+
// Includes 11 skill tools, 7 MCP server tools, and reusable script tools
|
|
327
327
|
expect(count).toBeGreaterThanOrEqual(45);
|
|
328
|
-
expect(count).toBeLessThanOrEqual(
|
|
328
|
+
expect(count).toBeLessThanOrEqual(100);
|
|
329
329
|
});
|
|
330
330
|
|
|
331
331
|
test("core tools are fewer than deferred tools", () => {
|
|
@@ -151,6 +151,31 @@ describe("toolCallToProgress", () => {
|
|
|
151
151
|
expect(result).toBe("💬 Posting to Slack");
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test("agent-swarm:script-search has pretty label", () => {
|
|
155
|
+
const result = toolCallToProgress("mcp__agent-swarm__script-search", {});
|
|
156
|
+
expect(result).toBe("📜 Searching scripts");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("agent-swarm:script-run has pretty label", () => {
|
|
160
|
+
const result = toolCallToProgress("mcp__agent-swarm__script-run", {});
|
|
161
|
+
expect(result).toBe("📜 Running script");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("agent-swarm:script-upsert has pretty label", () => {
|
|
165
|
+
const result = toolCallToProgress("mcp__agent-swarm__script-upsert", {});
|
|
166
|
+
expect(result).toBe("📜 Saving script");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("agent-swarm:script-delete has pretty label", () => {
|
|
170
|
+
const result = toolCallToProgress("mcp__agent-swarm__script-delete", {});
|
|
171
|
+
expect(result).toBe("📜 Deleting script");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("agent-swarm:script-query-types has pretty label", () => {
|
|
175
|
+
const result = toolCallToProgress("mcp__agent-swarm__script-query-types", {});
|
|
176
|
+
expect(result).toBe("📜 Reading script types");
|
|
177
|
+
});
|
|
178
|
+
|
|
154
179
|
// --- Agent-swarm MCP tool NOT in lookup (humanized fallback) ---
|
|
155
180
|
|
|
156
181
|
test("unknown agent-swarm tool gets humanized fallback", () => {
|
|
@@ -191,6 +216,11 @@ describe("toolCallToProgress", () => {
|
|
|
191
216
|
expect(result).toBe("🗃️ Querying database");
|
|
192
217
|
});
|
|
193
218
|
|
|
219
|
+
test("bare agent-swarm script-run has pretty label", () => {
|
|
220
|
+
const result = toolCallToProgress("script-run", {});
|
|
221
|
+
expect(result).toBe("📜 Running script");
|
|
222
|
+
});
|
|
223
|
+
|
|
194
224
|
test("codex MCP args with agent-swarm server use pretty labels", () => {
|
|
195
225
|
const result = toolCallToProgress("send-task", {
|
|
196
226
|
server: "agent-swarm",
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
createAgent,
|
|
6
|
+
createWorkflow,
|
|
7
|
+
getDb,
|
|
8
|
+
getTaskByWorkflowRunStepId,
|
|
9
|
+
getWorkflowRun,
|
|
10
|
+
getWorkflowRunStepsByRunId,
|
|
11
|
+
initDb,
|
|
12
|
+
} from "../be/db";
|
|
13
|
+
import { upsertScriptByName } from "../be/scripts/db";
|
|
14
|
+
import { setScriptEmbeddingProviderForTests } from "../be/scripts/embeddings";
|
|
15
|
+
import type { Workflow, WorkflowDefinition } from "../types";
|
|
16
|
+
import { startWorkflowExecution } from "../workflows/engine";
|
|
17
|
+
import { InProcessEventBus } from "../workflows/event-bus";
|
|
18
|
+
import { AgentTaskExecutor } from "../workflows/executors/agent-task";
|
|
19
|
+
import type { ExecutorDependencies } from "../workflows/executors/base";
|
|
20
|
+
import { ExecutorRegistry } from "../workflows/executors/registry";
|
|
21
|
+
import { SwarmScriptExecutor } from "../workflows/executors/swarm-script";
|
|
22
|
+
import { setupWorkflowResumeListener } from "../workflows/resume";
|
|
23
|
+
import { interpolate } from "../workflows/template";
|
|
24
|
+
|
|
25
|
+
const TEST_DB_PATH = "./test-workflow-e2e.sqlite";
|
|
26
|
+
const API_KEY = "test-workflow-e2e-key-1234567890";
|
|
27
|
+
|
|
28
|
+
const noOpEmbeddingProvider = {
|
|
29
|
+
name: "test/noop-workflow-e2e-embedding",
|
|
30
|
+
dimensions: 1,
|
|
31
|
+
async embed() {
|
|
32
|
+
return null;
|
|
33
|
+
},
|
|
34
|
+
async embedBatch(texts: string[]) {
|
|
35
|
+
return texts.map(() => null);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const signatureJson = JSON.stringify({
|
|
40
|
+
args: { type: "object" },
|
|
41
|
+
result: { type: "object" },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let savedEnv: NodeJS.ProcessEnv;
|
|
45
|
+
let agentId: string;
|
|
46
|
+
let eventBus: InProcessEventBus;
|
|
47
|
+
let registry: ExecutorRegistry;
|
|
48
|
+
|
|
49
|
+
async function removeDbFiles(): Promise<void> {
|
|
50
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
51
|
+
try {
|
|
52
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeWorkflow(def: WorkflowDefinition): Workflow {
|
|
60
|
+
return createWorkflow({
|
|
61
|
+
name: `workflow-e2e-${crypto.randomUUID()}`,
|
|
62
|
+
definition: def,
|
|
63
|
+
createdByAgentId: agentId,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function saveScript(name: string, source: string) {
|
|
68
|
+
return upsertScriptByName({
|
|
69
|
+
name,
|
|
70
|
+
scope: "agent",
|
|
71
|
+
scopeId: agentId,
|
|
72
|
+
source,
|
|
73
|
+
description: `${name} e2e script`,
|
|
74
|
+
intent: "workflow swarm-script e2e fixture",
|
|
75
|
+
signatureJson,
|
|
76
|
+
agentId,
|
|
77
|
+
typeChecked: true,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function waitForRunStatus(runId: string, status: string): Promise<void> {
|
|
82
|
+
const deadline = Date.now() + 5_000;
|
|
83
|
+
while (Date.now() < deadline) {
|
|
84
|
+
if (getWorkflowRun(runId)?.status === status) return;
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`Timed out waiting for workflow run ${runId} to reach ${status}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
beforeAll(async () => {
|
|
91
|
+
savedEnv = { ...process.env };
|
|
92
|
+
await removeDbFiles();
|
|
93
|
+
initDb(TEST_DB_PATH);
|
|
94
|
+
process.env.AGENT_SWARM_API_KEY = API_KEY;
|
|
95
|
+
delete process.env.API_KEY;
|
|
96
|
+
setScriptEmbeddingProviderForTests(noOpEmbeddingProvider);
|
|
97
|
+
|
|
98
|
+
const agent = createAgent({ name: "workflow-e2e-agent", isLead: true, status: "idle" });
|
|
99
|
+
agentId = agent.id;
|
|
100
|
+
|
|
101
|
+
eventBus = new InProcessEventBus();
|
|
102
|
+
const db = await import("../be/db");
|
|
103
|
+
const deps: ExecutorDependencies = {
|
|
104
|
+
db,
|
|
105
|
+
eventBus,
|
|
106
|
+
interpolate: (template, ctx) => interpolate(template, ctx).result,
|
|
107
|
+
};
|
|
108
|
+
registry = new ExecutorRegistry();
|
|
109
|
+
registry.register(new SwarmScriptExecutor(deps));
|
|
110
|
+
registry.register(new AgentTaskExecutor(deps));
|
|
111
|
+
setupWorkflowResumeListener(eventBus, registry);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterAll(async () => {
|
|
115
|
+
setScriptEmbeddingProviderForTests(null);
|
|
116
|
+
closeDb();
|
|
117
|
+
await removeDbFiles();
|
|
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
|
+
});
|
|
126
|
+
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
getDb().run("DELETE FROM workflow_run_steps");
|
|
129
|
+
getDb().run("DELETE FROM workflow_runs");
|
|
130
|
+
getDb().run("DELETE FROM scripts");
|
|
131
|
+
getDb().run("DELETE FROM agent_tasks");
|
|
132
|
+
getDb().run("DELETE FROM workflows");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("workflow e2e swarm-script", () => {
|
|
136
|
+
test("swarm-script full workflow run executes through the engine", async () => {
|
|
137
|
+
await saveScript(
|
|
138
|
+
"square",
|
|
139
|
+
`export default async (args: { value: number }) => ({ squared: args.value * args.value });`,
|
|
140
|
+
);
|
|
141
|
+
const workflow = makeWorkflow({
|
|
142
|
+
nodes: [
|
|
143
|
+
{
|
|
144
|
+
id: "script",
|
|
145
|
+
type: "swarm-script",
|
|
146
|
+
config: { scriptName: "square", args: { value: 4 } },
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
152
|
+
const run = getWorkflowRun(runId);
|
|
153
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
154
|
+
|
|
155
|
+
expect(run?.status).toBe("completed");
|
|
156
|
+
expect(steps).toHaveLength(1);
|
|
157
|
+
expect(steps[0]?.output).toMatchObject({ result: { squared: 16 } });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("swarm-script agent-task interleave", async () => {
|
|
161
|
+
await saveScript(
|
|
162
|
+
"first-script",
|
|
163
|
+
`export default async (args: { value: number }) => ({ value: args.value + 1 });`,
|
|
164
|
+
);
|
|
165
|
+
await saveScript(
|
|
166
|
+
"second-script",
|
|
167
|
+
`export default async (args: { value: string }) => ({ final: Number(args.value) + 1 });`,
|
|
168
|
+
);
|
|
169
|
+
const workflow = makeWorkflow({
|
|
170
|
+
nodes: [
|
|
171
|
+
{
|
|
172
|
+
id: "first",
|
|
173
|
+
type: "swarm-script",
|
|
174
|
+
config: { scriptName: "first-script", args: { value: 1 } },
|
|
175
|
+
next: "task",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "task",
|
|
179
|
+
type: "agent-task",
|
|
180
|
+
inputs: { first: "first.result.value" },
|
|
181
|
+
config: { template: "Use {{first}}" },
|
|
182
|
+
next: "second",
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: "second",
|
|
186
|
+
type: "swarm-script",
|
|
187
|
+
inputs: { taskValue: "task.taskOutput.value" },
|
|
188
|
+
config: { scriptName: "second-script", args: { value: "{{taskValue}}" } },
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
194
|
+
expect(getWorkflowRun(runId)?.status).toBe("waiting");
|
|
195
|
+
|
|
196
|
+
const waitingSteps = getWorkflowRunStepsByRunId(runId);
|
|
197
|
+
const taskStep = waitingSteps.find((step) => step.nodeId === "task");
|
|
198
|
+
expect(taskStep?.status).toBe("waiting");
|
|
199
|
+
const task = getTaskByWorkflowRunStepId(taskStep!.id);
|
|
200
|
+
expect(task?.task).toBe("Use 2");
|
|
201
|
+
|
|
202
|
+
eventBus.emit("task.completed", {
|
|
203
|
+
taskId: task!.id,
|
|
204
|
+
output: JSON.stringify({ value: 41 }),
|
|
205
|
+
workflowRunId: runId,
|
|
206
|
+
workflowRunStepId: taskStep!.id,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await waitForRunStatus(runId, "completed");
|
|
210
|
+
const completedSteps = getWorkflowRunStepsByRunId(runId);
|
|
211
|
+
expect(completedSteps).toHaveLength(3);
|
|
212
|
+
expect(completedSteps.find((step) => step.nodeId === "first")?.status).toBe("completed");
|
|
213
|
+
expect(completedSteps.find((step) => step.nodeId === "task")?.status).toBe("completed");
|
|
214
|
+
expect(completedSteps.find((step) => step.nodeId === "second")?.output).toMatchObject({
|
|
215
|
+
result: { final: 42 },
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -516,6 +516,34 @@ describe("ScriptExecutor", () => {
|
|
|
516
516
|
const valid = ScriptOutputSchema.safeParse({ exitCode: 0, stdout: "hi", stderr: "" });
|
|
517
517
|
expect(valid.success).toBe(true);
|
|
518
518
|
});
|
|
519
|
+
|
|
520
|
+
test("keeps raw {exitCode, stdout, stderr} when stdout is not valid JSON", async () => {
|
|
521
|
+
const result = await executor.run(
|
|
522
|
+
input({ runtime: "bash", script: "echo 'not-json {at all'" }, {}),
|
|
523
|
+
);
|
|
524
|
+
expect(result.status).toBe("success");
|
|
525
|
+
const out = result.output as { exitCode: number; stdout: string; stderr: string } & {
|
|
526
|
+
parsed?: unknown;
|
|
527
|
+
};
|
|
528
|
+
expect(out.exitCode).toBe(0);
|
|
529
|
+
expect(out.stdout).toBe("not-json {at all");
|
|
530
|
+
expect(out.stderr).toBe("");
|
|
531
|
+
// No parsed key merged in — only the raw three fields are present.
|
|
532
|
+
expect(Object.keys(out).sort()).toEqual(["exitCode", "stderr", "stdout"]);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("populates structured output on timeout instead of leaving it null", async () => {
|
|
536
|
+
const result = await executor.run(
|
|
537
|
+
input({ runtime: "bash", script: "sleep 5", timeout: 1000 }, {}),
|
|
538
|
+
);
|
|
539
|
+
expect(result.status).toBe("failed");
|
|
540
|
+
expect(result.error).toContain("Script timed out after 1000ms");
|
|
541
|
+
const out = result.output as { exitCode: number; stdout: string; stderr: string };
|
|
542
|
+
expect(out).toBeDefined();
|
|
543
|
+
expect(out.exitCode).toBe(-1);
|
|
544
|
+
expect(out.stdout).toBe("");
|
|
545
|
+
expect(out.stderr).toContain("Script timed out after 1000ms");
|
|
546
|
+
});
|
|
519
547
|
});
|
|
520
548
|
|
|
521
549
|
// ─── VCS Executor ────────────────────────────────────────────
|
|
@@ -706,7 +734,7 @@ describe("ValidateExecutor", () => {
|
|
|
706
734
|
// ─── Registry Wiring ─────────────────────────────────────────
|
|
707
735
|
|
|
708
736
|
describe("createExecutorRegistry", () => {
|
|
709
|
-
test("registers all
|
|
737
|
+
test("registers all 11 executors (8 instant + 3 async)", () => {
|
|
710
738
|
const registry = createExecutorRegistry(mockDeps);
|
|
711
739
|
const types = registry.types();
|
|
712
740
|
|
|
@@ -715,12 +743,13 @@ describe("createExecutorRegistry", () => {
|
|
|
715
743
|
expect(types).toContain("notify");
|
|
716
744
|
expect(types).toContain("raw-llm");
|
|
717
745
|
expect(types).toContain("script");
|
|
746
|
+
expect(types).toContain("swarm-script");
|
|
718
747
|
expect(types).toContain("vcs");
|
|
719
748
|
expect(types).toContain("validate");
|
|
720
749
|
expect(types).toContain("agent-task");
|
|
721
750
|
expect(types).toContain("human-in-the-loop");
|
|
722
751
|
expect(types).toContain("wait");
|
|
723
|
-
expect(types).toHaveLength(
|
|
752
|
+
expect(types).toHaveLength(11);
|
|
724
753
|
});
|
|
725
754
|
|
|
726
755
|
test("instant executors have mode instant, async executors have mode async", () => {
|
|
@@ -731,6 +760,7 @@ describe("createExecutorRegistry", () => {
|
|
|
731
760
|
"notify",
|
|
732
761
|
"raw-llm",
|
|
733
762
|
"script",
|
|
763
|
+
"swarm-script",
|
|
734
764
|
"vcs",
|
|
735
765
|
"validate",
|
|
736
766
|
];
|