@pikku/core 0.12.37 → 0.12.39

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 (52) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/errors/error-handler.d.ts +8 -0
  3. package/dist/errors/error-handler.js +8 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/services/in-memory-queue-service.d.ts +9 -0
  7. package/dist/services/in-memory-queue-service.js +42 -11
  8. package/dist/services/in-memory-workflow-service.d.ts +7 -2
  9. package/dist/services/in-memory-workflow-service.js +19 -1
  10. package/dist/services/workflow-service.d.ts +3 -0
  11. package/dist/testing/service-tests.js +35 -0
  12. package/dist/types/core.types.d.ts +1 -0
  13. package/dist/wirings/cli/cli-runner.js +12 -3
  14. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
  15. package/dist/wirings/workflow/graph/graph-runner.js +168 -106
  16. package/dist/wirings/workflow/index.d.ts +4 -1
  17. package/dist/wirings/workflow/index.js +4 -1
  18. package/dist/wirings/workflow/pikku-workflow-service.d.ts +95 -5
  19. package/dist/wirings/workflow/pikku-workflow-service.js +323 -175
  20. package/dist/wirings/workflow/run-timeline.d.ts +85 -0
  21. package/dist/wirings/workflow/run-timeline.js +153 -0
  22. package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
  23. package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
  24. package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
  25. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  26. package/package.json +2 -1
  27. package/src/dev/hot-reload.test.ts +5 -0
  28. package/src/errors/error-handler.ts +10 -0
  29. package/src/index.ts +1 -0
  30. package/src/services/in-memory-queue-service.test.ts +97 -0
  31. package/src/services/in-memory-queue-service.ts +51 -16
  32. package/src/services/in-memory-workflow-service.ts +27 -1
  33. package/src/services/workflow-service.ts +9 -0
  34. package/src/testing/service-tests.ts +65 -0
  35. package/src/types/core.types.ts +4 -0
  36. package/src/wirings/cli/cli-runner.ts +11 -3
  37. package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
  38. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
  39. package/src/wirings/workflow/graph/graph-runner.test.ts +159 -3
  40. package/src/wirings/workflow/graph/graph-runner.ts +253 -142
  41. package/src/wirings/workflow/index.ts +17 -0
  42. package/src/wirings/workflow/pikku-workflow-service.ts +447 -213
  43. package/src/wirings/workflow/run-timeline.test.ts +211 -0
  44. package/src/wirings/workflow/run-timeline.ts +241 -0
  45. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
  46. package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
  47. package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
  48. package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
  49. package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
  50. package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
  51. package/src/wirings/workflow/workflow.types.ts +7 -0
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -66,9 +66,30 @@ import {
66
66
  runFromMeta,
67
67
  } from './graph/graph-runner.js'
68
68
  import type { WorkflowService } from '../../services/workflow-service.js'
69
- import { PikkuError, addError } from '../../errors/error-handler.js'
69
+ import {
70
+ PikkuError,
71
+ addError,
72
+ isExpectedError,
73
+ } from '../../errors/error-handler.js'
70
74
  import { RPCNotFoundError } from '../rpc/rpc-runner.js'
71
75
  import { ChildWorkflowStartedException } from './graph/graph-runner.js'
76
+ import { deriveInvocationId } from './workflow-invocation-id.js'
77
+ import {
78
+ buildRunTimeline,
79
+ reconstructStateAt,
80
+ type RunTimeline,
81
+ type ReconstructedRunState,
82
+ } from './run-timeline.js'
83
+ import type { JobOptions } from '../queue/queue.types.js'
84
+
85
+ /**
86
+ * Default number of retries for a workflow step when none is specified. The
87
+ * workflow — not the queue — owns retry policy; a step inherits this unless it
88
+ * sets its own `retries` (including `retries: 0` to opt out entirely). Picked >0
89
+ * so a transient failure (a DB blip, a downstream restart, a deploy) is ridden
90
+ * out by default; safe because every step gets a stable `invocationId` to dedupe on.
91
+ */
92
+ export const DEFAULT_STEP_RETRIES = 5
72
93
 
73
94
  /**
74
95
  * Exception thrown when workflow needs to pause for async step
@@ -109,6 +130,25 @@ export class WorkflowSuspendedException extends Error {
109
130
  }
110
131
  }
111
132
 
133
+ /**
134
+ * Thrown when a step (or the orchestrator) could not be enqueued — the queue
135
+ * itself failed (e.g. pg-boss is momentarily down), NOT the step's own logic.
136
+ * This is transient infrastructure failure: the run is left untouched (the step
137
+ * stays `pending`, the run stays running) and the orchestrator job is rethrown
138
+ * so the queue redelivers it and the workflow replays from its snapshot. Treat
139
+ * it as non-terminal — never mark the run `failed` for it.
140
+ */
141
+ export class WorkflowDispatchException extends Error {
142
+ constructor(
143
+ public readonly runId: string,
144
+ public readonly stepName: string,
145
+ options?: { cause?: unknown }
146
+ ) {
147
+ super(`Failed to dispatch workflow step '${stepName}' (run ${runId})`, options)
148
+ this.name = 'WorkflowDispatchException'
149
+ }
150
+ }
151
+
112
152
  /**
113
153
  * Error class for workflow not found
114
154
  */
@@ -386,7 +426,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
386
426
  // Build step summaries from history (latest attempt per step)
387
427
  const stepMap = new Map<
388
428
  string,
389
- { status: StepStatus; startedAt?: Date; completedAt?: Date }
429
+ { status: StepStatus; startedAt?: Date; completedAt?: Date; attempts: number }
390
430
  >()
391
431
  for (const step of history) {
392
432
  const existing = stepMap.get(step.stepName)
@@ -395,6 +435,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
395
435
  status: step.status,
396
436
  startedAt: step.runningAt ?? step.createdAt,
397
437
  completedAt: step.succeededAt ?? step.failedAt,
438
+ attempts: step.attemptCount,
398
439
  })
399
440
  }
400
441
  }
@@ -406,6 +447,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
406
447
  s.startedAt && s.completedAt
407
448
  ? s.completedAt.getTime() - s.startedAt.getTime()
408
449
  : undefined,
450
+ attempts: s.attempts,
409
451
  }))
410
452
 
411
453
  return {
@@ -423,6 +465,33 @@ export abstract class PikkuWorkflowService implements WorkflowService {
423
465
  }
424
466
  }
425
467
 
468
+ /**
469
+ * Build the run's time-travel event stream from durable history.
470
+ * @param id - Run ID
471
+ * @returns Ordered timeline, or null if the run doesn't exist
472
+ */
473
+ public async getRunTimeline(id: string): Promise<RunTimeline | null> {
474
+ const run = await this.getRun(id)
475
+ if (!run) return null
476
+ return buildRunTimeline(await this.getRunHistory(id))
477
+ }
478
+
479
+ /**
480
+ * Reconstruct the run's state at a point in its timeline.
481
+ * @param id - Run ID
482
+ * @param at - A seq index (inclusive) or a Date (inclusive); omit for the
483
+ * final state.
484
+ * @returns Reconstructed state, or null if the run doesn't exist
485
+ */
486
+ public async reconstructRunStateAt(
487
+ id: string,
488
+ at?: number | Date
489
+ ): Promise<ReconstructedRunState | null> {
490
+ const timeline = await this.getRunTimeline(id)
491
+ if (!timeline) return null
492
+ return reconstructStateAt(timeline, at ?? timeline.length - 1)
493
+ }
494
+
426
495
  /**
427
496
  * Get workflow run history (all step attempts in chronological order)
428
497
  * @param runId - Run ID
@@ -471,14 +540,16 @@ export abstract class PikkuWorkflowService implements WorkflowService {
471
540
  stepName: string,
472
541
  rpcName: string | null,
473
542
  data: any,
474
- stepOptions?: WorkflowStepOptions
543
+ stepOptions?: WorkflowStepOptions,
544
+ fromStepName?: string
475
545
  ): Promise<StepState> {
476
546
  const step = await this.insertStepStateImpl(
477
547
  runId,
478
548
  stepName,
479
549
  rpcName,
480
550
  data,
481
- stepOptions
551
+ stepOptions,
552
+ fromStepName
482
553
  )
483
554
  await this.safeMirror(() =>
484
555
  this.mirror!.insertStepState(runId, { ...step, stepName, rpcName, data })
@@ -491,7 +562,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
491
562
  stepName: string,
492
563
  rpcName: string | null,
493
564
  data: any,
494
- stepOptions?: WorkflowStepOptions
565
+ stepOptions?: WorkflowStepOptions,
566
+ fromStepName?: string
495
567
  ): Promise<StepState>
496
568
 
497
569
  /**
@@ -574,6 +646,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
574
646
  message: error.message,
575
647
  stack: error.stack,
576
648
  code: (error as any).code,
649
+ expected: isExpectedError(error),
577
650
  }
578
651
  await this.safeMirror(() => this.mirror!.setStepError(stepId, serialized))
579
652
  }
@@ -663,6 +736,20 @@ export abstract class PikkuWorkflowService implements WorkflowService {
663
736
  nodeIds: string[]
664
737
  ): Promise<string[]>
665
738
 
739
+ /**
740
+ * List every step instance of a run (any status) with its predecessor.
741
+ * Drives bounded graph revisits: the runner counts instances per logical node
742
+ * and treats each `fromStepName → node` as a once-fired transition.
743
+ * @param runId - Run ID
744
+ */
745
+ abstract getStepInstances(runId: string): Promise<
746
+ Array<{
747
+ stepName: string
748
+ status: StepStatus
749
+ fromStepName?: string
750
+ }>
751
+ >
752
+
666
753
  /**
667
754
  * Get results for specific nodes
668
755
  * @param runId - Run ID
@@ -784,9 +871,42 @@ export abstract class PikkuWorkflowService implements WorkflowService {
784
871
  const run = await this.getRun(runId)
785
872
  workflowName = run?.workflow
786
873
  }
787
- await queueService.add(this.getOrchestratorQueueName(workflowName), {
788
- runId,
789
- })
874
+ // Carry an explicit retry policy on the orchestrator job too. Orchestrator
875
+ // runs are idempotent (they replay from the snapshot, returning cached step
876
+ // results), so redelivery is always safe — and it's what lets a transient
877
+ // dispatch/infra failure recover: the job is rethrown and retried instead of
878
+ // the run hanging. Passing `attempts` per-job overrides the queue default, so
879
+ // this holds even when the orchestrator queue is configured `retry_limit 0`.
880
+ await queueService.add(
881
+ this.getOrchestratorQueueName(workflowName),
882
+ { runId },
883
+ this.resolveStepJobOptions()
884
+ )
885
+ }
886
+
887
+ /**
888
+ * Resolve a step's retry policy into queue job options. The workflow is the
889
+ * sole source of truth for retries: an explicitly-set `retries` (including 0)
890
+ * is always honored, an unset one defaults to {@link DEFAULT_STEP_RETRIES},
891
+ * and we ALWAYS pass `attempts` so the queue can never fall back to its own
892
+ * default — which would re-run a step the workflow said not to retry. Backoff
893
+ * defaults to exponential whenever there's at least one retry, so retries ride
894
+ * out a transient outage instead of firing instantly.
895
+ */
896
+ protected resolveStepJobOptions(
897
+ stepOptions?: WorkflowStepOptions
898
+ ): JobOptions {
899
+ const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES
900
+ const retryDelay = stepOptions?.retryDelay
901
+ const backoff =
902
+ typeof retryDelay === 'number'
903
+ ? { type: 'fixed', delay: retryDelay }
904
+ : retryDelay === 'exponential'
905
+ ? 'exponential'
906
+ : retries > 0
907
+ ? 'exponential'
908
+ : undefined
909
+ return { attempts: retries + 1, ...(backoff ? { backoff } : {}) }
790
910
  }
791
911
 
792
912
  public async queueStepWorker(
@@ -794,32 +914,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
794
914
  stepName: string,
795
915
  rpcName: string,
796
916
  data: any,
797
- stepOptions?: WorkflowStepOptions
917
+ stepOptions?: WorkflowStepOptions,
918
+ fromStepName?: string
798
919
  ): Promise<void> {
799
920
  const queueService = this.verifyQueueService()
800
- const retries = stepOptions?.retries ?? 0
801
- const retryDelay = stepOptions?.retryDelay
802
921
  await queueService.add(
803
922
  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
923
+ JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })),
924
+ this.resolveStepJobOptions(stepOptions)
823
925
  )
824
926
  }
825
927
 
@@ -877,7 +979,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
877
979
  stepName: string,
878
980
  rpcName: string,
879
981
  data: unknown,
880
- stepOptions?: WorkflowStepOptions
982
+ stepOptions?: WorkflowStepOptions,
983
+ fromStepName?: string
881
984
  ): Promise<boolean> {
882
985
  // Step execution is decided purely by the function's `inline` flag (default
883
986
  // true). Only a function explicitly marked `inline: false` dispatches via
@@ -903,28 +1006,21 @@ export abstract class PikkuWorkflowService implements WorkflowService {
903
1006
  )
904
1007
  return false
905
1008
  }
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
- )
1009
+ try {
1010
+ await getSingletonServices()!.queueService!.add(
1011
+ this.getStepWorkerQueueName(rpcName),
1012
+ JSON.parse(
1013
+ JSON.stringify({ runId, stepName, rpcName, data, fromStepName })
1014
+ ),
1015
+ this.resolveStepJobOptions(stepOptions)
1016
+ )
1017
+ } catch (cause) {
1018
+ // The queue is down/unreachable — NOT a step failure. Surface it as a
1019
+ // transient dispatch error so the caller leaves the step `pending` and the
1020
+ // orchestrator job is retried (replayed from snapshot) rather than the run
1021
+ // being marked failed. `add` already throws on failure in every adapter.
1022
+ throw new WorkflowDispatchException(runId, stepName, { cause })
1023
+ }
928
1024
  return true
929
1025
  }
930
1026
 
@@ -1037,19 +1133,31 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1037
1133
  if (
1038
1134
  error.name !== 'WorkflowAsyncException' &&
1039
1135
  error.name !== 'WorkflowCancelledException' &&
1040
- error.name !== 'WorkflowSuspendedException'
1136
+ error.name !== 'WorkflowSuspendedException' &&
1137
+ // Transient queue failure — leave the run resumable, don't fail it.
1138
+ error.name !== 'WorkflowDispatchException'
1041
1139
  ) {
1042
1140
  await this.updateRunStatus(runId, 'failed', undefined, {
1043
1141
  name: error.name,
1044
1142
  message: error.message,
1045
1143
  stack: error.stack,
1046
1144
  })
1145
+ // An expected failure (a PikkuError, e.g. a build gate tripping) —
1146
+ // its message is the whole story, so don't dump the stack. The
1147
+ // `expected` flag survives the step-boundary rehydration that strips
1148
+ // the class. Anything else is an uncaught/unexpected error: log it in
1149
+ // full so the trace is there to debug.
1047
1150
  getSingletonServices()!.logger.error(
1048
1151
  `Workflow ${name} (run ${runId}) failed:`,
1049
- error
1152
+ isExpectedError(error) ? error.message : error
1050
1153
  )
1051
1154
  throw error
1052
1155
  }
1156
+ if (error.name === 'WorkflowDispatchException') {
1157
+ // Rethrow so the caller (poll loop / starter) sees the transient
1158
+ // failure; the run stays running and can be resumed.
1159
+ throw error
1160
+ }
1053
1161
  } finally {
1054
1162
  this.inlineRuns.delete(runId)
1055
1163
  }
@@ -1092,7 +1200,56 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1092
1200
  }
1093
1201
  }
1094
1202
 
1203
+ // Per-run, per-replay ordinal counters (runId → stepName → count).
1204
+ private stepOrdinals = new Map<string, Map<string, number>>()
1205
+ // Previous step key reached in the current DSL walk (runId → stepName), so a
1206
+ // new step records where it came from. Rebuilt each replay alongside ordinals.
1207
+ private stepLineage = new Map<string, string>()
1208
+
1209
+ private resetStepOrdinals(runId: string): void {
1210
+ this.stepOrdinals.set(runId, new Map())
1211
+ this.stepLineage.delete(runId)
1212
+ }
1213
+
1214
+ /** The step the DSL walk last reached (the predecessor for the next step). */
1215
+ private lastStepName(runId: string): string | undefined {
1216
+ return this.stepLineage.get(runId)
1217
+ }
1218
+
1219
+ /**
1220
+ * Physical, replay-stable key for the Nth reach of `logicalStepName` in a run:
1221
+ * bare name for the first reach (ordinal 0, unchanged behavior), `name#N` for
1222
+ * repeats — so the same literal step name can be invoked multiple times without
1223
+ * the rows clobbering. Deterministic given a deterministic DSL body.
1224
+ */
1225
+ private nextStepKey(runId: string, logicalStepName: string): string {
1226
+ let perRun = this.stepOrdinals.get(runId)
1227
+ if (!perRun) {
1228
+ perRun = new Map()
1229
+ this.stepOrdinals.set(runId, perRun)
1230
+ }
1231
+ const ordinal = perRun.get(logicalStepName) ?? 0
1232
+ perRun.set(logicalStepName, ordinal + 1)
1233
+ const stepName =
1234
+ ordinal === 0 ? logicalStepName : `${logicalStepName}#${ordinal}`
1235
+ this.stepLineage.set(runId, stepName)
1236
+ return stepName
1237
+ }
1238
+
1095
1239
  public async runWorkflowJob(runId: string, rpcService: any): Promise<void> {
1240
+ // Fresh ordinal counters per replay so step keys are deterministic.
1241
+ this.resetStepOrdinals(runId)
1242
+ try {
1243
+ await this.runWorkflowJobInner(runId, rpcService)
1244
+ } finally {
1245
+ this.stepOrdinals.delete(runId)
1246
+ }
1247
+ }
1248
+
1249
+ private async runWorkflowJobInner(
1250
+ runId: string,
1251
+ rpcService: any
1252
+ ): Promise<void> {
1096
1253
  const run = await this.getRun(runId)
1097
1254
  if (!run) {
1098
1255
  throw new WorkflowRunNotFoundError(runId)
@@ -1335,17 +1492,25 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1335
1492
  const isGraphWorkflow =
1336
1493
  workflowMeta?.source === 'graph' ||
1337
1494
  workflowMeta?.source === 'dynamic-workflow'
1338
- if (
1339
- isGraphWorkflow &&
1340
- workflowMeta?.nodes &&
1341
- stepName in workflowMeta.nodes
1342
- ) {
1495
+ // Map the physical step key back to its logical node: a revisit instance
1496
+ // is `node#N` (ordinal), which isn't a literal key in `nodes`.
1497
+ let graphNodeId: string | undefined
1498
+ if (isGraphWorkflow && workflowMeta?.nodes) {
1499
+ if (stepName in workflowMeta.nodes) {
1500
+ graphNodeId = stepName
1501
+ } else {
1502
+ const hash = stepName.lastIndexOf('#')
1503
+ const base = hash > 0 ? stepName.slice(0, hash) : undefined
1504
+ if (base && base in workflowMeta.nodes) graphNodeId = base
1505
+ }
1506
+ }
1507
+ if (graphNodeId) {
1343
1508
  result = await executeGraphStep(
1344
1509
  this,
1345
1510
  rpcService,
1346
1511
  runId,
1347
1512
  stepState.stepId,
1348
- stepName,
1513
+ graphNodeId,
1349
1514
  rpcName,
1350
1515
  data,
1351
1516
  run.workflow
@@ -1389,13 +1554,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1389
1554
  )
1390
1555
  }
1391
1556
  } else {
1392
- result = await rpcService.rpcWithWire(rpcName, data, {
1393
- workflowStep: {
1394
- runId,
1395
- stepId: stepState.stepId,
1396
- attemptCount: stepState.attemptCount,
1397
- },
1398
- })
1557
+ result = await this.invokeStepRpc(
1558
+ runId,
1559
+ stepName,
1560
+ stepState,
1561
+ rpcName,
1562
+ data,
1563
+ rpcService
1564
+ )
1399
1565
  }
1400
1566
  }
1401
1567
 
@@ -1424,7 +1590,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1424
1590
  // Store error and mark failed
1425
1591
  await this.setStepError(stepState.stepId, error)
1426
1592
 
1427
- const maxAttempts = (stepState.retries ?? 0) + 1
1593
+ const maxAttempts = (stepState.retries ?? DEFAULT_STEP_RETRIES) + 1
1428
1594
  const retriesExhausted = stepState.attemptCount >= maxAttempts
1429
1595
 
1430
1596
  if (retriesExhausted) {
@@ -1458,6 +1624,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1458
1624
  return
1459
1625
  }
1460
1626
 
1627
+ if (error.name === 'WorkflowDispatchException') {
1628
+ // Transient: the queue was unreachable, not a workflow failure. Leave the
1629
+ // run running and rethrow so the orchestrator job is redelivered and the
1630
+ // workflow replays from its snapshot. Do NOT mark the run failed.
1631
+ getSingletonServices()!.logger.warn(
1632
+ `Workflow run ${runId} could not dispatch a step (queue unavailable); leaving run for orchestrator retry`,
1633
+ error
1634
+ )
1635
+ throw error
1636
+ }
1637
+
1461
1638
  await this.updateRunStatus(runId, 'failed', undefined, {
1462
1639
  message: error.message,
1463
1640
  stack: error.stack,
@@ -1478,14 +1655,101 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1478
1655
  return getSingletonServices()!.queueService!
1479
1656
  }
1480
1657
 
1481
- private async rpcStep(
1658
+ /**
1659
+ * Invoke a step's RPC with the workflow-step wire (step identity + provenance).
1660
+ * Identical for the queue executor and the inline executor — the only thing
1661
+ * that differs between transports is who calls it, not the call itself.
1662
+ */
1663
+ private async invokeStepRpc(
1482
1664
  runId: string,
1483
1665
  stepName: string,
1666
+ stepState: StepState,
1667
+ rpcName: string,
1668
+ data: any,
1669
+ rpcService: any
1670
+ ): Promise<any> {
1671
+ return rpcService.rpcWithWire(rpcName, data, {
1672
+ workflowStep: {
1673
+ runId,
1674
+ stepId: stepState.stepId,
1675
+ invocationId: deriveInvocationId(runId, stepName),
1676
+ attemptCount: stepState.attemptCount,
1677
+ fromInvocationId: stepState.fromStepName
1678
+ ? deriveInvocationId(runId, stepState.fromStepName)
1679
+ : undefined,
1680
+ },
1681
+ })
1682
+ }
1683
+
1684
+ /**
1685
+ * Inline (straight-through) step execution with an in-process retry loop —
1686
+ * shared by inline RPC steps and inline function steps. Same scaffolding
1687
+ * (running → result, or fail → retry-attempt → backoff → retry) wrapped
1688
+ * around a step-specific `doWork` body. Stays O(K): no suspend/replay.
1689
+ *
1690
+ * `onError` is an optional hook for terminal errors that must NOT retry
1691
+ * (e.g. RPC-not-found → suspend the run for redeploy). If it throws, the
1692
+ * loop exits immediately without recording a step error or retrying.
1693
+ */
1694
+ private async runInlineRetryLoop(
1695
+ stepState: StepState,
1696
+ retries: number,
1697
+ retryDelay: WorkflowStepOptions['retryDelay'],
1698
+ doWork: (currentStepState: StepState) => Promise<any>,
1699
+ onError?: (error: any) => Promise<void>
1700
+ ): Promise<any> {
1701
+ let currentStepState = stepState
1702
+ while (true) {
1703
+ try {
1704
+ await this.setStepRunning(currentStepState.stepId)
1705
+ const result = await doWork(currentStepState)
1706
+ await this.setStepResult(currentStepState.stepId, result)
1707
+ return result
1708
+ } catch (error: any) {
1709
+ if (onError) await onError(error)
1710
+
1711
+ // Record the error (marks step as failed)
1712
+ await this.setStepError(currentStepState.stepId, error)
1713
+
1714
+ if (currentStepState.attemptCount < retries) {
1715
+ // Create a new pending retry attempt, then back off if configured.
1716
+ currentStepState = await this.createRetryAttempt(
1717
+ currentStepState.stepId,
1718
+ 'pending'
1719
+ )
1720
+ if (retryDelay) {
1721
+ await new Promise((resolve) =>
1722
+ setTimeout(resolve, getDurationInMilliseconds(retryDelay))
1723
+ )
1724
+ }
1725
+ // Continue loop to retry
1726
+ } else {
1727
+ // No more retries, fail the workflow
1728
+ throw error
1729
+ }
1730
+ }
1731
+ }
1732
+ }
1733
+
1734
+ private async rpcStep(
1735
+ runId: string,
1736
+ logicalStepName: string,
1484
1737
  rpcName: string,
1485
1738
  data: any,
1486
1739
  rpcService: any,
1487
1740
  stepOptions?: WorkflowStepOptions
1488
1741
  ): Promise<any> {
1742
+ // Capture the predecessor before nextStepKey advances the lineage to us.
1743
+ const fromStepName = this.lastStepName(runId)
1744
+ const stepName = this.nextStepKey(runId, logicalStepName)
1745
+ // Resolve the retry policy ONCE here so the value persisted on the step
1746
+ // (which drives `retriesExhausted`) is the same one the queue dispatch turns
1747
+ // into `attempts`. Without this the queue could retry N times while the
1748
+ // engine thinks retries are already exhausted (or vice-versa).
1749
+ const resolvedStepOptions: WorkflowStepOptions = {
1750
+ retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
1751
+ retryDelay: stepOptions?.retryDelay,
1752
+ }
1489
1753
  // Check if step already exists
1490
1754
  let stepState: StepState
1491
1755
  try {
@@ -1497,7 +1761,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1497
1761
  stepName,
1498
1762
  rpcName,
1499
1763
  data,
1500
- stepOptions
1764
+ resolvedStepOptions,
1765
+ fromStepName
1501
1766
  )
1502
1767
  }
1503
1768
 
@@ -1524,122 +1789,100 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1524
1789
  throw new WorkflowAsyncException(runId, stepName)
1525
1790
  }
1526
1791
 
1527
- // Step is pending - schedule it
1528
- await this.setStepScheduled(stepState.stepId)
1529
-
1530
1792
  // Hand off to subclass-overridable transport. Default behavior enqueues
1531
1793
  // via the queue service; DO-style subclasses RPC to a step worker.
1794
+ // Dispatch BEFORE marking the step `scheduled`: if the queue is down,
1795
+ // dispatchStep throws WorkflowDispatchException and the step stays `pending`,
1796
+ // so the orchestrator's next replay re-dispatches it. Marking `scheduled`
1797
+ // first would strand the step (replay sees `scheduled`, pauses, never
1798
+ // re-enqueues the job that was never created).
1532
1799
  const dispatched = await this.dispatchStep(
1533
1800
  runId,
1534
1801
  stepName,
1535
1802
  rpcName,
1536
1803
  data,
1537
- stepOptions
1804
+ resolvedStepOptions,
1805
+ fromStepName
1538
1806
  )
1539
1807
  if (dispatched) {
1808
+ await this.setStepScheduled(stepState.stepId)
1540
1809
  throw new WorkflowAsyncException(runId, stepName)
1541
1810
  }
1542
1811
 
1543
- {
1544
- // Inline (no transport available) - execute locally with retry loop
1545
- const retries = stepOptions?.retries ?? 0
1546
- const retryDelay = stepOptions?.retryDelay
1547
- let currentStepState = stepState
1548
-
1549
- while (true) {
1550
- try {
1551
- await this.setStepRunning(currentStepState.stepId)
1552
- // Check if the name refers to a workflow
1553
- const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
1554
- let result: any
1555
- if (workflowMeta) {
1556
- const childWire = {
1557
- type: 'workflow',
1558
- id: rpcName,
1559
- parentRunId: runId,
1560
- pikkuUserId: rpcService.wire?.pikkuUserId,
1812
+ // Inline (no transport available) - execute locally with the shared retry
1813
+ // loop. The body resolves to a sub-workflow result or a plain RPC result.
1814
+ const retries = resolvedStepOptions.retries ?? this.getConfig().retries
1815
+ const retryDelay = resolvedStepOptions.retryDelay
1816
+
1817
+ return this.runInlineRetryLoop(
1818
+ stepState,
1819
+ retries,
1820
+ retryDelay,
1821
+ async (currentStepState) => {
1822
+ // Check if the name refers to a workflow
1823
+ const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
1824
+ if (workflowMeta) {
1825
+ const childWire = {
1826
+ type: 'workflow',
1827
+ id: rpcName,
1828
+ parentRunId: runId,
1829
+ pikkuUserId: rpcService.wire?.pikkuUserId,
1830
+ }
1831
+ const { runId: childRunId } = await this.startWorkflow(
1832
+ rpcName,
1833
+ data,
1834
+ childWire,
1835
+ rpcService,
1836
+ { inline: true }
1837
+ )
1838
+ await this.setStepChildRunId(currentStepState.stepId, childRunId)
1839
+ // Poll until child workflow completes
1840
+ while (true) {
1841
+ const childRun = await this.getRun(childRunId)
1842
+ if (!childRun) {
1843
+ throw new WorkflowRunNotFoundError(childRunId)
1561
1844
  }
1562
- const { runId: childRunId } = await this.startWorkflow(
1563
- rpcName,
1564
- data,
1565
- childWire,
1566
- rpcService,
1567
- { inline: true }
1568
- )
1569
- await this.setStepChildRunId(currentStepState.stepId, childRunId)
1570
- // Poll until child workflow completes
1571
- while (true) {
1572
- const childRun = await this.getRun(childRunId)
1573
- if (!childRun) {
1574
- throw new WorkflowRunNotFoundError(childRunId)
1845
+ if (WORKFLOW_END_STATES.has(childRun.status)) {
1846
+ if (childRun.status === 'failed') {
1847
+ throw new Error(childRun.error?.message || 'Sub-workflow failed')
1575
1848
  }
1576
- if (WORKFLOW_END_STATES.has(childRun.status)) {
1577
- if (childRun.status === 'failed') {
1578
- throw new Error(
1579
- childRun.error?.message || 'Sub-workflow failed'
1580
- )
1581
- }
1582
- if (childRun.status === 'cancelled') {
1583
- throw new Error('Sub-workflow was cancelled')
1584
- }
1585
- result = childRun.output
1586
- break
1849
+ if (childRun.status === 'cancelled') {
1850
+ throw new Error('Sub-workflow was cancelled')
1587
1851
  }
1588
- await new Promise((resolve) => setTimeout(resolve, 500))
1852
+ return childRun.output
1589
1853
  }
1590
- } else {
1591
- result = await rpcService.rpcWithWire(rpcName, data, {
1592
- workflowStep: {
1593
- runId,
1594
- stepId: currentStepState.stepId,
1595
- attemptCount: currentStepState.attemptCount,
1596
- },
1597
- })
1598
- }
1599
- await this.setStepResult(currentStepState.stepId, result)
1600
- return result
1601
- } catch (error: any) {
1602
- if (error instanceof RPCNotFoundError) {
1603
- await this.updateRunStatus(runId, 'suspended', undefined, {
1604
- message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
1605
- code: 'RPC_NOT_FOUND',
1606
- })
1607
- throw error
1608
- }
1609
-
1610
- // Record the error (marks step as failed)
1611
- await this.setStepError(currentStepState.stepId, error)
1612
-
1613
- // Check if we should retry
1614
- if (currentStepState.attemptCount < retries) {
1615
- // Create a new pending retry attempt
1616
- currentStepState = await this.createRetryAttempt(
1617
- currentStepState.stepId,
1618
- 'pending'
1619
- )
1620
-
1621
- // Wait for retry delay if specified
1622
- if (retryDelay) {
1623
- await new Promise((resolve) =>
1624
- setTimeout(resolve, getDurationInMilliseconds(retryDelay))
1625
- )
1626
- }
1627
- // Continue loop to retry
1628
- } else {
1629
- // No more retries, fail the workflow
1630
- throw error
1854
+ await new Promise((resolve) => setTimeout(resolve, 500))
1631
1855
  }
1632
1856
  }
1857
+ return this.invokeStepRpc(
1858
+ runId,
1859
+ stepName,
1860
+ currentStepState,
1861
+ rpcName,
1862
+ data,
1863
+ rpcService
1864
+ )
1865
+ },
1866
+ async (error) => {
1867
+ if (error instanceof RPCNotFoundError) {
1868
+ await this.updateRunStatus(runId, 'suspended', undefined, {
1869
+ message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
1870
+ code: 'RPC_NOT_FOUND',
1871
+ })
1872
+ throw error
1873
+ }
1633
1874
  }
1634
- }
1875
+ )
1635
1876
  }
1636
1877
 
1637
1878
  private async inlineStep(
1638
1879
  runId: string,
1639
- stepName: string,
1880
+ logicalStepName: string,
1640
1881
  fn: Function,
1641
1882
  stepOptions?: WorkflowStepOptions
1642
1883
  ): Promise<any> {
1884
+ const fromStepName = this.lastStepName(runId)
1885
+ const stepName = this.nextStepKey(runId, logicalStepName)
1643
1886
  // Check if step already exists
1644
1887
  let stepState: StepState
1645
1888
  try {
@@ -1651,7 +1894,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1651
1894
  stepName,
1652
1895
  null,
1653
1896
  null,
1654
- stepOptions
1897
+ stepOptions,
1898
+ fromStepName
1655
1899
  )
1656
1900
  }
1657
1901
 
@@ -1663,44 +1907,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1663
1907
  // Execute inline function
1664
1908
  const retries = stepOptions?.retries ?? this.getConfig().retries
1665
1909
  const retryDelay = stepOptions?.retryDelay ?? this.getConfig().retryDelay
1666
- let currentStepState = stepState
1667
1910
 
1668
1911
  // Check if we're running inline (in-memory) or remote (queue-based)
1669
1912
  if (this.isInline(runId)) {
1670
- // Inline mode - execute with retry loop
1671
- while (true) {
1672
- try {
1673
- await this.setStepRunning(currentStepState.stepId)
1674
- const result = await fn()
1675
- await this.setStepResult(currentStepState.stepId, result)
1676
- return result
1677
- } catch (error: any) {
1678
- // Record the error (marks step as failed)
1679
- await this.setStepError(currentStepState.stepId, error)
1680
-
1681
- // Check if we should retry
1682
- if (currentStepState.attemptCount < retries) {
1683
- // Create a new pending retry attempt
1684
- currentStepState = await this.createRetryAttempt(
1685
- currentStepState.stepId,
1686
- 'pending'
1687
- )
1688
-
1689
- // Wait for retry delay if specified
1690
- if (retryDelay) {
1691
- await new Promise((resolve) =>
1692
- setTimeout(resolve, getDurationInMilliseconds(retryDelay))
1693
- )
1694
- }
1695
- // Continue loop to retry
1696
- } else {
1697
- // No more retries, fail the workflow
1698
- throw error
1699
- }
1700
- }
1701
- }
1913
+ // Inline mode - execute with the shared in-process retry loop.
1914
+ return this.runInlineRetryLoop(stepState, retries, retryDelay, () => fn())
1702
1915
  } else {
1703
- // Remote mode - use queue-based retry
1916
+ // Remote mode - single attempt, then suspend for orchestrator-driven retry.
1917
+ let currentStepState = stepState
1704
1918
  try {
1705
1919
  await this.setStepRunning(currentStepState.stepId)
1706
1920
  const result = await fn()
@@ -1728,16 +1942,27 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1728
1942
  }
1729
1943
  }
1730
1944
 
1731
- private async sleepStep(runId: string, stepName: string, duration: number) {
1945
+ private async sleepStep(
1946
+ runId: string,
1947
+ logicalStepName: string,
1948
+ duration: number
1949
+ ) {
1950
+ const fromStepName = this.lastStepName(runId)
1951
+ const stepName = this.nextStepKey(runId, logicalStepName)
1732
1952
  // Check if step already exists
1733
1953
  let stepState: StepState
1734
1954
  try {
1735
1955
  stepState = await this.getStepState(runId, stepName)
1736
1956
  } catch {
1737
1957
  // Step doesn't exist - create it (sleep step, no RPC)
1738
- stepState = await this.insertStepState(runId, stepName, null, {
1739
- duration,
1740
- })
1958
+ stepState = await this.insertStepState(
1959
+ runId,
1960
+ stepName,
1961
+ null,
1962
+ { duration },
1963
+ undefined,
1964
+ fromStepName
1965
+ )
1741
1966
  }
1742
1967
 
1743
1968
  if (stepState.status === 'succeeded') {
@@ -1750,18 +1975,19 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1750
1975
  throw new WorkflowAsyncException(runId, stepName)
1751
1976
  }
1752
1977
 
1753
- // Step is pending - schedule it
1754
- await this.setStepScheduled(stepState.stepId)
1755
-
1756
1978
  // Hand off to subclass-overridable transport. Default behavior schedules
1757
1979
  // 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
- )
1980
+ // override to use native timer primitives (e.g. setAlarm). Schedule BEFORE
1981
+ // marking `scheduled` so a scheduler outage leaves the step `pending` for
1982
+ // re-scheduling on replay instead of stranding it (see rpcStep).
1983
+ let scheduled: boolean
1984
+ try {
1985
+ scheduled = await this.scheduleSleep(runId, stepState.stepId, duration)
1986
+ } catch (cause) {
1987
+ throw new WorkflowDispatchException(runId, stepName, { cause })
1988
+ }
1764
1989
  if (scheduled) {
1990
+ await this.setStepScheduled(stepState.stepId)
1765
1991
  throw new WorkflowAsyncException(runId, stepName)
1766
1992
  }
1767
1993
 
@@ -1788,7 +2014,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1788
2014
  }
1789
2015
 
1790
2016
  private async suspendStep(runId: string, reason: string): Promise<void> {
1791
- const suspendStepName = this.getSuspendStepName(reason)
2017
+ const fromStepName = this.lastStepName(runId)
2018
+ const suspendStepName = this.nextStepKey(
2019
+ runId,
2020
+ this.getSuspendStepName(reason)
2021
+ )
1792
2022
  await this.withStepLock(runId, suspendStepName, async () => {
1793
2023
  let stepState: StepState
1794
2024
  try {
@@ -1800,7 +2030,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1800
2030
  'pikkuWorkflowSuspend',
1801
2031
  {
1802
2032
  reason,
1803
- }
2033
+ },
2034
+ undefined,
2035
+ fromStepName
1804
2036
  )
1805
2037
  }
1806
2038
  if (!stepState.stepId) {
@@ -1810,7 +2042,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1810
2042
  'pikkuWorkflowSuspend',
1811
2043
  {
1812
2044
  reason,
1813
- }
2045
+ },
2046
+ undefined,
2047
+ fromStepName
1814
2048
  )
1815
2049
  }
1816
2050
 
@@ -1900,7 +2134,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1900
2134
  const singletonServices = getSingletonServices()
1901
2135
  const workflow = singletonServices.config?.workflow
1902
2136
  return {
1903
- retries: workflow?.retries ?? 0,
2137
+ retries: workflow?.retries ?? DEFAULT_STEP_RETRIES,
1904
2138
  retryDelay: workflow?.retryDelay ?? 0,
1905
2139
  orchestratorQueueName:
1906
2140
  workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',