@pikku/core 0.12.36 → 0.12.38

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 (35) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/services/in-memory-workflow-service.d.ts +7 -2
  3. package/dist/services/in-memory-workflow-service.js +17 -1
  4. package/dist/services/meta-service.d.ts +4 -0
  5. package/dist/services/meta-service.js +9 -0
  6. package/dist/wirings/gateway/gateway.types.d.ts +4 -1
  7. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
  8. package/dist/wirings/workflow/graph/graph-runner.js +117 -45
  9. package/dist/wirings/workflow/index.d.ts +2 -1
  10. package/dist/wirings/workflow/index.js +2 -1
  11. package/dist/wirings/workflow/pikku-workflow-service.d.ts +63 -5
  12. package/dist/wirings/workflow/pikku-workflow-service.js +197 -66
  13. package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
  14. package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
  15. package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
  16. package/dist/wirings/workflow/workflow.types.d.ts +5 -0
  17. package/package.json +2 -1
  18. package/src/dev/hot-reload.test.ts +5 -0
  19. package/src/services/in-memory-workflow-service.ts +25 -1
  20. package/src/services/meta-service.ts +15 -0
  21. package/src/wirings/gateway/gateway.types.ts +6 -1
  22. package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
  23. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
  24. package/src/wirings/workflow/graph/graph-runner.test.ts +87 -3
  25. package/src/wirings/workflow/graph/graph-runner.ts +158 -56
  26. package/src/wirings/workflow/index.ts +3 -0
  27. package/src/wirings/workflow/pikku-workflow-service.ts +264 -86
  28. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
  29. package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
  30. package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
  31. package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
  32. package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
  33. package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
  34. package/src/wirings/workflow/workflow.types.ts +5 -0
  35. package/tsconfig.tsbuildinfo +1 -1
@@ -69,6 +69,17 @@ import type { WorkflowService } from '../../services/workflow-service.js'
69
69
  import { PikkuError, addError } from '../../errors/error-handler.js'
70
70
  import { RPCNotFoundError } from '../rpc/rpc-runner.js'
71
71
  import { ChildWorkflowStartedException } from './graph/graph-runner.js'
72
+ import { deriveInvocationId } from './workflow-invocation-id.js'
73
+ import type { JobOptions } from '../queue/queue.types.js'
74
+
75
+ /**
76
+ * Default number of retries for a workflow step when none is specified. The
77
+ * workflow — not the queue — owns retry policy; a step inherits this unless it
78
+ * sets its own `retries` (including `retries: 0` to opt out entirely). Picked >0
79
+ * so a transient failure (a DB blip, a downstream restart, a deploy) is ridden
80
+ * out by default; safe because every step gets a stable `invocationId` to dedupe on.
81
+ */
82
+ export const DEFAULT_STEP_RETRIES = 5
72
83
 
73
84
  /**
74
85
  * Exception thrown when workflow needs to pause for async step
@@ -109,6 +120,25 @@ export class WorkflowSuspendedException extends Error {
109
120
  }
110
121
  }
111
122
 
123
+ /**
124
+ * Thrown when a step (or the orchestrator) could not be enqueued — the queue
125
+ * itself failed (e.g. pg-boss is momentarily down), NOT the step's own logic.
126
+ * This is transient infrastructure failure: the run is left untouched (the step
127
+ * stays `pending`, the run stays running) and the orchestrator job is rethrown
128
+ * so the queue redelivers it and the workflow replays from its snapshot. Treat
129
+ * it as non-terminal — never mark the run `failed` for it.
130
+ */
131
+ export class WorkflowDispatchException extends Error {
132
+ constructor(
133
+ public readonly runId: string,
134
+ public readonly stepName: string,
135
+ options?: { cause?: unknown }
136
+ ) {
137
+ super(`Failed to dispatch workflow step '${stepName}' (run ${runId})`, options)
138
+ this.name = 'WorkflowDispatchException'
139
+ }
140
+ }
141
+
112
142
  /**
113
143
  * Error class for workflow not found
114
144
  */
@@ -471,14 +501,16 @@ export abstract class PikkuWorkflowService implements WorkflowService {
471
501
  stepName: string,
472
502
  rpcName: string | null,
473
503
  data: any,
474
- stepOptions?: WorkflowStepOptions
504
+ stepOptions?: WorkflowStepOptions,
505
+ fromStepName?: string
475
506
  ): Promise<StepState> {
476
507
  const step = await this.insertStepStateImpl(
477
508
  runId,
478
509
  stepName,
479
510
  rpcName,
480
511
  data,
481
- stepOptions
512
+ stepOptions,
513
+ fromStepName
482
514
  )
483
515
  await this.safeMirror(() =>
484
516
  this.mirror!.insertStepState(runId, { ...step, stepName, rpcName, data })
@@ -491,7 +523,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
491
523
  stepName: string,
492
524
  rpcName: string | null,
493
525
  data: any,
494
- stepOptions?: WorkflowStepOptions
526
+ stepOptions?: WorkflowStepOptions,
527
+ fromStepName?: string
495
528
  ): Promise<StepState>
496
529
 
497
530
  /**
@@ -663,6 +696,20 @@ export abstract class PikkuWorkflowService implements WorkflowService {
663
696
  nodeIds: string[]
664
697
  ): Promise<string[]>
665
698
 
699
+ /**
700
+ * List every step instance of a run (any status) with its predecessor.
701
+ * Drives bounded graph revisits: the runner counts instances per logical node
702
+ * and treats each `fromStepName → node` as a once-fired transition.
703
+ * @param runId - Run ID
704
+ */
705
+ abstract getStepInstances(runId: string): Promise<
706
+ Array<{
707
+ stepName: string
708
+ status: StepStatus
709
+ fromStepName?: string
710
+ }>
711
+ >
712
+
666
713
  /**
667
714
  * Get results for specific nodes
668
715
  * @param runId - Run ID
@@ -784,9 +831,42 @@ export abstract class PikkuWorkflowService implements WorkflowService {
784
831
  const run = await this.getRun(runId)
785
832
  workflowName = run?.workflow
786
833
  }
787
- await queueService.add(this.getOrchestratorQueueName(workflowName), {
788
- runId,
789
- })
834
+ // Carry an explicit retry policy on the orchestrator job too. Orchestrator
835
+ // runs are idempotent (they replay from the snapshot, returning cached step
836
+ // results), so redelivery is always safe — and it's what lets a transient
837
+ // dispatch/infra failure recover: the job is rethrown and retried instead of
838
+ // the run hanging. Passing `attempts` per-job overrides the queue default, so
839
+ // this holds even when the orchestrator queue is configured `retry_limit 0`.
840
+ await queueService.add(
841
+ this.getOrchestratorQueueName(workflowName),
842
+ { runId },
843
+ this.resolveStepJobOptions()
844
+ )
845
+ }
846
+
847
+ /**
848
+ * Resolve a step's retry policy into queue job options. The workflow is the
849
+ * sole source of truth for retries: an explicitly-set `retries` (including 0)
850
+ * is always honored, an unset one defaults to {@link DEFAULT_STEP_RETRIES},
851
+ * and we ALWAYS pass `attempts` so the queue can never fall back to its own
852
+ * default — which would re-run a step the workflow said not to retry. Backoff
853
+ * defaults to exponential whenever there's at least one retry, so retries ride
854
+ * out a transient outage instead of firing instantly.
855
+ */
856
+ protected resolveStepJobOptions(
857
+ stepOptions?: WorkflowStepOptions
858
+ ): JobOptions {
859
+ const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES
860
+ const retryDelay = stepOptions?.retryDelay
861
+ const backoff =
862
+ typeof retryDelay === 'number'
863
+ ? { type: 'fixed', delay: retryDelay }
864
+ : retryDelay === 'exponential'
865
+ ? 'exponential'
866
+ : retries > 0
867
+ ? 'exponential'
868
+ : undefined
869
+ return { attempts: retries + 1, ...(backoff ? { backoff } : {}) }
790
870
  }
791
871
 
792
872
  public async queueStepWorker(
@@ -794,32 +874,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
794
874
  stepName: string,
795
875
  rpcName: string,
796
876
  data: any,
797
- stepOptions?: WorkflowStepOptions
877
+ stepOptions?: WorkflowStepOptions,
878
+ fromStepName?: string
798
879
  ): Promise<void> {
799
880
  const queueService = this.verifyQueueService()
800
- const retries = stepOptions?.retries ?? 0
801
- const retryDelay = stepOptions?.retryDelay
802
881
  await queueService.add(
803
882
  this.getStepWorkerQueueName(rpcName),
804
- JSON.parse(
805
- JSON.stringify({
806
- runId,
807
- stepName,
808
- rpcName,
809
- data,
810
- })
811
- ),
812
- retries > 0 || retryDelay
813
- ? {
814
- attempts: retries + 1,
815
- backoff:
816
- typeof retryDelay === 'number'
817
- ? { type: 'fixed', delay: retryDelay }
818
- : retryDelay === 'exponential'
819
- ? 'exponential'
820
- : undefined,
821
- }
822
- : undefined
883
+ JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })),
884
+ this.resolveStepJobOptions(stepOptions)
823
885
  )
824
886
  }
825
887
 
@@ -877,7 +939,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
877
939
  stepName: string,
878
940
  rpcName: string,
879
941
  data: unknown,
880
- stepOptions?: WorkflowStepOptions
942
+ stepOptions?: WorkflowStepOptions,
943
+ fromStepName?: string
881
944
  ): Promise<boolean> {
882
945
  // Step execution is decided purely by the function's `inline` flag (default
883
946
  // true). Only a function explicitly marked `inline: false` dispatches via
@@ -903,28 +966,21 @@ export abstract class PikkuWorkflowService implements WorkflowService {
903
966
  )
904
967
  return false
905
968
  }
906
- const retries = stepOptions?.retries ?? 0
907
- const retryDelay = stepOptions?.retryDelay
908
- await getSingletonServices()!.queueService!.add(
909
- this.getStepWorkerQueueName(rpcName),
910
- JSON.parse(
911
- JSON.stringify({
912
- runId,
913
- stepName,
914
- rpcName,
915
- data,
916
- })
917
- ),
918
- {
919
- attempts: retries + 1,
920
- backoff:
921
- typeof retryDelay === 'number'
922
- ? { type: 'fixed', delay: retryDelay }
923
- : retryDelay === 'exponential'
924
- ? 'exponential'
925
- : undefined,
926
- }
927
- )
969
+ try {
970
+ await getSingletonServices()!.queueService!.add(
971
+ this.getStepWorkerQueueName(rpcName),
972
+ JSON.parse(
973
+ JSON.stringify({ runId, stepName, rpcName, data, fromStepName })
974
+ ),
975
+ this.resolveStepJobOptions(stepOptions)
976
+ )
977
+ } catch (cause) {
978
+ // The queue is down/unreachable — NOT a step failure. Surface it as a
979
+ // transient dispatch error so the caller leaves the step `pending` and the
980
+ // orchestrator job is retried (replayed from snapshot) rather than the run
981
+ // being marked failed. `add` already throws on failure in every adapter.
982
+ throw new WorkflowDispatchException(runId, stepName, { cause })
983
+ }
928
984
  return true
929
985
  }
930
986
 
@@ -1037,7 +1093,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1037
1093
  if (
1038
1094
  error.name !== 'WorkflowAsyncException' &&
1039
1095
  error.name !== 'WorkflowCancelledException' &&
1040
- error.name !== 'WorkflowSuspendedException'
1096
+ error.name !== 'WorkflowSuspendedException' &&
1097
+ // Transient queue failure — leave the run resumable, don't fail it.
1098
+ error.name !== 'WorkflowDispatchException'
1041
1099
  ) {
1042
1100
  await this.updateRunStatus(runId, 'failed', undefined, {
1043
1101
  name: error.name,
@@ -1050,6 +1108,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1050
1108
  )
1051
1109
  throw error
1052
1110
  }
1111
+ if (error.name === 'WorkflowDispatchException') {
1112
+ // Rethrow so the caller (poll loop / starter) sees the transient
1113
+ // failure; the run stays running and can be resumed.
1114
+ throw error
1115
+ }
1053
1116
  } finally {
1054
1117
  this.inlineRuns.delete(runId)
1055
1118
  }
@@ -1092,7 +1155,56 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1092
1155
  }
1093
1156
  }
1094
1157
 
1158
+ // Per-run, per-replay ordinal counters (runId → stepName → count).
1159
+ private stepOrdinals = new Map<string, Map<string, number>>()
1160
+ // Previous step key reached in the current DSL walk (runId → stepName), so a
1161
+ // new step records where it came from. Rebuilt each replay alongside ordinals.
1162
+ private stepLineage = new Map<string, string>()
1163
+
1164
+ private resetStepOrdinals(runId: string): void {
1165
+ this.stepOrdinals.set(runId, new Map())
1166
+ this.stepLineage.delete(runId)
1167
+ }
1168
+
1169
+ /** The step the DSL walk last reached (the predecessor for the next step). */
1170
+ private lastStepName(runId: string): string | undefined {
1171
+ return this.stepLineage.get(runId)
1172
+ }
1173
+
1174
+ /**
1175
+ * Physical, replay-stable key for the Nth reach of `logicalStepName` in a run:
1176
+ * bare name for the first reach (ordinal 0, unchanged behavior), `name#N` for
1177
+ * repeats — so the same literal step name can be invoked multiple times without
1178
+ * the rows clobbering. Deterministic given a deterministic DSL body.
1179
+ */
1180
+ private nextStepKey(runId: string, logicalStepName: string): string {
1181
+ let perRun = this.stepOrdinals.get(runId)
1182
+ if (!perRun) {
1183
+ perRun = new Map()
1184
+ this.stepOrdinals.set(runId, perRun)
1185
+ }
1186
+ const ordinal = perRun.get(logicalStepName) ?? 0
1187
+ perRun.set(logicalStepName, ordinal + 1)
1188
+ const stepName =
1189
+ ordinal === 0 ? logicalStepName : `${logicalStepName}#${ordinal}`
1190
+ this.stepLineage.set(runId, stepName)
1191
+ return stepName
1192
+ }
1193
+
1095
1194
  public async runWorkflowJob(runId: string, rpcService: any): Promise<void> {
1195
+ // Fresh ordinal counters per replay so step keys are deterministic.
1196
+ this.resetStepOrdinals(runId)
1197
+ try {
1198
+ await this.runWorkflowJobInner(runId, rpcService)
1199
+ } finally {
1200
+ this.stepOrdinals.delete(runId)
1201
+ }
1202
+ }
1203
+
1204
+ private async runWorkflowJobInner(
1205
+ runId: string,
1206
+ rpcService: any
1207
+ ): Promise<void> {
1096
1208
  const run = await this.getRun(runId)
1097
1209
  if (!run) {
1098
1210
  throw new WorkflowRunNotFoundError(runId)
@@ -1335,17 +1447,25 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1335
1447
  const isGraphWorkflow =
1336
1448
  workflowMeta?.source === 'graph' ||
1337
1449
  workflowMeta?.source === 'dynamic-workflow'
1338
- if (
1339
- isGraphWorkflow &&
1340
- workflowMeta?.nodes &&
1341
- stepName in workflowMeta.nodes
1342
- ) {
1450
+ // Map the physical step key back to its logical node: a revisit instance
1451
+ // is `node#N` (ordinal), which isn't a literal key in `nodes`.
1452
+ let graphNodeId: string | undefined
1453
+ if (isGraphWorkflow && workflowMeta?.nodes) {
1454
+ if (stepName in workflowMeta.nodes) {
1455
+ graphNodeId = stepName
1456
+ } else {
1457
+ const hash = stepName.lastIndexOf('#')
1458
+ const base = hash > 0 ? stepName.slice(0, hash) : undefined
1459
+ if (base && base in workflowMeta.nodes) graphNodeId = base
1460
+ }
1461
+ }
1462
+ if (graphNodeId) {
1343
1463
  result = await executeGraphStep(
1344
1464
  this,
1345
1465
  rpcService,
1346
1466
  runId,
1347
1467
  stepState.stepId,
1348
- stepName,
1468
+ graphNodeId,
1349
1469
  rpcName,
1350
1470
  data,
1351
1471
  run.workflow
@@ -1393,7 +1513,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1393
1513
  workflowStep: {
1394
1514
  runId,
1395
1515
  stepId: stepState.stepId,
1516
+ invocationId: deriveInvocationId(runId, stepName),
1396
1517
  attemptCount: stepState.attemptCount,
1518
+ fromInvocationId: stepState.fromStepName
1519
+ ? deriveInvocationId(runId, stepState.fromStepName)
1520
+ : undefined,
1397
1521
  },
1398
1522
  })
1399
1523
  }
@@ -1424,7 +1548,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1424
1548
  // Store error and mark failed
1425
1549
  await this.setStepError(stepState.stepId, error)
1426
1550
 
1427
- const maxAttempts = (stepState.retries ?? 0) + 1
1551
+ const maxAttempts = (stepState.retries ?? DEFAULT_STEP_RETRIES) + 1
1428
1552
  const retriesExhausted = stepState.attemptCount >= maxAttempts
1429
1553
 
1430
1554
  if (retriesExhausted) {
@@ -1458,6 +1582,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1458
1582
  return
1459
1583
  }
1460
1584
 
1585
+ if (error.name === 'WorkflowDispatchException') {
1586
+ // Transient: the queue was unreachable, not a workflow failure. Leave the
1587
+ // run running and rethrow so the orchestrator job is redelivered and the
1588
+ // workflow replays from its snapshot. Do NOT mark the run failed.
1589
+ getSingletonServices()!.logger.warn(
1590
+ `Workflow run ${runId} could not dispatch a step (queue unavailable); leaving run for orchestrator retry`,
1591
+ error
1592
+ )
1593
+ throw error
1594
+ }
1595
+
1461
1596
  await this.updateRunStatus(runId, 'failed', undefined, {
1462
1597
  message: error.message,
1463
1598
  stack: error.stack,
@@ -1480,12 +1615,23 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1480
1615
 
1481
1616
  private async rpcStep(
1482
1617
  runId: string,
1483
- stepName: string,
1618
+ logicalStepName: string,
1484
1619
  rpcName: string,
1485
1620
  data: any,
1486
1621
  rpcService: any,
1487
1622
  stepOptions?: WorkflowStepOptions
1488
1623
  ): Promise<any> {
1624
+ // Capture the predecessor before nextStepKey advances the lineage to us.
1625
+ const fromStepName = this.lastStepName(runId)
1626
+ const stepName = this.nextStepKey(runId, logicalStepName)
1627
+ // Resolve the retry policy ONCE here so the value persisted on the step
1628
+ // (which drives `retriesExhausted`) is the same one the queue dispatch turns
1629
+ // into `attempts`. Without this the queue could retry N times while the
1630
+ // engine thinks retries are already exhausted (or vice-versa).
1631
+ const resolvedStepOptions: WorkflowStepOptions = {
1632
+ retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
1633
+ retryDelay: stepOptions?.retryDelay,
1634
+ }
1489
1635
  // Check if step already exists
1490
1636
  let stepState: StepState
1491
1637
  try {
@@ -1497,7 +1643,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1497
1643
  stepName,
1498
1644
  rpcName,
1499
1645
  data,
1500
- stepOptions
1646
+ resolvedStepOptions,
1647
+ fromStepName
1501
1648
  )
1502
1649
  }
1503
1650
 
@@ -1524,26 +1671,30 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1524
1671
  throw new WorkflowAsyncException(runId, stepName)
1525
1672
  }
1526
1673
 
1527
- // Step is pending - schedule it
1528
- await this.setStepScheduled(stepState.stepId)
1529
-
1530
1674
  // Hand off to subclass-overridable transport. Default behavior enqueues
1531
1675
  // via the queue service; DO-style subclasses RPC to a step worker.
1676
+ // Dispatch BEFORE marking the step `scheduled`: if the queue is down,
1677
+ // dispatchStep throws WorkflowDispatchException and the step stays `pending`,
1678
+ // so the orchestrator's next replay re-dispatches it. Marking `scheduled`
1679
+ // first would strand the step (replay sees `scheduled`, pauses, never
1680
+ // re-enqueues the job that was never created).
1532
1681
  const dispatched = await this.dispatchStep(
1533
1682
  runId,
1534
1683
  stepName,
1535
1684
  rpcName,
1536
1685
  data,
1537
- stepOptions
1686
+ resolvedStepOptions,
1687
+ fromStepName
1538
1688
  )
1539
1689
  if (dispatched) {
1690
+ await this.setStepScheduled(stepState.stepId)
1540
1691
  throw new WorkflowAsyncException(runId, stepName)
1541
1692
  }
1542
1693
 
1543
1694
  {
1544
1695
  // Inline (no transport available) - execute locally with retry loop
1545
- const retries = stepOptions?.retries ?? 0
1546
- const retryDelay = stepOptions?.retryDelay
1696
+ const retries = resolvedStepOptions.retries ?? this.getConfig().retries
1697
+ const retryDelay = resolvedStepOptions.retryDelay
1547
1698
  let currentStepState = stepState
1548
1699
 
1549
1700
  while (true) {
@@ -1592,7 +1743,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1592
1743
  workflowStep: {
1593
1744
  runId,
1594
1745
  stepId: currentStepState.stepId,
1746
+ invocationId: deriveInvocationId(runId, stepName),
1595
1747
  attemptCount: currentStepState.attemptCount,
1748
+ fromInvocationId: currentStepState.fromStepName
1749
+ ? deriveInvocationId(runId, currentStepState.fromStepName)
1750
+ : undefined,
1596
1751
  },
1597
1752
  })
1598
1753
  }
@@ -1636,10 +1791,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1636
1791
 
1637
1792
  private async inlineStep(
1638
1793
  runId: string,
1639
- stepName: string,
1794
+ logicalStepName: string,
1640
1795
  fn: Function,
1641
1796
  stepOptions?: WorkflowStepOptions
1642
1797
  ): Promise<any> {
1798
+ const fromStepName = this.lastStepName(runId)
1799
+ const stepName = this.nextStepKey(runId, logicalStepName)
1643
1800
  // Check if step already exists
1644
1801
  let stepState: StepState
1645
1802
  try {
@@ -1651,7 +1808,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1651
1808
  stepName,
1652
1809
  null,
1653
1810
  null,
1654
- stepOptions
1811
+ stepOptions,
1812
+ fromStepName
1655
1813
  )
1656
1814
  }
1657
1815
 
@@ -1728,16 +1886,27 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1728
1886
  }
1729
1887
  }
1730
1888
 
1731
- private async sleepStep(runId: string, stepName: string, duration: number) {
1889
+ private async sleepStep(
1890
+ runId: string,
1891
+ logicalStepName: string,
1892
+ duration: number
1893
+ ) {
1894
+ const fromStepName = this.lastStepName(runId)
1895
+ const stepName = this.nextStepKey(runId, logicalStepName)
1732
1896
  // Check if step already exists
1733
1897
  let stepState: StepState
1734
1898
  try {
1735
1899
  stepState = await this.getStepState(runId, stepName)
1736
1900
  } catch {
1737
1901
  // Step doesn't exist - create it (sleep step, no RPC)
1738
- stepState = await this.insertStepState(runId, stepName, null, {
1739
- duration,
1740
- })
1902
+ stepState = await this.insertStepState(
1903
+ runId,
1904
+ stepName,
1905
+ null,
1906
+ { duration },
1907
+ undefined,
1908
+ fromStepName
1909
+ )
1741
1910
  }
1742
1911
 
1743
1912
  if (stepState.status === 'succeeded') {
@@ -1750,18 +1919,19 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1750
1919
  throw new WorkflowAsyncException(runId, stepName)
1751
1920
  }
1752
1921
 
1753
- // Step is pending - schedule it
1754
- await this.setStepScheduled(stepState.stepId)
1755
-
1756
1922
  // Hand off to subclass-overridable transport. Default behavior schedules
1757
1923
  // a delayed sleeper RPC via the scheduler service; DO-style subclasses
1758
- // override to use native timer primitives (e.g. setAlarm).
1759
- const scheduled = await this.scheduleSleep(
1760
- runId,
1761
- stepState.stepId,
1762
- duration
1763
- )
1924
+ // override to use native timer primitives (e.g. setAlarm). Schedule BEFORE
1925
+ // marking `scheduled` so a scheduler outage leaves the step `pending` for
1926
+ // re-scheduling on replay instead of stranding it (see rpcStep).
1927
+ let scheduled: boolean
1928
+ try {
1929
+ scheduled = await this.scheduleSleep(runId, stepState.stepId, duration)
1930
+ } catch (cause) {
1931
+ throw new WorkflowDispatchException(runId, stepName, { cause })
1932
+ }
1764
1933
  if (scheduled) {
1934
+ await this.setStepScheduled(stepState.stepId)
1765
1935
  throw new WorkflowAsyncException(runId, stepName)
1766
1936
  }
1767
1937
 
@@ -1788,7 +1958,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1788
1958
  }
1789
1959
 
1790
1960
  private async suspendStep(runId: string, reason: string): Promise<void> {
1791
- const suspendStepName = this.getSuspendStepName(reason)
1961
+ const fromStepName = this.lastStepName(runId)
1962
+ const suspendStepName = this.nextStepKey(
1963
+ runId,
1964
+ this.getSuspendStepName(reason)
1965
+ )
1792
1966
  await this.withStepLock(runId, suspendStepName, async () => {
1793
1967
  let stepState: StepState
1794
1968
  try {
@@ -1800,7 +1974,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1800
1974
  'pikkuWorkflowSuspend',
1801
1975
  {
1802
1976
  reason,
1803
- }
1977
+ },
1978
+ undefined,
1979
+ fromStepName
1804
1980
  )
1805
1981
  }
1806
1982
  if (!stepState.stepId) {
@@ -1810,7 +1986,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1810
1986
  'pikkuWorkflowSuspend',
1811
1987
  {
1812
1988
  reason,
1813
- }
1989
+ },
1990
+ undefined,
1991
+ fromStepName
1814
1992
  )
1815
1993
  }
1816
1994
 
@@ -1900,7 +2078,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1900
2078
  const singletonServices = getSingletonServices()
1901
2079
  const workflow = singletonServices.config?.workflow
1902
2080
  return {
1903
- retries: workflow?.retries ?? 0,
2081
+ retries: workflow?.retries ?? DEFAULT_STEP_RETRIES,
1904
2082
  retryDelay: workflow?.retryDelay ?? 0,
1905
2083
  orchestratorQueueName:
1906
2084
  workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',