duron 0.3.0-beta.4 → 0.3.0-beta.6

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/src/client.ts CHANGED
@@ -20,6 +20,38 @@ import type { JobStatusResult, JobStepStatusResult } from './adapters/schemas.js
20
20
  import { JOB_STATUS_CANCELLED, JOB_STATUS_COMPLETED, JOB_STATUS_FAILED, type JobStatus } from './constants.js'
21
21
  import { LocalTelemetryAdapter, noopTelemetryAdapter, type TelemetryAdapter } from './telemetry/index.js'
22
22
 
23
+ /**
24
+ * Extracts the inferred type from an action's input/output schema.
25
+ * Handles the case where the schema might be undefined.
26
+ */
27
+ type InferActionSchema<T> = T extends z.ZodTypeAny ? z.infer<T> : Record<string, unknown>
28
+
29
+ /**
30
+ * Result returned from waitForJob with untyped input and output.
31
+ */
32
+ export interface JobResult {
33
+ jobId: string
34
+ actionName: string
35
+ status: JobStatus
36
+ groupKey: string
37
+ input: unknown
38
+ output: unknown
39
+ error: Job['error']
40
+ }
41
+
42
+ /**
43
+ * Result returned from runActionAndWait with typed input and output based on the action's Zod schemas.
44
+ */
45
+ export interface TypedJobResult<TAction extends Action<any, any, any>> {
46
+ jobId: string
47
+ actionName: string
48
+ status: JobStatus
49
+ groupKey: string
50
+ input: InferActionSchema<NonNullable<TAction['input']>>
51
+ output: InferActionSchema<NonNullable<TAction['output']>>
52
+ error: Job['error']
53
+ }
54
+
23
55
  const BaseOptionsSchema = z.object({
24
56
  /**
25
57
  * Unique identifier for this Duron instance.
@@ -184,7 +216,7 @@ export class Client<
184
216
  #pendingJobWaits = new Map<
185
217
  string,
186
218
  Set<{
187
- resolve: (job: Job | null) => void
219
+ resolve: (result: JobResult | null) => void
188
220
  timeoutId?: NodeJS.Timeout
189
221
  signal?: AbortSignal
190
222
  abortHandler?: () => void
@@ -335,6 +367,132 @@ export class Client<
335
367
  return jobId
336
368
  }
337
369
 
370
+ /**
371
+ * Run an action and wait for its completion.
372
+ * This is a convenience method that combines `runAction` and `waitForJob`.
373
+ *
374
+ * @param actionName - Name of the action to run
375
+ * @param input - Input data for the action (validated against action's input schema if provided)
376
+ * @param options - Options including abort signal and timeout
377
+ * @returns Promise resolving to the job result with typed input and output
378
+ * @throws Error if action is not found, job creation fails, job is cancelled, or operation is aborted
379
+ */
380
+ async runActionAndWait<TActionName extends keyof TActions>(
381
+ actionName: TActionName,
382
+ input?: NonNullable<TActions[TActionName]['input']> extends z.ZodObject
383
+ ? z.input<NonNullable<TActions[TActionName]['input']>>
384
+ : never,
385
+ options?: {
386
+ /**
387
+ * AbortSignal to cancel the operation. If aborted, the job will be cancelled and the promise will reject.
388
+ */
389
+ signal?: AbortSignal
390
+ /**
391
+ * Timeout in milliseconds. If the job doesn't complete within this time, the job will be cancelled and the promise will reject.
392
+ */
393
+ timeout?: number
394
+ },
395
+ ): Promise<TypedJobResult<TActions[TActionName]>> {
396
+ // Check if already aborted before starting
397
+ if (options?.signal?.aborted) {
398
+ throw new Error('Operation was aborted')
399
+ }
400
+
401
+ // Create the job
402
+ const jobId = await this.runAction(actionName, input)
403
+
404
+ // Set up abort handler to cancel the job if signal is aborted
405
+ let abortHandler: (() => void) | undefined
406
+ if (options?.signal) {
407
+ abortHandler = () => {
408
+ this.cancelJob(jobId).catch((err) => {
409
+ this.#logger.error({ err, jobId }, '[Duron] Error cancelling job on abort')
410
+ })
411
+ }
412
+ options.signal.addEventListener('abort', abortHandler, { once: true })
413
+ }
414
+
415
+ // Set up timeout handler to cancel the job if timeout is reached
416
+ let timeoutId: NodeJS.Timeout | undefined
417
+ let timeoutAbortController: AbortController | undefined
418
+ if (options?.timeout) {
419
+ timeoutAbortController = new AbortController()
420
+ timeoutId = setTimeout(() => {
421
+ timeoutAbortController!.abort()
422
+ this.cancelJob(jobId).catch((err) => {
423
+ this.#logger.error({ err, jobId }, '[Duron] Error cancelling job on timeout')
424
+ })
425
+ }, options.timeout)
426
+ }
427
+
428
+ try {
429
+ // Combine signals if both are provided
430
+ let waitSignal: AbortSignal | undefined
431
+ if (options?.signal && timeoutAbortController) {
432
+ waitSignal = AbortSignal.any([options.signal, timeoutAbortController.signal])
433
+ } else if (options?.signal) {
434
+ waitSignal = options.signal
435
+ } else if (timeoutAbortController) {
436
+ waitSignal = timeoutAbortController.signal
437
+ }
438
+
439
+ // Wait for the job to complete
440
+ const job = await this.waitForJob(jobId, { signal: waitSignal })
441
+
442
+ // Clean up
443
+ if (timeoutId) {
444
+ clearTimeout(timeoutId)
445
+ }
446
+ if (options?.signal && abortHandler) {
447
+ options.signal.removeEventListener('abort', abortHandler)
448
+ }
449
+
450
+ // Handle null result (aborted or timed out)
451
+ if (!job) {
452
+ if (options?.signal?.aborted) {
453
+ throw new Error('Operation was aborted')
454
+ }
455
+ if (timeoutAbortController?.signal.aborted) {
456
+ throw new Error('Operation timed out')
457
+ }
458
+ throw new Error('Job not found')
459
+ }
460
+
461
+ // Handle cancelled job
462
+ if (job.status === JOB_STATUS_CANCELLED) {
463
+ if (options?.signal?.aborted) {
464
+ throw new Error('Operation was aborted')
465
+ }
466
+ if (timeoutAbortController?.signal.aborted) {
467
+ throw new Error('Operation timed out')
468
+ }
469
+ throw new Error('Job was cancelled')
470
+ }
471
+
472
+ // Handle failed job
473
+ if (job.status === JOB_STATUS_FAILED) {
474
+ const errorMessage = job.error?.message ?? 'Job failed'
475
+ const error = new Error(errorMessage)
476
+ if (job.error?.stack) {
477
+ error.stack = job.error.stack
478
+ }
479
+ throw error
480
+ }
481
+
482
+ // Return the job result with typed input/output
483
+ return job as TypedJobResult<TActions[TActionName]>
484
+ } catch (err) {
485
+ // Clean up on error
486
+ if (timeoutId) {
487
+ clearTimeout(timeoutId)
488
+ }
489
+ if (options?.signal && abortHandler) {
490
+ options.signal.removeEventListener('abort', abortHandler)
491
+ }
492
+ throw err
493
+ }
494
+ }
495
+
338
496
  /**
339
497
  * Fetch and process jobs from the database.
340
498
  * Concurrency limits are determined from the latest job created for each groupKey.
@@ -515,11 +673,11 @@ export class Client<
515
673
 
516
674
  /**
517
675
  * Wait for a job to change status by subscribing to job-status-changed events.
518
- * When the job status changes, the job is fetched and returned.
676
+ * When the job status changes, the job result is returned.
519
677
  *
520
678
  * @param jobId - The ID of the job to wait for
521
679
  * @param options - Optional configuration including timeout
522
- * @returns Promise resolving to the job when its status changes, or `null` if timeout
680
+ * @returns Promise resolving to the job result when its status changes, or `null` if timeout
523
681
  */
524
682
  async waitForJob(
525
683
  jobId: string,
@@ -534,7 +692,7 @@ export class Client<
534
692
  */
535
693
  signal?: AbortSignal
536
694
  },
537
- ): Promise<Job | null> {
695
+ ): Promise<JobResult | null> {
538
696
  await this.start()
539
697
 
540
698
  // First, check if the job already exists and is in a terminal state
@@ -546,14 +704,22 @@ export class Client<
546
704
  if (!job) {
547
705
  return null
548
706
  }
549
- return job
707
+ return {
708
+ jobId: job.id,
709
+ actionName: job.actionName,
710
+ status: job.status,
711
+ groupKey: job.groupKey,
712
+ input: job.input,
713
+ output: job.output,
714
+ error: job.error,
715
+ }
550
716
  }
551
717
  }
552
718
 
553
719
  // Set up the shared event listener if not already set up
554
720
  this.#setupJobStatusListener()
555
721
 
556
- return new Promise<Job | null>((resolve) => {
722
+ return new Promise<JobResult | null>((resolve) => {
557
723
  // Check if already aborted before setting up wait
558
724
  if (options?.signal?.aborted) {
559
725
  resolve(null)
@@ -796,6 +962,19 @@ export class Client<
796
962
  // Fetch the job once for all pending waits
797
963
  const job = await this.getJobById(event.jobId)
798
964
 
965
+ // Transform to JobResult
966
+ const result: JobResult | null = job
967
+ ? {
968
+ jobId: job.id,
969
+ actionName: job.actionName,
970
+ status: job.status,
971
+ groupKey: job.groupKey,
972
+ input: job.input,
973
+ output: job.output,
974
+ error: job.error,
975
+ }
976
+ : null
977
+
799
978
  // Resolve all pending waits for this job
800
979
  const waitsToResolve = Array.from(pendingWaits)
801
980
  this.#pendingJobWaits.delete(event.jobId)
@@ -808,7 +987,7 @@ export class Client<
808
987
  if (wait.signal && wait.abortHandler) {
809
988
  wait.signal.removeEventListener('abort', wait.abortHandler)
810
989
  }
811
- wait.resolve(job)
990
+ wait.resolve(result)
812
991
  }
813
992
  },
814
993
  )
@@ -820,7 +999,7 @@ export class Client<
820
999
  * @param jobId - The job ID
821
1000
  * @param resolve - The resolve function to remove
822
1001
  */
823
- #removeJobWait(jobId: string, resolve: (job: Job | null) => void) {
1002
+ #removeJobWait(jobId: string, resolve: (result: JobResult | null) => void) {
824
1003
  const pendingWaits = this.#pendingJobWaits.get(jobId)
825
1004
  if (!pendingWaits) {
826
1005
  return
@@ -148,7 +148,7 @@ export class StepManager {
148
148
  #telemetry: TelemetryAdapter
149
149
  #queue: fastq.queueAsPromised<TaskStep, any>
150
150
  #logger: Logger
151
- // each step name should be executed only once per action job
151
+ // each step name should be executed only once per parent (name + parentStepId)
152
152
  #historySteps = new Set<string>()
153
153
  // Store step spans for nested step tracking
154
154
  #stepSpans = new Map<string, Span>()
@@ -173,10 +173,12 @@ export class StepManager {
173
173
  this.#telemetry = options.telemetry
174
174
  this.#stepStore = new StepStore(options.adapter)
175
175
  this.#queue = fastq.promise(async (task: TaskStep) => {
176
- if (this.#historySteps.has(task.name)) {
176
+ // Create composite key: name + parentStepId (allows same name under different parents)
177
+ const stepKey = `${task.parentStepId ?? 'root'}:${task.name}`
178
+ if (this.#historySteps.has(stepKey)) {
177
179
  throw new StepAlreadyExecutedError(task.name, this.#jobId, this.#actionName)
178
180
  }
179
- this.#historySteps.add(task.name)
181
+ this.#historySteps.add(stepKey)
180
182
  return this.#executeStep(task.name, task.cb, task.options, task.abortSignal, task.parentStepId, task.parallel)
181
183
  }, options.concurrencyLimit)
182
184
  }