@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
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import type { WorkflowDefinition, WorkflowDefinitionPatch } from "../types";
2
+ import type { WorkflowDefinition, WorkflowPatch } from "../types";
3
3
  import { applyDefinitionPatch } from "../workflows/definition";
4
4
 
5
5
  // ─── Helper ──────────────────────────────────────────────────
@@ -22,7 +22,7 @@ describe("applyDefinitionPatch", () => {
22
22
  // --- Delete ---
23
23
 
24
24
  test("delete removes nodes", () => {
25
- const patch: WorkflowDefinitionPatch = { delete: ["b"] };
25
+ const patch: WorkflowPatch = { delete: ["b"] };
26
26
  const result = applyDefinitionPatch(baseDef, patch);
27
27
  expect(result.errors).toEqual([]);
28
28
  expect(result.definition.nodes).toHaveLength(1);
@@ -30,7 +30,7 @@ describe("applyDefinitionPatch", () => {
30
30
  });
31
31
 
32
32
  test("delete returns error for non-existent node", () => {
33
- const patch: WorkflowDefinitionPatch = { delete: ["nonexistent"] };
33
+ const patch: WorkflowPatch = { delete: ["nonexistent"] };
34
34
  const result = applyDefinitionPatch(baseDef, patch);
35
35
  expect(result.errors).toHaveLength(1);
36
36
  expect(result.errors[0]).toContain("non-existent");
@@ -40,7 +40,7 @@ describe("applyDefinitionPatch", () => {
40
40
  // --- Create ---
41
41
 
42
42
  test("create adds nodes", () => {
43
- const patch: WorkflowDefinitionPatch = {
43
+ const patch: WorkflowPatch = {
44
44
  create: [{ id: "c", type: "agent-task", config: { template: "New" } }],
45
45
  };
46
46
  const result = applyDefinitionPatch(baseDef, patch);
@@ -50,7 +50,7 @@ describe("applyDefinitionPatch", () => {
50
50
  });
51
51
 
52
52
  test("create returns error for duplicate ID", () => {
53
- const patch: WorkflowDefinitionPatch = {
53
+ const patch: WorkflowPatch = {
54
54
  create: [{ id: "a", type: "agent-task", config: { template: "Dup" } }],
55
55
  };
56
56
  const result = applyDefinitionPatch(baseDef, patch);
@@ -62,7 +62,7 @@ describe("applyDefinitionPatch", () => {
62
62
  // --- Update ---
63
63
 
64
64
  test("update merges fields (shallow)", () => {
65
- const patch: WorkflowDefinitionPatch = {
65
+ const patch: WorkflowPatch = {
66
66
  update: [{ nodeId: "b", node: { label: "Updated", config: { template: "Changed" } } }],
67
67
  };
68
68
  const result = applyDefinitionPatch(baseDef, patch);
@@ -74,7 +74,7 @@ describe("applyDefinitionPatch", () => {
74
74
  });
75
75
 
76
76
  test("update returns error for non-existent node", () => {
77
- const patch: WorkflowDefinitionPatch = {
77
+ const patch: WorkflowPatch = {
78
78
  update: [{ nodeId: "nonexistent", node: { label: "Nope" } }],
79
79
  };
80
80
  const result = applyDefinitionPatch(baseDef, patch);
@@ -86,7 +86,7 @@ describe("applyDefinitionPatch", () => {
86
86
  // --- Combined operations ---
87
87
 
88
88
  test("delete → create → update ordering works in single patch", () => {
89
- const patch: WorkflowDefinitionPatch = {
89
+ const patch: WorkflowPatch = {
90
90
  delete: ["b"],
91
91
  create: [{ id: "c", type: "script", config: {} }],
92
92
  update: [{ nodeId: "a", node: { next: "c" } }],
@@ -101,7 +101,7 @@ describe("applyDefinitionPatch", () => {
101
101
  });
102
102
 
103
103
  test("delete + create same ID in one patch works (delete runs first)", () => {
104
- const patch: WorkflowDefinitionPatch = {
104
+ const patch: WorkflowPatch = {
105
105
  delete: ["b"],
106
106
  create: [{ id: "b", type: "script", config: { template: "Replacement" } }],
107
107
  };
@@ -116,7 +116,7 @@ describe("applyDefinitionPatch", () => {
116
116
  // --- Multiple errors ---
117
117
 
118
118
  test("collects multiple errors in one patch", () => {
119
- const patch: WorkflowDefinitionPatch = {
119
+ const patch: WorkflowPatch = {
120
120
  delete: ["nonexistent1"],
121
121
  create: [{ id: "a", type: "script", config: {} }], // duplicate
122
122
  update: [{ nodeId: "nonexistent2", node: { label: "Nope" } }],
@@ -129,7 +129,7 @@ describe("applyDefinitionPatch", () => {
129
129
 
130
130
  test("preserves onNodeFailure when not patched", () => {
131
131
  const def = makeDef([{ id: "a", type: "agent-task", config: {} }], "continue");
132
- const patch: WorkflowDefinitionPatch = {
132
+ const patch: WorkflowPatch = {
133
133
  update: [{ nodeId: "a", node: { label: "Labeled" } }],
134
134
  };
135
135
  const result = applyDefinitionPatch(def, patch);
@@ -138,7 +138,7 @@ describe("applyDefinitionPatch", () => {
138
138
  });
139
139
 
140
140
  test("patches onNodeFailure when provided", () => {
141
- const patch: WorkflowDefinitionPatch = { onNodeFailure: "continue" };
141
+ const patch: WorkflowPatch = { onNodeFailure: "continue" };
142
142
  const result = applyDefinitionPatch(baseDef, patch);
143
143
  expect(result.errors).toEqual([]);
144
144
  expect(result.definition.onNodeFailure).toBe("continue");
@@ -147,7 +147,7 @@ describe("applyDefinitionPatch", () => {
147
147
  // --- Empty patch ---
148
148
 
149
149
  test("empty patch returns definition unchanged with no errors", () => {
150
- const patch: WorkflowDefinitionPatch = {};
150
+ const patch: WorkflowPatch = {};
151
151
  const result = applyDefinitionPatch(baseDef, patch);
152
152
  expect(result.errors).toEqual([]);
153
153
  expect(result.definition.nodes).toEqual(baseDef.nodes);
@@ -159,7 +159,7 @@ describe("applyDefinitionPatch", () => {
159
159
  test("does not mutate original definition", () => {
160
160
  const original = makeDef([{ id: "a", type: "agent-task", config: { template: "Hello" } }]);
161
161
  const originalNodes = [...original.nodes];
162
- const patch: WorkflowDefinitionPatch = {
162
+ const patch: WorkflowPatch = {
163
163
  create: [{ id: "b", type: "script", config: {} }],
164
164
  };
165
165
  applyDefinitionPatch(original, patch);
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Phase 4 verification: a `wait` node correlates against an existing
3
+ * built-in `workflowEventBus` event (`task.completed`) without any new
4
+ * emit code in production.
5
+ *
6
+ * Why a fan-out shape (entry → [agent-task, wait])?
7
+ * --------------------------------------------------
8
+ * Linear `agent-task → wait { eventName: "task.completed" }` does NOT work —
9
+ * by the time execution reaches the wait, the upstream's `task.completed`
10
+ * has already fired and the bus event is gone. The wait must subscribe
11
+ * BEFORE the event fires, so it has to register on a parallel branch.
12
+ *
13
+ * What this test simulates
14
+ * ------------------------
15
+ * Spinning up a real agent-task involves provider work (Claude / Codex /
16
+ * etc.) which is too heavy for a unit test. Instead we exercise the same
17
+ * code path the real emit hits by emitting `task.completed` directly via
18
+ * `workflowEventBus.emit` with a payload shaped exactly like the one
19
+ * produced from `src/be/db.ts` (`{ taskId, output, agentId, workflowRunId,
20
+ * workflowRunStepId }`). This proves the wait correlates against the
21
+ * built-in event by `workflowRunId` filter — no production-code changes
22
+ * outside of Phases 1–3.
23
+ */
24
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
25
+ import { unlink } from "node:fs/promises";
26
+ import * as db from "../be/db";
27
+ import {
28
+ closeDb,
29
+ createWorkflow,
30
+ deleteWorkflow,
31
+ getWaitStateByStepId,
32
+ getWorkflowRun,
33
+ getWorkflowRunStepsByRunId,
34
+ initDb,
35
+ } from "../be/db";
36
+ import type { Workflow, WorkflowDefinition } from "../types";
37
+ import { startWorkflowExecution } from "../workflows/engine";
38
+ import { workflowEventBus } from "../workflows/event-bus";
39
+ import type { ExecutorDependencies } from "../workflows/executors/base";
40
+ import { createExecutorRegistry } from "../workflows/executors/registry";
41
+ import { _resetWaitBusSubscriptionsForTests, initWaitBusSubscriptions } from "../workflows/resume";
42
+
43
+ const TEST_DB_PATH = "./test-workflow-wait-builtin-events.sqlite";
44
+
45
+ const deps: ExecutorDependencies = {
46
+ db: db as typeof import("../be/db"),
47
+ eventBus: workflowEventBus,
48
+ interpolate: (t: string) => t,
49
+ };
50
+
51
+ const createdWorkflowIds: string[] = [];
52
+
53
+ function makeWorkflow(name: string, def: WorkflowDefinition): Workflow {
54
+ const wf = createWorkflow({ name: `${name}-${Date.now()}-${Math.random()}`, definition: def });
55
+ createdWorkflowIds.push(wf.id);
56
+ return wf;
57
+ }
58
+
59
+ beforeAll(async () => {
60
+ try {
61
+ await unlink(TEST_DB_PATH);
62
+ } catch {
63
+ // ignore
64
+ }
65
+ initDb(TEST_DB_PATH);
66
+ });
67
+
68
+ afterAll(async () => {
69
+ for (const id of createdWorkflowIds) {
70
+ try {
71
+ deleteWorkflow(id);
72
+ } catch {}
73
+ }
74
+ closeDb();
75
+ for (const suffix of ["", "-wal", "-shm"]) {
76
+ await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
77
+ }
78
+ });
79
+
80
+ afterEach(() => {
81
+ _resetWaitBusSubscriptionsForTests();
82
+ });
83
+
84
+ async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
85
+ const t0 = Date.now();
86
+ while (!predicate()) {
87
+ if (Date.now() - t0 > timeoutMs) throw new Error("waitFor: timeout");
88
+ await new Promise((r) => setTimeout(r, 10));
89
+ }
90
+ }
91
+
92
+ describe("WaitExecutor — built-in bus events (Phase 4 verification)", () => {
93
+ test("wait correlates against task.completed via workflowRunId filter (fan-out)", async () => {
94
+ const registry = createExecutorRegistry(deps);
95
+
96
+ // Fan-out shape: a script entry node spawns two parallel branches.
97
+ // Branch A is a placeholder for the agent-task that would fire
98
+ // task.completed. Branch B is the wait node, which subscribes to
99
+ // `task.completed` BEFORE the simulated emit happens. After the wait
100
+ // resolves it falls through to a terminal `notify` node.
101
+ const def: WorkflowDefinition = {
102
+ nodes: [
103
+ {
104
+ id: "entry",
105
+ type: "script",
106
+ config: { runtime: "bash", script: "echo go" },
107
+ next: ["w1", "tail"],
108
+ },
109
+ {
110
+ id: "w1",
111
+ type: "wait",
112
+ config: {
113
+ mode: "event",
114
+ eventName: "task.completed",
115
+ // Built-in task.completed payload includes `workflowRunId` —
116
+ // we filter on it to correlate against this specific run.
117
+ // The matcher walks the dot-path, so this maps to
118
+ // payload.workflowRunId === <captured run id>.
119
+ // We can't interpolate {{trigger.runId}} here without an
120
+ // explicit `inputs` mapping, so the test rebuilds the def
121
+ // after `runId` is known and re-creates the workflow.
122
+ // For simplicity we use scope='run' which already enforces
123
+ // workflowRunId === waitState.workflowRunId — that's the
124
+ // canonical correlation path for built-in events.
125
+ scope: "run",
126
+ },
127
+ next: { event: "tail" },
128
+ },
129
+ {
130
+ id: "tail",
131
+ type: "notify",
132
+ config: { channel: "swarm", template: "done" },
133
+ },
134
+ ],
135
+ };
136
+ const wf = makeWorkflow("wait-builtin-task-completed", def);
137
+ const runId = await startWorkflowExecution(wf, {}, registry);
138
+
139
+ // Initialize the bus listener registry (parity with initWorkflows()).
140
+ initWaitBusSubscriptions(registry);
141
+
142
+ // The wait should be in 'waiting' status at this point — it registered
143
+ // its bus subscription during execute. The fan-out's other branch may
144
+ // already have run.
145
+ expect(getWorkflowRun(runId)?.status).toBe("waiting");
146
+ const steps = getWorkflowRunStepsByRunId(runId);
147
+ const w1 = steps.find((s) => s.nodeId === "w1");
148
+ expect(w1?.status).toBe("waiting");
149
+
150
+ const waitState = getWaitStateByStepId(w1!.id);
151
+ expect(waitState).not.toBeNull();
152
+ expect(waitState?.eventName).toBe("task.completed");
153
+
154
+ // Simulate the agent-task completing. The payload is exactly what
155
+ // `completeTask` in src/be/db.ts emits today — including
156
+ // workflowRunId, which the scope='run' matcher uses to correlate.
157
+ workflowEventBus.emit("task.completed", {
158
+ taskId: "fake-task-id-1",
159
+ output: '{"result":"ok"}',
160
+ agentId: "fake-agent-id",
161
+ workflowRunId: runId,
162
+ workflowRunStepId: "fake-step-id-on-some-other-run",
163
+ });
164
+
165
+ await waitFor(() => getWaitStateByStepId(w1!.id)?.status === "fired");
166
+
167
+ const fired = getWaitStateByStepId(w1!.id);
168
+ expect(fired?.status).toBe("fired");
169
+ const stored = fired?.firedPayload as { workflowRunId?: string; output?: string };
170
+ expect(stored?.workflowRunId).toBe(runId);
171
+
172
+ // The wait advanced via `event` port — the run should reach the tail.
173
+ const finalSteps = getWorkflowRunStepsByRunId(runId);
174
+ const w1After = finalSteps.find((s) => s.nodeId === "w1");
175
+ expect(w1After?.status).toBe("completed");
176
+ expect(w1After?.nextPort).toBe("event");
177
+
178
+ // tail should run (could be once or twice depending on convergence —
179
+ // we just assert it completed at least once).
180
+ const tailSteps = finalSteps.filter((s) => s.nodeId === "tail");
181
+ expect(tailSteps.length).toBeGreaterThanOrEqual(1);
182
+ expect(tailSteps.every((s) => s.status === "completed")).toBe(true);
183
+ });
184
+
185
+ test("scope='run' rejects task.completed from a different run", async () => {
186
+ const registry = createExecutorRegistry(deps);
187
+
188
+ const def: WorkflowDefinition = {
189
+ nodes: [
190
+ {
191
+ id: "entry",
192
+ type: "script",
193
+ config: { runtime: "bash", script: "echo go" },
194
+ next: ["w1", "tail"],
195
+ },
196
+ {
197
+ id: "w1",
198
+ type: "wait",
199
+ config: { mode: "event", eventName: "task.completed", scope: "run" },
200
+ next: { event: "tail" },
201
+ },
202
+ { id: "tail", type: "notify", config: { channel: "swarm", template: "done" } },
203
+ ],
204
+ };
205
+ const wf = makeWorkflow("wait-builtin-task-completed-other-run", def);
206
+ const runId = await startWorkflowExecution(wf, {}, registry);
207
+
208
+ initWaitBusSubscriptions(registry);
209
+
210
+ const steps = getWorkflowRunStepsByRunId(runId);
211
+ const w1 = steps.find((s) => s.nodeId === "w1");
212
+
213
+ // Emit a task.completed bound to a DIFFERENT run — must NOT resolve
214
+ // this wait because scope='run' enforces workflowRunId === this runId.
215
+ workflowEventBus.emit("task.completed", {
216
+ taskId: "task-from-elsewhere",
217
+ output: "ignored",
218
+ agentId: "x",
219
+ workflowRunId: "some-other-run-id-not-ours",
220
+ workflowRunStepId: "some-other-step",
221
+ });
222
+
223
+ // Yield to listeners.
224
+ await new Promise((r) => setTimeout(r, 50));
225
+ expect(getWaitStateByStepId(w1!.id)?.status).toBe("pending");
226
+
227
+ // Now emit one bound to OUR run — should resolve.
228
+ workflowEventBus.emit("task.completed", {
229
+ taskId: "task-for-us",
230
+ output: "ok",
231
+ agentId: "x",
232
+ workflowRunId: runId,
233
+ workflowRunStepId: "any-step",
234
+ });
235
+ await waitFor(() => getWaitStateByStepId(w1!.id)?.status === "fired");
236
+ expect(getWaitStateByStepId(w1!.id)?.status).toBe("fired");
237
+ });
238
+
239
+ test("create-workflow accepts a wait-node-containing definition (Zod round-trip)", () => {
240
+ // Validation is purely Zod-driven via WorkflowDefinitionSchema. Wait
241
+ // node config is a `z.record` at the schema level; the WaitExecutor's
242
+ // own configSchema parses it at execution time. Here we just round-trip
243
+ // a definition with both time- and event-mode wait nodes through the
244
+ // public `createWorkflow` path to prove the schema accepts them.
245
+ const def: WorkflowDefinition = {
246
+ nodes: [
247
+ {
248
+ id: "time-wait",
249
+ type: "wait",
250
+ config: { mode: "time", durationMs: 30000 },
251
+ next: { default: "event-wait" },
252
+ },
253
+ {
254
+ id: "event-wait",
255
+ type: "wait",
256
+ config: {
257
+ mode: "event",
258
+ eventName: "demo.signal",
259
+ filter: { ok: true },
260
+ scope: "run",
261
+ timeoutMs: 60_000,
262
+ },
263
+ next: { event: "yay", timeout: "nay" },
264
+ },
265
+ { id: "yay", type: "notify", config: { channel: "swarm", template: "ok" } },
266
+ { id: "nay", type: "notify", config: { channel: "swarm", template: "timed out" } },
267
+ ],
268
+ };
269
+ const wf = makeWorkflow("wait-roundtrip", def);
270
+ expect(wf.id).toBeTruthy();
271
+ expect(wf.definition.nodes).toHaveLength(4);
272
+ const timeWait = wf.definition.nodes.find((n) => n.id === "time-wait");
273
+ expect(timeWait?.type).toBe("wait");
274
+ expect((timeWait?.config as { mode?: string })?.mode).toBe("time");
275
+ const eventWait = wf.definition.nodes.find((n) => n.id === "event-wait");
276
+ expect((eventWait?.config as { mode?: string })?.mode).toBe("event");
277
+ expect((eventWait?.config as { timeoutMs?: number })?.timeoutMs).toBe(60_000);
278
+ });
279
+ });