codehooks-js 1.3.7 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,40 +1,76 @@
1
1
  /*
2
- Implements a steps engine that uses Codehooks.io as the state storage and queues for persistent workers.
3
- The engine manages step transitions, state persistence, and event handling for step-based workflows.
2
+ Implements a workflow engine that uses Codehooks.io as the state storage and queues for persistent workers.
3
+ The engine manages step transitions, state persistence, and event handling for workflow-based applications.
4
4
  */
5
5
 
6
6
  import { EventEmitter } from 'events';
7
7
 
8
8
  /**
9
- * StepsEngine class that manages step-based workflows
9
+ * Workflow class that manages step-based workflows
10
10
  * @extends EventEmitter
11
11
  */
12
- class StepsEngine extends EventEmitter {
12
+ class Workflow extends EventEmitter {
13
+
14
+ // Protected instance variables
15
+ #collectionName = 'workflowdata'; // collection name for storing workflow data
16
+ #queuePrefix = 'workflowqueue'; // queue prefix for storing workflow data
17
+ #timeout = 30000; // timeout for a workflow instance
18
+ #maxStepCount = 3; // maximum number of steps to execute
19
+ #steps = {}; // workflowsteps configuration
20
+ #definitions = null; // workflow definition map
21
+ #name = null; // workflow name
22
+ #description = null; // workflow description
23
+ #defaultStepOptions = { // default step options
24
+ timeout: 30000, // timeout for a step
25
+ maxRetries: 3 // maximum number of retries for a step
26
+ };
13
27
 
14
- constructor() {
28
+ /**
29
+ * Create a new workflow instance
30
+ * @param {string} name - Unique identifier for the workflow
31
+ * @param {string} description - Human-readable description
32
+ * @param {Object} definition - Object containing step definitions
33
+ * @param {Object} options - Optional configuration options
34
+ */
35
+ constructor(name, description, definition, options = {}) {
15
36
  super();
16
- this.definitions = new Map();
17
- }
37
+ this.#definitions = new Map();
38
+ this.#name = name;
39
+ this.#description = description;
40
+
41
+ // Apply any configuration options
42
+ if (options) {
43
+ this.configure(options);
44
+ }
18
45
 
19
- // Singleton instance
20
- static instance = null;
21
-
22
- // Configuration
23
- static collectionName = 'workflowdata';
24
- static queuePrefix = 'workflowqueue';
25
- static timeout = 30000;
26
- static maxStepCount = 3;
46
+ // Store the initial definition if provided
47
+ if (definition) {
48
+ this.#definitions.set(name, definition);
49
+ console.debug('Initial workflow definition stored:', {
50
+ name,
51
+ stepNames: Object.keys(definition)
52
+ });
53
+ }
54
+ }
27
55
 
28
56
  /**
29
57
  * Set the collection name for storing steps data
30
58
  * @param {string} name - Collection name
31
59
  * @throws {Error} If name is not a non-empty string
32
60
  */
33
- static setCollectionName(name) {
61
+ setCollectionName(name) {
34
62
  if (typeof name !== 'string' || !name.trim()) {
35
63
  throw new Error('Collection name must be a non-empty string');
36
64
  }
37
- StepsEngine.collectionName = name.trim();
65
+ this.#collectionName = name.trim();
66
+ }
67
+
68
+ /**
69
+ * Get the collection name
70
+ * @returns {string} The collection name
71
+ */
72
+ getCollectionName() {
73
+ return this.#collectionName;
38
74
  }
39
75
 
40
76
  /**
@@ -42,11 +78,19 @@ class StepsEngine extends EventEmitter {
42
78
  * @param {string} prefix - Queue prefix
43
79
  * @throws {Error} If prefix is not a non-empty string
44
80
  */
45
- static setQueuePrefix(prefix) {
81
+ setQueuePrefix(prefix) {
46
82
  if (typeof prefix !== 'string' || !prefix.trim()) {
47
83
  throw new Error('Queue prefix must be a non-empty string');
48
84
  }
49
- StepsEngine.queuePrefix = prefix.trim();
85
+ this.#queuePrefix = prefix.trim();
86
+ }
87
+
88
+ /**
89
+ * Get the queue prefix
90
+ * @returns {string} The queue prefix
91
+ */
92
+ getQueuePrefix() {
93
+ return this.#queuePrefix;
50
94
  }
51
95
 
52
96
  /**
@@ -54,11 +98,19 @@ class StepsEngine extends EventEmitter {
54
98
  * @param {number} timeout - Timeout in milliseconds
55
99
  * @throws {Error} If timeout is not a positive number
56
100
  */
57
- static setTimeout(timeout) {
101
+ setTimeout(timeout) {
58
102
  if (typeof timeout !== 'number' || timeout <= 0) {
59
103
  throw new Error('Timeout must be a positive number');
60
104
  }
61
- StepsEngine.timeout = timeout;
105
+ this.#timeout = timeout;
106
+ }
107
+
108
+ /**
109
+ * Get the timeout
110
+ * @returns {number} The timeout in milliseconds
111
+ */
112
+ getTimeout() {
113
+ return this.#timeout;
62
114
  }
63
115
 
64
116
  /**
@@ -66,11 +118,39 @@ class StepsEngine extends EventEmitter {
66
118
  * @param {number} maxStepCount - Maximum step count
67
119
  * @throws {Error} If maxStepCount is not a positive number
68
120
  */
69
- static setMaxStepCount(maxStepCount) {
121
+ setMaxStepCount(maxStepCount) {
70
122
  if (typeof maxStepCount !== 'number' || maxStepCount <= 0) {
71
123
  throw new Error('Maximum step count must be a positive number');
72
124
  }
73
- StepsEngine.maxStepCount = maxStepCount;
125
+ this.#maxStepCount = maxStepCount;
126
+ }
127
+
128
+ /**
129
+ * Get the maximum step count
130
+ * @returns {number} The maximum step count
131
+ */
132
+ getMaxStepCount() {
133
+ return this.#maxStepCount;
134
+ }
135
+
136
+ /**
137
+ * Set the steps configuration
138
+ * @param {Object} steps - Steps configuration
139
+ * @throws {Error} If steps is not an object or is null
140
+ */
141
+ setStepsConfig(steps) {
142
+ if (typeof steps !== 'object' || steps === null) {
143
+ throw new Error('Steps must be an object');
144
+ }
145
+ this.#steps = steps;
146
+ }
147
+
148
+ /**
149
+ * Get the steps configuration
150
+ * @returns {Object} The steps configuration
151
+ */
152
+ getStepsConfig() {
153
+ return this.#steps;
74
154
  }
75
155
 
76
156
  /**
@@ -79,33 +159,27 @@ class StepsEngine extends EventEmitter {
79
159
  * @param {string} config.collectionName - Collection name
80
160
  * @param {string} config.queuePrefix - Queue prefix
81
161
  * @param {number} config.timeout - Timeout in milliseconds
162
+ * @param {number} config.maxStepCount - Maximum step count
163
+ * @param {Object} config.steps - Steps configuration
82
164
  */
83
165
  configure(config) {
84
166
  if (config.collectionName) {
85
- StepsEngine.setCollectionName(config.collectionName);
167
+ this.setCollectionName(config.collectionName);
86
168
  }
87
169
  if (config.queuePrefix) {
88
- StepsEngine.setQueuePrefix(config.queuePrefix);
170
+ this.setQueuePrefix(config.queuePrefix);
89
171
  }
90
172
  if (config.timeout) {
91
- StepsEngine.setTimeout(config.timeout);
173
+ this.setTimeout(config.timeout);
92
174
  }
93
175
  if (config.maxStepCount) {
94
- StepsEngine.setMaxStepCount(config.maxStepCount);
176
+ this.setMaxStepCount(config.maxStepCount);
95
177
  }
96
- }
97
-
98
- /**
99
- * Get the singleton instance of StepsEngine
100
- * @returns {StepsEngine} The singleton instance
101
- */
102
- static getInstance() {
103
- if (!StepsEngine.instance) {
104
- StepsEngine.instance = new StepsEngine();
178
+ if (config.steps) {
179
+ this.setStepsConfig(config.steps);
105
180
  }
106
- return StepsEngine.instance;
107
181
  }
108
-
182
+
109
183
  /**
110
184
  * Get the step definition for a specific step
111
185
  * @param {string} stepsName - Name of the steps workflow
@@ -114,7 +188,7 @@ class StepsEngine extends EventEmitter {
114
188
  * @throws {Error} If steps definition or step function not found
115
189
  */
116
190
  getDefinition(stepsName, stepName) {
117
- const stepsDef = this.definitions.get(stepsName);
191
+ const stepsDef = this.#definitions.get(stepsName);
118
192
  if (!stepsDef) {
119
193
  throw new Error(`No Steps definition found for: ${stepsName}`);
120
194
  }
@@ -156,28 +230,34 @@ class StepsEngine extends EventEmitter {
156
230
  const connection = await Datastore.open();
157
231
 
158
232
  // Handle single next step
159
- StepsEngine.getInstance().emit('stepStarted', { workflowName: stepsName, step: nextStep, state: newState, instanceId });
233
+ this.emit('stepStarted', { workflowName: stepsName, step: nextStep, state: newState, instanceId });
160
234
 
161
235
  // remove the _id from the newState
162
236
  delete newState._id;
163
237
  // increment the step count
164
- newState.stepCount[nextStep] = (newState.stepCount[nextStep] || 0) + 1;
238
+ if (!newState.stepCount) {
239
+ newState.stepCount = {};
240
+ }
241
+ if (!newState.stepCount[nextStep]) {
242
+ newState.stepCount[nextStep] = { visits: 0, startTime: new Date().toISOString(), totalTime: 0 };
243
+ }
244
+ newState.stepCount[nextStep].visits += 1;
165
245
  // Update the existing steps state in the database
166
- newState = await connection.updateOne(StepsEngine.collectionName,
246
+ newState = await connection.updateOne(this.#collectionName,
167
247
  { _id: instanceId },
168
248
  { $set: { ...newState, nextStep: nextStep, updatedAt: new Date().toISOString(), stepCount: newState.stepCount } });
169
249
 
170
- StepsEngine.getInstance().emit('stateUpdated', { workflowName: stepsName, state: newState, instanceId });
250
+ this.emit('stateUpdated', { workflowName: stepsName, state: newState, instanceId });
171
251
 
172
252
  try {
173
253
  // Get the next step function
174
- const func = StepsEngine.getInstance().getDefinition(stepsName, nextStep);
254
+ const func = this.getDefinition(stepsName, nextStep);
175
255
 
176
256
  // Wrap the callback in a Promise to ensure proper async handling
177
257
  await new Promise(async (resolve, reject) => {
178
258
  // check if the step count is greater than the max step count
179
- if (newState.stepCount[nextStep] > StepsEngine.maxStepCount) {
180
- reject(new Error(`Step ${nextStep} has been executed ${newState.stepCount[nextStep]} times, which is greater than the maximum step count of ${StepsEngine.maxStepCount}`));
259
+ if (newState.stepCount[nextStep].visits > this.getMaxStepCount()) {
260
+ reject(new Error(`Step ${nextStep} has been executed ${newState.stepCount[nextStep].visits} times, which is greater than the maximum step count of ${this.getMaxStepCount()}`));
181
261
  return;
182
262
  }
183
263
  try {
@@ -202,16 +282,23 @@ class StepsEngine extends EventEmitter {
202
282
  ...protectedState
203
283
  };
204
284
 
285
+ // set step finish time
286
+ mergedState.stepCount[mergedState.nextStep].finishTime = new Date().toISOString();
287
+ mergedState.stepCount[mergedState.nextStep].totalTime = (new Date(mergedState.stepCount[mergedState.nextStep].finishTime) - new Date(mergedState.stepCount[mergedState.nextStep].startTime));
288
+
205
289
  // update the parallel steps metadata
206
290
  if (mergedState.parallelSteps && mergedState.parallelSteps[mergedState.nextStep]) {
207
291
  // get a fresh copy of the parallel steps
208
- const fresh = await connection.findOne(StepsEngine.collectionName, { _id: instanceId });
292
+ const fresh = await connection.findOne(this.#collectionName, { _id: instanceId });
209
293
  fresh.parallelSteps[mergedState.nextStep].done = true;
210
- fresh.parallelSteps[mergedState.nextStep].nextStep = nextStep;
294
+ fresh.parallelSteps[mergedState.nextStep].nextStep = nextStep;
295
+ //fresh.parallelSteps[mergedState.nextStep].previousStep = mergedState.previousStep || null;
211
296
  delete fresh._id;
212
- await connection.updateOne(StepsEngine.collectionName,
297
+ const updated = await connection.updateOne(this.#collectionName,
213
298
  { _id: instanceId },
214
299
  { $set: { ...fresh, parallelSteps: fresh.parallelSteps } });
300
+ //console.debug('updated', updated.parallelSteps);
301
+ mergedState.parallelSteps = fresh.parallelSteps;
215
302
  // Check if all parallel steps are done
216
303
  const allStepsDone = Object.values(fresh.parallelSteps).every(step => step.done);
217
304
  if (!allStepsDone) {
@@ -219,6 +306,14 @@ class StepsEngine extends EventEmitter {
219
306
  resolve();
220
307
  return;
221
308
  } else {
309
+ // validate that all parallel steps have the same next step
310
+ const nextSteps = Object.values(fresh.parallelSteps).map(step => step.nextStep);
311
+ const uniqueNextSteps = [...new Set(nextSteps)];
312
+ if (uniqueNextSteps.length > 1) {
313
+ throw new Error('Parallel steps must join to the same next step');
314
+ }
315
+ // reset the parallel steps
316
+ mergedState.parallelSteps = {};
222
317
  console.debug('All parallel steps are done');
223
318
  }
224
319
  }
@@ -226,13 +321,13 @@ class StepsEngine extends EventEmitter {
226
321
  // If there is no next step, the workflow is completed
227
322
  if (nextStep === null) {
228
323
  delete mergedState._id;
229
- const completionTime = (new Date(mergedState.updatedAt) - new Date(mergedState.createdAt)) / 1000;
324
+ const completionTime = (new Date(mergedState.updatedAt) - new Date(mergedState.createdAt));
230
325
 
231
- const finalresult = await connection.updateOne(StepsEngine.collectionName,
326
+ const finalresult = await connection.updateOne(this.#collectionName,
232
327
  { _id: instanceId },
233
- { $set: { ...mergedState, nextStep: null, updatedAt: new Date().toISOString() } });
234
- console.log(`Workflow ${stepsName} ${instanceId} is completed in time: ${completionTime}s 🎉`);
235
- StepsEngine.getInstance().emit('completed', { ...finalresult});
328
+ { $set: { ...mergedState, nextStep: null, updatedAt: new Date().toISOString(), totalTime: completionTime } });
329
+ console.log(`Workflow ${stepsName} ${instanceId} is completed in time: ${completionTime / 1000}s 🎉`);
330
+ this.emit('completed', { ...finalresult});
236
331
  resolve();
237
332
  return;
238
333
  }
@@ -241,17 +336,17 @@ class StepsEngine extends EventEmitter {
241
336
  if (Array.isArray(nextStep)) {
242
337
  const now = new Date().toISOString();
243
338
  const metadata = nextStep.reduce((acc, step) => {
244
- acc[step] = { done: false, startTime: now };
339
+ acc[step] = { done: false, startTime: now, previousStep: mergedState.previousStep };
245
340
  return acc;
246
341
  }, {});
247
- const metadataDoc = await connection.updateOne(StepsEngine.collectionName,
342
+ const metadataDoc = await connection.updateOne(this.#collectionName,
248
343
  { _id: instanceId },
249
344
  { $set: { parallelSteps: metadata } });
250
345
  //console.log('metadataDoc', metadataDoc);
251
346
  // enqueue all steps in parallel
252
347
  for (const step of nextStep) {
253
348
  console.debug('enqueue step', step, instanceId);
254
- await connection.enqueue(`${StepsEngine.queuePrefix}_${stepsName}_${step}`, {
349
+ await connection.enqueue(`${this.#queuePrefix}_${stepsName}_${step}`, {
255
350
  stepsName: stepsName,
256
351
  goto: step,
257
352
  state: mergedState,
@@ -261,7 +356,7 @@ class StepsEngine extends EventEmitter {
261
356
  }
262
357
  } else {
263
358
  console.debug('enqueue step', nextStep, instanceId);
264
- await connection.enqueue(`${StepsEngine.queuePrefix}_${stepsName}_${nextStep}`, {
359
+ await connection.enqueue(`${this.#queuePrefix}_${stepsName}_${nextStep}`, {
265
360
  stepsName: stepsName,
266
361
  goto: nextStep,
267
362
  state: mergedState,
@@ -269,7 +364,7 @@ class StepsEngine extends EventEmitter {
269
364
  instanceId: instanceId
270
365
  });
271
366
  }
272
- StepsEngine.getInstance().emit('stepEnqueued', { workflowName: stepsName, step: nextStep, state: newState, instanceId });
367
+ this.emit('stepEnqueued', { workflowName: stepsName, step: nextStep, state: newState, instanceId });
273
368
  resolve();
274
369
  } catch (error) {
275
370
  console.error('error', error.message);
@@ -288,35 +383,63 @@ class StepsEngine extends EventEmitter {
288
383
  }
289
384
 
290
385
  /**
291
- * Register a new steps workflow
386
+ * Register the workflow with a Codehooks application
387
+ * @param {Codehooks} app - Codehooks application instance
388
+ * @returns {Promise<string>} The registered workflow name
389
+ */
390
+ async register(app) {
391
+ if (!this.#name || !this.#description) {
392
+ throw new Error('Workflow name and description must be set before registration');
393
+ }
394
+
395
+ const definition = this.#definitions.get(this.#name);
396
+ if (!definition) {
397
+ throw new Error(`No workflow definition found for: ${this.#name}`);
398
+ }
399
+
400
+ return this.registerWithApp(app, this.#name, this.#description, definition);
401
+ }
402
+
403
+ /**
404
+ * Register a new workflow with a Codehooks application
292
405
  * @param {Codehooks} app - Codehooks application instance
293
- * @param {string} name - Unique identifier for the steps workflow
406
+ * @param {string} name - Unique identifier for the workflow
294
407
  * @param {string} description - Human-readable description
295
408
  * @param {Object} definition - Object containing step definitions
296
- * @returns {Promise<string>} The registered steps name
297
- * @throws {Error} If step definition is invalid
409
+ * @returns {Promise<string>} The registered workflow name
410
+ * @private
298
411
  */
299
- async register(app, name, description, definition) {
300
- StepsEngine.getInstance().emit('workflowCreated', { name, description });
412
+ async registerWithApp(app, name, description, definition) {
413
+ this.emit('workflowCreated', { name, description });
414
+
415
+ // Log initial state of definitions Map
416
+ console.debug('Before registration - Current definitions Map:', {
417
+ size: this.#definitions.size,
418
+ keys: Array.from(this.#definitions.keys()),
419
+ entries: Array.from(this.#definitions.entries()).map(([key, value]) => ({
420
+ key,
421
+ stepNames: Object.keys(value)
422
+ }))
423
+ });
301
424
 
302
425
  // Validate each step in the definition
303
426
  for (const [stepName, step] of Object.entries(definition)) {
304
427
  try {
305
428
  if (stepName !== undefined) {
306
- //console.log('registering queue for step', `${StepsEngine.queuePrefix}_${name}_${stepName}`);
307
- app.worker(`${StepsEngine.queuePrefix}_${name}_${stepName}`, async function(req, res) {
429
+ console.debug('registering queue for step', `${this.#queuePrefix}_${name}_${stepName}`);
430
+ app.worker(`${this.#queuePrefix}_${name}_${stepName}`, async (req, res) => {
308
431
  try {
309
432
  const { stepsName, goto, state, instanceId, options } = req.body.payload;
310
433
  console.debug('dequeue step', stepName, instanceId);
311
- const qid = await StepsEngine.getInstance().handleNextStep(stepsName, goto, state, instanceId, options);
434
+ const qid = await this.handleNextStep(stepsName, goto, state, instanceId, options);
312
435
  } catch (error) {
313
436
  const { stepsName, goto, state, instanceId, options } = req.body.payload;
314
437
  const connection = await Datastore.open();
315
- await connection.updateOne(StepsEngine.collectionName,
438
+ await connection.updateOne(this.#collectionName,
316
439
  { _id: instanceId },
317
440
  { $set: { lastError: error, updatedAt: new Date().toISOString() } });
318
441
  console.error('Error in function: ' + stepName, error);
319
- StepsEngine.getInstance().emit('error', error);
442
+ this.emit('error', error);
320
443
  } finally {
321
444
  res.end();
322
445
  }
@@ -328,7 +451,19 @@ class StepsEngine extends EventEmitter {
328
451
  }
329
452
  }
330
453
 
331
- this.definitions.set(name, definition);
454
+ // Store the definition
455
+ this.#definitions.set(name, definition);
456
+
457
+ // Log state after registration
458
+ console.debug('After registration - Updated definitions Map:', {
459
+ size: this.#definitions.size,
460
+ keys: Array.from(this.#definitions.keys()),
461
+ entries: Array.from(this.#definitions.entries()).map(([key, value]) => ({
462
+ key,
463
+ stepNames: Object.keys(value)
464
+ }))
465
+ });
466
+
332
467
  return name;
333
468
  }
334
469
 
@@ -340,22 +475,50 @@ class StepsEngine extends EventEmitter {
340
475
  * @throws {Error} If starting steps fails
341
476
  */
342
477
  async start(name, initialState) {
343
- StepsEngine.getInstance().emit('workflowStarted', { name, initialState });
478
+ this.emit('workflowStarted', { name, initialState });
479
+
480
+ // Log definitions Map state at start
481
+ console.debug('At workflow start - Current definitions Map:', {
482
+ size: this.#definitions.size,
483
+ keys: Array.from(this.#definitions.keys()),
484
+ entries: Array.from(this.#definitions.entries()).map(([key, value]) => ({
485
+ key,
486
+ stepNames: Object.keys(value)
487
+ }))
488
+ });
344
489
 
345
490
  return new Promise(async (resolve, reject) => {
346
491
  try {
347
- const funcs = this.definitions.get(name);
492
+ console.debug('Starting workflow', name);
493
+ const funcs = this.#definitions.get(name);
494
+ console.debug('Retrieved workflow definition:', {
495
+ exists: !!funcs,
496
+ stepNames: funcs ? Object.keys(funcs) : []
497
+ });
498
+
499
+ if (!funcs) {
500
+ reject(new Error(`No workflow definition found for: ${name}`));
501
+ return;
502
+ }
503
+
348
504
  const firstStepName = Object.keys(funcs)[0];
349
505
  const firstStep = funcs[firstStepName];
506
+ console.debug('First step details:', {
507
+ name: firstStepName,
508
+ exists: !!firstStep,
509
+ type: firstStep ? typeof firstStep : 'undefined'
510
+ });
511
+
350
512
  if (!firstStep) {
351
- reject(new Error('No start step defined in steps'));
513
+ reject(new Error('No start step defined in workflow'));
352
514
  return;
353
515
  }
516
+
354
517
  const connection = await Datastore.open();
355
- // Create a new steps state in the database
356
- const newState = await connection.insertOne(StepsEngine.collectionName,
357
- { ...initialState, nextStep: firstStepName, createdAt: new Date().toISOString(), workflowName: name, stepCount: {} });
358
- const { _id: ID } = await connection.enqueue(`${StepsEngine.queuePrefix}_${name}_${firstStepName}`, {
518
+ // Create a new workflow state in the database
519
+ const newState = await connection.insertOne(this.#collectionName,
520
+ { ...initialState, nextStep: firstStepName, createdAt: new Date().toISOString(), workflowName: name, stepCount: { } });
521
+ const { _id: ID } = await connection.enqueue(`${this.#queuePrefix}_${name}_${firstStepName}`, {
359
522
  stepsName: name,
360
523
  goto: firstStepName,
361
524
  state: newState,
@@ -364,25 +527,25 @@ class StepsEngine extends EventEmitter {
364
527
  });
365
528
  resolve(newState);
366
529
  } catch (error) {
367
- console.error('Error starting steps:', error.message);
530
+ console.error('Error starting workflow:', error.message);
368
531
  reject(error);
369
532
  }
370
533
  });
371
534
  }
372
535
 
373
536
  /**
374
- * Update the state of a steps instance
375
- * @param {string} stepsName - Name of the steps workflow
376
- * @param {string} instanceId - ID of the steps instance
537
+ * Update the state of a workflow instance
538
+ * @param {string} stepsName - Name of the workflow
539
+ * @param {string} instanceId - ID of the workflow instance
377
540
  * @param {Object} state - New state to update with
378
541
  * @param {Object} options - Options for the update, { continue: false } to avoid continuing the the step
379
542
  * @returns {Promise<Object>} The updated state
380
543
  */
381
544
  async updateState(stepsName, instanceId, state, options={continue: true}) {
382
- StepsEngine.getInstance().emit('stepsStateUpdating', { stepsName, instanceId, state });
545
+ this.emit('stepsStateUpdating', { stepsName, instanceId, state });
383
546
  const connection = await Datastore.open();
384
547
  return new Promise(async (resolve, reject) => {
385
- const doc = await connection.updateOne(StepsEngine.collectionName,
548
+ const doc = await connection.updateOne(this.#collectionName,
386
549
  { _id: instanceId },
387
550
  { $set: { ...state, updatedAt: new Date().toISOString() } });
388
551
  if (options.continue) {
@@ -400,7 +563,7 @@ class StepsEngine extends EventEmitter {
400
563
  */
401
564
  async setState(instanceId, { _id, state }) {
402
565
  const connection = await Datastore.open();
403
- await connection.replaceOne(StepsEngine.collectionName, { _id: _id }, { ...state });
566
+ await connection.replaceOne(this.#collectionName, { _id: _id }, { ...state });
404
567
  }
405
568
 
406
569
  /**
@@ -410,22 +573,29 @@ class StepsEngine extends EventEmitter {
410
573
  * @returns {Promise<{qId: string}>} Queue ID for the continued step
411
574
  * @throws {Error} If steps instance not found
412
575
  */
413
- async continue(stepsName, instanceId) {
576
+ async continue(stepsName, instanceId, reset=false) {
414
577
  const connection = await Datastore.open();
415
- const state = await connection.findOne(StepsEngine.collectionName, { _id: instanceId });
578
+ const state = await connection.findOne(this.#collectionName, { _id: instanceId });
416
579
  if (!state) {
417
580
  throw new Error(`No steps found with instanceId: ${instanceId}`);
418
581
  }
419
- // update all step counts to 0
420
- for (const step in state.stepCount) {
421
- state.stepCount[step] = 0;
582
+ if (reset) {
583
+ // reset the step count
584
+ // update all step counts to 0
585
+ for (const step in state.stepCount) {
586
+ state.stepCount[step] = { visits: 0, startTime: new Date().toISOString() };
587
+ }
588
+ } else {
589
+ // update the step count
590
+ state.stepCount[state.nextStep] = { visits: 0, startTime: new Date().toISOString() };
422
591
  }
423
- await connection.updateOne(StepsEngine.collectionName, { _id: instanceId }, { $set: { stepCount: state.stepCount } });
592
+
593
+ await connection.updateOne(this.#collectionName, { _id: instanceId }, { $set: { stepCount: state.stepCount } });
424
594
  console.debug('continue state', state);
425
- StepsEngine.getInstance().emit('workflowContinued', { stepsName, step: state.nextStep, instanceId });
595
+ this.emit('workflowContinued', { stepsName, step: state.nextStep, instanceId });
426
596
 
427
597
  return new Promise(async (resolve, reject) => {
428
- const { _id: ID } = await connection.enqueue(`${StepsEngine.queuePrefix}_${stepsName}_${state.nextStep}`, {
598
+ const { _id: ID } = await connection.enqueue(`${this.#queuePrefix}_${stepsName}_${state.nextStep}`, {
429
599
  stepsName,
430
600
  goto: state.nextStep,
431
601
  state: state,
@@ -438,21 +608,21 @@ class StepsEngine extends EventEmitter {
438
608
  }
439
609
 
440
610
  /**
441
- * Continue all timed out steps instances
611
+ * Continue all timed out workflows instances
442
612
  * @returns {Promise<Array<{qId: string}>>} Array of results containing queue IDs for continued workflows
443
613
  */
444
614
  async continueAllTimedOut() {
445
615
  const db = await Datastore.open();
446
- const timedOutWorkflows = await db.collection(StepsEngine.collectionName).find({nextStep: {$ne: null}}).toArray();
616
+ const timedOutWorkflows = await db.collection(this.#collectionName).find({nextStep: {$ne: null}}).toArray();
447
617
  const now = new Date();
448
618
  const results = [];
449
619
  for (const workflow of timedOutWorkflows) {
450
620
  const createdAt = new Date(workflow.createdAt);
451
621
  const diffMillis = now.getTime() - createdAt.getTime();
452
- if (diffMillis > StepsEngine.timeout) {
622
+ if (diffMillis > this.#timeout) {
453
623
  const diffMinutes = diffMillis / (1000 * 60);
454
624
  console.log('Timed out:', workflow._id, workflow.nextStep, `(${diffMinutes.toFixed(1)} minutes old)`);
455
- const result = await this.continue(workflow.workflowName, workflow._id);
625
+ const result = await this.continue(workflow.workflowName, workflow._id, true);
456
626
  console.log('Continued:', result._id);
457
627
  results.push(result);
458
628
  }
@@ -468,7 +638,7 @@ class StepsEngine extends EventEmitter {
468
638
  async getStepsStatus(id) {
469
639
  return new Promise(async (resolve, reject) => {
470
640
  const connection = await Datastore.open();
471
- const state = await connection.findOne(StepsEngine.collectionName, { _id: id });
641
+ const state = await connection.findOne(this.#collectionName, { _id: id });
472
642
  resolve(state);
473
643
  });
474
644
  }
@@ -481,8 +651,8 @@ class StepsEngine extends EventEmitter {
481
651
  async getInstances(filter) {
482
652
  return new Promise(async (resolve, reject) => {
483
653
  const connection = await Datastore.open();
484
- const states = await connection.find(StepsEngine.collectionName, filter).toArray();
485
- console.debug('listSteps', StepsEngine.collectionName, filter, states.length);
654
+ const states = await connection.find(this.#collectionName, filter).toArray();
655
+ console.debug('listSteps', this.#collectionName, filter, states.length);
486
656
  resolve(states);
487
657
  });
488
658
  }
@@ -493,21 +663,104 @@ class StepsEngine extends EventEmitter {
493
663
  * @returns {Promise<Object>} The cancellation result
494
664
  */
495
665
  async cancelSteps(id) {
496
- StepsEngine.getInstance().emit('cancelled', { id });
666
+ this.emit('cancelled', { id });
497
667
  return new Promise(async (resolve, reject) => {
498
668
  const connection = await Datastore.open();
499
- const state = await connection.updateOne(StepsEngine.collectionName,
669
+ const state = await connection.updateOne(this.#collectionName,
500
670
  { _id: id },
501
671
  { $set: { status: 'cancelled' } });
502
672
  resolve(state);
503
673
  });
504
674
  }
505
- }
506
675
 
507
- // Export the static methods directly
508
- export const setCollectionName = StepsEngine.setCollectionName.bind(StepsEngine);
509
- export const setQueuePrefix = StepsEngine.setQueuePrefix.bind(StepsEngine);
676
+ /**
677
+ * Check if a specific step in a workflow instance has timed out
678
+ * @param {Object} workflow - The workflow instance
679
+ * @param {string} stepName - The name of the step to check (defaults to previousStep)
680
+ * @returns {Object} Object containing timeout status and details
681
+ */
682
+ isStepTimedOut(workflow) {
683
+ // Use previousStep if no stepName provided
684
+ const stepToCheck = workflow.nextStep;
685
+
686
+ if (!stepToCheck || !workflow.stepCount || !workflow.stepCount[stepToCheck]) {
687
+ console.debug('no step', stepToCheck, workflow.stepCount);
688
+ return {
689
+ isTimedOut: false,
690
+ reason: 'Step not found in workflow',
691
+ step: stepToCheck
692
+ };
693
+ }
694
+
695
+ const step = workflow.stepCount[stepToCheck];
696
+
697
+ // Get the timeout value for this step
698
+ // First try step-specific config, then default options, finally fallback to global timeout
699
+ const stepConfig = this.#steps[stepToCheck];
700
+ const stepTimeout = stepConfig?.timeout ?? this.#defaultStepOptions.timeout ?? this.#timeout;
701
+
702
+ // If the step hasn't finished, check if it's been running too long
703
+ console.debug('isStepTimedOut', stepToCheck, stepTimeout);
704
+ if (!step.finishTime) {
705
+ const startTime = new Date(step.startTime);
706
+ const now = new Date();
707
+ const runningTime = now.getTime() - startTime.getTime();
708
+ console.debug('runningTime', runningTime, stepTimeout);
709
+ return {
710
+ isTimedOut: runningTime > stepTimeout,
711
+ runningTime,
712
+ timeout: stepTimeout,
713
+ step: stepToCheck,
714
+ startTime: step.startTime,
715
+ currentTime: now.toISOString(),
716
+ timeoutSource: stepConfig ? 'stepConfig' : (this.#defaultStepOptions.timeout ? 'defaultOptions' : 'globalTimeout')
717
+ };
718
+ }
719
+
720
+ // If the step has finished, check if it took too long
721
+ const startTime = new Date(step.startTime);
722
+ const finishTime = new Date(step.finishTime);
723
+ const executionTime = finishTime.getTime() - startTime.getTime();
724
+ console.debug('executionTime', executionTime, stepTimeout);
725
+ return {
726
+ isTimedOut: executionTime > stepTimeout,
727
+ executionTime,
728
+ timeout: stepTimeout,
729
+ step: stepToCheck,
730
+ startTime: step.startTime,
731
+ finishTime: step.finishTime,
732
+ timeoutSource: stepConfig ? 'stepConfig' : (this.#defaultStepOptions.timeout ? 'defaultOptions' : 'globalTimeout')
733
+ };
734
+ }
735
+
736
+ /**
737
+ * Find all workflow instances with timed out steps
738
+ * @param {Object} filter - Optional filter criteria for workflows
739
+ * @returns {Promise<Array<Object>>} Array of workflow instances with timed out steps
740
+ */
741
+ async findTimedOutSteps(filter = {}) {
742
+ const db = await Datastore.open();
743
+ const workflows = await db.getMany(this.#collectionName, {"nextStep": {$ne: null}}).toArray();
744
+ if (workflows.length > 0) {
745
+ console.debug('TimedOutSteps', workflows.length);
746
+ }
747
+ const timedOutWorkflows = workflows.map(workflow => {
748
+ console.debug('isStepTimedOut', workflow.nextStep);
749
+ const timeoutStatus = this.isStepTimedOut(workflow);
750
+ if (timeoutStatus.isTimedOut) {
751
+ return {
752
+ workflowId: workflow._id,
753
+ workflowName: workflow.workflowName,
754
+ ...timeoutStatus
755
+ };
756
+ }
757
+ return null;
758
+ }).filter(Boolean);
759
+
760
+ return timedOutWorkflows;
761
+ }
762
+ }
510
763
 
511
- // Export the singleton instance
512
- export const Steps = StepsEngine.getInstance();
513
- export default StepsEngine.getInstance();
764
+ // Export the class directly instead of a singleton instance
765
+ export { Workflow };
766
+ export default Workflow;