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.
Files changed (80) hide show
  1. package/dist/action-job.d.ts +31 -0
  2. package/dist/action-job.d.ts.map +1 -1
  3. package/dist/action-job.js +68 -7
  4. package/dist/action-manager.d.ts +42 -0
  5. package/dist/action-manager.d.ts.map +1 -1
  6. package/dist/action-manager.js +61 -0
  7. package/dist/action.d.ts +144 -0
  8. package/dist/action.d.ts.map +1 -1
  9. package/dist/action.js +133 -2
  10. package/dist/adapters/adapter.d.ts +359 -0
  11. package/dist/adapters/adapter.d.ts.map +1 -1
  12. package/dist/adapters/adapter.js +208 -0
  13. package/dist/adapters/postgres/base.d.ts +166 -0
  14. package/dist/adapters/postgres/base.d.ts.map +1 -1
  15. package/dist/adapters/postgres/base.js +273 -19
  16. package/dist/adapters/postgres/pglite.d.ts +37 -0
  17. package/dist/adapters/postgres/pglite.d.ts.map +1 -1
  18. package/dist/adapters/postgres/pglite.js +38 -0
  19. package/dist/adapters/postgres/postgres.d.ts +35 -0
  20. package/dist/adapters/postgres/postgres.d.ts.map +1 -1
  21. package/dist/adapters/postgres/postgres.js +42 -0
  22. package/dist/adapters/postgres/schema.d.ts.map +1 -1
  23. package/dist/adapters/postgres/schema.js +14 -2
  24. package/dist/adapters/schemas.d.ts +9 -0
  25. package/dist/adapters/schemas.d.ts.map +1 -1
  26. package/dist/adapters/schemas.js +73 -1
  27. package/dist/client.d.ts +249 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +413 -3
  30. package/dist/constants.js +6 -0
  31. package/dist/errors.d.ts +166 -9
  32. package/dist/errors.d.ts.map +1 -1
  33. package/dist/errors.js +189 -19
  34. package/dist/server.d.ts +44 -0
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +56 -0
  37. package/dist/step-manager.d.ts +84 -0
  38. package/dist/step-manager.d.ts.map +1 -1
  39. package/dist/step-manager.js +354 -14
  40. package/dist/telemetry/adapter.d.ts +344 -0
  41. package/dist/telemetry/adapter.d.ts.map +1 -1
  42. package/dist/telemetry/adapter.js +151 -0
  43. package/dist/telemetry/index.d.ts +1 -1
  44. package/dist/telemetry/index.d.ts.map +1 -1
  45. package/dist/telemetry/index.js +1 -0
  46. package/dist/telemetry/local.d.ts +50 -1
  47. package/dist/telemetry/local.d.ts.map +1 -1
  48. package/dist/telemetry/local.js +165 -0
  49. package/dist/telemetry/noop.d.ts +12 -1
  50. package/dist/telemetry/noop.d.ts.map +1 -1
  51. package/dist/telemetry/noop.js +70 -0
  52. package/dist/telemetry/opentelemetry.d.ts +25 -1
  53. package/dist/telemetry/opentelemetry.d.ts.map +1 -1
  54. package/dist/telemetry/opentelemetry.js +149 -0
  55. package/dist/utils/p-retry.d.ts +5 -0
  56. package/dist/utils/p-retry.d.ts.map +1 -1
  57. package/dist/utils/p-retry.js +8 -0
  58. package/dist/utils/wait-for-abort.d.ts +1 -0
  59. package/dist/utils/wait-for-abort.d.ts.map +1 -1
  60. package/dist/utils/wait-for-abort.js +1 -0
  61. package/migrations/postgres/{20251203223656_conscious_johnny_blaze → 20260119153838_flimsy_thor_girl}/migration.sql +29 -2
  62. package/migrations/postgres/{20260118202533_wealthy_mysterio → 20260119153838_flimsy_thor_girl}/snapshot.json +5 -5
  63. package/package.json +1 -1
  64. package/src/action-job.ts +14 -7
  65. package/src/action.ts +23 -13
  66. package/src/adapters/postgres/base.ts +45 -19
  67. package/src/adapters/postgres/schema.ts +5 -2
  68. package/src/adapters/schemas.ts +11 -1
  69. package/src/client.ts +187 -8
  70. package/src/errors.ts +141 -30
  71. package/src/step-manager.ts +171 -10
  72. package/src/telemetry/adapter.ts +174 -0
  73. package/src/telemetry/index.ts +3 -0
  74. package/src/telemetry/local.ts +93 -0
  75. package/src/telemetry/noop.ts +46 -0
  76. package/src/telemetry/opentelemetry.ts +145 -2
  77. package/migrations/postgres/20251203223656_conscious_johnny_blaze/snapshot.json +0 -941
  78. package/migrations/postgres/20260117231749_clumsy_penance/migration.sql +0 -3
  79. package/migrations/postgres/20260117231749_clumsy_penance/snapshot.json +0 -988
  80. package/migrations/postgres/20260118202533_wealthy_mysterio/migration.sql +0 -24
@@ -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
- if (this.#historySteps.has(task.name)) {
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(task.name);
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
- const unhandledError = new UnhandledChildStepsError(name, unsettledChildren.length);
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' && isNonRetriableError(error.cause))) {
248
- throw error;
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: this.#abortSignal,
372
- parentStepId: null,
711
+ abortSignal,
712
+ parentStepId,
373
713
  parallel: parsedOptions.parallel,
374
714
  });
375
715
  }