@desplega.ai/agent-swarm 1.74.0 → 1.74.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 +199 -1
- package/package.json +1 -1
- package/src/be/db.ts +278 -0
- package/src/be/migrations/049_wait_states.sql +30 -0
- package/src/be/migrations/050_wait_states_scope.sql +19 -0
- package/src/http/index.ts +2 -0
- package/src/http/trackers/jira.ts +84 -27
- package/src/http/trackers/linear.ts +67 -11
- package/src/http/utils.ts +15 -0
- package/src/http/workflow-events.ts +107 -0
- package/src/http/workflows.ts +55 -6
- package/src/jira/sync.ts +20 -7
- package/src/linear/gate.ts +122 -0
- package/src/linear/sync.ts +128 -0
- package/src/oauth/keepalive.ts +34 -13
- package/src/tests/ensure-token.test.ts +33 -0
- package/src/tests/linear-webhook.test.ts +383 -0
- package/src/tests/workflow-executors.test.ts +4 -2
- package/src/tests/workflow-mcp-trigger-schema.test.ts +617 -0
- package/src/tests/workflow-patch.test.ts +14 -14
- package/src/tests/workflow-wait-builtin-events.test.ts +279 -0
- package/src/tests/workflow-wait-event.test.ts +384 -0
- package/src/tests/workflow-wait-filter.test.ts +200 -0
- package/src/tests/workflow-wait-http.test.ts +177 -0
- package/src/tests/workflow-wait-recovery.test.ts +178 -0
- package/src/tests/workflow-wait-state-queries.test.ts +419 -0
- package/src/tests/workflow-wait-time.test.ts +255 -0
- package/src/tools/tracker/tracker-status.ts +7 -1
- package/src/tools/workflows/create-workflow.ts +16 -2
- package/src/tools/workflows/patch-workflow.ts +26 -6
- package/src/tools/workflows/trigger-workflow.ts +26 -1
- package/src/tools/workflows/update-workflow.ts +28 -2
- package/src/types.ts +48 -3
- package/src/workflows/definition.ts +2 -5
- package/src/workflows/executors/index.ts +1 -0
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/wait.ts +170 -0
- package/src/workflows/index.ts +18 -2
- package/src/workflows/json-schema-validator.ts +8 -1
- package/src/workflows/recovery.ts +55 -1
- package/src/workflows/resume.ts +272 -0
- package/src/workflows/wait-filter.ts +311 -0
- package/src/workflows/wait-poller.ts +63 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { unlink } from "node:fs/promises";
|
|
4
|
+
import {
|
|
5
|
+
createServer as createHttpServer,
|
|
6
|
+
type IncomingMessage,
|
|
7
|
+
type Server,
|
|
8
|
+
type ServerResponse,
|
|
9
|
+
} from "node:http";
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { closeDb, deleteWorkflow, getWorkflow, initDb } from "../be/db";
|
|
12
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
13
|
+
import { handleWorkflows } from "../http/workflows";
|
|
14
|
+
import { registerCreateWorkflowTool } from "../tools/workflows/create-workflow";
|
|
15
|
+
import { registerPatchWorkflowTool } from "../tools/workflows/patch-workflow";
|
|
16
|
+
import { registerTriggerWorkflowTool } from "../tools/workflows/trigger-workflow";
|
|
17
|
+
import { registerUpdateWorkflowTool } from "../tools/workflows/update-workflow";
|
|
18
|
+
import type { Workflow, WorkflowDefinition } from "../types";
|
|
19
|
+
import { initWorkflows, stopRetryPoller } from "../workflows";
|
|
20
|
+
|
|
21
|
+
const TEST_DB_PATH = "./test-workflow-mcp-trigger-schema.sqlite";
|
|
22
|
+
const TEST_PORT = 13031;
|
|
23
|
+
|
|
24
|
+
// ─── Test Harness ────────────────────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
// Registers the create-workflow and update-workflow MCP tools on a fresh
|
|
27
|
+
// McpServer instance and exposes their internal handlers so we can call
|
|
28
|
+
// them directly the same way the MCP SDK does at runtime
|
|
29
|
+
// (handler(args, extra) when inputSchema is defined).
|
|
30
|
+
|
|
31
|
+
type RegisteredHandler = (args: unknown, extra: unknown) => Promise<unknown>;
|
|
32
|
+
|
|
33
|
+
type ToolResult = {
|
|
34
|
+
content: Array<{ type: "text"; text: string }>;
|
|
35
|
+
structuredContent?: {
|
|
36
|
+
success: boolean;
|
|
37
|
+
message: string;
|
|
38
|
+
workflow?: { id: string; triggerSchema?: Record<string, unknown> } & Record<string, unknown>;
|
|
39
|
+
versionCreated?: number;
|
|
40
|
+
runId?: string;
|
|
41
|
+
skipped?: boolean;
|
|
42
|
+
validationErrors?: string[];
|
|
43
|
+
triggerSchema?: Record<string, unknown>;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function buildServerWithTools() {
|
|
48
|
+
const server = new McpServer({
|
|
49
|
+
name: "test-workflow-mcp-trigger-schema",
|
|
50
|
+
version: "1.0.0",
|
|
51
|
+
});
|
|
52
|
+
registerCreateWorkflowTool(server);
|
|
53
|
+
registerUpdateWorkflowTool(server);
|
|
54
|
+
registerPatchWorkflowTool(server);
|
|
55
|
+
registerTriggerWorkflowTool(server);
|
|
56
|
+
|
|
57
|
+
const registeredTools = (server as unknown as Record<string, unknown>)._registeredTools as Record<
|
|
58
|
+
string,
|
|
59
|
+
{ handler: RegisteredHandler }
|
|
60
|
+
>;
|
|
61
|
+
|
|
62
|
+
const callTool =
|
|
63
|
+
(name: string) =>
|
|
64
|
+
async (args: Record<string, unknown>, agentId = "agent-test") => {
|
|
65
|
+
const tool = registeredTools[name];
|
|
66
|
+
expect(tool).toBeDefined();
|
|
67
|
+
const extra = {
|
|
68
|
+
sessionId: "session-test",
|
|
69
|
+
requestInfo: { headers: { "x-agent-id": agentId } },
|
|
70
|
+
};
|
|
71
|
+
return (await tool.handler(args, extra)) as ToolResult;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
callCreate: callTool("create-workflow"),
|
|
76
|
+
callUpdate: callTool("update-workflow"),
|
|
77
|
+
callPatch: callTool("patch-workflow"),
|
|
78
|
+
callTrigger: callTool("trigger-workflow"),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── HTTP Test Server ────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function createTestServer(): Server {
|
|
85
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
86
|
+
res.setHeader("Content-Type", "application/json");
|
|
87
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
88
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
89
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
90
|
+
const handled = await handleWorkflows(req, res, pathSegments, queryParams, myAgentId);
|
|
91
|
+
if (!handled) {
|
|
92
|
+
res.writeHead(404);
|
|
93
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const httpHeaders = {
|
|
99
|
+
"Content-Type": "application/json",
|
|
100
|
+
"X-Agent-ID": crypto.randomUUID(),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const minimalDefinition: WorkflowDefinition = {
|
|
104
|
+
nodes: [
|
|
105
|
+
{
|
|
106
|
+
id: "step1",
|
|
107
|
+
type: "agent-task",
|
|
108
|
+
config: { template: "Hello" },
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const createdWorkflowIds: string[] = [];
|
|
114
|
+
let nameCounter = 0;
|
|
115
|
+
const uniqueName = (prefix: string) =>
|
|
116
|
+
`${prefix}-${++nameCounter}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
117
|
+
|
|
118
|
+
// ─── Tests ───────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
describe("MCP create-workflow / update-workflow / patch-workflow accept triggerSchema", () => {
|
|
121
|
+
let tools: ReturnType<typeof buildServerWithTools>;
|
|
122
|
+
let server: Server;
|
|
123
|
+
|
|
124
|
+
beforeAll(async () => {
|
|
125
|
+
try {
|
|
126
|
+
await unlink(TEST_DB_PATH);
|
|
127
|
+
} catch {
|
|
128
|
+
// File doesn't exist
|
|
129
|
+
}
|
|
130
|
+
initDb(TEST_DB_PATH);
|
|
131
|
+
initWorkflows();
|
|
132
|
+
tools = buildServerWithTools();
|
|
133
|
+
server = createTestServer();
|
|
134
|
+
await new Promise<void>((resolve) => {
|
|
135
|
+
server.listen(TEST_PORT, () => resolve());
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterAll(async () => {
|
|
140
|
+
stopRetryPoller();
|
|
141
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
142
|
+
for (const id of createdWorkflowIds) {
|
|
143
|
+
try {
|
|
144
|
+
deleteWorkflow(id);
|
|
145
|
+
} catch {
|
|
146
|
+
// Already deleted
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
closeDb();
|
|
150
|
+
try {
|
|
151
|
+
await unlink(TEST_DB_PATH);
|
|
152
|
+
await unlink(`${TEST_DB_PATH}-wal`);
|
|
153
|
+
await unlink(`${TEST_DB_PATH}-shm`);
|
|
154
|
+
} catch {
|
|
155
|
+
// Files may not exist
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ─── create-workflow with triggerSchema ─────────────────────
|
|
160
|
+
|
|
161
|
+
test("create-workflow with triggerSchema persists schema; getWorkflow returns identical object", async () => {
|
|
162
|
+
const triggerSchema: Record<string, unknown> = {
|
|
163
|
+
type: "object",
|
|
164
|
+
required: ["pr"],
|
|
165
|
+
properties: {
|
|
166
|
+
pr: {
|
|
167
|
+
type: "object",
|
|
168
|
+
required: ["number"],
|
|
169
|
+
properties: { number: { type: "number" } },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const result = await tools.callCreate({
|
|
175
|
+
name: uniqueName("mcp-trigger-schema-create-with"),
|
|
176
|
+
definition: minimalDefinition,
|
|
177
|
+
triggerSchema,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(result.structuredContent?.success).toBe(true);
|
|
181
|
+
const workflow = result.structuredContent?.workflow;
|
|
182
|
+
expect(workflow).toBeDefined();
|
|
183
|
+
expect(workflow!.id).toBeTruthy();
|
|
184
|
+
createdWorkflowIds.push(workflow!.id);
|
|
185
|
+
|
|
186
|
+
// Returned workflow contains the schema
|
|
187
|
+
expect(workflow!.triggerSchema).toEqual(triggerSchema);
|
|
188
|
+
|
|
189
|
+
// Persisted in DB and returned identically by getWorkflow
|
|
190
|
+
const loaded = getWorkflow(workflow!.id);
|
|
191
|
+
expect(loaded).not.toBeNull();
|
|
192
|
+
expect(loaded!.triggerSchema).toEqual(triggerSchema);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ─── create-workflow without triggerSchema ──────────────────
|
|
196
|
+
|
|
197
|
+
test("create-workflow without triggerSchema → returned triggerSchema is undefined", async () => {
|
|
198
|
+
const result = await tools.callCreate({
|
|
199
|
+
name: uniqueName("mcp-trigger-schema-create-without"),
|
|
200
|
+
definition: minimalDefinition,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(result.structuredContent?.success).toBe(true);
|
|
204
|
+
const workflow = result.structuredContent?.workflow;
|
|
205
|
+
expect(workflow).toBeDefined();
|
|
206
|
+
createdWorkflowIds.push(workflow!.id);
|
|
207
|
+
|
|
208
|
+
expect(workflow!.triggerSchema).toBeUndefined();
|
|
209
|
+
|
|
210
|
+
const loaded = getWorkflow(workflow!.id);
|
|
211
|
+
expect(loaded).not.toBeNull();
|
|
212
|
+
expect(loaded!.triggerSchema).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ─── update-workflow sets new triggerSchema ─────────────────
|
|
216
|
+
|
|
217
|
+
test("update-workflow with new triggerSchema → persisted", async () => {
|
|
218
|
+
const created = await tools.callCreate({
|
|
219
|
+
name: uniqueName("mcp-trigger-schema-update-set"),
|
|
220
|
+
definition: minimalDefinition,
|
|
221
|
+
});
|
|
222
|
+
const workflowId = created.structuredContent?.workflow?.id as string;
|
|
223
|
+
expect(workflowId).toBeTruthy();
|
|
224
|
+
createdWorkflowIds.push(workflowId);
|
|
225
|
+
|
|
226
|
+
const newSchema: Record<string, unknown> = {
|
|
227
|
+
type: "object",
|
|
228
|
+
required: ["foo"],
|
|
229
|
+
properties: { foo: { type: "string" } },
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const updated = await tools.callUpdate({
|
|
233
|
+
id: workflowId,
|
|
234
|
+
triggerSchema: newSchema,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(updated.structuredContent?.success).toBe(true);
|
|
238
|
+
expect(updated.structuredContent?.workflow?.triggerSchema).toEqual(newSchema);
|
|
239
|
+
|
|
240
|
+
const loaded = getWorkflow(workflowId);
|
|
241
|
+
expect(loaded).not.toBeNull();
|
|
242
|
+
expect(loaded!.triggerSchema).toEqual(newSchema);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ─── update-workflow with triggerSchema: null clears ────────
|
|
246
|
+
|
|
247
|
+
test("update-workflow with triggerSchema: null → DB column NULL, returned as undefined", async () => {
|
|
248
|
+
const initialSchema: Record<string, unknown> = {
|
|
249
|
+
type: "object",
|
|
250
|
+
required: ["a"],
|
|
251
|
+
properties: { a: { type: "string" } },
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const created = await tools.callCreate({
|
|
255
|
+
name: uniqueName("mcp-trigger-schema-update-clear"),
|
|
256
|
+
definition: minimalDefinition,
|
|
257
|
+
triggerSchema: initialSchema,
|
|
258
|
+
});
|
|
259
|
+
const workflowId = created.structuredContent?.workflow?.id as string;
|
|
260
|
+
expect(workflowId).toBeTruthy();
|
|
261
|
+
createdWorkflowIds.push(workflowId);
|
|
262
|
+
|
|
263
|
+
// Sanity: schema was set on create
|
|
264
|
+
expect(created.structuredContent?.workflow?.triggerSchema).toEqual(initialSchema);
|
|
265
|
+
|
|
266
|
+
const cleared = await tools.callUpdate({
|
|
267
|
+
id: workflowId,
|
|
268
|
+
triggerSchema: null,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(cleared.structuredContent?.success).toBe(true);
|
|
272
|
+
expect(cleared.structuredContent?.workflow?.triggerSchema).toBeUndefined();
|
|
273
|
+
|
|
274
|
+
const loaded = getWorkflow(workflowId);
|
|
275
|
+
expect(loaded).not.toBeNull();
|
|
276
|
+
expect(loaded!.triggerSchema).toBeUndefined();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ─── patch-workflow with triggerSchema only (no DAG ops) ────
|
|
280
|
+
|
|
281
|
+
test("patch-workflow with triggerSchema only → schema persisted, definition unchanged", async () => {
|
|
282
|
+
const created = await tools.callCreate({
|
|
283
|
+
name: uniqueName("mcp-trigger-schema-patch-only"),
|
|
284
|
+
definition: minimalDefinition,
|
|
285
|
+
});
|
|
286
|
+
const workflowId = created.structuredContent?.workflow?.id as string;
|
|
287
|
+
expect(workflowId).toBeTruthy();
|
|
288
|
+
createdWorkflowIds.push(workflowId);
|
|
289
|
+
const originalDefinition = getWorkflow(workflowId)?.definition;
|
|
290
|
+
expect(originalDefinition).toBeDefined();
|
|
291
|
+
|
|
292
|
+
const newSchema: Record<string, unknown> = {
|
|
293
|
+
type: "object",
|
|
294
|
+
required: ["pr"],
|
|
295
|
+
properties: { pr: { type: "object" } },
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const patched = await tools.callPatch({ id: workflowId, triggerSchema: newSchema });
|
|
299
|
+
expect(patched.structuredContent?.success).toBe(true);
|
|
300
|
+
expect(patched.structuredContent?.workflow?.triggerSchema).toEqual(newSchema);
|
|
301
|
+
|
|
302
|
+
// Definition unchanged by a metadata-only patch
|
|
303
|
+
const loaded = getWorkflow(workflowId);
|
|
304
|
+
expect(loaded?.definition).toEqual(originalDefinition!);
|
|
305
|
+
expect(loaded?.triggerSchema).toEqual(newSchema);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ─── patch-workflow with both DAG ops AND triggerSchema ─────
|
|
309
|
+
|
|
310
|
+
test("patch-workflow with DAG create AND triggerSchema → both applied", async () => {
|
|
311
|
+
const created = await tools.callCreate({
|
|
312
|
+
name: uniqueName("mcp-trigger-schema-patch-both"),
|
|
313
|
+
definition: minimalDefinition,
|
|
314
|
+
});
|
|
315
|
+
const workflowId = created.structuredContent?.workflow?.id as string;
|
|
316
|
+
expect(workflowId).toBeTruthy();
|
|
317
|
+
createdWorkflowIds.push(workflowId);
|
|
318
|
+
|
|
319
|
+
const newSchema: Record<string, unknown> = {
|
|
320
|
+
type: "object",
|
|
321
|
+
required: ["foo"],
|
|
322
|
+
properties: { foo: { type: "string" } },
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Single PATCH applies both a DAG op (create new node, chain it after step1)
|
|
326
|
+
// and a metadata change (triggerSchema)
|
|
327
|
+
const patched = await tools.callPatch({
|
|
328
|
+
id: workflowId,
|
|
329
|
+
create: [{ id: "extra", type: "agent-task", config: { template: "extra-step" } }],
|
|
330
|
+
update: [{ nodeId: "step1", node: { next: "extra" } }],
|
|
331
|
+
triggerSchema: newSchema,
|
|
332
|
+
});
|
|
333
|
+
expect(patched.structuredContent?.success).toBe(true);
|
|
334
|
+
|
|
335
|
+
const loaded = getWorkflow(workflowId);
|
|
336
|
+
expect(loaded?.triggerSchema).toEqual(newSchema);
|
|
337
|
+
expect(loaded?.definition.nodes).toHaveLength(2);
|
|
338
|
+
const nodeIds = loaded?.definition.nodes.map((n) => n.id).sort();
|
|
339
|
+
expect(nodeIds).toEqual(["extra", "step1"]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// ─── patch-workflow with triggerSchema: null clears ─────────
|
|
343
|
+
|
|
344
|
+
test("patch-workflow with triggerSchema: null → cleared", async () => {
|
|
345
|
+
const initialSchema: Record<string, unknown> = {
|
|
346
|
+
type: "object",
|
|
347
|
+
required: ["a"],
|
|
348
|
+
properties: { a: { type: "string" } },
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const created = await tools.callCreate({
|
|
352
|
+
name: uniqueName("mcp-trigger-schema-patch-clear"),
|
|
353
|
+
definition: minimalDefinition,
|
|
354
|
+
triggerSchema: initialSchema,
|
|
355
|
+
});
|
|
356
|
+
const workflowId = created.structuredContent?.workflow?.id as string;
|
|
357
|
+
expect(workflowId).toBeTruthy();
|
|
358
|
+
createdWorkflowIds.push(workflowId);
|
|
359
|
+
expect(getWorkflow(workflowId)?.triggerSchema).toEqual(initialSchema);
|
|
360
|
+
|
|
361
|
+
const cleared = await tools.callPatch({ id: workflowId, triggerSchema: null });
|
|
362
|
+
expect(cleared.structuredContent?.success).toBe(true);
|
|
363
|
+
expect(cleared.structuredContent?.workflow?.triggerSchema).toBeUndefined();
|
|
364
|
+
|
|
365
|
+
const loaded = getWorkflow(workflowId);
|
|
366
|
+
expect(loaded?.triggerSchema).toBeUndefined();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ─── HTTP PATCH /api/workflows/{id} with triggerSchema ──────
|
|
370
|
+
|
|
371
|
+
test("HTTP PATCH /api/workflows/{id} with { triggerSchema } → 200, persisted", async () => {
|
|
372
|
+
// Seed via HTTP POST so this test exercises the HTTP layer end-to-end
|
|
373
|
+
const createRes = await fetch(`http://localhost:${TEST_PORT}/api/workflows`, {
|
|
374
|
+
method: "POST",
|
|
375
|
+
headers: httpHeaders,
|
|
376
|
+
body: JSON.stringify({
|
|
377
|
+
name: uniqueName("http-patch-trigger-schema"),
|
|
378
|
+
definition: minimalDefinition,
|
|
379
|
+
}),
|
|
380
|
+
});
|
|
381
|
+
expect(createRes.status).toBe(201);
|
|
382
|
+
const createBody = (await createRes.json()) as Workflow;
|
|
383
|
+
createdWorkflowIds.push(createBody.id);
|
|
384
|
+
|
|
385
|
+
const newSchema: Record<string, unknown> = {
|
|
386
|
+
type: "object",
|
|
387
|
+
required: ["pr"],
|
|
388
|
+
properties: { pr: { type: "object" } },
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const patchRes = await fetch(`http://localhost:${TEST_PORT}/api/workflows/${createBody.id}`, {
|
|
392
|
+
method: "PATCH",
|
|
393
|
+
headers: httpHeaders,
|
|
394
|
+
body: JSON.stringify({ triggerSchema: newSchema }),
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(patchRes.status).toBe(200);
|
|
398
|
+
const patchBody = (await patchRes.json()) as Workflow;
|
|
399
|
+
expect(patchBody.triggerSchema).toEqual(newSchema);
|
|
400
|
+
|
|
401
|
+
// Verify persistence at the DB layer
|
|
402
|
+
const loaded = getWorkflow(createBody.id);
|
|
403
|
+
expect(loaded?.triggerSchema).toEqual(newSchema);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ─── Phase 3: trigger-workflow surfaces TriggerSchemaError ──
|
|
407
|
+
|
|
408
|
+
test("trigger-workflow with missing required field → structured TriggerSchemaError", async () => {
|
|
409
|
+
const triggerSchema: Record<string, unknown> = {
|
|
410
|
+
type: "object",
|
|
411
|
+
required: ["foo"],
|
|
412
|
+
properties: { foo: { type: "string" } },
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const created = await tools.callCreate({
|
|
416
|
+
name: uniqueName("mcp-trigger-error-missing"),
|
|
417
|
+
definition: minimalDefinition,
|
|
418
|
+
triggerSchema,
|
|
419
|
+
});
|
|
420
|
+
const workflowId = created.structuredContent?.workflow?.id as string;
|
|
421
|
+
expect(workflowId).toBeTruthy();
|
|
422
|
+
createdWorkflowIds.push(workflowId);
|
|
423
|
+
|
|
424
|
+
const triggered = await tools.callTrigger({ id: workflowId, triggerData: {} });
|
|
425
|
+
|
|
426
|
+
// Structured signal: not success, validationErrors carries the validator output verbatim,
|
|
427
|
+
// triggerSchema is echoed for self-correction.
|
|
428
|
+
expect(triggered.structuredContent?.success).toBe(false);
|
|
429
|
+
expect(triggered.structuredContent?.runId).toBeUndefined();
|
|
430
|
+
expect(triggered.structuredContent?.validationErrors).toEqual([
|
|
431
|
+
'root: missing required property "foo"',
|
|
432
|
+
]);
|
|
433
|
+
expect(triggered.structuredContent?.triggerSchema).toEqual(triggerSchema);
|
|
434
|
+
|
|
435
|
+
// Human-facing text contains the exact validator phrase from json-schema-validator.ts:39
|
|
436
|
+
const text = triggered.content[0]?.text ?? "";
|
|
437
|
+
expect(text).toContain('root: missing required property "foo"');
|
|
438
|
+
// Failing field name appears
|
|
439
|
+
expect(text).toContain("foo");
|
|
440
|
+
// No leaked stack trace or generic Failed: prefix
|
|
441
|
+
expect(text).not.toContain("Failed:");
|
|
442
|
+
expect(text).not.toMatch(/^Error:/);
|
|
443
|
+
expect(text).not.toContain("at ");
|
|
444
|
+
// Schema is echoed for the agent to self-correct
|
|
445
|
+
expect(text).toContain('"required"');
|
|
446
|
+
expect(text).toContain('"foo"');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("trigger-workflow with type-mismatched payload → structured TriggerSchemaError", async () => {
|
|
450
|
+
const triggerSchema: Record<string, unknown> = {
|
|
451
|
+
type: "object",
|
|
452
|
+
required: ["foo"],
|
|
453
|
+
properties: { foo: { type: "string" } },
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const created = await tools.callCreate({
|
|
457
|
+
name: uniqueName("mcp-trigger-error-type"),
|
|
458
|
+
definition: minimalDefinition,
|
|
459
|
+
triggerSchema,
|
|
460
|
+
});
|
|
461
|
+
const workflowId = created.structuredContent?.workflow?.id as string;
|
|
462
|
+
expect(workflowId).toBeTruthy();
|
|
463
|
+
createdWorkflowIds.push(workflowId);
|
|
464
|
+
|
|
465
|
+
const triggered = await tools.callTrigger({ id: workflowId, triggerData: { foo: 42 } });
|
|
466
|
+
|
|
467
|
+
expect(triggered.structuredContent?.success).toBe(false);
|
|
468
|
+
expect(triggered.structuredContent?.runId).toBeUndefined();
|
|
469
|
+
expect(triggered.structuredContent?.validationErrors).toEqual([
|
|
470
|
+
'foo: expected type "string", got number',
|
|
471
|
+
]);
|
|
472
|
+
expect(triggered.structuredContent?.triggerSchema).toEqual(triggerSchema);
|
|
473
|
+
|
|
474
|
+
const text = triggered.content[0]?.text ?? "";
|
|
475
|
+
// Exact validator phrase from json-schema-validator.ts:29
|
|
476
|
+
expect(text).toContain('foo: expected type "string", got number');
|
|
477
|
+
// Failing field name appears
|
|
478
|
+
expect(text).toContain("foo");
|
|
479
|
+
// No leaked stack trace or generic Failed: prefix
|
|
480
|
+
expect(text).not.toContain("Failed:");
|
|
481
|
+
expect(text).not.toMatch(/^Error:/);
|
|
482
|
+
expect(text).not.toContain("at ");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ─── Phase 3.5: HTTP 400 contract for TriggerSchemaError ────
|
|
486
|
+
|
|
487
|
+
test("HTTP POST /api/workflows/{id}/trigger with bad payload → 400 { error, message, details[] }", async () => {
|
|
488
|
+
const triggerSchema: Record<string, unknown> = {
|
|
489
|
+
type: "object",
|
|
490
|
+
required: ["pr"],
|
|
491
|
+
properties: {
|
|
492
|
+
pr: {
|
|
493
|
+
type: "object",
|
|
494
|
+
required: ["number"],
|
|
495
|
+
properties: { number: { type: "number" } },
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// Seed via MCP create-workflow so we don't reimplement validation hoops here
|
|
501
|
+
const created = await tools.callCreate({
|
|
502
|
+
name: uniqueName("http-trigger-400-contract"),
|
|
503
|
+
definition: minimalDefinition,
|
|
504
|
+
triggerSchema,
|
|
505
|
+
});
|
|
506
|
+
const workflowId = created.structuredContent?.workflow?.id as string;
|
|
507
|
+
expect(workflowId).toBeTruthy();
|
|
508
|
+
createdWorkflowIds.push(workflowId);
|
|
509
|
+
|
|
510
|
+
const res = await fetch(`http://localhost:${TEST_PORT}/api/workflows/${workflowId}/trigger`, {
|
|
511
|
+
method: "POST",
|
|
512
|
+
headers: httpHeaders,
|
|
513
|
+
body: JSON.stringify({}),
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
expect(res.status).toBe(400);
|
|
517
|
+
const body = (await res.json()) as {
|
|
518
|
+
error: string;
|
|
519
|
+
message: string;
|
|
520
|
+
details: string[];
|
|
521
|
+
};
|
|
522
|
+
// Frozen contract — tester (FE) reads these field names verbatim
|
|
523
|
+
expect(body.error).toBe("TriggerSchemaError");
|
|
524
|
+
expect(typeof body.message).toBe("string");
|
|
525
|
+
expect(body.message).toContain("Trigger schema validation failed");
|
|
526
|
+
expect(Array.isArray(body.details)).toBe(true);
|
|
527
|
+
expect(body.details).toEqual(['root: missing required property "pr"']);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// ─── /trigger/validate dry-run ──────────────────────────────
|
|
531
|
+
|
|
532
|
+
test("HTTP POST /trigger/validate with passing payload → 200 { valid: true }", async () => {
|
|
533
|
+
const triggerSchema: Record<string, unknown> = {
|
|
534
|
+
type: "object",
|
|
535
|
+
required: ["pr"],
|
|
536
|
+
properties: {
|
|
537
|
+
pr: {
|
|
538
|
+
type: "object",
|
|
539
|
+
required: ["number"],
|
|
540
|
+
properties: { number: { type: "number" } },
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
const created = await tools.callCreate({
|
|
545
|
+
name: uniqueName("http-trigger-validate-pass"),
|
|
546
|
+
definition: minimalDefinition,
|
|
547
|
+
triggerSchema,
|
|
548
|
+
});
|
|
549
|
+
const workflowId = created.structuredContent?.workflow?.id as string;
|
|
550
|
+
expect(workflowId).toBeTruthy();
|
|
551
|
+
createdWorkflowIds.push(workflowId);
|
|
552
|
+
|
|
553
|
+
const res = await fetch(
|
|
554
|
+
`http://localhost:${TEST_PORT}/api/workflows/${workflowId}/trigger/validate`,
|
|
555
|
+
{
|
|
556
|
+
method: "POST",
|
|
557
|
+
headers: httpHeaders,
|
|
558
|
+
body: JSON.stringify({ pr: { number: 42 } }),
|
|
559
|
+
},
|
|
560
|
+
);
|
|
561
|
+
expect(res.status).toBe(200);
|
|
562
|
+
const body = (await res.json()) as { valid: boolean };
|
|
563
|
+
expect(body.valid).toBe(true);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("HTTP POST /trigger/validate with failing payload → 400 + frozen contract", async () => {
|
|
567
|
+
const triggerSchema: Record<string, unknown> = {
|
|
568
|
+
type: "object",
|
|
569
|
+
required: ["pr"],
|
|
570
|
+
properties: { pr: { type: "object" } },
|
|
571
|
+
};
|
|
572
|
+
const created = await tools.callCreate({
|
|
573
|
+
name: uniqueName("http-trigger-validate-fail"),
|
|
574
|
+
definition: minimalDefinition,
|
|
575
|
+
triggerSchema,
|
|
576
|
+
});
|
|
577
|
+
const workflowId = created.structuredContent?.workflow?.id as string;
|
|
578
|
+
expect(workflowId).toBeTruthy();
|
|
579
|
+
createdWorkflowIds.push(workflowId);
|
|
580
|
+
|
|
581
|
+
const res = await fetch(
|
|
582
|
+
`http://localhost:${TEST_PORT}/api/workflows/${workflowId}/trigger/validate`,
|
|
583
|
+
{
|
|
584
|
+
method: "POST",
|
|
585
|
+
headers: httpHeaders,
|
|
586
|
+
body: JSON.stringify({}),
|
|
587
|
+
},
|
|
588
|
+
);
|
|
589
|
+
expect(res.status).toBe(400);
|
|
590
|
+
const body = (await res.json()) as { error: string; message: string; details: string[] };
|
|
591
|
+
expect(body.error).toBe("TriggerSchemaError");
|
|
592
|
+
expect(body.details).toEqual(['root: missing required property "pr"']);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("HTTP POST /trigger/validate on workflow without schema → 200 { valid: true, schema: null }", async () => {
|
|
596
|
+
const created = await tools.callCreate({
|
|
597
|
+
name: uniqueName("http-trigger-validate-noschema"),
|
|
598
|
+
definition: minimalDefinition,
|
|
599
|
+
});
|
|
600
|
+
const workflowId = created.structuredContent?.workflow?.id as string;
|
|
601
|
+
expect(workflowId).toBeTruthy();
|
|
602
|
+
createdWorkflowIds.push(workflowId);
|
|
603
|
+
|
|
604
|
+
const res = await fetch(
|
|
605
|
+
`http://localhost:${TEST_PORT}/api/workflows/${workflowId}/trigger/validate`,
|
|
606
|
+
{
|
|
607
|
+
method: "POST",
|
|
608
|
+
headers: httpHeaders,
|
|
609
|
+
body: JSON.stringify({ anything: "goes" }),
|
|
610
|
+
},
|
|
611
|
+
);
|
|
612
|
+
expect(res.status).toBe(200);
|
|
613
|
+
const body = (await res.json()) as { valid: boolean; schema: null };
|
|
614
|
+
expect(body.valid).toBe(true);
|
|
615
|
+
expect(body.schema).toBe(null);
|
|
616
|
+
});
|
|
617
|
+
});
|