@desplega.ai/agent-swarm 1.100.3 → 1.101.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/be/scripts/typecheck.ts +1 -1
- package/src/commands/codex-login.ts +2 -2
- package/src/http/mcp-bridge.ts +2 -2
- package/src/script-workflows/workflow-ctx.ts +2 -1
- package/src/scripts-runtime/sdk-allowlist.ts +8 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -1
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -1
- package/src/tests/codex-login.test.ts +32 -4
- package/src/tests/sdk-allowlist.test.ts +123 -1
- package/src/tests/workflow-ctx.test.ts +43 -0
- package/src/tests/workflow-swarm-script.test.ts +225 -0
- package/src/tests/workflow-template.test.ts +17 -0
- package/src/workflows/engine.ts +22 -1
- package/src/workflows/retry-poller.ts +2 -3
- package/src/workflows/template.ts +48 -0
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Swarm API",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.101.0",
|
|
6
6
|
"description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desplega.ai/agent-swarm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.101.0",
|
|
4
4
|
"description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "desplega.sh <contact@desplega.sh>",
|
|
@@ -149,7 +149,7 @@ export interface SwarmSdk {
|
|
|
149
149
|
workflow_patch(args: Record<string, unknown>): Promise<unknown>;
|
|
150
150
|
workflow_patchNode(args: Record<string, unknown>): Promise<unknown>;
|
|
151
151
|
workflow_delete(args: { id: string }): Promise<unknown>;
|
|
152
|
-
workflow_trigger(args: { id: string;
|
|
152
|
+
workflow_trigger(args: { id: string; triggerData?: Record<string, unknown> }): Promise<unknown>;
|
|
153
153
|
workflow_retryRun(args: { id: string }): Promise<unknown>;
|
|
154
154
|
workflow_cancelRun(args: { id: string }): Promise<unknown>;
|
|
155
155
|
|
|
@@ -182,7 +182,7 @@ Usage:
|
|
|
182
182
|
Options:
|
|
183
183
|
--api-url <url> Swarm API URL (default: MCP_BASE_URL or http://localhost:3013)
|
|
184
184
|
--api-key <key> Swarm API key (default: API_KEY or 123123)
|
|
185
|
-
--slot <n> Credential pool slot (0-
|
|
185
|
+
--slot <n> Credential pool slot (0-100, default: next free slot)
|
|
186
186
|
-h, --help Show this help
|
|
187
187
|
|
|
188
188
|
Without flags, the command prompts interactively for the target API URL and
|
|
@@ -198,7 +198,7 @@ Deployed Codex workers automatically restore these credentials at boot.
|
|
|
198
198
|
`);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
const MAX_SLOT =
|
|
201
|
+
const MAX_SLOT = 100;
|
|
202
202
|
|
|
203
203
|
export async function runCodexLogin(args: string[], deps: RunCodexLoginDeps = {}): Promise<void> {
|
|
204
204
|
const resolveConfig = deps.resolveConfig ?? resolveCodexLoginConfig;
|
package/src/http/mcp-bridge.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { createServer } from "@/server";
|
|
5
|
-
import {
|
|
5
|
+
import { isMcpToolAllowedForScripts } from "../scripts-runtime/sdk-allowlist";
|
|
6
6
|
import { route } from "./route-def";
|
|
7
7
|
import { json, jsonError } from "./utils";
|
|
8
8
|
|
|
@@ -55,7 +55,7 @@ export async function handleMcpBridge(
|
|
|
55
55
|
|
|
56
56
|
const { tool: toolName, args } = parsed.body;
|
|
57
57
|
|
|
58
|
-
if (!
|
|
58
|
+
if (!isMcpToolAllowedForScripts(toolName)) {
|
|
59
59
|
jsonError(res, `Tool '${toolName}' is not in the SDK allowlist`, 403);
|
|
60
60
|
return true;
|
|
61
61
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mcpToolNameForSdkMethod } from "../scripts-runtime/sdk-allowlist";
|
|
1
2
|
import { stdlib } from "../scripts-runtime/stdlib";
|
|
2
3
|
|
|
3
4
|
type StepStatusResponse =
|
|
@@ -161,7 +162,7 @@ export function buildWorkflowCtx(input: {
|
|
|
161
162
|
return (args?: unknown) =>
|
|
162
163
|
fetchJson("/api/mcp-bridge", {
|
|
163
164
|
method: "POST",
|
|
164
|
-
body: JSON.stringify({ tool: prop
|
|
165
|
+
body: JSON.stringify({ tool: mcpToolNameForSdkMethod(prop), args: args ?? {} }),
|
|
165
166
|
});
|
|
166
167
|
},
|
|
167
168
|
});
|
|
@@ -145,10 +145,18 @@ export const SDK_ALLOWLIST = Object.keys(SDK_TOOL_NAME_MAP) as Array<
|
|
|
145
145
|
keyof typeof SDK_TOOL_NAME_MAP
|
|
146
146
|
>;
|
|
147
147
|
|
|
148
|
+
/** Set of MCP tool names (values of SDK_TOOL_NAME_MAP) that scripts may call via the bridge. */
|
|
149
|
+
const MCP_TOOL_NAMES: ReadonlySet<string> = new Set<string>(Object.values(SDK_TOOL_NAME_MAP));
|
|
150
|
+
|
|
148
151
|
export function isSdkToolAllowed(name: string): boolean {
|
|
149
152
|
return (SDK_ALLOWLIST as readonly string[]).includes(name);
|
|
150
153
|
}
|
|
151
154
|
|
|
155
|
+
/** True if `mcpToolName` (e.g. "trigger-workflow") corresponds to an allowlisted SDK method. */
|
|
156
|
+
export function isMcpToolAllowedForScripts(mcpToolName: string): boolean {
|
|
157
|
+
return MCP_TOOL_NAMES.has(mcpToolName);
|
|
158
|
+
}
|
|
159
|
+
|
|
152
160
|
export function mcpToolNameForSdkMethod(name: string): string {
|
|
153
161
|
return SDK_TOOL_NAME_MAP[name as keyof typeof SDK_TOOL_NAME_MAP] ?? name;
|
|
154
162
|
}
|
|
@@ -243,7 +243,7 @@ declare module "swarm-sdk" {
|
|
|
243
243
|
workflow_patch(args: Record<string, unknown>): Promise<unknown>;
|
|
244
244
|
workflow_patchNode(args: Record<string, unknown>): Promise<unknown>;
|
|
245
245
|
workflow_delete(args: { id: string }): Promise<unknown>;
|
|
246
|
-
workflow_trigger(args: { id: string;
|
|
246
|
+
workflow_trigger(args: { id: string; triggerData?: Record<string, unknown> }): Promise<unknown>;
|
|
247
247
|
workflow_retryRun(args: { id: string }): Promise<unknown>;
|
|
248
248
|
workflow_cancelRun(args: { id: string }): Promise<unknown>;
|
|
249
249
|
|
|
@@ -225,7 +225,7 @@ declare module "swarm-sdk" {
|
|
|
225
225
|
workflow_patch(args: Record<string, unknown>): Promise<unknown>;
|
|
226
226
|
workflow_patchNode(args: Record<string, unknown>): Promise<unknown>;
|
|
227
227
|
workflow_delete(args: { id: string }): Promise<unknown>;
|
|
228
|
-
workflow_trigger(args: { id: string;
|
|
228
|
+
workflow_trigger(args: { id: string; triggerData?: Record<string, unknown> }): Promise<unknown>;
|
|
229
229
|
workflow_retryRun(args: { id: string }): Promise<unknown>;
|
|
230
230
|
workflow_cancelRun(args: { id: string }): Promise<unknown>;
|
|
231
231
|
|
|
@@ -246,7 +246,7 @@ describe("runCodexLogin", () => {
|
|
|
246
246
|
resolveConfig: async () => ({
|
|
247
247
|
apiUrl: "http://localhost:3013",
|
|
248
248
|
apiKey: "test-key",
|
|
249
|
-
slot:
|
|
249
|
+
slot: 101,
|
|
250
250
|
}),
|
|
251
251
|
login: mock(async () => {
|
|
252
252
|
throw new Error("should not start oauth");
|
|
@@ -259,18 +259,46 @@ describe("runCodexLogin", () => {
|
|
|
259
259
|
});
|
|
260
260
|
|
|
261
261
|
expect(error).toHaveBeenCalledWith(
|
|
262
|
-
expect.stringContaining("--slot must be an integer between 0 and
|
|
262
|
+
expect.stringContaining("--slot must be an integer between 0 and 100"),
|
|
263
263
|
);
|
|
264
264
|
expect(exit).toHaveBeenCalledWith(1);
|
|
265
265
|
expect(store).not.toHaveBeenCalled();
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
+
it("accepts slot 10 (previously rejected by the old 0-9 cap)", async () => {
|
|
269
|
+
let storedSlot: number | undefined;
|
|
270
|
+
const store = mock(async (_apiUrl: string, _apiKey: string, _creds: unknown, slot?: number) => {
|
|
271
|
+
storedSlot = slot;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await runCodexLogin([], {
|
|
275
|
+
resolveConfig: async () => ({
|
|
276
|
+
apiUrl: "http://localhost:3013",
|
|
277
|
+
apiKey: "test-key",
|
|
278
|
+
slot: 10,
|
|
279
|
+
}),
|
|
280
|
+
login: mock(async () => ({
|
|
281
|
+
access: "at_test",
|
|
282
|
+
refresh: "rt_test",
|
|
283
|
+
expires: Date.now() + 3600000,
|
|
284
|
+
accountId: "acc-test",
|
|
285
|
+
})),
|
|
286
|
+
store,
|
|
287
|
+
loadAllSlots: mock(async () => []),
|
|
288
|
+
log: () => {},
|
|
289
|
+
error: () => {},
|
|
290
|
+
exit: () => {},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(storedSlot).toBe(10);
|
|
294
|
+
});
|
|
295
|
+
|
|
268
296
|
it("errors when all slots are occupied", async () => {
|
|
269
297
|
const error = mock(() => {});
|
|
270
298
|
const exit = mock(() => {});
|
|
271
299
|
|
|
272
|
-
// All
|
|
273
|
-
const allSlots = Array.from({ length:
|
|
300
|
+
// All 101 slots occupied (0-100)
|
|
301
|
+
const allSlots = Array.from({ length: 101 }, (_, i) => ({
|
|
274
302
|
slot: i,
|
|
275
303
|
creds: { access: "", refresh: "", expires: 0, accountId: "" },
|
|
276
304
|
}));
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
3
5
|
import { closeDb, initDb } from "../be/db";
|
|
4
|
-
import {
|
|
6
|
+
import { handleMcpBridge } from "../http/mcp-bridge";
|
|
7
|
+
import {
|
|
8
|
+
isMcpToolAllowedForScripts,
|
|
9
|
+
mcpToolNameForSdkMethod,
|
|
10
|
+
SDK_ALLOWLIST,
|
|
11
|
+
} from "../scripts-runtime/sdk-allowlist";
|
|
5
12
|
import type { SwarmConfig } from "../scripts-runtime/swarm-config";
|
|
6
13
|
import { createSwarmSdk } from "../scripts-runtime/swarm-sdk";
|
|
7
14
|
import { createServer } from "../server";
|
|
@@ -56,4 +63,119 @@ describe("script SDK allowlist", () => {
|
|
|
56
63
|
expect(types).not.toContain("join_swarm(");
|
|
57
64
|
expect(types).not.toContain("start_worker(");
|
|
58
65
|
});
|
|
66
|
+
|
|
67
|
+
test("isMcpToolAllowedForScripts accepts every MCP name in the allowlist", () => {
|
|
68
|
+
for (const sdkName of SDK_ALLOWLIST) {
|
|
69
|
+
const mcpName = mcpToolNameForSdkMethod(sdkName);
|
|
70
|
+
expect(isMcpToolAllowedForScripts(mcpName)).toBe(true);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("isMcpToolAllowedForScripts rejects non-mapped MCP names", () => {
|
|
75
|
+
// SDK method names (underscores) are not MCP names — must be rejected
|
|
76
|
+
expect(isMcpToolAllowedForScripts("workflow_trigger")).toBe(false);
|
|
77
|
+
expect(isMcpToolAllowedForScripts("slack_post")).toBe(false);
|
|
78
|
+
// Completely unknown tool names
|
|
79
|
+
expect(isMcpToolAllowedForScripts("tool-does-not-exist")).toBe(false);
|
|
80
|
+
expect(isMcpToolAllowedForScripts("start-worker")).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("bundled swarm-sdk.d.ts uses triggerData (not input) for workflow_trigger", async () => {
|
|
84
|
+
const types = await Bun.file("src/scripts-runtime/types/swarm-sdk.d.ts").text();
|
|
85
|
+
expect(types).toContain("workflow_trigger(args: { id: string; triggerData?");
|
|
86
|
+
expect(types).not.toContain("workflow_trigger(args: { id: string; input?");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("mcp-bridge allowlist gate", () => {
|
|
91
|
+
const TEST_DB_PATH = "./test-sdk-allowlist-bridge.sqlite";
|
|
92
|
+
const API_KEY = "test-mcp-bridge-key-1234567890";
|
|
93
|
+
let prevApiKey: string | undefined;
|
|
94
|
+
|
|
95
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
96
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
97
|
+
try {
|
|
98
|
+
await unlink(path + suffix);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
beforeAll(async () => {
|
|
106
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
107
|
+
initDb(TEST_DB_PATH);
|
|
108
|
+
prevApiKey = process.env.AGENT_SWARM_API_KEY;
|
|
109
|
+
process.env.AGENT_SWARM_API_KEY = API_KEY;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterAll(async () => {
|
|
113
|
+
closeDb();
|
|
114
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
115
|
+
if (prevApiKey === undefined) {
|
|
116
|
+
delete process.env.AGENT_SWARM_API_KEY;
|
|
117
|
+
} else {
|
|
118
|
+
process.env.AGENT_SWARM_API_KEY = prevApiKey;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
async function postBridge(
|
|
123
|
+
body: Record<string, unknown>,
|
|
124
|
+
): Promise<{ status: number; body: unknown }> {
|
|
125
|
+
const raw = JSON.stringify(body);
|
|
126
|
+
const req = Readable.from([Buffer.from(raw)]) as IncomingMessage;
|
|
127
|
+
req.method = "POST";
|
|
128
|
+
req.url = "/api/mcp-bridge";
|
|
129
|
+
req.headers = {
|
|
130
|
+
authorization: `Bearer ${API_KEY}`,
|
|
131
|
+
"content-type": "application/json",
|
|
132
|
+
"x-agent-id": "test-agent-bridge",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let status = 200;
|
|
136
|
+
let text = "";
|
|
137
|
+
const res = {
|
|
138
|
+
headersSent: false,
|
|
139
|
+
writableEnded: false,
|
|
140
|
+
setHeader() {},
|
|
141
|
+
writeHead(code: number) {
|
|
142
|
+
status = code;
|
|
143
|
+
this.headersSent = true;
|
|
144
|
+
return this;
|
|
145
|
+
},
|
|
146
|
+
end(chunk?: unknown) {
|
|
147
|
+
if (chunk !== undefined) text += String(chunk);
|
|
148
|
+
this.writableEnded = true;
|
|
149
|
+
return this;
|
|
150
|
+
},
|
|
151
|
+
} as unknown as ServerResponse;
|
|
152
|
+
|
|
153
|
+
await handleMcpBridge(
|
|
154
|
+
req,
|
|
155
|
+
res,
|
|
156
|
+
["api", "mcp-bridge"],
|
|
157
|
+
new URLSearchParams(),
|
|
158
|
+
"test-agent-bridge",
|
|
159
|
+
);
|
|
160
|
+
return { status, body: text ? JSON.parse(text) : {} };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
test("trigger-workflow is NOT rejected with 403 (reaches tool handler)", async () => {
|
|
164
|
+
const result = await postBridge({
|
|
165
|
+
tool: "trigger-workflow",
|
|
166
|
+
args: { id: "00000000-0000-0000-0000-000000000001" },
|
|
167
|
+
});
|
|
168
|
+
// Must not be an allowlist 403; may be 404/500 (non-existent workflow) — that's fine.
|
|
169
|
+
expect(result.status).not.toBe(403);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("genuinely non-mapped MCP names still return 403", async () => {
|
|
173
|
+
// SDK method names (underscores) are not valid MCP names — must 403
|
|
174
|
+
const sdkNameResult = await postBridge({ tool: "workflow_trigger", args: { id: "x" } });
|
|
175
|
+
expect(sdkNameResult.status).toBe(403);
|
|
176
|
+
|
|
177
|
+
// Completely unknown tool names
|
|
178
|
+
const unknownResult = await postBridge({ tool: "start-worker", args: {} });
|
|
179
|
+
expect(unknownResult.status).toBe(403);
|
|
180
|
+
});
|
|
59
181
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildWorkflowCtx } from "../script-workflows/workflow-ctx";
|
|
3
|
+
|
|
4
|
+
describe("workflow-ctx: ctx.swarm proxy tool name resolution", () => {
|
|
5
|
+
test("non-mechanical SDK→MCP mappings are routed correctly", async () => {
|
|
6
|
+
const captured: string[] = [];
|
|
7
|
+
const origFetch = globalThis.fetch;
|
|
8
|
+
|
|
9
|
+
globalThis.fetch = async (url: unknown, init?: RequestInit) => {
|
|
10
|
+
if (typeof url === "string" && url.includes("/api/mcp-bridge")) {
|
|
11
|
+
const body = JSON.parse((init?.body as string) ?? "{}");
|
|
12
|
+
captured.push(body.tool);
|
|
13
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
14
|
+
}
|
|
15
|
+
return origFetch(url as URL, init);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const ctx = buildWorkflowCtx({
|
|
20
|
+
runId: "test-run",
|
|
21
|
+
agentId: "test-agent",
|
|
22
|
+
apiKey: "test-key",
|
|
23
|
+
baseUrl: "http://localhost:9999",
|
|
24
|
+
args: {},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Non-mechanical: SDK method name ≠ kebab-cased MCP name
|
|
28
|
+
await ctx.swarm.workflow_trigger({ id: "wf-1" }); // → "trigger-workflow" (not "workflow-trigger")
|
|
29
|
+
await ctx.swarm.page_create({ title: "T" }); // → "create_page" (not "page-create")
|
|
30
|
+
await ctx.swarm.memory_rate({ id: "x" }); // → "memory_rate" (not "memory-rate")
|
|
31
|
+
|
|
32
|
+
// Mechanical: verify mechanical mappings still work
|
|
33
|
+
await ctx.swarm.memory_search({ query: "q" }); // → "memory-search"
|
|
34
|
+
} finally {
|
|
35
|
+
globalThis.fetch = origFetch;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
expect(captured[0]).toBe("trigger-workflow");
|
|
39
|
+
expect(captured[1]).toBe("create_page");
|
|
40
|
+
expect(captured[2]).toBe("memory_rate");
|
|
41
|
+
expect(captured[3]).toBe("memory-search");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -255,6 +255,231 @@ describe("SwarmScriptExecutor", () => {
|
|
|
255
255
|
expect(scriptStep?.output).toMatchObject({ result: { seen: "mapped-value" } });
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
+
test("exact object token outside swarm-script args is still stringified", async () => {
|
|
259
|
+
const wf = makeWorkflow({
|
|
260
|
+
nodes: [
|
|
261
|
+
{
|
|
262
|
+
id: "echo",
|
|
263
|
+
type: "echo",
|
|
264
|
+
config: { value: "{{trigger.payload}}" },
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const runId = await startWorkflowExecution(wf, { payload: { a: 1 } }, registry);
|
|
270
|
+
const run = getWorkflowRun(runId);
|
|
271
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
272
|
+
const echoStep = steps.find((step) => step.nodeId === "echo");
|
|
273
|
+
|
|
274
|
+
expect(run?.status).toBe("completed");
|
|
275
|
+
expect(echoStep?.status).toBe("completed");
|
|
276
|
+
expect(echoStep?.output).toEqual({ value: '{"a":1}' });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("{{path}} args: object arg is injected as raw object, not JSON string", async () => {
|
|
280
|
+
await saveScript(
|
|
281
|
+
"echo-obj",
|
|
282
|
+
`export default async (args: { data: Record<string, unknown> }) => ({ isObject: typeof args.data === "object" && !Array.isArray(args.data), keys: Object.keys(args.data ?? {}) });`,
|
|
283
|
+
);
|
|
284
|
+
const wf = makeWorkflow({
|
|
285
|
+
nodes: [
|
|
286
|
+
{
|
|
287
|
+
id: "script",
|
|
288
|
+
type: "swarm-script",
|
|
289
|
+
config: {
|
|
290
|
+
scriptName: "echo-obj",
|
|
291
|
+
args: { data: "{{trigger.payload}}" },
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const runId = await startWorkflowExecution(wf, { payload: { a: 1, b: 2 } }, registry);
|
|
298
|
+
const run = getWorkflowRun(runId);
|
|
299
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
300
|
+
const scriptStep = steps.find((step) => step.nodeId === "script");
|
|
301
|
+
|
|
302
|
+
expect(run?.status).toBe("completed");
|
|
303
|
+
expect(scriptStep?.status).toBe("completed");
|
|
304
|
+
expect(scriptStep?.output).toMatchObject({ result: { isObject: true, keys: ["a", "b"] } });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("{{path}} args: array arg is injected as raw array, not JSON string", async () => {
|
|
308
|
+
await saveScript(
|
|
309
|
+
"echo-arr",
|
|
310
|
+
`export default async (args: { items: string[] }) => ({ isArray: Array.isArray(args.items), length: args.items.length });`,
|
|
311
|
+
);
|
|
312
|
+
const wf = makeWorkflow({
|
|
313
|
+
nodes: [
|
|
314
|
+
{
|
|
315
|
+
id: "script",
|
|
316
|
+
type: "swarm-script",
|
|
317
|
+
config: {
|
|
318
|
+
scriptName: "echo-arr",
|
|
319
|
+
args: { items: "{{trigger.list}}" },
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const runId = await startWorkflowExecution(wf, { list: ["x", "y", "z"] }, registry);
|
|
326
|
+
const run = getWorkflowRun(runId);
|
|
327
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
328
|
+
const scriptStep = steps.find((step) => step.nodeId === "script");
|
|
329
|
+
|
|
330
|
+
expect(run?.status).toBe("completed");
|
|
331
|
+
expect(scriptStep?.status).toBe("completed");
|
|
332
|
+
expect(scriptStep?.output).toMatchObject({ result: { isArray: true, length: 3 } });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("{{path}} args: empty array is injected as raw empty array with length 0, not '[]' string", async () => {
|
|
336
|
+
await saveScript(
|
|
337
|
+
"echo-empty-arr",
|
|
338
|
+
`export default async (args: { items: string[] }) => ({ isArray: Array.isArray(args.items), length: args.items.length });`,
|
|
339
|
+
);
|
|
340
|
+
const wf = makeWorkflow({
|
|
341
|
+
nodes: [
|
|
342
|
+
{
|
|
343
|
+
id: "script",
|
|
344
|
+
type: "swarm-script",
|
|
345
|
+
config: {
|
|
346
|
+
scriptName: "echo-empty-arr",
|
|
347
|
+
args: { items: "{{trigger.empty}}" },
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const runId = await startWorkflowExecution(wf, { empty: [] }, registry);
|
|
354
|
+
const run = getWorkflowRun(runId);
|
|
355
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
356
|
+
const scriptStep = steps.find((step) => step.nodeId === "script");
|
|
357
|
+
|
|
358
|
+
expect(run?.status).toBe("completed");
|
|
359
|
+
expect(scriptStep?.status).toBe("completed");
|
|
360
|
+
expect(scriptStep?.output).toMatchObject({ result: { isArray: true, length: 0 } });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("{{path}} args: string scalar arg is injected as the string value", async () => {
|
|
364
|
+
await saveScript(
|
|
365
|
+
"echo-str",
|
|
366
|
+
`export default async (args: { name: string }) => ({ isString: typeof args.name === "string", value: args.name });`,
|
|
367
|
+
);
|
|
368
|
+
const wf = makeWorkflow({
|
|
369
|
+
nodes: [
|
|
370
|
+
{
|
|
371
|
+
id: "script",
|
|
372
|
+
type: "swarm-script",
|
|
373
|
+
config: {
|
|
374
|
+
scriptName: "echo-str",
|
|
375
|
+
args: { name: "{{trigger.ruleName}}" },
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const runId = await startWorkflowExecution(
|
|
382
|
+
wf,
|
|
383
|
+
{ ruleName: "local-rules/cognitive-complexity" },
|
|
384
|
+
registry,
|
|
385
|
+
);
|
|
386
|
+
const run = getWorkflowRun(runId);
|
|
387
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
388
|
+
const scriptStep = steps.find((step) => step.nodeId === "script");
|
|
389
|
+
|
|
390
|
+
expect(run?.status).toBe("completed");
|
|
391
|
+
expect(scriptStep?.status).toBe("completed");
|
|
392
|
+
expect(scriptStep?.output).toMatchObject({
|
|
393
|
+
result: { isString: true, value: "local-rules/cognitive-complexity" },
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("{{path}} args: number scalar arg is injected as a number, not a string", async () => {
|
|
398
|
+
await saveScript(
|
|
399
|
+
"echo-num",
|
|
400
|
+
`export default async (args: { count: number }) => ({ isNumber: typeof args.count === "number", value: args.count });`,
|
|
401
|
+
);
|
|
402
|
+
const wf = makeWorkflow({
|
|
403
|
+
nodes: [
|
|
404
|
+
{
|
|
405
|
+
id: "script",
|
|
406
|
+
type: "swarm-script",
|
|
407
|
+
config: {
|
|
408
|
+
scriptName: "echo-num",
|
|
409
|
+
args: { count: "{{trigger.maxFiles}}" },
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const runId = await startWorkflowExecution(wf, { maxFiles: 3 }, registry);
|
|
416
|
+
const run = getWorkflowRun(runId);
|
|
417
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
418
|
+
const scriptStep = steps.find((step) => step.nodeId === "script");
|
|
419
|
+
|
|
420
|
+
expect(run?.status).toBe("completed");
|
|
421
|
+
expect(scriptStep?.status).toBe("completed");
|
|
422
|
+
expect(scriptStep?.output).toMatchObject({ result: { isNumber: true, value: 3 } });
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("{{path}} args: boolean scalar arg is injected as a boolean, not a string", async () => {
|
|
426
|
+
await saveScript(
|
|
427
|
+
"echo-bool",
|
|
428
|
+
`export default async (args: { enabled: boolean }) => ({ isBoolean: typeof args.enabled === "boolean", value: args.enabled });`,
|
|
429
|
+
);
|
|
430
|
+
const wf = makeWorkflow({
|
|
431
|
+
nodes: [
|
|
432
|
+
{
|
|
433
|
+
id: "script",
|
|
434
|
+
type: "swarm-script",
|
|
435
|
+
config: {
|
|
436
|
+
scriptName: "echo-bool",
|
|
437
|
+
args: { enabled: "{{trigger.enabled}}" },
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const runId = await startWorkflowExecution(wf, { enabled: false }, registry);
|
|
444
|
+
const run = getWorkflowRun(runId);
|
|
445
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
446
|
+
const scriptStep = steps.find((step) => step.nodeId === "script");
|
|
447
|
+
|
|
448
|
+
expect(run?.status).toBe("completed");
|
|
449
|
+
expect(scriptStep?.status).toBe("completed");
|
|
450
|
+
expect(scriptStep?.output).toMatchObject({ result: { isBoolean: true, value: false } });
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("{{path}} args: mixed string template still produces a string via interpolation", async () => {
|
|
454
|
+
await saveScript(
|
|
455
|
+
"echo-mixed",
|
|
456
|
+
`export default async (args: { label: string }) => ({ isString: typeof args.label === "string", value: args.label });`,
|
|
457
|
+
);
|
|
458
|
+
const wf = makeWorkflow({
|
|
459
|
+
nodes: [
|
|
460
|
+
{
|
|
461
|
+
id: "script",
|
|
462
|
+
type: "swarm-script",
|
|
463
|
+
config: {
|
|
464
|
+
scriptName: "echo-mixed",
|
|
465
|
+
args: { label: "rule-{{trigger.ruleName}}" },
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
],
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const runId = await startWorkflowExecution(wf, { ruleName: "no-explicit-any" }, registry);
|
|
472
|
+
const run = getWorkflowRun(runId);
|
|
473
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
474
|
+
const scriptStep = steps.find((step) => step.nodeId === "script");
|
|
475
|
+
|
|
476
|
+
expect(run?.status).toBe("completed");
|
|
477
|
+
expect(scriptStep?.status).toBe("completed");
|
|
478
|
+
expect(scriptStep?.output).toMatchObject({
|
|
479
|
+
result: { isString: true, value: "rule-no-explicit-any" },
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
258
483
|
test("fsMode workspace-rw is rejected at config validation with a clear error message", async () => {
|
|
259
484
|
await saveScript("noop", `export default async () => ({ ok: true });`);
|
|
260
485
|
const executor = new SwarmScriptExecutor(deps);
|
|
@@ -205,6 +205,23 @@ describe("deepInterpolate", () => {
|
|
|
205
205
|
expect(unresolved).toEqual([]);
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
+
test("exact object token is stringified by default", () => {
|
|
209
|
+
const { value, unresolved } = deepInterpolate("{{body}}", { body: { message: "hello" } });
|
|
210
|
+
expect(value).toBe('{"message":"hello"}');
|
|
211
|
+
expect(unresolved).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("exact object token preserves raw value when requested", () => {
|
|
215
|
+
const body = { message: "hello" };
|
|
216
|
+
const { value, unresolved } = deepInterpolate(
|
|
217
|
+
"{{body}}",
|
|
218
|
+
{ body },
|
|
219
|
+
{ preserveRawTokens: true },
|
|
220
|
+
);
|
|
221
|
+
expect(value).toBe(body);
|
|
222
|
+
expect(unresolved).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
|
|
208
225
|
test("mixed array (string + number + boolean)", () => {
|
|
209
226
|
const { value, unresolved } = deepInterpolate(["{{name}}", 42, true, null], {
|
|
210
227
|
name: "Test",
|
package/src/workflows/engine.ts
CHANGED
|
@@ -494,7 +494,7 @@ async function executeStep(
|
|
|
494
494
|
}
|
|
495
495
|
|
|
496
496
|
// 4. Deep-interpolate config using local context (not global ctx)
|
|
497
|
-
const { value: interpolatedValue, unresolved } =
|
|
497
|
+
const { value: interpolatedValue, unresolved } = interpolateNodeConfig(node, interpolationCtx);
|
|
498
498
|
const interpolatedConfig = interpolatedValue as Record<string, unknown>;
|
|
499
499
|
const executionCtx: Record<string, unknown> = { ...ctx, ...interpolationCtx };
|
|
500
500
|
|
|
@@ -709,6 +709,27 @@ export function findReadyNodes(
|
|
|
709
709
|
});
|
|
710
710
|
}
|
|
711
711
|
|
|
712
|
+
export function interpolateNodeConfig(
|
|
713
|
+
node: Pick<WorkflowNode, "type" | "config">,
|
|
714
|
+
interpolationCtx: Record<string, unknown>,
|
|
715
|
+
): { value: unknown; unresolved: string[] } {
|
|
716
|
+
if (node.type !== "swarm-script" || !Object.hasOwn(node.config, "args")) {
|
|
717
|
+
return deepInterpolate(node.config, interpolationCtx);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const { args, ...configWithoutArgs } = node.config;
|
|
721
|
+
const configResult = deepInterpolate(configWithoutArgs, interpolationCtx);
|
|
722
|
+
const argsResult = deepInterpolate(args, interpolationCtx, { preserveRawTokens: true });
|
|
723
|
+
|
|
724
|
+
return {
|
|
725
|
+
value: {
|
|
726
|
+
...(configResult.value as Record<string, unknown>),
|
|
727
|
+
args: argsResult.value,
|
|
728
|
+
},
|
|
729
|
+
unresolved: [...configResult.unresolved, ...argsResult.unresolved],
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
712
733
|
// ─── Helpers ───────────────────────────────────────────────
|
|
713
734
|
|
|
714
735
|
function timeoutPromise(ms: number): Promise<never> {
|
|
@@ -8,9 +8,8 @@ import {
|
|
|
8
8
|
import type { RetryPolicy } from "../types";
|
|
9
9
|
import { checkpointStep, checkpointStepFailure } from "./checkpoint";
|
|
10
10
|
import { getSuccessors } from "./definition";
|
|
11
|
-
import { walkGraph } from "./engine";
|
|
11
|
+
import { interpolateNodeConfig, walkGraph } from "./engine";
|
|
12
12
|
import type { ExecutorRegistry } from "./executors/registry";
|
|
13
|
-
import { deepInterpolate } from "./template";
|
|
14
13
|
import { runStepValidation } from "./validation";
|
|
15
14
|
|
|
16
15
|
let pollerTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -62,7 +61,7 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
|
|
|
62
61
|
const ctx = (run.context ?? {}) as Record<string, unknown>;
|
|
63
62
|
|
|
64
63
|
// Deep-interpolate config
|
|
65
|
-
const { value: interpolatedValue } =
|
|
64
|
+
const { value: interpolatedValue } = interpolateNodeConfig(node, ctx);
|
|
66
65
|
const interpolatedConfig = interpolatedValue as Record<string, unknown>;
|
|
67
66
|
|
|
68
67
|
// Get executor and re-run
|
|
@@ -10,6 +10,10 @@ export interface InterpolateResult {
|
|
|
10
10
|
unresolved: string[];
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export interface DeepInterpolateOptions {
|
|
14
|
+
preserveRawTokens?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
13
17
|
export function interpolate(template: string, ctx: Record<string, unknown>): InterpolateResult {
|
|
14
18
|
const unresolved: string[] = [];
|
|
15
19
|
const result = template.replace(/\{\{([^}]+)\}\}/g, (_match, path: string) => {
|
|
@@ -43,18 +47,62 @@ function safeStringify(value: unknown): string {
|
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
/** Matches a string that is EXACTLY one {{path}} token with no surrounding text. */
|
|
51
|
+
const EXACT_TOKEN_RE = /^\{\{([^}]+)\}\}$/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a dot-separated path against a context object.
|
|
55
|
+
* Returns `{ found: true, value }` on success, `{ found: false }` when any
|
|
56
|
+
* segment is missing or the traversal hits a non-object.
|
|
57
|
+
*/
|
|
58
|
+
function resolvePath(
|
|
59
|
+
path: string,
|
|
60
|
+
ctx: Record<string, unknown>,
|
|
61
|
+
): { found: true; value: unknown } | { found: false } {
|
|
62
|
+
const keys = path.trim().split(".");
|
|
63
|
+
let value: unknown = ctx;
|
|
64
|
+
for (const key of keys) {
|
|
65
|
+
if (value == null || typeof value !== "object") return { found: false };
|
|
66
|
+
value = (value as Record<string, unknown>)[key];
|
|
67
|
+
}
|
|
68
|
+
if (value === undefined) return { found: false };
|
|
69
|
+
return { found: true, value };
|
|
70
|
+
}
|
|
71
|
+
|
|
46
72
|
/**
|
|
47
73
|
* Deep-interpolate an arbitrary value tree (objects, arrays, strings).
|
|
74
|
+
*
|
|
75
|
+
* When `preserveRawTokens` is true and a string value is **exactly** one
|
|
76
|
+
* `{{path}}` token with no surrounding text, the resolved value is returned
|
|
77
|
+
* as-is (preserving object / array / number / boolean types). This is the
|
|
78
|
+
* "raw injection" path used by `swarm-script` node `config.args`.
|
|
79
|
+
*
|
|
80
|
+
* When a string contains multiple tokens or surrounding text (e.g.
|
|
81
|
+
* `"prefix-{{x}}"`) the existing string-interpolation path is used so the
|
|
82
|
+
* result remains a string.
|
|
83
|
+
*
|
|
48
84
|
* Non-string leaves are passed through unchanged.
|
|
49
85
|
*/
|
|
50
86
|
export function deepInterpolate(
|
|
51
87
|
value: unknown,
|
|
52
88
|
ctx: Record<string, unknown>,
|
|
89
|
+
options: DeepInterpolateOptions = {},
|
|
53
90
|
): { value: unknown; unresolved: string[] } {
|
|
54
91
|
const allUnresolved: string[] = [];
|
|
55
92
|
|
|
56
93
|
function walk(v: unknown): unknown {
|
|
57
94
|
if (typeof v === "string") {
|
|
95
|
+
const exactMatch = options.preserveRawTokens ? EXACT_TOKEN_RE.exec(v) : null;
|
|
96
|
+
if (exactMatch?.[1]) {
|
|
97
|
+
const path = exactMatch[1].trim();
|
|
98
|
+
const resolved = resolvePath(path, ctx);
|
|
99
|
+
if (!resolved.found) {
|
|
100
|
+
allUnresolved.push(path);
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
return resolved.value;
|
|
104
|
+
}
|
|
105
|
+
// Multi-token or mixed string - fall back to string interpolation.
|
|
58
106
|
const { result, unresolved } = interpolate(v, ctx);
|
|
59
107
|
allUnresolved.push(...unresolved);
|
|
60
108
|
return result;
|