@pikku/inspector 0.12.43 → 0.12.45

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 (36) hide show
  1. package/CHANGELOG.md +261 -0
  2. package/dist/add/add-credential.js +2 -0
  3. package/dist/add/add-keyed-wiring.js +2 -0
  4. package/dist/add/add-workflow.js +9 -4
  5. package/dist/utils/schema-generator.js +19 -4
  6. package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +373 -91
  7. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +218 -99
  8. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +62 -5
  9. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +3 -3
  10. package/package.json +3 -3
  11. package/run-tests.sh +1 -1
  12. package/src/add/add-auth.test.ts +4 -4
  13. package/src/add/add-credential.ts +2 -0
  14. package/src/add/add-keyed-wiring.ts +2 -0
  15. package/src/add/add-workflow-edge-cases.test.ts +201 -0
  16. package/src/add/add-workflow-fanout.test.ts +60 -0
  17. package/src/add/add-workflow-on-error.test.ts +118 -0
  18. package/src/add/add-workflow-return-switch.test.ts +121 -0
  19. package/src/add/add-workflow-silent-drops.test.ts +163 -0
  20. package/src/add/add-workflow-sleep-expression.test.ts +97 -0
  21. package/src/add/add-workflow.ts +9 -4
  22. package/src/add/config-value-metadata.test.ts +169 -0
  23. package/src/utils/schema-generator.test.ts +164 -0
  24. package/src/utils/schema-generator.ts +26 -3
  25. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +480 -119
  26. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
  27. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
  28. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
  29. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
  30. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
  31. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
  32. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +245 -105
  33. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +67 -10
  34. package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
  35. package/src/utils/workflow/graph/workflow-graph.types.ts +3 -2
  36. package/tsconfig.tsbuildinfo +1 -1
@@ -197,11 +197,15 @@ function extractStep(statement, context) {
197
197
  function extractVariableDeclaration(statement, context) {
198
198
  const declList = statement.declarationList;
199
199
  if (declList.declarations.length !== 1) {
200
+ context.errors.push({
201
+ message: `A single declaration statement may only declare one variable in DSL workflows. Split '${statement.getText().slice(0, 60)}' into separate statements.`,
202
+ node: statement,
203
+ });
200
204
  return null;
201
205
  }
202
206
  const decl = declList.declarations[0];
203
207
  if (!ts.isIdentifier(decl.name)) {
204
- return null;
208
+ return extractDestructuredDeclaration(statement, decl, context);
205
209
  }
206
210
  const varName = decl.name.text;
207
211
  const init = decl.initializer;
@@ -295,6 +299,55 @@ function extractVariableDeclaration(statement, context) {
295
299
  }
296
300
  return null;
297
301
  }
302
+ /**
303
+ * Extract a declaration whose binding is a destructuring pattern.
304
+ *
305
+ * `const [a, b] = await Promise.all([...])` is the idiomatic way to run steps
306
+ * in parallel and keep both results, so each element of the pattern is bound to
307
+ * the matching child step's output. Every other destructuring shape reports a
308
+ * diagnostic rather than silently dropping the step.
309
+ */
310
+ function extractDestructuredDeclaration(statement, decl, context) {
311
+ const init = decl.initializer;
312
+ if (init &&
313
+ ts.isAwaitExpression(init) &&
314
+ ts.isCallExpression(init.expression) &&
315
+ isParallelGroup(init.expression) &&
316
+ ts.isArrayBindingPattern(decl.name)) {
317
+ const elements = decl.name.elements;
318
+ const allSimple = elements.every((el) => ts.isBindingElement(el) &&
319
+ ts.isIdentifier(el.name) &&
320
+ !el.dotDotDotToken);
321
+ if (allSimple) {
322
+ const step = extractParallelGroup(init.expression, context);
323
+ if (step) {
324
+ if (elements.length !== step.children.length) {
325
+ context.errors.push({
326
+ message: `Destructuring binds ${elements.length} name(s) but Promise.all has ${step.children.length} step(s). They must match so each result can be bound to its step.`,
327
+ node: statement,
328
+ });
329
+ return null;
330
+ }
331
+ elements.forEach((el, i) => {
332
+ const name = el.name;
333
+ const varName = name.text;
334
+ step.children[i].outputVar = varName;
335
+ const type = context.checker.getTypeAtLocation(name);
336
+ context.outputVars.set(varName, { type, node: name });
337
+ if (isArrayType(type, context.checker)) {
338
+ context.arrayVars.add(varName);
339
+ }
340
+ });
341
+ return step;
342
+ }
343
+ }
344
+ }
345
+ context.errors.push({
346
+ message: `Destructuring a step result is not supported in DSL workflows. Assign it to a variable first (e.g. \`const result = await workflow.do(...)\`) and read its properties.`,
347
+ node: statement,
348
+ });
349
+ return null;
350
+ }
298
351
  /**
299
352
  * Extract expression statement (await workflow.do(...) without assignment)
300
353
  */
@@ -327,11 +380,11 @@ function extractExpressionStatement(statement, context) {
327
380
  value: literalValue,
328
381
  };
329
382
  }
330
- // Non-literal assignment to context var - use expression as string
383
+ // Non-literal assignment to context var - keep the source expression
331
384
  return {
332
385
  type: 'set',
333
386
  variable: outputVar,
334
- value: getSourceText(expr.right),
387
+ expression: getSourceText(expr.right),
335
388
  };
336
389
  }
337
390
  }
@@ -490,6 +543,17 @@ function extractStepOptions(optionsNode, context) {
490
543
  // Ignore extraction errors for retryDelay
491
544
  }
492
545
  }
546
+ else if (propName === 'onError') {
547
+ if (ts.isStringLiteral(prop.initializer)) {
548
+ options.onError = prop.initializer.text;
549
+ }
550
+ else {
551
+ context.errors.push({
552
+ message: 'onError must be a literal RPC name so it can be wired and drawn in the graph.',
553
+ node: prop.initializer,
554
+ });
555
+ }
556
+ }
493
557
  else if (propName === 'description') {
494
558
  try {
495
559
  options.description = extractStringLiteral(prop.initializer, context.checker);
@@ -518,7 +582,20 @@ function extractSleepStep(call, context) {
518
582
  duration = numValue;
519
583
  }
520
584
  else {
521
- duration = extractStringLiteral(args[1], context.checker);
585
+ try {
586
+ duration = extractStringLiteral(args[1], context.checker);
587
+ }
588
+ catch {
589
+ // A duration computed at runtime (a loop variable, a field off the
590
+ // input) is legal: the closure evaluates it. Record the source text so
591
+ // the graph can show what it waits on, rather than failing the workflow.
592
+ return {
593
+ type: 'sleep',
594
+ stepName,
595
+ duration: '',
596
+ expression: args[1].getText(),
597
+ };
598
+ }
522
599
  }
523
600
  return {
524
601
  type: 'sleep',
@@ -835,6 +912,84 @@ function extractArrayPredicate(call, context, outputVar) {
835
912
  outputVar,
836
913
  };
837
914
  }
915
+ /**
916
+ * Extract an ordered list of workflow.do steps from a per-iteration body.
917
+ *
918
+ * Unlike if/switch blocks, a fanout body is a real per-iteration scope, so
919
+ * `const x = await workflow.do(...)` is meaningful here: the binding is
920
+ * registered on the child context so later steps in the same iteration can
921
+ * reference it.
922
+ */
923
+ function extractFanoutBodyStep(stmt, childContext, body) {
924
+ if (ts.isVariableStatement(stmt)) {
925
+ const declList = stmt.declarationList;
926
+ if (declList.declarations.length !== 1) {
927
+ return;
928
+ }
929
+ const decl = declList.declarations[0];
930
+ const init = decl.initializer;
931
+ if (!init ||
932
+ !ts.isAwaitExpression(init) ||
933
+ !ts.isCallExpression(init.expression)) {
934
+ return;
935
+ }
936
+ const call = init.expression;
937
+ if (!isWorkflowDoCall(call, childContext.checker)) {
938
+ return;
939
+ }
940
+ const varName = ts.isIdentifier(decl.name) ? decl.name.text : undefined;
941
+ const step = extractRpcStep(call, childContext, varName);
942
+ if (!step) {
943
+ return;
944
+ }
945
+ if (varName) {
946
+ const type = childContext.checker.getTypeAtLocation(decl);
947
+ childContext.outputVars.set(varName, { type, node: decl });
948
+ if (isArrayType(type, childContext.checker)) {
949
+ childContext.arrayVars.add(varName);
950
+ }
951
+ }
952
+ body.push(step);
953
+ return;
954
+ }
955
+ if (ts.isExpressionStatement(stmt) || ts.isReturnStatement(stmt)) {
956
+ const expr = ts.isReturnStatement(stmt) ? stmt.expression : stmt.expression;
957
+ if (!expr) {
958
+ return;
959
+ }
960
+ const call = ts.isAwaitExpression(expr)
961
+ ? ts.isCallExpression(expr.expression)
962
+ ? expr.expression
963
+ : null
964
+ : ts.isCallExpression(expr)
965
+ ? expr
966
+ : null;
967
+ if (!call) {
968
+ return;
969
+ }
970
+ if (isWorkflowSleepCall(call, childContext.checker)) {
971
+ const sleepStep = extractSleepStep(call, childContext);
972
+ if (sleepStep && sleepStep.type === 'sleep') {
973
+ body.push(sleepStep);
974
+ }
975
+ return;
976
+ }
977
+ if (isWorkflowSuspendCall(call, childContext.checker)) {
978
+ const suspendStep = extractSuspendStep(call, childContext);
979
+ if (suspendStep && suspendStep.type === 'suspend') {
980
+ body.push(suspendStep);
981
+ }
982
+ return;
983
+ }
984
+ if (!isWorkflowDoCall(call, childContext.checker)) {
985
+ return;
986
+ }
987
+ const step = extractRpcStep(call, childContext);
988
+ if (step) {
989
+ body.push(step);
990
+ }
991
+ }
992
+ }
838
993
  /**
839
994
  * Extract parallel fanout from Promise.all(array.map(...))
840
995
  */
@@ -863,63 +1018,45 @@ function extractParallelFanout(call, context) {
863
1018
  return null;
864
1019
  }
865
1020
  const itemVar = itemParam.name.text;
866
- // Extract workflow.do call from map body
867
- let doCall = null;
868
- if (ts.isCallExpression(mapFn.body)) {
869
- doCall = mapFn.body;
870
- }
871
- else if (ts.isAwaitExpression(mapFn.body)) {
872
- // Handle: async (email) => await workflow.do(...)
873
- if (ts.isCallExpression(mapFn.body.expression)) {
874
- doCall = mapFn.body.expression;
875
- }
876
- }
877
- else if (ts.isBlock(mapFn.body)) {
878
- // Look for workflow.do in block
879
- for (const stmt of mapFn.body.statements) {
880
- if (ts.isExpressionStatement(stmt)) {
881
- // Handle: await workflow.do(...)
882
- if (ts.isAwaitExpression(stmt.expression) &&
883
- ts.isCallExpression(stmt.expression.expression)) {
884
- doCall = stmt.expression.expression;
885
- break;
886
- }
887
- }
888
- else if (ts.isReturnStatement(stmt) && stmt.expression) {
889
- if (ts.isCallExpression(stmt.expression)) {
890
- doCall = stmt.expression;
891
- break;
892
- }
893
- else if (ts.isAwaitExpression(stmt.expression)) {
894
- // Handle: return await workflow.do(...)
895
- if (ts.isCallExpression(stmt.expression.expression)) {
896
- doCall = stmt.expression.expression;
897
- break;
898
- }
899
- }
900
- }
901
- }
902
- }
903
- if (!doCall || !isWorkflowDoCall(doCall, context.checker)) {
904
- return null;
905
- }
906
- // Create a temporary context for the child step with the loop variable
1021
+ // Create a temporary context for the child steps with the loop variable
907
1022
  const childContext = {
908
1023
  ...context,
909
1024
  outputVars: new Map(context.outputVars),
1025
+ arrayVars: new Set(context.arrayVars),
910
1026
  loopVars: new Set([...context.loopVars, itemVar]),
911
1027
  };
912
- const childStep = extractRpcStep(doCall, childContext);
913
- if (!childStep) {
1028
+ const body = [];
1029
+ if (ts.isBlock(mapFn.body)) {
1030
+ for (const stmt of mapFn.body.statements) {
1031
+ extractFanoutBodyStep(stmt, childContext, body);
1032
+ }
1033
+ }
1034
+ else {
1035
+ // Concise body: (item) => workflow.do(...) / async (item) => await workflow.do(...)
1036
+ const expr = mapFn.body;
1037
+ const doCall = ts.isAwaitExpression(expr)
1038
+ ? ts.isCallExpression(expr.expression)
1039
+ ? expr.expression
1040
+ : null
1041
+ : ts.isCallExpression(expr)
1042
+ ? expr
1043
+ : null;
1044
+ if (doCall && isWorkflowDoCall(doCall, context.checker)) {
1045
+ const step = extractRpcStep(doCall, childContext);
1046
+ if (step) {
1047
+ body.push(step);
1048
+ }
1049
+ }
1050
+ }
1051
+ if (body.length === 0) {
914
1052
  return null;
915
1053
  }
916
1054
  return {
917
1055
  type: 'fanout',
918
- stepName: childStep.stepName,
919
1056
  sourceVar,
920
1057
  itemVar,
921
1058
  mode: 'parallel',
922
- child: childStep,
1059
+ body,
923
1060
  };
924
1061
  }
925
1062
  /**
@@ -959,55 +1096,28 @@ function extractSequentialFanout(statement, context) {
959
1096
  return null;
960
1097
  }
961
1098
  const { itemVar, sourceVar } = vars;
962
- // Extract child step and optional sleep from loop body
963
- if (!ts.isBlock(statement.statement)) {
964
- return null;
965
- }
966
- let childStep = null;
1099
+ // Extract child steps and optional sleep from the loop body. A brace-less
1100
+ // body (`for (const x of xs) await workflow.do(...)`) is a single statement
1101
+ // rather than a block, and must still be extracted.
1102
+ const bodyStatements = ts.isBlock(statement.statement)
1103
+ ? statement.statement.statements
1104
+ : [statement.statement];
1105
+ const body = [];
967
1106
  let timeBetween = undefined;
968
1107
  // Create a child context with the loop variable added
969
1108
  const childContext = {
970
1109
  ...context,
971
1110
  outputVars: new Map(context.outputVars),
1111
+ arrayVars: new Set(context.arrayVars),
972
1112
  loopVars: new Set([...context.loopVars, itemVar]),
973
1113
  };
974
- let workflowDoCount = 0;
975
- for (const stmt of statement.statement.statements) {
976
- // Look for workflow.do in VariableStatement (const x = await workflow.do(...))
977
- if (ts.isVariableStatement(stmt)) {
978
- const declList = stmt.declarationList;
979
- if (declList.declarations.length === 1) {
980
- const decl = declList.declarations[0];
981
- const init = decl.initializer;
982
- if (init &&
983
- ts.isAwaitExpression(init) &&
984
- ts.isCallExpression(init.expression)) {
985
- const call = init.expression;
986
- if (isWorkflowDoCall(call, context.checker)) {
987
- workflowDoCount++;
988
- const varName = ts.isIdentifier(decl.name)
989
- ? decl.name.text
990
- : undefined;
991
- const step = extractRpcStep(call, childContext, varName);
992
- if (step) {
993
- childStep = step;
994
- }
995
- }
996
- }
997
- }
998
- }
999
- // Look for workflow.do in ExpressionStatement (await workflow.do(...))
1114
+ for (const stmt of bodyStatements) {
1115
+ extractFanoutBodyStep(stmt, childContext, body);
1116
+ // Look for workflow.sleep in ExpressionStatement
1000
1117
  if (ts.isExpressionStatement(stmt)) {
1001
1118
  const expr = stmt.expression;
1002
1119
  if (ts.isAwaitExpression(expr) && ts.isCallExpression(expr.expression)) {
1003
1120
  const call = expr.expression;
1004
- if (isWorkflowDoCall(call, context.checker)) {
1005
- workflowDoCount++;
1006
- const step = extractRpcStep(call, childContext);
1007
- if (step) {
1008
- childStep = step;
1009
- }
1010
- }
1011
1121
  if (isWorkflowSleepCall(call, context.checker)) {
1012
1122
  // Extract duration for timeBetween
1013
1123
  const args = call.arguments;
@@ -1060,24 +1170,15 @@ function extractSequentialFanout(statement, context) {
1060
1170
  }
1061
1171
  }
1062
1172
  }
1063
- if (!childStep) {
1064
- return null;
1065
- }
1066
- // If there are multiple workflow.do calls, the loop is too complex for DSL
1067
- if (workflowDoCount > 1) {
1068
- context.errors.push({
1069
- message: `For-of loop has ${workflowDoCount} workflow.do calls but DSL only supports 1. Use pikkuWorkflowComplexFunc for complex loops.`,
1070
- node: statement,
1071
- });
1173
+ if (body.length === 0) {
1072
1174
  return null;
1073
1175
  }
1074
1176
  return {
1075
1177
  type: 'fanout',
1076
- stepName: childStep.stepName,
1077
1178
  sourceVar,
1078
1179
  itemVar,
1079
1180
  mode: 'sequential',
1080
- child: childStep,
1181
+ body,
1081
1182
  timeBetween,
1082
1183
  };
1083
1184
  }
@@ -1163,10 +1264,19 @@ function extractReturn(statement, context) {
1163
1264
  return extractSleepStep(call, context);
1164
1265
  }
1165
1266
  }
1267
+ // `return r` returns every field of r — record it as a spread of one.
1268
+ if (ts.isIdentifier(statement.expression)) {
1269
+ const varName = statement.expression.text;
1270
+ if (context.outputVars.has(varName) || context.contextVars.has(varName)) {
1271
+ return { type: 'return', outputs: {}, spread: [varName] };
1272
+ }
1273
+ return null;
1274
+ }
1166
1275
  if (!ts.isObjectLiteralExpression(statement.expression)) {
1167
1276
  return null;
1168
1277
  }
1169
1278
  const outputs = {};
1279
+ const spread = [];
1170
1280
  for (const prop of statement.expression.properties) {
1171
1281
  if (ts.isPropertyAssignment(prop) ||
1172
1282
  ts.isShorthandPropertyAssignment(prop)) {
@@ -1195,13 +1305,18 @@ function extractReturn(statement, context) {
1195
1305
  outputs[propName] = binding;
1196
1306
  }
1197
1307
  }
1308
+ else if (ts.isSpreadAssignment(prop) &&
1309
+ ts.isIdentifier(prop.expression)) {
1310
+ spread.push(prop.expression.text);
1311
+ }
1198
1312
  }
1199
- if (Object.keys(outputs).length === 0) {
1313
+ if (Object.keys(outputs).length === 0 && spread.length === 0) {
1200
1314
  return null;
1201
1315
  }
1202
1316
  return {
1203
1317
  type: 'return',
1204
1318
  outputs,
1319
+ ...(spread.length > 0 ? { spread } : {}),
1205
1320
  };
1206
1321
  }
1207
1322
  /**
@@ -1297,6 +1412,10 @@ function extractInputSource(node, context) {
1297
1412
  if (objName === context.inputParamName) {
1298
1413
  return { from: 'input', path: propName };
1299
1414
  }
1415
+ // A field of the fanout item: users.map((u) => do(..., { id: u.id }))
1416
+ if (context.loopVars.has(objName)) {
1417
+ return { from: 'item', path: propName };
1418
+ }
1300
1419
  if (context.outputVars.has(objName)) {
1301
1420
  return { from: 'outputVar', name: objName, path: propName };
1302
1421
  }
@@ -56,6 +56,7 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
56
56
  const node = {
57
57
  nodeId,
58
58
  rpcName: step.rpcName,
59
+ stepName: step.stepName,
59
60
  next: nextNodeId,
60
61
  };
61
62
  if (step.actor) {
@@ -82,16 +83,45 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
82
83
  if (step.options) {
83
84
  node.options = {
84
85
  retries: step.options.retries,
85
- retryDelay: step.options.retryDelay?.toString(),
86
+ retryDelay: step.options.retryDelay,
86
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
+ ];
103
+ }
88
104
  return [node];
89
105
  }
90
106
  case 'sleep': {
91
107
  const node = {
92
108
  nodeId,
93
109
  flow: 'sleep',
110
+ stepName: step.stepName,
94
111
  duration: step.duration,
112
+ expression: step.expression,
113
+ next: nextNodeId,
114
+ };
115
+ return [node];
116
+ }
117
+ case 'suspend': {
118
+ // A suspend yields no value, but it must still be a node: the previous
119
+ // step's `next` points at it, so omitting it dead-ends traversal and
120
+ // silently deletes every step that follows.
121
+ const node = {
122
+ nodeId,
123
+ flow: 'suspend',
124
+ reason: step.reason,
95
125
  next: nextNodeId,
96
126
  };
97
127
  return [node];
@@ -160,13 +190,28 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
160
190
  case 'switch': {
161
191
  const caseNodes = [];
162
192
  const cases = [];
193
+ // A case with no statements falls through to the next one. Dropping it
194
+ // would lose the value entirely, so it is held until an entry exists.
195
+ let fallingThrough = [];
163
196
  for (let i = 0; i < step.cases.length; i++) {
164
197
  const caseSteps = convertStepsToNodes(step.cases[i].steps, `${nodeId}_case${i}`);
165
- if (caseSteps.length > 0) {
198
+ if (caseSteps.length === 0) {
199
+ fallingThrough.push({
200
+ value: step.cases[i].value,
201
+ expression: step.cases[i].expression,
202
+ });
203
+ continue;
204
+ }
205
+ {
206
+ const entry = caseSteps[0].nodeId;
207
+ for (const pending of fallingThrough) {
208
+ cases.push({ ...pending, entry });
209
+ }
210
+ fallingThrough = [];
166
211
  cases.push({
167
212
  value: step.cases[i].value,
168
213
  expression: step.cases[i].expression,
169
- entry: caseSteps[0].nodeId,
214
+ entry,
170
215
  });
171
216
  // Link last case node to next (unless terminal flow)
172
217
  if (nextNodeId) {
@@ -191,6 +236,13 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
191
236
  caseNodes.push(...defaultNodes);
192
237
  }
193
238
  }
239
+ // Cases falling through past the last one land in default, or exit.
240
+ const trailingEntry = defaultEntry ?? nextNodeId;
241
+ if (trailingEntry) {
242
+ for (const pending of fallingThrough) {
243
+ cases.push({ ...pending, entry: trailingEntry });
244
+ }
245
+ }
194
246
  const node = {
195
247
  nodeId,
196
248
  flow: 'switch',
@@ -221,8 +273,11 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
221
273
  return [node, ...childNodes];
222
274
  }
223
275
  case 'fanout': {
224
- // Convert child step
225
- const childNodes = convertStepToNode(step.child, 0, [step.child], `${nodeId}_item`);
276
+ // Convert the per-iteration body into a `next`-chained sequence
277
+ const childNodes = [];
278
+ for (let i = 0; i < step.body.length; i++) {
279
+ childNodes.push(...convertStepToNode(step.body[i], i, step.body, `${nodeId}_item`));
280
+ }
226
281
  const node = {
227
282
  nodeId,
228
283
  flow: 'fanout',
@@ -265,6 +320,7 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
265
320
  nodeId,
266
321
  flow: 'return',
267
322
  outputs: step.outputs,
323
+ spread: step.spread,
268
324
  };
269
325
  return [node];
270
326
  }
@@ -282,6 +338,7 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
282
338
  flow: 'set',
283
339
  variable: step.variable,
284
340
  value: step.value,
341
+ expression: step.expression,
285
342
  next: nextNodeId,
286
343
  };
287
344
  return [node];
@@ -58,15 +58,15 @@ export type SerializedNext = string | string[] | {
58
58
  export interface NodeOptions {
59
59
  /** Number of retry attempts on failure */
60
60
  retries?: number;
61
- /** Delay between retries (e.g., '1s', '5s') */
62
- retryDelay?: string;
61
+ /** Delay between retries (e.g., '1s', '5s', or a number of milliseconds) */
62
+ retryDelay?: string | number;
63
63
  /** Timeout for node execution (e.g., '30s', '5m') */
64
64
  timeout?: string;
65
65
  }
66
66
  /**
67
67
  * Flow node types for control flow (no RPC call)
68
68
  */
69
- export type FlowType = 'sleep' | 'approval' | 'branch' | 'parallel' | 'fanout' | 'inline' | 'switch' | 'filter' | 'arrayPredicate' | 'return' | 'cancel' | 'set';
69
+ export type FlowType = 'sleep' | 'suspend' | 'approval' | 'branch' | 'parallel' | 'fanout' | 'inline' | 'switch' | 'filter' | 'arrayPredicate' | 'return' | 'cancel' | 'set';
70
70
  import type { ContextVariable, WorkflowContext, WorkflowPlannedStep } from '@pikku/core/workflow';
71
71
  export type { ContextVariable, WorkflowContext };
72
72
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.43",
3
+ "version": "0.12.45",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",
@@ -44,10 +44,10 @@
44
44
  "zod-to-ts": "^2.1.0"
45
45
  },
46
46
  "peerDependencies": {
47
- "@pikku/core": "^0.12.64"
47
+ "@pikku/core": "^0.12.67"
48
48
  },
49
49
  "devDependencies": {
50
- "@pikku/core": "^0.12.64",
50
+ "@pikku/core": "^0.12.67",
51
51
  "@types/node": "^24.11.0"
52
52
  },
53
53
  "engines": {
package/run-tests.sh CHANGED
@@ -38,7 +38,7 @@ if [ ${#files[@]} -eq 0 ]; then
38
38
  fi
39
39
 
40
40
  # Construct the node command
41
- node_cmd=(node --import tsx --test)
41
+ node_cmd=(node --import tsx --expose-gc --test)
42
42
 
43
43
  # Append options based on flags
44
44
  if [ "$watch_mode" = true ]; then
@@ -190,11 +190,11 @@ describe('addAuth inspector', () => {
190
190
  [
191
191
  "import { pikkuBetterAuth } from '@pikku/better-auth'",
192
192
  "import { betterAuth } from 'better-auth'",
193
- "import { bearer, admin, twoFactor } from 'better-auth/plugins'",
193
+ "import { bearer, apiKey, twoFactor } from 'better-auth/plugins'",
194
194
  'export const auth = pikkuBetterAuth(() =>',
195
195
  ' betterAuth({',
196
196
  " socialProviders: { github: { clientId: 'x', clientSecret: 'y' } },",
197
- ' plugins: [bearer(), admin(), twoFactor({ issuer: "pikku" })],',
197
+ ' plugins: [bearer(), apiKey(), twoFactor({ issuer: "pikku" })],',
198
198
  ' })',
199
199
  ')',
200
200
  ].join('\n')
@@ -204,10 +204,10 @@ describe('addAuth inspector', () => {
204
204
  try {
205
205
  const state = await inspect(makeLogger(criticals), [file], { rootDir })
206
206
  assert.equal(criticals.length, 0)
207
- assert.deepEqual(state.auth.plugins, ['bearer', 'admin', 'twoFactor'])
207
+ assert.deepEqual(state.auth.plugins, ['bearer', 'apiKey', 'twoFactor'])
208
208
  assert.deepEqual(state.auth.definition?.plugins, [
209
209
  'bearer',
210
- 'admin',
210
+ 'apiKey',
211
211
  'twoFactor',
212
212
  ])
213
213
  } finally {
@@ -45,6 +45,7 @@ export const addCredential: AddWiring = (
45
45
  | string
46
46
  | null
47
47
  const typeValue = getPropertyValue(obj, 'type') as string | null
48
+ const docsUrlValue = getPropertyValue(obj, 'docsUrl') as string | null
48
49
 
49
50
  if (!nameValue) {
50
51
  logger.critical(
@@ -179,6 +180,7 @@ export const addCredential: AddWiring = (
179
180
  displayName: displayNameValue,
180
181
  description: descriptionValue || undefined,
181
182
  type: typeValue as 'singleton' | 'wire',
183
+ docsUrl: docsUrlValue || undefined,
182
184
  schema: schemaLookupName,
183
185
  oauth2,
184
186
  sourceFile,
@@ -56,6 +56,7 @@ export const createAddKeyedWiring = (config: KeyedWiringConfig): AddWiring => {
56
56
  const rotationPeriodValue = getPropertyValue(obj, 'rotationPeriod') as
57
57
  | string
58
58
  | null
59
+ const docsUrlValue = getPropertyValue(obj, 'docsUrl') as string | null
59
60
  const idValue = getPropertyValue(obj, config.idField) as string | null
60
61
 
61
62
  let schemaVariableName: string | null = null
@@ -165,6 +166,7 @@ export const createAddKeyedWiring = (config: KeyedWiringConfig): AddWiring => {
165
166
  displayName: displayNameValue,
166
167
  description: descriptionValue || undefined,
167
168
  rotationPeriod: rotationPeriodValue || undefined,
169
+ docsUrl: docsUrlValue || undefined,
168
170
  [config.idField]: idValue,
169
171
  schema: schemaLookupName,
170
172
  sourceFile,