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.
- package/index.js +5 -6
- package/package.json +2 -4
- package/types/aggregation/index.d.mts +38 -1
- package/types/crudlify/index.d.mts +9 -5
- package/types/crudlify/lib/eventhooks.d.mts +1 -0
- package/types/crudlify/lib/query/q2m/index.d.mts +7 -0
- package/types/crudlify/lib/query/q2m/q2m.d.mts +1 -0
- package/types/crudlify/lib/schema/json-schema/index.d.mts +1 -0
- package/types/crudlify/lib/schema/yup/index.d.mts +2 -1
- package/types/crudlify/lib/schema/zod/index.d.mts +1 -0
- package/types/index.d.ts +1373 -126
- package/types/webserver.d.mts +2 -1
- package/types/workflow/engine.d.mts +5 -40
- package/types/workflow/index.d.mts +1 -5
- package/workflow/engine.mjs +362 -109
- package/workflow/index.mjs +2 -14
package/workflow/engine.mjs
CHANGED
|
@@ -1,40 +1,76 @@
|
|
|
1
1
|
/*
|
|
2
|
-
Implements a
|
|
3
|
-
The engine manages step transitions, state persistence, and event handling for
|
|
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
|
-
*
|
|
9
|
+
* Workflow class that manages step-based workflows
|
|
10
10
|
* @extends EventEmitter
|
|
11
11
|
*/
|
|
12
|
-
class
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
+
this.setCollectionName(config.collectionName);
|
|
86
168
|
}
|
|
87
169
|
if (config.queuePrefix) {
|
|
88
|
-
|
|
170
|
+
this.setQueuePrefix(config.queuePrefix);
|
|
89
171
|
}
|
|
90
172
|
if (config.timeout) {
|
|
91
|
-
|
|
173
|
+
this.setTimeout(config.timeout);
|
|
92
174
|
}
|
|
93
175
|
if (config.maxStepCount) {
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
250
|
+
this.emit('stateUpdated', { workflowName: stepsName, state: newState, instanceId });
|
|
171
251
|
|
|
172
252
|
try {
|
|
173
253
|
// Get the next step function
|
|
174
|
-
const func =
|
|
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] >
|
|
180
|
-
reject(new Error(`Step ${nextStep} has been executed ${newState.stepCount[nextStep]} times, which is greater than the maximum step count of ${
|
|
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(
|
|
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(
|
|
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))
|
|
324
|
+
const completionTime = (new Date(mergedState.updatedAt) - new Date(mergedState.createdAt));
|
|
230
325
|
|
|
231
|
-
const finalresult = await connection.updateOne(
|
|
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
|
-
|
|
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(
|
|
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(`${
|
|
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(`${
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
297
|
-
* @
|
|
409
|
+
* @returns {Promise<string>} The registered workflow name
|
|
410
|
+
* @private
|
|
298
411
|
*/
|
|
299
|
-
async
|
|
300
|
-
|
|
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
|
-
|
|
307
|
-
app.worker(`${
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
356
|
-
const newState = await connection.insertOne(
|
|
357
|
-
{ ...initialState, nextStep: firstStepName, createdAt: new Date().toISOString(), workflowName: name, stepCount: {} });
|
|
358
|
-
const { _id: ID } = await connection.enqueue(`${
|
|
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
|
|
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
|
|
375
|
-
* @param {string} stepsName - Name of the
|
|
376
|
-
* @param {string} instanceId - ID of the
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
592
|
+
|
|
593
|
+
await connection.updateOne(this.#collectionName, { _id: instanceId }, { $set: { stepCount: state.stepCount } });
|
|
424
594
|
console.debug('continue state', state);
|
|
425
|
-
|
|
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(`${
|
|
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
|
|
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(
|
|
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 >
|
|
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(
|
|
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(
|
|
485
|
-
console.debug('listSteps',
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
513
|
-
export default
|
|
764
|
+
// Export the class directly instead of a singleton instance
|
|
765
|
+
export { Workflow };
|
|
766
|
+
export default Workflow;
|