@pikku/inspector 0.12.98 → 0.12.99

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 0.12.99
2
+
3
+ ### Patch Changes
4
+
5
+ - ce3e5f5: Saga compensation for workflows. A function declares `compensate` inline; when a workflow step fails, completed steps are undone newest-first as durable `<step>:compensate` steps. New run statuses `compensating`, `compensated` and `compensation_failed`, `workflow.milestone(name)` to bound the unwind, `{ compensate: false }` to opt a call out, nested-workflow unwinding, and `PikkuWorkflowService.cancelRun` which unwinds too. Graph nodes replace `onError` with `recover` (`nodeId`, `nodeId[]` or `'ignore'`), exposing the error as `wire.graph.recoveringFrom`. **Breaking:** the DSL `onError` step option and the graph `onError` node field are removed; `getRunSteps` is now abstract on `PikkuWorkflowService`.
6
+ - Updated dependencies [ce3e5f5]
7
+ - Updated dependencies [fb36c37]
8
+ - @pikku/core@0.12.135
9
+
1
10
  ## 0.12.98
2
11
 
3
12
  ### Patch Changes
@@ -3,6 +3,7 @@ import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js';
3
3
  import { extractFunctionName, funcIdToTypeName, } from '../utils/extract-function-name.js';
4
4
  import { extractFunctionNode } from '../utils/extract-function-node.js';
5
5
  import { extractUsedWires } from '../utils/extract-services.js';
6
+ import { getPropertyAssignmentInitializer } from '../utils/type-utils.js';
6
7
  import { collectInvokedRpcNames } from './add-rpc-invocations.js';
7
8
  import { collectStartedWorkflows } from './collect-started-workflows.js';
8
9
  import { formatVersionedId, parseVersionedId } from '@pikku/core/utils';
@@ -721,7 +722,43 @@ export const addFunctions = (logger, node, checker, state, options) => {
721
722
  services.optimized = false;
722
723
  }
723
724
  }
724
- const wires = extractUsedWires(handler, 2);
725
+ let wires = extractUsedWires(handler, 2);
726
+ // `compensate` runs as the function's own sibling step, deployed wherever the
727
+ // function is, so what it needs is what the function needs.
728
+ const compensateInitializer = objectNode
729
+ ? getPropertyAssignmentInitializer(objectNode, 'compensate', true, checker)
730
+ : undefined;
731
+ const compensateNode = compensateInitializer &&
732
+ (ts.isArrowFunction(compensateInitializer) ||
733
+ ts.isFunctionExpression(compensateInitializer)
734
+ ? compensateInitializer
735
+ : undefined);
736
+ if (compensateNode) {
737
+ const compensateServices = compensateNode.parameters[0];
738
+ if (compensateServices) {
739
+ if (ts.isObjectBindingPattern(compensateServices.name)) {
740
+ for (const elem of compensateServices.name.elements) {
741
+ const original = elem.propertyName && ts.isIdentifier(elem.propertyName)
742
+ ? elem.propertyName.text
743
+ : ts.isIdentifier(elem.name)
744
+ ? elem.name.text
745
+ : undefined;
746
+ if (original && !services.services.includes(original)) {
747
+ services.services.push(original);
748
+ }
749
+ }
750
+ }
751
+ else if (ts.isIdentifier(compensateServices.name) &&
752
+ !compensateServices.name.text.startsWith('_')) {
753
+ services.optimized = false;
754
+ }
755
+ }
756
+ const compensateWires = extractUsedWires(compensateNode, 2);
757
+ wires = {
758
+ optimized: wires.optimized && compensateWires.optimized,
759
+ wires: [...new Set([...wires.wires, ...compensateWires.wires])],
760
+ };
761
+ }
725
762
  const invoked = collectInvokedRpcNames(handler.body);
726
763
  for (const call of invoked.dynamic) {
727
764
  logger.warn(`• ${pikkuFuncId} calls ${call} with a computed name — the deploy planner cannot bind its unit to the callee's, so the call fails with "No service binding" if the two are deployed apart. Use a literal RPC name.`);
@@ -1031,6 +1068,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
1031
1068
  workflowQueued: workflowQueued === true ? true : undefined,
1032
1069
  workflowRetries: workflowRetries ?? undefined,
1033
1070
  workflowTimeout: workflowTimeout ?? undefined,
1071
+ compensate: compensateInitializer ? true : undefined,
1034
1072
  scenarioStepSurfaces,
1035
1073
  scenarioStepRequiresActor,
1036
1074
  // `persona` is the default reading of an unmarked step, so it is left off
@@ -80,11 +80,6 @@ export function collectInvokedRPCs(steps, rpcs) {
80
80
  for (const step of steps) {
81
81
  if (step.type === 'rpc' && step.rpcName) {
82
82
  rpcs.add(step.rpcName);
83
- // The compensation handler is invoked at runtime too, so it has to be
84
- // wired like any other step.
85
- if (step.options?.onError) {
86
- rpcs.add(step.options.onError);
87
- }
88
83
  }
89
84
  else if (step.type === 'scenarioStep' && step.stepFunc) {
90
85
  // Steps are bundled like RPCs — the runner calls them through
@@ -70,6 +70,12 @@ function collectNamedSteps(steps) {
70
70
  displayName: reasonToLabel(step.reason),
71
71
  });
72
72
  break;
73
+ case 'milestone':
74
+ planned.push({
75
+ stepName: `__milestone__:${step.name}`,
76
+ displayName: reasonToLabel(step.name),
77
+ });
78
+ break;
73
79
  case 'approval':
74
80
  // Same as suspend, but namespaced `__workflow_approval:` to match the
75
81
  // runtime's separate step key.
@@ -142,7 +142,9 @@ function fanoutBodyToCode(entryNodeId, nodes, indent, itemVar) {
142
142
  const stepName = node.stepName || `Call ${node.rpcName}`;
143
143
  const input = (node.input || {});
144
144
  const inputCode = inputToCode(input, indent, itemVar);
145
- const options = node.options ? optionsToCode(node.options) : '';
145
+ const options = node.options || node.compensate === false
146
+ ? optionsToCode(node.options ?? {}, node.compensate === false ? false : undefined)
147
+ : '';
146
148
  const doCall = `await workflow.do('${escapeSingleQuotes(stepName)}', '${node.rpcName}', ${inputCode}${options ? `, ${options}` : ''})`;
147
149
  lines.push(node.outputVar
148
150
  ? `${indent}const ${node.outputVar} = ${doCall}`
@@ -163,19 +165,6 @@ function fanoutBodyToCode(entryNodeId, nodes, indent, itemVar) {
163
165
  */
164
166
  function collectChildNodeIds(nodes) {
165
167
  const owned = new Set();
166
- // An error handler belongs to the step that routes to it, not to the main
167
- // flow — emitting it top-level would turn compensation into a step that
168
- // always runs.
169
- for (const node of Object.values(nodes)) {
170
- const onError = node.onError;
171
- if (typeof onError === 'string') {
172
- owned.add(onError);
173
- }
174
- else if (Array.isArray(onError)) {
175
- for (const handlerId of onError)
176
- owned.add(handlerId);
177
- }
178
- }
179
168
  const walk = (entry, exit) => {
180
169
  const seen = new Set();
181
170
  let currentId = entry;
@@ -231,20 +220,6 @@ function bindingPrefix(outputVar) {
231
220
  ? `const ${outputVar} = `
232
221
  : '';
233
222
  }
234
- /**
235
- * Resolve a node's `onError` node id back to the rpc name the DSL author wrote.
236
- */
237
- function onErrorRpcNameOf(node, nodes) {
238
- const onError = node.onError;
239
- const handlerId = Array.isArray(onError) ? onError[0] : onError;
240
- if (typeof handlerId !== 'string') {
241
- return undefined;
242
- }
243
- const handler = nodes[handlerId];
244
- return handler && 'rpcName' in handler
245
- ? handler.rpcName
246
- : undefined;
247
- }
248
223
  /**
249
224
  * A duration is `string | number` ('5s' or 5000). Quoting a number would change
250
225
  * the value the runtime parses, so only strings get quotes.
@@ -267,17 +242,17 @@ function caseValueToCode(value) {
267
242
  /**
268
243
  * Convert options to code
269
244
  */
270
- function optionsToCode(options, onErrorRpcName) {
245
+ function optionsToCode(options, compensate) {
271
246
  const parts = [];
272
- if (onErrorRpcName) {
273
- parts.push(`onError: '${escapeSingleQuotes(onErrorRpcName)}'`);
274
- }
275
247
  if (options.retries !== undefined) {
276
248
  parts.push(`retries: ${options.retries}`);
277
249
  }
278
250
  if (options.retryDelay !== undefined) {
279
251
  parts.push(`retryDelay: ${durationToCode(options.retryDelay)}`);
280
252
  }
253
+ if (compensate === false) {
254
+ parts.push('compensate: false');
255
+ }
281
256
  return parts.length > 0 ? `{ ${parts.join(', ')} }` : '';
282
257
  }
283
258
  /**
@@ -422,9 +397,9 @@ function nodeToCode(node, nodes, indent, conditionalVars = new Set(), isInsideBr
422
397
  const outputVar = node.outputVar;
423
398
  let doCall = `await workflow.do('${stepName}', '${node.rpcName}', ${inputCode}`;
424
399
  // Add options if present
425
- const onErrorRpc = onErrorRpcNameOf(node, nodes);
426
- if (node.options || onErrorRpc) {
427
- const optCode = optionsToCode(node.options ?? {}, onErrorRpc);
400
+ const noCompensate = node.compensate === false ? false : undefined;
401
+ if (node.options || noCompensate === false) {
402
+ const optCode = optionsToCode(node.options ?? {}, noCompensate);
428
403
  if (optCode) {
429
404
  doCall += `, ${optCode}`;
430
405
  }
@@ -459,6 +434,10 @@ function nodeToCode(node, nodes, indent, conditionalVars = new Set(), isInsideBr
459
434
  lines.push(`${indent}await workflow.suspend('${escapeSingleQuotes(String(flowNode.reason ?? ''))}')`);
460
435
  lines.push('');
461
436
  break;
437
+ case 'milestone':
438
+ lines.push(`${indent}await workflow.milestone('${escapeSingleQuotes(String(flowNode.reason ?? ''))}')`);
439
+ lines.push('');
440
+ break;
462
441
  case 'approval': {
463
442
  const approvalCall = `await workflow.approval('${escapeSingleQuotes(String(flowNode.reason ?? ''))}')`;
464
443
  lines.push(flowNode.outputVar
@@ -537,8 +516,8 @@ function nodeToCode(node, nodes, indent, conditionalVars = new Set(), isInsideBr
537
516
  const input = (childNode.input || {});
538
517
  const inputCode = inputToCode(input, indent + ' ');
539
518
  let call = `workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode}`;
540
- if (childNode.options) {
541
- const optCode = optionsToCode(childNode.options);
519
+ if (childNode.options || childNode.compensate === false) {
520
+ const optCode = optionsToCode(childNode.options ?? {}, childNode.compensate === false ? false : undefined);
542
521
  if (optCode) {
543
522
  call += `, ${optCode}`;
544
523
  }
@@ -1021,14 +1000,20 @@ export function deserializeGraphWorkflow(workflow, options = {}) {
1021
1000
  configParts.push(`next: ${nextCode}`);
1022
1001
  }
1023
1002
  }
1024
- // onError, retries and retryDelay are all honoured by the graph runner,
1025
- // so dropping them here silently changes how the workflow behaves.
1026
- if ('onError' in node && node.onError) {
1027
- const onErrorCode = nextToCode(node.onError, workflow.nodes, flowNodeIds);
1028
- if (onErrorCode) {
1029
- configParts.push(`onError: ${onErrorCode}`);
1003
+ // recover, compensate, retries and retryDelay are all honoured by the
1004
+ // graph runner, so dropping them here silently changes how the workflow
1005
+ // behaves.
1006
+ if ('recover' in node && node.recover) {
1007
+ const recoverCode = node.recover === 'ignore'
1008
+ ? `'ignore'`
1009
+ : nextToCode(node.recover, workflow.nodes, flowNodeIds);
1010
+ if (recoverCode) {
1011
+ configParts.push(`recover: ${recoverCode}`);
1030
1012
  }
1031
1013
  }
1014
+ if ('compensate' in node && node.compensate === false) {
1015
+ configParts.push('compensate: false');
1016
+ }
1032
1017
  if ('options' in node && node.options) {
1033
1018
  const { retries, retryDelay } = node.options;
1034
1019
  if (retries !== undefined) {
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, isScenarioStepCall, isScenarioExpectationCall, getScenarioStepPhase, DYNAMIC_SCENARIO_STEP_TARGET, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isWorkflowApprovalCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
2
+ import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, isScenarioStepCall, isScenarioExpectationCall, getScenarioStepPhase, DYNAMIC_SCENARIO_STEP_TARGET, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isWorkflowMilestoneCall, isWorkflowApprovalCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
3
3
  import { validateNoDisallowedPatterns, validateAwaitedCalls, formatValidationErrors, } from './validation.js';
4
4
  import { extractStringLiteral, extractNumberLiteral, } from '../../extract-node-value.js';
5
5
  /**
@@ -518,6 +518,9 @@ function extractExpressionStatement(statement, context) {
518
518
  if (isWorkflowSuspendCall(call, context.checker)) {
519
519
  return extractSuspendStep(call, context);
520
520
  }
521
+ if (isWorkflowMilestoneCall(call, context.checker)) {
522
+ return extractMilestoneStep(call, context);
523
+ }
521
524
  if (isWorkflowApprovalCall(call, context.checker)) {
522
525
  const step = extractApprovalStep(call, context, outputVar);
523
526
  // Unlike suspend, an approval yields a value, so a downstream step can
@@ -696,13 +699,13 @@ function extractStepOptions(optionsNode, context) {
696
699
  // Ignore extraction errors for retryDelay
697
700
  }
698
701
  }
699
- else if (propName === 'onError') {
700
- if (ts.isStringLiteral(prop.initializer)) {
701
- options.onError = prop.initializer.text;
702
+ else if (propName === 'compensate') {
703
+ if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) {
704
+ options.compensate = false;
702
705
  }
703
706
  else {
704
707
  context.errors.push({
705
- message: 'onError must be a literal RPC name so it can be wired and drawn in the graph.',
708
+ message: "compensate on a call site can only be `false`: a step's compensation is declared on the function itself.",
706
709
  node: prop.initializer,
707
710
  });
708
711
  }
@@ -786,6 +789,27 @@ function extractSuspendStep(call, context) {
786
789
  return null;
787
790
  }
788
791
  }
792
+ /**
793
+ * Extract milestone step from workflow.milestone() call
794
+ */
795
+ function extractMilestoneStep(call, context) {
796
+ const args = call.arguments;
797
+ if (args.length < 1)
798
+ return null;
799
+ try {
800
+ return {
801
+ type: 'milestone',
802
+ name: extractStringLiteral(args[0], context.checker),
803
+ };
804
+ }
805
+ catch (error) {
806
+ context.errors.push({
807
+ message: `Failed to extract milestone step: ${error instanceof Error ? error.message : String(error)}`,
808
+ node: call,
809
+ });
810
+ return null;
811
+ }
812
+ }
789
813
  /**
790
814
  * Extract approval step from workflow.approval() call
791
815
  */
@@ -1146,6 +1170,13 @@ function extractFanoutBodyStep(stmt, childContext, body) {
1146
1170
  }
1147
1171
  return;
1148
1172
  }
1173
+ if (isWorkflowMilestoneCall(call, childContext.checker)) {
1174
+ const milestoneStep = extractMilestoneStep(call, childContext);
1175
+ if (milestoneStep) {
1176
+ body.push(milestoneStep);
1177
+ }
1178
+ return;
1179
+ }
1149
1180
  if (isScenarioStepCall(call)) {
1150
1181
  const scenarioStep = extractScenarioStep(call, childContext);
1151
1182
  if (scenarioStep) {
@@ -57,6 +57,10 @@ export declare function isWorkflowSleepCall(node: ts.CallExpression, checker: ts
57
57
  * Check if a call expression is workflow.suspend()
58
58
  */
59
59
  export declare function isWorkflowSuspendCall(node: ts.CallExpression, _checker: ts.TypeChecker): boolean;
60
+ /**
61
+ * Check if a call expression is workflow.milestone()
62
+ */
63
+ export declare function isWorkflowMilestoneCall(node: ts.CallExpression, _checker: ts.TypeChecker): boolean;
60
64
  /**
61
65
  * Check if a call expression is workflow.approval()
62
66
  */
@@ -133,6 +133,17 @@ export function isWorkflowSuspendCall(node, _checker) {
133
133
  return (propAccess.name.text === 'suspend' &&
134
134
  isWorkflowWireIdentifier(propAccess.expression));
135
135
  }
136
+ /**
137
+ * Check if a call expression is workflow.milestone()
138
+ */
139
+ export function isWorkflowMilestoneCall(node, _checker) {
140
+ if (!ts.isPropertyAccessExpression(node.expression)) {
141
+ return false;
142
+ }
143
+ const propAccess = node.expression;
144
+ return (propAccess.name.text === 'milestone' &&
145
+ isWorkflowWireIdentifier(propAccess.expression));
146
+ }
136
147
  /**
137
148
  * Check if a call expression is workflow.approval()
138
149
  */
@@ -85,21 +85,9 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
85
85
  retries: step.options.retries,
86
86
  retryDelay: step.options.retryDelay,
87
87
  };
88
- }
89
- // An onError handler is written as an rpc name, but the graph routes to
90
- // node ids — materialise a node for it so the console has something to
91
- // draw the error edge to, exactly as in a hand-authored graph.
92
- if (step.options?.onError) {
93
- const handlerId = `${nodeId}_onError`;
94
- node.onError = handlerId;
95
- return [
96
- node,
97
- {
98
- nodeId: handlerId,
99
- rpcName: step.options.onError,
100
- stepName: `${step.stepName} (on error)`,
101
- },
102
- ];
88
+ if (step.options.compensate === false) {
89
+ node.compensate = false;
90
+ }
103
91
  }
104
92
  return [node];
105
93
  }
@@ -161,6 +149,15 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
161
149
  };
162
150
  return [node];
163
151
  }
152
+ case 'milestone': {
153
+ const node = {
154
+ nodeId,
155
+ flow: 'milestone',
156
+ reason: step.name,
157
+ next: nextNodeId,
158
+ };
159
+ return [node];
160
+ }
164
161
  case 'approval': {
165
162
  // Unlike `suspend` — which has no output and so is deliberately absent
166
163
  // from the graph — an approval yields a value a later step can reference,
@@ -18,7 +18,8 @@ export declare function serializeWorkflowGraph(definition: {
18
18
  } | ((ref: any) => unknown);
19
19
  mode?: 'parallel' | 'sequential';
20
20
  next?: string | string[] | Record<string, string | string[]>;
21
- onError?: string | string[];
21
+ recover?: string | string[] | 'ignore';
22
+ compensate?: false;
22
23
  notes?: string;
23
24
  }>;
24
25
  }, options?: {
@@ -36,7 +37,8 @@ export declare function deserializeWorkflowGraph(serialized: SerializedWorkflowG
36
37
  rpcName: string;
37
38
  input: Record<string, unknown | DataRef>;
38
39
  next?: SerializedNext;
39
- onError?: string | string[];
40
+ recover?: string | string[] | 'ignore';
41
+ compensate?: false;
40
42
  }>;
41
43
  entryNodeIds: string[];
42
44
  };
@@ -115,7 +115,8 @@ export function serializeWorkflowGraph(definition, options) {
115
115
  rpcName,
116
116
  input,
117
117
  next: serializeNext(node.next),
118
- onError: node.onError,
118
+ recover: node.recover,
119
+ compensate: node.compensate,
119
120
  };
120
121
  const forEach = serializeForEach(node.forEach, createRef);
121
122
  if (forEach) {
@@ -158,7 +159,8 @@ export function deserializeWorkflowGraph(serialized) {
158
159
  rpcName: funcNode.rpcName,
159
160
  input: funcNode.input ?? {},
160
161
  next: funcNode.next,
161
- onError: funcNode.onError,
162
+ recover: funcNode.recover,
163
+ compensate: funcNode.compensate,
162
164
  };
163
165
  }
164
166
  }
@@ -66,7 +66,7 @@ export interface NodeOptions {
66
66
  /**
67
67
  * Flow node types for control flow (no RPC call)
68
68
  */
69
- export type FlowType = 'sleep' | 'suspend' | 'approval' | 'branch' | 'parallel' | 'fanout' | 'inline' | 'switch' | 'filter' | 'arrayPredicate' | 'return' | 'cancel' | 'set';
69
+ export type FlowType = 'sleep' | 'suspend' | 'milestone' | 'approval' | 'branch' | 'parallel' | 'fanout' | 'inline' | 'switch' | 'filter' | 'arrayPredicate' | 'return' | 'cancel' | 'set';
70
70
  import type { ContextVariable, WorkflowContext, WorkflowPlannedStep } from '@pikku/core/workflow';
71
71
  import type { ScenarioStepPhase } from '@pikku/core/scenario';
72
72
  export type { ContextVariable, WorkflowContext };
@@ -80,8 +80,10 @@ interface BaseNode {
80
80
  stepName?: string;
81
81
  /** Next node(s) - simple, parallel, or conditional */
82
82
  next?: SerializedNext;
83
- /** Error routing - node(s) to execute on error */
84
- onError?: string | string[];
83
+ /** What to do when this node fails: run these nodes, or 'ignore' to carry on. */
84
+ recover?: string | string[] | 'ignore';
85
+ /** Leave this node out of the saga unwind. */
86
+ compensate?: false;
85
87
  /** Execution options */
86
88
  options?: NodeOptions;
87
89
  /** Free-text node documentation. Non-semantic — excluded from graphHash. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.98",
3
+ "version": "0.12.99",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -54,10 +54,10 @@
54
54
  "zod": "^4.4.3"
55
55
  },
56
56
  "peerDependencies": {
57
- "@pikku/core": "^0.12.132"
57
+ "@pikku/core": "^0.12.135"
58
58
  },
59
59
  "devDependencies": {
60
- "@pikku/core": "^0.12.132",
60
+ "@pikku/core": "^0.12.135",
61
61
  "@types/node": "^24.13.3"
62
62
  },
63
63
  "engines": {