@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,232 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createWorkflow,
|
|
7
|
+
deleteWorkflow,
|
|
8
|
+
getWorkflowRunStepsByRunId,
|
|
9
|
+
initDb,
|
|
10
|
+
upsertSwarmConfig,
|
|
11
|
+
} from "../be/db";
|
|
12
|
+
import type { WorkflowDefinition } from "../types";
|
|
13
|
+
import { startWorkflowExecution } from "../workflows/engine";
|
|
14
|
+
import {
|
|
15
|
+
BaseExecutor,
|
|
16
|
+
type ExecutorDependencies,
|
|
17
|
+
type ExecutorResult,
|
|
18
|
+
} from "../workflows/executors/base";
|
|
19
|
+
import { ExecutorRegistry } from "../workflows/executors/registry";
|
|
20
|
+
import {
|
|
21
|
+
getSecretInputKeys,
|
|
22
|
+
REDACTED_SECRET_VALUE,
|
|
23
|
+
redactSecretsForStorage,
|
|
24
|
+
resolveInputs,
|
|
25
|
+
} from "../workflows/input";
|
|
26
|
+
|
|
27
|
+
const TEST_DB_PATH = "./test-workflow-input-redaction.sqlite";
|
|
28
|
+
|
|
29
|
+
// Captures the input it was invoked with so we can assert what the executor
|
|
30
|
+
// actually receives (real value, not redacted).
|
|
31
|
+
class CaptureExecutor extends BaseExecutor<
|
|
32
|
+
typeof CaptureExecutor.schema,
|
|
33
|
+
typeof CaptureExecutor.outSchema
|
|
34
|
+
> {
|
|
35
|
+
static readonly schema = z.object({ tokenSeen: z.string().optional() });
|
|
36
|
+
static readonly outSchema = z.object({ ok: z.boolean() });
|
|
37
|
+
|
|
38
|
+
readonly type = "capture";
|
|
39
|
+
readonly mode = "instant" as const;
|
|
40
|
+
readonly configSchema = CaptureExecutor.schema;
|
|
41
|
+
readonly outputSchema = CaptureExecutor.outSchema;
|
|
42
|
+
|
|
43
|
+
static lastTokenSeen: string | undefined = undefined;
|
|
44
|
+
|
|
45
|
+
protected async execute(
|
|
46
|
+
config: z.infer<typeof CaptureExecutor.schema>,
|
|
47
|
+
): Promise<ExecutorResult<z.infer<typeof CaptureExecutor.outSchema>>> {
|
|
48
|
+
CaptureExecutor.lastTokenSeen = config.tokenSeen;
|
|
49
|
+
return { status: "success", output: { ok: true } };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("getSecretInputKeys", () => {
|
|
54
|
+
test("flags secret.* references", () => {
|
|
55
|
+
const keys = getSecretInputKeys({ GITHUB_TOKEN: "secret.GITHUB_TOKEN" });
|
|
56
|
+
expect(keys.has("GITHUB_TOKEN")).toBe(true);
|
|
57
|
+
expect(keys.size).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("flags sensitive env-var references", () => {
|
|
61
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
|
|
62
|
+
const keys = getSecretInputKeys({ GH: "${GITHUB_TOKEN}" });
|
|
63
|
+
expect(keys.has("GH")).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("does not flag non-sensitive env-var references", () => {
|
|
67
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
|
|
68
|
+
const keys = getSecretInputKeys({ branch: "${GIT_BRANCH}", url: "${MCP_BASE_URL}" });
|
|
69
|
+
expect(keys.size).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("does not flag literal strings", () => {
|
|
73
|
+
const keys = getSecretInputKeys({ name: "literal", count: "42" });
|
|
74
|
+
expect(keys.size).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("handles undefined input", () => {
|
|
78
|
+
const keys = getSecretInputKeys(undefined);
|
|
79
|
+
expect(keys.size).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("flags all sensitive-suffix env names", () => {
|
|
83
|
+
const keys = getSecretInputKeys({
|
|
84
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
|
|
85
|
+
a: "${FOO_TOKEN}",
|
|
86
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
|
|
87
|
+
b: "${BAR_API_KEY}",
|
|
88
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
|
|
89
|
+
c: "${BAZ_SECRET}",
|
|
90
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
|
|
91
|
+
d: "${QUX_PASSWORD}",
|
|
92
|
+
});
|
|
93
|
+
expect(keys.size).toBe(4);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("redactSecretsForStorage", () => {
|
|
98
|
+
test("returns ctx unchanged when no secret keys", () => {
|
|
99
|
+
const ctx = { input: { foo: "bar" } };
|
|
100
|
+
const out = redactSecretsForStorage(ctx, new Set());
|
|
101
|
+
expect(out).toBe(ctx);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("redacts only declared secret keys in ctx.input", () => {
|
|
105
|
+
const ctx = {
|
|
106
|
+
trigger: { topic: "tuxedo" },
|
|
107
|
+
input: { GITHUB_TOKEN: "ghp_real_value_xxx", branch: "main" },
|
|
108
|
+
};
|
|
109
|
+
const out = redactSecretsForStorage(ctx, new Set(["GITHUB_TOKEN"]));
|
|
110
|
+
expect((out.input as Record<string, unknown>).GITHUB_TOKEN).toBe(REDACTED_SECRET_VALUE);
|
|
111
|
+
expect((out.input as Record<string, unknown>).branch).toBe("main");
|
|
112
|
+
// Trigger block untouched
|
|
113
|
+
expect(out.trigger).toEqual({ topic: "tuxedo" });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("does not mutate the original ctx (executor still sees real value)", () => {
|
|
117
|
+
const ctx = { input: { GITHUB_TOKEN: "ghp_real" } };
|
|
118
|
+
redactSecretsForStorage(ctx, new Set(["GITHUB_TOKEN"]));
|
|
119
|
+
expect((ctx.input as Record<string, unknown>).GITHUB_TOKEN).toBe("ghp_real");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("no-ops when ctx has no input block", () => {
|
|
123
|
+
const ctx = { trigger: {} };
|
|
124
|
+
const out = redactSecretsForStorage(ctx, new Set(["GITHUB_TOKEN"]));
|
|
125
|
+
expect(out).toBe(ctx);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("no-ops when secret key isn't actually present in ctx.input", () => {
|
|
129
|
+
const ctx = { input: { unrelated: "x" } };
|
|
130
|
+
const out = redactSecretsForStorage(ctx, new Set(["GITHUB_TOKEN"]));
|
|
131
|
+
expect(out).toBe(ctx);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("end-to-end — workflow step persistence redacts secrets", () => {
|
|
136
|
+
beforeAll(() => {
|
|
137
|
+
initDb(TEST_DB_PATH);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterAll(async () => {
|
|
141
|
+
closeDb();
|
|
142
|
+
try {
|
|
143
|
+
await unlink(TEST_DB_PATH);
|
|
144
|
+
} catch {
|
|
145
|
+
// ignore
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
let workflowId: string | null = null;
|
|
150
|
+
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
CaptureExecutor.lastTokenSeen = undefined;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
afterEach(() => {
|
|
156
|
+
if (workflowId) {
|
|
157
|
+
try {
|
|
158
|
+
deleteWorkflow(workflowId);
|
|
159
|
+
} catch {
|
|
160
|
+
// ignore
|
|
161
|
+
}
|
|
162
|
+
workflowId = null;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("ctx.input[secretKey] is redacted in workflow_run_steps but real value reaches the executor", async () => {
|
|
167
|
+
// Seed swarm config with a "secret" value
|
|
168
|
+
const SECRET_VALUE = "ghp_supersecret_token_value_abc123";
|
|
169
|
+
upsertSwarmConfig({
|
|
170
|
+
scope: "global",
|
|
171
|
+
key: "TEST_REDACTION_GITHUB_TOKEN",
|
|
172
|
+
value: SECRET_VALUE,
|
|
173
|
+
isSecret: true,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const def: WorkflowDefinition = {
|
|
177
|
+
nodes: [
|
|
178
|
+
{
|
|
179
|
+
id: "capture-node",
|
|
180
|
+
type: "capture",
|
|
181
|
+
config: { tokenSeen: "{{input.GITHUB_TOKEN}}" },
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
const workflow = createWorkflow({
|
|
186
|
+
name: `redaction-test-${Date.now()}`,
|
|
187
|
+
definition: def,
|
|
188
|
+
triggers: [],
|
|
189
|
+
input: {
|
|
190
|
+
GITHUB_TOKEN: "secret.TEST_REDACTION_GITHUB_TOKEN",
|
|
191
|
+
plain: "not-a-secret",
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
workflowId = workflow.id;
|
|
195
|
+
|
|
196
|
+
const registry = new ExecutorRegistry();
|
|
197
|
+
const deps: ExecutorDependencies = {
|
|
198
|
+
db: {} as typeof import("../be/db"),
|
|
199
|
+
eventBus: { emit: () => {}, on: () => {}, off: () => {} },
|
|
200
|
+
interpolate: (t: string) => t,
|
|
201
|
+
};
|
|
202
|
+
registry.register(new CaptureExecutor(deps));
|
|
203
|
+
|
|
204
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
205
|
+
expect(runId).toBeDefined();
|
|
206
|
+
|
|
207
|
+
// Executor must see the REAL secret value (interpolated from live ctx)
|
|
208
|
+
expect(CaptureExecutor.lastTokenSeen).toBe(SECRET_VALUE);
|
|
209
|
+
|
|
210
|
+
// Persisted step.input must have the secret redacted
|
|
211
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
212
|
+
expect(steps.length).toBe(1);
|
|
213
|
+
const persistedInput = steps[0]!.input as Record<string, unknown>;
|
|
214
|
+
const persistedInputBlock = persistedInput.input as Record<string, unknown>;
|
|
215
|
+
expect(persistedInputBlock.GITHUB_TOKEN).toBe(REDACTED_SECRET_VALUE);
|
|
216
|
+
// Non-secret key untouched
|
|
217
|
+
expect(persistedInputBlock.plain).toBe("not-a-secret");
|
|
218
|
+
// The real secret string must NOT appear anywhere in the persisted JSON
|
|
219
|
+
const serialized = JSON.stringify(steps[0]);
|
|
220
|
+
expect(serialized.includes(SECRET_VALUE)).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("resolveInputs (unchanged behavior)", () => {
|
|
225
|
+
test("still resolves env-var references", () => {
|
|
226
|
+
process.env.TEST_REDACT_VAR = "resolved";
|
|
227
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: testing env-var syntax
|
|
228
|
+
const out = resolveInputs({ x: "${TEST_REDACT_VAR}" });
|
|
229
|
+
expect(out.x).toBe("resolved");
|
|
230
|
+
delete process.env.TEST_REDACT_VAR;
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createAgent,
|
|
7
|
+
createWorkflow,
|
|
8
|
+
getDb,
|
|
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 {
|
|
19
|
+
BaseExecutor,
|
|
20
|
+
type ExecutorDependencies,
|
|
21
|
+
type ExecutorResult,
|
|
22
|
+
} from "../workflows/executors/base";
|
|
23
|
+
import { ExecutorRegistry } from "../workflows/executors/registry";
|
|
24
|
+
import { SwarmScriptExecutor } from "../workflows/executors/swarm-script";
|
|
25
|
+
import { interpolate } from "../workflows/template";
|
|
26
|
+
|
|
27
|
+
const TEST_DB_PATH = "./test-workflow-swarm-script.sqlite";
|
|
28
|
+
const API_KEY = "test-workflow-swarm-script-key-1234567890";
|
|
29
|
+
|
|
30
|
+
const noOpEmbeddingProvider = {
|
|
31
|
+
name: "test/noop-workflow-script-embedding",
|
|
32
|
+
dimensions: 1,
|
|
33
|
+
async embed() {
|
|
34
|
+
return null;
|
|
35
|
+
},
|
|
36
|
+
async embedBatch(texts: string[]) {
|
|
37
|
+
return texts.map(() => null);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const signatureJson = JSON.stringify({
|
|
42
|
+
args: { type: "object" },
|
|
43
|
+
result: { type: "object" },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
class EchoExecutor extends BaseExecutor<typeof EchoExecutor.schema, typeof EchoExecutor.outSchema> {
|
|
47
|
+
static readonly schema = z.object({ value: z.string() });
|
|
48
|
+
static readonly outSchema = z.object({ value: z.string() });
|
|
49
|
+
|
|
50
|
+
readonly type = "echo";
|
|
51
|
+
readonly mode = "instant" as const;
|
|
52
|
+
readonly configSchema = EchoExecutor.schema;
|
|
53
|
+
readonly outputSchema = EchoExecutor.outSchema;
|
|
54
|
+
|
|
55
|
+
protected async execute(
|
|
56
|
+
config: z.infer<typeof EchoExecutor.schema>,
|
|
57
|
+
): Promise<ExecutorResult<z.infer<typeof EchoExecutor.outSchema>>> {
|
|
58
|
+
return { status: "success", output: { value: config.value } };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let savedEnv: NodeJS.ProcessEnv;
|
|
63
|
+
let agentId: string;
|
|
64
|
+
let deps: ExecutorDependencies;
|
|
65
|
+
let registry: ExecutorRegistry;
|
|
66
|
+
|
|
67
|
+
async function removeDbFiles(): Promise<void> {
|
|
68
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
69
|
+
try {
|
|
70
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function makeWorkflow(def: WorkflowDefinition): Workflow {
|
|
78
|
+
const wf = createWorkflow({
|
|
79
|
+
name: `swarm-script-test-${crypto.randomUUID()}`,
|
|
80
|
+
definition: def,
|
|
81
|
+
createdByAgentId: agentId,
|
|
82
|
+
});
|
|
83
|
+
return wf;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function saveScript(name: string, source: string) {
|
|
87
|
+
return upsertScriptByName({
|
|
88
|
+
name,
|
|
89
|
+
scope: "agent",
|
|
90
|
+
scopeId: agentId,
|
|
91
|
+
source,
|
|
92
|
+
description: `${name} test script`,
|
|
93
|
+
intent: "workflow-swarm-script test fixture",
|
|
94
|
+
signatureJson,
|
|
95
|
+
agentId,
|
|
96
|
+
typeChecked: true,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
beforeAll(async () => {
|
|
101
|
+
savedEnv = { ...process.env };
|
|
102
|
+
await removeDbFiles();
|
|
103
|
+
initDb(TEST_DB_PATH);
|
|
104
|
+
process.env.AGENT_SWARM_API_KEY = API_KEY;
|
|
105
|
+
delete process.env.API_KEY;
|
|
106
|
+
setScriptEmbeddingProviderForTests(noOpEmbeddingProvider);
|
|
107
|
+
|
|
108
|
+
const agent = createAgent({ name: "workflow-script-agent", isLead: true, status: "idle" });
|
|
109
|
+
agentId = agent.id;
|
|
110
|
+
|
|
111
|
+
const eventBus = new InProcessEventBus();
|
|
112
|
+
const db = await import("../be/db");
|
|
113
|
+
deps = {
|
|
114
|
+
db,
|
|
115
|
+
eventBus,
|
|
116
|
+
interpolate: (template, ctx) => interpolate(template, ctx).result,
|
|
117
|
+
};
|
|
118
|
+
registry = new ExecutorRegistry();
|
|
119
|
+
registry.register(new EchoExecutor(deps));
|
|
120
|
+
registry.register(new SwarmScriptExecutor(deps));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterAll(async () => {
|
|
124
|
+
setScriptEmbeddingProviderForTests(null);
|
|
125
|
+
closeDb();
|
|
126
|
+
await removeDbFiles();
|
|
127
|
+
for (const key of Object.keys(process.env)) {
|
|
128
|
+
if (!(key in savedEnv)) delete process.env[key];
|
|
129
|
+
}
|
|
130
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
131
|
+
if (value === undefined) delete process.env[key];
|
|
132
|
+
else process.env[key] = value;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
getDb().run("DELETE FROM workflow_run_steps");
|
|
138
|
+
getDb().run("DELETE FROM workflow_runs");
|
|
139
|
+
getDb().run("DELETE FROM scripts");
|
|
140
|
+
getDb().run("DELETE FROM workflows");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("SwarmScriptExecutor", () => {
|
|
144
|
+
test("A workflow with one swarm-script node resolves by name + runs + returns result", async () => {
|
|
145
|
+
await saveScript(
|
|
146
|
+
"add-one",
|
|
147
|
+
`export default async (args: { value: number }) => ({ value: args.value + 1 });`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const executor = new SwarmScriptExecutor(deps);
|
|
151
|
+
const wf = makeWorkflow({ nodes: [] });
|
|
152
|
+
const result = await executor.run({
|
|
153
|
+
config: { scriptName: "add-one", args: { value: 6 } },
|
|
154
|
+
context: {},
|
|
155
|
+
meta: {
|
|
156
|
+
runId: crypto.randomUUID(),
|
|
157
|
+
stepId: crypto.randomUUID(),
|
|
158
|
+
nodeId: "script",
|
|
159
|
+
workflowId: wf.id,
|
|
160
|
+
dryRun: false,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(result.status).toBe("success");
|
|
165
|
+
expect(result.output?.result).toEqual({ value: 7 });
|
|
166
|
+
expect(result.output?.scriptName).toBe("add-one");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("pinHash correctly resolves to a historic script_versions row", async () => {
|
|
170
|
+
const first = await saveScript("versioned", `export default async () => ({ version: "old" });`);
|
|
171
|
+
await saveScript("versioned", `export default async () => ({ version: "new" });`);
|
|
172
|
+
|
|
173
|
+
const executor = new SwarmScriptExecutor(deps);
|
|
174
|
+
const wf = makeWorkflow({ nodes: [] });
|
|
175
|
+
const result = await executor.run({
|
|
176
|
+
config: { scriptName: "versioned", pinHash: first.script.contentHash },
|
|
177
|
+
context: {},
|
|
178
|
+
meta: {
|
|
179
|
+
runId: crypto.randomUUID(),
|
|
180
|
+
stepId: crypto.randomUUID(),
|
|
181
|
+
nodeId: "script",
|
|
182
|
+
workflowId: wf.id,
|
|
183
|
+
dryRun: false,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(result.status).toBe("success");
|
|
188
|
+
expect(result.output?.result).toEqual({ version: "old" });
|
|
189
|
+
expect(result.output?.contentHash).toBe(first.script.contentHash);
|
|
190
|
+
expect(result.output?.version).toBe(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("inputs mapping from a predecessor node correctly populates args", async () => {
|
|
194
|
+
await saveScript(
|
|
195
|
+
"from-input",
|
|
196
|
+
`export default async (args: { value: string }) => ({ seen: args.value });`,
|
|
197
|
+
);
|
|
198
|
+
const wf = makeWorkflow({
|
|
199
|
+
nodes: [
|
|
200
|
+
{ id: "source", type: "echo", config: { value: "mapped-value" }, next: "script" },
|
|
201
|
+
{
|
|
202
|
+
id: "script",
|
|
203
|
+
type: "swarm-script",
|
|
204
|
+
inputs: { sourceValue: "source.value" },
|
|
205
|
+
config: { scriptName: "from-input", args: { value: "{{sourceValue}}" } },
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const runId = await startWorkflowExecution(wf, {}, registry);
|
|
211
|
+
const run = getWorkflowRun(runId);
|
|
212
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
213
|
+
const scriptStep = steps.find((step) => step.nodeId === "script");
|
|
214
|
+
|
|
215
|
+
expect(run?.status).toBe("completed");
|
|
216
|
+
expect(scriptStep?.status).toBe("completed");
|
|
217
|
+
expect(scriptStep?.output).toMatchObject({ result: { seen: "mapped-value" } });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("fsMode workspace-rw is rejected at config validation with a clear error message", async () => {
|
|
221
|
+
await saveScript("noop", `export default async () => ({ ok: true });`);
|
|
222
|
+
const executor = new SwarmScriptExecutor(deps);
|
|
223
|
+
const wf = makeWorkflow({ nodes: [] });
|
|
224
|
+
const result = await executor.run({
|
|
225
|
+
config: { scriptName: "noop", fsMode: "workspace-rw" },
|
|
226
|
+
context: {},
|
|
227
|
+
meta: {
|
|
228
|
+
runId: crypto.randomUUID(),
|
|
229
|
+
stepId: crypto.randomUUID(),
|
|
230
|
+
nodeId: "script",
|
|
231
|
+
workflowId: wf.id,
|
|
232
|
+
dryRun: false,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result.status).toBe("failed");
|
|
237
|
+
expect(result.error).toContain("workspace-rw");
|
|
238
|
+
|
|
239
|
+
const success = await executor.run({
|
|
240
|
+
config: { scriptName: "noop", fsMode: "none" },
|
|
241
|
+
context: {},
|
|
242
|
+
meta: {
|
|
243
|
+
runId: crypto.randomUUID(),
|
|
244
|
+
stepId: crypto.randomUUID(),
|
|
245
|
+
nodeId: "script",
|
|
246
|
+
workflowId: wf.id,
|
|
247
|
+
dryRun: false,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
expect(success.status).toBe("success");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("Failure in the script surfaces as a workflow-node failure", async () => {
|
|
254
|
+
await saveScript("throws", `export default async () => { throw new Error("boom"); };`);
|
|
255
|
+
const executor = new SwarmScriptExecutor(deps);
|
|
256
|
+
const wf = makeWorkflow({ nodes: [] });
|
|
257
|
+
const result = await executor.run({
|
|
258
|
+
config: { scriptName: "throws" },
|
|
259
|
+
context: {},
|
|
260
|
+
meta: {
|
|
261
|
+
runId: crypto.randomUUID(),
|
|
262
|
+
stepId: crypto.randomUUID(),
|
|
263
|
+
nodeId: "script",
|
|
264
|
+
workflowId: wf.id,
|
|
265
|
+
dryRun: false,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(result.status).toBe("failed");
|
|
270
|
+
expect(result.error).toContain("boom");
|
|
271
|
+
expect(result.output?.exitCode).not.toBe(0);
|
|
272
|
+
});
|
|
273
|
+
});
|
package/src/tools/memory-rate.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { REFERENCES_SOURCE_MAX_LENGTH, sanitizeReferencesSource } from "@/be/memory/raters/types";
|
|
4
4
|
import { createToolRegistrar } from "@/tools/utils";
|
|
5
|
+
import { getApiKey } from "@/utils/api-key";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Plan: thoughts/taras/plans/2026-05-05-memory-rater-v1.5/step-5.md §1
|
|
@@ -92,7 +93,7 @@ export const registerMemoryRateTool = (server: McpServer) => {
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
const apiUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
|
|
95
|
-
const apiKey =
|
|
96
|
+
const apiKey = getApiKey();
|
|
96
97
|
|
|
97
98
|
const event = {
|
|
98
99
|
memoryId: id,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { getApiKey } from "@/utils/api-key";
|
|
4
|
+
import type { RequestInfo } from "./utils";
|
|
5
|
+
|
|
6
|
+
export const SCRIPT_TRANSPORT_ERROR =
|
|
7
|
+
"script_* tools require HTTP MCP transport — agent identity is not available over stdio in this build. Switch to MCP_BASE_URL=http://... or invoke the HTTP API directly.";
|
|
8
|
+
|
|
9
|
+
export const scriptNameSchema = z.string().min(1).max(200);
|
|
10
|
+
export const scriptScopeSchema = z.enum(["agent", "global"]);
|
|
11
|
+
export const scriptFsModeSchema = z.enum(["none", "workspace-rw"]);
|
|
12
|
+
|
|
13
|
+
export const scriptToolOutputSchema = z.object({
|
|
14
|
+
success: z.boolean(),
|
|
15
|
+
status: z.number(),
|
|
16
|
+
data: z.unknown().optional(),
|
|
17
|
+
error: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export type ScriptToolStructuredContent = z.infer<typeof scriptToolOutputSchema>;
|
|
21
|
+
|
|
22
|
+
function apiBaseUrl(): string {
|
|
23
|
+
return (process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`).replace(
|
|
24
|
+
/\/+$/,
|
|
25
|
+
"",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toolError(message: string, status = 400): CallToolResult {
|
|
30
|
+
return {
|
|
31
|
+
isError: true,
|
|
32
|
+
content: [{ type: "text", text: message }],
|
|
33
|
+
structuredContent: {
|
|
34
|
+
success: false,
|
|
35
|
+
status,
|
|
36
|
+
error: message,
|
|
37
|
+
} satisfies ScriptToolStructuredContent,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function proxyScriptsApi(args: {
|
|
42
|
+
method: "GET" | "POST" | "DELETE";
|
|
43
|
+
path: string;
|
|
44
|
+
body?: unknown;
|
|
45
|
+
requestInfo: RequestInfo;
|
|
46
|
+
successMessage: (data: unknown) => string;
|
|
47
|
+
}): Promise<CallToolResult> {
|
|
48
|
+
if (!args.requestInfo.agentId) return toolError(SCRIPT_TRANSPORT_ERROR);
|
|
49
|
+
|
|
50
|
+
const apiKey = getApiKey();
|
|
51
|
+
const res = await fetch(`${apiBaseUrl()}${args.path}`, {
|
|
52
|
+
method: args.method,
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${apiKey}`,
|
|
55
|
+
"X-Agent-ID": args.requestInfo.agentId,
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
},
|
|
58
|
+
body: args.body === undefined ? undefined : JSON.stringify(args.body),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const text = await res.text();
|
|
62
|
+
let data: unknown;
|
|
63
|
+
if (text) {
|
|
64
|
+
try {
|
|
65
|
+
data = JSON.parse(text);
|
|
66
|
+
} catch {
|
|
67
|
+
data = { error: text };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const error =
|
|
72
|
+
typeof data === "object" && data !== null && "error" in data
|
|
73
|
+
? String((data as { error: unknown }).error)
|
|
74
|
+
: undefined;
|
|
75
|
+
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
return toolError(error ?? `Scripts API request failed with ${res.status}`, res.status);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: args.successMessage(data) }],
|
|
82
|
+
structuredContent: {
|
|
83
|
+
success: true,
|
|
84
|
+
status: res.status,
|
|
85
|
+
data,
|
|
86
|
+
} satisfies ScriptToolStructuredContent,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { createToolRegistrar } from "@/tools/utils";
|
|
4
|
+
import {
|
|
5
|
+
proxyScriptsApi,
|
|
6
|
+
scriptNameSchema,
|
|
7
|
+
scriptScopeSchema,
|
|
8
|
+
scriptToolOutputSchema,
|
|
9
|
+
} from "./script-common";
|
|
10
|
+
|
|
11
|
+
export const SCRIPT_DELETE_DESCRIPTION =
|
|
12
|
+
"Remove a swarm-shared script from the catalog. Versions table preserves history.";
|
|
13
|
+
|
|
14
|
+
export const registerScriptDeleteTool = (server: McpServer) => {
|
|
15
|
+
createToolRegistrar(server)(
|
|
16
|
+
"script-delete",
|
|
17
|
+
{
|
|
18
|
+
title: "Script Delete",
|
|
19
|
+
description: SCRIPT_DELETE_DESCRIPTION,
|
|
20
|
+
annotations: { destructiveHint: true, openWorldHint: false },
|
|
21
|
+
inputSchema: z.object({
|
|
22
|
+
name: scriptNameSchema.describe("Script name to delete."),
|
|
23
|
+
scope: scriptScopeSchema.default("agent").describe("Script scope to delete from."),
|
|
24
|
+
}),
|
|
25
|
+
outputSchema: scriptToolOutputSchema,
|
|
26
|
+
},
|
|
27
|
+
async ({ name, scope }, requestInfo) =>
|
|
28
|
+
proxyScriptsApi({
|
|
29
|
+
method: "DELETE",
|
|
30
|
+
path: `/api/scripts/${encodeURIComponent(name)}?scope=${encodeURIComponent(scope)}`,
|
|
31
|
+
requestInfo,
|
|
32
|
+
successMessage: () => "Script delete completed.",
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { createToolRegistrar } from "@/tools/utils";
|
|
4
|
+
import {
|
|
5
|
+
proxyScriptsApi,
|
|
6
|
+
scriptNameSchema,
|
|
7
|
+
scriptScopeSchema,
|
|
8
|
+
scriptToolOutputSchema,
|
|
9
|
+
} from "./script-common";
|
|
10
|
+
|
|
11
|
+
export const SCRIPT_QUERY_TYPES_DESCRIPTION =
|
|
12
|
+
"Fetch the signature + the auto-generated `swarm-sdk.d.ts` (derived from the live MCP tool registry) + the `stdlib.d.ts` blobs — for IDE-style introspection before authoring or running a script. The same types are used by `script-upsert`'s typecheck pass, so they are authoritative.";
|
|
13
|
+
|
|
14
|
+
export const registerScriptQueryTypesTool = (server: McpServer) => {
|
|
15
|
+
createToolRegistrar(server)(
|
|
16
|
+
"script-query-types",
|
|
17
|
+
{
|
|
18
|
+
title: "Script Query Types",
|
|
19
|
+
description: SCRIPT_QUERY_TYPES_DESCRIPTION,
|
|
20
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
21
|
+
inputSchema: z.object({
|
|
22
|
+
name: scriptNameSchema.describe("Script name whose signature should be fetched."),
|
|
23
|
+
scope: scriptScopeSchema.optional().describe("Optional scope for script resolution."),
|
|
24
|
+
}),
|
|
25
|
+
outputSchema: scriptToolOutputSchema,
|
|
26
|
+
},
|
|
27
|
+
async ({ name, scope }, requestInfo) => {
|
|
28
|
+
const query = scope ? `?scope=${encodeURIComponent(scope)}` : "";
|
|
29
|
+
return proxyScriptsApi({
|
|
30
|
+
method: "GET",
|
|
31
|
+
path: `/api/scripts/${encodeURIComponent(name)}/types${query}`,
|
|
32
|
+
requestInfo,
|
|
33
|
+
successMessage: () => "Script type query completed.",
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
};
|