@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.
Files changed (43) hide show
  1. package/openapi.json +199 -1
  2. package/package.json +1 -1
  3. package/src/be/db.ts +278 -0
  4. package/src/be/migrations/049_wait_states.sql +30 -0
  5. package/src/be/migrations/050_wait_states_scope.sql +19 -0
  6. package/src/http/index.ts +2 -0
  7. package/src/http/trackers/jira.ts +84 -27
  8. package/src/http/trackers/linear.ts +67 -11
  9. package/src/http/utils.ts +15 -0
  10. package/src/http/workflow-events.ts +107 -0
  11. package/src/http/workflows.ts +55 -6
  12. package/src/jira/sync.ts +20 -7
  13. package/src/linear/gate.ts +122 -0
  14. package/src/linear/sync.ts +128 -0
  15. package/src/oauth/keepalive.ts +34 -13
  16. package/src/tests/ensure-token.test.ts +33 -0
  17. package/src/tests/linear-webhook.test.ts +383 -0
  18. package/src/tests/workflow-executors.test.ts +4 -2
  19. package/src/tests/workflow-mcp-trigger-schema.test.ts +617 -0
  20. package/src/tests/workflow-patch.test.ts +14 -14
  21. package/src/tests/workflow-wait-builtin-events.test.ts +279 -0
  22. package/src/tests/workflow-wait-event.test.ts +384 -0
  23. package/src/tests/workflow-wait-filter.test.ts +200 -0
  24. package/src/tests/workflow-wait-http.test.ts +177 -0
  25. package/src/tests/workflow-wait-recovery.test.ts +178 -0
  26. package/src/tests/workflow-wait-state-queries.test.ts +419 -0
  27. package/src/tests/workflow-wait-time.test.ts +255 -0
  28. package/src/tools/tracker/tracker-status.ts +7 -1
  29. package/src/tools/workflows/create-workflow.ts +16 -2
  30. package/src/tools/workflows/patch-workflow.ts +26 -6
  31. package/src/tools/workflows/trigger-workflow.ts +26 -1
  32. package/src/tools/workflows/update-workflow.ts +28 -2
  33. package/src/types.ts +48 -3
  34. package/src/workflows/definition.ts +2 -5
  35. package/src/workflows/executors/index.ts +1 -0
  36. package/src/workflows/executors/registry.ts +2 -0
  37. package/src/workflows/executors/wait.ts +170 -0
  38. package/src/workflows/index.ts +18 -2
  39. package/src/workflows/json-schema-validator.ts +8 -1
  40. package/src/workflows/recovery.ts +55 -1
  41. package/src/workflows/resume.ts +272 -0
  42. package/src/workflows/wait-filter.ts +311 -0
  43. 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
+ });