@pikku/core 0.12.37 → 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 (30) hide show
  1. package/CHANGELOG.md +60 -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/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
  5. package/dist/wirings/workflow/graph/graph-runner.js +117 -45
  6. package/dist/wirings/workflow/index.d.ts +2 -1
  7. package/dist/wirings/workflow/index.js +2 -1
  8. package/dist/wirings/workflow/pikku-workflow-service.d.ts +63 -5
  9. package/dist/wirings/workflow/pikku-workflow-service.js +197 -66
  10. package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
  11. package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
  12. package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
  13. package/dist/wirings/workflow/workflow.types.d.ts +5 -0
  14. package/package.json +2 -1
  15. package/src/dev/hot-reload.test.ts +5 -0
  16. package/src/services/in-memory-workflow-service.ts +25 -1
  17. package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
  18. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
  19. package/src/wirings/workflow/graph/graph-runner.test.ts +87 -3
  20. package/src/wirings/workflow/graph/graph-runner.ts +158 -56
  21. package/src/wirings/workflow/index.ts +3 -0
  22. package/src/wirings/workflow/pikku-workflow-service.ts +264 -86
  23. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
  24. package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
  25. package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
  26. package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
  27. package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
  28. package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
  29. package/src/wirings/workflow/workflow.types.ts +5 -0
  30. package/tsconfig.tsbuildinfo +1 -1
@@ -32,6 +32,15 @@ import { continueGraph, executeGraphStep, runWorkflowGraph, runFromMeta, } from
32
32
  import { PikkuError, addError } 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
+ /**
37
+ * Default number of retries for a workflow step when none is specified. The
38
+ * workflow — not the queue — owns retry policy; a step inherits this unless it
39
+ * sets its own `retries` (including `retries: 0` to opt out entirely). Picked >0
40
+ * so a transient failure (a DB blip, a downstream restart, a deploy) is ridden
41
+ * out by default; safe because every step gets a stable `invocationId` to dedupe on.
42
+ */
43
+ export const DEFAULT_STEP_RETRIES = 5;
35
44
  /**
36
45
  * Exception thrown when workflow needs to pause for async step
37
46
  */
@@ -71,6 +80,24 @@ export class WorkflowSuspendedException extends Error {
71
80
  this.name = 'WorkflowSuspendedException';
72
81
  }
73
82
  }
83
+ /**
84
+ * Thrown when a step (or the orchestrator) could not be enqueued — the queue
85
+ * itself failed (e.g. pg-boss is momentarily down), NOT the step's own logic.
86
+ * This is transient infrastructure failure: the run is left untouched (the step
87
+ * stays `pending`, the run stays running) and the orchestrator job is rethrown
88
+ * so the queue redelivers it and the workflow replays from its snapshot. Treat
89
+ * it as non-terminal — never mark the run `failed` for it.
90
+ */
91
+ export class WorkflowDispatchException extends Error {
92
+ runId;
93
+ stepName;
94
+ constructor(runId, stepName, options) {
95
+ super(`Failed to dispatch workflow step '${stepName}' (run ${runId})`, options);
96
+ this.runId = runId;
97
+ this.stepName = stepName;
98
+ this.name = 'WorkflowDispatchException';
99
+ }
100
+ }
74
101
  /**
75
102
  * Error class for workflow not found
76
103
  */
@@ -309,8 +336,8 @@ export class PikkuWorkflowService {
309
336
  * @param stepOptions - Step options (retries, retryDelay)
310
337
  * @returns Step state with generated stepId
311
338
  */
312
- async insertStepState(runId, stepName, rpcName, data, stepOptions) {
313
- const step = await this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions);
339
+ async insertStepState(runId, stepName, rpcName, data, stepOptions, fromStepName) {
340
+ const step = await this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName);
314
341
  await this.safeMirror(() => this.mirror.insertStepState(runId, { ...step, stepName, rpcName, data }));
315
342
  return step;
316
343
  }
@@ -423,29 +450,38 @@ export class PikkuWorkflowService {
423
450
  const run = await this.getRun(runId);
424
451
  workflowName = run?.workflow;
425
452
  }
426
- await queueService.add(this.getOrchestratorQueueName(workflowName), {
427
- runId,
428
- });
453
+ // Carry an explicit retry policy on the orchestrator job too. Orchestrator
454
+ // runs are idempotent (they replay from the snapshot, returning cached step
455
+ // results), so redelivery is always safe — and it's what lets a transient
456
+ // dispatch/infra failure recover: the job is rethrown and retried instead of
457
+ // the run hanging. Passing `attempts` per-job overrides the queue default, so
458
+ // this holds even when the orchestrator queue is configured `retry_limit 0`.
459
+ await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, this.resolveStepJobOptions());
429
460
  }
430
- async queueStepWorker(runId, stepName, rpcName, data, stepOptions) {
431
- const queueService = this.verifyQueueService();
432
- const retries = stepOptions?.retries ?? 0;
461
+ /**
462
+ * Resolve a step's retry policy into queue job options. The workflow is the
463
+ * sole source of truth for retries: an explicitly-set `retries` (including 0)
464
+ * is always honored, an unset one defaults to {@link DEFAULT_STEP_RETRIES},
465
+ * and we ALWAYS pass `attempts` so the queue can never fall back to its own
466
+ * default — which would re-run a step the workflow said not to retry. Backoff
467
+ * defaults to exponential whenever there's at least one retry, so retries ride
468
+ * out a transient outage instead of firing instantly.
469
+ */
470
+ resolveStepJobOptions(stepOptions) {
471
+ const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES;
433
472
  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);
473
+ const backoff = typeof retryDelay === 'number'
474
+ ? { type: 'fixed', delay: retryDelay }
475
+ : retryDelay === 'exponential'
476
+ ? 'exponential'
477
+ : retries > 0
478
+ ? 'exponential'
479
+ : undefined;
480
+ return { attempts: retries + 1, ...(backoff ? { backoff } : {}) };
481
+ }
482
+ async queueStepWorker(runId, stepName, rpcName, data, stepOptions, fromStepName) {
483
+ const queueService = this.verifyQueueService();
484
+ await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), this.resolveStepJobOptions(stepOptions));
449
485
  }
450
486
  /**
451
487
  * Execute a workflow sleep step completion
@@ -483,7 +519,7 @@ export class PikkuWorkflowService {
483
519
  * @returns true if dispatch was async (caller should pause), false to fall
484
520
  * through to the inline execution path.
485
521
  */
486
- async dispatchStep(runId, stepName, rpcName, data, stepOptions) {
522
+ async dispatchStep(runId, stepName, rpcName, data, stepOptions, fromStepName) {
487
523
  // Step execution is decided purely by the function's `inline` flag (default
488
524
  // true). Only a function explicitly marked `inline: false` dispatches via
489
525
  // the queue. The run-level inline state is intentionally NOT consulted
@@ -505,21 +541,16 @@ export class PikkuWorkflowService {
505
541
  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
542
  return false;
507
543
  }
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
- });
544
+ try {
545
+ await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), this.resolveStepJobOptions(stepOptions));
546
+ }
547
+ catch (cause) {
548
+ // The queue is down/unreachable — NOT a step failure. Surface it as a
549
+ // transient dispatch error so the caller leaves the step `pending` and the
550
+ // orchestrator job is retried (replayed from snapshot) rather than the run
551
+ // being marked failed. `add` already throws on failure in every adapter.
552
+ throw new WorkflowDispatchException(runId, stepName, { cause });
553
+ }
523
554
  return true;
524
555
  }
525
556
  /**
@@ -587,7 +618,9 @@ export class PikkuWorkflowService {
587
618
  catch (error) {
588
619
  if (error.name !== 'WorkflowAsyncException' &&
589
620
  error.name !== 'WorkflowCancelledException' &&
590
- error.name !== 'WorkflowSuspendedException') {
621
+ error.name !== 'WorkflowSuspendedException' &&
622
+ // Transient queue failure — leave the run resumable, don't fail it.
623
+ error.name !== 'WorkflowDispatchException') {
591
624
  await this.updateRunStatus(runId, 'failed', undefined, {
592
625
  name: error.name,
593
626
  message: error.message,
@@ -596,6 +629,11 @@ export class PikkuWorkflowService {
596
629
  getSingletonServices().logger.error(`Workflow ${name} (run ${runId}) failed:`, error);
597
630
  throw error;
598
631
  }
632
+ if (error.name === 'WorkflowDispatchException') {
633
+ // Rethrow so the caller (poll loop / starter) sees the transient
634
+ // failure; the run stays running and can be resumed.
635
+ throw error;
636
+ }
599
637
  }
600
638
  finally {
601
639
  this.inlineRuns.delete(runId);
@@ -626,7 +664,48 @@ export class PikkuWorkflowService {
626
664
  await new Promise((resolve) => setTimeout(resolve, pollInterval));
627
665
  }
628
666
  }
667
+ // Per-run, per-replay ordinal counters (runId → stepName → count).
668
+ stepOrdinals = new Map();
669
+ // Previous step key reached in the current DSL walk (runId → stepName), so a
670
+ // new step records where it came from. Rebuilt each replay alongside ordinals.
671
+ stepLineage = new Map();
672
+ resetStepOrdinals(runId) {
673
+ this.stepOrdinals.set(runId, new Map());
674
+ this.stepLineage.delete(runId);
675
+ }
676
+ /** The step the DSL walk last reached (the predecessor for the next step). */
677
+ lastStepName(runId) {
678
+ return this.stepLineage.get(runId);
679
+ }
680
+ /**
681
+ * Physical, replay-stable key for the Nth reach of `logicalStepName` in a run:
682
+ * bare name for the first reach (ordinal 0, unchanged behavior), `name#N` for
683
+ * repeats — so the same literal step name can be invoked multiple times without
684
+ * the rows clobbering. Deterministic given a deterministic DSL body.
685
+ */
686
+ nextStepKey(runId, logicalStepName) {
687
+ let perRun = this.stepOrdinals.get(runId);
688
+ if (!perRun) {
689
+ perRun = new Map();
690
+ this.stepOrdinals.set(runId, perRun);
691
+ }
692
+ const ordinal = perRun.get(logicalStepName) ?? 0;
693
+ perRun.set(logicalStepName, ordinal + 1);
694
+ const stepName = ordinal === 0 ? logicalStepName : `${logicalStepName}#${ordinal}`;
695
+ this.stepLineage.set(runId, stepName);
696
+ return stepName;
697
+ }
629
698
  async runWorkflowJob(runId, rpcService) {
699
+ // Fresh ordinal counters per replay so step keys are deterministic.
700
+ this.resetStepOrdinals(runId);
701
+ try {
702
+ await this.runWorkflowJobInner(runId, rpcService);
703
+ }
704
+ finally {
705
+ this.stepOrdinals.delete(runId);
706
+ }
707
+ }
708
+ async runWorkflowJobInner(runId, rpcService) {
630
709
  const run = await this.getRun(runId);
631
710
  if (!run) {
632
711
  throw new WorkflowRunNotFoundError(runId);
@@ -799,10 +878,22 @@ export class PikkuWorkflowService {
799
878
  const workflowMeta = meta[run.workflow];
800
879
  const isGraphWorkflow = workflowMeta?.source === 'graph' ||
801
880
  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);
881
+ // Map the physical step key back to its logical node: a revisit instance
882
+ // is `node#N` (ordinal), which isn't a literal key in `nodes`.
883
+ let graphNodeId;
884
+ if (isGraphWorkflow && workflowMeta?.nodes) {
885
+ if (stepName in workflowMeta.nodes) {
886
+ graphNodeId = stepName;
887
+ }
888
+ else {
889
+ const hash = stepName.lastIndexOf('#');
890
+ const base = hash > 0 ? stepName.slice(0, hash) : undefined;
891
+ if (base && base in workflowMeta.nodes)
892
+ graphNodeId = base;
893
+ }
894
+ }
895
+ if (graphNodeId) {
896
+ result = await executeGraphStep(this, rpcService, runId, stepState.stepId, graphNodeId, rpcName, data, run.workflow);
806
897
  }
807
898
  else {
808
899
  // Check if rpcName refers to a sub-workflow
@@ -837,7 +928,11 @@ export class PikkuWorkflowService {
837
928
  workflowStep: {
838
929
  runId,
839
930
  stepId: stepState.stepId,
931
+ invocationId: deriveInvocationId(runId, stepName),
840
932
  attemptCount: stepState.attemptCount,
933
+ fromInvocationId: stepState.fromStepName
934
+ ? deriveInvocationId(runId, stepState.fromStepName)
935
+ : undefined,
841
936
  },
842
937
  });
843
938
  }
@@ -862,7 +957,7 @@ export class PikkuWorkflowService {
862
957
  }
863
958
  // Store error and mark failed
864
959
  await this.setStepError(stepState.stepId, error);
865
- const maxAttempts = (stepState.retries ?? 0) + 1;
960
+ const maxAttempts = (stepState.retries ?? DEFAULT_STEP_RETRIES) + 1;
866
961
  const retriesExhausted = stepState.attemptCount >= maxAttempts;
867
962
  if (retriesExhausted) {
868
963
  // No more retries - resume orchestrator to mark workflow as failed
@@ -888,6 +983,13 @@ export class PikkuWorkflowService {
888
983
  error.name === 'WorkflowSuspendedException') {
889
984
  return;
890
985
  }
986
+ if (error.name === 'WorkflowDispatchException') {
987
+ // Transient: the queue was unreachable, not a workflow failure. Leave the
988
+ // run running and rethrow so the orchestrator job is redelivered and the
989
+ // workflow replays from its snapshot. Do NOT mark the run failed.
990
+ getSingletonServices().logger.warn(`Workflow run ${runId} could not dispatch a step (queue unavailable); leaving run for orchestrator retry`, error);
991
+ throw error;
992
+ }
891
993
  await this.updateRunStatus(runId, 'failed', undefined, {
892
994
  message: error.message,
893
995
  stack: error.stack,
@@ -902,7 +1004,18 @@ export class PikkuWorkflowService {
902
1004
  }
903
1005
  return getSingletonServices().queueService;
904
1006
  }
905
- async rpcStep(runId, stepName, rpcName, data, rpcService, stepOptions) {
1007
+ async rpcStep(runId, logicalStepName, rpcName, data, rpcService, stepOptions) {
1008
+ // Capture the predecessor before nextStepKey advances the lineage to us.
1009
+ const fromStepName = this.lastStepName(runId);
1010
+ const stepName = this.nextStepKey(runId, logicalStepName);
1011
+ // Resolve the retry policy ONCE here so the value persisted on the step
1012
+ // (which drives `retriesExhausted`) is the same one the queue dispatch turns
1013
+ // into `attempts`. Without this the queue could retry N times while the
1014
+ // engine thinks retries are already exhausted (or vice-versa).
1015
+ const resolvedStepOptions = {
1016
+ retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
1017
+ retryDelay: stepOptions?.retryDelay,
1018
+ };
906
1019
  // Check if step already exists
907
1020
  let stepState;
908
1021
  try {
@@ -910,7 +1023,7 @@ export class PikkuWorkflowService {
910
1023
  }
911
1024
  catch {
912
1025
  // Step doesn't exist - create it
913
- stepState = await this.insertStepState(runId, stepName, rpcName, data, stepOptions);
1026
+ stepState = await this.insertStepState(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
914
1027
  }
915
1028
  if (stepState.status === 'succeeded') {
916
1029
  // Return cached result
@@ -930,18 +1043,22 @@ export class PikkuWorkflowService {
930
1043
  // Step is already scheduled, pause workflow
931
1044
  throw new WorkflowAsyncException(runId, stepName);
932
1045
  }
933
- // Step is pending - schedule it
934
- await this.setStepScheduled(stepState.stepId);
935
1046
  // Hand off to subclass-overridable transport. Default behavior enqueues
936
1047
  // via the queue service; DO-style subclasses RPC to a step worker.
937
- const dispatched = await this.dispatchStep(runId, stepName, rpcName, data, stepOptions);
1048
+ // Dispatch BEFORE marking the step `scheduled`: if the queue is down,
1049
+ // dispatchStep throws WorkflowDispatchException and the step stays `pending`,
1050
+ // so the orchestrator's next replay re-dispatches it. Marking `scheduled`
1051
+ // first would strand the step (replay sees `scheduled`, pauses, never
1052
+ // re-enqueues the job that was never created).
1053
+ const dispatched = await this.dispatchStep(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
938
1054
  if (dispatched) {
1055
+ await this.setStepScheduled(stepState.stepId);
939
1056
  throw new WorkflowAsyncException(runId, stepName);
940
1057
  }
941
1058
  {
942
1059
  // Inline (no transport available) - execute locally with retry loop
943
- const retries = stepOptions?.retries ?? 0;
944
- const retryDelay = stepOptions?.retryDelay;
1060
+ const retries = resolvedStepOptions.retries ?? this.getConfig().retries;
1061
+ const retryDelay = resolvedStepOptions.retryDelay;
945
1062
  let currentStepState = stepState;
946
1063
  while (true) {
947
1064
  try {
@@ -982,7 +1099,11 @@ export class PikkuWorkflowService {
982
1099
  workflowStep: {
983
1100
  runId,
984
1101
  stepId: currentStepState.stepId,
1102
+ invocationId: deriveInvocationId(runId, stepName),
985
1103
  attemptCount: currentStepState.attemptCount,
1104
+ fromInvocationId: currentStepState.fromStepName
1105
+ ? deriveInvocationId(runId, currentStepState.fromStepName)
1106
+ : undefined,
986
1107
  },
987
1108
  });
988
1109
  }
@@ -1017,7 +1138,9 @@ export class PikkuWorkflowService {
1017
1138
  }
1018
1139
  }
1019
1140
  }
1020
- async inlineStep(runId, stepName, fn, stepOptions) {
1141
+ async inlineStep(runId, logicalStepName, fn, stepOptions) {
1142
+ const fromStepName = this.lastStepName(runId);
1143
+ const stepName = this.nextStepKey(runId, logicalStepName);
1021
1144
  // Check if step already exists
1022
1145
  let stepState;
1023
1146
  try {
@@ -1025,7 +1148,7 @@ export class PikkuWorkflowService {
1025
1148
  }
1026
1149
  catch {
1027
1150
  // Step doesn't exist - create it (inline, no RPC)
1028
- stepState = await this.insertStepState(runId, stepName, null, null, stepOptions);
1151
+ stepState = await this.insertStepState(runId, stepName, null, null, stepOptions, fromStepName);
1029
1152
  }
1030
1153
  if (stepState.status === 'succeeded') {
1031
1154
  // Return cached result
@@ -1090,7 +1213,9 @@ export class PikkuWorkflowService {
1090
1213
  }
1091
1214
  }
1092
1215
  }
1093
- async sleepStep(runId, stepName, duration) {
1216
+ async sleepStep(runId, logicalStepName, duration) {
1217
+ const fromStepName = this.lastStepName(runId);
1218
+ const stepName = this.nextStepKey(runId, logicalStepName);
1094
1219
  // Check if step already exists
1095
1220
  let stepState;
1096
1221
  try {
@@ -1098,9 +1223,7 @@ export class PikkuWorkflowService {
1098
1223
  }
1099
1224
  catch {
1100
1225
  // Step doesn't exist - create it (sleep step, no RPC)
1101
- stepState = await this.insertStepState(runId, stepName, null, {
1102
- duration,
1103
- });
1226
+ stepState = await this.insertStepState(runId, stepName, null, { duration }, undefined, fromStepName);
1104
1227
  }
1105
1228
  if (stepState.status === 'succeeded') {
1106
1229
  // Sleep already completed, return immediately
@@ -1110,13 +1233,20 @@ export class PikkuWorkflowService {
1110
1233
  // Sleep is already scheduled, pause workflow
1111
1234
  throw new WorkflowAsyncException(runId, stepName);
1112
1235
  }
1113
- // Step is pending - schedule it
1114
- await this.setStepScheduled(stepState.stepId);
1115
1236
  // Hand off to subclass-overridable transport. Default behavior schedules
1116
1237
  // 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);
1238
+ // override to use native timer primitives (e.g. setAlarm). Schedule BEFORE
1239
+ // marking `scheduled` so a scheduler outage leaves the step `pending` for
1240
+ // re-scheduling on replay instead of stranding it (see rpcStep).
1241
+ let scheduled;
1242
+ try {
1243
+ scheduled = await this.scheduleSleep(runId, stepState.stepId, duration);
1244
+ }
1245
+ catch (cause) {
1246
+ throw new WorkflowDispatchException(runId, stepName, { cause });
1247
+ }
1119
1248
  if (scheduled) {
1249
+ await this.setStepScheduled(stepState.stepId);
1120
1250
  throw new WorkflowAsyncException(runId, stepName);
1121
1251
  }
1122
1252
  // Inline mode - use setTimeout with actual duration
@@ -1138,7 +1268,8 @@ export class PikkuWorkflowService {
1138
1268
  return `__workflow_suspend:${reason}`;
1139
1269
  }
1140
1270
  async suspendStep(runId, reason) {
1141
- const suspendStepName = this.getSuspendStepName(reason);
1271
+ const fromStepName = this.lastStepName(runId);
1272
+ const suspendStepName = this.nextStepKey(runId, this.getSuspendStepName(reason));
1142
1273
  await this.withStepLock(runId, suspendStepName, async () => {
1143
1274
  let stepState;
1144
1275
  try {
@@ -1147,12 +1278,12 @@ export class PikkuWorkflowService {
1147
1278
  catch {
1148
1279
  stepState = await this.insertStepState(runId, suspendStepName, 'pikkuWorkflowSuspend', {
1149
1280
  reason,
1150
- });
1281
+ }, undefined, fromStepName);
1151
1282
  }
1152
1283
  if (!stepState.stepId) {
1153
1284
  stepState = await this.insertStepState(runId, suspendStepName, 'pikkuWorkflowSuspend', {
1154
1285
  reason,
1155
- });
1286
+ }, undefined, fromStepName);
1156
1287
  }
1157
1288
  if (stepState.status === 'succeeded') {
1158
1289
  return;
@@ -1206,7 +1337,7 @@ export class PikkuWorkflowService {
1206
1337
  const singletonServices = getSingletonServices();
1207
1338
  const workflow = singletonServices.config?.workflow;
1208
1339
  return {
1209
- retries: workflow?.retries ?? 0,
1340
+ retries: workflow?.retries ?? DEFAULT_STEP_RETRIES,
1210
1341
  retryDelay: workflow?.retryDelay ?? 0,
1211
1342
  orchestratorQueueName: workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',
1212
1343
  stepWorkerQueueName: workflow?.stepWorkerQueueName ?? 'pikku-workflow-step-worker',
@@ -0,0 +1,20 @@
1
+ /**
2
+ * RFC 4122 v5 (SHA-1, name-based) UUID. Deterministic: the same name +
3
+ * namespace always yields the same UUID — no `uuid` dependency needed.
4
+ */
5
+ export declare const uuidv5: (name: string, namespace?: string) => string;
6
+ /**
7
+ * The stable identity of one step *invocation* within a run — the idempotency /
8
+ * dedupe key handed to a step. Unlike `stepId` (minted fresh per attempt), this
9
+ * stays identical across every retry of the same call, because it is derived
10
+ * purely from `runId` + `stepName`, both of which are stable across replays.
11
+ *
12
+ * Same inputs → same UUID, so a step can safely
13
+ * `INSERT … ON CONFLICT (invocation_id)` / pass it as a Stripe idempotency key,
14
+ * and a retry of a half-applied side effect is collapsed onto the first attempt.
15
+ *
16
+ * NOTE: calling the *same* `stepName` more than once in a run is not yet
17
+ * disambiguated here (the store still keys steps by `runId:stepName`); when
18
+ * per-invocation ordinals land, the ordinal becomes the third hash component.
19
+ */
20
+ export declare const deriveInvocationId: (runId: string, stepName: string) => string;
@@ -0,0 +1,38 @@
1
+ import { createHash } from 'crypto';
2
+ // Fixed namespace for pikku workflow step invocation IDs. Frozen — changing it
3
+ // would alter every derived invocationId and break dedupe across a deploy.
4
+ const PIKKU_WORKFLOW_NAMESPACE = '70696b6b-7500-5770-9f6c-6f77000a0001';
5
+ const parseUuid = (uuid) => Buffer.from(uuid.replace(/-/g, ''), 'hex');
6
+ const formatUuid = (bytes) => {
7
+ const hex = bytes.toString('hex');
8
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
9
+ };
10
+ /**
11
+ * RFC 4122 v5 (SHA-1, name-based) UUID. Deterministic: the same name +
12
+ * namespace always yields the same UUID — no `uuid` dependency needed.
13
+ */
14
+ export const uuidv5 = (name, namespace = PIKKU_WORKFLOW_NAMESPACE) => {
15
+ const hash = createHash('sha1')
16
+ .update(parseUuid(namespace))
17
+ .update(name, 'utf8')
18
+ .digest();
19
+ const bytes = hash.subarray(0, 16);
20
+ bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5
21
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
22
+ return formatUuid(bytes);
23
+ };
24
+ /**
25
+ * The stable identity of one step *invocation* within a run — the idempotency /
26
+ * dedupe key handed to a step. Unlike `stepId` (minted fresh per attempt), this
27
+ * stays identical across every retry of the same call, because it is derived
28
+ * purely from `runId` + `stepName`, both of which are stable across replays.
29
+ *
30
+ * Same inputs → same UUID, so a step can safely
31
+ * `INSERT … ON CONFLICT (invocation_id)` / pass it as a Stripe idempotency key,
32
+ * and a retry of a half-applied side effect is collapsed onto the first attempt.
33
+ *
34
+ * NOTE: calling the *same* `stepName` more than once in a run is not yet
35
+ * disambiguated here (the store still keys steps by `runId:stepName`); when
36
+ * per-invocation ordinals land, the ordinal becomes the third hash component.
37
+ */
38
+ export const deriveInvocationId = (runId, stepName) => uuidv5(`${runId}:${stepName}`);
@@ -11,6 +11,8 @@ export interface WorkflowStepInput {
11
11
  stepName: string;
12
12
  rpcName: string;
13
13
  data: unknown;
14
+ /** Predecessor step name (the walked transition); undefined for entry steps. */
15
+ fromStepName?: string;
14
16
  }
15
17
  export interface PikkuWorkflowOrchestratorInput {
16
18
  runId: string;
@@ -83,6 +83,9 @@ export interface StepState {
83
83
  retries?: number;
84
84
  /** Delay between retries */
85
85
  retryDelay?: string | number;
86
+ /** Step name of the predecessor that scheduled this step (the transition/edge
87
+ * walked to reach it); undefined for entry steps. Reconstructs the path. */
88
+ fromStepName?: string;
86
89
  /** Creation timestamp */
87
90
  createdAt: Date;
88
91
  /** Last update timestamp */
@@ -269,6 +272,8 @@ export type WorkflowStepInput = {
269
272
  stepName: string;
270
273
  rpcName: string;
271
274
  data: any;
275
+ /** Predecessor step name (the walked transition); undefined for entry steps. */
276
+ fromStepName?: string;
272
277
  };
273
278
  export type WorkflowOrchestratorInput = {
274
279
  runId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.37",
3
+ "version": "0.12.38",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -54,6 +54,7 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@standard-schema/spec": "^1.1.0",
57
+ "@types/json-schema": "^7.0.15",
57
58
  "cookie": "^1.1.1",
58
59
  "json-schema": "^0.4.0",
59
60
  "path-to-regexp": "^8.3.0",
@@ -32,6 +32,11 @@ const ensureRecursiveWatchAvailable = async (
32
32
  t: TestContext,
33
33
  dir: string
34
34
  ): Promise<boolean> => {
35
+ if ('bun' in process.versions) {
36
+ t.skip('tsx esm dynamic reload not supported under bun')
37
+ return false
38
+ }
39
+
35
40
  if (process.platform === 'darwin') {
36
41
  t.skip('recursive fs.watch is unreliable on darwin')
37
42
  return false
@@ -7,6 +7,7 @@ import type {
7
7
  WorkflowRunService,
8
8
  WorkflowRunWire,
9
9
  StepState,
10
+ StepStatus,
10
11
  WorkflowStatus,
11
12
  WorkflowVersionStatus,
12
13
  WorkflowStepOptions,
@@ -140,7 +141,8 @@ export class InMemoryWorkflowService
140
141
  stepName: string,
141
142
  rpcName: string | null,
142
143
  data: any,
143
- stepOptions?: WorkflowStepOptions
144
+ stepOptions?: WorkflowStepOptions,
145
+ fromStepName?: string
144
146
  ): Promise<StepState> {
145
147
  const stepId = randomUUID()
146
148
  const now = new Date()
@@ -151,6 +153,7 @@ export class InMemoryWorkflowService
151
153
  attemptCount: 1,
152
154
  retries: stepOptions?.retries,
153
155
  retryDelay: stepOptions?.retryDelay,
156
+ fromStepName,
154
157
  createdAt: now,
155
158
  updatedAt: now,
156
159
  stepName,
@@ -281,6 +284,7 @@ export class InMemoryWorkflowService
281
284
  attemptCount: failedStep.attemptCount + 1,
282
285
  retries: failedStep.retries,
283
286
  retryDelay: failedStep.retryDelay,
287
+ fromStepName: failedStep.fromStepName,
284
288
  createdAt: now,
285
289
  updatedAt: now,
286
290
  stepName: stepName,
@@ -445,6 +449,26 @@ export class InMemoryWorkflowService
445
449
  return nodeIds.filter((id) => !existingSteps.has(id))
446
450
  }
447
451
 
452
+ async getStepInstances(runId: string): Promise<
453
+ Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
454
+ > {
455
+ const prefix = `${runId}:`
456
+ const instances: Array<{
457
+ stepName: string
458
+ status: StepStatus
459
+ fromStepName?: string
460
+ }> = []
461
+ for (const [key, step] of this.steps.entries()) {
462
+ if (!key.startsWith(prefix)) continue
463
+ instances.push({
464
+ stepName: key.substring(prefix.length),
465
+ status: step.status,
466
+ fromStepName: step.fromStepName,
467
+ })
468
+ }
469
+ return instances
470
+ }
471
+
448
472
  async getNodeResults(
449
473
  runId: string,
450
474
  nodeIds: string[]