@pikku/inspector 0.12.98 → 0.12.101

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,29 @@
1
+ ## 0.12.101
2
+
3
+ ### Patch Changes
4
+
5
+ - 9112494: `pikku knowledge validate` reports code that no note describes as a warning rather than info, and raises one `PKU960` diagnostic for it, so `--fail-on-warn` can gate on it and the "consistent" line no longer prints while notes are missing. A plain run still exits 0: only errors fail it.
6
+
7
+ ## 0.12.100
8
+
9
+ ### Patch Changes
10
+
11
+ - 149faae: Webhook `receive` steps lose their boilerplate. `pikkuWebhookReceive` (from `#pikku/trigger` or `#pikku/addon/trigger`) declares one: it is public, typed to the raw request with a required `http`, and never registered as an RPC, and the inspector rejects a `receive` declared with any other wrapper. `parseJson` (in `@pikku/core/utils`, generated as `#pikku/utils` and `#pikku/addon/utils`) parses text or bytes and answers a body that is not JSON with a 400. A webhook source whose `method` includes `'head'` answers HEAD probes itself with a 200.
12
+
13
+ `receive` no longer returns `{ respond }`: a handshake returns nothing and writes its answer to `http.response`, like any other HTTP function. The webhook route no longer returns a fetch `Response` either. An HTTP function that returns nothing is now answered with whatever status its response has (200 unless it set another), no longer a forced 204. A webhook receive is also left out of contract versioning, since nothing but its own route calls it.
14
+
15
+ - Updated dependencies [149faae]
16
+ - @pikku/core@0.12.136
17
+
18
+ ## 0.12.99
19
+
20
+ ### Patch Changes
21
+
22
+ - 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`.
23
+ - Updated dependencies [ce3e5f5]
24
+ - Updated dependencies [fb36c37]
25
+ - @pikku/core@0.12.135
26
+
1
27
  ## 0.12.98
2
28
 
3
29
  ### 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';
@@ -360,6 +361,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
360
361
  const pikkuFuncPattern = /pikku.*func/i;
361
362
  if (!pikkuFuncPattern.test(expression.text) &&
362
363
  expression.text !== 'pikkuScenario' &&
364
+ expression.text !== 'pikkuWebhookReceive' &&
363
365
  !SCENARIO_STEP_KINDS.has(expression.text)) {
364
366
  return;
365
367
  }
@@ -372,6 +374,10 @@ export const addFunctions = (logger, node, checker, state, options) => {
372
374
  // analyzer can tell it apart from an application workflow without having to
373
375
  // reach into the workflow graph.
374
376
  const isScenario = expression.text === 'pikkuScenario';
377
+ // A webhook source's `receive`. Only its source's route runs it, with the
378
+ // raw request, so like a scenario step it is bundled but never RPC-registered.
379
+ const isWebhookReceive = expression.text === 'pikkuWebhookReceive';
380
+ const notRpc = isScenarioStep || isWebhookReceive;
375
381
  // only handle calls like pikkuFunc(...)
376
382
  if (!ts.isIdentifier(expression) || !expression.text.startsWith('pikku')) {
377
383
  return;
@@ -721,7 +727,43 @@ export const addFunctions = (logger, node, checker, state, options) => {
721
727
  services.optimized = false;
722
728
  }
723
729
  }
724
- const wires = extractUsedWires(handler, 2);
730
+ let wires = extractUsedWires(handler, 2);
731
+ // `compensate` runs as the function's own sibling step, deployed wherever the
732
+ // function is, so what it needs is what the function needs.
733
+ const compensateInitializer = objectNode
734
+ ? getPropertyAssignmentInitializer(objectNode, 'compensate', true, checker)
735
+ : undefined;
736
+ const compensateNode = compensateInitializer &&
737
+ (ts.isArrowFunction(compensateInitializer) ||
738
+ ts.isFunctionExpression(compensateInitializer)
739
+ ? compensateInitializer
740
+ : undefined);
741
+ if (compensateNode) {
742
+ const compensateServices = compensateNode.parameters[0];
743
+ if (compensateServices) {
744
+ if (ts.isObjectBindingPattern(compensateServices.name)) {
745
+ for (const elem of compensateServices.name.elements) {
746
+ const original = elem.propertyName && ts.isIdentifier(elem.propertyName)
747
+ ? elem.propertyName.text
748
+ : ts.isIdentifier(elem.name)
749
+ ? elem.name.text
750
+ : undefined;
751
+ if (original && !services.services.includes(original)) {
752
+ services.services.push(original);
753
+ }
754
+ }
755
+ }
756
+ else if (ts.isIdentifier(compensateServices.name) &&
757
+ !compensateServices.name.text.startsWith('_')) {
758
+ services.optimized = false;
759
+ }
760
+ }
761
+ const compensateWires = extractUsedWires(compensateNode, 2);
762
+ wires = {
763
+ optimized: wires.optimized && compensateWires.optimized,
764
+ wires: [...new Set([...wires.wires, ...compensateWires.wires])],
765
+ };
766
+ }
725
767
  const invoked = collectInvokedRpcNames(handler.body);
726
768
  for (const call of invoked.dynamic) {
727
769
  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.`);
@@ -780,6 +822,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
780
822
  inputTypes = result.types;
781
823
  }
782
824
  }
825
+ // A receive's input is always the raw request, which no schema describes.
826
+ if (isWebhookReceive)
827
+ inputNames = [];
783
828
  // --- Output Extraction ---
784
829
  let outputNames = [];
785
830
  if (outputSchemaRef) {
@@ -1017,11 +1062,16 @@ export const addFunctions = (logger, node, checker, state, options) => {
1017
1062
  inputs: inputNames.filter((n) => n !== 'void') ?? null,
1018
1063
  outputs: outputNames.filter((n) => n !== 'void') ?? null,
1019
1064
  expose: expose || undefined,
1020
- auth: typeof auth === 'boolean' ? auth : undefined,
1065
+ auth: isWebhookReceive
1066
+ ? false
1067
+ : typeof auth === 'boolean'
1068
+ ? auth
1069
+ : undefined,
1021
1070
  permissionsInBody: permissionsInBody || undefined,
1022
1071
  audit,
1023
1072
  remote: remote || undefined,
1024
1073
  scenarioStep: isScenarioStep || undefined,
1074
+ webhookReceive: isWebhookReceive || undefined,
1025
1075
  scenario: isScenario || undefined,
1026
1076
  mcp: mcpEnabled || undefined,
1027
1077
  readonly: readonly_ || undefined,
@@ -1031,6 +1081,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
1031
1081
  workflowQueued: workflowQueued === true ? true : undefined,
1032
1082
  workflowRetries: workflowRetries ?? undefined,
1033
1083
  workflowTimeout: workflowTimeout ?? undefined,
1084
+ compensate: compensateInitializer ? true : undefined,
1034
1085
  scenarioStepSurfaces,
1035
1086
  scenarioStepRequiresActor,
1036
1087
  // `persona` is the default reading of an unmarked step, so it is left off
@@ -1135,7 +1186,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
1135
1186
  logger.error(`• Function with explicit name '${name}' is not exported, this is not allowed.`);
1136
1187
  return;
1137
1188
  }
1138
- if (remote && !isScenarioStep) {
1189
+ if (remote && !notRpc) {
1139
1190
  state.rpc.invokedFunctions.add(pikkuFuncId);
1140
1191
  // The consumer-facing surface a wireRemoteAddon imports (mirrors exposedMeta)
1141
1192
  state.rpc.remoteMeta[name] = pikkuFuncId;
@@ -1144,7 +1195,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
1144
1195
  exportedName,
1145
1196
  });
1146
1197
  }
1147
- if (expose && !isScenarioStep) {
1198
+ if (expose && !notRpc) {
1148
1199
  state.rpc.exposedMeta[name] = pikkuFuncId;
1149
1200
  state.rpc.exposedFiles.set(name, {
1150
1201
  path: node.getSourceFile().fileName,
@@ -1153,7 +1204,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
1153
1204
  // Track exposed RPC function for service aggregation
1154
1205
  state.serviceAggregation.usedFunctions.add(pikkuFuncId);
1155
1206
  }
1156
- if (!isScenarioStep) {
1207
+ if (!notRpc) {
1157
1208
  // We add it to internal meta to allow autocomplete for everything
1158
1209
  state.rpc.internalMeta[name] = pikkuFuncId;
1159
1210
  if (version !== undefined) {
@@ -184,6 +184,12 @@ const addWireTriggerWebhookSource = (logger, node, checker, state, firstArg) =>
184
184
  */
185
185
  export const validateWebhookSourceTriggers = (logger, state) => {
186
186
  const sources = state.triggers.webhookSourceMeta;
187
+ for (const source of Object.values(sources)) {
188
+ const meta = source.receive && state.functions.meta[source.receive];
189
+ if (meta && meta.funcWrapper && !meta.webhookReceive) {
190
+ logger.error(`Webhook source '${source.name}' receives with '${source.receive}', a ${meta.funcWrapper}. Declare it with pikkuWebhookReceive so it is never callable as an RPC.`);
191
+ }
192
+ }
187
193
  for (const trigger of Object.keys(state.triggers.meta)) {
188
194
  const colon = trigger.indexOf(':');
189
195
  if (colon === -1)
@@ -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
@@ -83,6 +83,7 @@ export declare enum ErrorCode {
83
83
  ADDON_WIRING_NOT_ALLOWED = "PKU920",
84
84
  ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = "PKU921",
85
85
  RPC_INVOCATION_TYPE_CAST = "PKU940",
86
+ KNOWLEDGE_NOTE_MISSING = "PKU960",
86
87
  SECRET_SERVICE_ALIASED = "PKU950",
87
88
  SECRET_NOT_DECLARED = "PKU951",
88
89
  SECRET_KEY_NOT_STATIC = "PKU952",
@@ -108,6 +108,8 @@ export var ErrorCode;
108
108
  ErrorCode["ADDON_WIRING_NOT_ALLOWED"] = "PKU920";
109
109
  ErrorCode["ADDON_CONTRACT_HANDLERS_NOT_ALLOWED"] = "PKU921";
110
110
  ErrorCode["RPC_INVOCATION_TYPE_CAST"] = "PKU940";
111
+ // Knowledge base errors
112
+ ErrorCode["KNOWLEDGE_NOTE_MISSING"] = "PKU960";
111
113
  // Secret boundary errors
112
114
  ErrorCode["SECRET_SERVICE_ALIASED"] = "PKU950";
113
115
  ErrorCode["SECRET_NOT_DECLARED"] = "PKU951";
@@ -71,7 +71,9 @@ function resolveSchema(typeNames, allSchemas, typesMap) {
71
71
  export function buildCurrentContracts(functionsMeta, allSchemas, typesMap) {
72
72
  const result = new Map();
73
73
  for (const [funcId, meta] of Object.entries(functionsMeta)) {
74
- if (meta.remote === true) {
74
+ // A webhook receive has no callers but its own route, so there is no
75
+ // contract for a version to protect.
76
+ if (meta.remote === true || meta.webhookReceive) {
75
77
  continue;
76
78
  }
77
79
  const parsed = parseVersionedId(funcId);
@@ -251,7 +253,7 @@ export function updateManifest(existing, currentContracts) {
251
253
  export function extractContractsFromMeta(functionsMeta) {
252
254
  const result = new Map();
253
255
  for (const [funcId, meta] of Object.entries(functionsMeta)) {
254
- if (meta.remote === true || !meta.contractHash) {
256
+ if (meta.remote === true || meta.webhookReceive || !meta.contractHash) {
255
257
  continue;
256
258
  }
257
259
  const parsed = parseVersionedId(funcId);
@@ -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.101",
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.136"
58
58
  },
59
59
  "devDependencies": {
60
- "@pikku/core": "^0.12.132",
60
+ "@pikku/core": "^0.12.136",
61
61
  "@types/node": "^24.13.3"
62
62
  },
63
63
  "engines": {