@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
@@ -29,9 +29,19 @@ const resolveWorkflowMeta = (name) => {
29
29
  };
30
30
  const toKebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
31
31
  import { continueGraph, executeGraphStep, runWorkflowGraph, runFromMeta, } from './graph/graph-runner.js';
32
- import { PikkuError, addError } from '../../errors/error-handler.js';
32
+ import { PikkuError, addError, isExpectedError, } from '../../errors/error-handler.js';
33
33
  import { RPCNotFoundError } from '../rpc/rpc-runner.js';
34
34
  import { ChildWorkflowStartedException } from './graph/graph-runner.js';
35
+ import { deriveInvocationId } from './workflow-invocation-id.js';
36
+ import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
37
+ /**
38
+ * Default number of retries for a workflow step when none is specified. The
39
+ * workflow — not the queue — owns retry policy; a step inherits this unless it
40
+ * sets its own `retries` (including `retries: 0` to opt out entirely). Picked >0
41
+ * so a transient failure (a DB blip, a downstream restart, a deploy) is ridden
42
+ * out by default; safe because every step gets a stable `invocationId` to dedupe on.
43
+ */
44
+ export const DEFAULT_STEP_RETRIES = 5;
35
45
  /**
36
46
  * Exception thrown when workflow needs to pause for async step
37
47
  */
@@ -71,6 +81,24 @@ export class WorkflowSuspendedException extends Error {
71
81
  this.name = 'WorkflowSuspendedException';
72
82
  }
73
83
  }
84
+ /**
85
+ * Thrown when a step (or the orchestrator) could not be enqueued — the queue
86
+ * itself failed (e.g. pg-boss is momentarily down), NOT the step's own logic.
87
+ * This is transient infrastructure failure: the run is left untouched (the step
88
+ * stays `pending`, the run stays running) and the orchestrator job is rethrown
89
+ * so the queue redelivers it and the workflow replays from its snapshot. Treat
90
+ * it as non-terminal — never mark the run `failed` for it.
91
+ */
92
+ export class WorkflowDispatchException extends Error {
93
+ runId;
94
+ stepName;
95
+ constructor(runId, stepName, options) {
96
+ super(`Failed to dispatch workflow step '${stepName}' (run ${runId})`, options);
97
+ this.runId = runId;
98
+ this.stepName = stepName;
99
+ this.name = 'WorkflowDispatchException';
100
+ }
101
+ }
74
102
  /**
75
103
  * Error class for workflow not found
76
104
  */
@@ -266,6 +294,7 @@ export class PikkuWorkflowService {
266
294
  status: step.status,
267
295
  startedAt: step.runningAt ?? step.createdAt,
268
296
  completedAt: step.succeededAt ?? step.failedAt,
297
+ attempts: step.attemptCount,
269
298
  });
270
299
  }
271
300
  }
@@ -275,6 +304,7 @@ export class PikkuWorkflowService {
275
304
  duration: s.startedAt && s.completedAt
276
305
  ? s.completedAt.getTime() - s.startedAt.getTime()
277
306
  : undefined,
307
+ attempts: s.attempts,
278
308
  }));
279
309
  return {
280
310
  id: run.id,
@@ -290,6 +320,30 @@ export class PikkuWorkflowService {
290
320
  : undefined,
291
321
  };
292
322
  }
323
+ /**
324
+ * Build the run's time-travel event stream from durable history.
325
+ * @param id - Run ID
326
+ * @returns Ordered timeline, or null if the run doesn't exist
327
+ */
328
+ async getRunTimeline(id) {
329
+ const run = await this.getRun(id);
330
+ if (!run)
331
+ return null;
332
+ return buildRunTimeline(await this.getRunHistory(id));
333
+ }
334
+ /**
335
+ * Reconstruct the run's state at a point in its timeline.
336
+ * @param id - Run ID
337
+ * @param at - A seq index (inclusive) or a Date (inclusive); omit for the
338
+ * final state.
339
+ * @returns Reconstructed state, or null if the run doesn't exist
340
+ */
341
+ async reconstructRunStateAt(id, at) {
342
+ const timeline = await this.getRunTimeline(id);
343
+ if (!timeline)
344
+ return null;
345
+ return reconstructStateAt(timeline, at ?? timeline.length - 1);
346
+ }
293
347
  /**
294
348
  * Update workflow run status
295
349
  * @param id - Run ID
@@ -309,8 +363,8 @@ export class PikkuWorkflowService {
309
363
  * @param stepOptions - Step options (retries, retryDelay)
310
364
  * @returns Step state with generated stepId
311
365
  */
312
- async insertStepState(runId, stepName, rpcName, data, stepOptions) {
313
- const step = await this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions);
366
+ async insertStepState(runId, stepName, rpcName, data, stepOptions, fromStepName) {
367
+ const step = await this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName);
314
368
  await this.safeMirror(() => this.mirror.insertStepState(runId, { ...step, stepName, rpcName, data }));
315
369
  return step;
316
370
  }
@@ -363,6 +417,7 @@ export class PikkuWorkflowService {
363
417
  message: error.message,
364
418
  stack: error.stack,
365
419
  code: error.code,
420
+ expected: isExpectedError(error),
366
421
  };
367
422
  await this.safeMirror(() => this.mirror.setStepError(stepId, serialized));
368
423
  }
@@ -423,29 +478,38 @@ export class PikkuWorkflowService {
423
478
  const run = await this.getRun(runId);
424
479
  workflowName = run?.workflow;
425
480
  }
426
- await queueService.add(this.getOrchestratorQueueName(workflowName), {
427
- runId,
428
- });
481
+ // Carry an explicit retry policy on the orchestrator job too. Orchestrator
482
+ // runs are idempotent (they replay from the snapshot, returning cached step
483
+ // results), so redelivery is always safe — and it's what lets a transient
484
+ // dispatch/infra failure recover: the job is rethrown and retried instead of
485
+ // the run hanging. Passing `attempts` per-job overrides the queue default, so
486
+ // this holds even when the orchestrator queue is configured `retry_limit 0`.
487
+ await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, this.resolveStepJobOptions());
429
488
  }
430
- async queueStepWorker(runId, stepName, rpcName, data, stepOptions) {
431
- const queueService = this.verifyQueueService();
432
- const retries = stepOptions?.retries ?? 0;
489
+ /**
490
+ * Resolve a step's retry policy into queue job options. The workflow is the
491
+ * sole source of truth for retries: an explicitly-set `retries` (including 0)
492
+ * is always honored, an unset one defaults to {@link DEFAULT_STEP_RETRIES},
493
+ * and we ALWAYS pass `attempts` so the queue can never fall back to its own
494
+ * default — which would re-run a step the workflow said not to retry. Backoff
495
+ * defaults to exponential whenever there's at least one retry, so retries ride
496
+ * out a transient outage instead of firing instantly.
497
+ */
498
+ resolveStepJobOptions(stepOptions) {
499
+ const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES;
433
500
  const retryDelay = stepOptions?.retryDelay;
434
- await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({
435
- runId,
436
- stepName,
437
- rpcName,
438
- data,
439
- })), retries > 0 || retryDelay
440
- ? {
441
- attempts: retries + 1,
442
- backoff: typeof retryDelay === 'number'
443
- ? { type: 'fixed', delay: retryDelay }
444
- : retryDelay === 'exponential'
445
- ? 'exponential'
446
- : undefined,
447
- }
448
- : undefined);
501
+ const backoff = typeof retryDelay === 'number'
502
+ ? { type: 'fixed', delay: retryDelay }
503
+ : retryDelay === 'exponential'
504
+ ? 'exponential'
505
+ : retries > 0
506
+ ? 'exponential'
507
+ : undefined;
508
+ return { attempts: retries + 1, ...(backoff ? { backoff } : {}) };
509
+ }
510
+ async queueStepWorker(runId, stepName, rpcName, data, stepOptions, fromStepName) {
511
+ const queueService = this.verifyQueueService();
512
+ await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), this.resolveStepJobOptions(stepOptions));
449
513
  }
450
514
  /**
451
515
  * Execute a workflow sleep step completion
@@ -483,7 +547,7 @@ export class PikkuWorkflowService {
483
547
  * @returns true if dispatch was async (caller should pause), false to fall
484
548
  * through to the inline execution path.
485
549
  */
486
- async dispatchStep(runId, stepName, rpcName, data, stepOptions) {
550
+ async dispatchStep(runId, stepName, rpcName, data, stepOptions, fromStepName) {
487
551
  // Step execution is decided purely by the function's `inline` flag (default
488
552
  // true). Only a function explicitly marked `inline: false` dispatches via
489
553
  // the queue. The run-level inline state is intentionally NOT consulted
@@ -505,21 +569,16 @@ export class PikkuWorkflowService {
505
569
  getSingletonServices()?.logger.warn(`Workflow step '${stepName}' (function '${rpcName}') is marked 'inline: false' but no queue service is configured — running it inline instead of dispatching to a queue.`);
506
570
  return false;
507
571
  }
508
- const retries = stepOptions?.retries ?? 0;
509
- const retryDelay = stepOptions?.retryDelay;
510
- await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({
511
- runId,
512
- stepName,
513
- rpcName,
514
- data,
515
- })), {
516
- attempts: retries + 1,
517
- backoff: typeof retryDelay === 'number'
518
- ? { type: 'fixed', delay: retryDelay }
519
- : retryDelay === 'exponential'
520
- ? 'exponential'
521
- : undefined,
522
- });
572
+ try {
573
+ await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), this.resolveStepJobOptions(stepOptions));
574
+ }
575
+ catch (cause) {
576
+ // The queue is down/unreachable — NOT a step failure. Surface it as a
577
+ // transient dispatch error so the caller leaves the step `pending` and the
578
+ // orchestrator job is retried (replayed from snapshot) rather than the run
579
+ // being marked failed. `add` already throws on failure in every adapter.
580
+ throw new WorkflowDispatchException(runId, stepName, { cause });
581
+ }
523
582
  return true;
524
583
  }
525
584
  /**
@@ -587,13 +646,25 @@ export class PikkuWorkflowService {
587
646
  catch (error) {
588
647
  if (error.name !== 'WorkflowAsyncException' &&
589
648
  error.name !== 'WorkflowCancelledException' &&
590
- error.name !== 'WorkflowSuspendedException') {
649
+ error.name !== 'WorkflowSuspendedException' &&
650
+ // Transient queue failure — leave the run resumable, don't fail it.
651
+ error.name !== 'WorkflowDispatchException') {
591
652
  await this.updateRunStatus(runId, 'failed', undefined, {
592
653
  name: error.name,
593
654
  message: error.message,
594
655
  stack: error.stack,
595
656
  });
596
- getSingletonServices().logger.error(`Workflow ${name} (run ${runId}) failed:`, error);
657
+ // An expected failure (a PikkuError, e.g. a build gate tripping)
658
+ // its message is the whole story, so don't dump the stack. The
659
+ // `expected` flag survives the step-boundary rehydration that strips
660
+ // the class. Anything else is an uncaught/unexpected error: log it in
661
+ // full so the trace is there to debug.
662
+ getSingletonServices().logger.error(`Workflow ${name} (run ${runId}) failed:`, isExpectedError(error) ? error.message : error);
663
+ throw error;
664
+ }
665
+ if (error.name === 'WorkflowDispatchException') {
666
+ // Rethrow so the caller (poll loop / starter) sees the transient
667
+ // failure; the run stays running and can be resumed.
597
668
  throw error;
598
669
  }
599
670
  }
@@ -626,7 +697,48 @@ export class PikkuWorkflowService {
626
697
  await new Promise((resolve) => setTimeout(resolve, pollInterval));
627
698
  }
628
699
  }
700
+ // Per-run, per-replay ordinal counters (runId → stepName → count).
701
+ stepOrdinals = new Map();
702
+ // Previous step key reached in the current DSL walk (runId → stepName), so a
703
+ // new step records where it came from. Rebuilt each replay alongside ordinals.
704
+ stepLineage = new Map();
705
+ resetStepOrdinals(runId) {
706
+ this.stepOrdinals.set(runId, new Map());
707
+ this.stepLineage.delete(runId);
708
+ }
709
+ /** The step the DSL walk last reached (the predecessor for the next step). */
710
+ lastStepName(runId) {
711
+ return this.stepLineage.get(runId);
712
+ }
713
+ /**
714
+ * Physical, replay-stable key for the Nth reach of `logicalStepName` in a run:
715
+ * bare name for the first reach (ordinal 0, unchanged behavior), `name#N` for
716
+ * repeats — so the same literal step name can be invoked multiple times without
717
+ * the rows clobbering. Deterministic given a deterministic DSL body.
718
+ */
719
+ nextStepKey(runId, logicalStepName) {
720
+ let perRun = this.stepOrdinals.get(runId);
721
+ if (!perRun) {
722
+ perRun = new Map();
723
+ this.stepOrdinals.set(runId, perRun);
724
+ }
725
+ const ordinal = perRun.get(logicalStepName) ?? 0;
726
+ perRun.set(logicalStepName, ordinal + 1);
727
+ const stepName = ordinal === 0 ? logicalStepName : `${logicalStepName}#${ordinal}`;
728
+ this.stepLineage.set(runId, stepName);
729
+ return stepName;
730
+ }
629
731
  async runWorkflowJob(runId, rpcService) {
732
+ // Fresh ordinal counters per replay so step keys are deterministic.
733
+ this.resetStepOrdinals(runId);
734
+ try {
735
+ await this.runWorkflowJobInner(runId, rpcService);
736
+ }
737
+ finally {
738
+ this.stepOrdinals.delete(runId);
739
+ }
740
+ }
741
+ async runWorkflowJobInner(runId, rpcService) {
630
742
  const run = await this.getRun(runId);
631
743
  if (!run) {
632
744
  throw new WorkflowRunNotFoundError(runId);
@@ -799,10 +911,22 @@ export class PikkuWorkflowService {
799
911
  const workflowMeta = meta[run.workflow];
800
912
  const isGraphWorkflow = workflowMeta?.source === 'graph' ||
801
913
  workflowMeta?.source === 'dynamic-workflow';
802
- if (isGraphWorkflow &&
803
- workflowMeta?.nodes &&
804
- stepName in workflowMeta.nodes) {
805
- result = await executeGraphStep(this, rpcService, runId, stepState.stepId, stepName, rpcName, data, run.workflow);
914
+ // Map the physical step key back to its logical node: a revisit instance
915
+ // is `node#N` (ordinal), which isn't a literal key in `nodes`.
916
+ let graphNodeId;
917
+ if (isGraphWorkflow && workflowMeta?.nodes) {
918
+ if (stepName in workflowMeta.nodes) {
919
+ graphNodeId = stepName;
920
+ }
921
+ else {
922
+ const hash = stepName.lastIndexOf('#');
923
+ const base = hash > 0 ? stepName.slice(0, hash) : undefined;
924
+ if (base && base in workflowMeta.nodes)
925
+ graphNodeId = base;
926
+ }
927
+ }
928
+ if (graphNodeId) {
929
+ result = await executeGraphStep(this, rpcService, runId, stepState.stepId, graphNodeId, rpcName, data, run.workflow);
806
930
  }
807
931
  else {
808
932
  // Check if rpcName refers to a sub-workflow
@@ -833,13 +957,7 @@ export class PikkuWorkflowService {
833
957
  }
834
958
  }
835
959
  else {
836
- result = await rpcService.rpcWithWire(rpcName, data, {
837
- workflowStep: {
838
- runId,
839
- stepId: stepState.stepId,
840
- attemptCount: stepState.attemptCount,
841
- },
842
- });
960
+ result = await this.invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService);
843
961
  }
844
962
  }
845
963
  // Store result and mark succeeded
@@ -862,7 +980,7 @@ export class PikkuWorkflowService {
862
980
  }
863
981
  // Store error and mark failed
864
982
  await this.setStepError(stepState.stepId, error);
865
- const maxAttempts = (stepState.retries ?? 0) + 1;
983
+ const maxAttempts = (stepState.retries ?? DEFAULT_STEP_RETRIES) + 1;
866
984
  const retriesExhausted = stepState.attemptCount >= maxAttempts;
867
985
  if (retriesExhausted) {
868
986
  // No more retries - resume orchestrator to mark workflow as failed
@@ -888,6 +1006,13 @@ export class PikkuWorkflowService {
888
1006
  error.name === 'WorkflowSuspendedException') {
889
1007
  return;
890
1008
  }
1009
+ if (error.name === 'WorkflowDispatchException') {
1010
+ // Transient: the queue was unreachable, not a workflow failure. Leave the
1011
+ // run running and rethrow so the orchestrator job is redelivered and the
1012
+ // workflow replays from its snapshot. Do NOT mark the run failed.
1013
+ getSingletonServices().logger.warn(`Workflow run ${runId} could not dispatch a step (queue unavailable); leaving run for orchestrator retry`, error);
1014
+ throw error;
1015
+ }
891
1016
  await this.updateRunStatus(runId, 'failed', undefined, {
892
1017
  message: error.message,
893
1018
  stack: error.stack,
@@ -902,7 +1027,75 @@ export class PikkuWorkflowService {
902
1027
  }
903
1028
  return getSingletonServices().queueService;
904
1029
  }
905
- async rpcStep(runId, stepName, rpcName, data, rpcService, stepOptions) {
1030
+ /**
1031
+ * Invoke a step's RPC with the workflow-step wire (step identity + provenance).
1032
+ * Identical for the queue executor and the inline executor — the only thing
1033
+ * that differs between transports is who calls it, not the call itself.
1034
+ */
1035
+ async invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService) {
1036
+ return rpcService.rpcWithWire(rpcName, data, {
1037
+ workflowStep: {
1038
+ runId,
1039
+ stepId: stepState.stepId,
1040
+ invocationId: deriveInvocationId(runId, stepName),
1041
+ attemptCount: stepState.attemptCount,
1042
+ fromInvocationId: stepState.fromStepName
1043
+ ? deriveInvocationId(runId, stepState.fromStepName)
1044
+ : undefined,
1045
+ },
1046
+ });
1047
+ }
1048
+ /**
1049
+ * Inline (straight-through) step execution with an in-process retry loop —
1050
+ * shared by inline RPC steps and inline function steps. Same scaffolding
1051
+ * (running → result, or fail → retry-attempt → backoff → retry) wrapped
1052
+ * around a step-specific `doWork` body. Stays O(K): no suspend/replay.
1053
+ *
1054
+ * `onError` is an optional hook for terminal errors that must NOT retry
1055
+ * (e.g. RPC-not-found → suspend the run for redeploy). If it throws, the
1056
+ * loop exits immediately without recording a step error or retrying.
1057
+ */
1058
+ async runInlineRetryLoop(stepState, retries, retryDelay, doWork, onError) {
1059
+ let currentStepState = stepState;
1060
+ while (true) {
1061
+ try {
1062
+ await this.setStepRunning(currentStepState.stepId);
1063
+ const result = await doWork(currentStepState);
1064
+ await this.setStepResult(currentStepState.stepId, result);
1065
+ return result;
1066
+ }
1067
+ catch (error) {
1068
+ if (onError)
1069
+ await onError(error);
1070
+ // Record the error (marks step as failed)
1071
+ await this.setStepError(currentStepState.stepId, error);
1072
+ if (currentStepState.attemptCount < retries) {
1073
+ // Create a new pending retry attempt, then back off if configured.
1074
+ currentStepState = await this.createRetryAttempt(currentStepState.stepId, 'pending');
1075
+ if (retryDelay) {
1076
+ await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(retryDelay)));
1077
+ }
1078
+ // Continue loop to retry
1079
+ }
1080
+ else {
1081
+ // No more retries, fail the workflow
1082
+ throw error;
1083
+ }
1084
+ }
1085
+ }
1086
+ }
1087
+ async rpcStep(runId, logicalStepName, rpcName, data, rpcService, stepOptions) {
1088
+ // Capture the predecessor before nextStepKey advances the lineage to us.
1089
+ const fromStepName = this.lastStepName(runId);
1090
+ const stepName = this.nextStepKey(runId, logicalStepName);
1091
+ // Resolve the retry policy ONCE here so the value persisted on the step
1092
+ // (which drives `retriesExhausted`) is the same one the queue dispatch turns
1093
+ // into `attempts`. Without this the queue could retry N times while the
1094
+ // engine thinks retries are already exhausted (or vice-versa).
1095
+ const resolvedStepOptions = {
1096
+ retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
1097
+ retryDelay: stepOptions?.retryDelay,
1098
+ };
906
1099
  // Check if step already exists
907
1100
  let stepState;
908
1101
  try {
@@ -910,7 +1103,7 @@ export class PikkuWorkflowService {
910
1103
  }
911
1104
  catch {
912
1105
  // Step doesn't exist - create it
913
- stepState = await this.insertStepState(runId, stepName, rpcName, data, stepOptions);
1106
+ stepState = await this.insertStepState(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
914
1107
  }
915
1108
  if (stepState.status === 'succeeded') {
916
1109
  // Return cached result
@@ -930,94 +1123,66 @@ export class PikkuWorkflowService {
930
1123
  // Step is already scheduled, pause workflow
931
1124
  throw new WorkflowAsyncException(runId, stepName);
932
1125
  }
933
- // Step is pending - schedule it
934
- await this.setStepScheduled(stepState.stepId);
935
1126
  // Hand off to subclass-overridable transport. Default behavior enqueues
936
1127
  // via the queue service; DO-style subclasses RPC to a step worker.
937
- const dispatched = await this.dispatchStep(runId, stepName, rpcName, data, stepOptions);
1128
+ // Dispatch BEFORE marking the step `scheduled`: if the queue is down,
1129
+ // dispatchStep throws WorkflowDispatchException and the step stays `pending`,
1130
+ // so the orchestrator's next replay re-dispatches it. Marking `scheduled`
1131
+ // first would strand the step (replay sees `scheduled`, pauses, never
1132
+ // re-enqueues the job that was never created).
1133
+ const dispatched = await this.dispatchStep(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
938
1134
  if (dispatched) {
1135
+ await this.setStepScheduled(stepState.stepId);
939
1136
  throw new WorkflowAsyncException(runId, stepName);
940
1137
  }
941
- {
942
- // Inline (no transport available) - execute locally with retry loop
943
- const retries = stepOptions?.retries ?? 0;
944
- const retryDelay = stepOptions?.retryDelay;
945
- let currentStepState = stepState;
946
- while (true) {
947
- try {
948
- await this.setStepRunning(currentStepState.stepId);
949
- // Check if the name refers to a workflow
950
- const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
951
- let result;
952
- if (workflowMeta) {
953
- const childWire = {
954
- type: 'workflow',
955
- id: rpcName,
956
- parentRunId: runId,
957
- pikkuUserId: rpcService.wire?.pikkuUserId,
958
- };
959
- const { runId: childRunId } = await this.startWorkflow(rpcName, data, childWire, rpcService, { inline: true });
960
- await this.setStepChildRunId(currentStepState.stepId, childRunId);
961
- // Poll until child workflow completes
962
- while (true) {
963
- const childRun = await this.getRun(childRunId);
964
- if (!childRun) {
965
- throw new WorkflowRunNotFoundError(childRunId);
966
- }
967
- if (WORKFLOW_END_STATES.has(childRun.status)) {
968
- if (childRun.status === 'failed') {
969
- throw new Error(childRun.error?.message || 'Sub-workflow failed');
970
- }
971
- if (childRun.status === 'cancelled') {
972
- throw new Error('Sub-workflow was cancelled');
973
- }
974
- result = childRun.output;
975
- break;
976
- }
977
- await new Promise((resolve) => setTimeout(resolve, 500));
978
- }
979
- }
980
- else {
981
- result = await rpcService.rpcWithWire(rpcName, data, {
982
- workflowStep: {
983
- runId,
984
- stepId: currentStepState.stepId,
985
- attemptCount: currentStepState.attemptCount,
986
- },
987
- });
988
- }
989
- await this.setStepResult(currentStepState.stepId, result);
990
- return result;
991
- }
992
- catch (error) {
993
- if (error instanceof RPCNotFoundError) {
994
- await this.updateRunStatus(runId, 'suspended', undefined, {
995
- message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
996
- code: 'RPC_NOT_FOUND',
997
- });
998
- throw error;
1138
+ // Inline (no transport available) - execute locally with the shared retry
1139
+ // loop. The body resolves to a sub-workflow result or a plain RPC result.
1140
+ const retries = resolvedStepOptions.retries ?? this.getConfig().retries;
1141
+ const retryDelay = resolvedStepOptions.retryDelay;
1142
+ return this.runInlineRetryLoop(stepState, retries, retryDelay, async (currentStepState) => {
1143
+ // Check if the name refers to a workflow
1144
+ const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
1145
+ if (workflowMeta) {
1146
+ const childWire = {
1147
+ type: 'workflow',
1148
+ id: rpcName,
1149
+ parentRunId: runId,
1150
+ pikkuUserId: rpcService.wire?.pikkuUserId,
1151
+ };
1152
+ const { runId: childRunId } = await this.startWorkflow(rpcName, data, childWire, rpcService, { inline: true });
1153
+ await this.setStepChildRunId(currentStepState.stepId, childRunId);
1154
+ // Poll until child workflow completes
1155
+ while (true) {
1156
+ const childRun = await this.getRun(childRunId);
1157
+ if (!childRun) {
1158
+ throw new WorkflowRunNotFoundError(childRunId);
999
1159
  }
1000
- // Record the error (marks step as failed)
1001
- await this.setStepError(currentStepState.stepId, error);
1002
- // Check if we should retry
1003
- if (currentStepState.attemptCount < retries) {
1004
- // Create a new pending retry attempt
1005
- currentStepState = await this.createRetryAttempt(currentStepState.stepId, 'pending');
1006
- // Wait for retry delay if specified
1007
- if (retryDelay) {
1008
- await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(retryDelay)));
1160
+ if (WORKFLOW_END_STATES.has(childRun.status)) {
1161
+ if (childRun.status === 'failed') {
1162
+ throw new Error(childRun.error?.message || 'Sub-workflow failed');
1009
1163
  }
1010
- // Continue loop to retry
1011
- }
1012
- else {
1013
- // No more retries, fail the workflow
1014
- throw error;
1164
+ if (childRun.status === 'cancelled') {
1165
+ throw new Error('Sub-workflow was cancelled');
1166
+ }
1167
+ return childRun.output;
1015
1168
  }
1169
+ await new Promise((resolve) => setTimeout(resolve, 500));
1016
1170
  }
1017
1171
  }
1018
- }
1172
+ return this.invokeStepRpc(runId, stepName, currentStepState, rpcName, data, rpcService);
1173
+ }, async (error) => {
1174
+ if (error instanceof RPCNotFoundError) {
1175
+ await this.updateRunStatus(runId, 'suspended', undefined, {
1176
+ message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
1177
+ code: 'RPC_NOT_FOUND',
1178
+ });
1179
+ throw error;
1180
+ }
1181
+ });
1019
1182
  }
1020
- async inlineStep(runId, stepName, fn, stepOptions) {
1183
+ async inlineStep(runId, logicalStepName, fn, stepOptions) {
1184
+ const fromStepName = this.lastStepName(runId);
1185
+ const stepName = this.nextStepKey(runId, logicalStepName);
1021
1186
  // Check if step already exists
1022
1187
  let stepState;
1023
1188
  try {
@@ -1025,7 +1190,7 @@ export class PikkuWorkflowService {
1025
1190
  }
1026
1191
  catch {
1027
1192
  // Step doesn't exist - create it (inline, no RPC)
1028
- stepState = await this.insertStepState(runId, stepName, null, null, stepOptions);
1193
+ stepState = await this.insertStepState(runId, stepName, null, null, stepOptions, fromStepName);
1029
1194
  }
1030
1195
  if (stepState.status === 'succeeded') {
1031
1196
  // Return cached result
@@ -1034,39 +1199,14 @@ export class PikkuWorkflowService {
1034
1199
  // Execute inline function
1035
1200
  const retries = stepOptions?.retries ?? this.getConfig().retries;
1036
1201
  const retryDelay = stepOptions?.retryDelay ?? this.getConfig().retryDelay;
1037
- let currentStepState = stepState;
1038
1202
  // Check if we're running inline (in-memory) or remote (queue-based)
1039
1203
  if (this.isInline(runId)) {
1040
- // Inline mode - execute with retry loop
1041
- while (true) {
1042
- try {
1043
- await this.setStepRunning(currentStepState.stepId);
1044
- const result = await fn();
1045
- await this.setStepResult(currentStepState.stepId, result);
1046
- return result;
1047
- }
1048
- catch (error) {
1049
- // Record the error (marks step as failed)
1050
- await this.setStepError(currentStepState.stepId, error);
1051
- // Check if we should retry
1052
- if (currentStepState.attemptCount < retries) {
1053
- // Create a new pending retry attempt
1054
- currentStepState = await this.createRetryAttempt(currentStepState.stepId, 'pending');
1055
- // Wait for retry delay if specified
1056
- if (retryDelay) {
1057
- await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(retryDelay)));
1058
- }
1059
- // Continue loop to retry
1060
- }
1061
- else {
1062
- // No more retries, fail the workflow
1063
- throw error;
1064
- }
1065
- }
1066
- }
1204
+ // Inline mode - execute with the shared in-process retry loop.
1205
+ return this.runInlineRetryLoop(stepState, retries, retryDelay, () => fn());
1067
1206
  }
1068
1207
  else {
1069
- // Remote mode - use queue-based retry
1208
+ // Remote mode - single attempt, then suspend for orchestrator-driven retry.
1209
+ let currentStepState = stepState;
1070
1210
  try {
1071
1211
  await this.setStepRunning(currentStepState.stepId);
1072
1212
  const result = await fn();
@@ -1090,7 +1230,9 @@ export class PikkuWorkflowService {
1090
1230
  }
1091
1231
  }
1092
1232
  }
1093
- async sleepStep(runId, stepName, duration) {
1233
+ async sleepStep(runId, logicalStepName, duration) {
1234
+ const fromStepName = this.lastStepName(runId);
1235
+ const stepName = this.nextStepKey(runId, logicalStepName);
1094
1236
  // Check if step already exists
1095
1237
  let stepState;
1096
1238
  try {
@@ -1098,9 +1240,7 @@ export class PikkuWorkflowService {
1098
1240
  }
1099
1241
  catch {
1100
1242
  // Step doesn't exist - create it (sleep step, no RPC)
1101
- stepState = await this.insertStepState(runId, stepName, null, {
1102
- duration,
1103
- });
1243
+ stepState = await this.insertStepState(runId, stepName, null, { duration }, undefined, fromStepName);
1104
1244
  }
1105
1245
  if (stepState.status === 'succeeded') {
1106
1246
  // Sleep already completed, return immediately
@@ -1110,13 +1250,20 @@ export class PikkuWorkflowService {
1110
1250
  // Sleep is already scheduled, pause workflow
1111
1251
  throw new WorkflowAsyncException(runId, stepName);
1112
1252
  }
1113
- // Step is pending - schedule it
1114
- await this.setStepScheduled(stepState.stepId);
1115
1253
  // Hand off to subclass-overridable transport. Default behavior schedules
1116
1254
  // a delayed sleeper RPC via the scheduler service; DO-style subclasses
1117
- // override to use native timer primitives (e.g. setAlarm).
1118
- const scheduled = await this.scheduleSleep(runId, stepState.stepId, duration);
1255
+ // override to use native timer primitives (e.g. setAlarm). Schedule BEFORE
1256
+ // marking `scheduled` so a scheduler outage leaves the step `pending` for
1257
+ // re-scheduling on replay instead of stranding it (see rpcStep).
1258
+ let scheduled;
1259
+ try {
1260
+ scheduled = await this.scheduleSleep(runId, stepState.stepId, duration);
1261
+ }
1262
+ catch (cause) {
1263
+ throw new WorkflowDispatchException(runId, stepName, { cause });
1264
+ }
1119
1265
  if (scheduled) {
1266
+ await this.setStepScheduled(stepState.stepId);
1120
1267
  throw new WorkflowAsyncException(runId, stepName);
1121
1268
  }
1122
1269
  // Inline mode - use setTimeout with actual duration
@@ -1138,7 +1285,8 @@ export class PikkuWorkflowService {
1138
1285
  return `__workflow_suspend:${reason}`;
1139
1286
  }
1140
1287
  async suspendStep(runId, reason) {
1141
- const suspendStepName = this.getSuspendStepName(reason);
1288
+ const fromStepName = this.lastStepName(runId);
1289
+ const suspendStepName = this.nextStepKey(runId, this.getSuspendStepName(reason));
1142
1290
  await this.withStepLock(runId, suspendStepName, async () => {
1143
1291
  let stepState;
1144
1292
  try {
@@ -1147,12 +1295,12 @@ export class PikkuWorkflowService {
1147
1295
  catch {
1148
1296
  stepState = await this.insertStepState(runId, suspendStepName, 'pikkuWorkflowSuspend', {
1149
1297
  reason,
1150
- });
1298
+ }, undefined, fromStepName);
1151
1299
  }
1152
1300
  if (!stepState.stepId) {
1153
1301
  stepState = await this.insertStepState(runId, suspendStepName, 'pikkuWorkflowSuspend', {
1154
1302
  reason,
1155
- });
1303
+ }, undefined, fromStepName);
1156
1304
  }
1157
1305
  if (stepState.status === 'succeeded') {
1158
1306
  return;
@@ -1206,7 +1354,7 @@ export class PikkuWorkflowService {
1206
1354
  const singletonServices = getSingletonServices();
1207
1355
  const workflow = singletonServices.config?.workflow;
1208
1356
  return {
1209
- retries: workflow?.retries ?? 0,
1357
+ retries: workflow?.retries ?? DEFAULT_STEP_RETRIES,
1210
1358
  retryDelay: workflow?.retryDelay ?? 0,
1211
1359
  orchestratorQueueName: workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',
1212
1360
  stepWorkerQueueName: workflow?.stepWorkerQueueName ?? 'pikku-workflow-step-worker',