@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
package/src/workflows/resume.ts
CHANGED
|
@@ -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
|
+
}
|