@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,177 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
4
|
+
import * as db from "../be/db";
|
|
5
|
+
import {
|
|
6
|
+
closeDb,
|
|
7
|
+
createWorkflow,
|
|
8
|
+
deleteWorkflow,
|
|
9
|
+
getWaitStateByStepId,
|
|
10
|
+
getWorkflowRunStepsByRunId,
|
|
11
|
+
initDb,
|
|
12
|
+
} from "../be/db";
|
|
13
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
14
|
+
import { handleWorkflowEvents } from "../http/workflow-events";
|
|
15
|
+
import type { Workflow, WorkflowDefinition } from "../types";
|
|
16
|
+
import { startWorkflowExecution } from "../workflows/engine";
|
|
17
|
+
import { workflowEventBus } from "../workflows/event-bus";
|
|
18
|
+
import type { ExecutorDependencies } from "../workflows/executors/base";
|
|
19
|
+
import { createExecutorRegistry } from "../workflows/executors/registry";
|
|
20
|
+
import { _resetWaitBusSubscriptionsForTests, initWaitBusSubscriptions } from "../workflows/resume";
|
|
21
|
+
|
|
22
|
+
const TEST_DB_PATH = "./test-workflow-wait-http.sqlite";
|
|
23
|
+
const TEST_PORT = 13041;
|
|
24
|
+
|
|
25
|
+
const deps: ExecutorDependencies = {
|
|
26
|
+
db: db as typeof import("../be/db"),
|
|
27
|
+
eventBus: workflowEventBus,
|
|
28
|
+
interpolate: (t: string) => t,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const createdWorkflowIds: string[] = [];
|
|
32
|
+
|
|
33
|
+
function makeWorkflow(name: string, def: WorkflowDefinition): Workflow {
|
|
34
|
+
const wf = createWorkflow({ name: `${name}-${Date.now()}-${Math.random()}`, definition: def });
|
|
35
|
+
createdWorkflowIds.push(wf.id);
|
|
36
|
+
return wf;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let server: Server;
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
try {
|
|
43
|
+
await unlink(TEST_DB_PATH);
|
|
44
|
+
} catch {}
|
|
45
|
+
initDb(TEST_DB_PATH);
|
|
46
|
+
|
|
47
|
+
server = createHttpServer(async (req, res) => {
|
|
48
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
49
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
50
|
+
const handled = await handleWorkflowEvents(req, res, pathSegments, queryParams);
|
|
51
|
+
if (!handled) {
|
|
52
|
+
res.writeHead(404);
|
|
53
|
+
res.end("Not Found");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
await new Promise<void>((r) => server.listen(TEST_PORT, () => r()));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterAll(async () => {
|
|
60
|
+
await new Promise<void>((r) => server.close(() => r()));
|
|
61
|
+
for (const id of createdWorkflowIds) {
|
|
62
|
+
try {
|
|
63
|
+
deleteWorkflow(id);
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
closeDb();
|
|
67
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
68
|
+
await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
_resetWaitBusSubscriptionsForTests();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const baseUrl = `http://localhost:${TEST_PORT}`;
|
|
77
|
+
|
|
78
|
+
async function waitFor(pred: () => boolean, timeoutMs = 1000): Promise<void> {
|
|
79
|
+
const t0 = Date.now();
|
|
80
|
+
while (!pred()) {
|
|
81
|
+
if (Date.now() - t0 > timeoutMs) throw new Error("waitFor: timeout");
|
|
82
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("Workflow events HTTP signal endpoints", () => {
|
|
87
|
+
test("POST /api/workflow-runs/:runId/events resolves a run-scoped event-mode wait", async () => {
|
|
88
|
+
const registry = createExecutorRegistry(deps);
|
|
89
|
+
|
|
90
|
+
const def: WorkflowDefinition = {
|
|
91
|
+
nodes: [
|
|
92
|
+
{
|
|
93
|
+
id: "w1",
|
|
94
|
+
type: "wait",
|
|
95
|
+
config: { mode: "event", eventName: "demo.http.signal", filter: { ok: true } },
|
|
96
|
+
next: { event: "done" },
|
|
97
|
+
},
|
|
98
|
+
{ id: "done", type: "notify", config: { channel: "swarm", template: "got it" } },
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
const wf = makeWorkflow("wait-http-run-scope", def);
|
|
102
|
+
const runId = await startWorkflowExecution(wf, {}, registry);
|
|
103
|
+
|
|
104
|
+
initWaitBusSubscriptions(registry);
|
|
105
|
+
|
|
106
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
107
|
+
const w1 = steps.find((s) => s.nodeId === "w1");
|
|
108
|
+
expect(getWaitStateByStepId(w1!.id)?.status).toBe("pending");
|
|
109
|
+
|
|
110
|
+
// POST run-scoped signal — handler must inject _runId.
|
|
111
|
+
const res = await fetch(`${baseUrl}/api/workflow-runs/${runId}/events`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify({ name: "demo.http.signal", payload: { ok: true } }),
|
|
115
|
+
});
|
|
116
|
+
expect(res.status).toBe(200);
|
|
117
|
+
const body = (await res.json()) as { ok: boolean; runId: string; name: string };
|
|
118
|
+
expect(body.ok).toBe(true);
|
|
119
|
+
expect(body.runId).toBe(runId);
|
|
120
|
+
|
|
121
|
+
await waitFor(() => getWaitStateByStepId(w1!.id)?.status === "fired");
|
|
122
|
+
expect(getWaitStateByStepId(w1!.id)?.status).toBe("fired");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("POST /api/workflow-runs/:runId/events returns 404 for unknown runId", async () => {
|
|
126
|
+
const res = await fetch(
|
|
127
|
+
`${baseUrl}/api/workflow-runs/00000000-0000-0000-0000-000000000000/events`,
|
|
128
|
+
{
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { "Content-Type": "application/json" },
|
|
131
|
+
body: JSON.stringify({ name: "any.event", payload: {} }),
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
expect(res.status).toBe(404);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("POST /api/workflow-events broadcasts globally and resolves a global-scope wait", async () => {
|
|
138
|
+
const registry = createExecutorRegistry(deps);
|
|
139
|
+
|
|
140
|
+
const def: WorkflowDefinition = {
|
|
141
|
+
nodes: [
|
|
142
|
+
{
|
|
143
|
+
id: "w1",
|
|
144
|
+
type: "wait",
|
|
145
|
+
config: { mode: "event", eventName: "broadcast.http.signal", scope: "global" },
|
|
146
|
+
next: { event: "done" },
|
|
147
|
+
},
|
|
148
|
+
{ id: "done", type: "notify", config: { channel: "swarm", template: "ok" } },
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
const wf = makeWorkflow("wait-http-global", def);
|
|
152
|
+
const runId = await startWorkflowExecution(wf, {}, registry);
|
|
153
|
+
|
|
154
|
+
initWaitBusSubscriptions(registry);
|
|
155
|
+
|
|
156
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
157
|
+
const w1 = steps.find((s) => s.nodeId === "w1");
|
|
158
|
+
|
|
159
|
+
const res = await fetch(`${baseUrl}/api/workflow-events`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { "Content-Type": "application/json" },
|
|
162
|
+
body: JSON.stringify({ name: "broadcast.http.signal", payload: { source: "ext" } }),
|
|
163
|
+
});
|
|
164
|
+
expect(res.status).toBe(200);
|
|
165
|
+
|
|
166
|
+
await waitFor(() => getWaitStateByStepId(w1!.id)?.status === "fired");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("POST /api/workflow-events validates body (missing name → 400)", async () => {
|
|
170
|
+
const res = await fetch(`${baseUrl}/api/workflow-events`, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: { "Content-Type": "application/json" },
|
|
173
|
+
body: JSON.stringify({ payload: { x: 1 } }),
|
|
174
|
+
});
|
|
175
|
+
expect(res.status).toBe(400);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import * as db from "../be/db";
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createWaitState,
|
|
7
|
+
createWorkflow,
|
|
8
|
+
createWorkflowRun,
|
|
9
|
+
createWorkflowRunStep,
|
|
10
|
+
deleteWorkflow,
|
|
11
|
+
getWaitStateByStepId,
|
|
12
|
+
getWorkflowRun,
|
|
13
|
+
getWorkflowRunStep,
|
|
14
|
+
getWorkflowRunStepsByRunId,
|
|
15
|
+
initDb,
|
|
16
|
+
updateWorkflowRun,
|
|
17
|
+
updateWorkflowRunStep,
|
|
18
|
+
} from "../be/db";
|
|
19
|
+
import type { Workflow, WorkflowDefinition } from "../types";
|
|
20
|
+
import { InProcessEventBus } from "../workflows/event-bus";
|
|
21
|
+
import type { ExecutorDependencies } from "../workflows/executors/base";
|
|
22
|
+
import { createExecutorRegistry } from "../workflows/executors/registry";
|
|
23
|
+
import { recoverIncompleteRuns } from "../workflows/recovery";
|
|
24
|
+
|
|
25
|
+
const TEST_DB_PATH = "./test-workflow-wait-recovery.sqlite";
|
|
26
|
+
|
|
27
|
+
const eventBus = new InProcessEventBus();
|
|
28
|
+
const deps: ExecutorDependencies = {
|
|
29
|
+
db: db as typeof import("../be/db"),
|
|
30
|
+
eventBus,
|
|
31
|
+
interpolate: (t: string) => t,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const createdWorkflowIds: string[] = [];
|
|
35
|
+
|
|
36
|
+
function makeWorkflow(name: string, def: WorkflowDefinition): Workflow {
|
|
37
|
+
const wf = createWorkflow({ name: `${name}-${Date.now()}-${Math.random()}`, definition: def });
|
|
38
|
+
createdWorkflowIds.push(wf.id);
|
|
39
|
+
return wf;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
try {
|
|
44
|
+
await unlink(TEST_DB_PATH);
|
|
45
|
+
} catch {
|
|
46
|
+
// ignore
|
|
47
|
+
}
|
|
48
|
+
initDb(TEST_DB_PATH);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
for (const id of createdWorkflowIds) {
|
|
53
|
+
try {
|
|
54
|
+
deleteWorkflow(id);
|
|
55
|
+
} catch {
|
|
56
|
+
// already deleted
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
closeDb();
|
|
60
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
61
|
+
await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("WaitExecutor — recovery on startup", () => {
|
|
66
|
+
test("server died while waiting: overdue time-mode wait recovers via 'default' port", async () => {
|
|
67
|
+
const registry = createExecutorRegistry(deps);
|
|
68
|
+
|
|
69
|
+
// 1. Set up a workflow with a wait + downstream notify node, but build
|
|
70
|
+
// the run/step state manually to simulate "server crashed mid-wait":
|
|
71
|
+
// run 'waiting', step 'waiting', wait_state pending with wakeUpAt in
|
|
72
|
+
// the past.
|
|
73
|
+
const def: WorkflowDefinition = {
|
|
74
|
+
nodes: [
|
|
75
|
+
{
|
|
76
|
+
id: "w1",
|
|
77
|
+
type: "wait",
|
|
78
|
+
config: { mode: "time", durationMs: 60_000 },
|
|
79
|
+
next: { default: "done" },
|
|
80
|
+
},
|
|
81
|
+
{ id: "done", type: "notify", config: { channel: "swarm", template: "recovered" } },
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
const wf = makeWorkflow("wait-recovery-overdue", def);
|
|
85
|
+
|
|
86
|
+
const run = createWorkflowRun({ id: crypto.randomUUID(), workflowId: wf.id });
|
|
87
|
+
updateWorkflowRun(run.id, { status: "waiting" });
|
|
88
|
+
|
|
89
|
+
const step = createWorkflowRunStep({
|
|
90
|
+
id: crypto.randomUUID(),
|
|
91
|
+
runId: run.id,
|
|
92
|
+
nodeId: "w1",
|
|
93
|
+
nodeType: "wait",
|
|
94
|
+
});
|
|
95
|
+
updateWorkflowRunStep(step.id, { status: "waiting" });
|
|
96
|
+
|
|
97
|
+
createWaitState({
|
|
98
|
+
id: crypto.randomUUID(),
|
|
99
|
+
workflowRunId: run.id,
|
|
100
|
+
workflowRunStepId: step.id,
|
|
101
|
+
mode: "time",
|
|
102
|
+
wakeUpAt: new Date(Date.now() - 60_000).toISOString(), // 1 min in the past
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Sanity: run is paused, wait is pending overdue.
|
|
106
|
+
expect(getWorkflowRun(run.id)?.status).toBe("waiting");
|
|
107
|
+
expect(getWaitStateByStepId(step.id)?.status).toBe("pending");
|
|
108
|
+
|
|
109
|
+
// 2. Run recovery — must resume the wait and walk to 'done'.
|
|
110
|
+
await recoverIncompleteRuns(registry);
|
|
111
|
+
|
|
112
|
+
// 3. Assertions: wait fired, wait step completed via 'default' port,
|
|
113
|
+
// notify step ran, run completed.
|
|
114
|
+
const recoveredWait = getWaitStateByStepId(step.id);
|
|
115
|
+
expect(recoveredWait?.status).toBe("fired");
|
|
116
|
+
expect(recoveredWait?.resolvedAt).not.toBeNull();
|
|
117
|
+
|
|
118
|
+
const recoveredStep = getWorkflowRunStep(step.id);
|
|
119
|
+
expect(recoveredStep?.status).toBe("completed");
|
|
120
|
+
expect(recoveredStep?.nextPort).toBe("default");
|
|
121
|
+
|
|
122
|
+
const recoveredRun = getWorkflowRun(run.id);
|
|
123
|
+
expect(recoveredRun?.status).toBe("completed");
|
|
124
|
+
|
|
125
|
+
const allSteps = getWorkflowRunStepsByRunId(run.id);
|
|
126
|
+
const doneStep = allSteps.find((s) => s.nodeId === "done");
|
|
127
|
+
expect(doneStep?.status).toBe("completed");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("recovery is idempotent: a second pass does not double-advance", async () => {
|
|
131
|
+
const registry = createExecutorRegistry(deps);
|
|
132
|
+
|
|
133
|
+
const def: WorkflowDefinition = {
|
|
134
|
+
nodes: [
|
|
135
|
+
{
|
|
136
|
+
id: "w1",
|
|
137
|
+
type: "wait",
|
|
138
|
+
config: { mode: "time", durationMs: 60_000 },
|
|
139
|
+
next: { default: "done" },
|
|
140
|
+
},
|
|
141
|
+
{ id: "done", type: "notify", config: { channel: "swarm", template: "recovered" } },
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
const wf = makeWorkflow("wait-recovery-idempotent", def);
|
|
145
|
+
const run = createWorkflowRun({ id: crypto.randomUUID(), workflowId: wf.id });
|
|
146
|
+
updateWorkflowRun(run.id, { status: "waiting" });
|
|
147
|
+
|
|
148
|
+
const step = createWorkflowRunStep({
|
|
149
|
+
id: crypto.randomUUID(),
|
|
150
|
+
runId: run.id,
|
|
151
|
+
nodeId: "w1",
|
|
152
|
+
nodeType: "wait",
|
|
153
|
+
});
|
|
154
|
+
updateWorkflowRunStep(step.id, { status: "waiting" });
|
|
155
|
+
|
|
156
|
+
createWaitState({
|
|
157
|
+
id: crypto.randomUUID(),
|
|
158
|
+
workflowRunId: run.id,
|
|
159
|
+
workflowRunStepId: step.id,
|
|
160
|
+
mode: "time",
|
|
161
|
+
wakeUpAt: new Date(Date.now() - 1_000).toISOString(),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await recoverIncompleteRuns(registry);
|
|
165
|
+
const stepsAfter1 = getWorkflowRunStepsByRunId(run.id);
|
|
166
|
+
expect(getWorkflowRun(run.id)?.status).toBe("completed");
|
|
167
|
+
const doneCount1 = stepsAfter1.filter((s) => s.nodeId === "done").length;
|
|
168
|
+
expect(doneCount1).toBe(1);
|
|
169
|
+
|
|
170
|
+
// Second recovery pass — wait_state is now 'fired' (case a in the
|
|
171
|
+
// recovery query). resumeWaitState's atomic update returns updated=false,
|
|
172
|
+
// so the step shouldn't re-run.
|
|
173
|
+
await recoverIncompleteRuns(registry);
|
|
174
|
+
const stepsAfter2 = getWorkflowRunStepsByRunId(run.id);
|
|
175
|
+
expect(stepsAfter2.filter((s) => s.nodeId === "done").length).toBe(1);
|
|
176
|
+
expect(getWorkflowRun(run.id)?.status).toBe("completed");
|
|
177
|
+
});
|
|
178
|
+
});
|