duron 0.2.2 → 0.3.0-beta.1

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 (77) hide show
  1. package/dist/action-job.d.ts +2 -0
  2. package/dist/action-job.d.ts.map +1 -1
  3. package/dist/action-job.js +20 -1
  4. package/dist/action-manager.d.ts +2 -0
  5. package/dist/action-manager.d.ts.map +1 -1
  6. package/dist/action-manager.js +3 -0
  7. package/dist/action.d.ts +27 -0
  8. package/dist/action.d.ts.map +1 -1
  9. package/dist/action.js +9 -0
  10. package/dist/adapters/adapter.d.ts +10 -2
  11. package/dist/adapters/adapter.d.ts.map +1 -1
  12. package/dist/adapters/adapter.js +59 -1
  13. package/dist/adapters/postgres/base.d.ts +9 -4
  14. package/dist/adapters/postgres/base.d.ts.map +1 -1
  15. package/dist/adapters/postgres/base.js +269 -19
  16. package/dist/adapters/postgres/schema.d.ts +249 -105
  17. package/dist/adapters/postgres/schema.d.ts.map +1 -1
  18. package/dist/adapters/postgres/schema.default.d.ts +249 -106
  19. package/dist/adapters/postgres/schema.default.d.ts.map +1 -1
  20. package/dist/adapters/postgres/schema.default.js +2 -2
  21. package/dist/adapters/postgres/schema.js +29 -1
  22. package/dist/adapters/schemas.d.ts +140 -7
  23. package/dist/adapters/schemas.d.ts.map +1 -1
  24. package/dist/adapters/schemas.js +52 -4
  25. package/dist/client.d.ts +8 -1
  26. package/dist/client.d.ts.map +1 -1
  27. package/dist/client.js +28 -0
  28. package/dist/errors.d.ts +6 -0
  29. package/dist/errors.d.ts.map +1 -1
  30. package/dist/errors.js +16 -1
  31. package/dist/index.d.ts +4 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +4 -2
  34. package/dist/server.d.ts +220 -16
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +123 -8
  37. package/dist/step-manager.d.ts +8 -2
  38. package/dist/step-manager.d.ts.map +1 -1
  39. package/dist/step-manager.js +174 -15
  40. package/dist/telemetry/adapter.d.ts +85 -0
  41. package/dist/telemetry/adapter.d.ts.map +1 -0
  42. package/dist/telemetry/adapter.js +128 -0
  43. package/dist/telemetry/index.d.ts +5 -0
  44. package/dist/telemetry/index.d.ts.map +1 -0
  45. package/dist/telemetry/index.js +4 -0
  46. package/dist/telemetry/local.d.ts +21 -0
  47. package/dist/telemetry/local.d.ts.map +1 -0
  48. package/dist/telemetry/local.js +180 -0
  49. package/dist/telemetry/noop.d.ts +16 -0
  50. package/dist/telemetry/noop.d.ts.map +1 -0
  51. package/dist/telemetry/noop.js +39 -0
  52. package/dist/telemetry/opentelemetry.d.ts +24 -0
  53. package/dist/telemetry/opentelemetry.d.ts.map +1 -0
  54. package/dist/telemetry/opentelemetry.js +202 -0
  55. package/migrations/postgres/20260117231749_clumsy_penance/migration.sql +3 -0
  56. package/migrations/postgres/20260117231749_clumsy_penance/snapshot.json +988 -0
  57. package/migrations/postgres/20260118202533_wealthy_mysterio/migration.sql +24 -0
  58. package/migrations/postgres/20260118202533_wealthy_mysterio/snapshot.json +1362 -0
  59. package/package.json +6 -4
  60. package/src/action-job.ts +35 -0
  61. package/src/action-manager.ts +5 -0
  62. package/src/action.ts +199 -0
  63. package/src/adapters/adapter.ts +151 -0
  64. package/src/adapters/postgres/base.ts +342 -23
  65. package/src/adapters/postgres/schema.default.ts +2 -2
  66. package/src/adapters/postgres/schema.ts +49 -1
  67. package/src/adapters/schemas.ts +81 -5
  68. package/src/client.ts +78 -0
  69. package/src/errors.ts +45 -1
  70. package/src/index.ts +10 -2
  71. package/src/server.ts +163 -8
  72. package/src/step-manager.ts +293 -13
  73. package/src/telemetry/adapter.ts +468 -0
  74. package/src/telemetry/index.ts +17 -0
  75. package/src/telemetry/local.ts +336 -0
  76. package/src/telemetry/noop.ts +95 -0
  77. package/src/telemetry/opentelemetry.ts +310 -0
@@ -5,6 +5,8 @@ import type { z } from 'zod'
5
5
  import {
6
6
  type Action,
7
7
  type ActionHandlerContext,
8
+ type StepDefinition,
9
+ type StepDefinitionHandlerContext,
8
10
  type StepHandlerContext,
9
11
  type StepOptions,
10
12
  StepOptionsSchema,
@@ -19,7 +21,9 @@ import {
19
21
  StepAlreadyExecutedError,
20
22
  StepTimeoutError,
21
23
  serializeError,
24
+ UnhandledChildStepsError,
22
25
  } from './errors.js'
26
+ import type { ObserveContext, Span, TelemetryAdapter } from './telemetry/adapter.js'
23
27
  import pRetry from './utils/p-retry.js'
24
28
  import waitForAbort from './utils/wait-for-abort.js'
25
29
 
@@ -28,6 +32,8 @@ export interface TaskStep {
28
32
  cb: (ctx: StepHandlerContext) => Promise<any>
29
33
  options: StepOptions
30
34
  abortSignal: AbortSignal
35
+ parentStepId: string | null
36
+ parallel: boolean
31
37
  }
32
38
 
33
39
  /**
@@ -61,16 +67,27 @@ export class StepStore {
61
67
  * @param name - The name of the step
62
68
  * @param timeoutMs - Timeout in milliseconds for the step
63
69
  * @param retriesLimit - Maximum number of retries for the step
70
+ * @param parentStepId - The ID of the parent step (null for root steps)
71
+ * @param parallel - Whether this step runs in parallel (independent from siblings during time travel)
64
72
  * @returns Promise resolving to the created step ID
65
73
  * @throws Error if step creation fails
66
74
  */
67
- async getOrCreate(jobId: string, name: string, timeoutMs: number, retriesLimit: number) {
75
+ async getOrCreate(
76
+ jobId: string,
77
+ name: string,
78
+ timeoutMs: number,
79
+ retriesLimit: number,
80
+ parentStepId: string | null = null,
81
+ parallel: boolean = false,
82
+ ) {
68
83
  try {
69
84
  return await this.#adapter.createOrRecoverJobStep({
70
85
  jobId,
71
86
  name,
72
87
  timeoutMs,
73
88
  retriesLimit,
89
+ parentStepId,
90
+ parallel,
74
91
  })
75
92
  } catch (error) {
76
93
  throw new NonRetriableError(`Failed to get or create step "${name}" for job "${jobId}"`, { cause: error })
@@ -115,6 +132,7 @@ export interface StepManagerOptions {
115
132
  jobId: string
116
133
  actionName: string
117
134
  adapter: Adapter
135
+ telemetry: TelemetryAdapter
118
136
  logger: Logger
119
137
  concurrencyLimit: number
120
138
  }
@@ -127,10 +145,15 @@ export class StepManager {
127
145
  #jobId: string
128
146
  #actionName: string
129
147
  #stepStore: StepStore
148
+ #telemetry: TelemetryAdapter
130
149
  #queue: fastq.queueAsPromised<TaskStep, any>
131
150
  #logger: Logger
132
151
  // each step name should be executed only once per action job
133
152
  #historySteps = new Set<string>()
153
+ // Store step spans for nested step tracking
154
+ #stepSpans = new Map<string, Span>()
155
+ // Store the job span for creating step spans
156
+ #jobSpan: Span | null = null
134
157
 
135
158
  // ============================================================================
136
159
  // Constructor
@@ -145,16 +168,25 @@ export class StepManager {
145
168
  this.#jobId = options.jobId
146
169
  this.#actionName = options.actionName
147
170
  this.#logger = options.logger
171
+ this.#telemetry = options.telemetry
148
172
  this.#stepStore = new StepStore(options.adapter)
149
173
  this.#queue = fastq.promise(async (task: TaskStep) => {
150
174
  if (this.#historySteps.has(task.name)) {
151
175
  throw new StepAlreadyExecutedError(task.name, this.#jobId, this.#actionName)
152
176
  }
153
177
  this.#historySteps.add(task.name)
154
- return this.#executeStep(task.name, task.cb, task.options, task.abortSignal)
178
+ return this.#executeStep(task.name, task.cb, task.options, task.abortSignal, task.parentStepId, task.parallel)
155
179
  }, options.concurrencyLimit)
156
180
  }
157
181
 
182
+ /**
183
+ * Set the job span for this step manager.
184
+ * Called from ActionJob after the job span is created.
185
+ */
186
+ setJobSpan(span: Span): void {
187
+ this.#jobSpan = span
188
+ }
189
+
158
190
  // ============================================================================
159
191
  // Public API Methods
160
192
  // ============================================================================
@@ -168,6 +200,7 @@ export class StepManager {
168
200
  * @param variables - Variables available to the action
169
201
  * @param abortSignal - Abort signal for cancelling the action
170
202
  * @param logger - Pino child logger for this job
203
+ * @param observeContext - Observability context for telemetry
171
204
  * @returns ActionHandlerContext instance
172
205
  */
173
206
  createActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVariables = Record<string, unknown>>(
@@ -176,8 +209,35 @@ export class StepManager {
176
209
  variables: TVariables,
177
210
  abortSignal: AbortSignal,
178
211
  logger: Logger,
212
+ observeContext: ObserveContext,
179
213
  ): ActionHandlerContext<TInput, TVariables> {
180
- return new ActionContext(this, job, action, variables, abortSignal, logger)
214
+ return new ActionContext(this, job, action, variables, abortSignal, logger, observeContext)
215
+ }
216
+
217
+ /**
218
+ * Create an observe context for a step.
219
+ */
220
+ createStepObserveContext(stepId: string): ObserveContext {
221
+ const stepSpan = this.#stepSpans.get(stepId)
222
+ if (stepSpan) {
223
+ return this.#telemetry.createObserveContext(this.#jobId, stepId, stepSpan)
224
+ }
225
+ // Fallback to job span if step span not found
226
+ if (this.#jobSpan) {
227
+ return this.#telemetry.createObserveContext(this.#jobId, stepId, this.#jobSpan)
228
+ }
229
+ // No-op observe context
230
+ return {
231
+ recordMetric: () => {
232
+ // No-op
233
+ },
234
+ addSpanAttribute: () => {
235
+ // No-op
236
+ },
237
+ addSpanEvent: () => {
238
+ // No-op
239
+ },
240
+ }
181
241
  }
182
242
 
183
243
  /**
@@ -206,9 +266,11 @@ export class StepManager {
206
266
  * @param cb - The step handler function
207
267
  * @param options - Step options including concurrency, retry, and expire settings
208
268
  * @param abortSignal - Abort signal for cancelling the step
269
+ * @param parentStepId - The ID of the parent step (null for root steps)
209
270
  * @returns Promise resolving to the step result
210
271
  * @throws StepTimeoutError if the step times out
211
272
  * @throws StepCancelError if the step is cancelled
273
+ * @throws UnhandledChildStepsError if child steps are not awaited
212
274
  * @throws Error if the step fails
213
275
  */
214
276
  async #executeStep<TResult>(
@@ -216,6 +278,8 @@ export class StepManager {
216
278
  cb: (ctx: StepHandlerContext) => Promise<TResult>,
217
279
  options: StepOptions,
218
280
  abortSignal: AbortSignal,
281
+ parentStepId: string | null,
282
+ parallel: boolean,
219
283
  ): Promise<TResult> {
220
284
  const expire = options.expire
221
285
  const retryOptions = options.retry
@@ -227,8 +291,15 @@ export class StepManager {
227
291
  throw new ActionCancelError(this.#actionName, this.#jobId, { cause: 'step cancelled before create step' })
228
292
  }
229
293
 
230
- // Create step record
231
- const newStep = await this.#stepStore.getOrCreate(this.#jobId, name, expire, retryOptions.limit)
294
+ // Create step record with parentStepId and parallel
295
+ const newStep = await this.#stepStore.getOrCreate(
296
+ this.#jobId,
297
+ name,
298
+ expire,
299
+ retryOptions.limit,
300
+ parentStepId,
301
+ parallel,
302
+ )
232
303
  if (!newStep) {
233
304
  throw new NonRetriableError(
234
305
  `Failed to create step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`,
@@ -238,6 +309,17 @@ export class StepManager {
238
309
 
239
310
  step = newStep
240
311
 
312
+ // Start step telemetry span
313
+ const parentSpan = parentStepId ? this.#stepSpans.get(parentStepId) : this.#jobSpan
314
+ const stepSpan = await this.#telemetry.startStepSpan({
315
+ jobId: this.#jobId,
316
+ stepId: step.id,
317
+ stepName: name,
318
+ parentSpan: parentSpan ?? undefined,
319
+ parentStepId,
320
+ })
321
+ this.#stepSpans.set(step.id, stepSpan)
322
+
241
323
  if (abortSignal.aborted) {
242
324
  throw new ActionCancelError(this.#actionName, this.#jobId, { cause: 'step cancelled after create step' })
243
325
  }
@@ -245,7 +327,7 @@ export class StepManager {
245
327
  if (step.status === STEP_STATUS_COMPLETED) {
246
328
  // this is how we recover a completed step
247
329
  this.#logger.debug(
248
- { jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id },
330
+ { jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id, parentStepId },
249
331
  '[StepManager] Step recovered (already completed)',
250
332
  )
251
333
  return step.output as TResult
@@ -265,11 +347,12 @@ export class StepManager {
265
347
 
266
348
  // Log step start
267
349
  this.#logger.debug(
268
- { jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id },
350
+ { jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id, parentStepId },
269
351
  '[StepManager] Step started executing',
270
352
  )
271
353
  }
272
354
 
355
+ // Create abort controller for this step's timeout
273
356
  const stepAbortController = new AbortController()
274
357
  const timeoutId = setTimeout(() => {
275
358
  const timeoutError = new StepTimeoutError(name, this.#jobId, expire)
@@ -278,18 +361,85 @@ export class StepManager {
278
361
 
279
362
  timeoutId?.unref?.()
280
363
 
281
- // Combine abort signals
282
- const signal = AbortSignal.any([abortSignal, stepAbortController.signal])
364
+ // Combine abort signals: parent chain + this step's timeout
365
+ const stepSignal = AbortSignal.any([abortSignal, stepAbortController.signal])
366
+
367
+ // Track child steps for enforcement
368
+ interface TrackedChildStep {
369
+ promise: Promise<any>
370
+ settled: boolean
371
+ }
372
+ const childSteps: TrackedChildStep[] = []
373
+
374
+ // Create abort controller for child steps (used when parent returns with pending children)
375
+ const childAbortController = new AbortController()
376
+ const childSignal = AbortSignal.any([stepSignal, childAbortController.signal])
377
+
378
+ // Create observe context for this step
379
+ const stepObserveContext = this.createStepObserveContext(step.id)
380
+
381
+ // Create StepHandlerContext with nested step support
382
+ const stepContext: StepHandlerContext = {
383
+ signal: stepSignal,
384
+ stepId: step.id,
385
+ parentStepId,
386
+ observe: stepObserveContext,
387
+ step: <TChildResult>(
388
+ childName: string,
389
+ childCb: (ctx: StepHandlerContext) => Promise<TChildResult>,
390
+ childOptions: z.input<typeof StepOptionsSchema> = {},
391
+ ): Promise<TChildResult> => {
392
+ // Inherit parent step options EXCEPT parallel (each step's parallel status is independent)
393
+ const { parallel: _parentParallel, ...inheritableOptions } = options
394
+ const parsedChildOptions = StepOptionsSchema.parse({
395
+ ...inheritableOptions,
396
+ ...childOptions,
397
+ })
398
+
399
+ // Push child step with this step as parent
400
+ const childPromise = this.push({
401
+ name: childName,
402
+ cb: childCb,
403
+ options: parsedChildOptions,
404
+ abortSignal: childSignal, // Child uses composed signal
405
+ parentStepId: step!.id, // This step is the parent
406
+ parallel: parsedChildOptions.parallel, // Pass parallel option
407
+ })
408
+
409
+ // Track the child promise
410
+ const trackedChild: TrackedChildStep = {
411
+ promise: childPromise,
412
+ settled: false,
413
+ }
414
+ childSteps.push(trackedChild)
415
+
416
+ // Mark as settled when done (success or failure)
417
+ // Use .then/.catch instead of .finally to properly handle rejections
418
+ childPromise
419
+ .then(() => {
420
+ trackedChild.settled = true
421
+ })
422
+ .catch(() => {
423
+ trackedChild.settled = true
424
+ // Swallow the error here - it will be re-thrown to the caller via the returned promise
425
+ })
426
+
427
+ return childPromise
428
+ },
429
+ }
283
430
 
284
431
  try {
285
432
  // Race between abort signal and callback execution
286
- const abortPromise = waitForAbort(signal)
287
- const callbackPromise = cb({ signal })
433
+ const abortPromise = waitForAbort(stepSignal)
434
+ const callbackPromise = cb(stepContext)
288
435
 
289
436
  let result: any = null
437
+ let aborted = false
290
438
 
291
439
  await Promise.race([
292
- abortPromise.promise,
440
+ abortPromise.promise.then(() => {
441
+ aborted = true
442
+ }),
293
443
  callbackPromise
294
444
  .then((res) => {
295
445
  if (res !== undefined && res !== null) {
@@ -301,12 +451,54 @@ export class StepManager {
301
451
  }),
302
452
  ])
303
453
 
454
+ // If aborted, wait for child steps to settle before propagating
455
+ if (aborted) {
456
+ // Wait for all child steps to settle (they'll be aborted via signal propagation)
457
+ if (childSteps.length > 0) {
458
+ await Promise.allSettled(childSteps.map((c) => c.promise))
459
+ }
460
+ // Re-throw the abort reason
461
+ throw stepSignal.reason
462
+ }
463
+
464
+ // After parent callback returns, check for pending children
465
+ const unsettledChildren = childSteps.filter((c) => !c.settled)
466
+ if (unsettledChildren.length > 0) {
467
+ this.#logger.warn(
468
+ {
469
+ jobId: this.#jobId,
470
+ actionName: this.#actionName,
471
+ stepName: name,
472
+ stepId: step.id,
473
+ pendingCount: unsettledChildren.length,
474
+ },
475
+ '[StepManager] Parent step completed with unhandled child steps - aborting children',
476
+ )
477
+
478
+ // Abort all pending children
479
+ const unhandledError = new UnhandledChildStepsError(name, unsettledChildren.length)
480
+ childAbortController.abort(unhandledError)
481
+
482
+ // Wait for all children to settle (they'll reject with cancellation)
483
+ await Promise.allSettled(unsettledChildren.map((c) => c.promise))
484
+
485
+ // Now throw the error
486
+ throw unhandledError
487
+ }
488
+
304
489
  // Update step as completed
305
490
  const completed = await this.#stepStore.updateStatus(step.id, 'completed', result)
306
491
  if (!completed) {
307
492
  throw new Error(`Failed to complete step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`)
308
493
  }
309
494
 
495
+ // End step telemetry span successfully
496
+ const stepSpan = this.#stepSpans.get(step.id)
497
+ if (stepSpan) {
498
+ await this.#telemetry.endStepSpan(stepSpan, { status: 'ok' })
499
+ this.#stepSpans.delete(step.id)
500
+ }
501
+
310
502
  // Log step completion
311
503
  this.#logger.debug(
312
504
  { jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id },
@@ -347,6 +539,17 @@ export class StepManager {
347
539
  },
348
540
  }).catch(async (error) => {
349
541
  if (step) {
542
+ // End step telemetry span with error/cancelled status
543
+ const stepSpan = this.#stepSpans.get(step.id)
544
+ if (stepSpan) {
545
+ if (isCancelError(error)) {
546
+ await this.#telemetry.endStepSpan(stepSpan, { status: 'cancelled' })
547
+ } else {
548
+ await this.#telemetry.endStepSpan(stepSpan, { status: 'error', error })
549
+ }
550
+ this.#stepSpans.delete(step.id)
551
+ }
552
+
350
553
  if (isCancelError(error)) {
351
554
  await this.#stepStore.updateStatus(step.id, 'cancelled')
352
555
  } else {
@@ -377,6 +580,7 @@ class ActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVa
377
580
  #jobId: string
378
581
  #groupKey: string = '@default'
379
582
  #action: Action<TInput, TOutput, TVariables>
583
+ #observeContext: ObserveContext
380
584
 
381
585
  // ============================================================================
382
586
  // Constructor
@@ -389,6 +593,7 @@ class ActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVa
389
593
  variables: TVariables,
390
594
  abortSignal: AbortSignal,
391
595
  logger: Logger,
596
+ observeContext: ObserveContext,
392
597
  ) {
393
598
  this.#stepManager = stepManager
394
599
  this.#variables = variables
@@ -397,6 +602,7 @@ class ActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVa
397
602
  this.#action = action
398
603
  this.#jobId = job.id
399
604
  this.#groupKey = job.groupKey ?? '@default'
605
+ this.#observeContext = observeContext
400
606
  if (action.input) {
401
607
  this.#input = action.input.parse(job.input, {
402
608
  error: () => 'Error parsing action input',
@@ -405,6 +611,7 @@ class ActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVa
405
611
  }
406
612
  this.#input = job.input ?? {}
407
613
  this.step = this.step.bind(this)
614
+ this.run = this.run.bind(this)
408
615
  }
409
616
 
410
617
  // ============================================================================
@@ -450,8 +657,16 @@ class ActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVa
450
657
  return this.#logger
451
658
  }
452
659
 
660
+ /**
661
+ * Get the observability context for recording metrics and span data.
662
+ */
663
+ get observe(): ObserveContext {
664
+ return this.#observeContext
665
+ }
666
+
453
667
  /**
454
668
  * Execute a step within the action.
669
+ * This creates a root step (no parent).
455
670
  *
456
671
  * @param name - The name of the step
457
672
  * @param cb - The step handler function
@@ -467,6 +682,71 @@ class ActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVa
467
682
  ...this.#action.steps,
468
683
  ...options,
469
684
  })
470
- return this.#stepManager.push({ name, cb, options: parsedOptions, abortSignal: this.#abortSignal })
685
+ return this.#stepManager.push({
686
+ name,
687
+ cb,
688
+ options: parsedOptions,
689
+ abortSignal: this.#abortSignal,
690
+ parentStepId: null, // Root steps have no parent
691
+ parallel: parsedOptions.parallel, // Pass parallel option
692
+ })
693
+ }
694
+
695
+ /**
696
+ * Execute a reusable step definition created with createStep().
697
+ *
698
+ * @param stepDef - The step definition to execute
699
+ * @param input - The input data for the step (validated against the step's input schema)
700
+ * @param options - Optional step configuration overrides
701
+ * @returns Promise resolving to the step result
702
+ */
703
+ async run<TStepInput extends z.ZodObject, TResult>(
704
+ stepDef: StepDefinition<TStepInput, TResult, TVariables>,
705
+ input: z.input<TStepInput>,
706
+ options: Partial<z.input<typeof StepOptionsSchema>> = {},
707
+ ): Promise<TResult> {
708
+ // Validate input against the step's schema if provided
709
+ // After parsing, validatedInput is z.output<TStepInput> (same as z.infer<TStepInput>)
710
+ const validatedInput: z.infer<TStepInput> = stepDef.input
711
+ ? stepDef.input.parse(input, {
712
+ error: () => 'Error parsing step input',
713
+ reportInput: true,
714
+ })
715
+ : (input as z.infer<TStepInput>)
716
+
717
+ // Resolve step name (static or dynamic)
718
+ const stepName = typeof stepDef.name === 'function' ? stepDef.name({ input: validatedInput }) : stepDef.name
719
+
720
+ // Merge options: action defaults -> step definition -> call-time overrides
721
+ const mergedOptions: z.input<typeof StepOptionsSchema> = {
722
+ ...this.#action.steps,
723
+ ...(stepDef.retry !== undefined && { retry: stepDef.retry }),
724
+ ...(stepDef.expire !== undefined && { expire: stepDef.expire }),
725
+ ...(stepDef.parallel !== undefined && { parallel: stepDef.parallel }),
726
+ ...options,
727
+ }
728
+
729
+ const parsedOptions = StepOptionsSchema.parse(mergedOptions)
730
+
731
+ // Create a wrapper callback that provides the extended context
732
+ const wrappedCb = async (baseCtx: StepHandlerContext): Promise<TResult> => {
733
+ const extendedCtx: StepDefinitionHandlerContext<TStepInput, TVariables> = {
734
+ ...baseCtx,
735
+ input: validatedInput,
736
+ var: this.#variables,
737
+ logger: this.#logger,
738
+ jobId: this.#jobId,
739
+ }
740
+ return stepDef.handler(extendedCtx)
741
+ }
742
+
743
+ return this.#stepManager.push({
744
+ name: stepName,
745
+ cb: wrappedCb,
746
+ options: parsedOptions,
747
+ abortSignal: this.#abortSignal,
748
+ parentStepId: null, // Root steps have no parent
749
+ parallel: parsedOptions.parallel,
750
+ })
471
751
  }
472
752
  }