duron 0.3.0-beta.10 → 0.3.0-beta.11
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/dist/action-job.d.ts +31 -0
- package/dist/action-job.d.ts.map +1 -1
- package/dist/action-job.js +59 -0
- package/dist/action-manager.d.ts +42 -0
- package/dist/action-manager.d.ts.map +1 -1
- package/dist/action-manager.js +61 -0
- package/dist/action.d.ts +143 -0
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +131 -0
- package/dist/adapters/adapter.d.ts +359 -0
- package/dist/adapters/adapter.d.ts.map +1 -1
- package/dist/adapters/adapter.js +208 -0
- package/dist/adapters/postgres/base.d.ts +166 -0
- package/dist/adapters/postgres/base.d.ts.map +1 -1
- package/dist/adapters/postgres/base.js +268 -17
- package/dist/adapters/postgres/pglite.d.ts +37 -0
- package/dist/adapters/postgres/pglite.d.ts.map +1 -1
- package/dist/adapters/postgres/pglite.js +38 -0
- package/dist/adapters/postgres/postgres.d.ts +35 -0
- package/dist/adapters/postgres/postgres.d.ts.map +1 -1
- package/dist/adapters/postgres/postgres.js +42 -0
- package/dist/adapters/postgres/schema.js +11 -1
- package/dist/adapters/schemas.d.ts +9 -0
- package/dist/adapters/schemas.d.ts.map +1 -1
- package/dist/adapters/schemas.js +73 -1
- package/dist/client.d.ts +224 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +311 -1
- package/dist/constants.js +6 -0
- package/dist/errors.d.ts +119 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +111 -0
- package/dist/server.d.ts +44 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +56 -0
- package/dist/step-manager.d.ts +83 -0
- package/dist/step-manager.d.ts.map +1 -1
- package/dist/step-manager.js +237 -5
- package/dist/telemetry/adapter.d.ts +322 -0
- package/dist/telemetry/adapter.d.ts.map +1 -1
- package/dist/telemetry/adapter.js +145 -0
- package/dist/telemetry/index.js +1 -0
- package/dist/telemetry/local.d.ts +48 -0
- package/dist/telemetry/local.d.ts.map +1 -1
- package/dist/telemetry/local.js +102 -0
- package/dist/telemetry/noop.d.ts +10 -0
- package/dist/telemetry/noop.d.ts.map +1 -1
- package/dist/telemetry/noop.js +43 -0
- package/dist/telemetry/opentelemetry.d.ts +23 -0
- package/dist/telemetry/opentelemetry.d.ts.map +1 -1
- package/dist/telemetry/opentelemetry.js +39 -0
- package/dist/utils/p-retry.d.ts +5 -0
- package/dist/utils/p-retry.d.ts.map +1 -1
- package/dist/utils/p-retry.js +8 -0
- package/dist/utils/wait-for-abort.d.ts +1 -0
- package/dist/utils/wait-for-abort.d.ts.map +1 -1
- package/dist/utils/wait-for-abort.js +1 -0
- package/package.json +1 -1
- package/src/adapters/postgres/base.ts +40 -17
- package/src/adapters/schemas.ts +11 -1
package/dist/step-manager.js
CHANGED
|
@@ -2,20 +2,30 @@ import fastq from 'fastq';
|
|
|
2
2
|
import { StepOptionsSchema, } from './action.js';
|
|
3
3
|
import { STEP_STATUS_CANCELLED, STEP_STATUS_COMPLETED, STEP_STATUS_FAILED } from './constants.js';
|
|
4
4
|
import { ActionCancelError, isCancelError, isNonRetriableError, isTimeoutError, NonRetriableError, StepAlreadyExecutedError, StepTimeoutError, serializeError, UnhandledChildStepsError, } from './errors.js';
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Noop Tracer (for fallback observe context)
|
|
7
|
+
// ============================================================================
|
|
5
8
|
const noopTracerSpan = {
|
|
6
9
|
setAttribute() {
|
|
10
|
+
// No-op
|
|
7
11
|
},
|
|
8
12
|
setAttributes() {
|
|
13
|
+
// No-op
|
|
9
14
|
},
|
|
10
15
|
addEvent() {
|
|
16
|
+
// No-op
|
|
11
17
|
},
|
|
12
18
|
recordException() {
|
|
19
|
+
// No-op
|
|
13
20
|
},
|
|
14
21
|
setStatusOk() {
|
|
22
|
+
// No-op
|
|
15
23
|
},
|
|
16
24
|
setStatusError() {
|
|
25
|
+
// No-op
|
|
17
26
|
},
|
|
18
27
|
end() {
|
|
28
|
+
// No-op
|
|
19
29
|
},
|
|
20
30
|
isRecording() {
|
|
21
31
|
return false;
|
|
@@ -29,11 +39,38 @@ const createNoopTracer = (name) => ({
|
|
|
29
39
|
});
|
|
30
40
|
import pRetry from './utils/p-retry.js';
|
|
31
41
|
import waitForAbort from './utils/wait-for-abort.js';
|
|
42
|
+
/**
|
|
43
|
+
* StepStore manages step records in the database.
|
|
44
|
+
* Provides methods to create, update, and delay steps.
|
|
45
|
+
*/
|
|
32
46
|
export class StepStore {
|
|
33
47
|
#adapter;
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Constructor
|
|
50
|
+
// ============================================================================
|
|
51
|
+
/**
|
|
52
|
+
* Create a new StepStore instance.
|
|
53
|
+
*
|
|
54
|
+
* @param adapter - The database adapter to use for step operations
|
|
55
|
+
*/
|
|
34
56
|
constructor(adapter) {
|
|
35
57
|
this.#adapter = adapter;
|
|
36
58
|
}
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Public API Methods
|
|
61
|
+
// ============================================================================
|
|
62
|
+
/**
|
|
63
|
+
* Get or create a step record in the database.
|
|
64
|
+
*
|
|
65
|
+
* @param jobId - The ID of the job this step belongs to
|
|
66
|
+
* @param name - The name of the step
|
|
67
|
+
* @param timeoutMs - Timeout in milliseconds for the step
|
|
68
|
+
* @param retriesLimit - Maximum number of retries for the step
|
|
69
|
+
* @param parentStepId - The ID of the parent step (null for root steps)
|
|
70
|
+
* @param parallel - Whether this step runs in parallel (independent from siblings during time travel)
|
|
71
|
+
* @returns Promise resolving to the created step ID
|
|
72
|
+
* @throws Error if step creation fails
|
|
73
|
+
*/
|
|
37
74
|
async getOrCreate(jobId, name, timeoutMs, retriesLimit, parentStepId = null, parallel = false) {
|
|
38
75
|
try {
|
|
39
76
|
return await this.#adapter.createOrRecoverJobStep({
|
|
@@ -49,6 +86,15 @@ export class StepStore {
|
|
|
49
86
|
throw new NonRetriableError(`Failed to get or create step "${name}" for job "${jobId}"`, { cause: error });
|
|
50
87
|
}
|
|
51
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Update the status of a step in the database.
|
|
91
|
+
*
|
|
92
|
+
* @param stepId - The ID of the step to update
|
|
93
|
+
* @param status - The new status (completed, failed, or cancelled)
|
|
94
|
+
* @param output - Optional output data for completed steps
|
|
95
|
+
* @param error - Optional error data for failed steps
|
|
96
|
+
* @returns Promise resolving to `true` if update succeeded, `false` otherwise
|
|
97
|
+
*/
|
|
52
98
|
async updateStatus(stepId, status, output, error) {
|
|
53
99
|
if (status === STEP_STATUS_COMPLETED) {
|
|
54
100
|
return this.#adapter.completeJobStep({ stepId, output });
|
|
@@ -61,10 +107,23 @@ export class StepStore {
|
|
|
61
107
|
}
|
|
62
108
|
return false;
|
|
63
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Delay a step execution.
|
|
112
|
+
* Used when a step fails and needs to be retried after a delay.
|
|
113
|
+
*
|
|
114
|
+
* @param stepId - The ID of the step to delay
|
|
115
|
+
* @param delayMs - The delay in milliseconds before retrying
|
|
116
|
+
* @param error - The error that caused the delay
|
|
117
|
+
* @returns Promise resolving to `true` if delayed successfully, `false` otherwise
|
|
118
|
+
*/
|
|
64
119
|
async delay(stepId, delayMs, error) {
|
|
65
120
|
return this.#adapter.delayJobStep({ stepId, delayMs, error });
|
|
66
121
|
}
|
|
67
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* StepManager manages steps for a single ActionJob.
|
|
125
|
+
* Each ActionJob has its own StepManager instance.
|
|
126
|
+
*/
|
|
68
127
|
export class StepManager {
|
|
69
128
|
#jobId;
|
|
70
129
|
#actionName;
|
|
@@ -72,10 +131,22 @@ export class StepManager {
|
|
|
72
131
|
#telemetry;
|
|
73
132
|
#queue;
|
|
74
133
|
#logger;
|
|
134
|
+
// each step name should be executed only once per parent (name + parentStepId)
|
|
75
135
|
#historySteps = new Set();
|
|
136
|
+
// Store step spans for nested step tracking
|
|
76
137
|
#stepSpans = new Map();
|
|
138
|
+
// Store the job span for creating step spans
|
|
77
139
|
#jobSpan = null;
|
|
140
|
+
// Factory function to create run functions with the correct parent step ID and abort signal
|
|
78
141
|
#runFnFactory = null;
|
|
142
|
+
// ============================================================================
|
|
143
|
+
// Constructor
|
|
144
|
+
// ============================================================================
|
|
145
|
+
/**
|
|
146
|
+
* Create a new StepManager instance.
|
|
147
|
+
*
|
|
148
|
+
* @param options - Configuration options for the step manager
|
|
149
|
+
*/
|
|
79
150
|
constructor(options) {
|
|
80
151
|
this.#jobId = options.jobId;
|
|
81
152
|
this.#actionName = options.actionName;
|
|
@@ -83,6 +154,7 @@ export class StepManager {
|
|
|
83
154
|
this.#telemetry = options.telemetry;
|
|
84
155
|
this.#stepStore = new StepStore(options.adapter);
|
|
85
156
|
this.#queue = fastq.promise(async (task) => {
|
|
157
|
+
// Create composite key: name + parentStepId (allows same name under different parents)
|
|
86
158
|
const stepKey = `${task.parentStepId ?? 'root'}:${task.name}`;
|
|
87
159
|
if (this.#historySteps.has(stepKey)) {
|
|
88
160
|
throw new StepAlreadyExecutedError(task.name, this.#jobId, this.#actionName);
|
|
@@ -91,36 +163,76 @@ export class StepManager {
|
|
|
91
163
|
return this.#executeStep(task.name, task.cb, task.options, task.abortSignal, task.parentStepId, task.parallel);
|
|
92
164
|
}, options.concurrencyLimit);
|
|
93
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Set the job span for this step manager.
|
|
168
|
+
* Called from ActionJob after the job span is created.
|
|
169
|
+
*/
|
|
94
170
|
setJobSpan(span) {
|
|
95
171
|
this.#jobSpan = span;
|
|
96
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Set the run function factory for executing step definitions from inline steps.
|
|
175
|
+
* Called from ActionContext after it's initialized.
|
|
176
|
+
*
|
|
177
|
+
* @param factory - A function that creates run functions with the correct parent step ID and abort signal
|
|
178
|
+
*/
|
|
97
179
|
setRunFnFactory(factory) {
|
|
98
180
|
this.#runFnFactory = factory;
|
|
99
181
|
}
|
|
182
|
+
// ============================================================================
|
|
183
|
+
// Public API Methods
|
|
184
|
+
// ============================================================================
|
|
185
|
+
/**
|
|
186
|
+
* Create an ActionContext for the action handler.
|
|
187
|
+
* The context provides access to input, variables, logger, and the step function.
|
|
188
|
+
*
|
|
189
|
+
* @param job - The job data including ID, input, and optional group key
|
|
190
|
+
* @param action - The action definition
|
|
191
|
+
* @param variables - Variables available to the action
|
|
192
|
+
* @param abortSignal - Abort signal for cancelling the action
|
|
193
|
+
* @param logger - Pino child logger for this job
|
|
194
|
+
* @param observeContext - Observability context for telemetry
|
|
195
|
+
* @returns ActionHandlerContext instance
|
|
196
|
+
*/
|
|
100
197
|
createActionContext(job, action, variables, abortSignal, logger, observeContext) {
|
|
101
198
|
return new ActionContext(this, job, action, variables, abortSignal, logger, observeContext);
|
|
102
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Create an observe context for a step.
|
|
202
|
+
*/
|
|
103
203
|
createStepObserveContext(stepId) {
|
|
104
204
|
const stepSpan = this.#stepSpans.get(stepId);
|
|
105
205
|
if (stepSpan) {
|
|
106
206
|
return this.#telemetry.createObserveContext(this.#jobId, stepId, stepSpan);
|
|
107
207
|
}
|
|
208
|
+
// Fallback to job span if step span not found
|
|
108
209
|
if (this.#jobSpan) {
|
|
109
210
|
return this.#telemetry.createObserveContext(this.#jobId, stepId, this.#jobSpan);
|
|
110
211
|
}
|
|
212
|
+
// No-op observe context
|
|
111
213
|
return {
|
|
112
214
|
recordMetric: () => {
|
|
215
|
+
// No-op
|
|
113
216
|
},
|
|
114
217
|
addSpanAttribute: () => {
|
|
218
|
+
// No-op
|
|
115
219
|
},
|
|
116
220
|
addSpanEvent: () => {
|
|
221
|
+
// No-op
|
|
117
222
|
},
|
|
118
223
|
getTracer: (name) => {
|
|
119
224
|
return createNoopTracer(name);
|
|
120
225
|
},
|
|
121
226
|
};
|
|
122
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Queue a step task for execution.
|
|
230
|
+
*
|
|
231
|
+
* @param task - The step task to queue
|
|
232
|
+
* @returns Promise resolving to the step result
|
|
233
|
+
*/
|
|
123
234
|
async push(task) {
|
|
235
|
+
// Warn about potential starvation when child steps are queued and all slots are occupied
|
|
124
236
|
if (task.parentStepId !== null && this.#queue.running() >= this.#queue.concurrency) {
|
|
125
237
|
this.#logger.warn({
|
|
126
238
|
jobId: this.#jobId,
|
|
@@ -134,9 +246,28 @@ export class StepManager {
|
|
|
134
246
|
}
|
|
135
247
|
return this.#queue.push(task);
|
|
136
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Clean up step queues by waiting for them to drain.
|
|
251
|
+
* Should be called when the job completes or is cancelled.
|
|
252
|
+
*/
|
|
137
253
|
async drain() {
|
|
138
254
|
await this.#queue.drain();
|
|
139
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Execute a step with retry logic and timeout handling.
|
|
258
|
+
* Creates a step record, queues the execution, and handles errors appropriately.
|
|
259
|
+
*
|
|
260
|
+
* @param name - The name of the step
|
|
261
|
+
* @param cb - The step handler function
|
|
262
|
+
* @param options - Step options including concurrency, retry, and expire settings
|
|
263
|
+
* @param abortSignal - Abort signal for cancelling the step
|
|
264
|
+
* @param parentStepId - The ID of the parent step (null for root steps)
|
|
265
|
+
* @returns Promise resolving to the step result
|
|
266
|
+
* @throws StepTimeoutError if the step times out
|
|
267
|
+
* @throws StepCancelError if the step is cancelled
|
|
268
|
+
* @throws UnhandledChildStepsError if child steps are not awaited
|
|
269
|
+
* @throws Error if the step fails
|
|
270
|
+
*/
|
|
140
271
|
async #executeStep(name, cb, options, abortSignal, parentStepId, parallel) {
|
|
141
272
|
const expire = options.expire;
|
|
142
273
|
const retryOptions = options.retry;
|
|
@@ -146,11 +277,13 @@ export class StepManager {
|
|
|
146
277
|
if (abortSignal.aborted) {
|
|
147
278
|
throw new ActionCancelError(this.#actionName, this.#jobId, { cause: 'step cancelled before create step' });
|
|
148
279
|
}
|
|
280
|
+
// Create step record with parentStepId and parallel
|
|
149
281
|
const newStep = await this.#stepStore.getOrCreate(this.#jobId, name, expire, retryOptions.limit, parentStepId, parallel);
|
|
150
282
|
if (!newStep) {
|
|
151
283
|
throw new NonRetriableError(`Failed to create step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`, { cause: 'step not created' });
|
|
152
284
|
}
|
|
153
285
|
step = newStep;
|
|
286
|
+
// Start step telemetry span
|
|
154
287
|
const parentSpan = parentStepId ? this.#stepSpans.get(parentStepId) : this.#jobSpan;
|
|
155
288
|
const stepSpan = await this.#telemetry.startStepSpan({
|
|
156
289
|
jobId: this.#jobId,
|
|
@@ -164,6 +297,7 @@ export class StepManager {
|
|
|
164
297
|
throw new ActionCancelError(this.#actionName, this.#jobId, { cause: 'step cancelled after create step' });
|
|
165
298
|
}
|
|
166
299
|
if (step.status === STEP_STATUS_COMPLETED) {
|
|
300
|
+
// this is how we recover a completed step
|
|
167
301
|
this.#logger.debug({ jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id, parentStepId }, '[StepManager] Step recovered (already completed)');
|
|
168
302
|
return step.output;
|
|
169
303
|
}
|
|
@@ -175,8 +309,10 @@ export class StepManager {
|
|
|
175
309
|
else if (step.status === STEP_STATUS_CANCELLED) {
|
|
176
310
|
throw new NonRetriableError(`Cannot recover a cancelled step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`, { cause: step.error });
|
|
177
311
|
}
|
|
312
|
+
// Log step start
|
|
178
313
|
this.#logger.debug({ jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id, parentStepId }, '[StepManager] Step started executing');
|
|
179
314
|
}
|
|
315
|
+
// Create abort controller for this step's timeout
|
|
180
316
|
const stepAbortController = new AbortController();
|
|
181
317
|
const timeoutId = setTimeout(() => {
|
|
182
318
|
const timeoutError = new StepTimeoutError(name, this.#jobId, expire, {
|
|
@@ -187,47 +323,59 @@ export class StepManager {
|
|
|
187
323
|
stepAbortController.abort(timeoutError);
|
|
188
324
|
}, expire);
|
|
189
325
|
timeoutId?.unref?.();
|
|
326
|
+
// Combine abort signals: parent chain + this step's timeout
|
|
190
327
|
const stepSignal = AbortSignal.any([abortSignal, stepAbortController.signal]);
|
|
191
328
|
const childSteps = [];
|
|
329
|
+
// Create abort controller for child steps (used when parent returns with pending children)
|
|
192
330
|
const childAbortController = new AbortController();
|
|
193
331
|
const childSignal = AbortSignal.any([stepSignal, childAbortController.signal]);
|
|
332
|
+
// Create observe context for this step
|
|
194
333
|
const stepObserveContext = this.createStepObserveContext(step.id);
|
|
334
|
+
// Create StepHandlerContext with nested step support
|
|
195
335
|
const stepContext = {
|
|
196
336
|
signal: stepSignal,
|
|
197
337
|
stepId: step.id,
|
|
198
338
|
parentStepId,
|
|
199
339
|
observe: stepObserveContext,
|
|
200
340
|
step: (childName, childCb, childOptions = {}) => {
|
|
341
|
+
// Inherit parent step options EXCEPT parallel (each step's parallel status is independent)
|
|
201
342
|
const { parallel: _parentParallel, ...inheritableOptions } = options;
|
|
202
343
|
const parsedChildOptions = StepOptionsSchema.parse({
|
|
203
344
|
...inheritableOptions,
|
|
204
345
|
...childOptions,
|
|
205
346
|
});
|
|
347
|
+
// Push child step with this step as parent
|
|
206
348
|
const childPromise = this.push({
|
|
207
349
|
name: childName,
|
|
208
350
|
cb: childCb,
|
|
209
351
|
options: parsedChildOptions,
|
|
210
|
-
abortSignal: childSignal,
|
|
211
|
-
parentStepId: step.id,
|
|
212
|
-
parallel: parsedChildOptions.parallel,
|
|
352
|
+
abortSignal: childSignal, // Child uses composed signal
|
|
353
|
+
parentStepId: step.id, // This step is the parent
|
|
354
|
+
parallel: parsedChildOptions.parallel, // Pass parallel option
|
|
213
355
|
});
|
|
356
|
+
// Track the child promise
|
|
214
357
|
const trackedChild = {
|
|
215
358
|
promise: childPromise,
|
|
216
359
|
settled: false,
|
|
217
360
|
};
|
|
218
361
|
childSteps.push(trackedChild);
|
|
362
|
+
// Mark as settled when done (success or failure)
|
|
363
|
+
// Use .then/.catch instead of .finally to properly handle rejections
|
|
219
364
|
childPromise
|
|
220
365
|
.then(() => {
|
|
221
366
|
trackedChild.settled = true;
|
|
222
367
|
})
|
|
223
368
|
.catch(() => {
|
|
224
369
|
trackedChild.settled = true;
|
|
370
|
+
// Swallow the error here - it will be re-thrown to the caller via the returned promise
|
|
371
|
+
// Note: sibling steps will be aborted when the error propagates to the action level
|
|
225
372
|
});
|
|
226
373
|
return childPromise;
|
|
227
374
|
},
|
|
228
375
|
run: this.#runFnFactory(step.id, childSignal),
|
|
229
376
|
};
|
|
230
377
|
try {
|
|
378
|
+
// Race between abort signal and callback execution
|
|
231
379
|
const abortPromise = waitForAbort(stepSignal);
|
|
232
380
|
const callbackPromise = cb(stepContext);
|
|
233
381
|
let result = null;
|
|
@@ -250,19 +398,26 @@ export class StepManager {
|
|
|
250
398
|
abortPromise.release();
|
|
251
399
|
}),
|
|
252
400
|
]);
|
|
401
|
+
// If callback threw an error, abort children and wait for them before re-throwing
|
|
253
402
|
if (callbackError) {
|
|
254
403
|
if (childSteps.length > 0) {
|
|
404
|
+
// Abort all children with the callback error as reason
|
|
255
405
|
childAbortController.abort(callbackError);
|
|
406
|
+
// Wait for all children to settle
|
|
256
407
|
await Promise.allSettled(childSteps.map((c) => c.promise));
|
|
257
408
|
}
|
|
258
409
|
throw callbackError;
|
|
259
410
|
}
|
|
411
|
+
// If aborted, wait for child steps to settle before propagating
|
|
260
412
|
if (aborted) {
|
|
413
|
+
// Wait for all child steps to settle (they'll be aborted via signal propagation)
|
|
261
414
|
if (childSteps.length > 0) {
|
|
262
415
|
await Promise.allSettled(childSteps.map((c) => c.promise));
|
|
263
416
|
}
|
|
417
|
+
// Re-throw the abort reason
|
|
264
418
|
throw stepSignal.reason;
|
|
265
419
|
}
|
|
420
|
+
// After parent callback returns, check for pending children
|
|
266
421
|
const unsettledChildren = childSteps.filter((c) => !c.settled);
|
|
267
422
|
if (unsettledChildren.length > 0) {
|
|
268
423
|
this.#logger.warn({
|
|
@@ -272,6 +427,7 @@ export class StepManager {
|
|
|
272
427
|
stepId: step.id,
|
|
273
428
|
pendingCount: unsettledChildren.length,
|
|
274
429
|
}, '[StepManager] Parent step completed with unhandled child steps - aborting children');
|
|
430
|
+
// Abort all pending children
|
|
275
431
|
const unhandledError = new UnhandledChildStepsError(name, unsettledChildren.length, {
|
|
276
432
|
stepId: step.id,
|
|
277
433
|
parentStepId,
|
|
@@ -279,18 +435,23 @@ export class StepManager {
|
|
|
279
435
|
actionName: this.#actionName,
|
|
280
436
|
});
|
|
281
437
|
childAbortController.abort(unhandledError);
|
|
438
|
+
// Wait for all children to settle (they'll reject with cancellation)
|
|
282
439
|
await Promise.allSettled(unsettledChildren.map((c) => c.promise));
|
|
440
|
+
// Now throw the error
|
|
283
441
|
throw unhandledError;
|
|
284
442
|
}
|
|
443
|
+
// Update step as completed
|
|
285
444
|
const completed = await this.#stepStore.updateStatus(step.id, 'completed', result);
|
|
286
445
|
if (!completed) {
|
|
287
446
|
throw new Error(`Failed to complete step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`);
|
|
288
447
|
}
|
|
448
|
+
// End step telemetry span successfully
|
|
289
449
|
const stepSpan = this.#stepSpans.get(step.id);
|
|
290
450
|
if (stepSpan) {
|
|
291
451
|
await this.#telemetry.endStepSpan(stepSpan, { status: 'ok' });
|
|
292
452
|
this.#stepSpans.delete(step.id);
|
|
293
453
|
}
|
|
454
|
+
// Log step completion
|
|
294
455
|
this.#logger.debug({ jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id }, '[StepManager] Step finished executing');
|
|
295
456
|
return result;
|
|
296
457
|
}
|
|
@@ -298,6 +459,7 @@ export class StepManager {
|
|
|
298
459
|
clearTimeout(timeoutId);
|
|
299
460
|
}
|
|
300
461
|
};
|
|
462
|
+
// Apply retry logic - skip retries for NonRetriableError
|
|
301
463
|
return pRetry(executeStep, {
|
|
302
464
|
retries: retryOptions.limit,
|
|
303
465
|
factor: retryOptions.factor,
|
|
@@ -307,6 +469,7 @@ export class StepManager {
|
|
|
307
469
|
maxTimeout: retryOptions.maxTimeout,
|
|
308
470
|
onFailedAttempt: async (ctx) => {
|
|
309
471
|
const error = ctx.error;
|
|
472
|
+
// Don't retry if error is non-retriable
|
|
310
473
|
if (isNonRetriableError(error) ||
|
|
311
474
|
(error.cause && isNonRetriableError(error.cause)) ||
|
|
312
475
|
(error instanceof Error && error.name === 'AbortError')) {
|
|
@@ -351,6 +514,7 @@ export class StepManager {
|
|
|
351
514
|
},
|
|
352
515
|
}).catch(async (error) => {
|
|
353
516
|
if (step) {
|
|
517
|
+
// End step telemetry span with error/cancelled status
|
|
354
518
|
const stepSpan = this.#stepSpans.get(step.id);
|
|
355
519
|
if (stepSpan) {
|
|
356
520
|
if (isCancelError(error)) {
|
|
@@ -371,6 +535,11 @@ export class StepManager {
|
|
|
371
535
|
throw error;
|
|
372
536
|
});
|
|
373
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* Clear the history of nested steps for a given step.
|
|
540
|
+
* We do't need to clear the history for the root step because it's not a parent step, it's the action itself.
|
|
541
|
+
* @param stepId - The ID of the step to clear the history for
|
|
542
|
+
*/
|
|
374
543
|
#clearHistoryForStep(stepId) {
|
|
375
544
|
this.#historySteps.forEach((stepKey) => {
|
|
376
545
|
if (stepKey.startsWith(stepId)) {
|
|
@@ -379,6 +548,13 @@ export class StepManager {
|
|
|
379
548
|
});
|
|
380
549
|
}
|
|
381
550
|
}
|
|
551
|
+
// ============================================================================
|
|
552
|
+
// ActionContext Class
|
|
553
|
+
// ============================================================================
|
|
554
|
+
/**
|
|
555
|
+
* ActionContext provides the context for action handlers.
|
|
556
|
+
* It implements ActionHandlerContext and provides access to input, variables, logger, and the step function.
|
|
557
|
+
*/
|
|
382
558
|
class ActionContext {
|
|
383
559
|
#stepManager;
|
|
384
560
|
#variables;
|
|
@@ -389,6 +565,9 @@ class ActionContext {
|
|
|
389
565
|
#groupKey = '@default';
|
|
390
566
|
#action;
|
|
391
567
|
#observeContext;
|
|
568
|
+
// ============================================================================
|
|
569
|
+
// Constructor
|
|
570
|
+
// ============================================================================
|
|
392
571
|
constructor(stepManager, job, action, variables, abortSignal, logger, observeContext) {
|
|
393
572
|
this.#stepManager = stepManager;
|
|
394
573
|
this.#variables = variables;
|
|
@@ -407,28 +586,63 @@ class ActionContext {
|
|
|
407
586
|
this.#input = job.input ?? {};
|
|
408
587
|
this.step = this.step.bind(this);
|
|
409
588
|
this.run = this.run.bind(this);
|
|
589
|
+
// Set the run function factory so inline steps can call step definitions with correct parent
|
|
410
590
|
this.#stepManager.setRunFnFactory((parentStepId, abortSignal) => {
|
|
411
591
|
return (stepDef, input, options) => this.#runInternal(stepDef, input, options, parentStepId, abortSignal);
|
|
412
592
|
});
|
|
413
593
|
}
|
|
594
|
+
// ============================================================================
|
|
595
|
+
// Public API Methods
|
|
596
|
+
// ============================================================================
|
|
597
|
+
/**
|
|
598
|
+
* Get the input data for this action.
|
|
599
|
+
*/
|
|
414
600
|
get input() {
|
|
415
601
|
return this.#input;
|
|
416
602
|
}
|
|
603
|
+
/**
|
|
604
|
+
* Get the job ID for this action context.
|
|
605
|
+
*
|
|
606
|
+
* @returns The job ID
|
|
607
|
+
*/
|
|
417
608
|
get jobId() {
|
|
418
609
|
return this.#jobId;
|
|
419
610
|
}
|
|
611
|
+
/**
|
|
612
|
+
* Get the group key for this action context.
|
|
613
|
+
*
|
|
614
|
+
* @returns The group key
|
|
615
|
+
*/
|
|
420
616
|
get groupKey() {
|
|
421
617
|
return this.#groupKey;
|
|
422
618
|
}
|
|
619
|
+
/**
|
|
620
|
+
* Get the variables available to this action.
|
|
621
|
+
*/
|
|
423
622
|
get var() {
|
|
424
623
|
return this.#variables;
|
|
425
624
|
}
|
|
625
|
+
/**
|
|
626
|
+
* Get the logger for this action job.
|
|
627
|
+
*/
|
|
426
628
|
get logger() {
|
|
427
629
|
return this.#logger;
|
|
428
630
|
}
|
|
631
|
+
/**
|
|
632
|
+
* Get the observability context for recording metrics and span data.
|
|
633
|
+
*/
|
|
429
634
|
get observe() {
|
|
430
635
|
return this.#observeContext;
|
|
431
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* Execute a step within the action.
|
|
639
|
+
* This creates a root step (no parent).
|
|
640
|
+
*
|
|
641
|
+
* @param name - The name of the step
|
|
642
|
+
* @param cb - The step handler function
|
|
643
|
+
* @param options - Optional step options (will be merged with defaults)
|
|
644
|
+
* @returns Promise resolving to the step result
|
|
645
|
+
*/
|
|
432
646
|
async step(name, cb, options = {}) {
|
|
433
647
|
const parsedOptions = StepOptionsSchema.parse({
|
|
434
648
|
...this.#action.steps,
|
|
@@ -439,21 +653,38 @@ class ActionContext {
|
|
|
439
653
|
cb,
|
|
440
654
|
options: parsedOptions,
|
|
441
655
|
abortSignal: this.#abortSignal,
|
|
442
|
-
parentStepId: null,
|
|
443
|
-
parallel: parsedOptions.parallel,
|
|
656
|
+
parentStepId: null, // Root steps have no parent
|
|
657
|
+
parallel: parsedOptions.parallel, // Pass parallel option
|
|
444
658
|
});
|
|
445
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* Execute a reusable step definition created with createStep().
|
|
662
|
+
* This is the public method called from action handlers.
|
|
663
|
+
*
|
|
664
|
+
* @param stepDef - The step definition to execute
|
|
665
|
+
* @param input - The input data for the step (validated against the step's input schema)
|
|
666
|
+
* @param options - Optional step configuration overrides
|
|
667
|
+
* @returns Promise resolving to the step result
|
|
668
|
+
*/
|
|
446
669
|
async run(stepDef, input, options = {}) {
|
|
447
670
|
return this.#runInternal(stepDef, input, options, null, this.#abortSignal);
|
|
448
671
|
}
|
|
672
|
+
/**
|
|
673
|
+
* Internal method to execute a step definition with explicit parent step ID and abort signal.
|
|
674
|
+
* Used by both the public run method and the run functions passed to step contexts.
|
|
675
|
+
*/
|
|
449
676
|
async #runInternal(stepDef, input, options = {}, parentStepId, abortSignal) {
|
|
677
|
+
// Validate input against the step's schema if provided
|
|
678
|
+
// After parsing, validatedInput is z.output<TStepInput> (same as z.infer<TStepInput>)
|
|
450
679
|
const validatedInput = stepDef.input
|
|
451
680
|
? stepDef.input.parse(input, {
|
|
452
681
|
error: () => 'Error parsing step input',
|
|
453
682
|
reportInput: true,
|
|
454
683
|
})
|
|
455
684
|
: input;
|
|
685
|
+
// Resolve step name (static or dynamic)
|
|
456
686
|
const stepName = typeof stepDef.name === 'function' ? stepDef.name({ input: validatedInput }) : stepDef.name;
|
|
687
|
+
// Merge options: action defaults -> step definition -> call-time overrides
|
|
457
688
|
const mergedOptions = {
|
|
458
689
|
...this.#action.steps,
|
|
459
690
|
...(stepDef.retry !== undefined && { retry: stepDef.retry }),
|
|
@@ -462,6 +693,7 @@ class ActionContext {
|
|
|
462
693
|
...options,
|
|
463
694
|
};
|
|
464
695
|
const parsedOptions = StepOptionsSchema.parse(mergedOptions);
|
|
696
|
+
// Create a wrapper callback that provides the extended context
|
|
465
697
|
const wrappedCb = async (baseCtx) => {
|
|
466
698
|
const extendedCtx = {
|
|
467
699
|
...baseCtx,
|