@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,11 +1,15 @@
1
1
  import {
2
2
  cancelTask,
3
3
  getCompletedStepNodeIds,
4
+ getPendingEventWaitNames,
5
+ getPendingWaitsByEvent,
4
6
  getTaskByWorkflowRunStepId,
7
+ getWaitStateById,
5
8
  getWorkflow,
6
9
  getWorkflowRun,
7
10
  getWorkflowRunStep,
8
11
  getWorkflowRunStepsByRunId,
12
+ resolveWaitState,
9
13
  updateWorkflowRun,
10
14
  updateWorkflowRunStep,
11
15
  } from "../be/db";
@@ -13,7 +17,10 @@ import { checkpointStep } from "./checkpoint";
13
17
  import { getSuccessors } from "./definition";
14
18
  import { findReadyNodes, walkGraph } from "./engine";
15
19
  import type { WorkflowEventBus } from "./event-bus";
20
+ import { workflowEventBus } from "./event-bus";
16
21
  import type { ExecutorRegistry } from "./executors/registry";
22
+ import { computeNextPort } from "./executors/wait";
23
+ import { matchesFilter } from "./wait-filter";
17
24
 
18
25
  interface TaskEvent {
19
26
  taskId: string;
@@ -341,3 +348,268 @@ async function resumeFromApprovalResolution(
341
348
  finalizeOrWait(run.id);
342
349
  }
343
350
  }
351
+
352
+ /**
353
+ * Resume a paused `wait` node. Single entry-point shared by the wait poller
354
+ * (Phase 2 — time mode + event-mode timeout) and, in Phase 3, the bus listener
355
+ * for event-mode signal arrival.
356
+ *
357
+ * Flow:
358
+ * 1. Atomically resolve the `wait_states` row (`pending → fired|timeout`).
359
+ * Race-safe: `resolveWaitState` returns `{updated: false}` when a
360
+ * concurrent caller already won — we bail without further side-effects.
361
+ * 2. Reload the run + step. Bail if the step is no longer in `waiting`
362
+ * (cancelled, failed, or somehow already advanced).
363
+ * 3. Compute the output port (time → `default`, event+fired → `event`,
364
+ * event+timeout → `timeout`).
365
+ * 4. Checkpoint the step as completed with the wait output, set the run
366
+ * back to `running`, and walk the successors of the chosen port.
367
+ *
368
+ * NOTE: there are intentionally NO `wait.fired` / `wait.timeout` bus events.
369
+ * Resumption is an internal function call — the poller invokes this directly,
370
+ * and the Phase 3 bus listener will too.
371
+ */
372
+ export async function resumeWaitState(
373
+ waitId: string,
374
+ status: "fired" | "timeout",
375
+ payload: unknown,
376
+ registry: ExecutorRegistry,
377
+ ): Promise<void> {
378
+ // 1. Cap firedPayload at 64KB (DB-write boundary). Webhook payloads can be
379
+ // 50KB+ — anything bigger is replaced with a marker so we don't bloat the
380
+ // row. The same truncated form is also what the workflow sees in
381
+ // `output.payload` so authors aren't surprised by stored vs delivered
382
+ // diverging.
383
+ const cappedPayload = capPayload(payload);
384
+
385
+ // 2. Atomic state transition. Only the first caller proceeds.
386
+ const result = resolveWaitState(waitId, { status, firedPayload: cappedPayload });
387
+ if (!result.updated || !result.row) return;
388
+
389
+ const waitRow = result.row;
390
+
391
+ // 2. Load the surrounding run + step. If anything has moved on (cancelled,
392
+ // failed, retried, etc.), stay quiet.
393
+ const run = getWorkflowRun(waitRow.workflowRunId);
394
+ if (!run || (run.status !== "waiting" && run.status !== "running")) return;
395
+
396
+ const step = getWorkflowRunStep(waitRow.workflowRunStepId);
397
+ if (!step || step.status !== "waiting") return;
398
+
399
+ const workflow = getWorkflow(run.workflowId);
400
+ if (!workflow) return;
401
+
402
+ // 3. Pick the output port.
403
+ const nextPort = computeNextPort(waitRow.mode, status);
404
+
405
+ // 4. Build step output, checkpoint, transition run, walk successors.
406
+ const ctx = (run.context ?? {}) as Record<string, unknown>;
407
+ const stepOutput = {
408
+ waitId: waitRow.id,
409
+ mode: waitRow.mode,
410
+ firedAt: waitRow.resolvedAt,
411
+ payload: cappedPayload === undefined ? undefined : cappedPayload,
412
+ };
413
+
414
+ checkpointStep(run.id, step.id, step.nodeId, { output: stepOutput, nextPort }, ctx);
415
+ updateWorkflowRun(run.id, { status: "running" });
416
+
417
+ // 5. Bus listener bookkeeping: this wait is no longer pending, so drop it
418
+ // from the per-event subscription set. If the set empties out, unwire the
419
+ // bus listener.
420
+ if (waitRow.mode === "event" && waitRow.eventName) {
421
+ pruneWaitFromBus(waitRow.id, waitRow.eventName);
422
+ }
423
+
424
+ const successors = getSuccessors(workflow.definition, step.nodeId, nextPort);
425
+ if (successors.length > 0) {
426
+ await walkGraph(workflow.definition, run.id, ctx, successors, registry, workflow.id);
427
+ } else {
428
+ finalizeOrWait(run.id);
429
+ }
430
+ }
431
+
432
+ // ─── 64KB firedPayload cap ──────────────────────────────────────────────────
433
+
434
+ const FIRED_PAYLOAD_BYTE_CAP = 64 * 1024; // 64KB
435
+
436
+ /**
437
+ * Apply the 64KB cap policy to event-mode `firedPayload`. If the JSON-encoded
438
+ * payload exceeds the cap, replace it with a structured truncation marker so
439
+ * downstream nodes can detect the truncation and either ignore it or pull the
440
+ * full payload from the source if needed.
441
+ *
442
+ * The same form flows into both the DB row AND the step output — see
443
+ * docstring above for rationale.
444
+ */
445
+ function capPayload(payload: unknown): unknown {
446
+ if (payload === undefined || payload === null) return payload;
447
+ let encoded: string;
448
+ try {
449
+ encoded = JSON.stringify(payload);
450
+ } catch {
451
+ // Non-serializable (function, symbol, circular ref, …) — hand back a
452
+ // marker rather than letting JSON.stringify failure bubble up.
453
+ return { truncated: true, reason: "non-serializable" };
454
+ }
455
+ if (encoded.length <= FIRED_PAYLOAD_BYTE_CAP) {
456
+ return payload;
457
+ }
458
+ // Build a 1KB summary slice for visibility.
459
+ const summary = encoded.slice(0, 1024);
460
+ return {
461
+ truncated: true,
462
+ originalSize: encoded.length,
463
+ summary,
464
+ };
465
+ }
466
+
467
+ // ─── Wait bus subscription registry (event mode) ────────────────────────────
468
+ //
469
+ // One bus listener per distinct `eventName`. Each listener iterates a Set of
470
+ // pending waitIds, looks up each row, applies scope + filter, and resolves on
471
+ // match. Listeners are created lazily (on first subscribeWaitToBus for an
472
+ // eventName) and torn down when the per-name Set empties.
473
+
474
+ const waitsByEvent = new Map<string, Set<string>>();
475
+ const listenersByEvent = new Map<string, (data: unknown) => void>();
476
+ let busRegistry: ExecutorRegistry | null = null;
477
+
478
+ /**
479
+ * Initialize the wait-bus subscription system. Called from `initWorkflows()`
480
+ * AFTER `setupWorkflowResumeListener`. Scans all pending event-mode waits and
481
+ * registers one listener per distinct event name.
482
+ *
483
+ * Subsequent calls update the registry reference (idempotent — listeners
484
+ * already registered are not re-registered).
485
+ */
486
+ export function initWaitBusSubscriptions(registry: ExecutorRegistry): void {
487
+ busRegistry = registry;
488
+ // Pre-existing listeners are fine — they pick up the new registry via the
489
+ // module-level `busRegistry` reference.
490
+ // Recover pending event-mode waits from DB so signals fired pre-recovery
491
+ // arrive at the right wait once the listener is registered.
492
+ // We use a dedicated DB query rather than getPendingWaitsByEvent so we can
493
+ // page through ALL distinct event names in one pass.
494
+ const pendingNames = collectPendingEventNames();
495
+ for (const name of pendingNames) {
496
+ const pending = getPendingWaitsByEvent(name);
497
+ for (const w of pending) {
498
+ registerWait(w.id, name);
499
+ }
500
+ }
501
+ }
502
+
503
+ function collectPendingEventNames(): Set<string> {
504
+ return new Set(getPendingEventWaitNames());
505
+ }
506
+
507
+ /**
508
+ * Add `waitId` to the subscription set for `eventName` and register the
509
+ * listener if not already present. Idempotent — safe to call from
510
+ * `WaitExecutor.execute`.
511
+ */
512
+ export function subscribeWaitToBus(waitId: string, eventName: string): void {
513
+ registerWait(waitId, eventName);
514
+ }
515
+
516
+ function registerWait(waitId: string, eventName: string): void {
517
+ let set = waitsByEvent.get(eventName);
518
+ if (!set) {
519
+ set = new Set();
520
+ waitsByEvent.set(eventName, set);
521
+ }
522
+ set.add(waitId);
523
+
524
+ if (!listenersByEvent.has(eventName)) {
525
+ const listener = (data: unknown) => {
526
+ // Fire-and-forget: don't block the bus thread. Errors are logged
527
+ // per-wait inside processBusEvent.
528
+ void processBusEvent(eventName, data);
529
+ };
530
+ listenersByEvent.set(eventName, listener);
531
+ workflowEventBus.on(eventName, listener);
532
+ }
533
+ }
534
+
535
+ function pruneWaitFromBus(waitId: string, eventName: string): void {
536
+ const set = waitsByEvent.get(eventName);
537
+ if (!set) return;
538
+ set.delete(waitId);
539
+ if (set.size === 0) {
540
+ waitsByEvent.delete(eventName);
541
+ const listener = listenersByEvent.get(eventName);
542
+ if (listener) {
543
+ workflowEventBus.off(eventName, listener);
544
+ listenersByEvent.delete(eventName);
545
+ }
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Bus listener body. Walks the per-event waitId set, applies scope + filter,
551
+ * resolves on match. Race-safety lives inside `resumeWaitState`.
552
+ */
553
+ async function processBusEvent(eventName: string, payload: unknown): Promise<void> {
554
+ const set = waitsByEvent.get(eventName);
555
+ if (!set || set.size === 0) return;
556
+ if (!busRegistry) return; // Pre-init — drop the event silently.
557
+
558
+ // Snapshot the set so we can mutate (prune) during iteration.
559
+ const waitIds = [...set];
560
+ for (const waitId of waitIds) {
561
+ try {
562
+ const row = getWaitStateById(waitId);
563
+ if (!row || row.status !== "pending") {
564
+ // Already resolved (race) or vanished — drop the stale subscription.
565
+ set.delete(waitId);
566
+ continue;
567
+ }
568
+
569
+ // Scope enforcement: 'run' requires payload._runId or
570
+ // payload.workflowRunId to match the wait's workflowRunId.
571
+ if (row.eventScope === "run") {
572
+ if (!isPayloadInRun(payload, row.workflowRunId)) continue;
573
+ }
574
+
575
+ // Filter match.
576
+ const ok = await matchesFilter(payload, row.eventFilter ?? undefined);
577
+ if (!ok) continue;
578
+
579
+ // Resolve via the shared helper. Race-safe: only the first caller wins.
580
+ await resumeWaitState(waitId, "fired", payload, busRegistry);
581
+ } catch (err) {
582
+ console.error(
583
+ `[workflows] Wait bus listener failed for wait=${waitId} event=${eventName}:`,
584
+ err,
585
+ );
586
+ }
587
+ }
588
+
589
+ // Clean up: if all waits for this event resolved, drop the listener.
590
+ if (set.size === 0) {
591
+ waitsByEvent.delete(eventName);
592
+ const listener = listenersByEvent.get(eventName);
593
+ if (listener) {
594
+ workflowEventBus.off(eventName, listener);
595
+ listenersByEvent.delete(eventName);
596
+ }
597
+ }
598
+ }
599
+
600
+ function isPayloadInRun(payload: unknown, runId: string): boolean {
601
+ if (typeof payload !== "object" || payload === null) return false;
602
+ const rec = payload as Record<string, unknown>;
603
+ return rec._runId === runId || rec.workflowRunId === runId;
604
+ }
605
+
606
+ // Test-only: clear in-memory subscription state. Used by unit tests that
607
+ // mount/unmount the bus across describe blocks.
608
+ export function _resetWaitBusSubscriptionsForTests(): void {
609
+ for (const [name, listener] of listenersByEvent.entries()) {
610
+ workflowEventBus.off(name, listener);
611
+ }
612
+ listenersByEvent.clear();
613
+ waitsByEvent.clear();
614
+ busRegistry = null;
615
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Wait-node event filter matcher.
3
+ *
4
+ * Filter shapes (discriminated by `typeof`):
5
+ *
6
+ * 1. **Object form** — flat key/dot-path equality. Each filter key may use
7
+ * dot-path segments (e.g. `"pr.number"`) and each value is compared by
8
+ * deep-equal (numbers/strings/booleans/arrays/objects). Missing keys or
9
+ * type mismatches → no-match. ALL keys must match.
10
+ *
11
+ * 2. **String form** — JS arrow-function body, evaluated in a sandbox. The
12
+ * body must evaluate to a function `(payload) => boolean`. Result is
13
+ * `!!`-coerced. Throws → no-match. Hard-capped at 50ms wall-clock.
14
+ *
15
+ * No filter (undefined) → matches anything.
16
+ *
17
+ * SECURITY:
18
+ * - The string-form sandbox shadows dangerous globals (incl. `eval`,
19
+ * `Function`, `AsyncFunction`) — STRICTER than `code-match` because filter
20
+ * strings are higher-volume and authored by less-trusted workflow authors.
21
+ * - Payload is `structuredClone`d before being passed to the user fn so
22
+ * side-effects on the cloned argument do not leak back to the bus payload.
23
+ * - The 50ms timeout defangs infinite loops and pathological-regex DoS.
24
+ */
25
+
26
+ // ─── Sandbox config ─────────────────────────────────────────
27
+
28
+ /**
29
+ * Globals to shadow when invoking the user filter via `new Function`.
30
+ *
31
+ * - `PARAM_SHADOW_KEYS`: passed as parameter names to `new Function` so they
32
+ * resolve to `undefined` at call time. `"use strict"` forbids `eval`,
33
+ * `arguments`, `Function`, `AsyncFunction` as parameter names — we shadow
34
+ * those via `var`-declared locals inside the function body instead.
35
+ * - `BODY_SHADOW_KEYS`: shadowed via `var X = undefined;` at the top of the
36
+ * body so identifier resolution finds the local before climbing to the
37
+ * global scope. This blocks `eval()`, `Function(...)`, and the
38
+ * constructor-chain escape `payload.constructor.constructor(...)` because
39
+ * the inner `Function` resolves to undefined.
40
+ *
41
+ * STRICTER than `src/workflows/executors/code-match.ts:19-30` because filter
42
+ * strings are higher-volume and authored by less-trusted workflow authors.
43
+ */
44
+ const PARAM_SHADOW_KEYS = [
45
+ "require",
46
+ "process",
47
+ "Bun",
48
+ "globalThis",
49
+ "global",
50
+ "fetch",
51
+ "setTimeout",
52
+ "setInterval",
53
+ ] as const;
54
+
55
+ const PARAM_SHADOW_VALUES = PARAM_SHADOW_KEYS.map(() => undefined);
56
+
57
+ // Identifier names that strict mode reserves as parameter names. Shadowed via
58
+ // `var X = undefined;` declarations at the top of the function body.
59
+ const BODY_SHADOW_KEYS = ["eval", "Function", "AsyncFunction"] as const;
60
+ const BODY_SHADOW_PREAMBLE = BODY_SHADOW_KEYS.map((k) => `var ${k} = undefined;`).join(" ");
61
+
62
+ const FILTER_TIMEOUT_MS = 50;
63
+
64
+ // ─── Object-form helpers ────────────────────────────────────
65
+
66
+ function getByDotPath(obj: unknown, path: string): { found: boolean; value: unknown } {
67
+ if (obj === null || typeof obj !== "object") {
68
+ return { found: false, value: undefined };
69
+ }
70
+ const segments = path.split(".");
71
+ let cursor: unknown = obj;
72
+ for (const seg of segments) {
73
+ if (cursor === null || typeof cursor !== "object") {
74
+ return { found: false, value: undefined };
75
+ }
76
+ const rec = cursor as Record<string, unknown>;
77
+ if (!(seg in rec)) {
78
+ return { found: false, value: undefined };
79
+ }
80
+ cursor = rec[seg];
81
+ }
82
+ return { found: true, value: cursor };
83
+ }
84
+
85
+ function deepEqual(a: unknown, b: unknown): boolean {
86
+ if (a === b) return true;
87
+ if (typeof a !== typeof b) return false;
88
+ if (a === null || b === null) return false;
89
+ if (Array.isArray(a)) {
90
+ if (!Array.isArray(b)) return false;
91
+ if (a.length !== b.length) return false;
92
+ for (let i = 0; i < a.length; i++) {
93
+ if (!deepEqual(a[i], b[i])) return false;
94
+ }
95
+ return true;
96
+ }
97
+ if (typeof a === "object" && typeof b === "object") {
98
+ const aKeys = Object.keys(a as Record<string, unknown>);
99
+ const bKeys = Object.keys(b as Record<string, unknown>);
100
+ if (aKeys.length !== bKeys.length) return false;
101
+ for (const k of aKeys) {
102
+ if (!deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) {
103
+ return false;
104
+ }
105
+ }
106
+ return true;
107
+ }
108
+ return false;
109
+ }
110
+
111
+ function matchObjectFilter(payload: unknown, filter: Record<string, unknown>): boolean {
112
+ for (const [key, expected] of Object.entries(filter)) {
113
+ const { found, value } = getByDotPath(payload, key);
114
+ if (!found) return false;
115
+ if (!deepEqual(value, expected)) return false;
116
+ }
117
+ return true;
118
+ }
119
+
120
+ // ─── String-form helpers ────────────────────────────────────
121
+
122
+ /**
123
+ * Run the user fn with a 50ms wall-clock cap. Returns `null` on timeout / throw.
124
+ *
125
+ * Note: this is a soft timeout — a tight CPU-bound loop in the user fn cannot
126
+ * be pre-empted by JS (single-threaded). The cap works because the timeout
127
+ * timer fires AFTER the synchronous fn returns or throws; if the fn never
128
+ * returns we still return `null` to the caller via the race below, but the
129
+ * fn itself keeps running on the event loop until it yields. In practice the
130
+ * sandbox blocks `setTimeout`/`setInterval`/async patterns, so an infinite
131
+ * loop will block the listener until the JIT or GC interrupts it; we accept
132
+ * that bounded risk per the plan's security note. The race ensures we DO NOT
133
+ * propagate a hung promise into the caller.
134
+ */
135
+ async function runWithTimeout<T>(fn: () => T): Promise<T | null> {
136
+ return new Promise<T | null>((resolve) => {
137
+ let settled = false;
138
+ const timer = setTimeout(() => {
139
+ if (settled) return;
140
+ settled = true;
141
+ resolve(null);
142
+ }, FILTER_TIMEOUT_MS);
143
+
144
+ // Run synchronously on the next microtask so we can race the timer.
145
+ queueMicrotask(() => {
146
+ if (settled) return;
147
+ try {
148
+ const v = fn();
149
+ // If v is a Promise, the contract says "filters are synchronous":
150
+ // attach a no-op catch IMMEDIATELY (so a sync rejection from inside
151
+ // the user fn doesn't crash the runtime) and resolve to a sentinel
152
+ // that the caller treats as "no-match". We wrap in a marker object
153
+ // so the outer `await` can't unwrap a Thenable for us.
154
+ if (
155
+ v !== null &&
156
+ typeof v === "object" &&
157
+ typeof (v as { then?: unknown }).then === "function"
158
+ ) {
159
+ (v as unknown as Promise<unknown>).catch(() => {});
160
+ settled = true;
161
+ clearTimeout(timer);
162
+ resolve({ __asyncRejected: true } as unknown as T);
163
+ return;
164
+ }
165
+ if (settled) return;
166
+ settled = true;
167
+ clearTimeout(timer);
168
+ resolve(v);
169
+ } catch {
170
+ if (settled) return;
171
+ settled = true;
172
+ clearTimeout(timer);
173
+ resolve(null);
174
+ }
175
+ });
176
+ });
177
+ }
178
+
179
+ const ASYNC_SENTINEL = "__asyncRejected";
180
+
181
+ function isAsyncSentinel(value: unknown): boolean {
182
+ return (
183
+ value !== null &&
184
+ typeof value === "object" &&
185
+ (value as Record<string, unknown>)[ASYNC_SENTINEL] === true
186
+ );
187
+ }
188
+
189
+ /**
190
+ * Compile and validate a string filter at executor-init time. Throws if the
191
+ * filter does not parse as `(${filter})`. Returns a callable.
192
+ */
193
+ export function compileStringFilter(filter: string): (payload: unknown) => unknown {
194
+ // Length cap is enforced by the Zod schema (`.max(2048)`); this is a
195
+ // defense-in-depth check.
196
+ if (filter.length > 2048) {
197
+ throw new Error(`wait filter source exceeds 2KB cap (${filter.length} bytes)`);
198
+ }
199
+ // Compile — surfaces SyntaxError early.
200
+ //
201
+ // We do NOT use `"use strict"` here because strict-mode FORBIDS declaring
202
+ // `eval`, `Function`, etc. as bindings (the very thing we need to do). We
203
+ // use sloppy mode + var-shadowing inside an IIFE-style wrapper so the user
204
+ // fn body sees `eval`, `Function`, `AsyncFunction` (and the param-shadowed
205
+ // `process`, `require`, etc.) as `undefined`.
206
+ //
207
+ // The user's fn itself is wrapped as `(filter)(payload)`. If the user
208
+ // writes their own `"use strict"` directive, it applies only inside the
209
+ // IIFE — that's fine, because by then the shadowed names are already
210
+ // bound to undefined in the enclosing scope.
211
+ let fn: (...args: unknown[]) => unknown;
212
+ try {
213
+ fn = new Function(
214
+ ...PARAM_SHADOW_KEYS,
215
+ "payload",
216
+ `${BODY_SHADOW_PREAMBLE} return (${filter})(payload);`,
217
+ ) as (...args: unknown[]) => unknown;
218
+ } catch (err) {
219
+ const msg = err instanceof Error ? err.message : String(err);
220
+ throw new Error(`wait filter compile error: ${msg}`);
221
+ }
222
+ return (payload: unknown) => fn(...PARAM_SHADOW_VALUES, payload);
223
+ }
224
+
225
+ // ─── Prototype-stripping helper ─────────────────────────────
226
+
227
+ /**
228
+ * Recursively rebuild `obj` with `null` prototypes on every plain object so
229
+ * the user fn cannot reach the global `Function` constructor via
230
+ * `payload.constructor.constructor(...)`. Arrays keep `Array.prototype` so
231
+ * `.some()`, `.map()`, etc. still work for legitimate predicates — Array's
232
+ * constructor chain still leaks `Function`, but that's a known acceptable
233
+ * trade-off (callers needing array methods accept the residual surface).
234
+ *
235
+ * For deep safety on arrays, callers can use object form filters instead.
236
+ *
237
+ * Returns primitives and arrays unchanged in shape; rebuilds objects.
238
+ */
239
+ function nullifyPrototypes(value: unknown): unknown {
240
+ if (value === null || typeof value !== "object") return value;
241
+ if (Array.isArray(value)) {
242
+ // Array methods like `.some` are useful for filter authors. Array's
243
+ // constructor IS still reachable via `payload.someArr.constructor`. The
244
+ // fix below also nullifies the per-element objects so most legitimate
245
+ // queries are safe; users who need stricter deep-blocking should use
246
+ // the object-form filter.
247
+ return value.map(nullifyPrototypes);
248
+ }
249
+ const out = Object.create(null) as Record<string, unknown>;
250
+ for (const [k, v] of Object.entries(value)) {
251
+ out[k] = nullifyPrototypes(v);
252
+ }
253
+ return out;
254
+ }
255
+
256
+ // ─── Public matcher ─────────────────────────────────────────
257
+
258
+ /**
259
+ * Returns true iff `payload` satisfies `filter`. See module-level docstring
260
+ * for filter shape semantics.
261
+ *
262
+ * NEVER throws — sandbox errors / timeouts are coerced to false.
263
+ */
264
+ export async function matchesFilter(payload: unknown, filter: unknown): Promise<boolean> {
265
+ // No filter → match everything.
266
+ if (filter === undefined || filter === null) return true;
267
+
268
+ // Object form.
269
+ if (typeof filter === "object" && !Array.isArray(filter)) {
270
+ return matchObjectFilter(payload, filter as Record<string, unknown>);
271
+ }
272
+
273
+ // String form.
274
+ if (typeof filter === "string") {
275
+ let userFn: (payload: unknown) => unknown;
276
+ try {
277
+ userFn = compileStringFilter(filter);
278
+ } catch {
279
+ return false;
280
+ }
281
+
282
+ // Defensive copy with null-prototype: prevents (a) mutation leaking back
283
+ // to the bus payload, and (b) the `payload.constructor.constructor`
284
+ // escape from reaching the global Function constructor through the
285
+ // prototype chain.
286
+ let cloned: unknown;
287
+ try {
288
+ const cloneOnce = structuredClone(payload);
289
+ cloned = nullifyPrototypes(cloneOnce);
290
+ } catch {
291
+ // Some payloads (functions, symbols) cannot be structuredClone'd —
292
+ // fall back to the raw payload but still apply nullifyPrototypes if
293
+ // possible.
294
+ try {
295
+ cloned = nullifyPrototypes(payload);
296
+ } catch {
297
+ cloned = payload;
298
+ }
299
+ }
300
+
301
+ const result = await runWithTimeout(() => userFn(cloned));
302
+ if (result === null) return false; // timeout / throw
303
+ // The runWithTimeout helper substitutes a sentinel for Promise returns
304
+ // (filters are synchronous by contract — async predicates → no-match).
305
+ if (isAsyncSentinel(result)) return false;
306
+ return !!result;
307
+ }
308
+
309
+ // Anything else (array, number, etc.) → no match.
310
+ return false;
311
+ }
@@ -0,0 +1,63 @@
1
+ import { getDueWaitStates } from "../be/db";
2
+ import type { ExecutorRegistry } from "./executors/registry";
3
+ import { resumeWaitState } from "./resume";
4
+
5
+ let pollerTimeout: ReturnType<typeof setTimeout> | null = null;
6
+
7
+ /**
8
+ * Start the wait-state poller.
9
+ *
10
+ * Mirrors `startRetryPoller`: chains `setTimeout` (NOT `setInterval`) so the
11
+ * next tick is only scheduled after the current one finishes. Default tick is
12
+ * 5s — same cadence as the retry poller.
13
+ *
14
+ * Each tick:
15
+ * 1. `getDueWaitStates()` — pending rows with `wakeUpAt <= now`
16
+ * (time mode) OR `expiresAt <= now` (event-mode timeout).
17
+ * 2. For each row, call `resumeWaitState(id, kind, undefined, registry)`.
18
+ * Time-mode rows resume as `fired`; event-mode overdue rows resume as
19
+ * `timeout`. `resumeWaitState` is race-safe (atomic UPDATE) — concurrent
20
+ * callers no-op.
21
+ *
22
+ * Errors per row are logged and the loop continues; one bad wait must not
23
+ * starve the rest.
24
+ */
25
+ export function startWaitPoller(registry: ExecutorRegistry, intervalMs = 5000): void {
26
+ if (pollerTimeout !== null) return; // Already running
27
+
28
+ async function poll(): Promise<void> {
29
+ try {
30
+ const dueWaits = getDueWaitStates();
31
+
32
+ for (const wait of dueWaits) {
33
+ try {
34
+ // Decide resume kind from wait shape:
35
+ // - time mode → fired
36
+ // - event mode + expired → timeout
37
+ // (Event-mode "fired by signal" goes through the bus listener in
38
+ // Phase 3, not the poller. The poller only handles overdue rows.)
39
+ const kind: "fired" | "timeout" = wait.mode === "time" ? "fired" : "timeout";
40
+ await resumeWaitState(wait.id, kind, undefined, registry);
41
+ } catch (err) {
42
+ console.error(`[workflows] Wait poller failed for wait ${wait.id}:`, err);
43
+ }
44
+ }
45
+ } catch (err) {
46
+ console.error("[workflows] Wait poller tick error:", err);
47
+ }
48
+
49
+ // Schedule the next tick after this one completes.
50
+ pollerTimeout = setTimeout(poll, intervalMs);
51
+ }
52
+
53
+ // Start the first tick.
54
+ pollerTimeout = setTimeout(poll, intervalMs);
55
+ }
56
+
57
+ /** Stop the wait poller (clean shutdown). */
58
+ export function stopWaitPoller(): void {
59
+ if (pollerTimeout !== null) {
60
+ clearTimeout(pollerTimeout);
61
+ pollerTimeout = null;
62
+ }
63
+ }