@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.
- package/LICENSE +21 -0
- package/README.md +740 -0
- package/dist/container-BzpIMrrj.mjs +2697 -0
- package/dist/errors-BqunvWPz.mjs +129 -0
- package/dist/events-B5aTz7kD.mjs +28 -0
- package/dist/events-C0sZINZq.d.mts +92 -0
- package/dist/index.d.mts +1589 -0
- package/dist/index.mjs +1102 -0
- package/dist/integrations/fastify.d.mts +12 -0
- package/dist/integrations/fastify.mjs +23 -0
- package/dist/telemetry/index.d.mts +18 -0
- package/dist/telemetry/index.mjs +102 -0
- package/dist/types-DG85_LzF.d.mts +275 -0
- package/package.json +104 -0
|
@@ -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 };
|