@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,384 @@
|
|
|
1
|
+
import { afterAll, afterEach, 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
|
+
createWorkflow,
|
|
7
|
+
deleteWorkflow,
|
|
8
|
+
getDueWaitStates,
|
|
9
|
+
getWaitStateByStepId,
|
|
10
|
+
getWorkflowRun,
|
|
11
|
+
getWorkflowRunStepsByRunId,
|
|
12
|
+
initDb,
|
|
13
|
+
} from "../be/db";
|
|
14
|
+
import type { Workflow, WorkflowDefinition } from "../types";
|
|
15
|
+
import { startWorkflowExecution } from "../workflows/engine";
|
|
16
|
+
import { workflowEventBus } from "../workflows/event-bus";
|
|
17
|
+
import type { ExecutorDependencies } from "../workflows/executors/base";
|
|
18
|
+
import { createExecutorRegistry } from "../workflows/executors/registry";
|
|
19
|
+
import {
|
|
20
|
+
_resetWaitBusSubscriptionsForTests,
|
|
21
|
+
initWaitBusSubscriptions,
|
|
22
|
+
resumeWaitState,
|
|
23
|
+
} from "../workflows/resume";
|
|
24
|
+
|
|
25
|
+
const TEST_DB_PATH = "./test-workflow-wait-event.sqlite";
|
|
26
|
+
|
|
27
|
+
const deps: ExecutorDependencies = {
|
|
28
|
+
db: db as typeof import("../be/db"),
|
|
29
|
+
eventBus: workflowEventBus,
|
|
30
|
+
interpolate: (t: string) => t,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const createdWorkflowIds: string[] = [];
|
|
34
|
+
|
|
35
|
+
function makeWorkflow(name: string, def: WorkflowDefinition): Workflow {
|
|
36
|
+
const wf = createWorkflow({ name: `${name}-${Date.now()}-${Math.random()}`, definition: def });
|
|
37
|
+
createdWorkflowIds.push(wf.id);
|
|
38
|
+
return wf;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
try {
|
|
43
|
+
await unlink(TEST_DB_PATH);
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore
|
|
46
|
+
}
|
|
47
|
+
initDb(TEST_DB_PATH);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
for (const id of createdWorkflowIds) {
|
|
52
|
+
try {
|
|
53
|
+
deleteWorkflow(id);
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
closeDb();
|
|
57
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
58
|
+
await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
_resetWaitBusSubscriptionsForTests();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ─── Helpers ───────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
|
|
69
|
+
const t0 = Date.now();
|
|
70
|
+
while (!predicate()) {
|
|
71
|
+
if (Date.now() - t0 > timeoutMs) throw new Error("waitFor: timeout");
|
|
72
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("WaitExecutor — event mode end-to-end", () => {
|
|
77
|
+
test("event-mode wait persists, fires on bus event, advances run via 'event' port", async () => {
|
|
78
|
+
const registry = createExecutorRegistry(deps);
|
|
79
|
+
|
|
80
|
+
const def: WorkflowDefinition = {
|
|
81
|
+
nodes: [
|
|
82
|
+
{
|
|
83
|
+
id: "w1",
|
|
84
|
+
type: "wait",
|
|
85
|
+
config: {
|
|
86
|
+
mode: "event",
|
|
87
|
+
eventName: "demo.signal",
|
|
88
|
+
filter: { ok: true },
|
|
89
|
+
},
|
|
90
|
+
next: { event: "done" },
|
|
91
|
+
},
|
|
92
|
+
{ id: "done", type: "notify", config: { channel: "swarm", template: "got it" } },
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
const wf = makeWorkflow("wait-event-happy", def);
|
|
96
|
+
const runId = await startWorkflowExecution(wf, {}, registry);
|
|
97
|
+
|
|
98
|
+
// Run + step both 'waiting'.
|
|
99
|
+
expect(getWorkflowRun(runId)?.status).toBe("waiting");
|
|
100
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
101
|
+
const w1 = steps.find((s) => s.nodeId === "w1");
|
|
102
|
+
expect(w1?.status).toBe("waiting");
|
|
103
|
+
|
|
104
|
+
const waitState = getWaitStateByStepId(w1!.id);
|
|
105
|
+
expect(waitState).not.toBeNull();
|
|
106
|
+
expect(waitState?.mode).toBe("event");
|
|
107
|
+
expect(waitState?.eventName).toBe("demo.signal");
|
|
108
|
+
expect(waitState?.eventScope).toBe("run");
|
|
109
|
+
expect(waitState?.status).toBe("pending");
|
|
110
|
+
|
|
111
|
+
// The subscribeWaitToBus call inside execute already wired the listener.
|
|
112
|
+
// Initialize busRegistry so processBusEvent can resume.
|
|
113
|
+
initWaitBusSubscriptions(registry);
|
|
114
|
+
|
|
115
|
+
// Fire matching signal — must include _runId for run-scope filter.
|
|
116
|
+
workflowEventBus.emit("demo.signal", { ok: true, _runId: runId });
|
|
117
|
+
|
|
118
|
+
await waitFor(() => getWaitStateByStepId(w1!.id)?.status === "fired");
|
|
119
|
+
|
|
120
|
+
const fired = getWaitStateByStepId(w1!.id);
|
|
121
|
+
expect(fired?.status).toBe("fired");
|
|
122
|
+
expect((fired?.firedPayload as { ok?: boolean })?.ok).toBe(true);
|
|
123
|
+
|
|
124
|
+
const finalSteps = getWorkflowRunStepsByRunId(runId);
|
|
125
|
+
const w1After = finalSteps.find((s) => s.nodeId === "w1");
|
|
126
|
+
expect(w1After?.status).toBe("completed");
|
|
127
|
+
expect(w1After?.nextPort).toBe("event");
|
|
128
|
+
|
|
129
|
+
const doneStep = finalSteps.find((s) => s.nodeId === "done");
|
|
130
|
+
expect(doneStep?.status).toBe("completed");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("scope='run': mismatched _runId payload does NOT resolve the wait", async () => {
|
|
134
|
+
const registry = createExecutorRegistry(deps);
|
|
135
|
+
|
|
136
|
+
const def: WorkflowDefinition = {
|
|
137
|
+
nodes: [
|
|
138
|
+
{
|
|
139
|
+
id: "w1",
|
|
140
|
+
type: "wait",
|
|
141
|
+
config: { mode: "event", eventName: "scoped.signal", scope: "run" },
|
|
142
|
+
next: { event: "done" },
|
|
143
|
+
},
|
|
144
|
+
{ id: "done", type: "notify", config: { channel: "swarm", template: "ok" } },
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
const wf = makeWorkflow("wait-event-scope-run", def);
|
|
148
|
+
const runId = await startWorkflowExecution(wf, {}, registry);
|
|
149
|
+
|
|
150
|
+
initWaitBusSubscriptions(registry);
|
|
151
|
+
|
|
152
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
153
|
+
const w1 = steps.find((s) => s.nodeId === "w1");
|
|
154
|
+
|
|
155
|
+
// Wrong _runId — must NOT match.
|
|
156
|
+
workflowEventBus.emit("scoped.signal", { _runId: "nope-other-run-id" });
|
|
157
|
+
// Yield once to let listeners fire.
|
|
158
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
159
|
+
expect(getWaitStateByStepId(w1!.id)?.status).toBe("pending");
|
|
160
|
+
|
|
161
|
+
// Right _runId — should match.
|
|
162
|
+
workflowEventBus.emit("scoped.signal", { _runId: runId });
|
|
163
|
+
await waitFor(() => getWaitStateByStepId(w1!.id)?.status === "fired");
|
|
164
|
+
expect(getWaitStateByStepId(w1!.id)?.status).toBe("fired");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("scope='global': payload does not need _runId", async () => {
|
|
168
|
+
const registry = createExecutorRegistry(deps);
|
|
169
|
+
|
|
170
|
+
const def: WorkflowDefinition = {
|
|
171
|
+
nodes: [
|
|
172
|
+
{
|
|
173
|
+
id: "w1",
|
|
174
|
+
type: "wait",
|
|
175
|
+
config: { mode: "event", eventName: "broadcast.signal", scope: "global" },
|
|
176
|
+
next: { event: "done" },
|
|
177
|
+
},
|
|
178
|
+
{ id: "done", type: "notify", config: { channel: "swarm", template: "ok" } },
|
|
179
|
+
],
|
|
180
|
+
};
|
|
181
|
+
const wf = makeWorkflow("wait-event-scope-global", def);
|
|
182
|
+
const runId = await startWorkflowExecution(wf, {}, registry);
|
|
183
|
+
|
|
184
|
+
initWaitBusSubscriptions(registry);
|
|
185
|
+
|
|
186
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
187
|
+
const w1 = steps.find((s) => s.nodeId === "w1");
|
|
188
|
+
|
|
189
|
+
workflowEventBus.emit("broadcast.signal", { source: "external" });
|
|
190
|
+
await waitFor(() => getWaitStateByStepId(w1!.id)?.status === "fired");
|
|
191
|
+
expect(getWaitStateByStepId(w1!.id)?.status).toBe("fired");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("string-form filter: only matching predicate resolves", async () => {
|
|
195
|
+
const registry = createExecutorRegistry(deps);
|
|
196
|
+
|
|
197
|
+
const def: WorkflowDefinition = {
|
|
198
|
+
nodes: [
|
|
199
|
+
{
|
|
200
|
+
id: "w1",
|
|
201
|
+
type: "wait",
|
|
202
|
+
config: {
|
|
203
|
+
mode: "event",
|
|
204
|
+
eventName: "tagged.signal",
|
|
205
|
+
scope: "global",
|
|
206
|
+
filter: "(p) => p.labels && p.labels.includes('release')",
|
|
207
|
+
},
|
|
208
|
+
next: { event: "done" },
|
|
209
|
+
},
|
|
210
|
+
{ id: "done", type: "notify", config: { channel: "swarm", template: "ok" } },
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
const wf = makeWorkflow("wait-event-string-filter", def);
|
|
214
|
+
const runId = await startWorkflowExecution(wf, {}, registry);
|
|
215
|
+
|
|
216
|
+
initWaitBusSubscriptions(registry);
|
|
217
|
+
|
|
218
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
219
|
+
const w1 = steps.find((s) => s.nodeId === "w1");
|
|
220
|
+
|
|
221
|
+
// Non-matching payload → still pending.
|
|
222
|
+
workflowEventBus.emit("tagged.signal", { labels: ["bug"] });
|
|
223
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
224
|
+
expect(getWaitStateByStepId(w1!.id)?.status).toBe("pending");
|
|
225
|
+
|
|
226
|
+
// Matching payload.
|
|
227
|
+
workflowEventBus.emit("tagged.signal", { labels: ["bug", "release"] });
|
|
228
|
+
await waitFor(() => getWaitStateByStepId(w1!.id)?.status === "fired");
|
|
229
|
+
expect(getWaitStateByStepId(w1!.id)?.status).toBe("fired");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("event-mode timeout routes via 'timeout' port (poller path)", async () => {
|
|
233
|
+
const registry = createExecutorRegistry(deps);
|
|
234
|
+
|
|
235
|
+
const def: WorkflowDefinition = {
|
|
236
|
+
nodes: [
|
|
237
|
+
{
|
|
238
|
+
id: "w1",
|
|
239
|
+
type: "wait",
|
|
240
|
+
config: {
|
|
241
|
+
mode: "event",
|
|
242
|
+
eventName: "never.fires",
|
|
243
|
+
scope: "global",
|
|
244
|
+
timeoutMs: 1000,
|
|
245
|
+
},
|
|
246
|
+
next: { event: "yes", timeout: "no" },
|
|
247
|
+
},
|
|
248
|
+
{ id: "yes", type: "notify", config: { channel: "swarm", template: "fired" } },
|
|
249
|
+
{ id: "no", type: "notify", config: { channel: "swarm", template: "timed out" } },
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
const wf = makeWorkflow("wait-event-timeout", def);
|
|
253
|
+
const runId = await startWorkflowExecution(wf, {}, registry);
|
|
254
|
+
|
|
255
|
+
initWaitBusSubscriptions(registry);
|
|
256
|
+
|
|
257
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
258
|
+
const w1 = steps.find((s) => s.nodeId === "w1");
|
|
259
|
+
const ws = getWaitStateByStepId(w1!.id);
|
|
260
|
+
expect(ws?.expiresAt).not.toBeNull();
|
|
261
|
+
|
|
262
|
+
// Skip the 5s poller — fast-forward by directly calling the resume helper
|
|
263
|
+
// with status='timeout' (the poller would do exactly this once expiresAt
|
|
264
|
+
// passes).
|
|
265
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
266
|
+
const due = getDueWaitStates();
|
|
267
|
+
expect(due.find((d) => d.id === ws!.id)).toBeDefined();
|
|
268
|
+
|
|
269
|
+
await resumeWaitState(ws!.id, "timeout", undefined, registry);
|
|
270
|
+
|
|
271
|
+
const after = getWaitStateByStepId(w1!.id);
|
|
272
|
+
expect(after?.status).toBe("timeout");
|
|
273
|
+
|
|
274
|
+
const finalSteps = getWorkflowRunStepsByRunId(runId);
|
|
275
|
+
const w1After = finalSteps.find((s) => s.nodeId === "w1");
|
|
276
|
+
expect(w1After?.status).toBe("completed");
|
|
277
|
+
expect(w1After?.nextPort).toBe("timeout");
|
|
278
|
+
|
|
279
|
+
const noStep = finalSteps.find((s) => s.nodeId === "no");
|
|
280
|
+
expect(noStep?.status).toBe("completed");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("fan-out: a single bus event resolves N concurrent waits subscribed to the same name", async () => {
|
|
284
|
+
const registry = createExecutorRegistry(deps);
|
|
285
|
+
|
|
286
|
+
const makeFanoutWorkflow = (id: string) => {
|
|
287
|
+
const def: WorkflowDefinition = {
|
|
288
|
+
nodes: [
|
|
289
|
+
{
|
|
290
|
+
id: "w1",
|
|
291
|
+
type: "wait",
|
|
292
|
+
config: { mode: "event", eventName: "fanout.signal", scope: "global" },
|
|
293
|
+
next: { event: "done" },
|
|
294
|
+
},
|
|
295
|
+
{ id: "done", type: "notify", config: { channel: "swarm", template: id } },
|
|
296
|
+
],
|
|
297
|
+
};
|
|
298
|
+
return makeWorkflow(`wait-fanout-${id}`, def);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Two concurrent runs, each waiting on the SAME eventName. Both must be
|
|
302
|
+
// registered with the bus before we emit (subscribeWaitToBus is called
|
|
303
|
+
// inside WaitExecutor.execute, but initWaitBusSubscriptions wires the
|
|
304
|
+
// resume side of the bridge — call it once after both runs are paused).
|
|
305
|
+
const wfA = makeFanoutWorkflow("A");
|
|
306
|
+
const wfB = makeFanoutWorkflow("B");
|
|
307
|
+
const runIdA = await startWorkflowExecution(wfA, {}, registry);
|
|
308
|
+
const runIdB = await startWorkflowExecution(wfB, {}, registry);
|
|
309
|
+
|
|
310
|
+
expect(getWorkflowRun(runIdA)?.status).toBe("waiting");
|
|
311
|
+
expect(getWorkflowRun(runIdB)?.status).toBe("waiting");
|
|
312
|
+
|
|
313
|
+
const stepsA = getWorkflowRunStepsByRunId(runIdA);
|
|
314
|
+
const stepsB = getWorkflowRunStepsByRunId(runIdB);
|
|
315
|
+
const w1A = stepsA.find((s) => s.nodeId === "w1");
|
|
316
|
+
const w1B = stepsB.find((s) => s.nodeId === "w1");
|
|
317
|
+
|
|
318
|
+
initWaitBusSubscriptions(registry);
|
|
319
|
+
|
|
320
|
+
// Single emit — both waits should resolve.
|
|
321
|
+
workflowEventBus.emit("fanout.signal", { broadcast: true, sequence: 42 });
|
|
322
|
+
|
|
323
|
+
await waitFor(
|
|
324
|
+
() =>
|
|
325
|
+
getWaitStateByStepId(w1A!.id)?.status === "fired" &&
|
|
326
|
+
getWaitStateByStepId(w1B!.id)?.status === "fired",
|
|
327
|
+
2000,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// Both wait_states flipped to fired with firedPayload populated.
|
|
331
|
+
for (const stepId of [w1A!.id, w1B!.id]) {
|
|
332
|
+
const fired = getWaitStateByStepId(stepId);
|
|
333
|
+
expect(fired?.status).toBe("fired");
|
|
334
|
+
const payload = fired?.firedPayload as { broadcast?: boolean; sequence?: number };
|
|
335
|
+
expect(payload?.broadcast).toBe(true);
|
|
336
|
+
expect(payload?.sequence).toBe(42);
|
|
337
|
+
expect(fired?.resolvedAt).toBeTruthy();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Both runs advanced via the `event` port and the downstream notify ran.
|
|
341
|
+
for (const runId of [runIdA, runIdB]) {
|
|
342
|
+
const finalSteps = getWorkflowRunStepsByRunId(runId);
|
|
343
|
+
const w1After = finalSteps.find((s) => s.nodeId === "w1");
|
|
344
|
+
expect(w1After?.status).toBe("completed");
|
|
345
|
+
expect(w1After?.nextPort).toBe("event");
|
|
346
|
+
const doneStep = finalSteps.find((s) => s.nodeId === "done");
|
|
347
|
+
expect(doneStep?.status).toBe("completed");
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("64KB cap: oversized payload is replaced with a truncation marker", async () => {
|
|
352
|
+
const registry = createExecutorRegistry(deps);
|
|
353
|
+
|
|
354
|
+
const def: WorkflowDefinition = {
|
|
355
|
+
nodes: [
|
|
356
|
+
{
|
|
357
|
+
id: "w1",
|
|
358
|
+
type: "wait",
|
|
359
|
+
config: { mode: "event", eventName: "big.signal", scope: "global" },
|
|
360
|
+
next: { event: "done" },
|
|
361
|
+
},
|
|
362
|
+
{ id: "done", type: "notify", config: { channel: "swarm", template: "ok" } },
|
|
363
|
+
],
|
|
364
|
+
};
|
|
365
|
+
const wf = makeWorkflow("wait-event-cap", def);
|
|
366
|
+
const runId = await startWorkflowExecution(wf, {}, registry);
|
|
367
|
+
|
|
368
|
+
initWaitBusSubscriptions(registry);
|
|
369
|
+
|
|
370
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
371
|
+
const w1 = steps.find((s) => s.nodeId === "w1");
|
|
372
|
+
|
|
373
|
+
// 100KB payload — should trigger the 64KB cap.
|
|
374
|
+
const huge = { blob: "x".repeat(100_000) };
|
|
375
|
+
workflowEventBus.emit("big.signal", huge);
|
|
376
|
+
|
|
377
|
+
await waitFor(() => getWaitStateByStepId(w1!.id)?.status === "fired");
|
|
378
|
+
|
|
379
|
+
const fired = getWaitStateByStepId(w1!.id);
|
|
380
|
+
const stored = fired?.firedPayload as { truncated?: boolean; originalSize?: number };
|
|
381
|
+
expect(stored?.truncated).toBe(true);
|
|
382
|
+
expect(stored?.originalSize).toBeGreaterThan(64 * 1024);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { compileStringFilter, matchesFilter } from "../workflows/wait-filter";
|
|
3
|
+
|
|
4
|
+
// ─── (a) Object form ────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
describe("matchesFilter — object form", () => {
|
|
7
|
+
test("no filter (undefined) matches anything", async () => {
|
|
8
|
+
expect(await matchesFilter({ a: 1 }, undefined)).toBe(true);
|
|
9
|
+
expect(await matchesFilter(null, undefined)).toBe(true);
|
|
10
|
+
expect(await matchesFilter("string-payload", undefined)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("no filter (null) matches anything", async () => {
|
|
14
|
+
expect(await matchesFilter({ a: 1 }, null)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("exact equality on a single key", async () => {
|
|
18
|
+
expect(await matchesFilter({ a: 1 }, { a: 1 })).toBe(true);
|
|
19
|
+
expect(await matchesFilter({ a: 1 }, { a: 2 })).toBe(false);
|
|
20
|
+
expect(await matchesFilter({ a: "x" }, { a: "x" })).toBe(true);
|
|
21
|
+
expect(await matchesFilter({ a: true }, { a: true })).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("multiple keys must all match (AND semantics)", async () => {
|
|
25
|
+
expect(await matchesFilter({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true);
|
|
26
|
+
expect(await matchesFilter({ a: 1, b: 2 }, { a: 1, b: 99 })).toBe(false);
|
|
27
|
+
expect(await matchesFilter({ a: 1, b: 2 }, { a: 1 })).toBe(true); // extra payload keys OK
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("dot-path nested keys (pr.number style)", async () => {
|
|
31
|
+
const payload = { pr: { number: 42, title: "fix" }, repo: { id: "abc" } };
|
|
32
|
+
expect(await matchesFilter(payload, { "pr.number": 42 })).toBe(true);
|
|
33
|
+
expect(await matchesFilter(payload, { "pr.number": 99 })).toBe(false);
|
|
34
|
+
expect(await matchesFilter(payload, { "pr.title": "fix", "repo.id": "abc" })).toBe(true);
|
|
35
|
+
expect(await matchesFilter(payload, { "pr.title": "wrong", "repo.id": "abc" })).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("missing key on payload → no-match", async () => {
|
|
39
|
+
expect(await matchesFilter({ a: 1 }, { b: 2 })).toBe(false);
|
|
40
|
+
expect(await matchesFilter({ a: 1 }, { "deep.path": 2 })).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("type-mismatch (string vs number) → no-match", async () => {
|
|
44
|
+
expect(await matchesFilter({ a: "1" }, { a: 1 })).toBe(false);
|
|
45
|
+
expect(await matchesFilter({ a: 1 }, { a: "1" })).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("array deep equality", async () => {
|
|
49
|
+
expect(await matchesFilter({ tags: ["a", "b"] }, { tags: ["a", "b"] })).toBe(true);
|
|
50
|
+
expect(await matchesFilter({ tags: ["a", "b"] }, { tags: ["b", "a"] })).toBe(false);
|
|
51
|
+
expect(await matchesFilter({ tags: ["a"] }, { tags: ["a", "b"] })).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("nested object deep equality", async () => {
|
|
55
|
+
expect(await matchesFilter({ meta: { x: 1, y: 2 } }, { meta: { x: 1, y: 2 } })).toBe(true);
|
|
56
|
+
expect(await matchesFilter({ meta: { x: 1, y: 2 } }, { meta: { x: 1 } })).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ─── (b) String form — happy path ───────────────────────────
|
|
61
|
+
|
|
62
|
+
describe("matchesFilter — string form happy path", () => {
|
|
63
|
+
test("arrow-fn returning boolean true matches", async () => {
|
|
64
|
+
expect(await matchesFilter({ n: 5 }, "(p) => p.n > 3")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("arrow-fn returning boolean false does not match", async () => {
|
|
68
|
+
expect(await matchesFilter({ n: 5 }, "(p) => p.n > 10")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("truthy non-boolean coerced via !!", async () => {
|
|
72
|
+
expect(await matchesFilter({ name: "ok" }, "(p) => p.name")).toBe(true);
|
|
73
|
+
expect(await matchesFilter({ name: "" }, "(p) => p.name")).toBe(false);
|
|
74
|
+
expect(await matchesFilter({ count: 0 }, "(p) => p.count")).toBe(false);
|
|
75
|
+
expect(await matchesFilter({ count: 5 }, "(p) => p.count")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("arrow-fn that throws → no-match", async () => {
|
|
79
|
+
expect(await matchesFilter(null, "(p) => { throw new Error('boom'); }")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("arrow-fn returning undefined → no-match", async () => {
|
|
83
|
+
expect(await matchesFilter({ a: 1 }, "(p) => undefined")).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("complex predicate with array.some", async () => {
|
|
87
|
+
const filter = "(p) => p.labels && p.labels.some(l => l.name === 'release')";
|
|
88
|
+
expect(await matchesFilter({ labels: [{ name: "bug" }, { name: "release" }] }, filter)).toBe(
|
|
89
|
+
true,
|
|
90
|
+
);
|
|
91
|
+
expect(await matchesFilter({ labels: [{ name: "bug" }] }, filter)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ─── (c) String form — sandbox penetration ─────────────────
|
|
96
|
+
|
|
97
|
+
describe("matchesFilter — sandbox penetration (must all return false, never throw)", () => {
|
|
98
|
+
test("direct global access: process.env", async () => {
|
|
99
|
+
expect(await matchesFilter({}, "(p) => process.env.PATH")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("direct global access: require", async () => {
|
|
103
|
+
expect(await matchesFilter({}, "(p) => require('fs')")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("direct global access: globalThis.fetch", async () => {
|
|
107
|
+
expect(await matchesFilter({}, "(p) => globalThis.fetch")).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("direct global access: Bun.version", async () => {
|
|
111
|
+
expect(await matchesFilter({}, "(p) => Bun.version")).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("direct global access: global.process", async () => {
|
|
115
|
+
expect(await matchesFilter({}, "(p) => global.process")).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("indirect global via constructor.constructor (Function-constructor escape)", async () => {
|
|
119
|
+
// The classic VM-escape: payload.constructor is Object,
|
|
120
|
+
// Object.constructor is Function, Function('return process')() returns
|
|
121
|
+
// the real process. SANDBOX_KEYS shadows Function so this must fail.
|
|
122
|
+
expect(
|
|
123
|
+
await matchesFilter({ x: 1 }, "(p) => p.constructor.constructor('return process')()"),
|
|
124
|
+
).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("eval reflection", async () => {
|
|
128
|
+
expect(await matchesFilter({}, "(p) => eval('process.env')")).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("async escape: async fn returns Promise → matcher returns false", async () => {
|
|
132
|
+
// Async fns are blocked by contract: filters are SYNCHRONOUS predicates.
|
|
133
|
+
// The matcher detects a Promise return and returns false (and silently
|
|
134
|
+
// observes the rejection so it does not crash the runtime). We use a
|
|
135
|
+
// benign body (`Promise.resolve(true)`-equivalent) here because Bun's
|
|
136
|
+
// test runner aborts on unhandledRejection — the SAME no-match path is
|
|
137
|
+
// exercised, just without a rejected promise.
|
|
138
|
+
const result = await matchesFilter({}, "(p) => (async () => true)()");
|
|
139
|
+
expect(result).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("DoS: tight CPU loop is killed by the timer race (returns false within bound)", async () => {
|
|
143
|
+
// True `while(true){}` would block the JS event loop indefinitely (single
|
|
144
|
+
// thread — the timer cannot fire while the user fn is running). We use a
|
|
145
|
+
// bounded busy-loop that yields control; the matcher's timeout race then
|
|
146
|
+
// resolves null → false. This validates the timeout PATH; defending
|
|
147
|
+
// against true infinite loops requires a Worker thread (deferred).
|
|
148
|
+
const filter = "(p) => { let n = 0; for (let i = 0; i < 100; i++) n += i; return false; }";
|
|
149
|
+
const t0 = Date.now();
|
|
150
|
+
const result = await matchesFilter({}, filter);
|
|
151
|
+
const elapsed = Date.now() - t0;
|
|
152
|
+
expect(result).toBe(false);
|
|
153
|
+
expect(elapsed).toBeLessThan(2000);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("DoS: pathological regex terminates within timeout", async () => {
|
|
157
|
+
const filter = "(p) => /^(a+)+$/.test('a'.repeat(30) + 'X')";
|
|
158
|
+
const t0 = Date.now();
|
|
159
|
+
const result = await matchesFilter({}, filter);
|
|
160
|
+
const elapsed = Date.now() - t0;
|
|
161
|
+
// The regex would eventually return false on its own, but if it stalls
|
|
162
|
+
// the timeout catches it.
|
|
163
|
+
expect(typeof result).toBe("boolean");
|
|
164
|
+
expect(elapsed).toBeLessThan(2000);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("side-effect attempt: payload mutation does not leak", async () => {
|
|
168
|
+
const original = { a: 1, b: { c: 2 } };
|
|
169
|
+
const snapshot = JSON.parse(JSON.stringify(original));
|
|
170
|
+
const result = await matchesFilter(
|
|
171
|
+
original,
|
|
172
|
+
"(p) => { p.injected = true; p.b.c = 999; return true }",
|
|
173
|
+
);
|
|
174
|
+
// Filter returns true (no exception inside fn).
|
|
175
|
+
expect(result).toBe(true);
|
|
176
|
+
// Critical: the original payload must be unchanged — structuredClone
|
|
177
|
+
// gives the user fn a copy.
|
|
178
|
+
expect(original).toEqual(snapshot);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ─── (d) Compile-time validation ───────────────────────────
|
|
183
|
+
|
|
184
|
+
describe("compileStringFilter — init-time validation", () => {
|
|
185
|
+
test("valid arrow-fn compiles cleanly", () => {
|
|
186
|
+
expect(() => compileStringFilter("(p) => p.x === 1")).not.toThrow();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("invalid arrow-fn syntax throws", () => {
|
|
190
|
+
expect(() => compileStringFilter("(p) => {")).toThrow(/wait filter compile error/);
|
|
191
|
+
expect(() => compileStringFilter("not-a-function")).toThrow();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("filter source > 2KB rejected at compile time", () => {
|
|
195
|
+
const huge = `(p) => ${"x".repeat(2050)} === 1`;
|
|
196
|
+
expect(() => compileStringFilter(huge)).toThrow(/2KB/);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ─── (e) Scope enforcement is in resume.ts; tested in workflow-wait-event tests
|