@pikku/core 0.12.134 → 0.12.136

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 (72) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/analytics/analytics.types.d.ts +20 -0
  3. package/dist/analytics/anonymous-analytics-identity.d.ts +2 -0
  4. package/dist/analytics/anonymous-analytics-identity.js +2 -0
  5. package/dist/analytics/compose-analytics-identity.d.ts +2 -0
  6. package/dist/analytics/compose-analytics-identity.js +2 -0
  7. package/dist/analytics/cookie-analytics-identity.d.ts +2 -0
  8. package/dist/analytics/cookie-analytics-identity.js +2 -0
  9. package/dist/analytics/define-analytics-events.d.ts +3 -0
  10. package/dist/analytics/define-analytics-events.js +2 -0
  11. package/dist/analytics/fan-out-analytics.d.ts +2 -0
  12. package/dist/analytics/fan-out-analytics.js +2 -0
  13. package/dist/analytics/logger-analytics-service.d.ts +2 -0
  14. package/dist/analytics/logger-analytics-service.js +2 -0
  15. package/dist/analytics/mint-cookie.d.ts +8 -1
  16. package/dist/analytics/mint-cookie.js +8 -4
  17. package/dist/function/compensation-name.d.ts +4 -0
  18. package/dist/function/compensation-name.js +6 -0
  19. package/dist/function/function-meta.types.d.ts +7 -0
  20. package/dist/function/function-runner.js +17 -0
  21. package/dist/function/functions.types.d.ts +18 -0
  22. package/dist/function/functions.types.js +11 -0
  23. package/dist/middleware/require-origin.d.ts +2 -0
  24. package/dist/middleware/require-origin.js +2 -0
  25. package/dist/permissions.d.ts +5 -0
  26. package/dist/permissions.js +5 -0
  27. package/dist/testing/service-tests/queued-workflow-harness.d.ts +61 -0
  28. package/dist/testing/service-tests/queued-workflow-harness.js +151 -0
  29. package/dist/testing/service-tests/workflow-compensation-queued-tests.d.ts +11 -0
  30. package/dist/testing/service-tests/workflow-compensation-queued-tests.js +683 -0
  31. package/dist/testing/service-tests.d.ts +4 -0
  32. package/dist/testing/service-tests.js +4 -0
  33. package/dist/utils/hmac.d.ts +0 -33
  34. package/dist/utils/hmac.js +0 -61
  35. package/dist/utils.d.ts +7 -0
  36. package/dist/utils.js +16 -0
  37. package/dist/wirings/flag/define-feature-flags.d.ts +1 -13
  38. package/dist/wirings/flag/define-feature-flags.js +1 -13
  39. package/dist/wirings/http/http-runner.js +1 -3
  40. package/dist/wirings/rpc/rpc-runner.js +21 -1
  41. package/dist/wirings/trigger/webhook-source-runner.d.ts +2 -2
  42. package/dist/wirings/trigger/webhook-source-runner.js +15 -15
  43. package/dist/wirings/trigger/webhook-source.types.d.ts +9 -12
  44. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +38 -6
  45. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -1
  46. package/dist/wirings/workflow/graph/graph-node.js +2 -1
  47. package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -1
  48. package/dist/wirings/workflow/graph/graph-runner.js +104 -67
  49. package/dist/wirings/workflow/graph/graph-validation.js +2 -2
  50. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +20 -1
  51. package/dist/wirings/workflow/index.d.ts +1 -1
  52. package/dist/wirings/workflow/pikku-workflow-service.d.ts +13 -0
  53. package/dist/wirings/workflow/pikku-workflow-service.js +113 -143
  54. package/dist/wirings/workflow/run-timeline.d.ts +2 -0
  55. package/dist/wirings/workflow/run-timeline.js +3 -0
  56. package/dist/wirings/workflow/workflow-child-step.d.ts +17 -0
  57. package/dist/wirings/workflow/workflow-child-step.js +31 -0
  58. package/dist/wirings/workflow/workflow-compensation.d.ts +68 -0
  59. package/dist/wirings/workflow/workflow-compensation.js +282 -0
  60. package/dist/wirings/workflow/workflow-constants.d.ts +5 -0
  61. package/dist/wirings/workflow/workflow-constants.js +9 -0
  62. package/dist/wirings/workflow/workflow-dsl-pass.d.ts +12 -0
  63. package/dist/wirings/workflow/workflow-dsl-pass.js +75 -0
  64. package/dist/wirings/workflow/workflow-queue-routing.js +3 -3
  65. package/dist/wirings/workflow/workflow-run-status.js +13 -1
  66. package/dist/wirings/workflow/workflow-status-stream.js +2 -0
  67. package/dist/wirings/workflow/workflow-unwind-plan.d.ts +38 -0
  68. package/dist/wirings/workflow/workflow-unwind-plan.js +120 -0
  69. package/dist/wirings/workflow/workflow.types.d.ts +9 -2
  70. package/knowledge/decisions/internals/workflow-step-compensation-runs-as-its-own-durable-step.md +21 -18
  71. package/package.json +1 -1
  72. package/src/public-surface.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { WorkflowAsyncException, WorkflowSuspendedException, } from '../workflow-errors.js';
2
- import { DEFAULT_STEP_RETRIES } from '../workflow-constants.js';
2
+ import { DEFAULT_STEP_RETRIES, NO_COMPENSATE_STATE_PREFIX, } from '../workflow-constants.js';
3
+ import { childRunFailure } from '../workflow-compensation.js';
3
4
  import { runningStepLease } from '../workflow-step-lease.js';
4
5
  import { pikkuState, getSingletonServices } from '../../../pikku-state.js';
5
6
  import { RPCNotFoundError } from '../../rpc/rpc-runner.js';
@@ -87,7 +88,7 @@ function closesCycle(source, target, nodes) {
87
88
  }
88
89
  return false;
89
90
  }
90
- function planGraphTransitions(nodes, instances, branchByStep, entryNodeIds, graphName, fanoutWidths = {}) {
91
+ function planGraphTransitions(nodes, instances, branchByStep, entryNodeIds, graphName, fanoutWidths = {}, recovered = new Set()) {
91
92
  const toLogical = (name) => remapStepNamesToNodeIds([name], nodes, graphName)[0];
92
93
  const countByLogical = {};
93
94
  const instancesByLogical = {};
@@ -103,10 +104,12 @@ function planGraphTransitions(nodes, instances, branchByStep, entryNodeIds, grap
103
104
  const width = fanoutWidths[nodeId];
104
105
  if (width === undefined)
105
106
  return false;
106
- const succeeded = (instancesByLogical[nodeId] ?? []).filter((i) => i.status === 'succeeded').length;
107
+ const succeeded = (instancesByLogical[nodeId] ?? []).filter(isDone).length;
107
108
  return succeeded >= width;
108
109
  };
109
- const completed = instances.filter((i) => i.status === 'succeeded');
110
+ const ignored = (name) => recovered.has(name) && nodes[toLogical(name)]?.recover === 'ignore';
111
+ const isDone = (i) => i.status === 'succeeded' || ignored(i.stepName);
112
+ const completed = instances.filter(isDone);
110
113
  const completedLogical = new Set();
111
114
  for (const inst of completed) {
112
115
  const logical = toLogical(inst.stepName);
@@ -121,6 +124,20 @@ function planGraphTransitions(nodes, instances, branchByStep, entryNodeIds, grap
121
124
  for (const entryId of entryNodeIds) {
122
125
  edges.push({ fromKey: ENTRY_FROM, target: entryId });
123
126
  }
127
+ for (const inst of instances) {
128
+ if (!recovered.has(inst.stepName))
129
+ continue;
130
+ const fromLogical = toLogical(inst.stepName);
131
+ for (const target of recoverTargets(nodes[fromLogical])) {
132
+ edges.push({
133
+ from: inst.stepName,
134
+ fromKey: inst.stepName,
135
+ fromLogical,
136
+ target,
137
+ recoveringFrom: { nodeId: fromLogical, stepName: inst.stepName },
138
+ });
139
+ }
140
+ }
124
141
  const fannedEdgesEmitted = new Set();
125
142
  for (const inst of completed) {
126
143
  const fromLogical = toLogical(inst.stepName);
@@ -155,12 +172,23 @@ function planGraphTransitions(nodes, instances, branchByStep, entryNodeIds, grap
155
172
  edges.push({ from: nodeId, fromKey: nodeId, fromLogical: nodeId, target });
156
173
  }
157
174
  }
175
+ const deadLogical = new Set(instances
176
+ .filter((i) => recovered.has(i.stepName) && !isDone(i))
177
+ .map((i) => toLogical(i.stepName)));
178
+ const readsDeadNode = (nodeId) => nodeDependencies(nodes[nodeId] ?? {}).some((dep) => deadLogical.has(dep));
158
179
  const toFire = [];
159
180
  const plannedFanout = new Set();
160
181
  let blockedWaiting = false;
161
182
  for (const edge of edges) {
162
183
  const target = edge.target;
163
184
  const edgeKey = `${edge.fromKey}->${target}`;
185
+ // A node reading the output of a failure that was recovered without
186
+ // `'ignore'` has no output to read and never will; waiting on it would
187
+ // leave the run unable to finish.
188
+ if (readsDeadNode(target)) {
189
+ consumed.add(edgeKey);
190
+ continue;
191
+ }
164
192
  if (isFanned(target)) {
165
193
  if (plannedFanout.has(target))
166
194
  continue;
@@ -221,13 +249,14 @@ function planGraphTransitions(nodes, instances, branchByStep, entryNodeIds, grap
221
249
  logical: target,
222
250
  instanceKey: visits === 0 ? target : `${target}#${visits}`,
223
251
  fromStepName: edge.from,
252
+ recoveringFrom: edge.recoveringFrom,
224
253
  });
225
254
  countByLogical[target] = visits + 1;
226
255
  consumed.add(edgeKey);
227
256
  }
228
257
  return {
229
258
  toFire,
230
- hasInFlight: instances.some((i) => i.status !== 'succeeded'),
259
+ hasInFlight: instances.some((i) => !isDone(i) && !recovered.has(i.stepName)),
231
260
  blockedWaiting,
232
261
  };
233
262
  }
@@ -396,6 +425,8 @@ function extractReferencedNodeIds(input) {
396
425
  }
397
426
  const IGNORED_REFS = new Set(['trigger', '$item', 'unknown']);
398
427
  const FOREACH_MODES = new Set(['parallel', 'sequential']);
428
+ const recoverTargets = (node) => node.recover === 'ignore' ? [] : normalizeNodeTargets(node.recover);
429
+ const recoversFailure = (node) => !!node?.recover;
399
430
  function normalizeNodeTargets(value) {
400
431
  if (!value)
401
432
  return [];
@@ -447,10 +478,9 @@ function validateGraphReferences(graphName, nodes, entryNodes) {
447
478
  throw new Error(`Workflow graph '${graphName}': node '${nodeId}' routes to unknown node '${nextId}'`);
448
479
  }
449
480
  }
450
- const errorTargets = normalizeNodeTargets(node.onError);
451
- for (const errorId of errorTargets) {
481
+ for (const errorId of recoverTargets(node)) {
452
482
  if (!nodeIds.has(errorId)) {
453
- throw new Error(`Workflow graph '${graphName}': node '${nodeId}' onError targets unknown node '${errorId}'`);
483
+ throw new Error(`Workflow graph '${graphName}': node '${nodeId}' recover targets unknown node '${errorId}'`);
454
484
  }
455
485
  }
456
486
  }
@@ -564,6 +594,51 @@ async function createGraphResultReader(workflowService, runId, graphName, nodes,
564
594
  },
565
595
  };
566
596
  }
597
+ const splitFailures = (rawFailed, nodes, graphName) => {
598
+ const logical = remapStepNamesToNodeIds(rawFailed, nodes, graphName);
599
+ const recovered = new Set();
600
+ const unrecovered = [];
601
+ rawFailed.forEach((stepName, i) => {
602
+ if (recoversFailure(nodes[logical[i]]))
603
+ recovered.add(stepName);
604
+ else
605
+ unrecovered.push(logical[i]);
606
+ });
607
+ return { recovered, unrecovered };
608
+ };
609
+ async function failGraphRun(workflowService, runId, failedNode, rpcService) {
610
+ await failGraphRunWith(workflowService, runId, new Error(`Graph node '${failedNode}' failed after exhausting retries`), rpcService);
611
+ }
612
+ async function failGraphRunWith(workflowService, runId, cause, rpcService) {
613
+ const error = Object.assign(cause, { code: 'GRAPH_NODE_FAILED' });
614
+ const run = await workflowService.getRun(runId);
615
+ if (run && rpcService) {
616
+ await workflowService.failOrUnwind(run, error, rpcService);
617
+ return;
618
+ }
619
+ await workflowService.updateRunStatus(runId, 'failed', undefined, {
620
+ message: error.message,
621
+ stack: error.stack ?? '',
622
+ code: 'GRAPH_NODE_FAILED',
623
+ });
624
+ if (run)
625
+ await workflowService.notifyChildWorkflowFailed(run, error);
626
+ }
627
+ const RECOVERING_FROM_STATE_PREFIX = 'recoveringFrom:';
628
+ async function prepareFire(workflowService, runId, fire, node) {
629
+ if (node.compensate === false) {
630
+ await workflowService.updateRunState(runId, `${NO_COMPENSATE_STATE_PREFIX}${fire.instanceKey}`, true);
631
+ }
632
+ const from = fire.recoveringFrom;
633
+ if (!from) {
634
+ await workflowService.updateRunState(runId, `${RECOVERING_FROM_STATE_PREFIX}${fire.instanceKey}`, null);
635
+ return;
636
+ }
637
+ const steps = await workflowService.getRunSteps(runId);
638
+ const failed = steps.find((step) => step.stepName === from.stepName);
639
+ await workflowService.updateRunState(runId, `${RECOVERING_FROM_STATE_PREFIX}${fire.instanceKey}`, { ...from, error: failed?.error ?? { message: 'step failed' } });
640
+ await workflowService.updateRunState(runId, `${NO_COMPENSATE_STATE_PREFIX}${from.stepName}`, true);
641
+ }
567
642
  const graphStepOptions = (nodeConfig) => ({
568
643
  retries: nodeConfig?.retries ?? DEFAULT_STEP_RETRIES,
569
644
  retryDelay: nodeConfig?.retryDelay,
@@ -573,7 +648,7 @@ async function queueGraphNode(workflowService, runId, _graphName, nodeId, rpcNam
573
648
  await workflowService.insertStepState(runId, nodeId, rpcName, input, stepOptions, fromStepName);
574
649
  await workflowService.queueStepWorker(runId, nodeId, rpcName, input, stepOptions, fromStepName);
575
650
  }
576
- export async function continueGraph(workflowService, runId, graphName, overrideMeta) {
651
+ export async function continueGraph(workflowService, runId, graphName, overrideMeta, rpcService) {
577
652
  const meta = overrideMeta ?? getWorkflowMeta(graphName);
578
653
  if (!meta?.nodes) {
579
654
  throw new Error(`Workflow graph meta '${graphName}' not found`);
@@ -583,14 +658,9 @@ export async function continueGraph(workflowService, runId, graphName, overrideM
583
658
  const { completedNodeIds: rawCompleted, failedNodeIds: rawFailed, branchKeys: branchByStep, } = await workflowService.getCompletedGraphState(runId);
584
659
  remapStepNamesToNodeIds(rawCompleted, nodes, graphName);
585
660
  remapBranchKeys(branchByStep, nodes, graphName);
586
- const failedNodeIds = remapStepNamesToNodeIds(rawFailed, nodes, graphName);
587
- if (failedNodeIds.length > 0) {
588
- const failedNode = failedNodeIds[0];
589
- await workflowService.updateRunStatus(runId, 'failed', undefined, {
590
- message: `Graph node '${failedNode}' failed after exhausting retries`,
591
- stack: '',
592
- code: 'GRAPH_NODE_FAILED',
593
- });
661
+ const { recovered, unrecovered } = splitFailures(rawFailed, nodes, graphName);
662
+ if (unrecovered.length > 0) {
663
+ await failGraphRun(workflowService, runId, unrecovered[0], rpcService);
594
664
  return;
595
665
  }
596
666
  const currentRun = await workflowService.getRun(runId);
@@ -600,7 +670,7 @@ export async function continueGraph(workflowService, runId, graphName, overrideM
600
670
  const instances = await workflowService.getStepInstances(runId);
601
671
  const triggerInput = currentRun?.input;
602
672
  const reader = await createGraphResultReader(workflowService, runId, graphName, nodes, triggerInput, instances);
603
- const plan = planGraphTransitions(nodes, instances, branchByStep, meta.entryNodeIds ?? [], graphName, reader.fanoutWidths);
673
+ const plan = planGraphTransitions(nodes, instances, branchByStep, meta.entryNodeIds ?? [], graphName, reader.fanoutWidths, recovered);
604
674
  const resolveNodeInput = async (logical, itemIndex) => {
605
675
  const node = nodes[logical];
606
676
  const referencedNodeIds = extractReferencedNodeIds(node.input).filter((id) => !IGNORED_REFS.has(id));
@@ -638,6 +708,7 @@ export async function continueGraph(workflowService, runId, graphName, overrideM
638
708
  if (!node?.rpcName)
639
709
  continue;
640
710
  const resolvedInput = await resolveNodeInput(fire.logical, fire.itemIndex);
711
+ await prepareFire(workflowService, runId, fire, node);
641
712
  await queueGraphNode(workflowService, runId, graphName, fire.instanceKey, node.rpcName, resolvedInput, node, fire.fromStepName);
642
713
  }
643
714
  }
@@ -652,6 +723,7 @@ async function invokeGraphNodeRpc(workflowService, rpcService, runId, stepId, no
652
723
  },
653
724
  setState: (name, value) => workflowService.updateRunState(runId, name, value),
654
725
  getState: () => workflowService.getRunState(runId),
726
+ recoveringFrom: (await workflowService.getRunState(runId))[`${RECOVERING_FROM_STATE_PREFIX}${nodeId}`],
655
727
  };
656
728
  const result = await rpcService.rpcWithWire(rpcName, input, {
657
729
  graph: graphWire,
@@ -681,12 +753,9 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
681
753
  await workflowService.setStepChildRunId(stepId, childRunId);
682
754
  if (shouldInline) {
683
755
  const childRun = await workflowService.getRun(childRunId);
684
- if (childRun?.status === 'failed') {
685
- throw new Error(childRun.error?.message || 'Sub-workflow failed');
686
- }
687
- if (childRun?.status === 'cancelled') {
688
- throw new Error('Sub-workflow was cancelled');
689
- }
756
+ const failure = childRun && childRunFailure(childRun);
757
+ if (failure)
758
+ throw failure;
690
759
  result = childRun?.output;
691
760
  }
692
761
  else {
@@ -717,27 +786,11 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
717
786
  });
718
787
  throw error;
719
788
  }
720
- const meta = getWorkflowMeta(graphName);
721
- if (meta?.nodes) {
722
- const node = meta.nodes[nodeId];
723
- if (node?.onError) {
724
- const errorNodes = Array.isArray(node.onError)
725
- ? node.onError
726
- : [node.onError];
727
- for (const errorNodeId of errorNodes) {
728
- const errorNode = meta.nodes[errorNodeId];
729
- if (errorNode) {
730
- await queueGraphNode(workflowService, runId, graphName, errorNodeId, errorNode.rpcName, { error: { message: error.message } }, errorNode);
731
- }
732
- }
733
- throw error;
734
- }
735
- }
736
789
  throw error;
737
790
  }
738
791
  }
739
792
  export async function runFromMeta(workflowService, runId, meta, _rpcService) {
740
- await continueGraph(workflowService, runId, meta.name, meta);
793
+ await continueGraph(workflowService, runId, meta.name, meta, _rpcService);
741
794
  }
742
795
  async function executeGraphNodeInline(workflowService, rpcService, runId, graphName, nodeId, instanceKey, input, nodes, fromStepName) {
743
796
  const node = nodes[nodeId];
@@ -762,12 +815,9 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
762
815
  const { runId: childRunId } = await workflowService.startWorkflow(rpcName, input, childWire, rpcService, { inline: true });
763
816
  await workflowService.setStepChildRunId(stepState.stepId, childRunId);
764
817
  const childRun = await workflowService.getRun(childRunId);
765
- if (childRun?.status === 'failed') {
766
- throw new Error(childRun.error?.message || 'Sub-workflow failed');
767
- }
768
- if (childRun?.status === 'cancelled') {
769
- throw new Error('Sub-workflow was cancelled');
770
- }
818
+ const failure = childRun && childRunFailure(childRun);
819
+ if (failure)
820
+ throw failure;
771
821
  result = childRun?.output;
772
822
  }
773
823
  else if (agentMeta) {
@@ -793,13 +843,8 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
793
843
  throw new WorkflowSuspendedException(runId, 'RPC_NOT_FOUND');
794
844
  }
795
845
  await workflowService.setStepError(stepState.stepId, error);
796
- if (node?.onError) {
797
- const errorNodes = Array.isArray(node.onError)
798
- ? node.onError
799
- : [node.onError];
800
- await Promise.all(errorNodes.map((errorNodeId) => executeGraphNodeInline(workflowService, rpcService, runId, graphName, errorNodeId, errorNodeId, { error: { message: error.message } }, nodes, nodeId)));
846
+ if (recoversFailure(node))
801
847
  return;
802
- }
803
848
  throw error;
804
849
  }
805
850
  }
@@ -808,14 +853,9 @@ async function continueGraphInline(workflowService, rpcService, runId, graphName
808
853
  const { failedNodeIds: rawFailed, branchKeys: branchByStep, completedNodeIds: rawCompleted, } = await workflowService.getCompletedGraphState(runId);
809
854
  remapStepNamesToNodeIds(rawCompleted, nodes, graphName);
810
855
  remapBranchKeys(branchByStep, nodes, graphName);
811
- const failedNodeIds = remapStepNamesToNodeIds(rawFailed, nodes, graphName);
812
- if (failedNodeIds.length > 0) {
813
- const failedNode = failedNodeIds[0];
814
- await workflowService.updateRunStatus(runId, 'failed', undefined, {
815
- message: `Graph node '${failedNode}' failed after exhausting retries`,
816
- stack: '',
817
- code: 'GRAPH_NODE_FAILED',
818
- });
856
+ const { recovered, unrecovered } = splitFailures(rawFailed, nodes, graphName);
857
+ if (unrecovered.length > 0) {
858
+ await failGraphRun(workflowService, runId, unrecovered[0], rpcService);
819
859
  return;
820
860
  }
821
861
  const run = await workflowService.getRun(runId);
@@ -824,7 +864,7 @@ async function continueGraphInline(workflowService, rpcService, runId, graphName
824
864
  }
825
865
  const instances = await workflowService.getStepInstances(runId);
826
866
  const reader = await createGraphResultReader(workflowService, runId, graphName, nodes, triggerInput, instances);
827
- const plan = planGraphTransitions(nodes, instances, branchByStep, entryNodeIds, graphName, reader.fanoutWidths);
867
+ const plan = planGraphTransitions(nodes, instances, branchByStep, entryNodeIds, graphName, reader.fanoutWidths, recovered);
828
868
  if (plan.toFire.length === 0) {
829
869
  if (!plan.hasInFlight && !plan.blockedWaiting) {
830
870
  await workflowService.updateRunStatus(runId, 'completed');
@@ -848,6 +888,7 @@ async function continueGraphInline(workflowService, rpcService, runId, graphName
848
888
  }
849
889
  const resolvedInput = resolveSerializedInput(node.input, nodeResults);
850
890
  executed++;
891
+ await prepareFire(workflowService, runId, fire, node);
851
892
  await executeGraphNodeInline(workflowService, rpcService, runId, graphName, fire.logical, fire.instanceKey, resolvedInput, nodes, fire.fromStepName);
852
893
  }));
853
894
  if (executed === 0)
@@ -926,11 +967,7 @@ export async function runWorkflowGraph(workflowService, graphName, triggerInput,
926
967
  error instanceof WorkflowSuspendedException) {
927
968
  return;
928
969
  }
929
- await workflowService.updateRunStatus(runId, 'failed', undefined, {
930
- message: error.message,
931
- stack: error.stack || '',
932
- code: 'GRAPH_NODE_FAILED',
933
- });
970
+ await failGraphRunWith(workflowService, runId, error, rpcService);
934
971
  }
935
972
  finally {
936
973
  workflowService.unregisterInlineRun(runId);
@@ -24,9 +24,9 @@ export function generateMermaidDiagram(workflowName, nodes, entryNodeIds) {
24
24
  for (const target of nextTargets) {
25
25
  lines.push(` ${nodeId} --> ${target}`);
26
26
  }
27
- const errorTargets = normalizeTargets(node.onError);
27
+ const errorTargets = node.recover === 'ignore' ? [] : normalizeTargets(node.recover);
28
28
  for (const target of errorTargets) {
29
- lines.push(` ${nodeId} -.->|error| ${target}`);
29
+ lines.push(` ${nodeId} -.->|recover| ${target}`);
30
30
  }
31
31
  }
32
32
  return lines.join('\n');
@@ -26,7 +26,14 @@ export interface GraphNodeConfig<NodeIds extends string = string> {
26
26
  mode?: ForEachMode;
27
27
  input?: (ref: RefFn<NodeIds>, template: TemplateFn, $item: ItemFn) => Record<string, unknown>;
28
28
  next?: NextConfig<NodeIds>;
29
- onError?: NodeIds | NodeIds[];
29
+ /**
30
+ * What happens when this node fails after its retries: run these nodes
31
+ * instead (the error is on `graph.recoveringFrom`), or `'ignore'` to carry
32
+ * on to `next` with a null output. A recovered failure is not compensated.
33
+ */
34
+ recover?: NodeIds | NodeIds[] | 'ignore';
35
+ /** Leave this node out of the saga unwind. */
36
+ compensate?: false;
30
37
  retries?: number;
31
38
  retryDelay?: string | number;
32
39
  notes?: string;
@@ -38,6 +45,18 @@ export interface PikkuGraphWire {
38
45
  branch: (key: string) => void;
39
46
  setState: (name: string, value: unknown) => Promise<void>;
40
47
  getState: () => Promise<Record<string, unknown>>;
48
+ /** Set on a node that runs because another node failed and named it in `recover`. */
49
+ recoveringFrom?: GraphRecovery;
50
+ }
51
+ export interface GraphRecovery {
52
+ nodeId: string;
53
+ stepName: string;
54
+ error: {
55
+ message: string;
56
+ stack?: string;
57
+ code?: string;
58
+ name?: string;
59
+ };
41
60
  }
42
61
  export interface GraphWireState {
43
62
  branchKey?: string;
@@ -16,6 +16,6 @@ export { template, type TemplateString } from './graph/template.js';
16
16
  export { pikkuWorkflowGraph, type PikkuWorkflowGraphConfig, type PikkuWorkflowGraphResult, } from './graph/wire-workflow-graph.js';
17
17
  export type { WorkflowStepInput as WorkflowStepQueueInput, PikkuWorkflowOrchestratorInput, PikkuWorkflowSleeperInput, } from './workflow-queue-workers.js';
18
18
  export type { WorkflowService, WorkflowQueueOptions, WorkflowServiceOptions, WorkflowServiceConfig, WorkflowPlannedStep, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, StepStatus, WorkflowRun, WorkflowRunStatus, StepState, WorkflowRunService, WorkflowRunMirror, CoreWorkflow, PikkuWorkflow, ContextVariable, WorkflowContext, WorkflowsMeta, WorkflowsRuntimeMeta, } from './workflow.types.js';
19
- export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, Condition, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './workflow.types.js';
19
+ export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, Condition, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, MilestoneStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './workflow.types.js';
20
20
  export { createGraph } from './graph/graph-node.js';
21
21
  export type { GraphNodeConfig, ForEachConfig, ForEachMode, ItemFn, TemplateFn, } from './graph/workflow-graph.types.js';
@@ -52,6 +52,11 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
52
52
  abstract getRunHistory(runId: string): Promise<Array<StepState & {
53
53
  stepName: string;
54
54
  }>>;
55
+ abstract getRunSteps(runId: string): Promise<Array<StepState & {
56
+ stepName: string;
57
+ rpcName?: string;
58
+ data?: any;
59
+ }>>;
55
60
  updateRunStatus(id: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
56
61
  protected abstract updateRunStatusImpl(id: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
57
62
  insertStepState(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<StepState>;
@@ -212,6 +217,7 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
212
217
  runWorkflowJob(runId: string, rpcService: PikkuRPC): Promise<void>;
213
218
  private runWorkflowJobInner;
214
219
  private onChildWorkflowCompleted;
220
+ notifyChildWorkflowFailed(childRun: WorkflowRun, error: Error): Promise<void>;
215
221
  protected onChildWorkflowFailed(childRun: WorkflowRun, error: Error): Promise<void>;
216
222
  executeWorkflowStep(runId: string, stepName: string, rpcName: string, data: any, rpcService: PikkuRPC): Promise<void>;
217
223
  /**
@@ -245,6 +251,13 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
245
251
  approveStep(runId: string, reason: string, decision: unknown, session?: CoreUserSession): Promise<void>;
246
252
  private approvalStep;
247
253
  createWorkflowWire(name: string, runId: string, rpcService: PikkuRPC, addonNamespace?: string | null): PikkuWorkflowWire;
254
+ private compensationApi;
255
+ failOrUnwind(run: WorkflowRun, error: Error & {
256
+ code?: string;
257
+ }, rpcService: PikkuRPC): Promise<'unwinding' | 'finished' | 'failed'>;
258
+ /** Stops an unfinished run, children first, and unwinds what it completed. */
259
+ cancelRun(runId: string, rpcService: PikkuRPC, reason?: string): Promise<WorkflowStatus | undefined>;
260
+ protected markFailedUnlessUnwound(runId: string, error: SerializedError): Promise<void>;
248
261
  protected verifyStepName(stepName: string): void;
249
262
  private getConfig;
250
263
  protected getOrchestratorQueueName(workflowName?: string): string;