@classytic/streamline 1.0.0 → 2.0.0

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.
@@ -1,144 +1,10 @@
1
- import { a as StepNotFoundError, c as WorkflowNotFoundError, r as InvalidStateError } from "./errors-BqunvWPz.mjs";
2
- import { n as globalEventBus, t as WorkflowEventBus } from "./events-B5aTz7kD.mjs";
1
+ import { a as logger, n as StepContextImpl, r as WaitSignal, t as GotoSignal } from "./context-DMkusPr_.mjs";
2
+ import { a as StepNotFoundError, c as WorkflowNotFoundError, r as InvalidStateError } from "./errors-Ba7ZziTN.mjs";
3
+ import { n as globalEventBus, t as WorkflowEventBus } from "./events-CpesEn3I.mjs";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { Repository, methodRegistryPlugin, mongoOperationsPlugin } from "@classytic/mongokit";
5
6
  import mongoose, { Schema } from "mongoose";
6
7
 
7
- //#region src/utils/logger.ts
8
- var Logger = class {
9
- minLevel = "info";
10
- setLevel(level) {
11
- this.minLevel = level;
12
- }
13
- debug(message, context) {
14
- this.log("debug", message, context);
15
- }
16
- info(message, context) {
17
- this.log("info", message, context);
18
- }
19
- warn(message, context) {
20
- this.log("warn", message, context);
21
- }
22
- error(message, error, context) {
23
- const errorContext = error instanceof Error ? {
24
- error: {
25
- message: error.message,
26
- stack: error.stack
27
- },
28
- ...context
29
- } : {
30
- error,
31
- ...context
32
- };
33
- this.log("error", message, errorContext);
34
- }
35
- log(level, message, context) {
36
- if (!this.shouldLog(level)) return;
37
- const logEntry = {
38
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
39
- level: level.toUpperCase(),
40
- message,
41
- ...context
42
- };
43
- this.getLogFunction(level)(JSON.stringify(logEntry));
44
- }
45
- shouldLog(level) {
46
- const levels = [
47
- "debug",
48
- "info",
49
- "warn",
50
- "error"
51
- ];
52
- return levels.indexOf(level) >= levels.indexOf(this.minLevel);
53
- }
54
- getLogFunction(level) {
55
- switch (level) {
56
- case "error": return console.error;
57
- case "warn": return console.warn;
58
- case "info": return console.info;
59
- default: return console.debug;
60
- }
61
- }
62
- };
63
- const logger = new Logger();
64
- if (process.env.NODE_ENV === "development") logger.setLevel("debug");
65
-
66
- //#endregion
67
- //#region src/execution/context.ts
68
- var WaitSignal = class extends Error {
69
- constructor(type, reason, data) {
70
- super(reason);
71
- this.type = type;
72
- this.reason = reason;
73
- this.data = data;
74
- this.name = "WaitSignal";
75
- }
76
- };
77
- var StepContextImpl = class {
78
- signal;
79
- constructor(runId, stepId, context, input, attempt, run, repository, eventBus, signal) {
80
- this.runId = runId;
81
- this.stepId = stepId;
82
- this.context = context;
83
- this.input = input;
84
- this.attempt = attempt;
85
- this.run = run;
86
- this.repository = repository;
87
- this.eventBus = eventBus;
88
- this.signal = signal ?? new AbortController().signal;
89
- }
90
- async set(key, value) {
91
- if (this.signal.aborted) throw new Error(`Cannot update context: workflow ${this.runId} has been cancelled`);
92
- this.context[key] = value;
93
- this.run.context[key] = value;
94
- if ((await this.repository.updateOne({
95
- _id: this.runId,
96
- status: { $ne: "cancelled" }
97
- }, { $set: {
98
- [`context.${String(key)}`]: value,
99
- updatedAt: /* @__PURE__ */ new Date()
100
- } }, { bypassTenant: true })).modifiedCount === 0) throw new Error(`Cannot update context: workflow ${this.runId} may have been cancelled`);
101
- }
102
- getOutput(stepId) {
103
- return this.run.steps.find((s) => s.stepId === stepId)?.output;
104
- }
105
- async wait(reason, data) {
106
- throw new WaitSignal("human", reason, data);
107
- }
108
- async waitFor(eventName, reason) {
109
- throw new WaitSignal("event", reason || `Waiting for ${eventName}`, { eventName });
110
- }
111
- async sleep(ms) {
112
- const resumeAt = new Date(Date.now() + ms);
113
- throw new WaitSignal("timer", `Sleep ${ms}ms`, { resumeAt });
114
- }
115
- async heartbeat() {
116
- if (this.signal.aborted) return;
117
- try {
118
- await this.repository.updateOne({
119
- _id: this.runId,
120
- status: { $ne: "cancelled" }
121
- }, { lastHeartbeat: /* @__PURE__ */ new Date() }, { bypassTenant: true });
122
- } catch {}
123
- }
124
- emit(eventName, data) {
125
- this.eventBus.emit(eventName, {
126
- runId: this.runId,
127
- stepId: this.stepId,
128
- data
129
- });
130
- }
131
- log(message, data) {
132
- logger.info(message, {
133
- runId: this.runId,
134
- stepId: this.stepId,
135
- attempt: this.attempt,
136
- ...data !== void 0 && { data }
137
- });
138
- }
139
- };
140
-
141
- //#endregion
142
8
  //#region src/core/status.ts
143
9
  const STEP_STATUS_VALUES = [
144
10
  "pending",
@@ -400,11 +266,12 @@ const CANCELLED_GUARD = { status: { $ne: "cancelled" } };
400
266
  var StepExecutor = class {
401
267
  /** Track active AbortControllers by runId for cancellation support */
402
268
  activeControllers = /* @__PURE__ */ new Map();
403
- constructor(registry, repository, eventBus, cache) {
269
+ constructor(registry, repository, eventBus, cache, signalStore) {
404
270
  this.registry = registry;
405
271
  this.repository = repository;
406
272
  this.eventBus = eventBus;
407
273
  this.cache = cache;
274
+ this.signalStore = signalStore;
408
275
  }
409
276
  /**
410
277
  * Abort any in-flight step execution for a workflow.
@@ -566,7 +433,7 @@ var StepExecutor = class {
566
433
  this.activeControllers.set(run._id, abortController);
567
434
  try {
568
435
  const stepState = run.steps.find((s) => s.stepId === stepId);
569
- const ctx = new StepContextImpl(run._id, stepId, run.context, run.input, stepState.attempts, run, this.repository, this.eventBus, abortController.signal);
436
+ const ctx = new StepContextImpl(run._id, stepId, run.context, run.input, stepState.attempts, run, this.repository, this.eventBus, abortController.signal, this.signalStore);
570
437
  const output = await this.executeWithTimeout(handler, ctx, step.timeout || this.registry.definition.defaults?.timeout, run._id, abortController);
571
438
  run = await this.updateStepState(run, stepId, {
572
439
  status: "done",
@@ -584,6 +451,11 @@ var StepExecutor = class {
584
451
  return await this.moveToNextStep(run);
585
452
  } catch (error) {
586
453
  if (error instanceof WaitSignal) return await this.handleWait(run, stepId, error);
454
+ else if (error instanceof GotoSignal) try {
455
+ return await this.handleGoto(run, stepId, error.targetStepId);
456
+ } catch (gotoError) {
457
+ return await this.handleFailure(run, stepId, gotoError);
458
+ }
587
459
  else if (error instanceof InvalidStateError) throw error;
588
460
  else return await this.handleFailure(run, stepId, error);
589
461
  } finally {
@@ -593,11 +465,22 @@ var StepExecutor = class {
593
465
  async executeWithTimeout(handler, ctx, timeout, runId, abortController) {
594
466
  let heartbeatTimer;
595
467
  let timeoutHandle;
596
- if (runId) heartbeatTimer = setInterval(async () => {
597
- try {
598
- await this.repository.updateOne({ _id: runId }, { lastHeartbeat: /* @__PURE__ */ new Date() }, { bypassTenant: true });
599
- } catch {}
600
- }, TIMING.HEARTBEAT_INTERVAL_MS);
468
+ if (runId) {
469
+ let consecutiveHeartbeatFailures = 0;
470
+ heartbeatTimer = setInterval(async () => {
471
+ try {
472
+ await this.repository.updateOne({ _id: runId }, { lastHeartbeat: /* @__PURE__ */ new Date() }, { bypassTenant: true });
473
+ consecutiveHeartbeatFailures = 0;
474
+ } catch (error) {
475
+ consecutiveHeartbeatFailures++;
476
+ this.eventBus.emit("engine:error", {
477
+ runId,
478
+ error: error instanceof Error ? error : new Error(String(error)),
479
+ context: consecutiveHeartbeatFailures >= 3 ? "heartbeat-critical" : "heartbeat-warning"
480
+ });
481
+ }
482
+ }, TIMING.HEARTBEAT_INTERVAL_MS);
483
+ }
601
484
  const cleanup = () => {
602
485
  if (heartbeatTimer) clearInterval(heartbeatTimer);
603
486
  if (timeoutHandle) clearTimeout(timeoutHandle);
@@ -793,6 +676,55 @@ var StepExecutor = class {
793
676
  return run;
794
677
  }
795
678
  /**
679
+ * Handle a goto signal — jump execution to a target step.
680
+ * Marks the current step as done and sets currentStepId to the target.
681
+ */
682
+ async handleGoto(run, fromStepId, targetStepId) {
683
+ if (!this.registry.getStep(targetStepId)) {
684
+ const availableSteps = this.registry.definition.steps.map((s) => s.id);
685
+ throw new StepNotFoundError(targetStepId, run.workflowId, availableSteps);
686
+ }
687
+ run = await this.updateStepState(run, fromStepId, {
688
+ status: "skipped",
689
+ endedAt: /* @__PURE__ */ new Date()
690
+ });
691
+ this.eventBus.emit("step:completed", {
692
+ runId: run._id,
693
+ stepId: fromStepId,
694
+ data: { goto: targetStepId }
695
+ });
696
+ const now = /* @__PURE__ */ new Date();
697
+ const targetIndex = this.registry.definition.steps.findIndex((s) => s.id === targetStepId);
698
+ const updates = {
699
+ currentStepId: targetStepId,
700
+ status: "running",
701
+ updatedAt: now
702
+ };
703
+ if (targetIndex !== -1) {
704
+ updates[`steps.${targetIndex}.status`] = "pending";
705
+ updates[`steps.${targetIndex}.attempts`] = 0;
706
+ const step = run.steps[targetIndex];
707
+ if (step) {
708
+ step.status = "pending";
709
+ step.attempts = 0;
710
+ step.output = void 0;
711
+ step.error = void 0;
712
+ step.startedAt = void 0;
713
+ step.endedAt = void 0;
714
+ step.waitingFor = void 0;
715
+ step.retryAfter = void 0;
716
+ }
717
+ }
718
+ await this.repository.updateOne({
719
+ _id: run._id,
720
+ status: { $ne: "cancelled" }
721
+ }, { $set: updates }, { bypassTenant: true });
722
+ run.currentStepId = targetStepId;
723
+ run.status = "running";
724
+ run.updatedAt = now;
725
+ return run;
726
+ }
727
+ /**
796
728
  * Resume a waiting step with payload.
797
729
  * Marks the step as done and continues to next step.
798
730
  *
@@ -805,7 +737,7 @@ var StepExecutor = class {
805
737
  const stepId = run.currentStepId;
806
738
  const { step: stepState } = this.findStepOrThrow(run, stepId);
807
739
  if (stepState.status !== "waiting") {
808
- const { InvalidStateError } = await import("./errors-BqunvWPz.mjs").then((n) => n.l);
740
+ const { InvalidStateError } = await import("./errors-Ba7ZziTN.mjs").then((n) => n.l);
809
741
  throw new InvalidStateError("resume step", stepState.status, ["waiting"], {
810
742
  runId: run._id,
811
743
  stepId
@@ -906,7 +838,8 @@ const DEFAULT_SCHEDULER_CONFIG = {
906
838
  adaptivePolling: true,
907
839
  maxConsecutiveFailures: SCHEDULER.MAX_CONSECUTIVE_FAILURES,
908
840
  staleCheckInterval: SCHEDULER.STALE_CHECK_INTERVAL_MS,
909
- staleThreshold: TIMING.STALE_WORKFLOW_THRESHOLD_MS
841
+ staleThreshold: TIMING.STALE_WORKFLOW_THRESHOLD_MS,
842
+ maxConcurrentExecutions: Infinity
910
843
  };
911
844
  var SmartScheduler = class {
912
845
  timers = /* @__PURE__ */ new Map();
@@ -1108,7 +1041,17 @@ var SmartScheduler = class {
1108
1041
  const startTime = Date.now();
1109
1042
  try {
1110
1043
  const now = /* @__PURE__ */ new Date();
1111
- const limit = this.config.maxWorkflowsPerPoll;
1044
+ let limit = this.config.maxWorkflowsPerPoll;
1045
+ if (this.config.maxConcurrentExecutions !== Infinity) {
1046
+ const activeCount = (await this.repository.getRunningRuns()).length;
1047
+ if (activeCount >= this.config.maxConcurrentExecutions) {
1048
+ const duration = Date.now() - startTime;
1049
+ this.metrics.recordPoll(duration, true, 0);
1050
+ return;
1051
+ }
1052
+ const availableSlots = this.config.maxConcurrentExecutions - activeCount;
1053
+ limit = Math.min(limit, availableSlots);
1054
+ }
1112
1055
  const waiting = await this.repository.getReadyToResume(now, limit);
1113
1056
  const retrying = await this.repository.getReadyForRetry(now, limit);
1114
1057
  let resumedCount = 0;
@@ -1126,17 +1069,6 @@ var SmartScheduler = class {
1126
1069
  else logger.warn(`Retry workflow detected but no retry callback set`, { runId: run._id });
1127
1070
  const scheduled = (await this.repository.getScheduledWorkflowsReadyToExecute(now, { limit })).docs || [];
1128
1071
  for (const run of scheduled) if (this.retryCallback) try {
1129
- if ((await this.repository.updateOne({
1130
- _id: run._id,
1131
- status: "draft",
1132
- "scheduling.executionTime": { $lte: now },
1133
- paused: { $ne: true }
1134
- }, {
1135
- status: "running",
1136
- startedAt: now,
1137
- updatedAt: now,
1138
- lastHeartbeat: now
1139
- }, { bypassTenant: true })).modifiedCount === 0) continue;
1140
1072
  await this.retryCallback(run._id);
1141
1073
  resumedCount++;
1142
1074
  } catch (err) {
@@ -1389,15 +1321,39 @@ var HookRegistry = class {
1389
1321
  /** Global hook registry instance */
1390
1322
  const hookRegistry = new HookRegistry();
1391
1323
  /**
1324
+ * Global registry mapping workflowId → engine.
1325
+ * Populated by createWorkflow(). Enables ctx.startChildWorkflow() to find
1326
+ * and start child workflows by ID without the caller needing a reference.
1327
+ */
1328
+ var WorkflowRegistryGlobal = class {
1329
+ engines = /* @__PURE__ */ new Map();
1330
+ register(workflowId, engine) {
1331
+ this.engines.set(workflowId, new WeakRef(engine));
1332
+ }
1333
+ getEngine(workflowId) {
1334
+ const ref = this.engines.get(workflowId);
1335
+ if (!ref) return void 0;
1336
+ const engine = ref.deref();
1337
+ if (!engine) {
1338
+ this.engines.delete(workflowId);
1339
+ return;
1340
+ }
1341
+ return engine;
1342
+ }
1343
+ };
1344
+ const workflowRegistry = new WorkflowRegistryGlobal();
1345
+ /**
1392
1346
  * Clean up all event listeners for a specific workflow
1393
1347
  */
1394
1348
  function cleanupEventListeners(runId, listeners, eventBus) {
1395
1349
  const prefix = `${runId}:`;
1396
- const keysToRemove = Array.from(listeners.keys()).filter((key) => key.startsWith(prefix));
1350
+ const signalPrefix = `signal:${runId}:`;
1351
+ const keysToRemove = Array.from(listeners.keys()).filter((key) => key.startsWith(prefix) || key.startsWith(signalPrefix));
1397
1352
  for (const key of keysToRemove) {
1398
1353
  const entry = listeners.get(key);
1399
1354
  if (entry) {
1400
- eventBus.off(entry.eventName, entry.listener);
1355
+ if (key.startsWith("signal:")) entry.listener();
1356
+ else eventBus.off(entry.eventName, entry.listener);
1401
1357
  listeners.delete(key);
1402
1358
  }
1403
1359
  }
@@ -1456,7 +1412,7 @@ var WorkflowEngine = class {
1456
1412
  this.handlers = handlers;
1457
1413
  this.container = container;
1458
1414
  this.registry = new WorkflowRegistry(definition, handlers);
1459
- this.executor = new StepExecutor(this.registry, container.repository, container.eventBus, container.cache);
1415
+ this.executor = new StepExecutor(this.registry, container.repository, container.eventBus, container.cache, container.signalStore);
1460
1416
  this.eventListeners = /* @__PURE__ */ new Map();
1461
1417
  const schedulerConfig = {
1462
1418
  ...DEFAULT_SCHEDULER_CONFIG,
@@ -1465,6 +1421,7 @@ var WorkflowEngine = class {
1465
1421
  this.scheduler = new SmartScheduler(container.repository, async (runId) => {
1466
1422
  await this.resume(runId);
1467
1423
  }, schedulerConfig, container.eventBus);
1424
+ workflowRegistry.register(definition.id, this);
1468
1425
  this.scheduler.setStaleRecoveryCallback(async (runId, thresholdMs) => {
1469
1426
  return this.recoverStale(runId, thresholdMs);
1470
1427
  });
@@ -1555,11 +1512,67 @@ var WorkflowEngine = class {
1555
1512
  if (error instanceof InvalidStateError) {
1556
1513
  const cancelled = await this.get(runId);
1557
1514
  if (cancelled) run = cancelled;
1515
+ } else if (error instanceof StepNotFoundError) {
1516
+ const stepId = run.currentStepId;
1517
+ this.container.eventBus.emit("engine:error", {
1518
+ runId,
1519
+ error: /* @__PURE__ */ new Error(`Version mismatch: workflow "${run.workflowId}" run has currentStepId="${stepId}" but this engine (v${this.definition.version}) doesn't define it. Register the old version with workflowRegistry or migrate in-flight runs.`),
1520
+ context: "version-mismatch"
1521
+ });
1522
+ const now = /* @__PURE__ */ new Date();
1523
+ await this.container.repository.updateOne({ _id: runId }, { $set: {
1524
+ status: "failed",
1525
+ updatedAt: now,
1526
+ endedAt: now,
1527
+ error: {
1528
+ message: `Step "${stepId}" not found — code version changed while workflow was in-flight`,
1529
+ code: "VERSION_MISMATCH"
1530
+ }
1531
+ } }, { bypassTenant: true });
1532
+ run.status = "failed";
1533
+ run.endedAt = now;
1534
+ run.error = {
1535
+ message: `Step "${stepId}" not found — code version changed while workflow was in-flight`,
1536
+ code: "VERSION_MISMATCH"
1537
+ };
1558
1538
  } else throw error;
1559
1539
  }
1560
1540
  if (isTerminalState(run.status)) {
1561
1541
  cleanupEventListeners(runId, this.eventListeners, this.container.eventBus);
1562
1542
  hookRegistry.unregister(runId);
1543
+ if (run.status === "failed" && this.options.compensationHandlers) run = await this.runCompensation(run);
1544
+ }
1545
+ return run;
1546
+ }
1547
+ /**
1548
+ * Run saga compensation handlers for completed steps in reverse order.
1549
+ * Called automatically when a workflow fails and compensation handlers are registered.
1550
+ */
1551
+ async runCompensation(run) {
1552
+ const compensationHandlers = this.options.compensationHandlers;
1553
+ if (!compensationHandlers) return run;
1554
+ const completedSteps = run.steps.filter((s) => s.status === "done" && compensationHandlers[s.stepId]).reverse();
1555
+ if (completedSteps.length === 0) return run;
1556
+ this.container.eventBus.emit("workflow:compensating", {
1557
+ runId: run._id,
1558
+ data: { steps: completedSteps.map((s) => s.stepId) }
1559
+ });
1560
+ for (const stepState of completedSteps) {
1561
+ const handler = compensationHandlers[stepState.stepId];
1562
+ if (!handler) continue;
1563
+ try {
1564
+ await handler(new (await (import("./context-DMkusPr_.mjs").then((n) => n.i))).StepContextImpl(run._id, stepState.stepId, run.context, run.input, stepState.attempts, run, this.container.repository, this.container.eventBus));
1565
+ this.container.eventBus.emit("step:compensated", {
1566
+ runId: run._id,
1567
+ stepId: stepState.stepId
1568
+ });
1569
+ } catch (err) {
1570
+ this.container.eventBus.emit("engine:error", {
1571
+ runId: run._id,
1572
+ error: err instanceof Error ? err : new Error(String(err)),
1573
+ context: `compensation-${stepState.stepId}`
1574
+ });
1575
+ }
1563
1576
  }
1564
1577
  return run;
1565
1578
  }
@@ -1584,6 +1597,9 @@ var WorkflowEngine = class {
1584
1597
  checkNoProgress(run, prevStepId, prevStepStatus) {
1585
1598
  const currentStep = run.steps.find((s) => s.stepId === run.currentStepId);
1586
1599
  const isRetryPending = currentStep?.status === "pending" && currentStep?.retryAfter;
1600
+ if (prevStepId) {
1601
+ if (run.steps.find((s) => s.stepId === prevStepId)?.status === "skipped") return false;
1602
+ }
1587
1603
  return run.currentStepId === prevStepId && currentStep?.status === prevStepStatus && !isRetryPending;
1588
1604
  }
1589
1605
  /**
@@ -1612,6 +1628,46 @@ var WorkflowEngine = class {
1612
1628
  }
1613
1629
  return false;
1614
1630
  }
1631
+ if (stepState.waitingFor?.type === "childWorkflow") {
1632
+ const data = stepState.waitingFor.data;
1633
+ if (data?.childWorkflowId && !data.childRunId) {
1634
+ const childEngine = workflowRegistry.getEngine(data.childWorkflowId);
1635
+ if (childEngine) {
1636
+ const childRun = await childEngine.start(data.childInput);
1637
+ const stepIndex = run.steps.findIndex((s) => s.stepId === run.currentStepId);
1638
+ if (stepIndex !== -1) await this.container.repository.updateOne({ _id: runId }, { $set: { [`steps.${stepIndex}.waitingFor.data.childRunId`]: childRun._id } }, { bypassTenant: true });
1639
+ const childCompletionHandler = async (payload) => {
1640
+ if (!payload.runId || payload.runId !== childRun._id) return;
1641
+ try {
1642
+ const completedChild = await childEngine.get(childRun._id);
1643
+ const output = completedChild?.output ?? completedChild?.context;
1644
+ await this.resume(runId, output);
1645
+ } catch {}
1646
+ this.container.eventBus.off("workflow:completed", childCompletionHandler);
1647
+ this.container.eventBus.off("workflow:failed", childFailHandler);
1648
+ };
1649
+ const childFailHandler = async (payload) => {
1650
+ if (!payload.runId || payload.runId !== childRun._id) return;
1651
+ try {
1652
+ const failedChild = await childEngine.get(childRun._id);
1653
+ await this.resume(runId, {
1654
+ __childFailed: true,
1655
+ error: failedChild?.error
1656
+ });
1657
+ } catch {}
1658
+ this.container.eventBus.off("workflow:completed", childCompletionHandler);
1659
+ this.container.eventBus.off("workflow:failed", childFailHandler);
1660
+ };
1661
+ this.container.eventBus.on("workflow:completed", childCompletionHandler);
1662
+ this.container.eventBus.on("workflow:failed", childFailHandler);
1663
+ } else this.container.eventBus.emit("engine:error", {
1664
+ runId,
1665
+ error: /* @__PURE__ */ new Error(`Child workflow '${data.childWorkflowId}' not registered. Ensure the child workflow is created with createWorkflow() before the parent starts. Or resume the parent manually when the child completes.`),
1666
+ context: "child-workflow-not-found"
1667
+ });
1668
+ }
1669
+ return false;
1670
+ }
1615
1671
  return false;
1616
1672
  }
1617
1673
  findCurrentStep(run) {
@@ -1684,6 +1740,28 @@ var WorkflowEngine = class {
1684
1740
  listener,
1685
1741
  eventName
1686
1742
  });
1743
+ const signalUnsub = this.container.signalStore.subscribe(`streamline:event:${eventName}`, (data) => {
1744
+ const signalPayload = data;
1745
+ if (!signalPayload || signalPayload.runId === runId || signalPayload.broadcast) listener(signalPayload);
1746
+ });
1747
+ const signalKey = `signal:${listenerKey}`;
1748
+ const currentEntry = this.eventListeners.get(listenerKey);
1749
+ if (currentEntry) {
1750
+ const origListener = currentEntry.listener;
1751
+ this.eventListeners.set(listenerKey, {
1752
+ ...currentEntry,
1753
+ listener: ((...args) => {
1754
+ origListener(...args);
1755
+ })
1756
+ });
1757
+ this.eventListeners.set(signalKey, {
1758
+ listener: (() => {
1759
+ if (typeof signalUnsub === "function") signalUnsub();
1760
+ else if (signalUnsub instanceof Promise) signalUnsub.then((fn) => fn());
1761
+ }),
1762
+ eventName: signalKey
1763
+ });
1764
+ }
1687
1765
  }
1688
1766
  /**
1689
1767
  * Handle retry backoff wait with inline execution for short delays
@@ -1716,9 +1794,15 @@ var WorkflowEngine = class {
1716
1794
  async resume(runId, payload) {
1717
1795
  const run = await this.getOrThrow(runId);
1718
1796
  if (run.paused) {
1797
+ if ((await this.container.repository.updateOne({
1798
+ _id: runId,
1799
+ paused: true
1800
+ }, { $set: {
1801
+ paused: false,
1802
+ updatedAt: /* @__PURE__ */ new Date()
1803
+ } }, { bypassTenant: true })).modifiedCount === 0) return await this.getOrThrow(runId);
1719
1804
  run.paused = false;
1720
1805
  run.updatedAt = /* @__PURE__ */ new Date();
1721
- await this.container.repository.update(runId, run, { bypassTenant: true });
1722
1806
  this.container.cache.set(run);
1723
1807
  }
1724
1808
  if (run.status === "waiting") return this.resumeWaitingWorkflow(run, payload);
@@ -1757,6 +1841,15 @@ var WorkflowEngine = class {
1757
1841
  updatedAt: /* @__PURE__ */ new Date()
1758
1842
  }, { bypassTenant: true })).modifiedCount === 0) return null;
1759
1843
  this.container.cache.delete(runId);
1844
+ const run = await this.get(runId);
1845
+ if (run?.currentStepId) {
1846
+ const stepIndex = run.steps.findIndex((s) => s.stepId === run.currentStepId);
1847
+ if (stepIndex !== -1 && run.steps[stepIndex]?.status === "running") await this.container.repository.updateOne({ _id: runId }, { $set: {
1848
+ [`steps.${stepIndex}.status`]: "pending",
1849
+ [`steps.${stepIndex}.startedAt`]: void 0
1850
+ } }, { bypassTenant: true });
1851
+ }
1852
+ this.container.cache.delete(runId);
1760
1853
  this.container.eventBus.emit("workflow:recovered", { runId });
1761
1854
  return this.execute(runId);
1762
1855
  }
@@ -2135,7 +2228,11 @@ const WorkflowRunSchema = new Schema({
2135
2228
  meta: Schema.Types.Mixed
2136
2229
  }, {
2137
2230
  collection: "workflow_runs",
2138
- timestamps: false
2231
+ timestamps: false,
2232
+ writeConcern: {
2233
+ w: "majority",
2234
+ j: true
2235
+ }
2139
2236
  });
2140
2237
  WorkflowRunSchema.index({
2141
2238
  workflowId: 1,
@@ -2169,6 +2266,18 @@ WorkflowRunSchema.index({
2169
2266
  "scheduling.executionTime": 1,
2170
2267
  paused: 1
2171
2268
  });
2269
+ WorkflowRunSchema.index({
2270
+ status: 1,
2271
+ paused: 1,
2272
+ updatedAt: -1,
2273
+ _id: -1
2274
+ });
2275
+ WorkflowRunSchema.index({
2276
+ status: 1,
2277
+ paused: 1,
2278
+ updatedAt: 1,
2279
+ _id: 1
2280
+ });
2172
2281
  /**
2173
2282
  * MULTI-TENANCY & SCHEDULED WORKFLOWS - COMPOSITE INDEXES
2174
2283
  *
@@ -2637,6 +2746,24 @@ const workflowRunRepository = createWorkflowRepository();
2637
2746
  * All shared dependencies are passed through this container.
2638
2747
  */
2639
2748
  /**
2749
+ * Default in-memory signal store (process-local).
2750
+ * Sufficient for single-worker deployments and testing.
2751
+ */
2752
+ var InMemorySignalStore = class {
2753
+ listeners = /* @__PURE__ */ new Map();
2754
+ publish(channel, data) {
2755
+ const handlers = this.listeners.get(channel);
2756
+ if (handlers) for (const handler of handlers) handler(data);
2757
+ }
2758
+ subscribe(channel, handler) {
2759
+ if (!this.listeners.has(channel)) this.listeners.set(channel, /* @__PURE__ */ new Set());
2760
+ this.listeners.get(channel).add(handler);
2761
+ return () => {
2762
+ this.listeners.get(channel)?.delete(handler);
2763
+ };
2764
+ }
2765
+ };
2766
+ /**
2640
2767
  * Create a new container with configurable dependencies.
2641
2768
  *
2642
2769
  * @param options - Optional configuration for container dependencies
@@ -2678,10 +2805,12 @@ function createContainer(options = {}) {
2678
2805
  else if (options.eventBus instanceof WorkflowEventBus) eventBus = options.eventBus;
2679
2806
  else eventBus = new WorkflowEventBus();
2680
2807
  const cache = options.cache ?? new WorkflowCache();
2808
+ const signalStore = options.signalStore ?? new InMemorySignalStore();
2681
2809
  return {
2682
2810
  repository,
2683
2811
  eventBus,
2684
- cache
2812
+ cache,
2813
+ signalStore
2685
2814
  };
2686
2815
  }
2687
2816
  /**
@@ -2694,4 +2823,4 @@ function isStreamlineContainer(obj) {
2694
2823
  }
2695
2824
 
2696
2825
  //#endregion
2697
- export { isValidStepTransition as A, RUN_STATUS_VALUES as C, isStepStatus as D, isRunStatus as E, logger as M, isTerminalState as O, shouldSkipStep as S, deriveRunStatus as T, COMPUTED as _, singleTenantPlugin as a, createCondition as b, RUN_STATUS as c, WorkflowRunModel as d, WorkflowCache as f, validateRetryConfig as g, validateId as h, workflowRunRepository as i, WaitSignal as j, isValidRunTransition as k, STEP_STATUS as l, hookRegistry as m, isStreamlineContainer as n, tenantFilterPlugin as o, WorkflowEngine as p, createWorkflowRepository as r, CommonQueries as s, createContainer as t, WorkflowQueryBuilder as u, SCHEDULING as v, STEP_STATUS_VALUES as w, isConditionalStep as x, conditions as y };
2826
+ export { isValidRunTransition as A, shouldSkipStep as C, isRunStatus as D, deriveRunStatus as E, isStepStatus as O, isConditionalStep as S, STEP_STATUS_VALUES as T, validateRetryConfig as _, singleTenantPlugin as a, conditions as b, RUN_STATUS as c, WorkflowRunModel as d, WorkflowCache as f, validateId as g, workflowRegistry as h, workflowRunRepository as i, isValidStepTransition as j, isTerminalState as k, STEP_STATUS as l, hookRegistry as m, isStreamlineContainer as n, tenantFilterPlugin as o, WorkflowEngine as p, createWorkflowRepository as r, CommonQueries as s, createContainer as t, WorkflowQueryBuilder as u, COMPUTED as v, RUN_STATUS_VALUES as w, createCondition as x, SCHEDULING as y };