@xtr-dev/payload-automation 0.0.43 → 0.0.46

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.
Files changed (74) hide show
  1. package/README.md +221 -49
  2. package/dist/collections/Steps.d.ts +6 -0
  3. package/dist/collections/Steps.js +166 -0
  4. package/dist/collections/Steps.js.map +1 -0
  5. package/dist/collections/Triggers.d.ts +7 -0
  6. package/dist/collections/Triggers.js +224 -0
  7. package/dist/collections/Triggers.js.map +1 -0
  8. package/dist/collections/Workflow.d.ts +5 -2
  9. package/dist/collections/Workflow.js +179 -39
  10. package/dist/collections/Workflow.js.map +1 -1
  11. package/dist/collections/WorkflowRuns.d.ts +4 -0
  12. package/dist/collections/WorkflowRuns.js +219 -24
  13. package/dist/collections/WorkflowRuns.js.map +1 -1
  14. package/dist/components/WorkflowBuilder/WorkflowBuilder.js.map +1 -1
  15. package/dist/core/expression-engine.d.ts +58 -0
  16. package/dist/core/expression-engine.js +191 -0
  17. package/dist/core/expression-engine.js.map +1 -0
  18. package/dist/core/workflow-executor.d.ts +70 -56
  19. package/dist/core/workflow-executor.js +354 -677
  20. package/dist/core/workflow-executor.js.map +1 -1
  21. package/dist/exports/client.js +1 -3
  22. package/dist/exports/client.js.map +1 -1
  23. package/dist/exports/views.js +2 -4
  24. package/dist/exports/views.js.map +1 -1
  25. package/dist/index.d.ts +3 -2
  26. package/dist/index.js +1 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/plugin/config-types.d.ts +43 -5
  29. package/dist/plugin/config-types.js +3 -1
  30. package/dist/plugin/config-types.js.map +1 -1
  31. package/dist/plugin/index.d.ts +1 -1
  32. package/dist/plugin/index.js +150 -55
  33. package/dist/plugin/index.js.map +1 -1
  34. package/dist/plugin/trigger-hook.d.ts +13 -0
  35. package/dist/plugin/trigger-hook.js +184 -0
  36. package/dist/plugin/trigger-hook.js.map +1 -0
  37. package/dist/steps/create-step.d.ts +66 -0
  38. package/dist/steps/create-step.js +59 -0
  39. package/dist/steps/create-step.js.map +1 -0
  40. package/dist/steps/index.d.ts +2 -0
  41. package/dist/steps/index.js +3 -0
  42. package/dist/steps/index.js.map +1 -1
  43. package/dist/steps/read-document-handler.js +1 -1
  44. package/dist/steps/read-document-handler.js.map +1 -1
  45. package/dist/steps/update-document-handler.js +1 -1
  46. package/dist/steps/update-document-handler.js.map +1 -1
  47. package/dist/triggers/hook-options.d.ts +34 -0
  48. package/dist/triggers/hook-options.js +158 -0
  49. package/dist/triggers/hook-options.js.map +1 -0
  50. package/dist/triggers/index.d.ts +2 -2
  51. package/dist/triggers/index.js +1 -2
  52. package/dist/triggers/index.js.map +1 -1
  53. package/dist/types/index.d.ts +8 -0
  54. package/dist/types/index.js +4 -5
  55. package/dist/types/index.js.map +1 -1
  56. package/dist/utils/validation.d.ts +64 -0
  57. package/dist/utils/validation.js +107 -0
  58. package/dist/utils/validation.js.map +1 -0
  59. package/package.json +2 -1
  60. package/dist/plugin/collection-hook.d.ts +0 -1
  61. package/dist/plugin/collection-hook.js +0 -92
  62. package/dist/plugin/collection-hook.js.map +0 -1
  63. package/dist/plugin/global-hook.d.ts +0 -1
  64. package/dist/plugin/global-hook.js +0 -83
  65. package/dist/plugin/global-hook.js.map +0 -1
  66. package/dist/triggers/collection-trigger.d.ts +0 -2
  67. package/dist/triggers/collection-trigger.js +0 -36
  68. package/dist/triggers/collection-trigger.js.map +0 -1
  69. package/dist/triggers/global-trigger.d.ts +0 -2
  70. package/dist/triggers/global-trigger.js +0 -29
  71. package/dist/triggers/global-trigger.js.map +0 -1
  72. package/dist/triggers/types.d.ts +0 -5
  73. package/dist/triggers/types.js +0 -3
  74. package/dist/triggers/types.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import Handlebars from 'handlebars';
1
+ import { evaluateCondition as evalCondition, resolveStepInput as resolveInput } from './expression-engine.js';
2
2
  export class WorkflowExecutor {
3
3
  payload;
4
4
  logger;
@@ -7,507 +7,294 @@ export class WorkflowExecutor {
7
7
  this.logger = logger;
8
8
  }
9
9
  /**
10
- * Convert string values to appropriate types based on common patterns
11
- */ convertValueType(value, key) {
12
- if (typeof value !== 'string') {
13
- return value;
10
+ * Resolve workflow steps by loading base step configurations and merging with overrides
11
+ */ async resolveWorkflowSteps(workflow) {
12
+ const resolvedSteps = [];
13
+ if (!workflow.steps || workflow.steps.length === 0) {
14
+ return resolvedSteps;
14
15
  }
15
- // Type conversion patterns based on field names and values
16
- const numericFields = [
17
- 'timeout',
18
- 'retries',
19
- 'delay',
20
- 'port',
21
- 'limit',
22
- 'offset',
23
- 'count',
24
- 'max',
25
- 'min'
26
- ];
27
- const booleanFields = [
28
- 'enabled',
29
- 'required',
30
- 'active',
31
- 'success',
32
- 'failed',
33
- 'complete'
34
- ];
35
- // Convert numeric fields
36
- if (numericFields.some((field)=>key.toLowerCase().includes(field))) {
37
- const numValue = Number(value);
38
- if (!isNaN(numValue)) {
39
- this.logger.debug({
40
- key,
41
- originalValue: value,
42
- convertedValue: numValue
43
- }, 'Auto-converted field to number');
44
- return numValue;
16
+ for(let i = 0; i < workflow.steps.length; i++){
17
+ const workflowStep = workflow.steps[i];
18
+ let baseStep;
19
+ if (typeof workflowStep.step === 'object' && workflowStep.step !== null) {
20
+ baseStep = workflowStep.step;
21
+ } else {
22
+ try {
23
+ baseStep = await this.payload.findByID({
24
+ collection: 'automation-steps',
25
+ id: workflowStep.step,
26
+ depth: 0
27
+ });
28
+ } catch (error) {
29
+ this.logger.error({
30
+ stepId: workflowStep.step,
31
+ stepIndex: i,
32
+ error: error instanceof Error ? error.message : 'Unknown error'
33
+ }, 'Failed to load step configuration');
34
+ throw new Error(`Failed to load step ${workflowStep.step}: ${error instanceof Error ? error.message : 'Unknown error'}`);
35
+ }
45
36
  }
37
+ const baseConfig = baseStep.config || {};
38
+ const overrides = workflowStep.inputOverrides || {};
39
+ const mergedConfig = {
40
+ ...baseConfig,
41
+ ...overrides
42
+ };
43
+ const dependencies = (workflowStep.dependencies || []).map((d)=>d.stepIndex);
44
+ resolvedSteps.push({
45
+ stepIndex: i,
46
+ stepId: baseStep.id,
47
+ stepName: workflowStep.stepName || baseStep.name || `step-${i}`,
48
+ stepType: baseStep.type,
49
+ config: mergedConfig,
50
+ condition: workflowStep.condition,
51
+ dependencies,
52
+ retryOnFailure: baseStep.retryOnFailure,
53
+ maxRetries: baseStep.maxRetries || workflow.maxRetries || 3,
54
+ retryDelay: baseStep.retryDelay || workflow.retryDelay || 1000
55
+ });
46
56
  }
47
- // Convert boolean fields
48
- if (booleanFields.some((field)=>key.toLowerCase().includes(field))) {
49
- if (value === 'true') return true;
50
- if (value === 'false') return false;
51
- }
52
- // Try to parse as number if it looks numeric
53
- if (/^\d+$/.test(value)) {
54
- const numValue = parseInt(value, 10);
55
- this.logger.debug({
56
- key,
57
- originalValue: value,
58
- convertedValue: numValue
59
- }, 'Auto-converted numeric string to number');
60
- return numValue;
61
- }
62
- // Try to parse as float if it looks like a decimal
63
- if (/^\d+\.\d+$/.test(value)) {
64
- const floatValue = parseFloat(value);
65
- this.logger.debug({
66
- key,
67
- originalValue: value,
68
- convertedValue: floatValue
69
- }, 'Auto-converted decimal string to number');
70
- return floatValue;
71
- }
72
- // Return as string if no conversion applies
73
- return value;
57
+ return resolvedSteps;
74
58
  }
75
59
  /**
76
- * Classifies error types based on error messages
77
- */ classifyErrorType(errorMessage) {
78
- if (errorMessage.includes('timeout') || errorMessage.includes('ETIMEDOUT')) {
79
- return 'timeout';
80
- }
81
- if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) {
82
- return 'dns';
60
+ * Resolve step execution order based on dependencies
61
+ */ resolveExecutionOrder(steps) {
62
+ const indegree = new Map();
63
+ const dependents = new Map();
64
+ for (const step of steps){
65
+ indegree.set(step.stepIndex, step.dependencies.length);
66
+ dependents.set(step.stepIndex, []);
83
67
  }
84
- if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('ECONNRESET')) {
85
- return 'connection';
68
+ for (const step of steps){
69
+ for (const depIndex of step.dependencies){
70
+ const deps = dependents.get(depIndex) || [];
71
+ deps.push(step.stepIndex);
72
+ dependents.set(depIndex, deps);
73
+ }
86
74
  }
87
- if (errorMessage.includes('network') || errorMessage.includes('fetch')) {
88
- return 'network';
75
+ const executionBatches = [];
76
+ const processed = new Set();
77
+ while(processed.size < steps.length){
78
+ const currentBatch = [];
79
+ for (const step of steps){
80
+ if (!processed.has(step.stepIndex) && indegree.get(step.stepIndex) === 0) {
81
+ currentBatch.push(step);
82
+ }
83
+ }
84
+ if (currentBatch.length === 0) {
85
+ throw new Error('Circular dependency detected in workflow steps');
86
+ }
87
+ executionBatches.push(currentBatch);
88
+ for (const step of currentBatch){
89
+ processed.add(step.stepIndex);
90
+ for (const depIndex of dependents.get(step.stepIndex) || []){
91
+ indegree.set(depIndex, (indegree.get(depIndex) || 1) - 1);
92
+ }
93
+ }
89
94
  }
90
- return 'unknown';
91
- }
92
- /**
93
- * Evaluate a step condition using JSONPath
94
- */ evaluateStepCondition(condition, context) {
95
- return this.evaluateCondition(condition, context);
95
+ return executionBatches;
96
96
  }
97
97
  /**
98
98
  * Execute a single workflow step
99
- */ async executeStep(step, stepIndex, context, req, workflowRunId) {
100
- const stepName = step.name || 'step-' + stepIndex;
99
+ */ async executeStep(step, context, req, stepResults, jobMeta) {
100
+ const result = {
101
+ step: step.stepId,
102
+ stepName: step.stepName,
103
+ stepIndex: step.stepIndex,
104
+ status: 'running',
105
+ startedAt: new Date().toISOString(),
106
+ retryCount: 0
107
+ };
101
108
  this.logger.info({
102
- hasStep: 'step' in step,
103
- step: JSON.stringify(step),
104
- stepName
109
+ stepName: step.stepName,
110
+ stepType: step.stepType,
111
+ stepIndex: step.stepIndex
105
112
  }, 'Executing step');
106
113
  // Check step condition if present
107
114
  if (step.condition) {
108
- this.logger.debug({
109
- condition: step.condition,
110
- stepName,
111
- availableSteps: Object.keys(context.steps),
112
- completedSteps: Object.entries(context.steps).filter(([_, s])=>s.state === 'succeeded').map(([name])=>name),
113
- triggerType: context.trigger?.type
114
- }, 'Evaluating step condition');
115
- const conditionMet = this.evaluateStepCondition(step.condition, context);
115
+ const conditionMet = await this.evaluateCondition(step.condition, context);
116
116
  if (!conditionMet) {
117
117
  this.logger.info({
118
- condition: step.condition,
119
- stepName,
120
- contextSnapshot: JSON.stringify({
121
- stepOutputs: Object.entries(context.steps).reduce((acc, [name, step])=>{
122
- acc[name] = {
123
- state: step.state,
124
- hasOutput: !!step.output
125
- };
126
- return acc;
127
- }, {}),
128
- triggerData: context.trigger?.data ? 'present' : 'absent'
129
- })
118
+ stepName: step.stepName,
119
+ condition: step.condition
130
120
  }, 'Step condition not met, skipping');
131
- // Mark step as completed but skipped
132
- context.steps[stepName] = {
133
- error: undefined,
134
- input: undefined,
135
- output: {
136
- reason: 'Condition not met',
137
- skipped: true
138
- },
139
- state: 'succeeded'
121
+ result.status = 'skipped';
122
+ result.completedAt = new Date().toISOString();
123
+ result.output = {
124
+ reason: 'Condition not met',
125
+ skipped: true
140
126
  };
141
- // Update workflow run context if needed
142
- if (workflowRunId) {
143
- await this.updateWorkflowRunContext(workflowRunId, context, req);
144
- }
145
- return;
127
+ context.steps[step.stepName] = {
128
+ state: 'skipped',
129
+ output: result.output
130
+ };
131
+ return result;
146
132
  }
147
- this.logger.info({
148
- condition: step.condition,
149
- stepName,
150
- contextSnapshot: JSON.stringify({
151
- stepOutputs: Object.entries(context.steps).reduce((acc, [name, step])=>{
152
- acc[name] = {
153
- state: step.state,
154
- hasOutput: !!step.output
155
- };
156
- return acc;
157
- }, {}),
158
- triggerData: context.trigger?.data ? 'present' : 'absent'
159
- })
160
- }, 'Step condition met, proceeding with execution');
161
133
  }
162
- // Initialize step context
163
- context.steps[stepName] = {
164
- error: undefined,
165
- input: undefined,
166
- output: undefined,
134
+ // Resolve input using JSONata expressions
135
+ const resolvedInput = await this.resolveStepInput(step.config, context);
136
+ result.input = resolvedInput;
137
+ context.steps[step.stepName] = {
167
138
  state: 'running',
168
- _startTime: Date.now() // Track execution start time for independent duration tracking
139
+ input: resolvedInput
169
140
  };
170
- // Move taskSlug declaration outside try block so it's accessible in catch
171
- const taskSlug = step.type;
172
141
  try {
173
- // Get input configuration from the step
174
- const inputConfig = step.input || {};
175
- // Resolve input data using Handlebars templates
176
- const resolvedInput = this.resolveStepInput(inputConfig, context, taskSlug);
177
- context.steps[stepName].input = resolvedInput;
178
- if (!taskSlug) {
179
- throw new Error(`Step ${stepName} is missing a task type`);
180
- }
181
- this.logger.info({
182
- hasInput: !!resolvedInput,
183
- hasReq: !!req,
184
- stepName,
185
- taskSlug
186
- }, 'Queueing task');
187
142
  const job = await this.payload.jobs.queue({
188
143
  input: resolvedInput,
189
144
  req,
190
- task: taskSlug
145
+ task: step.stepType
191
146
  });
192
- // Run the specific job immediately and wait for completion
193
- this.logger.info({
194
- jobId: job.id
195
- }, 'Running job immediately using runByID');
196
- const runResults = await this.payload.jobs.runByID({
147
+ // Update the job with automation context fields
148
+ // This allows tracking which workflow run triggered this job
149
+ await this.payload.update({
150
+ collection: 'payload-jobs',
197
151
  id: job.id,
152
+ data: {
153
+ automationWorkflow: jobMeta.automationWorkflowId,
154
+ automationWorkflowRun: jobMeta.automationWorkflowRunId,
155
+ automationTrigger: jobMeta.automationTriggerId,
156
+ automationStepName: step.stepName
157
+ },
198
158
  req
199
159
  });
200
- this.logger.info({
201
- jobId: job.id,
202
- runResult: runResults,
203
- hasResult: !!runResults
204
- }, 'Job run completed');
205
- // Give a small delay to ensure job is fully processed
206
- await new Promise((resolve)=>setTimeout(resolve, 100));
207
- // Get the job result
208
- const completedJob = await this.payload.findByID({
160
+ // Run the job and capture the result directly from runByID
161
+ // This is important because PayloadCMS may delete jobs on completion (deleteJobOnComplete: true by default)
162
+ const runResult = await this.payload.jobs.runByID({
209
163
  id: job.id,
210
- collection: 'payload-jobs',
211
164
  req
212
165
  });
213
- this.logger.info({
214
- jobId: job.id,
215
- totalTried: completedJob.totalTried,
216
- hasError: completedJob.hasError,
217
- taskStatus: completedJob.taskStatus ? Object.keys(completedJob.taskStatus) : 'null'
218
- }, 'Retrieved job results');
219
- const taskStatus = completedJob.taskStatus?.[completedJob.taskSlug]?.[completedJob.totalTried];
220
- const isComplete = taskStatus?.complete === true;
221
- const hasError = completedJob.hasError || !isComplete;
222
- // Extract error information from job if available
223
- let errorMessage;
224
- if (hasError) {
225
- // Try to get error from the latest log entry
226
- if (completedJob.log && completedJob.log.length > 0) {
227
- const latestLog = completedJob.log[completedJob.log.length - 1];
228
- errorMessage = latestLog.error?.message || latestLog.error;
229
- }
230
- // Fallback to top-level error
231
- if (!errorMessage && completedJob.error) {
232
- errorMessage = completedJob.error.message || completedJob.error;
233
- }
234
- // Try to get error from task output if available
235
- if (!errorMessage && taskStatus?.output?.error) {
236
- errorMessage = taskStatus.output.error;
166
+ // Check the job status from the run result
167
+ // runByID returns { jobStatus: { [jobId]: { status: 'success' | 'error' | ... } }, ... }
168
+ const jobStatus = runResult?.jobStatus?.[job.id];
169
+ const jobSucceeded = jobStatus?.status === 'success';
170
+ if (jobSucceeded) {
171
+ // Job completed successfully - try to get output from the job if it still exists
172
+ // Note: Job may have been deleted if deleteJobOnComplete is true
173
+ let output = {};
174
+ try {
175
+ const completedJob = await this.payload.findByID({
176
+ id: job.id,
177
+ collection: 'payload-jobs',
178
+ req
179
+ });
180
+ const taskStatus = completedJob.taskStatus?.[completedJob.taskSlug]?.[completedJob.totalTried];
181
+ output = taskStatus?.output || {};
182
+ } catch {
183
+ // Job was deleted after completion - this is expected behavior with deleteJobOnComplete: true
184
+ // The job succeeded, so we proceed without the output
185
+ this.logger.debug({
186
+ stepName: step.stepName
187
+ }, 'Job was deleted after successful completion (deleteJobOnComplete)');
237
188
  }
238
- // Check if task handler returned with state='failed'
239
- if (!errorMessage && taskStatus?.state === 'failed') {
240
- errorMessage = 'Task handler returned a failed state';
241
- // Try to get more specific error from output
242
- if (taskStatus.output?.error) {
243
- errorMessage = taskStatus.output.error;
189
+ result.status = 'succeeded';
190
+ result.output = output;
191
+ result.completedAt = new Date().toISOString();
192
+ result.duration = new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime();
193
+ } else {
194
+ // Job failed - try to get error details from the job
195
+ let errorMessage = 'Task failed';
196
+ try {
197
+ const completedJob = await this.payload.findByID({
198
+ id: job.id,
199
+ collection: 'payload-jobs',
200
+ req
201
+ });
202
+ const taskStatus = completedJob.taskStatus?.[completedJob.taskSlug]?.[completedJob.totalTried];
203
+ if (completedJob.log && completedJob.log.length > 0) {
204
+ const latestLog = completedJob.log[completedJob.log.length - 1];
205
+ errorMessage = latestLog.error?.message || latestLog.error || errorMessage;
244
206
  }
245
- }
246
- // Check for network errors in the job data
247
- if (!errorMessage && completedJob.result) {
248
- const result = completedJob.result;
249
- if (result.error) {
250
- errorMessage = result.error;
207
+ if (taskStatus?.output?.errorMessage) {
208
+ errorMessage = taskStatus.output.errorMessage;
251
209
  }
210
+ } catch {
211
+ // Job may have been deleted - use the job status from run result
212
+ errorMessage = `Task failed with status: ${jobStatus?.status || 'unknown'}`;
252
213
  }
253
- // Final fallback to generic message with more detail
254
- if (!errorMessage) {
255
- const jobDetails = {
256
- taskSlug,
257
- hasError: completedJob.hasError,
258
- taskStatus: taskStatus?.complete,
259
- totalTried: completedJob.totalTried
260
- };
261
- errorMessage = `Task ${taskSlug} failed without detailed error information. Job details: ${JSON.stringify(jobDetails)}`;
262
- }
214
+ throw new Error(errorMessage);
263
215
  }
264
- const result = {
265
- error: errorMessage,
266
- output: taskStatus?.output || {},
267
- state: isComplete ? 'succeeded' : 'failed'
268
- };
269
- // Store the output and error
270
- context.steps[stepName].output = result.output;
271
- context.steps[stepName].state = result.state;
272
- if (result.error) {
273
- context.steps[stepName].error = result.error;
274
- }
275
- // Independent execution tracking (not dependent on PayloadCMS task status)
276
- context.steps[stepName].executionInfo = {
277
- completed: true,
278
- success: result.state === 'succeeded',
279
- executedAt: new Date().toISOString(),
280
- duration: Date.now() - (context.steps[stepName]._startTime || Date.now())
216
+ context.steps[step.stepName] = {
217
+ state: 'succeeded',
218
+ input: resolvedInput,
219
+ output: result.output
281
220
  };
282
- // For failed steps, try to extract detailed error information from the job logs
283
- // This approach is more reliable than external storage and persists with the workflow
284
- if (result.state === 'failed') {
285
- const errorDetails = this.extractErrorDetailsFromJob(completedJob, context.steps[stepName], stepName);
286
- if (errorDetails) {
287
- context.steps[stepName].errorDetails = errorDetails;
288
- this.logger.info({
289
- stepName,
290
- errorType: errorDetails.errorType,
291
- duration: errorDetails.duration,
292
- attempts: errorDetails.attempts
293
- }, 'Extracted detailed error information for failed step');
294
- }
295
- }
296
- this.logger.debug({
297
- context
298
- }, 'Step execution context');
299
- if (result.state !== 'succeeded') {
300
- throw new Error(result.error || `Step ${stepName} failed`);
301
- }
302
221
  this.logger.info({
303
- output: result.output,
304
- stepName
305
- }, 'Step completed');
306
- // Update workflow run with current step results if workflowRunId is provided
307
- if (workflowRunId) {
308
- await this.updateWorkflowRunContext(workflowRunId, context, req);
309
- }
222
+ stepName: step.stepName,
223
+ duration: result.duration
224
+ }, 'Step completed successfully');
310
225
  } catch (error) {
311
226
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
312
- context.steps[stepName].state = 'failed';
313
- context.steps[stepName].error = errorMessage;
314
- // Independent execution tracking for failed steps
315
- context.steps[stepName].executionInfo = {
316
- completed: true,
317
- success: false,
318
- executedAt: new Date().toISOString(),
319
- duration: Date.now() - (context.steps[stepName]._startTime || Date.now()),
320
- failureReason: errorMessage
227
+ result.status = 'failed';
228
+ result.error = errorMessage;
229
+ result.completedAt = new Date().toISOString();
230
+ result.duration = new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime();
231
+ context.steps[step.stepName] = {
232
+ state: 'failed',
233
+ input: resolvedInput,
234
+ error: errorMessage
321
235
  };
322
236
  this.logger.error({
323
- error: errorMessage,
324
- input: context.steps[stepName].input,
325
- stepName,
326
- taskSlug
237
+ stepName: step.stepName,
238
+ error: errorMessage
327
239
  }, 'Step execution failed');
328
- // Update workflow run with current step results if workflowRunId is provided
329
- if (workflowRunId) {
330
- try {
331
- await this.updateWorkflowRunContext(workflowRunId, context, req);
332
- } catch (updateError) {
333
- this.logger.error({
334
- error: updateError instanceof Error ? updateError.message : 'Unknown error',
335
- stepName
336
- }, 'Failed to update workflow run context after step failure');
337
- }
338
- }
339
240
  throw error;
340
241
  }
242
+ return result;
341
243
  }
342
244
  /**
343
- * Extracts detailed error information from job logs and input
344
- */ extractErrorDetailsFromJob(job, stepContext, stepName) {
245
+ * Resolve step input using JSONata expressions
246
+ */ async resolveStepInput(config, context) {
345
247
  try {
346
- // Get error information from multiple sources
347
- const input = stepContext.input || {};
348
- const logs = job.log || [];
349
- const latestLog = logs[logs.length - 1];
350
- // Extract error message from job error or log
351
- const errorMessage = job.error?.message || latestLog?.error?.message || 'Unknown error';
352
- // For timeout scenarios, check if it's a timeout based on duration and timeout setting
353
- let errorType = this.classifyErrorType(errorMessage);
354
- // Special handling for HTTP timeouts - if task failed and duration exceeds timeout, it's likely a timeout
355
- if (errorType === 'unknown' && input.timeout && stepContext.executionInfo?.duration) {
356
- const timeoutMs = parseInt(input.timeout) || 30000;
357
- const actualDuration = stepContext.executionInfo.duration;
358
- // If execution duration is close to or exceeds timeout, classify as timeout
359
- if (actualDuration >= timeoutMs * 0.9) {
360
- errorType = 'timeout';
361
- this.logger.debug({
362
- timeoutMs,
363
- actualDuration,
364
- stepName
365
- }, 'Classified error as timeout based on duration analysis');
366
- }
367
- }
368
- // Calculate duration from execution info if available
369
- const duration = stepContext.executionInfo?.duration || 0;
370
- // Extract attempt count from logs
371
- const attempts = job.totalTried || 1;
372
- return {
373
- stepId: `${stepName}-${Date.now()}`,
374
- errorType,
375
- duration,
376
- attempts,
377
- finalError: errorMessage,
378
- context: {
379
- url: input.url,
380
- method: input.method,
381
- timeout: input.timeout,
382
- statusCode: latestLog?.output?.status,
383
- headers: input.headers
384
- },
385
- timestamp: new Date().toISOString()
386
- };
248
+ return await resolveInput(config, context, {
249
+ timeout: 5000
250
+ });
387
251
  } catch (error) {
388
252
  this.logger.warn({
389
- error: error instanceof Error ? error.message : 'Unknown error',
390
- stepName
391
- }, 'Failed to extract error details from job');
392
- return null;
253
+ error: error instanceof Error ? error.message : 'Unknown error'
254
+ }, 'Failed to resolve step input, using raw config');
255
+ return config;
393
256
  }
394
257
  }
395
258
  /**
396
- * Resolve step execution order based on dependencies
397
- */ resolveExecutionOrder(steps) {
398
- const stepMap = new Map();
399
- const dependencyGraph = new Map();
400
- const indegree = new Map();
401
- // Build the step map and dependency graph
402
- for (const step of steps){
403
- const stepName = step.name || `step-${steps.indexOf(step)}`;
404
- const dependencies = step.dependencies || [];
405
- stepMap.set(stepName, {
406
- ...step,
407
- name: stepName,
408
- dependencies
259
+ * Evaluate a condition using JSONata
260
+ */ async evaluateCondition(condition, context) {
261
+ try {
262
+ return await evalCondition(condition, context, {
263
+ timeout: 5000
409
264
  });
410
- dependencyGraph.set(stepName, dependencies);
411
- indegree.set(stepName, dependencies.length);
412
- }
413
- // Topological sort to determine execution batches
414
- const executionBatches = [];
415
- const processed = new Set();
416
- while(processed.size < steps.length){
417
- const currentBatch = [];
418
- // Find all steps with no remaining dependencies
419
- for (const [stepName, inDegree] of indegree.entries()){
420
- if (inDegree === 0 && !processed.has(stepName)) {
421
- const step = stepMap.get(stepName);
422
- if (step) {
423
- currentBatch.push(step);
424
- }
425
- }
426
- }
427
- if (currentBatch.length === 0) {
428
- throw new Error('Circular dependency detected in workflow steps');
429
- }
430
- executionBatches.push(currentBatch);
431
- // Update indegrees for next iteration
432
- for (const step of currentBatch){
433
- processed.add(step.name);
434
- // Reduce indegree for steps that depend on completed steps
435
- for (const [otherStepName, dependencies] of dependencyGraph.entries()){
436
- if (dependencies.includes(step.name) && !processed.has(otherStepName)) {
437
- indegree.set(otherStepName, (indegree.get(otherStepName) || 0) - 1);
438
- }
439
- }
440
- }
441
- }
442
- return executionBatches;
443
- }
444
- /**
445
- * Resolve step input using Handlebars templates with automatic type conversion
446
- */ resolveStepInput(config, context, stepType) {
447
- const resolved = {};
448
- this.logger.debug({
449
- configKeys: Object.keys(config),
450
- contextSteps: Object.keys(context.steps),
451
- triggerType: context.trigger?.type,
452
- stepType
453
- }, 'Starting step input resolution with Handlebars');
454
- for (const [key, value] of Object.entries(config)){
455
- if (typeof value === 'string') {
456
- // Check if the string contains Handlebars templates
457
- if (value.includes('{{') && value.includes('}}')) {
458
- this.logger.debug({
459
- key,
460
- template: value,
461
- availableSteps: Object.keys(context.steps),
462
- hasTriggerData: !!context.trigger?.data,
463
- hasTriggerDoc: !!context.trigger?.doc
464
- }, 'Processing Handlebars template');
465
- try {
466
- const template = Handlebars.compile(value);
467
- const result = template(context);
468
- this.logger.debug({
469
- key,
470
- template: value,
471
- result: JSON.stringify(result).substring(0, 200),
472
- resultType: typeof result
473
- }, 'Handlebars template resolved successfully');
474
- resolved[key] = this.convertValueType(result, key);
475
- } catch (error) {
476
- this.logger.warn({
477
- error: error instanceof Error ? error.message : 'Unknown error',
478
- key,
479
- template: value,
480
- contextSnapshot: JSON.stringify(context).substring(0, 500)
481
- }, 'Failed to resolve Handlebars template');
482
- resolved[key] = value; // Keep original value if resolution fails
483
- }
484
- } else {
485
- // Regular string, apply type conversion
486
- resolved[key] = this.convertValueType(value, key);
487
- }
488
- } else if (typeof value === 'object' && value !== null) {
489
- // Recursively resolve nested objects
490
- this.logger.debug({
491
- key,
492
- nestedKeys: Object.keys(value)
493
- }, 'Recursively resolving nested object');
494
- resolved[key] = this.resolveStepInput(value, context, stepType);
495
- } else {
496
- // Keep literal values as-is
497
- resolved[key] = value;
498
- }
265
+ } catch (error) {
266
+ this.logger.warn({
267
+ condition,
268
+ error: error instanceof Error ? error.message : 'Unknown error'
269
+ }, 'Failed to evaluate condition');
270
+ return false;
499
271
  }
500
- this.logger.debug({
501
- resolvedKeys: Object.keys(resolved),
502
- originalKeys: Object.keys(config)
503
- }, 'Step input resolution completed');
504
- return resolved;
505
272
  }
506
273
  /**
507
- * Safely serialize an object, handling circular references and non-serializable values
274
+ * Safely serialize an object for storage
508
275
  */ safeSerialize(obj) {
509
276
  const seen = new WeakSet();
510
- const serialize = (value)=>{
277
+ // Keys to completely exclude from serialization
278
+ const excludeKeys = new Set([
279
+ 'table',
280
+ 'schema',
281
+ '_',
282
+ '__',
283
+ 'payload',
284
+ 'res',
285
+ 'transactionID',
286
+ 'i18n',
287
+ 'fallbackLocale'
288
+ ]);
289
+ // For req object, only keep these useful debugging properties
290
+ const reqAllowedKeys = new Set([
291
+ 'payloadAPI',
292
+ 'locale',
293
+ 'user',
294
+ 'method',
295
+ 'url'
296
+ ]);
297
+ const serialize = (value, parentKey)=>{
511
298
  if (value === null || typeof value !== 'object') {
512
299
  return value;
513
300
  }
@@ -516,18 +303,20 @@ export class WorkflowExecutor {
516
303
  }
517
304
  seen.add(value);
518
305
  if (Array.isArray(value)) {
519
- return value.map(serialize);
306
+ return value.map((v)=>serialize(v));
520
307
  }
521
308
  const result = {};
522
309
  for (const [key, val] of Object.entries(value)){
523
310
  try {
524
- // Skip non-serializable properties that are likely internal database objects
525
- if (key === 'table' || key === 'schema' || key === '_' || key === '__') {
311
+ if (excludeKeys.has(key)) {
526
312
  continue;
527
313
  }
528
- result[key] = serialize(val);
314
+ // Special handling for req object - only include allowed keys
315
+ if (parentKey === 'req' && !reqAllowedKeys.has(key)) {
316
+ continue;
317
+ }
318
+ result[key] = serialize(val, key);
529
319
  } catch {
530
- // Skip properties that can't be accessed or serialized
531
320
  result[key] = '[Non-serializable]';
532
321
  }
533
322
  }
@@ -536,277 +325,165 @@ export class WorkflowExecutor {
536
325
  return serialize(obj);
537
326
  }
538
327
  /**
539
- * Update workflow run with current context
540
- */ async updateWorkflowRunContext(workflowRunId, context, req) {
541
- const serializeContext = ()=>({
542
- steps: this.safeSerialize(context.steps),
543
- trigger: {
544
- type: context.trigger.type,
545
- collection: context.trigger.collection,
546
- data: this.safeSerialize(context.trigger.data),
547
- doc: this.safeSerialize(context.trigger.doc),
548
- operation: context.trigger.operation,
549
- previousDoc: this.safeSerialize(context.trigger.previousDoc),
550
- triggeredAt: context.trigger.triggeredAt,
551
- user: context.trigger.req?.user
552
- }
328
+ * Execute a workflow with the given context
329
+ */ async execute(workflow, context, req, firedTrigger) {
330
+ this.logger.info({
331
+ workflowId: workflow.id,
332
+ workflowName: workflow.name,
333
+ triggerId: firedTrigger?.id,
334
+ triggerName: firedTrigger?.name
335
+ }, 'Starting workflow execution');
336
+ const resolvedSteps = await this.resolveWorkflowSteps(workflow);
337
+ const stepResults = [];
338
+ for (const step of resolvedSteps){
339
+ stepResults.push({
340
+ step: step.stepId,
341
+ stepName: step.stepName,
342
+ stepIndex: step.stepIndex,
343
+ status: 'pending'
553
344
  });
554
- await this.payload.update({
555
- id: workflowRunId,
345
+ }
346
+ const workflowRun = await this.payload.create({
556
347
  collection: 'workflow-runs',
557
348
  data: {
558
- context: serializeContext()
349
+ workflow: workflow.id,
350
+ workflowVersion: 1,
351
+ firedTrigger: firedTrigger?.id,
352
+ triggerData: this.safeSerialize(context.trigger),
353
+ status: 'running',
354
+ startedAt: new Date().toISOString(),
355
+ triggeredBy: context.trigger.req?.user?.email || 'system',
356
+ stepResults,
357
+ context: this.safeSerialize(context),
358
+ inputs: this.safeSerialize(context.trigger),
359
+ logs: [
360
+ {
361
+ timestamp: new Date().toISOString(),
362
+ level: 'info',
363
+ message: 'Workflow execution started'
364
+ }
365
+ ]
559
366
  },
560
367
  req
561
368
  });
562
- }
563
- /**
564
- * Evaluate a condition using Handlebars templates and comparison operators
565
- */ evaluateCondition(condition, context) {
566
- this.logger.debug({
567
- condition,
568
- contextKeys: Object.keys(context),
569
- triggerType: context.trigger?.type,
570
- triggerData: context.trigger?.data,
571
- triggerDoc: context.trigger?.doc ? 'present' : 'absent'
572
- }, 'Starting condition evaluation');
573
- try {
574
- // Check if this is a comparison expression
575
- const comparisonMatch = condition.match(/^(.+?)\s*(==|!=|>|<|>=|<=)\s*(.+)$/);
576
- if (comparisonMatch) {
577
- const [, leftExpr, operator, rightExpr] = comparisonMatch;
578
- // Evaluate left side (could be Handlebars template or JSONPath)
579
- const leftValue = this.resolveConditionValue(leftExpr.trim(), context);
580
- // Evaluate right side (could be Handlebars template, JSONPath, or literal)
581
- const rightValue = this.resolveConditionValue(rightExpr.trim(), context);
582
- this.logger.debug({
583
- condition,
584
- leftExpr: leftExpr.trim(),
585
- leftValue,
586
- operator,
587
- rightExpr: rightExpr.trim(),
588
- rightValue,
589
- leftType: typeof leftValue,
590
- rightType: typeof rightValue
591
- }, 'Evaluating comparison condition');
592
- // Perform comparison
593
- let result;
594
- switch(operator){
595
- case '!=':
596
- result = leftValue !== rightValue;
597
- break;
598
- case '<':
599
- result = Number(leftValue) < Number(rightValue);
600
- break;
601
- case '<=':
602
- result = Number(leftValue) <= Number(rightValue);
603
- break;
604
- case '==':
605
- result = leftValue === rightValue;
606
- break;
607
- case '>':
608
- result = Number(leftValue) > Number(rightValue);
609
- break;
610
- case '>=':
611
- result = Number(leftValue) >= Number(rightValue);
612
- break;
613
- default:
614
- throw new Error(`Unknown comparison operator: ${operator}`);
615
- }
616
- this.logger.debug({
617
- condition,
618
- result,
619
- leftValue,
620
- rightValue,
621
- operator
622
- }, 'Comparison condition evaluation completed');
623
- return result;
624
- } else {
625
- // Treat as template or JSONPath boolean evaluation
626
- const result = this.resolveConditionValue(condition, context);
627
- this.logger.debug({
628
- condition,
629
- result,
630
- resultType: Array.isArray(result) ? 'array' : typeof result,
631
- resultLength: Array.isArray(result) ? result.length : undefined
632
- }, 'Boolean evaluation result');
633
- // Handle different result types
634
- let finalResult;
635
- if (Array.isArray(result)) {
636
- finalResult = result.length > 0 && Boolean(result[0]);
637
- } else {
638
- finalResult = Boolean(result);
639
- }
640
- this.logger.debug({
641
- condition,
642
- finalResult,
643
- originalResult: result
644
- }, 'Boolean condition evaluation completed');
645
- return finalResult;
646
- }
647
- } catch (error) {
648
- this.logger.warn({
649
- condition,
650
- error: error instanceof Error ? error.message : 'Unknown error',
651
- errorStack: error instanceof Error ? error.stack : undefined
652
- }, 'Failed to evaluate condition');
653
- // If condition evaluation fails, assume false
654
- return false;
655
- }
656
- }
657
- /**
658
- * Resolve a condition value using Handlebars templates or JSONPath
659
- */ resolveConditionValue(expr, context) {
660
- // Handle string literals
661
- if (expr.startsWith('"') && expr.endsWith('"') || expr.startsWith("'") && expr.endsWith("'")) {
662
- return expr.slice(1, -1) // Remove quotes
663
- ;
664
- }
665
- // Handle boolean literals
666
- if (expr === 'true') {
667
- return true;
668
- }
669
- if (expr === 'false') {
670
- return false;
671
- }
672
- // Handle number literals
673
- if (/^-?\d+(?:\.\d+)?$/.test(expr)) {
674
- return Number(expr);
675
- }
676
- // Handle Handlebars templates
677
- if (expr.includes('{{') && expr.includes('}}')) {
678
- try {
679
- const template = Handlebars.compile(expr);
680
- return template(context);
681
- } catch (error) {
682
- this.logger.warn({
683
- error: error instanceof Error ? error.message : 'Unknown error',
684
- expr
685
- }, 'Failed to resolve Handlebars condition');
686
- return false;
687
- }
688
- }
689
- // Return as string if nothing else matches
690
- return expr;
691
- }
692
- /**
693
- * Execute a workflow with the given context
694
- */ async execute(workflow, context, req) {
695
- this.logger.info({
696
- workflowId: workflow.id,
697
- workflowName: workflow.name
698
- }, 'Starting workflow execution');
699
- const serializeContext = ()=>({
700
- steps: this.safeSerialize(context.steps),
701
- trigger: {
702
- type: context.trigger.type,
703
- collection: context.trigger.collection,
704
- data: this.safeSerialize(context.trigger.data),
705
- doc: this.safeSerialize(context.trigger.doc),
706
- operation: context.trigger.operation,
707
- previousDoc: this.safeSerialize(context.trigger.previousDoc),
708
- triggeredAt: context.trigger.triggeredAt,
709
- user: context.trigger.req?.user
710
- }
711
- });
712
369
  this.logger.info({
713
- workflowId: workflow.id,
714
- workflowName: workflow.name,
715
- contextSummary: {
716
- triggerType: context.trigger.type,
717
- triggerCollection: context.trigger.collection,
718
- triggerOperation: context.trigger.operation,
719
- hasDoc: !!context.trigger.doc,
720
- userEmail: context.trigger.req?.user?.email
721
- }
722
- }, 'About to create workflow run record');
723
- // Create a workflow run record
724
- let workflowRun;
725
- try {
726
- workflowRun = await this.payload.create({
727
- collection: 'workflow-runs',
728
- data: {
729
- context: serializeContext(),
730
- startedAt: new Date().toISOString(),
731
- status: 'running',
732
- triggeredBy: context.trigger.req?.user?.email || 'system',
733
- workflow: workflow.id,
734
- workflowVersion: 1 // Default version since generated type doesn't have _version field
735
- },
736
- req
737
- });
738
- this.logger.info({
739
- workflowRunId: workflowRun.id,
740
- workflowId: workflow.id,
741
- workflowName: workflow.name
742
- }, 'Workflow run record created successfully');
743
- } catch (error) {
744
- this.logger.error({
745
- error: error instanceof Error ? error.message : 'Unknown error',
746
- errorStack: error instanceof Error ? error.stack : undefined,
747
- workflowId: workflow.id,
748
- workflowName: workflow.name
749
- }, 'Failed to create workflow run record');
750
- throw error;
751
- }
370
+ workflowRunId: workflowRun.id,
371
+ workflowId: workflow.id
372
+ }, 'Workflow run record created');
373
+ // Create job metadata for tracking workflow context in payload-jobs
374
+ const jobMeta = {
375
+ automationWorkflowId: workflow.id,
376
+ automationWorkflowRunId: workflowRun.id,
377
+ automationTriggerId: firedTrigger?.id
378
+ };
752
379
  try {
753
- // Resolve execution order based on dependencies
754
- const executionBatches = this.resolveExecutionOrder(workflow.steps || []);
380
+ const executionBatches = this.resolveExecutionOrder(resolvedSteps);
755
381
  this.logger.info({
756
- batchSizes: executionBatches.map((batch)=>batch.length),
757
- totalBatches: executionBatches.length
382
+ batchCount: executionBatches.length,
383
+ batchSizes: executionBatches.map((b)=>b.length)
758
384
  }, 'Resolved step execution order');
759
- // Execute each batch in sequence, but steps within each batch in parallel
760
385
  for(let batchIndex = 0; batchIndex < executionBatches.length; batchIndex++){
761
386
  const batch = executionBatches[batchIndex];
762
387
  this.logger.info({
763
388
  batchIndex,
764
389
  stepCount: batch.length,
765
- stepNames: batch.map((s)=>s.name)
390
+ stepNames: batch.map((s)=>s.stepName)
766
391
  }, 'Executing batch');
767
- // Execute all steps in this batch in parallel
768
- const batchPromises = batch.map((step, stepIndex)=>this.executeStep(step, stepIndex, context, req, workflowRun.id));
769
- // Wait for all steps in the current batch to complete
392
+ const batchPromises = batch.map(async (step)=>{
393
+ try {
394
+ const result = await this.executeStep(step, context, req, stepResults, jobMeta);
395
+ const idx = stepResults.findIndex((r)=>r.stepIndex === step.stepIndex);
396
+ if (idx !== -1) {
397
+ stepResults[idx] = result;
398
+ }
399
+ return result;
400
+ } catch (error) {
401
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
402
+ const idx = stepResults.findIndex((r)=>r.stepIndex === step.stepIndex);
403
+ if (idx !== -1) {
404
+ stepResults[idx] = {
405
+ ...stepResults[idx],
406
+ status: 'failed',
407
+ error: errorMessage,
408
+ completedAt: new Date().toISOString()
409
+ };
410
+ }
411
+ if (workflow.errorHandling === 'stop') {
412
+ throw error;
413
+ }
414
+ this.logger.warn({
415
+ stepName: step.stepName,
416
+ error: errorMessage
417
+ }, 'Step failed but continuing due to error handling setting');
418
+ }
419
+ });
770
420
  await Promise.all(batchPromises);
771
- this.logger.info({
772
- batchIndex,
773
- stepCount: batch.length
774
- }, 'Batch completed');
421
+ await this.payload.update({
422
+ id: workflowRun.id,
423
+ collection: 'workflow-runs',
424
+ data: {
425
+ stepResults,
426
+ context: this.safeSerialize(context)
427
+ },
428
+ req
429
+ });
430
+ }
431
+ const outputs = {};
432
+ for (const result of stepResults){
433
+ if (result.status === 'succeeded' && result.output) {
434
+ outputs[result.stepName] = result.output;
435
+ }
775
436
  }
776
- // Update workflow run as completed
777
437
  await this.payload.update({
778
438
  id: workflowRun.id,
779
439
  collection: 'workflow-runs',
780
440
  data: {
441
+ status: 'completed',
781
442
  completedAt: new Date().toISOString(),
782
- context: serializeContext(),
783
- status: 'completed'
443
+ stepResults,
444
+ context: this.safeSerialize(context),
445
+ outputs,
446
+ logs: [
447
+ ...workflowRun.logs || [],
448
+ {
449
+ timestamp: new Date().toISOString(),
450
+ level: 'info',
451
+ message: 'Workflow execution completed successfully'
452
+ }
453
+ ]
784
454
  },
785
455
  req
786
456
  });
787
457
  this.logger.info({
788
- runId: workflowRun.id,
789
- workflowId: workflow.id,
790
- workflowName: workflow.name
458
+ workflowRunId: workflowRun.id,
459
+ workflowId: workflow.id
791
460
  }, 'Workflow execution completed');
792
461
  } catch (error) {
793
- // Update workflow run as failed
462
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
794
463
  await this.payload.update({
795
464
  id: workflowRun.id,
796
465
  collection: 'workflow-runs',
797
466
  data: {
467
+ status: 'failed',
798
468
  completedAt: new Date().toISOString(),
799
- context: serializeContext(),
800
- error: error instanceof Error ? error.message : 'Unknown error',
801
- status: 'failed'
469
+ stepResults,
470
+ context: this.safeSerialize(context),
471
+ error: errorMessage,
472
+ logs: [
473
+ ...workflowRun.logs || [],
474
+ {
475
+ timestamp: new Date().toISOString(),
476
+ level: 'error',
477
+ message: `Workflow execution failed: ${errorMessage}`
478
+ }
479
+ ]
802
480
  },
803
481
  req
804
482
  });
805
483
  this.logger.error({
806
- error: error instanceof Error ? error.message : 'Unknown error',
807
- runId: workflowRun.id,
484
+ workflowRunId: workflowRun.id,
808
485
  workflowId: workflow.id,
809
- workflowName: workflow.name
486
+ error: errorMessage
810
487
  }, 'Workflow execution failed');
811
488
  throw error;
812
489
  }