@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
@@ -8,6 +8,8 @@ import type {
8
8
  FanoutStepMeta,
9
9
  CancelStepMeta,
10
10
  SuspendStepMeta,
11
+ SleepStepMeta,
12
+ ApprovalStepMeta,
11
13
  SetStepMeta,
12
14
  SwitchStepMeta,
13
15
  SwitchCaseMeta,
@@ -25,6 +27,7 @@ import {
25
27
  extractActorFromOptions,
26
28
  isWorkflowSleepCall,
27
29
  isWorkflowSuspendCall,
30
+ isWorkflowApprovalCall,
28
31
  isThrowCancelException,
29
32
  extractCancelReason,
30
33
  isParallelFanout,
@@ -320,12 +323,16 @@ function extractVariableDeclaration(
320
323
  ): WorkflowStepMeta | null {
321
324
  const declList = statement.declarationList
322
325
  if (declList.declarations.length !== 1) {
326
+ context.errors.push({
327
+ message: `A single declaration statement may only declare one variable in DSL workflows. Split '${statement.getText().slice(0, 60)}' into separate statements.`,
328
+ node: statement,
329
+ })
323
330
  return null
324
331
  }
325
332
 
326
333
  const decl = declList.declarations[0]
327
334
  if (!ts.isIdentifier(decl.name)) {
328
- return null
335
+ return extractDestructuredDeclaration(statement, decl, context)
329
336
  }
330
337
 
331
338
  const varName = decl.name.text
@@ -435,6 +442,70 @@ function extractVariableDeclaration(
435
442
  return null
436
443
  }
437
444
 
445
+ /**
446
+ * Extract a declaration whose binding is a destructuring pattern.
447
+ *
448
+ * `const [a, b] = await Promise.all([...])` is the idiomatic way to run steps
449
+ * in parallel and keep both results, so each element of the pattern is bound to
450
+ * the matching child step's output. Every other destructuring shape reports a
451
+ * diagnostic rather than silently dropping the step.
452
+ */
453
+ function extractDestructuredDeclaration(
454
+ statement: ts.VariableStatement,
455
+ decl: ts.VariableDeclaration,
456
+ context: ExtractionContext
457
+ ): WorkflowStepMeta | null {
458
+ const init = decl.initializer
459
+
460
+ if (
461
+ init &&
462
+ ts.isAwaitExpression(init) &&
463
+ ts.isCallExpression(init.expression) &&
464
+ isParallelGroup(init.expression) &&
465
+ ts.isArrayBindingPattern(decl.name)
466
+ ) {
467
+ const elements = decl.name.elements
468
+ const allSimple = elements.every(
469
+ (el) =>
470
+ ts.isBindingElement(el) &&
471
+ ts.isIdentifier(el.name) &&
472
+ !el.dotDotDotToken
473
+ )
474
+
475
+ if (allSimple) {
476
+ const step = extractParallelGroup(init.expression, context)
477
+ if (step) {
478
+ if (elements.length !== step.children.length) {
479
+ context.errors.push({
480
+ 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.`,
481
+ node: statement,
482
+ })
483
+ return null
484
+ }
485
+
486
+ elements.forEach((el, i) => {
487
+ const name = (el as ts.BindingElement).name as ts.Identifier
488
+ const varName = name.text
489
+ step.children[i].outputVar = varName
490
+ const type = context.checker.getTypeAtLocation(name)
491
+ context.outputVars.set(varName, { type, node: name })
492
+ if (isArrayType(type, context.checker)) {
493
+ context.arrayVars.add(varName)
494
+ }
495
+ })
496
+
497
+ return step
498
+ }
499
+ }
500
+ }
501
+
502
+ context.errors.push({
503
+ 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.`,
504
+ node: statement,
505
+ })
506
+ return null
507
+ }
508
+
438
509
  /**
439
510
  * Extract expression statement (await workflow.do(...) without assignment)
440
511
  */
@@ -476,11 +547,11 @@ function extractExpressionStatement(
476
547
  value: literalValue,
477
548
  } as SetStepMeta
478
549
  }
479
- // Non-literal assignment to context var - use expression as string
550
+ // Non-literal assignment to context var - keep the source expression
480
551
  return {
481
552
  type: 'set',
482
553
  variable: outputVar,
483
- value: getSourceText(expr.right),
554
+ expression: getSourceText(expr.right),
484
555
  } as SetStepMeta
485
556
  }
486
557
  }
@@ -520,6 +591,22 @@ function extractExpressionStatement(
520
591
  return extractSuspendStep(call, context)
521
592
  }
522
593
 
594
+ if (isWorkflowApprovalCall(call, context.checker)) {
595
+ const step = extractApprovalStep(call, context, outputVar)
596
+
597
+ // Unlike suspend, an approval yields a value, so a downstream step can
598
+ // reference it — track the binding the same way workflow.do() does.
599
+ if (outputVar && step) {
600
+ const type = context.checker.getTypeAtLocation(expr)
601
+ context.outputVars.set(outputVar, { type, node: expr })
602
+ if (isArrayType(type, context.checker)) {
603
+ context.arrayVars.add(outputVar)
604
+ }
605
+ }
606
+
607
+ return step
608
+ }
609
+
523
610
  // Check for parallel group or fanout
524
611
  if (isParallelFanout(call)) {
525
612
  return extractParallelFanout(call, context)
@@ -659,6 +746,16 @@ function extractStepOptions(
659
746
  } catch {
660
747
  // Ignore extraction errors for retryDelay
661
748
  }
749
+ } else if (propName === 'onError') {
750
+ if (ts.isStringLiteral(prop.initializer)) {
751
+ options.onError = prop.initializer.text
752
+ } else {
753
+ context.errors.push({
754
+ message:
755
+ 'onError must be a literal RPC name so it can be wired and drawn in the graph.',
756
+ node: prop.initializer,
757
+ })
758
+ }
662
759
  } else if (propName === 'description') {
663
760
  try {
664
761
  options.description = extractStringLiteral(
@@ -696,7 +793,19 @@ function extractSleepStep(
696
793
  if (numValue !== null) {
697
794
  duration = numValue
698
795
  } else {
699
- duration = extractStringLiteral(args[1], context.checker)
796
+ try {
797
+ duration = extractStringLiteral(args[1], context.checker)
798
+ } catch {
799
+ // A duration computed at runtime (a loop variable, a field off the
800
+ // input) is legal: the closure evaluates it. Record the source text so
801
+ // the graph can show what it waits on, rather than failing the workflow.
802
+ return {
803
+ type: 'sleep',
804
+ stepName,
805
+ duration: '',
806
+ expression: args[1].getText(),
807
+ }
808
+ }
700
809
  }
701
810
 
702
811
  return {
@@ -738,6 +847,54 @@ function extractSuspendStep(
738
847
  }
739
848
  }
740
849
 
850
+ /**
851
+ * Extract approval step from workflow.approval() call
852
+ */
853
+ function extractApprovalStep(
854
+ call: ts.CallExpression,
855
+ context: ExtractionContext,
856
+ outputVar?: string
857
+ ): ApprovalStepMeta | null {
858
+ const args = call.arguments
859
+ if (args.length < 1) return null
860
+
861
+ try {
862
+ const reason = extractStringLiteral(args[0], context.checker)
863
+ const step: ApprovalStepMeta = {
864
+ type: 'approval',
865
+ reason,
866
+ }
867
+ if (outputVar) {
868
+ step.outputVar = outputVar
869
+ }
870
+ // The `schema` option is a runtime value validated inside the workflow body,
871
+ // so it is deliberately not serialized here — only `expiry`, which the graph
872
+ // and planned-step ladder need in order to describe the gate.
873
+ const options = args[1]
874
+ if (options && ts.isObjectLiteralExpression(options)) {
875
+ for (const prop of options.properties) {
876
+ if (
877
+ ts.isPropertyAssignment(prop) &&
878
+ prop.name.getText() === 'expiry' &&
879
+ (ts.isStringLiteral(prop.initializer) ||
880
+ ts.isNumericLiteral(prop.initializer))
881
+ ) {
882
+ step.expiry = ts.isNumericLiteral(prop.initializer)
883
+ ? Number(prop.initializer.text)
884
+ : prop.initializer.text
885
+ }
886
+ }
887
+ }
888
+ return step
889
+ } catch (error) {
890
+ context.errors.push({
891
+ message: `Failed to extract approval step: ${error instanceof Error ? error.message : String(error)}`,
892
+ node: call,
893
+ })
894
+ return null
895
+ }
896
+ }
897
+
741
898
  /**
742
899
  * Extract cancel step from throw WorkflowCancelledException statement
743
900
  */
@@ -1037,6 +1194,92 @@ function extractArrayPredicate(
1037
1194
  }
1038
1195
  }
1039
1196
 
1197
+ /**
1198
+ * Extract an ordered list of workflow.do steps from a per-iteration body.
1199
+ *
1200
+ * Unlike if/switch blocks, a fanout body is a real per-iteration scope, so
1201
+ * `const x = await workflow.do(...)` is meaningful here: the binding is
1202
+ * registered on the child context so later steps in the same iteration can
1203
+ * reference it.
1204
+ */
1205
+ function extractFanoutBodyStep(
1206
+ stmt: ts.Statement,
1207
+ childContext: ExtractionContext,
1208
+ body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>
1209
+ ): void {
1210
+ if (ts.isVariableStatement(stmt)) {
1211
+ const declList = stmt.declarationList
1212
+ if (declList.declarations.length !== 1) {
1213
+ return
1214
+ }
1215
+ const decl = declList.declarations[0]
1216
+ const init = decl.initializer
1217
+ if (
1218
+ !init ||
1219
+ !ts.isAwaitExpression(init) ||
1220
+ !ts.isCallExpression(init.expression)
1221
+ ) {
1222
+ return
1223
+ }
1224
+ const call = init.expression
1225
+ if (!isWorkflowDoCall(call, childContext.checker)) {
1226
+ return
1227
+ }
1228
+ const varName = ts.isIdentifier(decl.name) ? decl.name.text : undefined
1229
+ const step = extractRpcStep(call, childContext, varName)
1230
+ if (!step) {
1231
+ return
1232
+ }
1233
+ if (varName) {
1234
+ const type = childContext.checker.getTypeAtLocation(decl)
1235
+ childContext.outputVars.set(varName, { type, node: decl })
1236
+ if (isArrayType(type, childContext.checker)) {
1237
+ childContext.arrayVars.add(varName)
1238
+ }
1239
+ }
1240
+ body.push(step)
1241
+ return
1242
+ }
1243
+
1244
+ if (ts.isExpressionStatement(stmt) || ts.isReturnStatement(stmt)) {
1245
+ const expr = ts.isReturnStatement(stmt) ? stmt.expression : stmt.expression
1246
+ if (!expr) {
1247
+ return
1248
+ }
1249
+ const call = ts.isAwaitExpression(expr)
1250
+ ? ts.isCallExpression(expr.expression)
1251
+ ? expr.expression
1252
+ : null
1253
+ : ts.isCallExpression(expr)
1254
+ ? expr
1255
+ : null
1256
+ if (!call) {
1257
+ return
1258
+ }
1259
+ if (isWorkflowSleepCall(call, childContext.checker)) {
1260
+ const sleepStep = extractSleepStep(call, childContext)
1261
+ if (sleepStep && sleepStep.type === 'sleep') {
1262
+ body.push(sleepStep)
1263
+ }
1264
+ return
1265
+ }
1266
+ if (isWorkflowSuspendCall(call, childContext.checker)) {
1267
+ const suspendStep = extractSuspendStep(call, childContext)
1268
+ if (suspendStep && suspendStep.type === 'suspend') {
1269
+ body.push(suspendStep)
1270
+ }
1271
+ return
1272
+ }
1273
+ if (!isWorkflowDoCall(call, childContext.checker)) {
1274
+ return
1275
+ }
1276
+ const step = extractRpcStep(call, childContext)
1277
+ if (step) {
1278
+ body.push(step)
1279
+ }
1280
+ }
1281
+ }
1282
+
1040
1283
  /**
1041
1284
  * Extract parallel fanout from Promise.all(array.map(...))
1042
1285
  */
@@ -1075,66 +1318,49 @@ function extractParallelFanout(
1075
1318
 
1076
1319
  const itemVar = itemParam.name.text
1077
1320
 
1078
- // Extract workflow.do call from map body
1079
- let doCall: ts.CallExpression | null = null
1080
-
1081
- if (ts.isCallExpression(mapFn.body)) {
1082
- doCall = mapFn.body
1083
- } else if (ts.isAwaitExpression(mapFn.body)) {
1084
- // Handle: async (email) => await workflow.do(...)
1085
- if (ts.isCallExpression(mapFn.body.expression)) {
1086
- doCall = mapFn.body.expression
1087
- }
1088
- } else if (ts.isBlock(mapFn.body)) {
1089
- // Look for workflow.do in block
1090
- for (const stmt of mapFn.body.statements) {
1091
- if (ts.isExpressionStatement(stmt)) {
1092
- // Handle: await workflow.do(...)
1093
- if (
1094
- ts.isAwaitExpression(stmt.expression) &&
1095
- ts.isCallExpression(stmt.expression.expression)
1096
- ) {
1097
- doCall = stmt.expression.expression
1098
- break
1099
- }
1100
- } else if (ts.isReturnStatement(stmt) && stmt.expression) {
1101
- if (ts.isCallExpression(stmt.expression)) {
1102
- doCall = stmt.expression
1103
- break
1104
- } else if (ts.isAwaitExpression(stmt.expression)) {
1105
- // Handle: return await workflow.do(...)
1106
- if (ts.isCallExpression(stmt.expression.expression)) {
1107
- doCall = stmt.expression.expression
1108
- break
1109
- }
1110
- }
1111
- }
1112
- }
1113
- }
1114
-
1115
- if (!doCall || !isWorkflowDoCall(doCall, context.checker)) {
1116
- return null
1117
- }
1118
-
1119
- // Create a temporary context for the child step with the loop variable
1321
+ // Create a temporary context for the child steps with the loop variable
1120
1322
  const childContext: ExtractionContext = {
1121
1323
  ...context,
1122
1324
  outputVars: new Map(context.outputVars),
1325
+ arrayVars: new Set(context.arrayVars),
1123
1326
  loopVars: new Set([...context.loopVars, itemVar]),
1124
1327
  }
1125
1328
 
1126
- const childStep = extractRpcStep(doCall, childContext)
1127
- if (!childStep) {
1329
+ const body: FanoutStepMeta['body'] = []
1330
+
1331
+ if (ts.isBlock(mapFn.body)) {
1332
+ for (const stmt of mapFn.body.statements) {
1333
+ extractFanoutBodyStep(stmt, childContext, body)
1334
+ }
1335
+ } else {
1336
+ // Concise body: (item) => workflow.do(...) / async (item) => await workflow.do(...)
1337
+ const expr = mapFn.body
1338
+ const doCall = ts.isAwaitExpression(expr)
1339
+ ? ts.isCallExpression(expr.expression)
1340
+ ? expr.expression
1341
+ : null
1342
+ : ts.isCallExpression(expr)
1343
+ ? expr
1344
+ : null
1345
+
1346
+ if (doCall && isWorkflowDoCall(doCall, context.checker)) {
1347
+ const step = extractRpcStep(doCall, childContext)
1348
+ if (step) {
1349
+ body.push(step)
1350
+ }
1351
+ }
1352
+ }
1353
+
1354
+ if (body.length === 0) {
1128
1355
  return null
1129
1356
  }
1130
1357
 
1131
1358
  return {
1132
1359
  type: 'fanout',
1133
- stepName: childStep.stepName,
1134
1360
  sourceVar,
1135
1361
  itemVar,
1136
1362
  mode: 'parallel',
1137
- child: childStep,
1363
+ body,
1138
1364
  }
1139
1365
  }
1140
1366
 
@@ -1189,65 +1415,34 @@ function extractSequentialFanout(
1189
1415
 
1190
1416
  const { itemVar, sourceVar } = vars
1191
1417
 
1192
- // Extract child step and optional sleep from loop body
1193
- if (!ts.isBlock(statement.statement)) {
1194
- return null
1195
- }
1418
+ // Extract child steps and optional sleep from the loop body. A brace-less
1419
+ // body (`for (const x of xs) await workflow.do(...)`) is a single statement
1420
+ // rather than a block, and must still be extracted.
1421
+ const bodyStatements = ts.isBlock(statement.statement)
1422
+ ? statement.statement.statements
1423
+ : [statement.statement]
1196
1424
 
1197
- let childStep: RpcStepMeta | null = null
1425
+ const body: FanoutStepMeta['body'] = []
1198
1426
  let timeBetween: string | undefined = undefined
1199
1427
 
1200
1428
  // Create a child context with the loop variable added
1201
1429
  const childContext: ExtractionContext = {
1202
1430
  ...context,
1203
1431
  outputVars: new Map(context.outputVars),
1432
+ arrayVars: new Set(context.arrayVars),
1204
1433
  loopVars: new Set([...context.loopVars, itemVar]),
1205
1434
  }
1206
1435
 
1207
- let workflowDoCount = 0
1208
-
1209
- for (const stmt of statement.statement.statements) {
1210
- // Look for workflow.do in VariableStatement (const x = await workflow.do(...))
1211
- if (ts.isVariableStatement(stmt)) {
1212
- const declList = stmt.declarationList
1213
- if (declList.declarations.length === 1) {
1214
- const decl = declList.declarations[0]
1215
- const init = decl.initializer
1216
- if (
1217
- init &&
1218
- ts.isAwaitExpression(init) &&
1219
- ts.isCallExpression(init.expression)
1220
- ) {
1221
- const call = init.expression
1222
- if (isWorkflowDoCall(call, context.checker)) {
1223
- workflowDoCount++
1224
- const varName = ts.isIdentifier(decl.name)
1225
- ? decl.name.text
1226
- : undefined
1227
- const step = extractRpcStep(call, childContext, varName)
1228
- if (step) {
1229
- childStep = step
1230
- }
1231
- }
1232
- }
1233
- }
1234
- }
1436
+ for (const stmt of bodyStatements) {
1437
+ extractFanoutBodyStep(stmt, childContext, body)
1235
1438
 
1236
- // Look for workflow.do in ExpressionStatement (await workflow.do(...))
1439
+ // Look for workflow.sleep in ExpressionStatement
1237
1440
  if (ts.isExpressionStatement(stmt)) {
1238
1441
  const expr = stmt.expression
1239
1442
 
1240
1443
  if (ts.isAwaitExpression(expr) && ts.isCallExpression(expr.expression)) {
1241
1444
  const call = expr.expression
1242
1445
 
1243
- if (isWorkflowDoCall(call, context.checker)) {
1244
- workflowDoCount++
1245
- const step = extractRpcStep(call, childContext)
1246
- if (step) {
1247
- childStep = step
1248
- }
1249
- }
1250
-
1251
1446
  if (isWorkflowSleepCall(call, context.checker)) {
1252
1447
  // Extract duration for timeBetween
1253
1448
  const args = call.arguments
@@ -1305,26 +1500,16 @@ function extractSequentialFanout(
1305
1500
  }
1306
1501
  }
1307
1502
 
1308
- if (!childStep) {
1309
- return null
1310
- }
1311
-
1312
- // If there are multiple workflow.do calls, the loop is too complex for DSL
1313
- if (workflowDoCount > 1) {
1314
- context.errors.push({
1315
- message: `For-of loop has ${workflowDoCount} workflow.do calls but DSL only supports 1. Use pikkuWorkflowComplexFunc for complex loops.`,
1316
- node: statement,
1317
- })
1503
+ if (body.length === 0) {
1318
1504
  return null
1319
1505
  }
1320
1506
 
1321
1507
  return {
1322
1508
  type: 'fanout',
1323
- stepName: childStep.stepName,
1324
1509
  sourceVar,
1325
1510
  itemVar,
1326
1511
  mode: 'sequential',
1327
- child: childStep,
1512
+ body,
1328
1513
  timeBetween,
1329
1514
  }
1330
1515
  }
@@ -1427,11 +1612,21 @@ function extractReturn(
1427
1612
  }
1428
1613
  }
1429
1614
 
1615
+ // `return r` returns every field of r — record it as a spread of one.
1616
+ if (ts.isIdentifier(statement.expression)) {
1617
+ const varName = statement.expression.text
1618
+ if (context.outputVars.has(varName) || context.contextVars.has(varName)) {
1619
+ return { type: 'return', outputs: {}, spread: [varName] }
1620
+ }
1621
+ return null
1622
+ }
1623
+
1430
1624
  if (!ts.isObjectLiteralExpression(statement.expression)) {
1431
1625
  return null
1432
1626
  }
1433
1627
 
1434
1628
  const outputs: Record<string, OutputBinding> = {}
1629
+ const spread: string[] = []
1435
1630
 
1436
1631
  for (const prop of statement.expression.properties) {
1437
1632
  if (
@@ -1462,16 +1657,22 @@ function extractReturn(
1462
1657
  if (binding) {
1463
1658
  outputs[propName] = binding
1464
1659
  }
1660
+ } else if (
1661
+ ts.isSpreadAssignment(prop) &&
1662
+ ts.isIdentifier(prop.expression)
1663
+ ) {
1664
+ spread.push(prop.expression.text)
1465
1665
  }
1466
1666
  }
1467
1667
 
1468
- if (Object.keys(outputs).length === 0) {
1668
+ if (Object.keys(outputs).length === 0 && spread.length === 0) {
1469
1669
  return null
1470
1670
  }
1471
1671
 
1472
1672
  return {
1473
1673
  type: 'return',
1474
1674
  outputs,
1675
+ ...(spread.length > 0 ? { spread } : {}),
1475
1676
  }
1476
1677
  }
1477
1678
 
@@ -1586,6 +1787,11 @@ function extractInputSource(
1586
1787
  return { from: 'input', path: propName }
1587
1788
  }
1588
1789
 
1790
+ // A field of the fanout item: users.map((u) => do(..., { id: u.id }))
1791
+ if (context.loopVars.has(objName)) {
1792
+ return { from: 'item', path: propName }
1793
+ }
1794
+
1589
1795
  if (context.outputVars.has(objName)) {
1590
1796
  return { from: 'outputVar', name: objName, path: propName }
1591
1797
  }
@@ -115,6 +115,24 @@ export function isWorkflowSuspendCall(
115
115
  )
116
116
  }
117
117
 
118
+ /**
119
+ * Check if a call expression is workflow.approval()
120
+ */
121
+ export function isWorkflowApprovalCall(
122
+ node: ts.CallExpression,
123
+ _checker: ts.TypeChecker
124
+ ): boolean {
125
+ if (!ts.isPropertyAccessExpression(node.expression)) {
126
+ return false
127
+ }
128
+
129
+ const propAccess = node.expression
130
+ return (
131
+ propAccess.name.text === 'approval' &&
132
+ isWorkflowWireIdentifier(propAccess.expression)
133
+ )
134
+ }
135
+
118
136
  /**
119
137
  * Check if a throw statement throws WorkflowCancelledException
120
138
  * Matches: throw new WorkflowCancelledException(...) or throw WorkflowCancelledException(...)