duron 0.3.0-beta.1 → 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 +68 -7
- 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 +144 -0
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +133 -2
- 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 +273 -19
- 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.d.ts.map +1 -1
- package/dist/adapters/postgres/schema.js +14 -2
- 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 +249 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +413 -3
- package/dist/constants.js +6 -0
- package/dist/errors.d.ts +166 -9
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +189 -19
- 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 +84 -0
- package/dist/step-manager.d.ts.map +1 -1
- package/dist/step-manager.js +354 -14
- package/dist/telemetry/adapter.d.ts +344 -0
- package/dist/telemetry/adapter.d.ts.map +1 -1
- package/dist/telemetry/adapter.js +151 -0
- package/dist/telemetry/index.d.ts +1 -1
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/telemetry/index.js +1 -0
- package/dist/telemetry/local.d.ts +50 -1
- package/dist/telemetry/local.d.ts.map +1 -1
- package/dist/telemetry/local.js +165 -0
- package/dist/telemetry/noop.d.ts +12 -1
- package/dist/telemetry/noop.d.ts.map +1 -1
- package/dist/telemetry/noop.js +70 -0
- package/dist/telemetry/opentelemetry.d.ts +25 -1
- package/dist/telemetry/opentelemetry.d.ts.map +1 -1
- package/dist/telemetry/opentelemetry.js +149 -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/migrations/postgres/{20251203223656_conscious_johnny_blaze → 20260119153838_flimsy_thor_girl}/migration.sql +29 -2
- package/migrations/postgres/{20260118202533_wealthy_mysterio → 20260119153838_flimsy_thor_girl}/snapshot.json +5 -5
- package/package.json +1 -1
- package/src/action-job.ts +14 -7
- package/src/action.ts +23 -13
- package/src/adapters/postgres/base.ts +45 -19
- package/src/adapters/postgres/schema.ts +5 -2
- package/src/adapters/schemas.ts +11 -1
- package/src/client.ts +187 -8
- package/src/errors.ts +141 -30
- package/src/step-manager.ts +171 -10
- package/src/telemetry/adapter.ts +174 -0
- package/src/telemetry/index.ts +3 -0
- package/src/telemetry/local.ts +93 -0
- package/src/telemetry/noop.ts +46 -0
- package/src/telemetry/opentelemetry.ts +145 -2
- package/migrations/postgres/20251203223656_conscious_johnny_blaze/snapshot.json +0 -941
- package/migrations/postgres/20260117231749_clumsy_penance/migration.sql +0 -3
- package/migrations/postgres/20260117231749_clumsy_penance/snapshot.json +0 -988
- package/migrations/postgres/20260118202533_wealthy_mysterio/migration.sql +0 -24
package/dist/step-manager.js
CHANGED
|
@@ -1,14 +1,76 @@
|
|
|
1
1
|
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
|
-
import { ActionCancelError, isCancelError, isNonRetriableError, NonRetriableError, StepAlreadyExecutedError, StepTimeoutError, serializeError, UnhandledChildStepsError, } from './errors.js';
|
|
4
|
+
import { ActionCancelError, isCancelError, isNonRetriableError, isTimeoutError, NonRetriableError, StepAlreadyExecutedError, StepTimeoutError, serializeError, UnhandledChildStepsError, } from './errors.js';
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Noop Tracer (for fallback observe context)
|
|
7
|
+
// ============================================================================
|
|
8
|
+
const noopTracerSpan = {
|
|
9
|
+
setAttribute() {
|
|
10
|
+
// No-op
|
|
11
|
+
},
|
|
12
|
+
setAttributes() {
|
|
13
|
+
// No-op
|
|
14
|
+
},
|
|
15
|
+
addEvent() {
|
|
16
|
+
// No-op
|
|
17
|
+
},
|
|
18
|
+
recordException() {
|
|
19
|
+
// No-op
|
|
20
|
+
},
|
|
21
|
+
setStatusOk() {
|
|
22
|
+
// No-op
|
|
23
|
+
},
|
|
24
|
+
setStatusError() {
|
|
25
|
+
// No-op
|
|
26
|
+
},
|
|
27
|
+
end() {
|
|
28
|
+
// No-op
|
|
29
|
+
},
|
|
30
|
+
isRecording() {
|
|
31
|
+
return false;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const createNoopTracer = (name) => ({
|
|
35
|
+
name,
|
|
36
|
+
startSpan() {
|
|
37
|
+
return noopTracerSpan;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
5
40
|
import pRetry from './utils/p-retry.js';
|
|
6
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
|
+
*/
|
|
7
46
|
export class StepStore {
|
|
8
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
|
+
*/
|
|
9
56
|
constructor(adapter) {
|
|
10
57
|
this.#adapter = adapter;
|
|
11
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
|
+
*/
|
|
12
74
|
async getOrCreate(jobId, name, timeoutMs, retriesLimit, parentStepId = null, parallel = false) {
|
|
13
75
|
try {
|
|
14
76
|
return await this.#adapter.createOrRecoverJobStep({
|
|
@@ -24,6 +86,15 @@ export class StepStore {
|
|
|
24
86
|
throw new NonRetriableError(`Failed to get or create step "${name}" for job "${jobId}"`, { cause: error });
|
|
25
87
|
}
|
|
26
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
|
+
*/
|
|
27
98
|
async updateStatus(stepId, status, output, error) {
|
|
28
99
|
if (status === STEP_STATUS_COMPLETED) {
|
|
29
100
|
return this.#adapter.completeJobStep({ stepId, output });
|
|
@@ -36,10 +107,23 @@ export class StepStore {
|
|
|
36
107
|
}
|
|
37
108
|
return false;
|
|
38
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
|
+
*/
|
|
39
119
|
async delay(stepId, delayMs, error) {
|
|
40
120
|
return this.#adapter.delayJobStep({ stepId, delayMs, error });
|
|
41
121
|
}
|
|
42
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* StepManager manages steps for a single ActionJob.
|
|
125
|
+
* Each ActionJob has its own StepManager instance.
|
|
126
|
+
*/
|
|
43
127
|
export class StepManager {
|
|
44
128
|
#jobId;
|
|
45
129
|
#actionName;
|
|
@@ -47,9 +131,22 @@ export class StepManager {
|
|
|
47
131
|
#telemetry;
|
|
48
132
|
#queue;
|
|
49
133
|
#logger;
|
|
134
|
+
// each step name should be executed only once per parent (name + parentStepId)
|
|
50
135
|
#historySteps = new Set();
|
|
136
|
+
// Store step spans for nested step tracking
|
|
51
137
|
#stepSpans = new Map();
|
|
138
|
+
// Store the job span for creating step spans
|
|
52
139
|
#jobSpan = null;
|
|
140
|
+
// Factory function to create run functions with the correct parent step ID and abort signal
|
|
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
|
+
*/
|
|
53
150
|
constructor(options) {
|
|
54
151
|
this.#jobId = options.jobId;
|
|
55
152
|
this.#actionName = options.actionName;
|
|
@@ -57,42 +154,120 @@ export class StepManager {
|
|
|
57
154
|
this.#telemetry = options.telemetry;
|
|
58
155
|
this.#stepStore = new StepStore(options.adapter);
|
|
59
156
|
this.#queue = fastq.promise(async (task) => {
|
|
60
|
-
|
|
157
|
+
// Create composite key: name + parentStepId (allows same name under different parents)
|
|
158
|
+
const stepKey = `${task.parentStepId ?? 'root'}:${task.name}`;
|
|
159
|
+
if (this.#historySteps.has(stepKey)) {
|
|
61
160
|
throw new StepAlreadyExecutedError(task.name, this.#jobId, this.#actionName);
|
|
62
161
|
}
|
|
63
|
-
this.#historySteps.add(
|
|
162
|
+
this.#historySteps.add(stepKey);
|
|
64
163
|
return this.#executeStep(task.name, task.cb, task.options, task.abortSignal, task.parentStepId, task.parallel);
|
|
65
164
|
}, options.concurrencyLimit);
|
|
66
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Set the job span for this step manager.
|
|
168
|
+
* Called from ActionJob after the job span is created.
|
|
169
|
+
*/
|
|
67
170
|
setJobSpan(span) {
|
|
68
171
|
this.#jobSpan = span;
|
|
69
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
|
+
*/
|
|
179
|
+
setRunFnFactory(factory) {
|
|
180
|
+
this.#runFnFactory = factory;
|
|
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
|
+
*/
|
|
70
197
|
createActionContext(job, action, variables, abortSignal, logger, observeContext) {
|
|
71
198
|
return new ActionContext(this, job, action, variables, abortSignal, logger, observeContext);
|
|
72
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Create an observe context for a step.
|
|
202
|
+
*/
|
|
73
203
|
createStepObserveContext(stepId) {
|
|
74
204
|
const stepSpan = this.#stepSpans.get(stepId);
|
|
75
205
|
if (stepSpan) {
|
|
76
206
|
return this.#telemetry.createObserveContext(this.#jobId, stepId, stepSpan);
|
|
77
207
|
}
|
|
208
|
+
// Fallback to job span if step span not found
|
|
78
209
|
if (this.#jobSpan) {
|
|
79
210
|
return this.#telemetry.createObserveContext(this.#jobId, stepId, this.#jobSpan);
|
|
80
211
|
}
|
|
212
|
+
// No-op observe context
|
|
81
213
|
return {
|
|
82
214
|
recordMetric: () => {
|
|
215
|
+
// No-op
|
|
83
216
|
},
|
|
84
217
|
addSpanAttribute: () => {
|
|
218
|
+
// No-op
|
|
85
219
|
},
|
|
86
220
|
addSpanEvent: () => {
|
|
221
|
+
// No-op
|
|
222
|
+
},
|
|
223
|
+
getTracer: (name) => {
|
|
224
|
+
return createNoopTracer(name);
|
|
87
225
|
},
|
|
88
226
|
};
|
|
89
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
|
+
*/
|
|
90
234
|
async push(task) {
|
|
235
|
+
// Warn about potential starvation when child steps are queued and all slots are occupied
|
|
236
|
+
if (task.parentStepId !== null && this.#queue.running() >= this.#queue.concurrency) {
|
|
237
|
+
this.#logger.warn({
|
|
238
|
+
jobId: this.#jobId,
|
|
239
|
+
actionName: this.#actionName,
|
|
240
|
+
stepName: task.name,
|
|
241
|
+
parentStepId: task.parentStepId,
|
|
242
|
+
running: this.#queue.running(),
|
|
243
|
+
waiting: this.#queue.length(),
|
|
244
|
+
concurrency: this.#queue.concurrency,
|
|
245
|
+
}, '[StepManager] Potential starvation: child step queued while all concurrency slots are occupied by parent steps. Consider increasing steps.concurrency.');
|
|
246
|
+
}
|
|
91
247
|
return this.#queue.push(task);
|
|
92
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
|
+
*/
|
|
93
253
|
async drain() {
|
|
94
254
|
await this.#queue.drain();
|
|
95
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
|
+
*/
|
|
96
271
|
async #executeStep(name, cb, options, abortSignal, parentStepId, parallel) {
|
|
97
272
|
const expire = options.expire;
|
|
98
273
|
const retryOptions = options.retry;
|
|
@@ -102,11 +277,13 @@ export class StepManager {
|
|
|
102
277
|
if (abortSignal.aborted) {
|
|
103
278
|
throw new ActionCancelError(this.#actionName, this.#jobId, { cause: 'step cancelled before create step' });
|
|
104
279
|
}
|
|
280
|
+
// Create step record with parentStepId and parallel
|
|
105
281
|
const newStep = await this.#stepStore.getOrCreate(this.#jobId, name, expire, retryOptions.limit, parentStepId, parallel);
|
|
106
282
|
if (!newStep) {
|
|
107
283
|
throw new NonRetriableError(`Failed to create step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`, { cause: 'step not created' });
|
|
108
284
|
}
|
|
109
285
|
step = newStep;
|
|
286
|
+
// Start step telemetry span
|
|
110
287
|
const parentSpan = parentStepId ? this.#stepSpans.get(parentStepId) : this.#jobSpan;
|
|
111
288
|
const stepSpan = await this.#telemetry.startStepSpan({
|
|
112
289
|
jobId: this.#jobId,
|
|
@@ -120,6 +297,7 @@ export class StepManager {
|
|
|
120
297
|
throw new ActionCancelError(this.#actionName, this.#jobId, { cause: 'step cancelled after create step' });
|
|
121
298
|
}
|
|
122
299
|
if (step.status === STEP_STATUS_COMPLETED) {
|
|
300
|
+
// this is how we recover a completed step
|
|
123
301
|
this.#logger.debug({ jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id, parentStepId }, '[StepManager] Step recovered (already completed)');
|
|
124
302
|
return step.output;
|
|
125
303
|
}
|
|
@@ -131,58 +309,78 @@ export class StepManager {
|
|
|
131
309
|
else if (step.status === STEP_STATUS_CANCELLED) {
|
|
132
310
|
throw new NonRetriableError(`Cannot recover a cancelled step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`, { cause: step.error });
|
|
133
311
|
}
|
|
312
|
+
// Log step start
|
|
134
313
|
this.#logger.debug({ jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id, parentStepId }, '[StepManager] Step started executing');
|
|
135
314
|
}
|
|
315
|
+
// Create abort controller for this step's timeout
|
|
136
316
|
const stepAbortController = new AbortController();
|
|
137
317
|
const timeoutId = setTimeout(() => {
|
|
138
|
-
const timeoutError = new StepTimeoutError(name, this.#jobId, expire
|
|
318
|
+
const timeoutError = new StepTimeoutError(name, this.#jobId, expire, {
|
|
319
|
+
stepId: step?.id,
|
|
320
|
+
parentStepId,
|
|
321
|
+
actionName: this.#actionName,
|
|
322
|
+
});
|
|
139
323
|
stepAbortController.abort(timeoutError);
|
|
140
324
|
}, expire);
|
|
141
325
|
timeoutId?.unref?.();
|
|
326
|
+
// Combine abort signals: parent chain + this step's timeout
|
|
142
327
|
const stepSignal = AbortSignal.any([abortSignal, stepAbortController.signal]);
|
|
143
328
|
const childSteps = [];
|
|
329
|
+
// Create abort controller for child steps (used when parent returns with pending children)
|
|
144
330
|
const childAbortController = new AbortController();
|
|
145
331
|
const childSignal = AbortSignal.any([stepSignal, childAbortController.signal]);
|
|
332
|
+
// Create observe context for this step
|
|
146
333
|
const stepObserveContext = this.createStepObserveContext(step.id);
|
|
334
|
+
// Create StepHandlerContext with nested step support
|
|
147
335
|
const stepContext = {
|
|
148
336
|
signal: stepSignal,
|
|
149
337
|
stepId: step.id,
|
|
150
338
|
parentStepId,
|
|
151
339
|
observe: stepObserveContext,
|
|
152
340
|
step: (childName, childCb, childOptions = {}) => {
|
|
341
|
+
// Inherit parent step options EXCEPT parallel (each step's parallel status is independent)
|
|
153
342
|
const { parallel: _parentParallel, ...inheritableOptions } = options;
|
|
154
343
|
const parsedChildOptions = StepOptionsSchema.parse({
|
|
155
344
|
...inheritableOptions,
|
|
156
345
|
...childOptions,
|
|
157
346
|
});
|
|
347
|
+
// Push child step with this step as parent
|
|
158
348
|
const childPromise = this.push({
|
|
159
349
|
name: childName,
|
|
160
350
|
cb: childCb,
|
|
161
351
|
options: parsedChildOptions,
|
|
162
|
-
abortSignal: childSignal,
|
|
163
|
-
parentStepId: step.id,
|
|
164
|
-
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
|
|
165
355
|
});
|
|
356
|
+
// Track the child promise
|
|
166
357
|
const trackedChild = {
|
|
167
358
|
promise: childPromise,
|
|
168
359
|
settled: false,
|
|
169
360
|
};
|
|
170
361
|
childSteps.push(trackedChild);
|
|
362
|
+
// Mark as settled when done (success or failure)
|
|
363
|
+
// Use .then/.catch instead of .finally to properly handle rejections
|
|
171
364
|
childPromise
|
|
172
365
|
.then(() => {
|
|
173
366
|
trackedChild.settled = true;
|
|
174
367
|
})
|
|
175
368
|
.catch(() => {
|
|
176
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
|
|
177
372
|
});
|
|
178
373
|
return childPromise;
|
|
179
374
|
},
|
|
375
|
+
run: this.#runFnFactory(step.id, childSignal),
|
|
180
376
|
};
|
|
181
377
|
try {
|
|
378
|
+
// Race between abort signal and callback execution
|
|
182
379
|
const abortPromise = waitForAbort(stepSignal);
|
|
183
380
|
const callbackPromise = cb(stepContext);
|
|
184
381
|
let result = null;
|
|
185
382
|
let aborted = false;
|
|
383
|
+
let callbackError = null;
|
|
186
384
|
await Promise.race([
|
|
187
385
|
abortPromise.promise.then(() => {
|
|
188
386
|
aborted = true;
|
|
@@ -192,17 +390,34 @@ export class StepManager {
|
|
|
192
390
|
if (res !== undefined && res !== null) {
|
|
193
391
|
result = res;
|
|
194
392
|
}
|
|
393
|
+
})
|
|
394
|
+
.catch((err) => {
|
|
395
|
+
callbackError = err;
|
|
195
396
|
})
|
|
196
397
|
.finally(() => {
|
|
197
398
|
abortPromise.release();
|
|
198
399
|
}),
|
|
199
400
|
]);
|
|
401
|
+
// If callback threw an error, abort children and wait for them before re-throwing
|
|
402
|
+
if (callbackError) {
|
|
403
|
+
if (childSteps.length > 0) {
|
|
404
|
+
// Abort all children with the callback error as reason
|
|
405
|
+
childAbortController.abort(callbackError);
|
|
406
|
+
// Wait for all children to settle
|
|
407
|
+
await Promise.allSettled(childSteps.map((c) => c.promise));
|
|
408
|
+
}
|
|
409
|
+
throw callbackError;
|
|
410
|
+
}
|
|
411
|
+
// If aborted, wait for child steps to settle before propagating
|
|
200
412
|
if (aborted) {
|
|
413
|
+
// Wait for all child steps to settle (they'll be aborted via signal propagation)
|
|
201
414
|
if (childSteps.length > 0) {
|
|
202
415
|
await Promise.allSettled(childSteps.map((c) => c.promise));
|
|
203
416
|
}
|
|
417
|
+
// Re-throw the abort reason
|
|
204
418
|
throw stepSignal.reason;
|
|
205
419
|
}
|
|
420
|
+
// After parent callback returns, check for pending children
|
|
206
421
|
const unsettledChildren = childSteps.filter((c) => !c.settled);
|
|
207
422
|
if (unsettledChildren.length > 0) {
|
|
208
423
|
this.#logger.warn({
|
|
@@ -212,20 +427,31 @@ export class StepManager {
|
|
|
212
427
|
stepId: step.id,
|
|
213
428
|
pendingCount: unsettledChildren.length,
|
|
214
429
|
}, '[StepManager] Parent step completed with unhandled child steps - aborting children');
|
|
215
|
-
|
|
430
|
+
// Abort all pending children
|
|
431
|
+
const unhandledError = new UnhandledChildStepsError(name, unsettledChildren.length, {
|
|
432
|
+
stepId: step.id,
|
|
433
|
+
parentStepId,
|
|
434
|
+
jobId: this.#jobId,
|
|
435
|
+
actionName: this.#actionName,
|
|
436
|
+
});
|
|
216
437
|
childAbortController.abort(unhandledError);
|
|
438
|
+
// Wait for all children to settle (they'll reject with cancellation)
|
|
217
439
|
await Promise.allSettled(unsettledChildren.map((c) => c.promise));
|
|
440
|
+
// Now throw the error
|
|
218
441
|
throw unhandledError;
|
|
219
442
|
}
|
|
443
|
+
// Update step as completed
|
|
220
444
|
const completed = await this.#stepStore.updateStatus(step.id, 'completed', result);
|
|
221
445
|
if (!completed) {
|
|
222
446
|
throw new Error(`Failed to complete step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`);
|
|
223
447
|
}
|
|
448
|
+
// End step telemetry span successfully
|
|
224
449
|
const stepSpan = this.#stepSpans.get(step.id);
|
|
225
450
|
if (stepSpan) {
|
|
226
451
|
await this.#telemetry.endStepSpan(stepSpan, { status: 'ok' });
|
|
227
452
|
this.#stepSpans.delete(step.id);
|
|
228
453
|
}
|
|
454
|
+
// Log step completion
|
|
229
455
|
this.#logger.debug({ jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id }, '[StepManager] Step finished executing');
|
|
230
456
|
return result;
|
|
231
457
|
}
|
|
@@ -233,6 +459,7 @@ export class StepManager {
|
|
|
233
459
|
clearTimeout(timeoutId);
|
|
234
460
|
}
|
|
235
461
|
};
|
|
462
|
+
// Apply retry logic - skip retries for NonRetriableError
|
|
236
463
|
return pRetry(executeStep, {
|
|
237
464
|
retries: retryOptions.limit,
|
|
238
465
|
factor: retryOptions.factor,
|
|
@@ -242,20 +469,52 @@ export class StepManager {
|
|
|
242
469
|
maxTimeout: retryOptions.maxTimeout,
|
|
243
470
|
onFailedAttempt: async (ctx) => {
|
|
244
471
|
const error = ctx.error;
|
|
472
|
+
// Don't retry if error is non-retriable
|
|
245
473
|
if (isNonRetriableError(error) ||
|
|
246
474
|
(error.cause && isNonRetriableError(error.cause)) ||
|
|
247
|
-
(error instanceof Error && error.name === 'AbortError'
|
|
248
|
-
|
|
475
|
+
(error instanceof Error && error.name === 'AbortError')) {
|
|
476
|
+
const err = isNonRetriableError(error)
|
|
477
|
+
? error
|
|
478
|
+
: error instanceof Error && error.name === 'AbortError'
|
|
479
|
+
? new NonRetriableError(error.message, { cause: error.cause })
|
|
480
|
+
: error.cause;
|
|
481
|
+
if (Object.keys(err.metadata).length === 0) {
|
|
482
|
+
err.setMetadata({
|
|
483
|
+
stepId: step?.id,
|
|
484
|
+
parentStepId,
|
|
485
|
+
jobId: this.#jobId,
|
|
486
|
+
actionName: this.#actionName,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
throw err;
|
|
249
490
|
}
|
|
250
491
|
if (ctx.retriesLeft > 0 && step) {
|
|
492
|
+
this.#clearHistoryForStep(step.id);
|
|
251
493
|
const delayed = await this.#stepStore.delay(step.id, ctx.finalDelay, serializeError(error));
|
|
252
494
|
if (!delayed) {
|
|
253
495
|
throw new Error(`Failed to delay step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`);
|
|
254
496
|
}
|
|
255
497
|
}
|
|
498
|
+
else {
|
|
499
|
+
if (isTimeoutError(error)) {
|
|
500
|
+
;
|
|
501
|
+
error.nonRetriable = true;
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
505
|
+
const err = new NonRetriableError(errorMessage, { cause: error });
|
|
506
|
+
err.setMetadata({
|
|
507
|
+
stepId: step?.id,
|
|
508
|
+
parentStepId,
|
|
509
|
+
jobId: this.#jobId,
|
|
510
|
+
actionName: this.#actionName,
|
|
511
|
+
});
|
|
512
|
+
throw err;
|
|
513
|
+
}
|
|
256
514
|
},
|
|
257
515
|
}).catch(async (error) => {
|
|
258
516
|
if (step) {
|
|
517
|
+
// End step telemetry span with error/cancelled status
|
|
259
518
|
const stepSpan = this.#stepSpans.get(step.id);
|
|
260
519
|
if (stepSpan) {
|
|
261
520
|
if (isCancelError(error)) {
|
|
@@ -276,7 +535,26 @@ export class StepManager {
|
|
|
276
535
|
throw error;
|
|
277
536
|
});
|
|
278
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
|
+
*/
|
|
543
|
+
#clearHistoryForStep(stepId) {
|
|
544
|
+
this.#historySteps.forEach((stepKey) => {
|
|
545
|
+
if (stepKey.startsWith(stepId)) {
|
|
546
|
+
this.#historySteps.delete(stepKey);
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
}
|
|
279
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
|
+
*/
|
|
280
558
|
class ActionContext {
|
|
281
559
|
#stepManager;
|
|
282
560
|
#variables;
|
|
@@ -287,6 +565,9 @@ class ActionContext {
|
|
|
287
565
|
#groupKey = '@default';
|
|
288
566
|
#action;
|
|
289
567
|
#observeContext;
|
|
568
|
+
// ============================================================================
|
|
569
|
+
// Constructor
|
|
570
|
+
// ============================================================================
|
|
290
571
|
constructor(stepManager, job, action, variables, abortSignal, logger, observeContext) {
|
|
291
572
|
this.#stepManager = stepManager;
|
|
292
573
|
this.#variables = variables;
|
|
@@ -305,25 +586,63 @@ class ActionContext {
|
|
|
305
586
|
this.#input = job.input ?? {};
|
|
306
587
|
this.step = this.step.bind(this);
|
|
307
588
|
this.run = this.run.bind(this);
|
|
589
|
+
// Set the run function factory so inline steps can call step definitions with correct parent
|
|
590
|
+
this.#stepManager.setRunFnFactory((parentStepId, abortSignal) => {
|
|
591
|
+
return (stepDef, input, options) => this.#runInternal(stepDef, input, options, parentStepId, abortSignal);
|
|
592
|
+
});
|
|
308
593
|
}
|
|
594
|
+
// ============================================================================
|
|
595
|
+
// Public API Methods
|
|
596
|
+
// ============================================================================
|
|
597
|
+
/**
|
|
598
|
+
* Get the input data for this action.
|
|
599
|
+
*/
|
|
309
600
|
get input() {
|
|
310
601
|
return this.#input;
|
|
311
602
|
}
|
|
603
|
+
/**
|
|
604
|
+
* Get the job ID for this action context.
|
|
605
|
+
*
|
|
606
|
+
* @returns The job ID
|
|
607
|
+
*/
|
|
312
608
|
get jobId() {
|
|
313
609
|
return this.#jobId;
|
|
314
610
|
}
|
|
611
|
+
/**
|
|
612
|
+
* Get the group key for this action context.
|
|
613
|
+
*
|
|
614
|
+
* @returns The group key
|
|
615
|
+
*/
|
|
315
616
|
get groupKey() {
|
|
316
617
|
return this.#groupKey;
|
|
317
618
|
}
|
|
619
|
+
/**
|
|
620
|
+
* Get the variables available to this action.
|
|
621
|
+
*/
|
|
318
622
|
get var() {
|
|
319
623
|
return this.#variables;
|
|
320
624
|
}
|
|
625
|
+
/**
|
|
626
|
+
* Get the logger for this action job.
|
|
627
|
+
*/
|
|
321
628
|
get logger() {
|
|
322
629
|
return this.#logger;
|
|
323
630
|
}
|
|
631
|
+
/**
|
|
632
|
+
* Get the observability context for recording metrics and span data.
|
|
633
|
+
*/
|
|
324
634
|
get observe() {
|
|
325
635
|
return this.#observeContext;
|
|
326
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
|
+
*/
|
|
327
646
|
async step(name, cb, options = {}) {
|
|
328
647
|
const parsedOptions = StepOptionsSchema.parse({
|
|
329
648
|
...this.#action.steps,
|
|
@@ -334,18 +653,38 @@ class ActionContext {
|
|
|
334
653
|
cb,
|
|
335
654
|
options: parsedOptions,
|
|
336
655
|
abortSignal: this.#abortSignal,
|
|
337
|
-
parentStepId: null,
|
|
338
|
-
parallel: parsedOptions.parallel,
|
|
656
|
+
parentStepId: null, // Root steps have no parent
|
|
657
|
+
parallel: parsedOptions.parallel, // Pass parallel option
|
|
339
658
|
});
|
|
340
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
|
+
*/
|
|
341
669
|
async run(stepDef, input, options = {}) {
|
|
670
|
+
return this.#runInternal(stepDef, input, options, null, this.#abortSignal);
|
|
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
|
+
*/
|
|
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>)
|
|
342
679
|
const validatedInput = stepDef.input
|
|
343
680
|
? stepDef.input.parse(input, {
|
|
344
681
|
error: () => 'Error parsing step input',
|
|
345
682
|
reportInput: true,
|
|
346
683
|
})
|
|
347
684
|
: input;
|
|
685
|
+
// Resolve step name (static or dynamic)
|
|
348
686
|
const stepName = typeof stepDef.name === 'function' ? stepDef.name({ input: validatedInput }) : stepDef.name;
|
|
687
|
+
// Merge options: action defaults -> step definition -> call-time overrides
|
|
349
688
|
const mergedOptions = {
|
|
350
689
|
...this.#action.steps,
|
|
351
690
|
...(stepDef.retry !== undefined && { retry: stepDef.retry }),
|
|
@@ -354,6 +693,7 @@ class ActionContext {
|
|
|
354
693
|
...options,
|
|
355
694
|
};
|
|
356
695
|
const parsedOptions = StepOptionsSchema.parse(mergedOptions);
|
|
696
|
+
// Create a wrapper callback that provides the extended context
|
|
357
697
|
const wrappedCb = async (baseCtx) => {
|
|
358
698
|
const extendedCtx = {
|
|
359
699
|
...baseCtx,
|
|
@@ -368,8 +708,8 @@ class ActionContext {
|
|
|
368
708
|
name: stepName,
|
|
369
709
|
cb: wrappedCb,
|
|
370
710
|
options: parsedOptions,
|
|
371
|
-
abortSignal
|
|
372
|
-
parentStepId
|
|
711
|
+
abortSignal,
|
|
712
|
+
parentStepId,
|
|
373
713
|
parallel: parsedOptions.parallel,
|
|
374
714
|
});
|
|
375
715
|
}
|