@pikku/inspector 0.12.42 → 0.12.44

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 (126) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/dist/add/add-addon-bans.js +1 -0
  3. package/dist/add/add-ai-agent.d.ts +3 -1
  4. package/dist/add/add-ai-agent.js +82 -0
  5. package/dist/add/add-credential.js +5 -1
  6. package/dist/add/add-functions.js +10 -9
  7. package/dist/add/add-gateway.js +3 -0
  8. package/dist/add/add-http-route.js +2 -2
  9. package/dist/add/add-keyed-wiring.js +2 -0
  10. package/dist/add/add-mcp-prompt.js +0 -1
  11. package/dist/add/add-mcp-resource.js +0 -1
  12. package/dist/add/add-permission.d.ts +1 -1
  13. package/dist/add/add-permission.js +3 -177
  14. package/dist/add/add-queue-worker.js +3 -0
  15. package/dist/add/add-schedule.js +3 -0
  16. package/dist/add/add-scope.d.ts +2 -0
  17. package/dist/add/add-scope.js +146 -0
  18. package/dist/add/add-trigger.js +3 -0
  19. package/dist/add/add-wire-remote-addon.d.ts +10 -0
  20. package/dist/add/add-wire-remote-addon.js +66 -0
  21. package/dist/add/add-workflow-graph.js +32 -3
  22. package/dist/add/add-workflow.js +9 -4
  23. package/dist/error-codes.d.ts +3 -0
  24. package/dist/error-codes.js +4 -0
  25. package/dist/inspector.js +11 -5
  26. package/dist/types.d.ts +19 -13
  27. package/dist/utils/custom-types-generator.js +2 -1
  28. package/dist/utils/ensure-function-metadata.d.ts +15 -0
  29. package/dist/utils/ensure-function-metadata.js +24 -0
  30. package/dist/utils/filter-inspector-state.js +9 -0
  31. package/dist/utils/get-property-value.d.ts +7 -2
  32. package/dist/utils/get-property-value.js +48 -1
  33. package/dist/utils/load-addon-functions-meta.js +81 -6
  34. package/dist/utils/permissions.d.ts +1 -21
  35. package/dist/utils/permissions.js +17 -108
  36. package/dist/utils/post-process.d.ts +30 -1
  37. package/dist/utils/post-process.js +150 -72
  38. package/dist/utils/schema-generator.js +19 -4
  39. package/dist/utils/serialize-inspector-state.d.ts +13 -8
  40. package/dist/utils/serialize-inspector-state.js +12 -6
  41. package/dist/utils/serialize-permissions-groups-meta.d.ts +0 -2
  42. package/dist/utils/serialize-permissions-groups-meta.js +0 -26
  43. package/dist/utils/workflow/derive-workflow-plan.js +9 -3
  44. package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +373 -91
  45. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +274 -100
  46. package/dist/utils/workflow/dsl/patterns.d.ts +4 -0
  47. package/dist/utils/workflow/dsl/patterns.js +11 -0
  48. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +76 -5
  49. package/dist/utils/workflow/graph/finalize-workflows.d.ts +7 -0
  50. package/dist/utils/workflow/graph/finalize-workflows.js +11 -2
  51. package/dist/utils/workflow/graph/serialize-workflow-graph.d.ts +2 -0
  52. package/dist/utils/workflow/graph/serialize-workflow-graph.js +4 -0
  53. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +8 -4
  54. package/dist/visit.js +4 -0
  55. package/package.json +3 -3
  56. package/run-tests.sh +1 -1
  57. package/src/add/add-addon-bans.ts +1 -0
  58. package/src/add/add-ai-agent.test.ts +87 -0
  59. package/src/add/add-ai-agent.ts +122 -0
  60. package/src/add/add-credential.ts +8 -0
  61. package/src/add/add-functions.ts +10 -12
  62. package/src/add/add-gateway.ts +11 -0
  63. package/src/add/add-http-route.ts +2 -8
  64. package/src/add/add-keyed-wiring.ts +2 -0
  65. package/src/add/add-mcp-prompt.ts +0 -1
  66. package/src/add/add-mcp-resource.ts +0 -1
  67. package/src/add/add-permission.ts +4 -242
  68. package/src/add/add-queue-worker.ts +11 -0
  69. package/src/add/add-schedule.ts +11 -0
  70. package/src/add/add-scope.test.ts +346 -0
  71. package/src/add/add-scope.ts +225 -0
  72. package/src/add/add-trigger.ts +11 -0
  73. package/src/add/add-wire-remote-addon.ts +77 -0
  74. package/src/add/add-workflow-edge-cases.test.ts +201 -0
  75. package/src/add/add-workflow-fanout.test.ts +60 -0
  76. package/src/add/add-workflow-graph-input.test.ts +94 -0
  77. package/src/add/add-workflow-graph-notes.test.ts +74 -0
  78. package/src/add/add-workflow-graph.ts +35 -3
  79. package/src/add/add-workflow-on-error.test.ts +118 -0
  80. package/src/add/add-workflow-return-switch.test.ts +121 -0
  81. package/src/add/add-workflow-silent-drops.test.ts +163 -0
  82. package/src/add/add-workflow-sleep-expression.test.ts +97 -0
  83. package/src/add/add-workflow.ts +9 -4
  84. package/src/add/config-value-metadata.test.ts +169 -0
  85. package/src/add/inline-wiring-function.test.ts +104 -0
  86. package/src/add/validate-workflow-graph-addons.test.ts +102 -0
  87. package/src/error-codes.ts +5 -0
  88. package/src/inspector.ts +14 -4
  89. package/src/types.ts +16 -17
  90. package/src/utils/custom-types-generator.test.ts +26 -1
  91. package/src/utils/custom-types-generator.ts +2 -1
  92. package/src/utils/ensure-function-metadata.ts +42 -0
  93. package/src/utils/filter-inspector-state.test.ts +0 -2
  94. package/src/utils/filter-inspector-state.ts +9 -0
  95. package/src/utils/get-property-value.test.ts +141 -0
  96. package/src/utils/get-property-value.ts +62 -1
  97. package/src/utils/load-addon-functions-meta.test.ts +157 -0
  98. package/src/utils/load-addon-functions-meta.ts +94 -6
  99. package/src/utils/load-addon-scopes.test.ts +138 -0
  100. package/src/utils/permissions.test.ts +7 -232
  101. package/src/utils/permissions.ts +20 -160
  102. package/src/utils/post-process.test.ts +269 -4
  103. package/src/utils/post-process.ts +216 -95
  104. package/src/utils/schema-generator.test.ts +164 -0
  105. package/src/utils/schema-generator.ts +26 -3
  106. package/src/utils/serialize-inspector-state.ts +22 -25
  107. package/src/utils/serialize-permissions-groups-meta.ts +0 -28
  108. package/src/utils/workflow/derive-workflow-plan.test.ts +41 -3
  109. package/src/utils/workflow/derive-workflow-plan.ts +11 -4
  110. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +480 -119
  111. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
  112. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
  113. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
  114. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
  115. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
  116. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
  117. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +311 -105
  118. package/src/utils/workflow/dsl/patterns.ts +18 -0
  119. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +82 -10
  120. package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
  121. package/src/utils/workflow/graph/finalize-workflows.test.ts +50 -0
  122. package/src/utils/workflow/graph/finalize-workflows.ts +13 -2
  123. package/src/utils/workflow/graph/serialize-workflow-graph.ts +6 -0
  124. package/src/utils/workflow/graph/workflow-graph.types.ts +11 -2
  125. package/src/visit.ts +4 -0
  126. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
2
+ import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, 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
  /**
@@ -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
  }
@@ -363,6 +416,19 @@ function extractExpressionStatement(statement, context) {
363
416
  if (isWorkflowSuspendCall(call, context.checker)) {
364
417
  return extractSuspendStep(call, context);
365
418
  }
419
+ if (isWorkflowApprovalCall(call, context.checker)) {
420
+ const step = extractApprovalStep(call, context, outputVar);
421
+ // Unlike suspend, an approval yields a value, so a downstream step can
422
+ // reference it — track the binding the same way workflow.do() does.
423
+ if (outputVar && step) {
424
+ const type = context.checker.getTypeAtLocation(expr);
425
+ context.outputVars.set(outputVar, { type, node: expr });
426
+ if (isArrayType(type, context.checker)) {
427
+ context.arrayVars.add(outputVar);
428
+ }
429
+ }
430
+ return step;
431
+ }
366
432
  // Check for parallel group or fanout
367
433
  if (isParallelFanout(call)) {
368
434
  return extractParallelFanout(call, context);
@@ -477,6 +543,17 @@ function extractStepOptions(optionsNode, context) {
477
543
  // Ignore extraction errors for retryDelay
478
544
  }
479
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
+ }
480
557
  else if (propName === 'description') {
481
558
  try {
482
559
  options.description = extractStringLiteral(prop.initializer, context.checker);
@@ -505,7 +582,20 @@ function extractSleepStep(call, context) {
505
582
  duration = numValue;
506
583
  }
507
584
  else {
508
- 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
+ }
509
599
  }
510
600
  return {
511
601
  type: 'sleep',
@@ -543,6 +633,48 @@ function extractSuspendStep(call, context) {
543
633
  return null;
544
634
  }
545
635
  }
636
+ /**
637
+ * Extract approval step from workflow.approval() call
638
+ */
639
+ function extractApprovalStep(call, context, outputVar) {
640
+ const args = call.arguments;
641
+ if (args.length < 1)
642
+ return null;
643
+ try {
644
+ const reason = extractStringLiteral(args[0], context.checker);
645
+ const step = {
646
+ type: 'approval',
647
+ reason,
648
+ };
649
+ if (outputVar) {
650
+ step.outputVar = outputVar;
651
+ }
652
+ // The `schema` option is a runtime value validated inside the workflow body,
653
+ // so it is deliberately not serialized here — only `expiry`, which the graph
654
+ // and planned-step ladder need in order to describe the gate.
655
+ const options = args[1];
656
+ if (options && ts.isObjectLiteralExpression(options)) {
657
+ for (const prop of options.properties) {
658
+ if (ts.isPropertyAssignment(prop) &&
659
+ prop.name.getText() === 'expiry' &&
660
+ (ts.isStringLiteral(prop.initializer) ||
661
+ ts.isNumericLiteral(prop.initializer))) {
662
+ step.expiry = ts.isNumericLiteral(prop.initializer)
663
+ ? Number(prop.initializer.text)
664
+ : prop.initializer.text;
665
+ }
666
+ }
667
+ }
668
+ return step;
669
+ }
670
+ catch (error) {
671
+ context.errors.push({
672
+ message: `Failed to extract approval step: ${error instanceof Error ? error.message : String(error)}`,
673
+ node: call,
674
+ });
675
+ return null;
676
+ }
677
+ }
546
678
  /**
547
679
  * Extract cancel step from throw WorkflowCancelledException statement
548
680
  */
@@ -780,6 +912,84 @@ function extractArrayPredicate(call, context, outputVar) {
780
912
  outputVar,
781
913
  };
782
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
+ }
783
993
  /**
784
994
  * Extract parallel fanout from Promise.all(array.map(...))
785
995
  */
@@ -808,63 +1018,45 @@ function extractParallelFanout(call, context) {
808
1018
  return null;
809
1019
  }
810
1020
  const itemVar = itemParam.name.text;
811
- // Extract workflow.do call from map body
812
- let doCall = null;
813
- if (ts.isCallExpression(mapFn.body)) {
814
- doCall = mapFn.body;
815
- }
816
- else if (ts.isAwaitExpression(mapFn.body)) {
817
- // Handle: async (email) => await workflow.do(...)
818
- if (ts.isCallExpression(mapFn.body.expression)) {
819
- doCall = mapFn.body.expression;
820
- }
821
- }
822
- else if (ts.isBlock(mapFn.body)) {
823
- // Look for workflow.do in block
824
- for (const stmt of mapFn.body.statements) {
825
- if (ts.isExpressionStatement(stmt)) {
826
- // Handle: await workflow.do(...)
827
- if (ts.isAwaitExpression(stmt.expression) &&
828
- ts.isCallExpression(stmt.expression.expression)) {
829
- doCall = stmt.expression.expression;
830
- break;
831
- }
832
- }
833
- else if (ts.isReturnStatement(stmt) && stmt.expression) {
834
- if (ts.isCallExpression(stmt.expression)) {
835
- doCall = stmt.expression;
836
- break;
837
- }
838
- else if (ts.isAwaitExpression(stmt.expression)) {
839
- // Handle: return await workflow.do(...)
840
- if (ts.isCallExpression(stmt.expression.expression)) {
841
- doCall = stmt.expression.expression;
842
- break;
843
- }
844
- }
845
- }
846
- }
847
- }
848
- if (!doCall || !isWorkflowDoCall(doCall, context.checker)) {
849
- return null;
850
- }
851
- // 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
852
1022
  const childContext = {
853
1023
  ...context,
854
1024
  outputVars: new Map(context.outputVars),
1025
+ arrayVars: new Set(context.arrayVars),
855
1026
  loopVars: new Set([...context.loopVars, itemVar]),
856
1027
  };
857
- const childStep = extractRpcStep(doCall, childContext);
858
- 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) {
859
1052
  return null;
860
1053
  }
861
1054
  return {
862
1055
  type: 'fanout',
863
- stepName: childStep.stepName,
864
1056
  sourceVar,
865
1057
  itemVar,
866
1058
  mode: 'parallel',
867
- child: childStep,
1059
+ body,
868
1060
  };
869
1061
  }
870
1062
  /**
@@ -904,55 +1096,28 @@ function extractSequentialFanout(statement, context) {
904
1096
  return null;
905
1097
  }
906
1098
  const { itemVar, sourceVar } = vars;
907
- // Extract child step and optional sleep from loop body
908
- if (!ts.isBlock(statement.statement)) {
909
- return null;
910
- }
911
- 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 = [];
912
1106
  let timeBetween = undefined;
913
1107
  // Create a child context with the loop variable added
914
1108
  const childContext = {
915
1109
  ...context,
916
1110
  outputVars: new Map(context.outputVars),
1111
+ arrayVars: new Set(context.arrayVars),
917
1112
  loopVars: new Set([...context.loopVars, itemVar]),
918
1113
  };
919
- let workflowDoCount = 0;
920
- for (const stmt of statement.statement.statements) {
921
- // Look for workflow.do in VariableStatement (const x = await workflow.do(...))
922
- if (ts.isVariableStatement(stmt)) {
923
- const declList = stmt.declarationList;
924
- if (declList.declarations.length === 1) {
925
- const decl = declList.declarations[0];
926
- const init = decl.initializer;
927
- if (init &&
928
- ts.isAwaitExpression(init) &&
929
- ts.isCallExpression(init.expression)) {
930
- const call = init.expression;
931
- if (isWorkflowDoCall(call, context.checker)) {
932
- workflowDoCount++;
933
- const varName = ts.isIdentifier(decl.name)
934
- ? decl.name.text
935
- : undefined;
936
- const step = extractRpcStep(call, childContext, varName);
937
- if (step) {
938
- childStep = step;
939
- }
940
- }
941
- }
942
- }
943
- }
944
- // 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
945
1117
  if (ts.isExpressionStatement(stmt)) {
946
1118
  const expr = stmt.expression;
947
1119
  if (ts.isAwaitExpression(expr) && ts.isCallExpression(expr.expression)) {
948
1120
  const call = expr.expression;
949
- if (isWorkflowDoCall(call, context.checker)) {
950
- workflowDoCount++;
951
- const step = extractRpcStep(call, childContext);
952
- if (step) {
953
- childStep = step;
954
- }
955
- }
956
1121
  if (isWorkflowSleepCall(call, context.checker)) {
957
1122
  // Extract duration for timeBetween
958
1123
  const args = call.arguments;
@@ -1005,24 +1170,15 @@ function extractSequentialFanout(statement, context) {
1005
1170
  }
1006
1171
  }
1007
1172
  }
1008
- if (!childStep) {
1009
- return null;
1010
- }
1011
- // If there are multiple workflow.do calls, the loop is too complex for DSL
1012
- if (workflowDoCount > 1) {
1013
- context.errors.push({
1014
- message: `For-of loop has ${workflowDoCount} workflow.do calls but DSL only supports 1. Use pikkuWorkflowComplexFunc for complex loops.`,
1015
- node: statement,
1016
- });
1173
+ if (body.length === 0) {
1017
1174
  return null;
1018
1175
  }
1019
1176
  return {
1020
1177
  type: 'fanout',
1021
- stepName: childStep.stepName,
1022
1178
  sourceVar,
1023
1179
  itemVar,
1024
1180
  mode: 'sequential',
1025
- child: childStep,
1181
+ body,
1026
1182
  timeBetween,
1027
1183
  };
1028
1184
  }
@@ -1108,10 +1264,19 @@ function extractReturn(statement, context) {
1108
1264
  return extractSleepStep(call, context);
1109
1265
  }
1110
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
+ }
1111
1275
  if (!ts.isObjectLiteralExpression(statement.expression)) {
1112
1276
  return null;
1113
1277
  }
1114
1278
  const outputs = {};
1279
+ const spread = [];
1115
1280
  for (const prop of statement.expression.properties) {
1116
1281
  if (ts.isPropertyAssignment(prop) ||
1117
1282
  ts.isShorthandPropertyAssignment(prop)) {
@@ -1140,13 +1305,18 @@ function extractReturn(statement, context) {
1140
1305
  outputs[propName] = binding;
1141
1306
  }
1142
1307
  }
1308
+ else if (ts.isSpreadAssignment(prop) &&
1309
+ ts.isIdentifier(prop.expression)) {
1310
+ spread.push(prop.expression.text);
1311
+ }
1143
1312
  }
1144
- if (Object.keys(outputs).length === 0) {
1313
+ if (Object.keys(outputs).length === 0 && spread.length === 0) {
1145
1314
  return null;
1146
1315
  }
1147
1316
  return {
1148
1317
  type: 'return',
1149
1318
  outputs,
1319
+ ...(spread.length > 0 ? { spread } : {}),
1150
1320
  };
1151
1321
  }
1152
1322
  /**
@@ -1242,6 +1412,10 @@ function extractInputSource(node, context) {
1242
1412
  if (objName === context.inputParamName) {
1243
1413
  return { from: 'input', path: propName };
1244
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
+ }
1245
1419
  if (context.outputVars.has(objName)) {
1246
1420
  return { from: 'outputVar', name: objName, path: propName };
1247
1421
  }
@@ -31,6 +31,10 @@ export declare function isWorkflowSleepCall(node: ts.CallExpression, checker: ts
31
31
  * Check if a call expression is workflow.suspend()
32
32
  */
33
33
  export declare function isWorkflowSuspendCall(node: ts.CallExpression, _checker: ts.TypeChecker): boolean;
34
+ /**
35
+ * Check if a call expression is workflow.approval()
36
+ */
37
+ export declare function isWorkflowApprovalCall(node: ts.CallExpression, _checker: ts.TypeChecker): boolean;
34
38
  /**
35
39
  * Check if a throw statement throws WorkflowCancelledException
36
40
  * Matches: throw new WorkflowCancelledException(...) or throw WorkflowCancelledException(...)
@@ -78,6 +78,17 @@ export function isWorkflowSuspendCall(node, _checker) {
78
78
  return (propAccess.name.text === 'suspend' &&
79
79
  isWorkflowWireIdentifier(propAccess.expression));
80
80
  }
81
+ /**
82
+ * Check if a call expression is workflow.approval()
83
+ */
84
+ export function isWorkflowApprovalCall(node, _checker) {
85
+ if (!ts.isPropertyAccessExpression(node.expression)) {
86
+ return false;
87
+ }
88
+ const propAccess = node.expression;
89
+ return (propAccess.name.text === 'approval' &&
90
+ isWorkflowWireIdentifier(propAccess.expression));
91
+ }
81
92
  /**
82
93
  * Check if a throw statement throws WorkflowCancelledException
83
94
  * Matches: throw new WorkflowCancelledException(...) or throw WorkflowCancelledException(...)
@@ -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,59 @@ 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,
125
+ next: nextNodeId,
126
+ };
127
+ return [node];
128
+ }
129
+ case 'approval': {
130
+ // Unlike `suspend` — which has no output and so is deliberately absent
131
+ // from the graph — an approval yields a value a later step can reference,
132
+ // so it must be a real node for $ref resolution and graphHash to see it.
133
+ const node = {
134
+ nodeId,
135
+ flow: 'approval',
136
+ reason: step.reason,
137
+ outputVar: step.outputVar,
138
+ expiry: step.expiry,
95
139
  next: nextNodeId,
96
140
  };
97
141
  return [node];
@@ -146,13 +190,28 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
146
190
  case 'switch': {
147
191
  const caseNodes = [];
148
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 = [];
149
196
  for (let i = 0; i < step.cases.length; i++) {
150
197
  const caseSteps = convertStepsToNodes(step.cases[i].steps, `${nodeId}_case${i}`);
151
- 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 = [];
152
211
  cases.push({
153
212
  value: step.cases[i].value,
154
213
  expression: step.cases[i].expression,
155
- entry: caseSteps[0].nodeId,
214
+ entry,
156
215
  });
157
216
  // Link last case node to next (unless terminal flow)
158
217
  if (nextNodeId) {
@@ -177,6 +236,13 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
177
236
  caseNodes.push(...defaultNodes);
178
237
  }
179
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
+ }
180
246
  const node = {
181
247
  nodeId,
182
248
  flow: 'switch',
@@ -207,8 +273,11 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
207
273
  return [node, ...childNodes];
208
274
  }
209
275
  case 'fanout': {
210
- // Convert child step
211
- 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
+ }
212
281
  const node = {
213
282
  nodeId,
214
283
  flow: 'fanout',
@@ -251,6 +320,7 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
251
320
  nodeId,
252
321
  flow: 'return',
253
322
  outputs: step.outputs,
323
+ spread: step.spread,
254
324
  };
255
325
  return [node];
256
326
  }
@@ -268,6 +338,7 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
268
338
  flow: 'set',
269
339
  variable: step.variable,
270
340
  value: step.value,
341
+ expression: step.expression,
271
342
  next: nextNodeId,
272
343
  };
273
344
  return [node];