@classytic/streamline 1.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.
@@ -0,0 +1,2697 @@
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";
3
+ import { randomUUID } from "node:crypto";
4
+ import { Repository, methodRegistryPlugin, mongoOperationsPlugin } from "@classytic/mongokit";
5
+ import mongoose, { Schema } from "mongoose";
6
+
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
+ //#region src/core/status.ts
143
+ const STEP_STATUS_VALUES = [
144
+ "pending",
145
+ "running",
146
+ "waiting",
147
+ "done",
148
+ "failed",
149
+ "skipped"
150
+ ];
151
+ const RUN_STATUS_VALUES = [
152
+ "draft",
153
+ "running",
154
+ "waiting",
155
+ "done",
156
+ "failed",
157
+ "cancelled"
158
+ ];
159
+ function isStepStatus(value) {
160
+ return typeof value === "string" && STEP_STATUS_VALUES.includes(value);
161
+ }
162
+ function isRunStatus(value) {
163
+ return typeof value === "string" && RUN_STATUS_VALUES.includes(value);
164
+ }
165
+ function deriveRunStatus(run) {
166
+ if (run.status === "cancelled") return "cancelled";
167
+ const hasWaiting = run.steps.some((s) => s.status === "waiting");
168
+ const hasFailed = run.steps.some((s) => s.status === "failed");
169
+ const allDone = run.steps.every((s) => s.status === "done" || s.status === "skipped");
170
+ if (hasFailed) return "failed";
171
+ if (hasWaiting) return "waiting";
172
+ if (allDone) return "done";
173
+ return "running";
174
+ }
175
+ function isValidStepTransition(from, to) {
176
+ return {
177
+ pending: ["running", "skipped"],
178
+ running: [
179
+ "done",
180
+ "failed",
181
+ "waiting",
182
+ "pending"
183
+ ],
184
+ waiting: [
185
+ "pending",
186
+ "done",
187
+ "failed"
188
+ ],
189
+ done: ["pending"],
190
+ failed: ["pending"],
191
+ skipped: []
192
+ }[from]?.includes(to) ?? false;
193
+ }
194
+ function isValidRunTransition(from, to) {
195
+ return {
196
+ draft: ["running", "cancelled"],
197
+ running: [
198
+ "waiting",
199
+ "done",
200
+ "failed",
201
+ "cancelled"
202
+ ],
203
+ waiting: ["running", "cancelled"],
204
+ done: ["running"],
205
+ failed: ["running"],
206
+ cancelled: []
207
+ }[from]?.includes(to) ?? false;
208
+ }
209
+ /**
210
+ * Check if a workflow status represents a terminal (final) state
211
+ */
212
+ function isTerminalState(status) {
213
+ return status === "done" || status === "failed" || status === "cancelled";
214
+ }
215
+
216
+ //#endregion
217
+ //#region src/features/conditional.ts
218
+ function isConditionalStep(step) {
219
+ const conditionalStep = step;
220
+ return !!(conditionalStep.condition || conditionalStep.skipIf || conditionalStep.runIf);
221
+ }
222
+ async function shouldSkipStep(step, context, run) {
223
+ if (step.condition) return !await Promise.resolve(step.condition(context, run));
224
+ if (step.skipIf) return await Promise.resolve(step.skipIf(context));
225
+ if (step.runIf) return !await Promise.resolve(step.runIf(context));
226
+ return false;
227
+ }
228
+ function createCondition(predicate) {
229
+ return predicate;
230
+ }
231
+ const conditions = {
232
+ hasValue: (key) => (context) => context[key] !== void 0 && context[key] !== null,
233
+ equals: (key, value) => (context) => context[key] === value,
234
+ notEquals: (key, value) => (context) => context[key] !== value,
235
+ greaterThan: (key, value) => (context) => typeof context[key] === "number" && context[key] > value,
236
+ lessThan: (key, value) => (context) => typeof context[key] === "number" && context[key] < value,
237
+ in: (key, values) => (context) => values.includes(context[key]),
238
+ and: (...predicates) => (context) => predicates.every((p) => p(context)),
239
+ or: (...predicates) => (context) => predicates.some((p) => p(context)),
240
+ not: (predicate) => (context) => !predicate(context),
241
+ custom: (predicate) => predicate
242
+ };
243
+
244
+ //#endregion
245
+ //#region src/utils/helpers.ts
246
+ /**
247
+ * Calculate retry delay with exponential backoff and jitter
248
+ *
249
+ * Jitter prevents thundering herd problem where many workflows
250
+ * retry at exactly the same time after a system failure.
251
+ *
252
+ * @param baseDelay - Starting delay (e.g., 1000ms)
253
+ * @param attempt - Current attempt number (0-indexed)
254
+ * @param multiplier - Exponential multiplier (e.g., 2 for doubling)
255
+ * @param maxDelay - Cap for maximum delay
256
+ * @param jitterFactor - Randomness factor (0.3 = ±30%)
257
+ * @returns Delay in milliseconds with applied jitter
258
+ *
259
+ * @example
260
+ * // Attempt 0: 1000ms ± 30% = 700-1300ms
261
+ * // Attempt 1: 2000ms ± 30% = 1400-2600ms
262
+ * // Attempt 2: 4000ms ± 30% = 2800-5200ms
263
+ */
264
+ function calculateRetryDelay(baseDelay, attempt, multiplier, maxDelay, jitterFactor = .3) {
265
+ const exponentialDelay = baseDelay * Math.pow(multiplier, attempt);
266
+ const cappedDelay = Math.min(exponentialDelay, maxDelay);
267
+ const jitter = 1 + (Math.random() * 2 - 1) * jitterFactor;
268
+ const delayWithJitter = Math.round(cappedDelay * jitter);
269
+ return Math.max(delayWithJitter, 0);
270
+ }
271
+
272
+ //#endregion
273
+ //#region src/config/constants.ts
274
+ /**
275
+ * System-wide timing and limit constants
276
+ * Centralized to avoid magic numbers scattered across codebase
277
+ */
278
+ const TIMING = {
279
+ HEARTBEAT_INTERVAL_MS: 3e4,
280
+ SHORT_DELAY_THRESHOLD_MS: 5e3,
281
+ STALE_WORKFLOW_THRESHOLD_MS: 300 * 1e3,
282
+ MAX_RETRY_DELAY_MS: 6e4,
283
+ RETRY_BASE_DELAY_MS: 1e3,
284
+ RETRY_MULTIPLIER: 2,
285
+ WEBHOOK_TIMEOUT_MS: 3e4,
286
+ HOOK_CLEANUP_INTERVAL_MS: 6e4
287
+ };
288
+ const LIMITS = {
289
+ MAX_CACHE_SIZE: 1e4,
290
+ DEFAULT_BATCH_SIZE: 100,
291
+ SCHEDULER_BATCH_SIZE: 100,
292
+ MAX_ID_LENGTH: 100,
293
+ MAX_STEPS_PER_WORKFLOW: 1e3,
294
+ MAX_RETRIES: 20,
295
+ MAX_STEP_TIMEOUT_MS: 1800 * 1e3
296
+ };
297
+ const RETRY = {
298
+ DEFAULT_MAX_ATTEMPTS: 3,
299
+ DEFAULT_TIMEOUT_MS: 3e4,
300
+ JITTER_FACTOR: .3
301
+ };
302
+ const SCHEDULER = {
303
+ MIN_POLL_INTERVAL_MS: 1e4,
304
+ MAX_POLL_INTERVAL_MS: 300 * 1e3,
305
+ BASE_POLL_INTERVAL_MS: 6e4,
306
+ IDLE_TIMEOUT_MS: 12e4,
307
+ MAX_CONSECUTIVE_FAILURES: 5,
308
+ STALE_CHECK_INTERVAL_MS: 300 * 1e3
309
+ };
310
+ const SCHEDULING = { PAST_SCHEDULE_GRACE_MS: 6e4 };
311
+ /**
312
+ * Computed values derived from base constants.
313
+ * Useful for monitoring, alerting, and capacity planning.
314
+ */
315
+ const COMPUTED = {
316
+ CACHE_WARNING_THRESHOLD: Math.floor(LIMITS.MAX_CACHE_SIZE * .8),
317
+ CACHE_CRITICAL_THRESHOLD: Math.floor(LIMITS.MAX_CACHE_SIZE * .95),
318
+ MAX_TIMEOUT_SAFE_MS: 2147483647,
319
+ RETRY_DELAY_SEQUENCE_MS: Array.from({ length: 5 }, (_, i) => Math.min(TIMING.RETRY_BASE_DELAY_MS * Math.pow(TIMING.RETRY_MULTIPLIER, i), TIMING.MAX_RETRY_DELAY_MS))
320
+ };
321
+
322
+ //#endregion
323
+ //#region src/execution/step-updater.ts
324
+ /**
325
+ * Build MongoDB update operators for step state changes
326
+ *
327
+ * Automatically handles:
328
+ * - undefined values → $unset (remove from document)
329
+ * - defined values → $set (update document)
330
+ * - Always updates workflow-level updatedAt timestamp
331
+ *
332
+ * @param stepIndex - Array index of step in workflow.steps[]
333
+ * @param updates - Partial step state with fields to update
334
+ * @param includeStatus - Whether to include derived status in $set
335
+ * @returns MongoDB update operators ready for updateOne()
336
+ */
337
+ function buildStepUpdateOps(stepIndex, updates, options) {
338
+ const $set = {};
339
+ const $unset = {};
340
+ for (const [key, value] of Object.entries(updates)) {
341
+ const fieldPath = `steps.${stepIndex}.${key}`;
342
+ if (value === void 0) $unset[fieldPath] = "";
343
+ else $set[fieldPath] = value;
344
+ }
345
+ if (options?.includeUpdatedAt !== false) $set.updatedAt = /* @__PURE__ */ new Date();
346
+ if (options?.includeStatus) $set.status = options.includeStatus;
347
+ return {
348
+ $set,
349
+ $unset
350
+ };
351
+ }
352
+ /**
353
+ * Apply step updates to in-memory workflow run
354
+ * Mirrors MongoDB update operations for consistency
355
+ */
356
+ function applyStepUpdates(stepId, steps, updates) {
357
+ return steps.map((step) => {
358
+ if (step.stepId !== stepId) return step;
359
+ const updated = {
360
+ ...step,
361
+ ...updates
362
+ };
363
+ for (const key in updates) if (updates[key] === void 0) delete updated[key];
364
+ return updated;
365
+ });
366
+ }
367
+ /**
368
+ * Convert Mongoose document to plain object (if needed)
369
+ * Preserves context field which Mongoose sometimes drops for empty objects
370
+ */
371
+ function toPlainRun(run) {
372
+ if ("toObject" in run && typeof run.toObject === "function") {
373
+ const savedContext = run.context;
374
+ const plain = run.toObject();
375
+ if (plain.context === void 0 && savedContext !== void 0) plain.context = savedContext;
376
+ return plain;
377
+ }
378
+ return run;
379
+ }
380
+
381
+ //#endregion
382
+ //#region src/execution/executor.ts
383
+ /**
384
+ * Cancelled is the only externally-forced terminal state.
385
+ * 'done' and 'failed' are reached through normal execution flow.
386
+ * We only guard against 'cancelled' to prevent race conditions where
387
+ * a cancelled workflow gets updated by an in-flight handler.
388
+ */
389
+ const CANCELLED_GUARD = { status: { $ne: "cancelled" } };
390
+ /**
391
+ * Executor for workflow steps.
392
+ *
393
+ * Handles step execution with support for:
394
+ * - Conditional execution (skip steps based on conditions)
395
+ * - Atomic claiming (prevents duplicate execution in multi-worker setups)
396
+ * - Retry with exponential backoff
397
+ * - Timeout handling
398
+ * - Wait states (sleep, human input, events)
399
+ */
400
+ var StepExecutor = class {
401
+ /** Track active AbortControllers by runId for cancellation support */
402
+ activeControllers = /* @__PURE__ */ new Map();
403
+ constructor(registry, repository, eventBus, cache) {
404
+ this.registry = registry;
405
+ this.repository = repository;
406
+ this.eventBus = eventBus;
407
+ this.cache = cache;
408
+ }
409
+ /**
410
+ * Abort any in-flight step execution for a workflow.
411
+ * Called by engine.cancel() to stop long-running handlers.
412
+ *
413
+ * **Multi-worker limitation**: This only aborts handlers running in this process.
414
+ * If another worker is executing the step, the abort signal won't reach it.
415
+ * However, DB-level guards prevent cancelled workflows from being updated,
416
+ * so the other worker's updates will be rejected and the workflow stays cancelled.
417
+ */
418
+ abortWorkflow(runId) {
419
+ const controller = this.activeControllers.get(runId);
420
+ if (controller) {
421
+ controller.abort(/* @__PURE__ */ new Error("Workflow cancelled"));
422
+ this.activeControllers.delete(runId);
423
+ }
424
+ }
425
+ /**
426
+ * Find a step by ID in a workflow run, throwing if not found
427
+ */
428
+ findStepOrThrow(run, stepId) {
429
+ const index = run.steps.findIndex((s) => s.stepId === stepId);
430
+ if (index === -1) {
431
+ const availableSteps = run.steps.map((s) => s.stepId);
432
+ throw new StepNotFoundError(stepId, run.workflowId, availableSteps);
433
+ }
434
+ return {
435
+ step: run.steps[index],
436
+ index
437
+ };
438
+ }
439
+ /**
440
+ * Check if step should be skipped due to conditions.
441
+ * Returns updated run if skipped, null otherwise.
442
+ */
443
+ async checkConditionalSkip(run, stepId, step) {
444
+ if (!isConditionalStep(step)) return null;
445
+ if (!await shouldSkipStep(step, run.context, run)) return null;
446
+ run = await this.updateStepState(run, stepId, {
447
+ status: "skipped",
448
+ endedAt: /* @__PURE__ */ new Date()
449
+ });
450
+ this.eventBus.emit("step:skipped", {
451
+ runId: run._id,
452
+ stepId
453
+ });
454
+ return await this.moveToNextStep(run);
455
+ }
456
+ /**
457
+ * Check if step is waiting for retry backoff.
458
+ * Returns updated run if waiting, null otherwise.
459
+ */
460
+ async checkRetryBackoff(run, currentStepState) {
461
+ if (!currentStepState.retryAfter || /* @__PURE__ */ new Date() >= currentStepState.retryAfter) return null;
462
+ if (run.status !== "waiting") {
463
+ const now = /* @__PURE__ */ new Date();
464
+ if ((await this.repository.updateOne({
465
+ _id: run._id,
466
+ ...CANCELLED_GUARD
467
+ }, {
468
+ status: "waiting",
469
+ updatedAt: now
470
+ }, { bypassTenant: true })).modifiedCount > 0) {
471
+ run.status = "waiting";
472
+ run.updatedAt = now;
473
+ } else this.cache.delete(run._id);
474
+ }
475
+ return run;
476
+ }
477
+ /**
478
+ * Check if step is already running by another worker.
479
+ * Returns refreshed run if already running, null otherwise.
480
+ */
481
+ async checkAlreadyRunning(run, currentStepState) {
482
+ if (currentStepState.status !== "running") return null;
483
+ const refreshed = await this.repository.getById(run._id);
484
+ if (!refreshed) throw new WorkflowNotFoundError(run._id);
485
+ return refreshed;
486
+ }
487
+ /**
488
+ * Main execution method for a single step.
489
+ * Orchestrates conditional checks, atomic claiming, and handler execution.
490
+ */
491
+ async executeStep(run) {
492
+ const stepId = run.currentStepId;
493
+ const step = this.registry.getStep(stepId);
494
+ const handler = this.registry.getHandler(stepId);
495
+ if (!step || !handler) {
496
+ const availableSteps = this.registry.definition.steps.map((s) => s.id);
497
+ throw new StepNotFoundError(stepId, run.workflowId, availableSteps);
498
+ }
499
+ const skippedRun = await this.checkConditionalSkip(run, stepId, step);
500
+ if (skippedRun) return skippedRun;
501
+ const currentStepState = run.steps.find((s) => s.stepId === stepId);
502
+ const waitingRun = await this.checkRetryBackoff(run, currentStepState);
503
+ if (waitingRun) return waitingRun;
504
+ const runningRun = await this.checkAlreadyRunning(run, currentStepState);
505
+ if (runningRun) return runningRun;
506
+ const claimedRun = await this.claimStepExecution(run, stepId, currentStepState);
507
+ if (!claimedRun) {
508
+ const refreshed = await this.repository.getById(run._id);
509
+ if (!refreshed) throw new WorkflowNotFoundError(run._id);
510
+ return refreshed;
511
+ }
512
+ return await this.executeStepHandler(claimedRun, stepId, step, handler);
513
+ }
514
+ /**
515
+ * Atomically claim a step for execution.
516
+ * Uses MongoDB atomic update to prevent duplicate execution in multi-worker setups.
517
+ * Returns updated run if claim succeeded, null if another worker claimed it.
518
+ */
519
+ async claimStepExecution(run, stepId, currentStepState) {
520
+ const stepIndex = run.steps.findIndex((s) => s.stepId === stepId);
521
+ if (stepIndex === -1) {
522
+ const availableSteps = this.registry.definition.steps.map((s) => s.id);
523
+ throw new StepNotFoundError(stepId, run.workflowId, availableSteps);
524
+ }
525
+ const newAttempts = currentStepState.attempts + 1;
526
+ const now = /* @__PURE__ */ new Date();
527
+ if ((await this.repository.updateOne({
528
+ _id: run._id,
529
+ ...CANCELLED_GUARD,
530
+ [`steps.${stepIndex}.status`]: { $in: ["pending", "failed"] },
531
+ $or: [{ [`steps.${stepIndex}.retryAfter`]: { $exists: false } }, { [`steps.${stepIndex}.retryAfter`]: { $lte: now } }]
532
+ }, {
533
+ $set: {
534
+ [`steps.${stepIndex}.status`]: "running",
535
+ [`steps.${stepIndex}.startedAt`]: now,
536
+ [`steps.${stepIndex}.attempts`]: newAttempts,
537
+ lastHeartbeat: now
538
+ },
539
+ $unset: {
540
+ [`steps.${stepIndex}.error`]: "",
541
+ [`steps.${stepIndex}.waitingFor`]: "",
542
+ [`steps.${stepIndex}.retryAfter`]: ""
543
+ }
544
+ }, { bypassTenant: true })).modifiedCount === 0) return null;
545
+ run = await this.updateStepState(run, stepId, {
546
+ status: "running",
547
+ startedAt: now,
548
+ attempts: newAttempts,
549
+ error: void 0,
550
+ waitingFor: void 0,
551
+ retryAfter: void 0
552
+ });
553
+ run.lastHeartbeat = now;
554
+ this.eventBus.emit("step:started", {
555
+ runId: run._id,
556
+ stepId
557
+ });
558
+ return run;
559
+ }
560
+ /**
561
+ * Execute the step handler and handle the result.
562
+ * Handles success, wait signals, and failures.
563
+ */
564
+ async executeStepHandler(run, stepId, step, handler) {
565
+ const abortController = new AbortController();
566
+ this.activeControllers.set(run._id, abortController);
567
+ try {
568
+ 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);
570
+ const output = await this.executeWithTimeout(handler, ctx, step.timeout || this.registry.definition.defaults?.timeout, run._id, abortController);
571
+ run = await this.updateStepState(run, stepId, {
572
+ status: "done",
573
+ endedAt: /* @__PURE__ */ new Date(),
574
+ output,
575
+ error: void 0,
576
+ waitingFor: void 0,
577
+ retryAfter: void 0
578
+ });
579
+ this.eventBus.emit("step:completed", {
580
+ runId: run._id,
581
+ stepId,
582
+ data: output
583
+ });
584
+ return await this.moveToNextStep(run);
585
+ } catch (error) {
586
+ if (error instanceof WaitSignal) return await this.handleWait(run, stepId, error);
587
+ else if (error instanceof InvalidStateError) throw error;
588
+ else return await this.handleFailure(run, stepId, error);
589
+ } finally {
590
+ this.activeControllers.delete(run._id);
591
+ }
592
+ }
593
+ async executeWithTimeout(handler, ctx, timeout, runId, abortController) {
594
+ let heartbeatTimer;
595
+ 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);
601
+ const cleanup = () => {
602
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
603
+ if (timeoutHandle) clearTimeout(timeoutHandle);
604
+ };
605
+ try {
606
+ if (!timeout) {
607
+ const result = await handler(ctx);
608
+ cleanup();
609
+ return result;
610
+ }
611
+ const result = await Promise.race([handler(ctx), new Promise((_, reject) => {
612
+ timeoutHandle = setTimeout(() => {
613
+ if (abortController) abortController.abort(/* @__PURE__ */ new Error(`Step timeout after ${timeout}ms`));
614
+ reject(/* @__PURE__ */ new Error(`Step timeout after ${timeout}ms`));
615
+ }, timeout);
616
+ })]);
617
+ cleanup();
618
+ return result;
619
+ } catch (error) {
620
+ cleanup();
621
+ if (abortController && !abortController.signal.aborted) abortController.abort(error);
622
+ throw error;
623
+ }
624
+ }
625
+ /**
626
+ * Handle a wait signal from a step handler.
627
+ * Updates step state to waiting and emits events.
628
+ */
629
+ async handleWait(run, stepId, signal) {
630
+ const signalData = signal.data;
631
+ const stepState = await this.updateStepState(run, stepId, {
632
+ status: "waiting",
633
+ waitingFor: {
634
+ type: signal.type,
635
+ reason: signal.reason,
636
+ resumeAt: signalData?.resumeAt,
637
+ eventName: signalData?.eventName,
638
+ data: signal.data
639
+ }
640
+ });
641
+ this.eventBus.emit("step:waiting", {
642
+ runId: run._id,
643
+ stepId,
644
+ data: signal.data
645
+ });
646
+ this.eventBus.emit("workflow:waiting", {
647
+ runId: run._id,
648
+ data: signal.data
649
+ });
650
+ return stepState;
651
+ }
652
+ /**
653
+ * Handle step failure with retry logic.
654
+ * Implements exponential backoff with jitter for retries.
655
+ * If all retries exhausted, marks step as failed.
656
+ */
657
+ async handleFailure(run, stepId, error) {
658
+ const step = this.registry.getStep(stepId);
659
+ const stepState = run.steps.find((s) => s.stepId === stepId);
660
+ const maxRetries = step?.retries ?? this.registry.definition.defaults?.retries ?? 3;
661
+ if (error.retriable !== false && stepState.attempts < maxRetries) {
662
+ const delayMs = calculateRetryDelay(TIMING.RETRY_BASE_DELAY_MS, stepState.attempts - 1, TIMING.RETRY_MULTIPLIER, TIMING.MAX_RETRY_DELAY_MS, RETRY.JITTER_FACTOR);
663
+ const retryAfter = new Date(Date.now() + delayMs);
664
+ const stepIndex = run.steps.findIndex((s) => s.stepId === stepId);
665
+ if ((await this.repository.updateOne({
666
+ _id: run._id,
667
+ ...CANCELLED_GUARD
668
+ }, {
669
+ $set: {
670
+ status: "waiting",
671
+ updatedAt: /* @__PURE__ */ new Date(),
672
+ [`steps.${stepIndex}.status`]: "pending",
673
+ [`steps.${stepIndex}.retryAfter`]: retryAfter,
674
+ [`steps.${stepIndex}.error`]: {
675
+ message: error.message,
676
+ retriable: true,
677
+ stack: error.stack
678
+ }
679
+ },
680
+ $unset: { [`steps.${stepIndex}.waitingFor`]: "" }
681
+ }, { bypassTenant: true })).modifiedCount > 0) {
682
+ run.status = "waiting";
683
+ run.steps[stepIndex].status = "pending";
684
+ run.steps[stepIndex].retryAfter = retryAfter;
685
+ run.steps[stepIndex].error = {
686
+ message: error.message,
687
+ retriable: true,
688
+ stack: error.stack
689
+ };
690
+ run.steps[stepIndex] = {
691
+ ...run.steps[stepIndex],
692
+ waitingFor: void 0
693
+ };
694
+ this.eventBus.emit("step:retry-scheduled", {
695
+ runId: run._id,
696
+ stepId,
697
+ data: {
698
+ attempt: stepState.attempts,
699
+ maxRetries,
700
+ retryAfter
701
+ }
702
+ });
703
+ } else this.cache.delete(run._id);
704
+ return run;
705
+ }
706
+ const errorWithCode = error;
707
+ const failedRun = await this.updateStepState(run, stepId, {
708
+ status: "failed",
709
+ endedAt: /* @__PURE__ */ new Date(),
710
+ error: {
711
+ message: error.message,
712
+ code: errorWithCode.code,
713
+ retriable: false,
714
+ stack: error.stack
715
+ }
716
+ });
717
+ this.eventBus.emit("step:failed", {
718
+ runId: run._id,
719
+ stepId,
720
+ data: { error }
721
+ });
722
+ this.eventBus.emit("workflow:failed", {
723
+ runId: run._id,
724
+ data: { error }
725
+ });
726
+ return failedRun;
727
+ }
728
+ /**
729
+ * Update a step's state and derive new workflow status.
730
+ * Atomically updates both in-memory and database state.
731
+ */
732
+ async updateStepState(run, stepId, updates) {
733
+ run = toPlainRun(run);
734
+ const { index: stepIndex } = this.findStepOrThrow(run, stepId);
735
+ const newStatus = deriveRunStatus({
736
+ ...run,
737
+ steps: applyStepUpdates(stepId, run.steps, updates)
738
+ });
739
+ const updateOps = buildStepUpdateOps(stepIndex, updates, { includeStatus: newStatus });
740
+ run.steps = applyStepUpdates(stepId, run.steps, updates);
741
+ run.updatedAt = /* @__PURE__ */ new Date();
742
+ run.status = newStatus;
743
+ if ((await this.repository.updateOne({
744
+ _id: run._id,
745
+ ...CANCELLED_GUARD
746
+ }, updateOps, { bypassTenant: true })).modifiedCount === 0) {
747
+ this.cache.delete(run._id);
748
+ if ((await this.repository.getById(run._id))?.status === "cancelled") throw new InvalidStateError("update step", "cancelled", [
749
+ "running",
750
+ "waiting",
751
+ "draft"
752
+ ], {
753
+ runId: run._id,
754
+ stepId
755
+ });
756
+ }
757
+ return run;
758
+ }
759
+ /**
760
+ * Move workflow to the next step or mark as complete.
761
+ * If no more steps, marks workflow as done and sets final output.
762
+ */
763
+ async moveToNextStep(run) {
764
+ const nextStep = this.registry.getNextStep(run.currentStepId);
765
+ const updates = { updatedAt: /* @__PURE__ */ new Date() };
766
+ if (nextStep) {
767
+ run.currentStepId = nextStep.id;
768
+ run.status = "running";
769
+ updates.currentStepId = nextStep.id;
770
+ updates.status = "running";
771
+ } else {
772
+ run.output = run.steps.find((s) => s.stepId === run.currentStepId)?.output;
773
+ run.currentStepId = null;
774
+ run.status = "done";
775
+ run.endedAt = /* @__PURE__ */ new Date();
776
+ updates.output = run.output;
777
+ updates.currentStepId = null;
778
+ updates.status = "done";
779
+ updates.endedAt = run.endedAt;
780
+ this.eventBus.emit("workflow:completed", {
781
+ runId: run._id,
782
+ data: run.output
783
+ });
784
+ }
785
+ run.updatedAt = updates.updatedAt;
786
+ if ((await this.repository.updateOne({
787
+ _id: run._id,
788
+ ...CANCELLED_GUARD
789
+ }, updates, { bypassTenant: true })).modifiedCount === 0) {
790
+ this.cache.delete(run._id);
791
+ if ((await this.repository.getById(run._id))?.status === "cancelled") throw new InvalidStateError("move to next step", "cancelled", ["running", "waiting"], { runId: run._id });
792
+ }
793
+ return run;
794
+ }
795
+ /**
796
+ * Resume a waiting step with payload.
797
+ * Marks the step as done and continues to next step.
798
+ *
799
+ * @param run - Workflow run to resume
800
+ * @param payload - Data to pass as step output
801
+ * @returns Updated workflow run
802
+ * @throws {InvalidStateError} If step is not in waiting state
803
+ */
804
+ async resumeStep(run, payload) {
805
+ const stepId = run.currentStepId;
806
+ const { step: stepState } = this.findStepOrThrow(run, stepId);
807
+ if (stepState.status !== "waiting") {
808
+ const { InvalidStateError } = await import("./errors-BqunvWPz.mjs").then((n) => n.l);
809
+ throw new InvalidStateError("resume step", stepState.status, ["waiting"], {
810
+ runId: run._id,
811
+ stepId
812
+ });
813
+ }
814
+ let updatedRun = await this.updateStepState(run, stepId, {
815
+ status: "done",
816
+ endedAt: /* @__PURE__ */ new Date(),
817
+ output: payload,
818
+ waitingFor: void 0
819
+ });
820
+ this.eventBus.emit("workflow:resumed", {
821
+ runId: run._id,
822
+ stepId,
823
+ data: payload
824
+ });
825
+ updatedRun = await this.moveToNextStep(updatedRun);
826
+ return updatedRun;
827
+ }
828
+ };
829
+
830
+ //#endregion
831
+ //#region src/execution/smart-scheduler.ts
832
+ /**
833
+ * Intelligent Workflow Scheduler
834
+ *
835
+ * Features:
836
+ * - Lazy start: Only polls when workflows exist
837
+ * - Auto-stop: Stops when no workflows
838
+ * - Adaptive polling: Adjusts interval based on load
839
+ * - Circuit breaker: Handles failures gracefully
840
+ * - Metrics: Tracks performance for monitoring
841
+ *
842
+ * Philosophy: Be smart, not wasteful. Iron Man, not Homer Simpson.
843
+ */
844
+ var SchedulerMetrics = class {
845
+ stats = {
846
+ totalPolls: 0,
847
+ successfulPolls: 0,
848
+ failedPolls: 0,
849
+ avgPollDuration: 0,
850
+ activeWorkflows: 0,
851
+ resumedWorkflows: 0,
852
+ missedResumes: 0,
853
+ isPolling: false,
854
+ pollInterval: 0,
855
+ uptime: 0
856
+ };
857
+ pollDurations = [];
858
+ startTime;
859
+ maxDurationsToTrack = 100;
860
+ start(pollInterval) {
861
+ this.startTime = /* @__PURE__ */ new Date();
862
+ this.stats.isPolling = true;
863
+ this.stats.pollInterval = pollInterval;
864
+ }
865
+ stop() {
866
+ this.stats.isPolling = false;
867
+ }
868
+ recordPoll(duration, success, workflowsFound) {
869
+ this.stats.totalPolls++;
870
+ this.stats.lastPollAt = /* @__PURE__ */ new Date();
871
+ if (success) this.stats.successfulPolls++;
872
+ else this.stats.failedPolls++;
873
+ this.pollDurations.push(duration);
874
+ if (this.pollDurations.length > this.maxDurationsToTrack) this.pollDurations.shift();
875
+ this.stats.avgPollDuration = this.pollDurations.reduce((sum, d) => sum + d, 0) / this.pollDurations.length;
876
+ this.stats.activeWorkflows = workflowsFound;
877
+ }
878
+ recordResume(success) {
879
+ if (success) this.stats.resumedWorkflows++;
880
+ else this.stats.missedResumes++;
881
+ }
882
+ getStats() {
883
+ if (this.startTime) this.stats.uptime = Date.now() - this.startTime.getTime();
884
+ return { ...this.stats };
885
+ }
886
+ isHealthy() {
887
+ const stats = this.getStats();
888
+ if (stats.isPolling && !stats.lastPollAt) return false;
889
+ if (stats.lastPollAt) {
890
+ if (Date.now() - stats.lastPollAt.getTime() > stats.pollInterval * 2) return false;
891
+ }
892
+ if (stats.totalPolls > 10) {
893
+ if (stats.successfulPolls / stats.totalPolls < .9) return false;
894
+ }
895
+ if (stats.missedResumes > 0) return false;
896
+ return true;
897
+ }
898
+ };
899
+ const MAX_SETTIMEOUT_DELAY = 2147483647;
900
+ const DEFAULT_SCHEDULER_CONFIG = {
901
+ basePollInterval: SCHEDULER.BASE_POLL_INTERVAL_MS,
902
+ maxPollInterval: SCHEDULER.MAX_POLL_INTERVAL_MS,
903
+ minPollInterval: SCHEDULER.MIN_POLL_INTERVAL_MS,
904
+ maxWorkflowsPerPoll: LIMITS.SCHEDULER_BATCH_SIZE,
905
+ idleTimeout: SCHEDULER.IDLE_TIMEOUT_MS,
906
+ adaptivePolling: true,
907
+ maxConsecutiveFailures: SCHEDULER.MAX_CONSECUTIVE_FAILURES,
908
+ staleCheckInterval: SCHEDULER.STALE_CHECK_INTERVAL_MS,
909
+ staleThreshold: TIMING.STALE_WORKFLOW_THRESHOLD_MS
910
+ };
911
+ var SmartScheduler = class {
912
+ timers = /* @__PURE__ */ new Map();
913
+ pollInterval;
914
+ idleTimer;
915
+ staleCheckTimer;
916
+ isPolling = false;
917
+ isStaleCheckActive = false;
918
+ currentInterval;
919
+ consecutiveFailures = 0;
920
+ lastWorkflowCount = 0;
921
+ metrics;
922
+ staleRecoveryCallback;
923
+ retryCallback;
924
+ constructor(repository, resumeCallback, config = DEFAULT_SCHEDULER_CONFIG, eventBus) {
925
+ this.repository = repository;
926
+ this.resumeCallback = resumeCallback;
927
+ this.config = config;
928
+ this.eventBus = eventBus;
929
+ this.currentInterval = config.basePollInterval;
930
+ this.metrics = new SchedulerMetrics();
931
+ }
932
+ emitError(context, error, runId) {
933
+ if (this.eventBus) this.eventBus.emit("scheduler:error", {
934
+ runId,
935
+ error: error instanceof Error ? error : new Error(String(error)),
936
+ context
937
+ });
938
+ }
939
+ /**
940
+ * Set callback for recovering stale 'running' workflows
941
+ * Separate from resume because stale recovery requires different atomic claim logic
942
+ */
943
+ setStaleRecoveryCallback(callback) {
944
+ this.staleRecoveryCallback = callback;
945
+ }
946
+ /**
947
+ * Set callback for retrying failed steps
948
+ * Separate from resume because retries need execute() (re-run step), not resumeStep() (mark done with payload)
949
+ */
950
+ setRetryCallback(callback) {
951
+ this.retryCallback = callback;
952
+ }
953
+ /**
954
+ * Intelligent start: Only starts if workflows exist
955
+ * Checks for both waiting workflows AND running workflows that might need stale recovery
956
+ */
957
+ async startIfNeeded() {
958
+ this.startStaleCheck();
959
+ if (this.isPolling) return true;
960
+ if (await this.hasActiveWorkflows()) {
961
+ this.startPolling();
962
+ return true;
963
+ }
964
+ return false;
965
+ }
966
+ /**
967
+ * Force start polling immediately
968
+ */
969
+ start() {
970
+ if (!this.isPolling) this.startPolling();
971
+ this.startStaleCheck();
972
+ }
973
+ /**
974
+ * Stop polling
975
+ */
976
+ stop() {
977
+ this.stopPolling();
978
+ this.stopStaleCheck();
979
+ }
980
+ /**
981
+ * Schedule a workflow to resume at specific time
982
+ * Handles both short delays (setTimeout) and long delays (MongoDB polling)
983
+ */
984
+ scheduleResume(runId, resumeAt) {
985
+ const delay = resumeAt.getTime() - Date.now();
986
+ if (!this.isPolling) this.startPolling();
987
+ this.resetIdleTimer();
988
+ if (delay <= 0) {
989
+ setImmediate(() => this.resumeWorkflow(runId));
990
+ return;
991
+ }
992
+ if (delay >= MAX_SETTIMEOUT_DELAY) {
993
+ logger.info(`Long delay scheduled - using MongoDB polling`, {
994
+ runId,
995
+ delayDays: Math.round(delay / 864e5)
996
+ });
997
+ return;
998
+ }
999
+ const timer = setTimeout(() => {
1000
+ this.timers.delete(runId);
1001
+ this.resumeWorkflow(runId);
1002
+ }, delay);
1003
+ timer.unref();
1004
+ this.timers.set(runId, timer);
1005
+ }
1006
+ /**
1007
+ * Cancel scheduled resume
1008
+ */
1009
+ cancelSchedule(runId) {
1010
+ const timer = this.timers.get(runId);
1011
+ if (timer) {
1012
+ clearTimeout(timer);
1013
+ this.timers.delete(runId);
1014
+ }
1015
+ }
1016
+ /**
1017
+ * Get scheduler statistics
1018
+ */
1019
+ getStats() {
1020
+ return this.metrics.getStats();
1021
+ }
1022
+ /**
1023
+ * Health check
1024
+ */
1025
+ isHealthy() {
1026
+ return this.metrics.isHealthy();
1027
+ }
1028
+ /**
1029
+ * Get current polling interval
1030
+ */
1031
+ getCurrentInterval() {
1032
+ return this.currentInterval;
1033
+ }
1034
+ startPolling() {
1035
+ if (this.isPolling) return;
1036
+ this.isPolling = true;
1037
+ this.metrics.start(this.currentInterval);
1038
+ this.schedulePoll();
1039
+ this.resetIdleTimer();
1040
+ }
1041
+ stopPolling() {
1042
+ if (!this.isPolling) return;
1043
+ this.isPolling = false;
1044
+ this.metrics.stop();
1045
+ if (this.pollInterval) {
1046
+ clearTimeout(this.pollInterval);
1047
+ this.pollInterval = void 0;
1048
+ }
1049
+ if (this.idleTimer) {
1050
+ clearTimeout(this.idleTimer);
1051
+ this.idleTimer = void 0;
1052
+ }
1053
+ this.timers.forEach((timer) => clearTimeout(timer));
1054
+ this.timers.clear();
1055
+ }
1056
+ /**
1057
+ * Start background stale workflow check
1058
+ * Runs independently of main polling loop to ensure crashed workflows are always recovered
1059
+ */
1060
+ startStaleCheck() {
1061
+ if (this.isStaleCheckActive) return;
1062
+ this.isStaleCheckActive = true;
1063
+ const scheduleNext = () => {
1064
+ if (!this.isStaleCheckActive) return;
1065
+ this.staleCheckTimer = setTimeout(async () => {
1066
+ await this.checkForStaleWorkflows();
1067
+ scheduleNext();
1068
+ }, this.config.staleCheckInterval);
1069
+ this.staleCheckTimer.unref();
1070
+ };
1071
+ scheduleNext();
1072
+ }
1073
+ /**
1074
+ * Stop background stale workflow check
1075
+ * Guarantees no further checks will be scheduled, even if one is currently running
1076
+ */
1077
+ stopStaleCheck() {
1078
+ this.isStaleCheckActive = false;
1079
+ if (this.staleCheckTimer) {
1080
+ clearTimeout(this.staleCheckTimer);
1081
+ this.staleCheckTimer = void 0;
1082
+ }
1083
+ }
1084
+ /**
1085
+ * Background check for stale workflows (runs even when scheduler is idle)
1086
+ * If stale workflows found, start the main polling loop
1087
+ */
1088
+ async checkForStaleWorkflows() {
1089
+ try {
1090
+ const staleWorkflows = await this.repository.getStaleRunningWorkflows(this.config.staleThreshold, 1);
1091
+ if (staleWorkflows.length > 0 && !this.isPolling) {
1092
+ logger.info(`Background check found stale workflows - starting polling`, { count: staleWorkflows.length });
1093
+ this.startPolling();
1094
+ }
1095
+ } catch (error) {
1096
+ this.emitError("stale-check", error);
1097
+ }
1098
+ }
1099
+ schedulePoll() {
1100
+ if (!this.isPolling) return;
1101
+ this.pollInterval = setTimeout(async () => {
1102
+ await this.poll();
1103
+ this.schedulePoll();
1104
+ }, this.currentInterval);
1105
+ this.pollInterval.unref();
1106
+ }
1107
+ async poll() {
1108
+ const startTime = Date.now();
1109
+ try {
1110
+ const now = /* @__PURE__ */ new Date();
1111
+ const limit = this.config.maxWorkflowsPerPoll;
1112
+ const waiting = await this.repository.getReadyToResume(now, limit);
1113
+ const retrying = await this.repository.getReadyForRetry(now, limit);
1114
+ let resumedCount = 0;
1115
+ for (const run of waiting) {
1116
+ if (this.timers.has(run._id)) continue;
1117
+ await this.resumeWorkflow(run._id);
1118
+ resumedCount++;
1119
+ }
1120
+ for (const run of retrying) if (this.retryCallback) try {
1121
+ await this.retryCallback(run._id);
1122
+ resumedCount++;
1123
+ } catch (err) {
1124
+ this.emitError("retry-workflow", err, run._id);
1125
+ }
1126
+ else logger.warn(`Retry workflow detected but no retry callback set`, { runId: run._id });
1127
+ const scheduled = (await this.repository.getScheduledWorkflowsReadyToExecute(now, { limit })).docs || [];
1128
+ 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
+ await this.retryCallback(run._id);
1141
+ resumedCount++;
1142
+ } catch (err) {
1143
+ this.emitError("execute-scheduled", err, run._id);
1144
+ }
1145
+ else logger.warn(`Scheduled workflow detected but no execution callback set`, { runId: run._id });
1146
+ const stale = await this.repository.getStaleRunningWorkflows(this.config.staleThreshold, limit);
1147
+ for (const run of stale) if (this.staleRecoveryCallback) try {
1148
+ await this.staleRecoveryCallback(run._id, this.config.staleThreshold);
1149
+ resumedCount++;
1150
+ } catch (err) {
1151
+ this.emitError("recover-stale", err, run._id);
1152
+ }
1153
+ else logger.warn(`Stale workflow detected but no recovery callback set`, { runId: run._id });
1154
+ const duration = Date.now() - startTime;
1155
+ const totalWorkflows = waiting.length + retrying.length + scheduled.length + stale.length;
1156
+ this.metrics.recordPoll(duration, true, totalWorkflows);
1157
+ this.consecutiveFailures = 0;
1158
+ this.lastWorkflowCount = totalWorkflows;
1159
+ if (this.config.adaptivePolling) this.adjustInterval(totalWorkflows);
1160
+ if (totalWorkflows === 0 && resumedCount === 0) {
1161
+ if (!this.idleTimer) this.resetIdleTimer();
1162
+ } else if (this.idleTimer) {
1163
+ clearTimeout(this.idleTimer);
1164
+ this.idleTimer = void 0;
1165
+ }
1166
+ } catch (error) {
1167
+ const duration = Date.now() - startTime;
1168
+ this.metrics.recordPoll(duration, false, 0);
1169
+ this.consecutiveFailures++;
1170
+ this.emitError("poll", error);
1171
+ if (this.consecutiveFailures >= this.config.maxConsecutiveFailures) {
1172
+ if (this.eventBus) this.eventBus.emit("scheduler:circuit-open", {
1173
+ error: /* @__PURE__ */ new Error(`Circuit breaker triggered after ${this.consecutiveFailures} consecutive failures`),
1174
+ context: "circuit-breaker"
1175
+ });
1176
+ this.stopPolling();
1177
+ }
1178
+ }
1179
+ }
1180
+ async resumeWorkflow(runId) {
1181
+ try {
1182
+ await this.resumeCallback(runId);
1183
+ this.metrics.recordResume(true);
1184
+ } catch (error) {
1185
+ this.emitError("resume-workflow", error, runId);
1186
+ this.metrics.recordResume(false);
1187
+ }
1188
+ }
1189
+ async hasWaitingWorkflows() {
1190
+ try {
1191
+ return (await this.repository.getWaitingRuns()).length > 0;
1192
+ } catch (error) {
1193
+ this.emitError("check-waiting", error);
1194
+ return false;
1195
+ }
1196
+ }
1197
+ /**
1198
+ * Check if there are any active workflows that need scheduler attention
1199
+ * Checks for waiting workflows (resume/retry), scheduled workflows, OR stale running workflows
1200
+ * Note: Healthy running workflows don't need the scheduler
1201
+ */
1202
+ async hasActiveWorkflows() {
1203
+ try {
1204
+ if ((await this.repository.getWaitingRuns()).length > 0) return true;
1205
+ const scheduled = await this.repository.getScheduledWorkflowsReadyToExecute(/* @__PURE__ */ new Date(), { limit: 1 });
1206
+ if (scheduled.docs && scheduled.docs.length > 0) return true;
1207
+ if ((await this.repository.getStaleRunningWorkflows(this.config.staleThreshold, 1)).length > 0) return true;
1208
+ return false;
1209
+ } catch (error) {
1210
+ this.emitError("check-active", error);
1211
+ return false;
1212
+ }
1213
+ }
1214
+ adjustInterval(workflowCount) {
1215
+ const { minPollInterval, maxPollInterval, basePollInterval } = this.config;
1216
+ if (workflowCount === 0) this.currentInterval = maxPollInterval;
1217
+ else if (workflowCount >= 100) this.currentInterval = minPollInterval;
1218
+ else if (workflowCount >= 10) this.currentInterval = basePollInterval / 2;
1219
+ else this.currentInterval = basePollInterval;
1220
+ this.metrics.start(this.currentInterval);
1221
+ }
1222
+ resetIdleTimer() {
1223
+ if (this.idleTimer) clearTimeout(this.idleTimer);
1224
+ this.idleTimer = setTimeout(async () => {
1225
+ if (!await this.hasWaitingWorkflows() && this.timers.size === 0) {
1226
+ logger.info("No workflows for idle timeout - stopping polling");
1227
+ this.stopPolling();
1228
+ }
1229
+ }, this.config.idleTimeout);
1230
+ this.idleTimer.unref();
1231
+ }
1232
+ };
1233
+
1234
+ //#endregion
1235
+ //#region src/utils/validation.ts
1236
+ /**
1237
+ * Validate workflow/step ID format
1238
+ * Must be alphanumeric with hyphens/underscores only
1239
+ */
1240
+ function validateId(id, type = "workflow") {
1241
+ if (!id || typeof id !== "string") throw new Error(`${type} ID must be a non-empty string`);
1242
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error(`${type} ID "${id}" contains invalid characters. Only alphanumeric characters, hyphens, and underscores are allowed.`);
1243
+ if (id.length > LIMITS.MAX_ID_LENGTH) throw new Error(`${type} ID "${id}" is too long (max ${LIMITS.MAX_ID_LENGTH} characters)`);
1244
+ }
1245
+ /**
1246
+ * Validate retry configuration
1247
+ */
1248
+ function validateRetryConfig(retries, timeout) {
1249
+ if (retries !== void 0 && (!Number.isInteger(retries) || retries < 0)) throw new Error(`retries must be a non-negative integer, got: ${retries}`);
1250
+ if (timeout !== void 0 && (!Number.isInteger(timeout) || timeout <= 0)) throw new Error(`timeout must be a positive integer (milliseconds), got: ${timeout}`);
1251
+ }
1252
+ /**
1253
+ * Validate workflow definition and handlers.
1254
+ * Keeps it simple - validates IDs, checks for duplicates, ensures handlers exist.
1255
+ */
1256
+ function validateWorkflowDefinition(definition, handlers) {
1257
+ if (!definition.id) throw new Error("Workflow ID is required");
1258
+ if (!definition.name) throw new Error("Workflow name is required");
1259
+ if (!definition.version) throw new Error("Workflow version is required");
1260
+ if (definition.steps.length === 0) throw new Error("Workflow must have at least one step");
1261
+ const stepIds = /* @__PURE__ */ new Set();
1262
+ for (const step of definition.steps) {
1263
+ if (stepIds.has(step.id)) throw new Error(`Duplicate step ID "${step.id}"`);
1264
+ stepIds.add(step.id);
1265
+ if (!handlers[step.id]) throw new Error(`Handler for step "${step.id}" not found`);
1266
+ }
1267
+ }
1268
+
1269
+ //#endregion
1270
+ //#region src/workflow/registry.ts
1271
+ /**
1272
+ * Registry for workflow definition and handlers.
1273
+ * Provides validation and utility methods for workflow execution.
1274
+ */
1275
+ var WorkflowRegistry = class {
1276
+ constructor(definition, handlers) {
1277
+ this.definition = definition;
1278
+ this.handlers = handlers;
1279
+ validateWorkflowDefinition(definition, handlers);
1280
+ }
1281
+ /**
1282
+ * Get step definition by ID
1283
+ */
1284
+ getStep(stepId) {
1285
+ return this.definition.steps.find((s) => s.id === stepId);
1286
+ }
1287
+ /**
1288
+ * Get step handler by ID
1289
+ */
1290
+ getHandler(stepId) {
1291
+ return this.handlers[stepId];
1292
+ }
1293
+ /**
1294
+ * Get the next step after the given step ID
1295
+ */
1296
+ getNextStep(currentStepId) {
1297
+ const currentIndex = this.definition.steps.findIndex((s) => s.id === currentStepId);
1298
+ if (currentIndex === -1 || currentIndex === this.definition.steps.length - 1) return;
1299
+ return this.definition.steps[currentIndex + 1];
1300
+ }
1301
+ /**
1302
+ * Create a new workflow run from input
1303
+ */
1304
+ createRun(input, meta) {
1305
+ const now = /* @__PURE__ */ new Date();
1306
+ const runId = randomUUID();
1307
+ const steps = this.definition.steps.map((step) => ({
1308
+ stepId: step.id,
1309
+ status: "pending",
1310
+ attempts: 0
1311
+ }));
1312
+ return {
1313
+ _id: runId,
1314
+ workflowId: this.definition.id,
1315
+ status: "draft",
1316
+ steps,
1317
+ currentStepId: this.definition.steps[0]?.id || null,
1318
+ context: this.definition.createContext(input),
1319
+ input,
1320
+ createdAt: now,
1321
+ updatedAt: now,
1322
+ meta
1323
+ };
1324
+ }
1325
+ /**
1326
+ * Rewind a workflow run to a previous step
1327
+ */
1328
+ rewindRun(run, targetStepId) {
1329
+ const targetIndex = this.definition.steps.findIndex((s) => s.id === targetStepId);
1330
+ if (targetIndex === -1) throw new Error(`Step "${targetStepId}" not found in workflow`);
1331
+ run.steps = run.steps.map((stepState, index) => {
1332
+ if (index >= targetIndex) return {
1333
+ stepId: stepState.stepId,
1334
+ status: "pending",
1335
+ attempts: 0
1336
+ };
1337
+ return stepState;
1338
+ });
1339
+ run.currentStepId = targetStepId;
1340
+ run.status = "running";
1341
+ run.updatedAt = /* @__PURE__ */ new Date();
1342
+ run.output = void 0;
1343
+ run.endedAt = void 0;
1344
+ run.error = void 0;
1345
+ return run;
1346
+ }
1347
+ };
1348
+
1349
+ //#endregion
1350
+ //#region src/execution/engine.ts
1351
+ /**
1352
+ * Registry mapping runId to the engine managing that run.
1353
+ * Enables resumeHook() to find the correct engine for resuming.
1354
+ */
1355
+ var HookRegistry = class {
1356
+ engines = /* @__PURE__ */ new Map();
1357
+ cleanupInterval = null;
1358
+ register(runId, engine) {
1359
+ this.engines.set(runId, new WeakRef(engine));
1360
+ if (!this.cleanupInterval) {
1361
+ this.cleanupInterval = setInterval(() => this.cleanup(), TIMING.HOOK_CLEANUP_INTERVAL_MS);
1362
+ this.cleanupInterval.unref();
1363
+ }
1364
+ }
1365
+ unregister(runId) {
1366
+ this.engines.delete(runId);
1367
+ }
1368
+ getEngine(runId) {
1369
+ const ref = this.engines.get(runId);
1370
+ if (!ref) return void 0;
1371
+ const engine = ref.deref();
1372
+ if (!engine) {
1373
+ this.engines.delete(runId);
1374
+ return;
1375
+ }
1376
+ return engine;
1377
+ }
1378
+ cleanup() {
1379
+ for (const [runId, ref] of this.engines) if (!ref.deref()) this.engines.delete(runId);
1380
+ }
1381
+ shutdown() {
1382
+ if (this.cleanupInterval) {
1383
+ clearInterval(this.cleanupInterval);
1384
+ this.cleanupInterval = null;
1385
+ }
1386
+ this.engines.clear();
1387
+ }
1388
+ };
1389
+ /** Global hook registry instance */
1390
+ const hookRegistry = new HookRegistry();
1391
+ /**
1392
+ * Clean up all event listeners for a specific workflow
1393
+ */
1394
+ function cleanupEventListeners(runId, listeners, eventBus) {
1395
+ const prefix = `${runId}:`;
1396
+ const keysToRemove = Array.from(listeners.keys()).filter((key) => key.startsWith(prefix));
1397
+ for (const key of keysToRemove) {
1398
+ const entry = listeners.get(key);
1399
+ if (entry) {
1400
+ eventBus.off(entry.eventName, entry.listener);
1401
+ listeners.delete(key);
1402
+ }
1403
+ }
1404
+ }
1405
+ /**
1406
+ * Handle short delay (< 5s) inline or schedule for later
1407
+ */
1408
+ async function handleShortDelayOrSchedule(runId, targetTime, scheduleLongDelay, repository, cache) {
1409
+ const delayMs = targetTime.getTime() - Date.now();
1410
+ if (delayMs > 0 && delayMs <= TIMING.SHORT_DELAY_THRESHOLD_MS) {
1411
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1412
+ const remaining = targetTime.getTime() - Date.now();
1413
+ if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining + 10));
1414
+ }
1415
+ if (delayMs <= TIMING.SHORT_DELAY_THRESHOLD_MS) {
1416
+ if ((await repository.updateOne({
1417
+ _id: runId,
1418
+ status: "waiting",
1419
+ paused: { $ne: true }
1420
+ }, {
1421
+ status: "running",
1422
+ updatedAt: /* @__PURE__ */ new Date(),
1423
+ lastHeartbeat: /* @__PURE__ */ new Date()
1424
+ }, { bypassTenant: true })).modifiedCount > 0) {
1425
+ cache.delete(runId);
1426
+ return true;
1427
+ }
1428
+ return false;
1429
+ }
1430
+ scheduleLongDelay();
1431
+ return false;
1432
+ }
1433
+ /**
1434
+ * Core workflow execution engine.
1435
+ *
1436
+ * Manages workflow lifecycle: start, execute, pause, resume, cancel.
1437
+ * Handles waiting states, retries, and crash recovery automatically.
1438
+ *
1439
+ * @typeParam TContext - Type of workflow context
1440
+ *
1441
+ * @example
1442
+ * ```typescript
1443
+ * const engine = new WorkflowEngine(definition, handlers, container);
1444
+ * const run = await engine.start({ orderId: '123' });
1445
+ * ```
1446
+ */
1447
+ var WorkflowEngine = class {
1448
+ executor;
1449
+ scheduler;
1450
+ registry;
1451
+ options;
1452
+ eventListeners;
1453
+ /** Exposed for hook registry and external access */
1454
+ container;
1455
+ constructor(definition, handlers, container, options = {}) {
1456
+ this.handlers = handlers;
1457
+ this.container = container;
1458
+ this.registry = new WorkflowRegistry(definition, handlers);
1459
+ this.executor = new StepExecutor(this.registry, container.repository, container.eventBus, container.cache);
1460
+ this.eventListeners = /* @__PURE__ */ new Map();
1461
+ const schedulerConfig = {
1462
+ ...DEFAULT_SCHEDULER_CONFIG,
1463
+ ...options.scheduler
1464
+ };
1465
+ this.scheduler = new SmartScheduler(container.repository, async (runId) => {
1466
+ await this.resume(runId);
1467
+ }, schedulerConfig, container.eventBus);
1468
+ this.scheduler.setStaleRecoveryCallback(async (runId, thresholdMs) => {
1469
+ return this.recoverStale(runId, thresholdMs);
1470
+ });
1471
+ this.scheduler.setRetryCallback(async (runId) => {
1472
+ return this.executeRetry(runId);
1473
+ });
1474
+ this.scheduler.startIfNeeded().catch((err) => {
1475
+ this.container.eventBus.emit("engine:error", {
1476
+ runId: void 0,
1477
+ error: err,
1478
+ context: "scheduler-start"
1479
+ });
1480
+ });
1481
+ this.options = {
1482
+ autoExecute: true,
1483
+ ...options
1484
+ };
1485
+ }
1486
+ /** Get the workflow definition */
1487
+ get definition() {
1488
+ return this.registry.definition;
1489
+ }
1490
+ /**
1491
+ * Start a new workflow run.
1492
+ *
1493
+ * @param input - Input data for the workflow
1494
+ * @param meta - Optional metadata (userId, tags, etc.)
1495
+ * @returns The created workflow run
1496
+ */
1497
+ async start(input, meta) {
1498
+ const run = this.registry.createRun(input, meta);
1499
+ run.status = "running";
1500
+ run.startedAt = /* @__PURE__ */ new Date();
1501
+ await this.container.repository.create(run);
1502
+ this.container.cache.set(run);
1503
+ hookRegistry.register(run._id, this);
1504
+ this.container.eventBus.emit("workflow:started", { runId: run._id });
1505
+ if (this.options.autoExecute) setImmediate(() => this.execute(run._id).catch((err) => {
1506
+ this.container.eventBus.emit("engine:error", {
1507
+ runId: run._id,
1508
+ error: err,
1509
+ context: "auto-execution"
1510
+ });
1511
+ }));
1512
+ return run;
1513
+ }
1514
+ /**
1515
+ * Get a workflow run by ID.
1516
+ * Returns from cache if available, otherwise fetches from database.
1517
+ *
1518
+ * @param runId - Workflow run ID
1519
+ * @returns The workflow run or null if not found
1520
+ */
1521
+ async get(runId) {
1522
+ const cached = this.container.cache.get(runId);
1523
+ if (cached) return cached;
1524
+ return await this.container.repository.getById(runId);
1525
+ }
1526
+ /**
1527
+ * Execute a workflow run to completion.
1528
+ *
1529
+ * Runs steps sequentially until:
1530
+ * - All steps complete (status: 'done')
1531
+ * - A step fails after retries (status: 'failed')
1532
+ * - A step waits for external input (status: 'waiting')
1533
+ * - The workflow is cancelled (status: 'cancelled')
1534
+ *
1535
+ * @param runId - Workflow run ID to execute
1536
+ * @returns The updated workflow run
1537
+ */
1538
+ async execute(runId) {
1539
+ let run = await this.getOrThrow(runId);
1540
+ try {
1541
+ while (this.shouldContinueExecution(run)) {
1542
+ const { run: updatedRun, shouldBreak } = await this.executeNextStep(run);
1543
+ run = updatedRun;
1544
+ if (shouldBreak) break;
1545
+ if (run.status === "waiting") {
1546
+ if (!await this.handleWaitingState(runId, run)) break;
1547
+ run = await this.get(runId);
1548
+ }
1549
+ if (isTerminalState(run.status)) {
1550
+ cleanupEventListeners(runId, this.eventListeners, this.container.eventBus);
1551
+ break;
1552
+ }
1553
+ }
1554
+ } catch (error) {
1555
+ if (error instanceof InvalidStateError) {
1556
+ const cancelled = await this.get(runId);
1557
+ if (cancelled) run = cancelled;
1558
+ } else throw error;
1559
+ }
1560
+ if (isTerminalState(run.status)) {
1561
+ cleanupEventListeners(runId, this.eventListeners, this.container.eventBus);
1562
+ hookRegistry.unregister(runId);
1563
+ }
1564
+ return run;
1565
+ }
1566
+ async getOrThrow(runId) {
1567
+ const run = await this.get(runId);
1568
+ if (!run) throw new WorkflowNotFoundError(runId);
1569
+ return run;
1570
+ }
1571
+ shouldContinueExecution(run) {
1572
+ return run.status === "running" && run.currentStepId !== null;
1573
+ }
1574
+ async executeNextStep(run) {
1575
+ const prevStepId = run.currentStepId;
1576
+ const prevStepStatus = run.steps.find((s) => s.stepId === prevStepId)?.status;
1577
+ const updatedRun = await this.executor.executeStep(run);
1578
+ this.container.cache.set(updatedRun);
1579
+ return {
1580
+ run: updatedRun,
1581
+ shouldBreak: this.checkNoProgress(updatedRun, prevStepId, prevStepStatus)
1582
+ };
1583
+ }
1584
+ checkNoProgress(run, prevStepId, prevStepStatus) {
1585
+ const currentStep = run.steps.find((s) => s.stepId === run.currentStepId);
1586
+ const isRetryPending = currentStep?.status === "pending" && currentStep?.retryAfter;
1587
+ return run.currentStepId === prevStepId && currentStep?.status === prevStepStatus && !isRetryPending;
1588
+ }
1589
+ /**
1590
+ * Handle different waiting states (event, retry, timer, human input)
1591
+ * @returns true if execution should continue, false to break
1592
+ */
1593
+ async handleWaitingState(runId, run) {
1594
+ const stepState = this.findCurrentStep(run);
1595
+ if (!stepState && run.currentStepId) {
1596
+ await this.failCorruption(runId, run);
1597
+ cleanupEventListeners(runId, this.eventListeners, this.container.eventBus);
1598
+ return false;
1599
+ }
1600
+ if (!stepState) return false;
1601
+ if (stepState.waitingFor?.type === "event" && stepState.waitingFor.eventName) {
1602
+ this.handleEventWait(runId, stepState.waitingFor.eventName);
1603
+ return false;
1604
+ }
1605
+ if (stepState.status === "pending" && stepState.retryAfter) return this.handleRetryWait(runId, stepState.retryAfter);
1606
+ if (stepState.waitingFor?.type === "timer" && stepState.waitingFor.resumeAt) {
1607
+ if (await this.handleTimerWait(runId, stepState.waitingFor.resumeAt)) {
1608
+ const refreshedRun = await this.getOrThrow(runId);
1609
+ const updatedRun = await this.executor.resumeStep(refreshedRun, void 0);
1610
+ this.container.cache.set(updatedRun);
1611
+ return true;
1612
+ }
1613
+ return false;
1614
+ }
1615
+ return false;
1616
+ }
1617
+ findCurrentStep(run) {
1618
+ return run.steps.find((s) => s.stepId === run.currentStepId);
1619
+ }
1620
+ /**
1621
+ * Handle data corruption scenario (missing step state)
1622
+ */
1623
+ async failCorruption(runId, run) {
1624
+ const errorMsg = `Data corruption: currentStepId '${run.currentStepId}' not found in steps`;
1625
+ this.container.eventBus.emit("engine:error", {
1626
+ runId,
1627
+ error: new Error(errorMsg),
1628
+ context: "data-corruption"
1629
+ });
1630
+ const now = /* @__PURE__ */ new Date();
1631
+ const errorPayload = {
1632
+ message: errorMsg,
1633
+ code: "DATA_CORRUPTION"
1634
+ };
1635
+ await this.container.repository.updateOne({ _id: runId }, {
1636
+ status: "failed",
1637
+ updatedAt: now,
1638
+ endedAt: now,
1639
+ error: errorPayload
1640
+ }, { bypassTenant: true });
1641
+ run.status = "failed";
1642
+ run.updatedAt = now;
1643
+ run.endedAt = now;
1644
+ run.error = errorPayload;
1645
+ this.container.eventBus.emit("workflow:failed", {
1646
+ runId,
1647
+ data: { error: errorPayload }
1648
+ });
1649
+ return run;
1650
+ }
1651
+ /**
1652
+ * Register event listener for event-based waits
1653
+ * IMPORTANT: Paused workflows will NOT be resumed by events until explicitly resumed by user
1654
+ */
1655
+ handleEventWait(runId, eventName) {
1656
+ const listenerKey = `${runId}:${eventName}`;
1657
+ if (this.eventListeners.has(listenerKey)) return;
1658
+ const listener = async (...args) => {
1659
+ const payload = args[0];
1660
+ try {
1661
+ if (payload && !payload.runId && !payload.broadcast) logger.warn(`Event '${eventName}' emitted without runId or broadcast flag. This will resume ALL workflows waiting on '${eventName}'.`, {
1662
+ runId,
1663
+ eventName
1664
+ });
1665
+ if (!payload || payload.runId === runId || payload.broadcast === true) {
1666
+ if ((await this.get(runId))?.paused) return;
1667
+ const entry = this.eventListeners.get(listenerKey);
1668
+ if (entry) {
1669
+ this.container.eventBus.off(entry.eventName, entry.listener);
1670
+ this.eventListeners.delete(listenerKey);
1671
+ }
1672
+ await this.resume(runId, payload?.data);
1673
+ }
1674
+ } catch (err) {
1675
+ this.container.eventBus.emit("engine:error", {
1676
+ runId,
1677
+ error: err,
1678
+ context: "event-handler"
1679
+ });
1680
+ }
1681
+ };
1682
+ this.container.eventBus.on(eventName, listener);
1683
+ this.eventListeners.set(listenerKey, {
1684
+ listener,
1685
+ eventName
1686
+ });
1687
+ }
1688
+ /**
1689
+ * Handle retry backoff wait with inline execution for short delays
1690
+ * @returns true if workflow should continue execution immediately
1691
+ */
1692
+ async handleRetryWait(runId, retryAfter) {
1693
+ return handleShortDelayOrSchedule(runId, retryAfter, () => this.scheduler.start(), this.container.repository, this.container.cache);
1694
+ }
1695
+ /**
1696
+ * Handle timer-based wait (sleep) with inline execution for short delays
1697
+ * @returns true if workflow should continue execution immediately
1698
+ */
1699
+ async handleTimerWait(runId, resumeAt) {
1700
+ return handleShortDelayOrSchedule(runId, resumeAt, () => {
1701
+ this.scheduler.scheduleResume(runId, resumeAt);
1702
+ }, this.container.repository, this.container.cache);
1703
+ }
1704
+ /**
1705
+ * Resume a paused or waiting workflow.
1706
+ *
1707
+ * For waiting workflows:
1708
+ * - If waiting for human input: payload is passed as the step output
1709
+ * - If waiting for timer/retry: continues execution from current step
1710
+ *
1711
+ * @param runId - Workflow run ID to resume
1712
+ * @param payload - Data to pass to the waiting step (becomes step output)
1713
+ * @returns The updated workflow run
1714
+ * @throws {InvalidStateError} If workflow is not in waiting/running state
1715
+ */
1716
+ async resume(runId, payload) {
1717
+ const run = await this.getOrThrow(runId);
1718
+ if (run.paused) {
1719
+ run.paused = false;
1720
+ run.updatedAt = /* @__PURE__ */ new Date();
1721
+ await this.container.repository.update(runId, run, { bypassTenant: true });
1722
+ this.container.cache.set(run);
1723
+ }
1724
+ if (run.status === "waiting") return this.resumeWaitingWorkflow(run, payload);
1725
+ if (run.status === "running") return this.execute(runId);
1726
+ throw new InvalidStateError("resume workflow", run.status, ["waiting", "running"], { runId });
1727
+ }
1728
+ async resumeWaitingWorkflow(run, payload) {
1729
+ const runId = run._id;
1730
+ const currentStepId = run.currentStepId;
1731
+ if (currentStepId) {
1732
+ if (run.steps.find((s) => s.stepId === currentStepId)?.status === "waiting") {
1733
+ cleanupEventListeners(runId, this.eventListeners, this.container.eventBus);
1734
+ const updatedRun = await this.executor.resumeStep(run, payload);
1735
+ this.container.cache.set(updatedRun);
1736
+ return this.execute(runId);
1737
+ }
1738
+ }
1739
+ run.status = "running";
1740
+ run.lastHeartbeat = /* @__PURE__ */ new Date();
1741
+ await this.container.repository.update(runId, run, { bypassTenant: true });
1742
+ this.container.cache.set(run);
1743
+ return this.execute(runId);
1744
+ }
1745
+ /**
1746
+ * Recover a stale 'running' workflow (crashed mid-execution)
1747
+ * Uses atomic claim to prevent multiple servers from recovering the same workflow
1748
+ */
1749
+ async recoverStale(runId, staleThresholdMs) {
1750
+ const staleTime = new Date(Date.now() - staleThresholdMs);
1751
+ if ((await this.container.repository.updateOne({
1752
+ _id: runId,
1753
+ status: "running",
1754
+ $or: [{ lastHeartbeat: { $lt: staleTime } }, { lastHeartbeat: { $exists: false } }]
1755
+ }, {
1756
+ lastHeartbeat: /* @__PURE__ */ new Date(),
1757
+ updatedAt: /* @__PURE__ */ new Date()
1758
+ }, { bypassTenant: true })).modifiedCount === 0) return null;
1759
+ this.container.cache.delete(runId);
1760
+ this.container.eventBus.emit("workflow:recovered", { runId });
1761
+ return this.execute(runId);
1762
+ }
1763
+ /**
1764
+ * Execute a retry for a workflow that failed and is waiting for backoff timer
1765
+ * Uses atomic claim to prevent multiple servers from retrying the same workflow
1766
+ */
1767
+ async executeRetry(runId) {
1768
+ const now = /* @__PURE__ */ new Date();
1769
+ let claimResult = await this.container.repository.updateOne({
1770
+ _id: runId,
1771
+ status: "waiting",
1772
+ paused: { $ne: true },
1773
+ steps: { $elemMatch: {
1774
+ status: "pending",
1775
+ retryAfter: { $lte: now }
1776
+ } }
1777
+ }, { $set: {
1778
+ status: "running",
1779
+ updatedAt: now,
1780
+ lastHeartbeat: now
1781
+ } }, { bypassTenant: true });
1782
+ if (claimResult.modifiedCount === 0) claimResult = await this.container.repository.updateOne({
1783
+ _id: runId,
1784
+ status: "draft",
1785
+ "scheduling.executionTime": { $lte: now },
1786
+ paused: { $ne: true }
1787
+ }, { $set: {
1788
+ status: "running",
1789
+ updatedAt: now,
1790
+ lastHeartbeat: now,
1791
+ startedAt: now
1792
+ } }, { bypassTenant: true });
1793
+ if (claimResult.modifiedCount === 0) return null;
1794
+ this.container.cache.delete(runId);
1795
+ hookRegistry.register(runId, this);
1796
+ this.container.eventBus.emit("workflow:retry", { runId });
1797
+ return this.execute(runId);
1798
+ }
1799
+ /**
1800
+ * Rewind a workflow to a previous step.
1801
+ * Resets all steps from target step onwards to pending state.
1802
+ *
1803
+ * @param runId - Workflow run ID
1804
+ * @param stepId - Step ID to rewind to
1805
+ * @returns The rewound workflow run
1806
+ */
1807
+ async rewindTo(runId, stepId) {
1808
+ const run = await this.getOrThrow(runId);
1809
+ const rewoundRun = this.registry.rewindRun(run, stepId);
1810
+ await this.container.repository.update(runId, rewoundRun, { bypassTenant: true });
1811
+ this.container.cache.set(rewoundRun);
1812
+ return rewoundRun;
1813
+ }
1814
+ /**
1815
+ * Cancel a running workflow.
1816
+ * Cleans up all resources and marks workflow as cancelled.
1817
+ *
1818
+ * @param runId - Workflow run ID to cancel
1819
+ * @returns The cancelled workflow run
1820
+ */
1821
+ async cancel(runId) {
1822
+ const run = await this.getOrThrow(runId);
1823
+ this.executor.abortWorkflow(runId);
1824
+ const cancelledRun = {
1825
+ ...run,
1826
+ status: "cancelled",
1827
+ endedAt: /* @__PURE__ */ new Date(),
1828
+ updatedAt: /* @__PURE__ */ new Date()
1829
+ };
1830
+ await this.container.repository.update(runId, cancelledRun, { bypassTenant: true });
1831
+ this.scheduler.cancelSchedule(runId);
1832
+ cleanupEventListeners(runId, this.eventListeners, this.container.eventBus);
1833
+ hookRegistry.unregister(runId);
1834
+ this.container.cache.delete(runId);
1835
+ this.container.eventBus.emit("workflow:cancelled", { runId });
1836
+ return cancelledRun;
1837
+ }
1838
+ /**
1839
+ * Pause a workflow run.
1840
+ *
1841
+ * Sets the `paused` flag to prevent the scheduler from processing this workflow.
1842
+ * Paused workflows can be resumed later with `resume()`.
1843
+ *
1844
+ * @param runId - Workflow run ID to pause
1845
+ * @returns The paused workflow run
1846
+ */
1847
+ async pause(runId) {
1848
+ const run = await this.getOrThrow(runId);
1849
+ if (isTerminalState(run.status)) return run;
1850
+ if (run.paused) return run;
1851
+ const pausedRun = {
1852
+ ...run,
1853
+ paused: true,
1854
+ updatedAt: /* @__PURE__ */ new Date()
1855
+ };
1856
+ await this.container.repository.update(runId, pausedRun, { bypassTenant: true });
1857
+ this.scheduler.cancelSchedule(runId);
1858
+ this.container.cache.set(pausedRun);
1859
+ return pausedRun;
1860
+ }
1861
+ /**
1862
+ * Configure engine options (scheduler settings, etc.)
1863
+ */
1864
+ configure(options) {
1865
+ if (options.scheduler) {
1866
+ const currentConfig = {
1867
+ ...DEFAULT_SCHEDULER_CONFIG,
1868
+ ...this.options.scheduler,
1869
+ ...options.scheduler
1870
+ };
1871
+ this.scheduler.stop();
1872
+ this.scheduler = new SmartScheduler(this.container.repository, async (runId) => {
1873
+ await this.resume(runId);
1874
+ }, currentConfig, this.container.eventBus);
1875
+ this.scheduler.setStaleRecoveryCallback(async (runId, thresholdMs) => {
1876
+ return this.recoverStale(runId, thresholdMs);
1877
+ });
1878
+ this.scheduler.setRetryCallback(async (runId) => {
1879
+ return this.executeRetry(runId);
1880
+ });
1881
+ this.options.scheduler = currentConfig;
1882
+ }
1883
+ }
1884
+ shutdown() {
1885
+ this.scheduler.stop();
1886
+ for (const [, entry] of this.eventListeners.entries()) this.container.eventBus.off(entry.eventName, entry.listener);
1887
+ this.eventListeners.clear();
1888
+ }
1889
+ /**
1890
+ * Get scheduler statistics for monitoring
1891
+ */
1892
+ getSchedulerStats() {
1893
+ return this.scheduler.getStats();
1894
+ }
1895
+ /**
1896
+ * Check if scheduler is healthy
1897
+ */
1898
+ isSchedulerHealthy() {
1899
+ return this.scheduler.isHealthy();
1900
+ }
1901
+ };
1902
+
1903
+ //#endregion
1904
+ //#region src/storage/cache.ts
1905
+ /**
1906
+ * O(1) LRU cache using Map's insertion-order iteration
1907
+ *
1908
+ * Map maintains insertion order, so we use delete+set to move items to the end.
1909
+ * - set/get/delete: O(1)
1910
+ * - eviction: O(1) - just delete first key
1911
+ * - Only caches active workflows (running/waiting)
1912
+ */
1913
+ var WorkflowCache = class {
1914
+ maxSize;
1915
+ cache = /* @__PURE__ */ new Map();
1916
+ constructor(maxSize = LIMITS.MAX_CACHE_SIZE) {
1917
+ this.maxSize = maxSize;
1918
+ }
1919
+ set(run) {
1920
+ if (!this.isActive(run.status)) {
1921
+ this.cache.delete(run._id);
1922
+ return;
1923
+ }
1924
+ const exists = this.cache.has(run._id);
1925
+ if (exists) this.cache.delete(run._id);
1926
+ if (!exists && this.cache.size >= this.maxSize) {
1927
+ const oldest = this.cache.keys().next().value;
1928
+ if (oldest) this.cache.delete(oldest);
1929
+ }
1930
+ this.cache.set(run._id, run);
1931
+ }
1932
+ get(runId) {
1933
+ const run = this.cache.get(runId);
1934
+ if (!run) return null;
1935
+ this.cache.delete(runId);
1936
+ this.cache.set(runId, run);
1937
+ return run;
1938
+ }
1939
+ delete(runId) {
1940
+ this.cache.delete(runId);
1941
+ }
1942
+ clear() {
1943
+ this.cache.clear();
1944
+ }
1945
+ getActive() {
1946
+ return Array.from(this.cache.values());
1947
+ }
1948
+ size() {
1949
+ return this.cache.size;
1950
+ }
1951
+ getStats() {
1952
+ return {
1953
+ size: this.cache.size,
1954
+ maxSize: this.maxSize,
1955
+ utilizationPercent: Math.round(this.cache.size / this.maxSize * 100)
1956
+ };
1957
+ }
1958
+ /**
1959
+ * Check if cache is approaching capacity.
1960
+ * Useful for monitoring and proactive scaling.
1961
+ *
1962
+ * @param threshold - Utilization ratio (0-1), default 0.8 (80%)
1963
+ */
1964
+ isNearCapacity(threshold = .8) {
1965
+ return this.cache.size / this.maxSize >= threshold;
1966
+ }
1967
+ /**
1968
+ * Get cache health status for monitoring dashboards.
1969
+ * Returns status and human-readable message.
1970
+ */
1971
+ getHealth() {
1972
+ const utilization = this.cache.size / this.maxSize;
1973
+ const utilizationPercent = Math.round(utilization * 100);
1974
+ if (this.cache.size < COMPUTED.CACHE_WARNING_THRESHOLD) return {
1975
+ status: "healthy",
1976
+ message: `Cache at ${utilizationPercent}% capacity`,
1977
+ utilizationPercent
1978
+ };
1979
+ if (this.cache.size < COMPUTED.CACHE_CRITICAL_THRESHOLD) return {
1980
+ status: "warning",
1981
+ message: `Cache at ${utilizationPercent}% - consider increasing maxSize`,
1982
+ utilizationPercent
1983
+ };
1984
+ return {
1985
+ status: "critical",
1986
+ message: `Cache at ${utilizationPercent}% - frequent evictions may impact performance`,
1987
+ utilizationPercent
1988
+ };
1989
+ }
1990
+ isActive(status) {
1991
+ return status === "running" || status === "waiting";
1992
+ }
1993
+ };
1994
+ /** Singleton for test utilities - each workflow creates its own cache via container */
1995
+ const workflowCache = new WorkflowCache();
1996
+
1997
+ //#endregion
1998
+ //#region src/storage/run.model.ts
1999
+ const StepStateSchema = new Schema({
2000
+ stepId: {
2001
+ type: String,
2002
+ required: true
2003
+ },
2004
+ status: {
2005
+ type: String,
2006
+ enum: [
2007
+ "pending",
2008
+ "running",
2009
+ "waiting",
2010
+ "done",
2011
+ "failed",
2012
+ "skipped"
2013
+ ],
2014
+ required: true
2015
+ },
2016
+ attempts: {
2017
+ type: Number,
2018
+ default: 0
2019
+ },
2020
+ startedAt: Date,
2021
+ endedAt: Date,
2022
+ output: Schema.Types.Mixed,
2023
+ waitingFor: {
2024
+ type: Schema.Types.Mixed,
2025
+ required: false
2026
+ },
2027
+ error: {
2028
+ type: Schema.Types.Mixed,
2029
+ required: false
2030
+ },
2031
+ retryAfter: Date
2032
+ }, { _id: false });
2033
+ const RecurrencePatternSchema = new Schema({
2034
+ pattern: {
2035
+ type: String,
2036
+ enum: [
2037
+ "daily",
2038
+ "weekly",
2039
+ "monthly",
2040
+ "custom"
2041
+ ],
2042
+ required: true
2043
+ },
2044
+ daysOfWeek: [Number],
2045
+ dayOfMonth: Number,
2046
+ cronExpression: String,
2047
+ until: Date,
2048
+ count: Number,
2049
+ occurrences: {
2050
+ type: Number,
2051
+ default: 0
2052
+ }
2053
+ }, { _id: false });
2054
+ const SchedulingInfoSchema = new Schema({
2055
+ scheduledFor: {
2056
+ type: String,
2057
+ required: true
2058
+ },
2059
+ timezone: {
2060
+ type: String,
2061
+ required: true
2062
+ },
2063
+ localTimeDisplay: {
2064
+ type: String,
2065
+ required: true
2066
+ },
2067
+ executionTime: {
2068
+ type: Date,
2069
+ required: true,
2070
+ index: true
2071
+ },
2072
+ isDSTTransition: {
2073
+ type: Boolean,
2074
+ default: false
2075
+ },
2076
+ dstNote: String,
2077
+ recurrence: RecurrencePatternSchema
2078
+ }, { _id: false });
2079
+ const WorkflowRunSchema = new Schema({
2080
+ _id: {
2081
+ type: String,
2082
+ required: true
2083
+ },
2084
+ workflowId: {
2085
+ type: String,
2086
+ required: true,
2087
+ index: true
2088
+ },
2089
+ status: {
2090
+ type: String,
2091
+ enum: [
2092
+ "draft",
2093
+ "running",
2094
+ "waiting",
2095
+ "done",
2096
+ "failed",
2097
+ "cancelled"
2098
+ ],
2099
+ required: true,
2100
+ index: true
2101
+ },
2102
+ steps: [StepStateSchema],
2103
+ currentStepId: String,
2104
+ context: {
2105
+ type: Schema.Types.Mixed,
2106
+ default: {}
2107
+ },
2108
+ input: Schema.Types.Mixed,
2109
+ output: Schema.Types.Mixed,
2110
+ error: {
2111
+ type: Schema.Types.Mixed,
2112
+ required: false
2113
+ },
2114
+ createdAt: {
2115
+ type: Date,
2116
+ required: true
2117
+ },
2118
+ updatedAt: {
2119
+ type: Date,
2120
+ required: true
2121
+ },
2122
+ startedAt: Date,
2123
+ endedAt: Date,
2124
+ lastHeartbeat: Date,
2125
+ paused: {
2126
+ type: Boolean,
2127
+ default: false
2128
+ },
2129
+ scheduling: SchedulingInfoSchema,
2130
+ userId: {
2131
+ type: String,
2132
+ index: true
2133
+ },
2134
+ tags: [String],
2135
+ meta: Schema.Types.Mixed
2136
+ }, {
2137
+ collection: "workflow_runs",
2138
+ timestamps: false
2139
+ });
2140
+ WorkflowRunSchema.index({
2141
+ workflowId: 1,
2142
+ status: 1
2143
+ });
2144
+ WorkflowRunSchema.index({
2145
+ status: 1,
2146
+ updatedAt: -1
2147
+ });
2148
+ WorkflowRunSchema.index({
2149
+ userId: 1,
2150
+ createdAt: -1
2151
+ });
2152
+ WorkflowRunSchema.index({ "steps.stepId": 1 });
2153
+ WorkflowRunSchema.index({
2154
+ status: 1,
2155
+ "steps.status": 1,
2156
+ "steps.waitingFor.resumeAt": 1
2157
+ });
2158
+ WorkflowRunSchema.index({
2159
+ status: 1,
2160
+ "steps.status": 1,
2161
+ "steps.retryAfter": 1
2162
+ });
2163
+ WorkflowRunSchema.index({
2164
+ status: 1,
2165
+ lastHeartbeat: 1
2166
+ });
2167
+ WorkflowRunSchema.index({
2168
+ status: 1,
2169
+ "scheduling.executionTime": 1,
2170
+ paused: 1
2171
+ });
2172
+ /**
2173
+ * MULTI-TENANCY & SCHEDULED WORKFLOWS - COMPOSITE INDEXES
2174
+ *
2175
+ * For multi-tenant scheduled workflows, add composite indexes with tenantId FIRST.
2176
+ * MongoDB can only use indexes if the query matches the prefix pattern.
2177
+ *
2178
+ * Example: Multi-tenant scheduled workflow polling
2179
+ * ```typescript
2180
+ * import { WorkflowRunModel } from '@classytic/streamline';
2181
+ *
2182
+ * // Add composite index: tenantId first, then scheduling fields
2183
+ * WorkflowRunModel.collection.createIndex({
2184
+ * 'context.tenantId': 1,
2185
+ * status: 1,
2186
+ * 'scheduling.executionTime': 1,
2187
+ * paused: 1
2188
+ * });
2189
+ *
2190
+ * // This query will use the index efficiently:
2191
+ * const scheduledWorkflows = await WorkflowRunModel.find({
2192
+ * 'context.tenantId': 'tenant123',
2193
+ * status: 'draft',
2194
+ * 'scheduling.executionTime': { $lte: new Date() },
2195
+ * paused: { $ne: true }
2196
+ * }).sort({ 'scheduling.executionTime': 1 }).limit(100);
2197
+ * ```
2198
+ *
2199
+ * Example: List workflows by tenant and time
2200
+ * ```typescript
2201
+ * WorkflowRunModel.collection.createIndex({
2202
+ * 'context.tenantId': 1,
2203
+ * workflowId: 1,
2204
+ * createdAt: -1
2205
+ * });
2206
+ * ```
2207
+ *
2208
+ * IMPORTANT: Put tenantId FIRST in all multi-tenant indexes for query efficiency.
2209
+ */
2210
+ /**
2211
+ * MULTI-TENANCY & CUSTOM INDEXES
2212
+ *
2213
+ * The engine is unopinionated about multi-tenancy. Add indexes for YOUR needs:
2214
+ *
2215
+ * Option 1: Add tenantId to metadata and index it
2216
+ * ```typescript
2217
+ * import { WorkflowRunModel } from '@classytic/streamline';
2218
+ *
2219
+ * // Add custom index for tenant-scoped queries
2220
+ * WorkflowRunModel.collection.createIndex({ 'meta.tenantId': 1, status: 1 });
2221
+ * WorkflowRunModel.collection.createIndex({ 'meta.orgId': 1, createdAt: -1 });
2222
+ * ```
2223
+ *
2224
+ * Option 2: Extend the schema (before first use)
2225
+ * ```typescript
2226
+ * import { WorkflowRunModel } from '@classytic/streamline';
2227
+ *
2228
+ * WorkflowRunModel.schema.add({
2229
+ * tenantId: { type: String, index: true }
2230
+ * });
2231
+ * WorkflowRunModel.schema.index({ tenantId: 1, status: 1 });
2232
+ * ```
2233
+ *
2234
+ * Then query: engine.get(runId) and filter by tenantId in your app layer
2235
+ */
2236
+ /**
2237
+ * Export WorkflowRunModel with hot-reload safety
2238
+ *
2239
+ * The pattern checks if the model already exists before creating a new one.
2240
+ * This prevents "OverwriteModelError" in development with hot module replacement.
2241
+ */
2242
+ let WorkflowRunModel;
2243
+ if (mongoose.models.WorkflowRun) WorkflowRunModel = mongoose.models.WorkflowRun;
2244
+ else WorkflowRunModel = mongoose.model("WorkflowRun", WorkflowRunSchema);
2245
+
2246
+ //#endregion
2247
+ //#region src/storage/query-builder.ts
2248
+ const RUN_STATUS = {
2249
+ DRAFT: "draft",
2250
+ RUNNING: "running",
2251
+ WAITING: "waiting",
2252
+ DONE: "done",
2253
+ FAILED: "failed",
2254
+ CANCELLED: "cancelled"
2255
+ };
2256
+ const STEP_STATUS = {
2257
+ PENDING: "pending",
2258
+ RUNNING: "running",
2259
+ WAITING: "waiting",
2260
+ DONE: "done",
2261
+ FAILED: "failed",
2262
+ SKIPPED: "skipped"
2263
+ };
2264
+ var WorkflowQueryBuilder = class WorkflowQueryBuilder {
2265
+ query = {};
2266
+ static create() {
2267
+ return new WorkflowQueryBuilder();
2268
+ }
2269
+ withStatus(status) {
2270
+ this.query.status = Array.isArray(status) ? { $in: status } : status;
2271
+ return this;
2272
+ }
2273
+ notPaused() {
2274
+ this.query.paused = { $ne: true };
2275
+ return this;
2276
+ }
2277
+ isPaused() {
2278
+ this.query.paused = true;
2279
+ return this;
2280
+ }
2281
+ withWorkflowId(workflowId) {
2282
+ this.query.workflowId = workflowId;
2283
+ return this;
2284
+ }
2285
+ withRunId(runId) {
2286
+ this.query._id = runId;
2287
+ return this;
2288
+ }
2289
+ withUserId(userId) {
2290
+ this.query.userId = userId;
2291
+ return this;
2292
+ }
2293
+ withTags(tags) {
2294
+ this.query.tags = Array.isArray(tags) ? { $all: tags } : tags;
2295
+ return this;
2296
+ }
2297
+ withStepReady(stepStatus, field, beforeTime) {
2298
+ this.query.steps = { $elemMatch: {
2299
+ status: stepStatus,
2300
+ [field]: { $lte: beforeTime }
2301
+ } };
2302
+ return this;
2303
+ }
2304
+ withRetryReady(now = /* @__PURE__ */ new Date()) {
2305
+ return this.withStepReady(STEP_STATUS.PENDING, "retryAfter", now);
2306
+ }
2307
+ withTimerReady(now = /* @__PURE__ */ new Date()) {
2308
+ this.query.steps = { $elemMatch: {
2309
+ status: STEP_STATUS.WAITING,
2310
+ "waitingFor.type": "timer",
2311
+ "waitingFor.resumeAt": { $lte: now }
2312
+ } };
2313
+ return this;
2314
+ }
2315
+ withStaleHeartbeat(thresholdMs) {
2316
+ const staleTime = new Date(Date.now() - thresholdMs);
2317
+ this.query.$or = [{ lastHeartbeat: { $lt: staleTime } }, { lastHeartbeat: { $exists: false } }];
2318
+ return this;
2319
+ }
2320
+ withScheduledBefore(time) {
2321
+ this.query["scheduling.executionTime"] = { $lte: time };
2322
+ return this;
2323
+ }
2324
+ withScheduledAfter(time) {
2325
+ this.query["scheduling.executionTime"] = { $gte: time };
2326
+ return this;
2327
+ }
2328
+ createdBefore(date) {
2329
+ const existing = this.query.createdAt ?? {};
2330
+ this.query.createdAt = {
2331
+ ...existing,
2332
+ $lte: date
2333
+ };
2334
+ return this;
2335
+ }
2336
+ createdAfter(date) {
2337
+ const existing = this.query.createdAt ?? {};
2338
+ this.query.createdAt = {
2339
+ ...existing,
2340
+ $gte: date
2341
+ };
2342
+ return this;
2343
+ }
2344
+ where(conditions) {
2345
+ Object.assign(this.query, conditions);
2346
+ return this;
2347
+ }
2348
+ build() {
2349
+ return this.query;
2350
+ }
2351
+ };
2352
+ const CommonQueries = {
2353
+ active: () => WorkflowQueryBuilder.create().withStatus([RUN_STATUS.RUNNING, RUN_STATUS.WAITING]).notPaused().build(),
2354
+ readyForRetry: (now) => WorkflowQueryBuilder.create().withStatus(RUN_STATUS.WAITING).notPaused().withRetryReady(now).build(),
2355
+ readyToResume: (now) => WorkflowQueryBuilder.create().withStatus(RUN_STATUS.WAITING).notPaused().withTimerReady(now).build(),
2356
+ staleRunning: (thresholdMs) => WorkflowQueryBuilder.create().withStatus(RUN_STATUS.RUNNING).notPaused().withStaleHeartbeat(thresholdMs).build(),
2357
+ scheduledReady: (now) => WorkflowQueryBuilder.create().withStatus(RUN_STATUS.DRAFT).notPaused().withScheduledBefore(now ?? /* @__PURE__ */ new Date()).build(),
2358
+ byUser: (userId, status) => {
2359
+ const builder = WorkflowQueryBuilder.create().withUserId(userId);
2360
+ return status ? builder.withStatus(status).build() : builder.build();
2361
+ },
2362
+ failed: () => WorkflowQueryBuilder.create().withStatus(RUN_STATUS.FAILED).build(),
2363
+ completed: () => WorkflowQueryBuilder.create().withStatus(RUN_STATUS.DONE).build()
2364
+ };
2365
+
2366
+ //#endregion
2367
+ //#region src/plugins/tenant-filter.plugin.ts
2368
+ /**
2369
+ * Tenant filter plugin factory
2370
+ *
2371
+ * Creates a plugin that automatically injects tenant filters into all queries.
2372
+ * Prevents cross-tenant data leaks by enforcing tenant isolation at repository level.
2373
+ *
2374
+ * @param options - Plugin configuration options
2375
+ * @returns MongoKit plugin instance
2376
+ *
2377
+ * @example Multi-Tenant with Strict Mode
2378
+ * ```typescript
2379
+ * const plugin = tenantFilterPlugin({
2380
+ * tenantField: 'context.tenantId',
2381
+ * strict: true,
2382
+ * allowBypass: false // No bypasses allowed
2383
+ * });
2384
+ * ```
2385
+ *
2386
+ * @example Single-Tenant with Static ID
2387
+ * ```typescript
2388
+ * const plugin = tenantFilterPlugin({
2389
+ * tenantField: 'context.tenantId',
2390
+ * staticTenantId: process.env.ORGANIZATION_ID
2391
+ * });
2392
+ * ```
2393
+ */
2394
+ function tenantFilterPlugin(options = {}) {
2395
+ const tenantField = options.tenantField || "context.tenantId";
2396
+ const staticTenantId = options.staticTenantId;
2397
+ const strict = options.strict !== false;
2398
+ const allowBypass = options.allowBypass !== false;
2399
+ return {
2400
+ name: "tenantFilter",
2401
+ apply(repo) {
2402
+ /**
2403
+ * Inject tenant filter into context
2404
+ * Validates tenantId presence and builds filter object
2405
+ */
2406
+ const injectTenantFilter = (context) => {
2407
+ if (context.bypassTenant) {
2408
+ if (!allowBypass) throw new Error("[tenantFilterPlugin] Tenant bypass not allowed (allowBypass: false)");
2409
+ return;
2410
+ }
2411
+ const tenantId = context.tenantId || staticTenantId;
2412
+ if (!tenantId && strict) throw new Error(`[tenantFilterPlugin] Missing tenantId in ${context.operation} operation. Pass 'tenantId' in query options or set 'staticTenantId' in plugin config.`);
2413
+ if (!tenantId) return;
2414
+ const tenantFilter = { [tenantField]: tenantId };
2415
+ if (context.operation === "getAll") context.filters = {
2416
+ ...context.filters,
2417
+ ...tenantFilter
2418
+ };
2419
+ else if (context.operation === "getById" || context.operation === "getByQuery" || context.operation === "update" || context.operation === "delete") context.query = {
2420
+ ...context.query || {},
2421
+ ...tenantFilter
2422
+ };
2423
+ else if (context.operation === "aggregatePaginate") {
2424
+ const pipeline = context.pipeline;
2425
+ if (Array.isArray(pipeline)) pipeline.unshift({ $match: tenantFilter });
2426
+ else context.pipeline = [{ $match: tenantFilter }];
2427
+ }
2428
+ };
2429
+ repo.on("before:getAll", injectTenantFilter);
2430
+ repo.on("before:getById", injectTenantFilter);
2431
+ repo.on("before:getByQuery", injectTenantFilter);
2432
+ repo.on("before:update", injectTenantFilter);
2433
+ repo.on("before:delete", injectTenantFilter);
2434
+ repo.on("before:aggregatePaginate", injectTenantFilter);
2435
+ repo.on("before:create", (context) => {
2436
+ if (context.bypassTenant && allowBypass) return;
2437
+ const tenantId = context.tenantId || staticTenantId;
2438
+ if (!tenantId && strict) throw new Error("[tenantFilterPlugin] Missing tenantId in create operation. Pass 'tenantId' in options or set 'staticTenantId' in plugin config.");
2439
+ if (tenantId) {
2440
+ const data = context.data;
2441
+ if (data) {
2442
+ const fieldParts = tenantField.split(".");
2443
+ if (fieldParts.length === 1) data[tenantField] = tenantId;
2444
+ else {
2445
+ let current = data;
2446
+ for (let i = 0; i < fieldParts.length - 1; i++) {
2447
+ const part = fieldParts[i];
2448
+ if (!current[part] || typeof current[part] !== "object") current[part] = {};
2449
+ current = current[part];
2450
+ }
2451
+ current[fieldParts[fieldParts.length - 1]] = tenantId;
2452
+ }
2453
+ }
2454
+ }
2455
+ });
2456
+ repo.on("before:createMany", (context) => {
2457
+ if (context.bypassTenant && allowBypass) return;
2458
+ const tenantId = context.tenantId || staticTenantId;
2459
+ if (!tenantId && strict) throw new Error("[tenantFilterPlugin] Missing tenantId in createMany operation. Pass 'tenantId' in options or set 'staticTenantId' in plugin config.");
2460
+ if (tenantId) {
2461
+ const dataArray = context.dataArray;
2462
+ if (Array.isArray(dataArray)) dataArray.forEach((data) => {
2463
+ const fieldParts = tenantField.split(".");
2464
+ if (fieldParts.length === 1) data[tenantField] = tenantId;
2465
+ else {
2466
+ let current = data;
2467
+ for (let i = 0; i < fieldParts.length - 1; i++) {
2468
+ const part = fieldParts[i];
2469
+ if (!current[part] || typeof current[part] !== "object") current[part] = {};
2470
+ current = current[part];
2471
+ }
2472
+ current[fieldParts[fieldParts.length - 1]] = tenantId;
2473
+ }
2474
+ });
2475
+ }
2476
+ });
2477
+ }
2478
+ };
2479
+ }
2480
+ /**
2481
+ * Helper to create single-tenant repository (syntactic sugar)
2482
+ *
2483
+ * @param tenantId - Static tenant ID for all operations
2484
+ * @param tenantField - Field path for tenant ID (default: 'context.tenantId')
2485
+ * @returns Plugin configuration for single-tenant mode
2486
+ *
2487
+ * @example
2488
+ * ```typescript
2489
+ * import { singleTenantPlugin } from './plugins/tenant-filter.plugin';
2490
+ *
2491
+ * const repo = new Repository(Model, [
2492
+ * singleTenantPlugin('my-organization-id')
2493
+ * ]);
2494
+ * ```
2495
+ */
2496
+ function singleTenantPlugin(tenantId, tenantField = "context.tenantId") {
2497
+ return tenantFilterPlugin({
2498
+ tenantField,
2499
+ staticTenantId: tenantId,
2500
+ strict: true,
2501
+ allowBypass: false
2502
+ });
2503
+ }
2504
+
2505
+ //#endregion
2506
+ //#region src/storage/run.repository.ts
2507
+ /**
2508
+ * Workflow Run Repository
2509
+ *
2510
+ * MongoKit-powered repository with multi-tenant support and atomic operations.
2511
+ */
2512
+ var WorkflowRepository = class {
2513
+ repo;
2514
+ tenantField;
2515
+ isMultiTenant;
2516
+ isStrictTenant;
2517
+ constructor(config = {}) {
2518
+ this.isMultiTenant = !!config.multiTenant;
2519
+ this.isStrictTenant = config.multiTenant?.strict !== false;
2520
+ this.tenantField = config.multiTenant?.tenantField || "context.tenantId";
2521
+ this.repo = new Repository(WorkflowRunModel, [
2522
+ methodRegistryPlugin(),
2523
+ mongoOperationsPlugin(),
2524
+ ...config.multiTenant ? [tenantFilterPlugin(config.multiTenant)] : []
2525
+ ]);
2526
+ }
2527
+ async create(run) {
2528
+ return this.repo.create(run);
2529
+ }
2530
+ async getById(id) {
2531
+ return this.repo.getById(id);
2532
+ }
2533
+ getAll(...args) {
2534
+ return this.repo.getAll(...args);
2535
+ }
2536
+ async update(id, data, options) {
2537
+ if (options?.bypassTenant) {
2538
+ const result = await WorkflowRunModel.findByIdAndUpdate(id, data, {
2539
+ returnDocument: "after",
2540
+ runValidators: true,
2541
+ lean: true
2542
+ });
2543
+ if (!result) throw new Error(`Workflow run "${id}" not found`);
2544
+ return result;
2545
+ }
2546
+ return this.repo.update(id, data);
2547
+ }
2548
+ delete(...args) {
2549
+ return this.repo.delete(...args);
2550
+ }
2551
+ async updateOne(filter, update, options) {
2552
+ const finalFilter = this.applyTenantFilter(filter, options, "updateOne");
2553
+ const finalUpdate = Object.keys(update).some((k) => k.startsWith("$")) ? update : { $set: update };
2554
+ return { modifiedCount: (await WorkflowRunModel.updateOne(finalFilter, finalUpdate)).modifiedCount };
2555
+ }
2556
+ async getActiveRuns() {
2557
+ return this.queryLean(CommonQueries.active(), "-updatedAt", 1e3);
2558
+ }
2559
+ async getRunsByWorkflow(workflowId, limit = 100) {
2560
+ return this.queryLean({ workflowId }, "-createdAt", limit);
2561
+ }
2562
+ async getWaitingRuns() {
2563
+ return this.queryLean({
2564
+ status: "waiting",
2565
+ paused: { $ne: true }
2566
+ }, "-updatedAt", 1e3);
2567
+ }
2568
+ async getRunningRuns() {
2569
+ return this.queryLean({ status: "running" }, "-updatedAt", 1e3);
2570
+ }
2571
+ async getReadyToResume(now, limit = 100) {
2572
+ return this.queryLean(CommonQueries.readyToResume(now), { updatedAt: 1 }, limit);
2573
+ }
2574
+ async getReadyForRetry(now, limit = 100) {
2575
+ return this.queryLean(CommonQueries.readyForRetry(now), { updatedAt: 1 }, limit);
2576
+ }
2577
+ async getStaleRunningWorkflows(staleThresholdMs, limit = 100) {
2578
+ return this.queryLean(CommonQueries.staleRunning(staleThresholdMs), { updatedAt: 1 }, limit);
2579
+ }
2580
+ async getScheduledWorkflowsReadyToExecute(now, options = {}) {
2581
+ const { page = 1, limit = 100, cursor, tenantId } = options;
2582
+ return await this.repo.getAll({
2583
+ filters: CommonQueries.scheduledReady(now),
2584
+ sort: { "scheduling.executionTime": 1 },
2585
+ page,
2586
+ limit,
2587
+ cursor: cursor ?? void 0,
2588
+ ...tenantId && { tenantId }
2589
+ }, { lean: true });
2590
+ }
2591
+ get base() {
2592
+ return this.repo;
2593
+ }
2594
+ get _hooks() {
2595
+ return this.repo._hooks;
2596
+ }
2597
+ async queryLean(filters, sort, limit) {
2598
+ return (await this.repo.getAll({
2599
+ filters,
2600
+ sort,
2601
+ limit
2602
+ }, { lean: true })).docs;
2603
+ }
2604
+ applyTenantFilter(filter, options, operation) {
2605
+ if (!this.isMultiTenant || options?.bypassTenant) return filter;
2606
+ if (this.isStrictTenant && !options?.tenantId) throw new Error(`[WorkflowRepository.${operation}] tenantId required in multi-tenant mode. Pass { tenantId } or { bypassTenant: true }.`);
2607
+ if (!options?.tenantId) return filter;
2608
+ return {
2609
+ ...filter,
2610
+ [this.tenantField]: options.tenantId
2611
+ };
2612
+ }
2613
+ };
2614
+ /**
2615
+ * Create a workflow repository instance.
2616
+ *
2617
+ * @example
2618
+ * // Single-tenant:
2619
+ * const repo = createWorkflowRepository();
2620
+ *
2621
+ * // Multi-tenant:
2622
+ * const repo = createWorkflowRepository({
2623
+ * multiTenant: { tenantField: 'context.tenantId', strict: true }
2624
+ * });
2625
+ */
2626
+ function createWorkflowRepository(config = {}) {
2627
+ return new WorkflowRepository(config);
2628
+ }
2629
+ const workflowRunRepository = createWorkflowRepository();
2630
+
2631
+ //#endregion
2632
+ //#region src/core/container.ts
2633
+ /**
2634
+ * Dependency Injection Container
2635
+ *
2636
+ * Enables testability and multi-instance support by eliminating global singletons.
2637
+ * All shared dependencies are passed through this container.
2638
+ */
2639
+ /**
2640
+ * Create a new container with configurable dependencies.
2641
+ *
2642
+ * @param options - Optional configuration for container dependencies
2643
+ * @returns A container with the specified or default dependencies
2644
+ *
2645
+ * @example Default container (isolated instances)
2646
+ * ```typescript
2647
+ * const container = createContainer();
2648
+ * ```
2649
+ *
2650
+ * @example Multi-tenant container
2651
+ * ```typescript
2652
+ * const container = createContainer({
2653
+ * repository: { multiTenant: { tenantField: 'meta.tenantId', strict: true } }
2654
+ * });
2655
+ * ```
2656
+ *
2657
+ * @example Container with global event bus (for telemetry)
2658
+ * ```typescript
2659
+ * const container = createContainer({ eventBus: 'global' });
2660
+ * ```
2661
+ *
2662
+ * @example Fully custom container
2663
+ * ```typescript
2664
+ * const container = createContainer({
2665
+ * repository: myCustomRepo,
2666
+ * eventBus: myCustomEventBus,
2667
+ * cache: myCustomCache
2668
+ * });
2669
+ * ```
2670
+ */
2671
+ function createContainer(options = {}) {
2672
+ let repository;
2673
+ if (!options.repository) repository = workflowRunRepository;
2674
+ else if ("create" in options.repository && "getById" in options.repository) repository = options.repository;
2675
+ else repository = createWorkflowRepository(options.repository);
2676
+ let eventBus;
2677
+ if (options.eventBus === "global") eventBus = globalEventBus;
2678
+ else if (options.eventBus instanceof WorkflowEventBus) eventBus = options.eventBus;
2679
+ else eventBus = new WorkflowEventBus();
2680
+ const cache = options.cache ?? new WorkflowCache();
2681
+ return {
2682
+ repository,
2683
+ eventBus,
2684
+ cache
2685
+ };
2686
+ }
2687
+ /**
2688
+ * Type guard to check if an object is a valid StreamlineContainer
2689
+ */
2690
+ function isStreamlineContainer(obj) {
2691
+ if (!obj || typeof obj !== "object") return false;
2692
+ const container = obj;
2693
+ return "repository" in container && "eventBus" in container && "cache" in container && container.eventBus instanceof WorkflowEventBus && container.cache instanceof WorkflowCache;
2694
+ }
2695
+
2696
+ //#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 };